Раздел 23 · Rust

Архитектура эмулятора

senior~55 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Архитектура эмулятора

Открываем блок из шести уроков. К концу у тебя будет работающий эмулятор учебного процессора, периферия к нему, слой драйверов и инструменты отладки. Всё на Rust, всё своё. Начнём не с кода, а с двух решений, которые определят весь блок: что именно мы воспроизводим и насколько точно. А чтобы абстракция не висела в воздухе, в середине урока ты погоняешь готовый процессор прямо в браузере: это тот же код, что мы спроектируем, собранный в WebAssembly.

Что такое эмуляция

Эмулятор это программа, которая притворяется машиной. Ты даёшь ей программу, написанную для какого-то процессора, а она ведёт себя так, будто этот процессор настоящий: те же регистры, та же память, те же результаты. Браузерный эмулятор Денди запускает картриджи, которым тридцать лет. Эмулятор в твоём телефоне крутит игры с приставки, которой нет в природе.

Важно сразу отделить три похожих слова.

  • Эмуляция воспроизводит чужую систему команд на нашей. Гость и хозяин говорят на разных машинных языках, и мы переводим один в другой.
  • Симуляция моделирует систему ради наблюдения, а не ради точного исполнения. Симулятор схемы считает напряжения, ему не обязательно гонять реальные программы.
  • Виртуализация даёт гостю исполняться на своём же железе почти напрямую, лишь подставляя изоляцию. Гость и хозяин тут одной архитектуры, переводить нечего.

Мы строим именно эмулятор: наш процессор, назовём его bcpu, не существует в кремнии. Его система команд живёт только в виде правил, которые мы сейчас придумаем, и кода, который эти правила исполняет.

Почему свой процессор, а не настоящий 6502 или x86? Потому что чужую систему команд надо сначала вычитать из сотен страниц документации, и в ней полно исторических углов: режимы совместимости, недокументированные опкоды, особые случаи. Своя ISA помещается на одну страницу, её легко объяснить, легко протестировать и легко скомпилировать в WebAssembly. Настоящий 6502 ждёт тебя в следующем проекте этого раздела, когда механика уже будет в руках.

Развилка: уровни точности

Первое большое решение эмулятора: насколько точно повторять оригинал. Это не выбор между правильно и неправильно, а шкала, и на ней есть цена.

  • Точность на уровне результата. Программа досчитала до тех же чисел, что и на железе, а как именно она туда шла, нас не волнует. Самый дешёвый и быстрый вариант.
  • Инструкционная точность. Воспроизводим каждую инструкцию по отдельности и состояние после неё: регистры, флаги, память. Но время считаем грубо, одна инструкция это один шаг.
  • Цикл-точность (такт-точность). Считаем не инструкции, а такты процессора, и воспроизводим, сколько тактов стоит каждая операция. Это нужно, когда железо завязано на тайминг: видеочип приставки рисует строку ровно за столько-то тактов процессора, и ошибка в один такт ломает картинку.

Цикл-точность звучит как очевидно лучший вариант, но за неё платят скоростью и сложностью кода. Чем точнее модель времени, тем больше работы на каждую инструкцию и тем труднее её писать без ошибок.

Вот эта развилка одной строкой, чтобы запомнить цену:

УровеньЧто воспроизводитЦенаГде нужен
результатфинальные числаминимальнаятрансляция, разовый прогон
инструкционныйсостояние после каждой инструкциисредняяотладчики, обучение, наш bcpu
цикл-точныйплюс такты и порядок обращенийвысокаяконсоли, ретро с жёстким таймингом

Для bcpu мы выбираем инструкционную точность по умолчанию: она ровно про то, как процессор меняет состояние, и читается без боли. Цикл-бюджет (сколько тактов стоит каждая инструкция) мы заложим в модель сразу, но включим его всерьёз только в последнем уроке блока, когда будем гоняться за цикл-точностью осознанно. Это и есть инженерное чтение шкалы: берём минимально достаточную точность, а не максимальную.

Интерпретация против рекомпиляции

Второе большое решение: как именно исполнять чужие инструкции.

Интерпретатор работает в лоб: взял инструкцию, разобрал, выполнил, взял следующую. Один цикл на всю программу. Каждую инструкцию он разбирает заново, даже если она в теле цикла прокручивается миллион раз. Зато код прозрачный: его видно насквозь, его легко отлаживать и тестировать.

