Раздел 23 · Rust

Отладчик и тестирование

middle-senior~60 мин

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

Отладчик и тестирование

Эмулятор из прошлых уроков работает: ядро, шина, периферия, HAL. Но как понять, что он работает правильно, и как разглядеть, что происходит внутри? Закроем блок инструментами вокруг эмулятора: дизассемблером, пошаговым отладчиком с точками останова, дампом состояния и трейсами. И вернём долг про цикл-точность, который копили с первого урока.

Дизассемблер: инструкция обратно в текст

Декодер из урока 38 превращал биты в структуру. Дизассемблер это парная операция: из инструкции он делает читаемую строку. Без него отладчик и трейсы показывали бы голые числа.

Сначала три маленьких помощника: имя регистра, мнемоника операции ALU и знаковое смещение со знаком впереди (+5, -3), чтобы ветвления читались как в настоящем дизассемблере.

fn reg(r: Reg) -> String {
    format!("r{}", r.0)
}

fn alu_mnemonic(op: AluOp) -> &'static str {
    match op {
        AluOp::Add => "ADD",
        AluOp::Sub => "SUB",
        AluOp::And => "AND",
        AluOp::Or => "OR",
        AluOp::Xor => "XOR",
        AluOp::Shl => "SHL",
        AluOp::Shr => "SHR",
        AluOp::Sar => "SAR",
    }
}

/// Знаковое смещение со знаком впереди: `+5`, `-3`.
fn signed(off: i16) -> String {
    if off < 0 {
        format!("{off}")
    } else {
        format!("+{off}")
    }
}

Сам дизассемблер это match по варианту инструкции, зеркальный декодеру: одна ветка на вид, каждая собирает строку из полей. SYS дополнительно расшифровывает известные коды в имена.

/// Текст одной инструкции.
pub fn disassemble(instr: Instr) -> String {
    match instr {
        Instr::Alu { op, rd, rs1, rs2 } => {
            format!(
                "{} {}, {}, {}",
                alu_mnemonic(op),
                reg(rd),
                reg(rs1),
                reg(rs2)
            )
        }
        Instr::Addi { rd, rs1, imm } => format!("ADDI {}, {}, {imm}", reg(rd), reg(rs1)),
        Instr::Lui { rd, imm } => format!("LUI {}, {imm:#04x}", reg(rd)),
        Instr::Lli { rd, imm } => format!("LLI {}, {imm:#04x}", reg(rd)),
        Instr::Ori { rd, rs1, imm } => format!("ORI {}, {}, {imm}", reg(rd), reg(rs1)),
        Instr::Lw { rd, base, off } => format!("LW {}, {off}({})", reg(rd), reg(base)),
        Instr::Sw { src, base, off } => format!("SW {}, {off}({})", reg(src), reg(base)),
        Instr::Lb { rd, base, off } => format!("LB {}, {off}({})", reg(rd), reg(base)),
        Instr::Sb { src, base, off } => format!("SB {}, {off}({})", reg(src), reg(base)),
        Instr::Beq { a, b, off } => format!("BEQ {}, {}, {}", reg(a), reg(b), signed(off)),
        Instr::Bne { a, b, off } => format!("BNE {}, {}, {}", reg(a), reg(b), signed(off)),
        Instr::Blt { a, b, off } => format!("BLT {}, {}, {}", reg(a), reg(b), signed(off)),
        Instr::Bge { a, b, off } => format!("BGE {}, {}, {}", reg(a), reg(b), signed(off)),
        Instr::Jal { rd, off } => format!("JAL {}, {}", reg(rd), signed(off)),
        Instr::Jalr { rd, base, off } => format!("JALR {}, {off}({})", reg(rd), reg(base)),
        Instr::Sys { code } => match code {
            sys::HALT => "SYS HALT".to_string(),
            sys::RETI => "SYS RETI".to_string(),
            sys::EI => "SYS EI".to_string(),
            sys::DI => "SYS DI".to_string(),
            other => format!("SYS {other}"),
        },
    }
}

/// Декодировать и сразу дизассемблировать сырое слово.
pub fn disassemble_word(word: u16) -> String {
    disassemble(decode(word))
}

/// Дизассемблировать программу: список `(адрес, слово, текст)`.
pub fn disassemble_program(words: &[u16], base: u16) -> Vec<(u16, u16, String)> {
    words
        .iter()
        .enumerate()
        .map(|(i, &word)| {
            let addr = base.wrapping_add((i * 2) as u16);
            (addr, word, disassemble_word(word))
        })
        .collect()
}

