Состояние процессора и шина
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Состояние процессора и шина
В прошлом уроке мы спроектировали систему команд и видели цикл fetch-decode-execute. Теперь дадим процессору две вещи, без которых он не оживёт: память состояния (регистры, флаги, счётчик) и одну дверь в мир, через которую он трогает всё остальное. Эта дверь, трейт шины, и есть главное архитектурное решение блока в его самой чистой форме.
Состояние это просто структура
Что такое процессор в момент времени? Это набор чисел: значения регистров, адрес следующей инструкции, флаги, пара служебных битов. Всё это вместе и есть состояние. Соберём его в одну структуру.
/// Состояние процессора: восемь регистров, счётчик, флаги и пара служебных битов.
pub struct Cpu {
regs: [u16; 8],
pc: u16,
flags: Flags,
interrupts_enabled: bool,
halted: bool,
cycles: u64,
}
impl Cpu {
/// Процессор после сброса: всё в нуле, прерывания запрещены.
pub fn new() -> Self {
Cpu {
regs: [0; 8],
pc: 0,
flags: Flags::new(),
interrupts_enabled: false,
halted: false,
cycles: 0,
}
}
/// Запустить процессор с адреса entry (точка входа программы).
pub fn reset(&mut self, entry: u16) {
self.regs = [0; 8];
self.pc = entry;
self.flags = Flags::new();
self.interrupts_enabled = false;
self.halted = false;
self.cycles = 0;
}
}
Регистры это просто массив из восьми u16. cycles копит такты для будущего цикл-бюджета (урок 41), interrupts_enabled и halted оживут в уроке 39. Главное здесь: всё состояние процессора лежит в одном месте, и его можно целиком сбросить одной функцией.
Нулевой регистр в коде
В прошлом уроке мы договорились, что r0 всегда равен нулю. В коде это превращается в две короткие функции доступа. Чтение r0 возвращает 0, не заглядывая в массив; запись в r0 молча выбрасывается.
impl Cpu {
/// Читает регистр. r0 всегда возвращает 0.
pub fn reg(&self, r: Reg) -> u16 {
if r.0 == 0 {
0
} else {
self.regs[r.index()]
}
}
/// Пишет регистр. Запись в r0 молча отбрасывается.
pub fn set_reg(&mut self, r: Reg, value: u16) {
if r.0 != 0 {
self.regs[r.index()] = value;
}
}
}
Две проверки r.0 == 0 это вся цена за нулевой регистр. Взамен исполнитель пишет результат любой инструкции через set_reg, не думая, был ли это r0: запись просто исчезнет. Поэтому CMP a, b это SUB r0, a, b: вычитание считает флаги, а результат уезжает в r0 и пропадает. В виджете ниже выбери программу со стеком и заметь: r0 остаётся нулём, что бы ни происходило вокруг.
Окна в состояние
Регистры мы трогаем через reg/set_reg, но дизассемблеру, отладчику и тестам из урока 41 нужно ещё уметь посмотреть на остальные поля: счётчик, флаги, такты, признак остановки. Заведём набор маленьких функций-окон, по одной на поле. Они только читают, поэтому берут &self, и не дают трогать внутренности напрямую.
impl Cpu {
pub fn pc(&self) -> u16 {
self.pc
}
pub fn set_pc(&mut self, pc: u16) {
self.pc = pc;
}
pub fn flags(&self) -> Flags {
self.flags
}
pub fn cycles(&self) -> u64 {
self.cycles
}
pub fn is_halted(&self) -> bool {
self.halted
}
pub fn interrupts_enabled(&self) -> bool {
self.interrupts_enabled
}
/// Снимок всех восьми регистров сразу (для дампа состояния).
pub fn registers(&self) -> [u16; 8] {
self.regs
}
}
impl Default for Cpu {
fn default() -> Self {
Self::new()
}
}
Похоже на лишний бойлерплейт, но именно через эти окна потом смотрят дизассемблер и golden-трейсы, не получая права что-либо менять. Default добавляем, потому что в Rust для типа с new() без аргументов это хороший тон, и линтер о нём напомнит.
Флаги
Флаги это четыре бита, которые ALU выставляет попутно. Храним их распакованно, по одному полю на бит, ради читаемости. Как именно они считаются, разберём в следующем уроке про арифметику; пока достаточно их формы.
/// Четыре бита состояния, выставляются арифметико-логическим устройством.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub struct Flags {
pub zero: bool, // Z: результат равен нулю
pub negative: bool, // N: старший бит результата
pub carry: bool, // C: перенос или заём за разряд
pub overflow: bool, // V: знаковое переполнение
}
impl Flags {
pub const fn new() -> Self {
Flags { zero: false, negative: false, carry: false, overflow: false }
}
/// Z и N выводятся прямо из результата, поэтому ставим их одной строкой.
/// Этим методом будет пользоваться ALU в следующем уроке.
pub fn set_zn(&mut self, result: u16) {
self.zero = result == 0;
self.negative = result & 0x8000 != 0;
}
/// Компактная запись для дампа состояния: `znCv`, заглавная буква значит,
/// что бит взведён. Пригодится отладчику в уроке 41.
pub fn as_str(self) -> String {
let bit = |on: bool, c: char| if on { c.to_ascii_uppercase() } else { c };
format!(
"{}{}{}{}",
bit(self.zero, 'z'),
bit(self.negative, 'n'),
bit(self.carry, 'c'),
bit(self.overflow, 'v'),
)
}
}
Положи flags.rs рядом с будущим ядром. Это весь модуль флагов целиком: set_zn мы позовём из ALU, as_str из отладчика, а сами биты будут жить полем в структуре Cpu.
Шина: единственная дверь в мир
Процессору нужна память. Но если просто положить массив байт внутрь Cpu, мы навсегда прибьём процессор к одному виду памяти. А нам нужно, чтобы по одному и тому же адресу мог оказаться и кусок ОЗУ, и регистр таймера, и порт UART. Решение: процессор не владеет памятью, он обращается к ней через трейт шины.
/// Контракт шины: единственная дверь процессора в мир. Реализатор обязан дать
/// побайтовый доступ; всё остальное идёт по умолчанию через него.
pub trait Bus {
/// Прочитать байт. &mut self, потому что чтение регистра устройства может
/// иметь побочный эффект (например, сбросить флаг готовности).
fn read8(&mut self, addr: u16) -> u8;
/// Записать байт.
fn write8(&mut self, addr: u16, value: u8);
/// Прочитать слово little-endian. По умолчанию это два чтения байт.
fn read16(&mut self, addr: u16) -> u16 {
let lo = self.read8(addr) as u16;
let hi = self.read8(addr.wrapping_add(1)) as u16;
lo | (hi << 8)
}
/// Записать слово little-endian.
fn write16(&mut self, addr: u16, value: u16) {
self.write8(addr, (value & 0xFF) as u8);
self.write8(addr.wrapping_add(1), (value >> 8) as u8);
}
/// Продвинуть периферию на `cycles` тактов. Плоская память это игнорирует;
/// таймеры и счётчики из урока 39 на этом живут.
fn tick(&mut self, _cycles: u32) {}
/// Опросить контроллер прерываний. `Some(vector)` значит «есть запрос,
/// прыгай по вектору». Плоская память прерываний не порождает.
fn poll_irq(&mut self) -> Option<u8> {
None
}
}
Обрати внимание на четыре вещи. Во-первых, обязательны только read8 и write8; 16-битный доступ идёт через них по умолчанию, и реализатору его переопределять не нужно. Во-вторых, read8 берёт &mut self, а не &self: чтение регистра устройства может что-то менять (скажем, сбрасывать флаг готовности при чтении). Память это не всегда пассивный массив, и трейт это учитывает с самого начала. В-третьих, tick и poll_irq это две заготовки на будущее: плоская память их игнорирует (тело по умолчанию пустое), но в уроке 39 за тем же трейтом окажется шина с периферией, и они оживут, не меняя ни строки в ядре. В-четвёртых, у трейта четыре метода с телом по умолчанию: реализатору хватит написать два обязательных, и шина готова.
Самая простая реализация шины это плоская память на все 64 KiB. Для неё адрес это буквально индекс в массиве.
/// Плоская оперативная память на все 64 KiB. Адрес это индекс.
pub struct Ram {
bytes: Box<[u8; Self::SIZE]>,
}
impl Ram {
pub const SIZE: usize = 1 << 16;
pub fn new() -> Self {
Ram { bytes: Box::new([0u8; Self::SIZE]) }
}
/// Загружает сырые байты по адресу `at` (строку, таблицу, что угодно).
pub fn load(&mut self, at: u16, data: &[u8]) {
for (i, &byte) in data.iter().enumerate() {
let addr = at.wrapping_add(i as u16);
self.bytes[addr as usize] = byte;
}
}
/// Загружает образ программы из слов инструкций (little-endian) по адресу `at`.
pub fn load_program(&mut self, at: u16, words: &[u16]) {
for (i, &word) in words.iter().enumerate() {
let addr = at.wrapping_add((i * 2) as u16);
self.write16(addr, word);
}
}
}
impl Default for Ram {
fn default() -> Self {
Self::new()
}
}
impl Bus for Ram {
fn read8(&mut self, addr: u16) -> u8 {
self.bytes[addr as usize]
}
fn write8(&mut self, addr: u16, value: u8) {
self.bytes[addr as usize] = value;
}
}
Вот и всё, что нужно процессору от памяти: уметь читать и писать байт по адресу. Массив на 64 KiB лежит в Box, чтобы не раздувать структуру на стеке. load_program кладёт слова инструкций, load сырые байты (например, строку для UART); обе функции пишут через ту же шину, что и процессор.
Загрузка и чтение через шину
Команды LW/SW (слово) и LB/SB (байт) это единственные инструкции, которые ходят в память, и все они идут через read16/write16/read8/write8. Никаких других путей у процессора к памяти нет. Попробуй: выбери программу про память, прогони по шагу и смотри, как значение из регистра уезжает в ячейку памяти, а потом читается обратно в другой регистр.
Колонка «шина видит память» это и есть состояние памяти, прочитанное тем же трейтом, которым пользуется процессор. Регистр r6 подсвечен особо: на программе со стеком видно, как он ходит вниз и вверх.
Стека-регистра нет, есть соглашение
В прошлом уроке мы решили не заводить отдельный регистр под стек. Стек у нас это соглашение: договорились, что r6 играет роль sp и растёт вниз. PUSH это пара инструкций (сдвинуть sp на 2 вниз и записать), POP это обратная пара. В коде процессора это всего две вспомогательные функции, которыми пользуются прерывания.
impl Cpu {
fn push16(&mut self, bus: &mut impl Bus, value: u16) {
let sp = self.reg(Reg::SP).wrapping_sub(2);
self.set_reg(Reg::SP, sp);
bus.write16(sp, value);
}
fn pop16(&mut self, bus: &mut impl Bus) -> u16 {
let sp = self.reg(Reg::SP);
let value = bus.read16(sp);
self.set_reg(Reg::SP, sp.wrapping_add(2));
value
}
}
Заметь: push16/pop16 это обычные SW/LW плюс арифметика над r6. Процессор не знает слова «стек», он знает регистры и шину. Стек целиком живёт в соглашении, а не в железе. В виджете программа про стек кладёт два числа и достаёт их в обратном порядке (последним вошёл, первым вышел): это видно и по регистрам, и по двум ячейкам памяти.
Что уже на руках
После этого урока у тебя три готовых файла будущего эмулятора:
flags.rsэто модуль флагов целиком (Flags,set_zn,as_str).bus.rsэто трейтBusсо всеми четырьмя методами и плоская памятьRam.cpu.rsпока содержит структуруCpu, сброс, доступ к регистрам, окна в состояние и стек-помощникиpush16/pop16.
Чего в cpu.rs ещё нет, так это самого цикла: функций step и execute, которые читают слово по PC, декодируют его и исполняют. Их мы напишем в следующем уроке, и Cpu оживёт. Сейчас процессор полностью собран как структура данных, но ещё не сделал ни одного шага.
Развилка дизайна и её цена
Главная мысль урока: ядро процессора и память это две разные ответственности, и между ними проходит трейт.
Можно было сделать иначе: положить Ram полем прямо в Cpu и читать self.ram.bytes[addr]. Код стал бы на одну косвенность короче. Но тогда процессор был бы навсегда женат на плоской памяти. Чтобы подключить периферию, пришлось бы лезть внутрь ядра и расставлять проверки адресов прямо в исполнителе. Чтобы протестировать процессор с памятью-заглушкой, пришлось бы городить флаги. Чтобы скомпилировать в браузер, пришлось бы переписывать.
Трейт Bus разрывает эту связь. Процессор зависит от контракта, а не от реализации. Цена: один вызов через трейт на каждое обращение к памяти и обобщённый код (bus: &mut impl Bus) вместо прямого. Выгода: в уроке 39 мы подставим вместо Ram шину с периферией, не тронув ни строки в ядре; тесты дают процессору любую реализацию Bus; виджет выше работает поверх того же трейта в WebAssembly. Для эмулятора это выгодный обмен: косвенность тонет в общей стоимости интерпретации, а гибкость окупается в каждом следующем уроке.
Это та же развилка «механизм против интерфейса», что и в прошлом уроке, только теперь у неё есть имя: трейт Bus. В следующем уроке мы наполним цикл fetch-decode-execute: научимся превращать биты в инструкции и считать арифметику с флагами.