Динамическая рекомпиляция, она же JIT, переводит горячие куски гостевого кода в нативный код хозяина один раз, а дальше запускает уже перевод, без повторного разбора. Это путь быстрых эмуляторов, но он сложный: нужен генератор кода, учёт инвалидации, отладка становится кошмаром. Цена за скорость это сложность, которую мы здесь не возьмём.

bcpu это интерпретатор. Для учебного процессора и для виджета в браузере его скорости с запасом хватает, а ясность важнее. Когда дочитаешь блок и захочешь скорости, ты будешь точно знать, какую сложность за неё придётся заплатить.

Цикл fetch-decode-execute

Любой интерпретатор процессора это один цикл из трёх шагов, повторяющийся до остановки.

fetch-decode-execute это сердце процессора:

  1. Fetch (выборка). Прочитать слово инструкции из памяти по адресу, который лежит в программном счётчике PC.
  2. Decode (декодирование). Разобрать биты слова: что это за инструкция, какие у неё операнды.
  3. Execute (исполнение). Сделать то, что инструкция велит: сложить регистры, записать в память, прыгнуть в другое место.

После исполнения PC сдвигается на следующую инструкцию, и всё повторяется. Переход (jump) это просто запись нового адреса в PC вместо обычного сдвига. Вот и весь процессор: цикл, который двигает PC и меняет состояние.

В коде на Rust скелет цикла выглядит почти как этот список. Мы сейчас не будем разбирать каждую строку (декодирование это урок 38, состояние и шина это урок 37), но посмотри на форму: она буквально повторяет три шага.

/// Один шаг процессора: выбрать, декодировать, исполнить одну инструкцию.
pub fn step(&mut self, bus: &mut impl Bus) -> Step {
    // fetch: читаем слово по адресу из PC
    let word = bus.read16(self.pc);

    // decode: биты слова превращаем в структурированную инструкцию
    let instr = decode(word);

    // PC сдвигается на следующую инструкцию (инструкции бывают по 2 байта)
    self.pc = self.pc.wrapping_add(2);

    // execute: выполняем и сообщаем, чем шаг закончился
    self.execute(bus, instr)
}

Заметь bus: процессор читает память не напрямую, а через посредника. Почему так, разберём в конце урока и подробно в следующем. Пока запомни форму цикла, а теперь самое время увидеть его живьём.

Попробуй сам

Ниже настоящий bcpu, собранный в WebAssembly. JS тут не исполняет ни одной инструкции: он только грузит программу в память и просит сделать шаг, а весь цикл fetch-decode-execute крутится внутри скомпилированного Rust-кода. Выбери программу, жми шаг и смотри, как PC ползёт по листингу, как меняются регистры и флаги. Панель сверху показывает, какое слово сейчас выбрано по PC и во что оно декодируется.

Обрати внимание на три вещи. Первая: PC всегда чётный и шагает на 2, потому что каждая инструкция занимает ровно два байта. Вторая: регистр r0 не меняется никогда, что бы в него ни писали (скоро поймёшь, почему так удобно). Третья: переход назад в цикле это просто прыжок PC вверх по листингу, никакой магии. Это и есть весь процессор, который мы сейчас спроектируем на бумаге.

Проектируем ISA bcpu

Теперь придумаем систему команд. ISA это контракт: что видит программа и что обязан сделать процессор. Хороший учебный контракт должен быть маленьким, регулярным и удобным для декодирования. Возьмём за образец дух RISC: мало простых инструкций фиксированной длины, доступ к памяти только через отдельные load и store, остальное между регистрами.

Модель

  • Слово 16 бит. Память 64 KiB, адресуется побайтово, порядок байт little-endian.
  • Инструкции фиксированной ширины: ровно 16 бит, выровнены по 2 байта. Поэтому PC всегда чётный и по умолчанию шагает на +2.
  • Восемь регистров r0..r7, по 16 бит. Регистр r0 жёстко равен нулю: чтение всегда даёт 0, запись молча отбрасывается.
  • Отдельный программный счётчик PC и четыре флага Z, N, C, V.

Трюк с нулевым регистром стоит объяснить отдельно, это классическая RISC-идиома (так же устроен x0 в RISC-V). Имея всегда доступный ноль, ты бесплатно получаешь кучу операций без отдельных инструкций: обнуление это сложить с r0, копирование регистра это прибавить r0, сравнение это вычесть и выбросить результат в r0. Одно дизайн-решение убирает пяток лишних команд. В виджете выше ты как раз видел, что MOV и обнуление это ADD с участием r0.