Это весь disasm.rs. disassemble_word склеивает декодер и дизассемблер (биты в текст за один вызов), а disassemble_program проходит по образу программы и нумерует строки адресами, как листинг.

Отладчик это надстройка над шагом

Отладчик в эмуляторе устроен так: он не меняет процессор, он лишь решает, когда остановиться. Точка останова это адрес: дойдя до него, отладчик возвращает управление.

/// Чем закончился прогон под отладчиком.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DebugStop {
    /// Дошли до точки останова по адресу.
    Breakpoint(u16),
    /// Процессор остановился сам (`SYS HALT`).
    Halted { code: u16 },
    /// Исчерпан бюджет шагов.
    BudgetExhausted,
}

/// Отладчик хранит набор точек останова. Адреса упорядочены для стабильного дампа.
#[derive(Default)]
pub struct Debugger {
    breakpoints: BTreeSet<u16>,
}

impl Debugger {
    pub fn new() -> Self {
        Debugger::default()
    }

    pub fn add_breakpoint(&mut self, addr: u16) {
        self.breakpoints.insert(addr);
    }

    pub fn remove_breakpoint(&mut self, addr: u16) {
        self.breakpoints.remove(&addr);
    }

    pub fn breakpoints(&self) -> impl Iterator<Item = u16> + '_ {
        self.breakpoints.iter().copied()
    }

    /// Один шаг процессора, без оглядки на точки останова.
    pub fn step(&self, cpu: &mut Cpu, bus: &mut impl Bus) -> Step {
        cpu.step(bus)
    }

    /// Гонит процессор до точки останова, остановки или бюджета шагов. Первую
    /// инструкцию исполняет всегда, поэтому возобновление со стоящей точки
    /// останова не зависает на ней же.
    pub fn run(&self, cpu: &mut Cpu, bus: &mut impl Bus, max_steps: u64) -> DebugStop {
        let mut first = true;
        for _ in 0..max_steps {
            if !first && self.breakpoints.contains(&cpu.pc()) {
                return DebugStop::Breakpoint(cpu.pc());
            }
            first = false;
            if let Step::Halted { code } = cpu.step(bus) {
                return DebugStop::Halted { code };
            }
        }
        DebugStop::BudgetExhausted
    }
}

Тонкость в флаге first: если бы мы проверяли точку останова перед самым первым шагом, то возобновление с адреса, на котором стоим, мгновенно остановилось бы снова, и программа бы не двигалась. Поэтому первую инструкцию исполняем всегда. Заметь, что step отладчика это тонкая обёртка над cpu.step: отладчик ничего не добавляет к процессору, он лишь решает, когда вернуть управление. Это и есть «надстройка над шагом» из определения.

Дамп состояния это форматирование полей Cpu в человекочитаемый вид через те самые окна (pc, flags, cycles, registers), что мы завели в уроке 37. Никакого доступа к приватным полям, только публичные геттеры.

/// Человекочитаемый дамп состояния процессора.
pub fn dump_state(cpu: &Cpu) -> String {
    let regs = cpu.registers();
    let mut lines = Vec::new();
    lines.push(format!(
        "PC={:04X}  flags=[{}]  cycles={}",
        cpu.pc(),
        cpu.flags().as_str(),
        cpu.cycles()
    ));
    for row in 0..2 {
        let cells = (0..4)
            .map(|col| {
                let i = row * 4 + col;
                format!("r{i}={:04X}", regs[i])
            })
            .collect::<Vec<_>>()
            .join("  ");
        lines.push(cells);
    }
    lines.join("\n")
}

/// Дизассемблирует `count` инструкций начиная с `addr` (как `disassemble` в gdb).
pub fn disassemble_at(bus: &mut impl Bus, addr: u16, count: usize) -> Vec<(u16, String)> {
    (0..count)
        .map(|i| {
            let a = addr.wrapping_add((i * 2) as u16);
            (a, disassemble(decode(bus.read16(a))))
        })
        .collect()
}

dump_state рисует две строки по четыре регистра плюс шапку с PC, флагами и тактами. disassemble_at читает count слов прямо из шины и дизассемблирует их, как команда disassemble в gdb: удобно глянуть, что лежит вокруг точки останова.

