Раздел 23 · Rust

Состояние процессора и шина

senior~45 мин

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

Состояние процессора и шина

В прошлом уроке мы спроектировали систему команд и видели цикл 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: научимся превращать биты в инструкции и считать арифметику с флагами.

Домашка