Отдельного регистра под стек у нас нет, и это тоже осознанный выбор. Указатель стека это просто соглашение поверх обычного регистра: договоримся, что r6 это sp, а r1 это адрес возврата. Ровно так же sp в RISC-V это просто x2. Меньше специальных регистров, меньше особых случаев в декодере.

Форматы инструкций

Шестнадцать бит надо разложить на поля. Старшие 4 бита всегда отдаём под опкод, это даёт 16 видов инструкций. Поле регистра занимает 3 бита (как раз r0..r7). Дальше биты раскладываются четырьмя способами, под разные нужды:

R  регистр-регистр   op[15:12] rd[11:9] rs1[8:6] rs2[5:3] funct[2:0]
I  регистр + imm6     op[15:12] rd[11:9] rs1[8:6] imm6[5:0]
U  старший/младший    op[15:12] rd[11:9]  0[8]    imm8[7:0]
J  rd + imm9          op[15:12] rd[11:9] imm9[8:0]

Формат R это операции между тремя регистрами (поле funct уточняет, какая именно из восьми операций ALU). Формат I это операция с регистром и небольшим встроенным числом (imm это непосредственное значение). Формат U грузит байт в старшую или младшую половину регистра (так из двух инструкций собирается любое 16-битное число). Формат J это переходы с большим смещением.

Размеры чисел продиктованы тем, что осталось от 16 бит после опкода и регистров: imm6 знаковое (от -32 до 31), imm9 знаковое (от -256 до 255), imm8 беззнаковое (от 0 до 255).

Таблица опкодов

Вот вся система команд bcpu на одной таблице. Шестнадцать опкодов, ни одной дырки.

opмнемоникаформатчто делаетфлаги
0x0ALU rd, rs1, rs2Rrd = rs1 ⟨funct⟩ rs2да
0x1ADDI rd, rs1, imm6Ird = rs1 + sext(imm6)да
0x2LUI rd, imm8Urd = imm8 << 8нет
0x3LLI rd, imm8Uмладший байт rd заменяем на imm8нет
0x4LW rd, imm6(rs1)Ird = mem16[rs1 + sext(imm6)]нет
0x5SW rd, imm6(rs1)Imem16[rs1 + sext(imm6)] = rdнет
0x6LB rd, imm6(rs1)Ird = байт из памяти, расширенный нуляминет
0x7SB rd, imm6(rs1)Imem8[rs1 + sext(imm6)] = младший байт rdнет
0x8BEQ rd, rs1, imm6Iесли rd == rs1, прыжок на imm6 инструкцийнет
0x9BNE rd, rs1, imm6Iесли rd != rs1, прыжокнет
0xABLT rd, rs1, imm6Iесли rd < rs1 со знаком, прыжокнет
0xBBGE rd, rs1, imm6Iесли rd >= rs1 со знаком, прыжокнет
0xCJAL rd, imm9Jrd = адрес возврата; PC += imm9нет
0xDJALR rd, imm6(rs1)Iпрыжок по адресу из регистра, возврат в rdнет
0xEORI rd, rs1, imm6Ird = rs1 побитовое ИЛИ с imm6нет
0xFSYS imm9Jсистемный вызов, imm9 это код (0 это HALT)нет

Здесь sext это знаковое расширение: короткое число со знаком дотягиваем до 16 бит, сохраняя знак. Смещения ветвлений и JAL считаются от адреса следующей инструкции (на момент исполнения PC уже сдвинут на +2) и меряются в инструкциях, поэтому в листинге виджета прыжки попадают ровно на строку.

Поле funct для опкода ALU выбирает одну из восьми операций арифметико-логического устройства. Это связь с тем, что ты уже разбирал про биты и дополнительный код и машинный код:

functоперациясмысл
0ADDсложение
1SUBвычитание
2ANDпобитовое И
3ORпобитовое ИЛИ
4XORпобитовое исключающее ИЛИ
5SHLсдвиг влево
6SHRлогический сдвиг вправо
7SARарифметический сдвиг вправо

Режимы адресации

ISA задаёт ещё и то, откуда брать операнды. У bcpu четыре режима адресации:

  • Регистровый: операнды лежат в регистрах (формат R).
  • Непосредственный: число встроено в инструкцию (ADDI, ORI, LUI, LLI).
  • База со смещением: адрес это регистр плюс маленькое смещение (LW, SW, LB, SB, JALR).
  • Относительно PC: адрес считается от текущего места (ветвления и JAL).

Четырёх хватает на всё, а декодер от этого остаётся крошечным. Богатые ISA вроде x86 имеют десятки режимов, и платят за это размером и сложностью декодера. Снова та же мысль: каждая возможность в контракте это работа в реализации.

