Архитектура эмулятора
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Архитектура эмулятора
Открываем блок из шести уроков. К концу у тебя будет работающий эмулятор учебного процессора, периферия к нему, слой драйверов и инструменты отладки. Всё на Rust, всё своё. Начнём не с кода, а с двух решений, которые определят весь блок: что именно мы воспроизводим и насколько точно. А чтобы абстракция не висела в воздухе, в середине урока ты погоняешь готовый процессор прямо в браузере: это тот же код, что мы спроектируем, собранный в WebAssembly.
Что такое эмуляция
Эмулятор это программа, которая притворяется машиной. Ты даёшь ей программу, написанную для какого-то процессора, а она ведёт себя так, будто этот процессор настоящий: те же регистры, та же память, те же результаты. Браузерный эмулятор Денди запускает картриджи, которым тридцать лет. Эмулятор в твоём телефоне крутит игры с приставки, которой нет в природе.
Важно сразу отделить три похожих слова.
- Эмуляция воспроизводит чужую систему команд на нашей. Гость и хозяин говорят на разных машинных языках, и мы переводим один в другой.
- Симуляция моделирует систему ради наблюдения, а не ради точного исполнения. Симулятор схемы считает напряжения, ему не обязательно гонять реальные программы.
- Виртуализация даёт гостю исполняться на своём же железе почти напрямую, лишь подставляя изоляцию. Гость и хозяин тут одной архитектуры, переводить нечего.
Мы строим именно эмулятор: наш процессор, назовём его bcpu, не существует в кремнии. Его система команд живёт только в виде правил, которые мы сейчас придумаем, и кода, который эти правила исполняет.
Почему свой процессор, а не настоящий 6502 или x86? Потому что чужую систему команд надо сначала вычитать из сотен страниц документации, и в ней полно исторических углов: режимы совместимости, недокументированные опкоды, особые случаи. Своя ISA помещается на одну страницу, её легко объяснить, легко протестировать и легко скомпилировать в WebAssembly. Настоящий 6502 ждёт тебя в следующем проекте этого раздела, когда механика уже будет в руках.
Развилка: уровни точности
Первое большое решение эмулятора: насколько точно повторять оригинал. Это не выбор между правильно и неправильно, а шкала, и на ней есть цена.
- Точность на уровне результата. Программа досчитала до тех же чисел, что и на железе, а как именно она туда шла, нас не волнует. Самый дешёвый и быстрый вариант.
- Инструкционная точность. Воспроизводим каждую инструкцию по отдельности и состояние после неё: регистры, флаги, память. Но время считаем грубо, одна инструкция это один шаг.
- Цикл-точность (такт-точность). Считаем не инструкции, а такты процессора, и воспроизводим, сколько тактов стоит каждая операция. Это нужно, когда железо завязано на тайминг: видеочип приставки рисует строку ровно за столько-то тактов процессора, и ошибка в один такт ломает картинку.
Цикл-точность звучит как очевидно лучший вариант, но за неё платят скоростью и сложностью кода. Чем точнее модель времени, тем больше работы на каждую инструкцию и тем труднее её писать без ошибок.
Вот эта развилка одной строкой, чтобы запомнить цену:
| Уровень | Что воспроизводит | Цена | Где нужен |
|---|---|---|---|
| результат | финальные числа | минимальная | трансляция, разовый прогон |
| инструкционный | состояние после каждой инструкции | средняя | отладчики, обучение, наш bcpu |
| цикл-точный | плюс такты и порядок обращений | высокая | консоли, ретро с жёстким таймингом |
Для bcpu мы выбираем инструкционную точность по умолчанию: она ровно про то, как процессор меняет состояние, и читается без боли. Цикл-бюджет (сколько тактов стоит каждая инструкция) мы заложим в модель сразу, но включим его всерьёз только в последнем уроке блока, когда будем гоняться за цикл-точностью осознанно. Это и есть инженерное чтение шкалы: берём минимально достаточную точность, а не максимальную.
Интерпретация против рекомпиляции
Второе большое решение: как именно исполнять чужие инструкции.
Интерпретатор работает в лоб: взял инструкцию, разобрал, выполнил, взял следующую. Один цикл на всю программу. Каждую инструкцию он разбирает заново, даже если она в теле цикла прокручивается миллион раз. Зато код прозрачный: его видно насквозь, его легко отлаживать и тестировать.
Динамическая рекомпиляция, она же JIT, переводит горячие куски гостевого кода в нативный код хозяина один раз, а дальше запускает уже перевод, без повторного разбора. Это путь быстрых эмуляторов, но он сложный: нужен генератор кода, учёт инвалидации, отладка становится кошмаром. Цена за скорость это сложность, которую мы здесь не возьмём.
bcpu это интерпретатор. Для учебного процессора и для виджета в браузере его скорости с запасом хватает, а ясность важнее. Когда дочитаешь блок и захочешь скорости, ты будешь точно знать, какую сложность за неё придётся заплатить.
Цикл fetch-decode-execute
Любой интерпретатор процессора это один цикл из трёх шагов, повторяющийся до остановки.
fetch-decode-execute это сердце процессора:
- Fetch (выборка). Прочитать слово инструкции из памяти по адресу, который лежит в программном счётчике
PC. - Decode (декодирование). Разобрать биты слова: что это за инструкция, какие у неё операнды.
- 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 | мнемоника | формат | что делает | флаги |
|---|---|---|---|---|
| 0x0 | ALU rd, rs1, rs2 | R | rd = rs1 ⟨funct⟩ rs2 | да |
| 0x1 | ADDI rd, rs1, imm6 | I | rd = rs1 + sext(imm6) | да |
| 0x2 | LUI rd, imm8 | U | rd = imm8 << 8 | нет |
| 0x3 | LLI rd, imm8 | U | младший байт rd заменяем на imm8 | нет |
| 0x4 | LW rd, imm6(rs1) | I | rd = mem16[rs1 + sext(imm6)] | нет |
| 0x5 | SW rd, imm6(rs1) | I | mem16[rs1 + sext(imm6)] = rd | нет |
| 0x6 | LB rd, imm6(rs1) | I | rd = байт из памяти, расширенный нулями | нет |
| 0x7 | SB rd, imm6(rs1) | I | mem8[rs1 + sext(imm6)] = младший байт rd | нет |
| 0x8 | BEQ rd, rs1, imm6 | I | если rd == rs1, прыжок на imm6 инструкций | нет |
| 0x9 | BNE rd, rs1, imm6 | I | если rd != rs1, прыжок | нет |
| 0xA | BLT rd, rs1, imm6 | I | если rd < rs1 со знаком, прыжок | нет |
| 0xB | BGE rd, rs1, imm6 | I | если rd >= rs1 со знаком, прыжок | нет |
| 0xC | JAL rd, imm9 | J | rd = адрес возврата; PC += imm9 | нет |
| 0xD | JALR rd, imm6(rs1) | I | прыжок по адресу из регистра, возврат в rd | нет |
| 0xE | ORI rd, rs1, imm6 | I | rd = rs1 побитовое ИЛИ с imm6 | нет |
| 0xF | SYS imm9 | J | системный вызов, imm9 это код (0 это HALT) | нет |
Здесь sext это знаковое расширение: короткое число со знаком дотягиваем до 16 бит, сохраняя знак. Смещения ветвлений и JAL считаются от адреса следующей инструкции (на момент исполнения PC уже сдвинут на +2) и меряются в инструкциях, поэтому в листинге виджета прыжки попадают ровно на строку.
Поле funct для опкода ALU выбирает одну из восьми операций арифметико-логического устройства. Это связь с тем, что ты уже разбирал про биты и дополнительный код и машинный код:
| funct | операция | смысл |
|---|---|---|
| 0 | ADD | сложение |
| 1 | SUB | вычитание |
| 2 | AND | побитовое И |
| 3 | OR | побитовое ИЛИ |
| 4 | XOR | побитовое исключающее ИЛИ |
| 5 | SHL | сдвиг влево |
| 6 | SHR | логический сдвиг вправо |
| 7 | SAR | арифметический сдвиг вправо |
Режимы адресации
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, ты гонял прямо в уроке. В следующем уроке мы дадим процессору состояние и ту самую шину, за которой прячется память.