В виджете кликни по строке, чтобы поставить точку останова (красная точка), и жми «до точки»: внутри работает ровно тот Debugger::run, что выше. Каждый одиночный шаг копится в трейс справа.

Трейс и golden-тестирование

Как тестировать целый эмулятор? Проверять каждую инструкцию по отдельности хорошо, но не ловит ошибок взаимодействия. Тут выручает трейс: запись «что процессор делал по шагам». Прогоняем программу, на каждом шаге пишем адрес, мнемонику и снимок состояния.

/// Один шаг трейса: адрес инструкции и состояние сразу после её исполнения.
#[derive(Clone, Debug)]
pub struct TraceEntry {
    pub pc: u16,
    pub word: u16,
    pub text: String,
    pub regs: [u16; 8],
    pub flags: Flags,
    pub cycles: u64,
}

/// Прогоняет процессор и собирает трейс до остановки или бюджета шагов.
pub fn trace(cpu: &mut Cpu, bus: &mut impl Bus, max_steps: u64) -> Vec<TraceEntry> {
    let mut out = Vec::new();
    for _ in 0..max_steps {
        if cpu.is_halted() {
            break;
        }
        let pc = cpu.pc();
        let word = bus.read16(pc);
        let step = cpu.step(bus);
        let text = match step {
            Step::Interrupt { vector, .. } => format!("<IRQ {vector}>"),
            _ => disassemble(decode(word)),
        };
        out.push(TraceEntry {
            pc,
            word,
            text,
            regs: cpu.registers(),
            flags: cpu.flags(),
            cycles: cpu.cycles(),
        });
        if matches!(step, Step::Halted { .. }) {
            break;
        }
    }
    out
}

Шаг трейса снимает состояние не до, а после исполнения, поэтому в строке видно результат инструкции. Слово инструкции мы читаем отдельно по PC до шага, потому что после шага PC уже уехал. Если сработало прерывание (урок 39), пишем <IRQ n> вместо мнемоники: на этом шаге процессор не исполнял инструкцию по PC, а ушёл в обработчик.

Дальше форматируем трейс в детерминированный текст и сохраняем его как эталон (golden-тест). После любой правки в декодере, ALU или исполнителе перегоняем трейс и сравниваем с эталоном: одно расхождение в diff, и регрессия найдена. В виджете справа как раз растёт такой трейс: каждая строка это адрес, инструкция и снимок регистров с флагами. Сохрани его мысленно как эталон, и любое изменение поведения процессора сразу станет видно.

/// Форматирует трейс в детерминированный текст, пригодный для сравнения с эталоном.
pub fn format_trace(entries: &[TraceEntry]) -> String {
    let mut lines = Vec::with_capacity(entries.len());
    for e in entries {
        let regs = (1..8)
            .map(|i| format!("r{i}={:04X}", e.regs[i]))
            .collect::<Vec<_>>()
            .join(" ");
        lines.push(format!(
            "{:04X}  {:<22}  {regs}  [{}]  c={}",
            e.pc,
            e.text,
            e.flags.as_str(),
            e.cycles
        ));
    }
    lines.join("\n")
}

Каждая строка это адрес мнемоника регистры [флаги] c=такты. Регистры берём с r1 (потому что r0 всегда ноль, печатать его незачем). Вот как выглядят первые строки трейса программы, считающей Фибоначчи:

0000  ADD r1, r0, r0          r1=0000 r2=0000 ... r7=0000  [Zncv]  c=1
0002  LLI r1, 0x00            r1=0000 r2=0000 ... r7=0000  [Zncv]  c=2
0004  ADD r2, r0, r0          r1=0000 r2=0000 ... r7=0000  [Zncv]  c=3
...
0010  ADD r5, r1, r2          r1=0000 r2=0001 ... r7=0000  [zncv]  c=9

Текст детерминирован: одни и те же входы дают байт в байт тот же вывод. Поэтому его можно один раз записать в файл и потом сравнивать.

Мини-ассемблер: программы без ручных битов

Чтобы писать программы для тестов и golden-трейсов, не кодируя биты руками, заведём крошечный билдер. Он опирается на encode из урока 36: ты вызываешь методы, а на выходе получаешь слова, готовые для Ram::load_program. Сначала конструкторы одной инструкции, по одному на мнемонику (показаны несколько, остальные строятся ровно так же по таблице опкодов).

/// Короткая запись регистра: `r(3)` это `r3`.
pub const fn r(n: u8) -> Reg {
    Reg::new(n)
}