ISA в коде

Теперь переложим бумажный контракт в типы Rust. Это ещё не исполнение, а описание: какие бывают регистры, операции и инструкции, и как инструкция кодируется в 16 бит. Декодирование (обратную операцию) мы напишем в уроке 38, но кодирование удобно иметь сразу: на нём держатся тесты и сборка программ.

Начнём с регистра и операций ALU. Регистр это просто индекс, а константы ZERO, RA, SP фиксируют наши соглашения.

/// Индекс регистра r0..r7. r0 жёстко равен нулю.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct Reg(pub u8);

impl Reg {
    /// Берём только младшие 3 бита, поэтому индекс всегда валиден.
    pub const fn new(n: u8) -> Self {
        Reg(n & 0x7)
    }

    pub const ZERO: Reg = Reg(0);
    /// Соглашение: r1 это адрес возврата.
    pub const RA: Reg = Reg(1);
    /// Соглашение: r6 это указатель стека.
    pub const SP: Reg = Reg(6);

    pub const fn index(self) -> usize {
        self.0 as usize
    }
}

/// Операция ALU, поле funct инструкции ALU (опкод 0x0).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[repr(u8)]
pub enum AluOp {
    Add = 0,
    Sub = 1,
    And = 2,
    Or = 3,
    Xor = 4,
    Shl = 5,
    Shr = 6,
    Sar = 7,
}

Опкоды вынесем в отдельный модуль констант, чтобы и кодировщик, и будущий декодер ссылались на одни и те же имена, а не на магические числа.

/// Старшие 4 бита инструкции. Имена совпадают с таблицей опкодов.
pub mod opcode {
    pub const ALU: u8 = 0x0;
    pub const ADDI: u8 = 0x1;
    pub const LUI: u8 = 0x2;
    pub const LLI: u8 = 0x3;
    pub const LW: u8 = 0x4;
    pub const SW: u8 = 0x5;
    pub const LB: u8 = 0x6;
    pub const SB: u8 = 0x7;
    pub const BEQ: u8 = 0x8;
    pub const BNE: u8 = 0x9;
    pub const BLT: u8 = 0xA;
    pub const BGE: u8 = 0xB;
    pub const JAL: u8 = 0xC;
    pub const JALR: u8 = 0xD;
    pub const ORI: u8 = 0xE;
    pub const SYS: u8 = 0xF;
}

Теперь главный тип: декодированная инструкция. Это сумма всех шестнадцати вариантов. Тут важная дизайн-мысль: поля уже разобраны и расширены по знаку. Исполнитель и дизассемблер будут работать с готовыми значениями (imm: i16), а не ковыряться в битах. Биты живут только на границе декодирования.

/// Декодированная инструкция: биты уже разобраны в поля.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Instr {
    Alu { op: AluOp, rd: Reg, rs1: Reg, rs2: Reg },
    Addi { rd: Reg, rs1: Reg, imm: i16 },
    Lui { rd: Reg, imm: u8 },
    Lli { rd: Reg, imm: u8 },
    Lw { rd: Reg, base: Reg, off: i16 },
    Sw { src: Reg, base: Reg, off: i16 },
    Lb { rd: Reg, base: Reg, off: i16 },
    Sb { src: Reg, base: Reg, off: i16 },
    Beq { a: Reg, b: Reg, off: i16 },
    Bne { a: Reg, b: Reg, off: i16 },
    Blt { a: Reg, b: Reg, off: i16 },
    Bge { a: Reg, b: Reg, off: i16 },
    Jal { rd: Reg, off: i16 },
    Jalr { rd: Reg, base: Reg, off: i16 },
    Ori { rd: Reg, rs1: Reg, imm: u16 },
    Sys { code: u16 },
}

Знаковое расширение это маленькая, но важная функция: она дотягивает короткое знаковое число до i16, повторяя знаковый бит. Делается это парой сдвигов, и тут пригодилось то, что в Rust сдвиг вправо у знакового типа арифметический.

/// Расширяет bits-битное знаковое значение value до i16.
pub const fn sign_extend(value: u16, bits: u32) -> i16 {
    let shift = 16 - bits;
    // влево, чтобы знаковый бит встал на место 15, потом вправо со знаком
    ((value << shift) as i16) >> shift
}

И обратная сторона контракта: кодирование инструкции в 16-битное слово. Каждый формат собирается из своих полей сдвигами и масками. Это ровно те числа, что ты видел в листинге виджета: 0xf000 это SYS HALT, 0x0200 это ADD r1, r0, r0.

