Отладчик и тестирование
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Отладчик и тестирование
Эмулятор из прошлых уроков работает: ядро, шина, периферия, 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 и собрал инструменты отладки. Тот же код ты гонял в браузере на каждом шаге. Дальше в разделе ждёт эмулятор посложнее, где цена цикл-точности станет не теорией, а необходимостью.