/// Конструкторы одной инструкции. Имена совпадают с мнемониками ISA.
pub mod op {
    use super::*;

    pub fn add(rd: Reg, rs1: Reg, rs2: Reg) -> Instr {
        Instr::Alu { op: AluOp::Add, rd, rs1, rs2 }
    }
    pub fn sub(rd: Reg, rs1: Reg, rs2: Reg) -> Instr {
        Instr::Alu { op: AluOp::Sub, rd, rs1, rs2 }
    }
    pub fn addi(rd: Reg, rs1: Reg, imm: i16) -> Instr {
        Instr::Addi { rd, rs1, imm }
    }
    pub fn lui(rd: Reg, imm: u8) -> Instr {
        Instr::Lui { rd, imm }
    }
    pub fn lli(rd: Reg, imm: u8) -> Instr {
        Instr::Lli { rd, imm }
    }
    pub fn lw(rd: Reg, off: i16, base: Reg) -> Instr {
        Instr::Lw { rd, base, off }
    }
    pub fn sw(src: Reg, off: i16, base: Reg) -> Instr {
        Instr::Sw { src, base, off }
    }
    pub fn bne(a: Reg, b: Reg, off: i16) -> Instr {
        Instr::Bne { a, b, off }
    }
    pub fn jal(rd: Reg, off: i16) -> Instr {
        Instr::Jal { rd, off }
    }
    pub fn jalr(rd: Reg, off: i16, base: Reg) -> Instr {
        Instr::Jalr { rd, base, off }
    }
    pub fn sys(code: u16) -> Instr {
        Instr::Sys { code }
    }
    // и так далее: and, or, xor, shl, shr, sar, ori, lb, sb, beq, blt, bge.
}

Поверх конструкторов это билдер Asm. Он копит инструкции и заодно раскрывает псевдоинструкции из урока 36 (LI, MOV, CMP, PUSH, POP, CALL, RET, J, NOP) в настоящие команды, прямо как ассемблер.

/// Билдер программы. Методы добавляют инструкции и возвращают `&mut self`,
/// поэтому код собирается цепочкой.
#[derive(Default)]
pub struct Asm {
    code: Vec<Instr>,
}

impl Asm {
    pub fn new() -> Self {
        Asm { code: Vec::new() }
    }

    /// Добавить готовую инструкцию.
    pub fn emit(&mut self, instr: Instr) -> &mut Self {
        self.code.push(instr);
        self
    }

    /// `NOP` = `ADDI r0, r0, 0`.
    pub fn nop(&mut self) -> &mut Self {
        self.emit(op::addi(Reg::ZERO, Reg::ZERO, 0))
    }

    /// `MOV rd, rs` = `ADD rd, rs, r0`.
    pub fn mov(&mut self, rd: Reg, rs: Reg) -> &mut Self {
        self.emit(op::add(rd, rs, Reg::ZERO))
    }

    /// `LI rd, imm16`: верхний байт через `LUI`, нижний через `LLI`. Когда хватает
    /// одного нижнего байта, `LUI` заменяем на обнуление через `r0`.
    pub fn li(&mut self, rd: Reg, imm: u16) -> &mut Self {
        let hi = (imm >> 8) as u8;
        let lo = (imm & 0xFF) as u8;
        if hi != 0 {
            self.emit(op::lui(rd, hi));
        } else {
            self.emit(op::add(rd, Reg::ZERO, Reg::ZERO));
        }
        self.emit(op::lli(rd, lo))
    }

    /// `PUSH rs` = `ADDI sp, sp, -2` + `SW rs, 0(sp)`.
    pub fn push(&mut self, rs: Reg) -> &mut Self {
        self.emit(op::addi(Reg::SP, Reg::SP, -2));
        self.emit(op::sw(rs, 0, Reg::SP))
    }

    /// `POP rd` = `LW rd, 0(sp)` + `ADDI sp, sp, 2`.
    pub fn pop(&mut self, rd: Reg) -> &mut Self {
        self.emit(op::lw(rd, 0, Reg::SP));
        self.emit(op::addi(Reg::SP, Reg::SP, 2))
    }

    /// `HALT` = `SYS 0`.
    pub fn halt(&mut self) -> &mut Self {
        self.emit(op::sys(sys::HALT))
    }