impl Instr {
    /// Кодирует инструкцию обратно в 16-битное слово.
    pub fn encode(self) -> u16 {
        match self {
            Instr::Alu { op, rd, rs1, rs2 } => {
                pack_r(opcode::ALU, rd, rs1, rs2, op as u8)
            }
            Instr::Addi { rd, rs1, imm } => pack_i(opcode::ADDI, rd, rs1, imm),
            Instr::Lui { rd, imm } => pack_u(opcode::LUI, rd, imm),
            Instr::Lli { rd, imm } => pack_u(opcode::LLI, rd, imm),
            Instr::Sys { code } => pack_j(opcode::SYS, Reg::ZERO, code as i16),
            // ... остальные варианты идут по своим форматам так же
            _ => unreachable!("каждый вариант кодируется своим форматом"),
        }
    }
}

/// Формат R: три регистра и поле funct.
const fn pack_r(op: u8, rd: Reg, rs1: Reg, rs2: Reg, funct: u8) -> u16 {
    ((op as u16) << 12)
        | ((rd.0 as u16 & 0x7) << 9)
        | ((rs1.0 as u16 & 0x7) << 6)
        | ((rs2.0 as u16 & 0x7) << 3)
        | (funct as u16 & 0x7)
}

/// Формат I: rd, rs1 и знаковое imm6.
const fn pack_i(op: u8, rd: Reg, rs1: Reg, imm: i16) -> u16 {
    ((op as u16) << 12)
        | ((rd.0 as u16 & 0x7) << 9)
        | ((rs1.0 as u16 & 0x7) << 6)
        | (imm as u16 & 0x3F)
}

/// Формат U: rd и беззнаковый байт imm8.
const fn pack_u(op: u8, rd: Reg, imm: u8) -> u16 {
    ((op as u16) << 12) | ((rd.0 as u16 & 0x7) << 9) | (imm as u16)
}

/// Формат J: rd и знаковое imm9.
const fn pack_j(op: u8, rd: Reg, imm: i16) -> u16 {
    ((op as u16) << 12) | ((rd.0 as u16 & 0x7) << 9) | (imm as u16 & 0x1FF)
}

Декодер будет точной инверсией encode: тест на круговой проход (закодировать, декодировать, сравнить) держит обе стороны честными. Это типичный приём, когда у тебя есть прямое и обратное преобразование.

Развилка дизайна и её цена

В этом блоке у каждого урока есть одна главная мысль про устройство эмулятора. Мысль урока 36 такая: эмулятор это про разделение механизма и интерфейса.

Вернись к скелету step. Процессор читал память не сам, а через bus:

let word = bus.read16(self.pc);

Это не случайность, а главное архитектурное решение блока. Ядро процессора не знает и не должно знать, что лежит по адресу: оперативная память, регистр таймера, порт ввода-вывода. Оно умеет ровно одно: попросить шину прочитать или записать байты по адресу. Шина (мы сделаем её трейтом Bus в следующем уроке) это интерфейс, а что за ним стоит, дело сменное. Сегодня плоская память, в уроке 39 туда встанет периферия, а в браузере, как ты только что видел, тот же механизм работает поверх WebAssembly.

Цена у этого решения есть, и про неё надо говорить честно. Косвенность через трейт стоит одного лишнего вызова на каждое обращение к памяти и заставляет писать обобщённый код вместо прямого. Взамен ты получаешь процессор, который тестируется с памятью-заглушкой, переносится на периферию без единой правки в ядре и компилируется в браузер без переписывания. Для эмулятора это выгодный обмен: гибкость интерфейса важнее пары наносекунд, которые в интерпретаторе всё равно тонут в разборе инструкций.

Запомни эту развилку, она вернётся в каждом уроке блока в новой форме: ядро против шины (урок 37), таблица против match в декодере (урок 38), когда тикать периферию (урок 39), механизм против политики в HAL (урок 40), точность против скорости в инструментах (урок 41). Каждый раз мы будем называть цену вслух.

Что мы спроектировали

Ты прошёл два главных решения эмулятора (уровень точности и способ исполнения) и спроектировал целую ISA: модель машины, четыре формата инструкций, шестнадцать опкодов, режимы адресации. Бумажный контракт уже переложен в типы Rust, и тот же код, собранный в WebAssembly, ты гонял прямо в уроке. В следующем уроке мы дадим процессору состояние и ту самую шину, за которой прячется память.

Домашка