    /// Кодирует программу в слова для `Ram::load_program`.
    pub fn assemble(&self) -> Vec<u16> {
        self.code.iter().map(|i| i.encode()).collect()
    }
}

Остальные псевдоинструкции (CMP, J, CALL, RET) добавляются так же, одной строкой через emit. assemble это encode по всем инструкциям: круг замкнулся, ассемблер обратен дизассемблеру.

Golden-тест на практике

Теперь все детали сложились в настоящий тест. Соберём программу Фибоначчи ассемблером, прогоним трейс, отформатируем и сравним с файлом-эталоном tests/golden/fib.trace. Эталон создаётся один раз (например, отдельным примером, который печатает трейс в файл), а тест следит, чтобы он не менялся молча.

fn fib_program() -> Asm {
    let mut asm = Asm::new();
    asm.li(r(1), 0).li(r(2), 1).li(r(3), 10).li(r(4), 1);
    asm.emit(op::add(r(5), r(1), r(2))); // r5 = r1 + r2
    asm.mov(r(1), r(2)); // сдвигаем окно
    asm.mov(r(2), r(5));
    asm.emit(op::sub(r(3), r(3), r(4))); // счётчик вниз
    asm.emit(op::bne(r(3), r(0), -5)); // пока не ноль, на начало тела
    asm.halt();
    asm
}

#[test]
fn fib_trace_matches_golden() {
    let mut ram = Ram::new();
    ram.load_program(0, &fib_program().assemble());
    let mut cpu = Cpu::new();
    cpu.reset(0);

    let entries = trace(&mut cpu, &mut ram, 1000);
    let got = format_trace(&entries);

    let golden = include_str!("golden/fib.trace");
    assert_eq!(got.trim_end(), golden.trim_end());
}

include_str! вшивает файл-эталон в бинарник на этапе компиляции, поэтому тест не лезет в файловую систему. Если ты случайно сломаешь флаг C в sub или сдвиг ветвления, этот один assert_eq! покажет ровно ту строку, где трейс разошёлся. Один тест держит честным весь конвейер: декодер, ALU, исполнитель, счётчик тактов и дизассемблер сразу.

Цикл-бюджет: возвращаем долг

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

// Внутри execute разные инструкции возвращают разную стоимость:
//   ALU-классы и переходы без взятия      1 такт
//   доступ к памяти (LW/SW/LB/SB)          3 такта
//   взятое ветвление, JAL/JALR             2 такта
// step накапливает их и тикает периферию:
self.cycles += cost as u64;
bus.tick(cost);

Это всё ещё не цикл-точность: мы не моделируем, что происходит внутри инструкции по тактам, мы лишь приписываем каждой инструкции честную цену и продвигаем время устройств на неё. Но именно эта таблица стоимостей и есть мостик к цикл-точности. Захочешь её, начнёшь дробить tick внутри инструкции, проверяя прерывания между микрошагами. В виджете в дампе видно счётчик тактов: доступ к памяти стоит дороже, чем сложение, и это заметно по тому, как растёт тактов.

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

Это финальная развилка блока, и она же первая: точность против скорости. Весь блок мы держали инструкционную точность, и весь блок называли её цену. Теперь видно, на чём она держится: step считает такты порциями по инструкции, отладчик и трейс работают в единицах инструкций, golden-тесты фиксируют состояние после каждой инструкции, а не после каждого такта.

Если бы нам нужна была цикл-точность (а в следующем проекте раздела, эмуляторе NES, она нужна), пришлось бы заплатить: дробить инструкции на микрошаги, тикать периферию между ними, проверять прерывания посреди инструкции, и трейс распух бы с инструкций до тактов. Код стал бы в разы сложнее и медленнее, а взамен видеочип увидел бы каждую запись ровно на нужном такте. Мы сознательно не платим эту цену здесь: для учебного процессора, отладчика и виджета инструкционной точности хватает с запасом, а ясность дороже. Инженерное решение это не «всегда максимум», а «минимально достаточно для задачи», и весь блок был про то, чтобы видеть эту шкалу и читать её осознанно.

На этом блок про bcpu закончен: ты спроектировал ISA, написал ядро с шиной, декодер и ALU, подключил периферию через MMIO и прерывания, спрятал железо за HAL и собрал инструменты отладки. Тот же код ты гонял в браузере на каждом шаге. Дальше в разделе ждёт эмулятор посложнее, где цена цикл-точности станет не теорией, а необходимостью.

Домашка