NEMU PA1与sdb调试器:单步执行、内存扫描和表达式求值

发布时间:2026/11/3 19:56:05
NEMU PA1与sdb调试器:单步执行、内存扫描和表达式求值 简介这份PDF文档围绕NEMU PA1实践项目展开面向学习计算机系统、操作系统或体系结构课程的学生与研究者重点解决如何通过实现简易调试器sdb理解模拟器基础设施的问题。内容从概述、原理与应用三部分梳理NEMU的CPU模拟、内存模拟、设备模拟及调试器实现具体涉及单步执行、打印寄存器、扫描内存、表达式求值、断点设置与状态查看等知识点并延伸到课程教学、体系结构研究、底层软件测试等场景。资源包共1个文件为PDF格式大小约74KB轻量便于随查随用。目前已有799人学习读者可据此快速建立PA1知识框架理解sdb与NEMU的衔接关系掌握调试命令背后的模拟器机制并为实验报告撰写和后续PA任务打下基础。1. 从一条si命令说起NEMU PA1 到底给了你什么你在终端敲下si 3屏幕回三行pc变化然后q退出——如果你以为 NEMU PA1 只是“模拟器入门作业”那大概率会在第二次make run时卡住它真正要你交出的东西是一个能自解释的调试器sdbSimple Debugger以及支撑它的 CPU、内存、设备三层模拟底座。这份 PDF 把 PA1 拆成概述、原理、应用三段核心落点很明确单步执行、打印寄存器、扫描内存、表达式求值四件事全都要靠你自己那几十行 C 代码撑起来。它适合正在啃计算机系统实践、想搞懂“指令是怎么被一条条喂进去执行”的开发者也适合当二次开发的起点——后面加断点、命中打印、指令追踪都是在这个骨架上长出来的。它不是插件是一套跑在本机的软件模拟器调试的是被它模拟出来的那台“虚拟机器”。2. 架构拆解三层模拟结构与 PA1 的落点先把整体看清再谈代码。NEMU 的骨架并不复杂复杂的是“谁模拟谁、谁调试谁”这个关系容易绕晕。文档里提到的 CPU 模拟、内存模拟、设备模拟三件事落到工程上其实是三层ISA 层指令怎么解释、内存层地址怎么映射到宿主机字节数组、设备层输入输出怎么接。PA1 阶段只需要前两层设备层通常是编译期开关控制的可选件不开也能跑通单步。2.1 CPU 层与内存层的分工CPU 层负责“取指—译码—执行—更新 pc”这一轮循环它对外只暴露少数几个符号一个CPU_state结构体里面装通用寄存器和 pc、一个exec_once()、一个isa_reg_display()。内存层对外暴露vaddr_read/vaddr_write和paddr_read/paddr_write这几个函数内部把客户机物理地址映射到宿主机的一大块静态数组上。/* nemu/src/memory/paddr.c —— 客户机物理内存就是一块宿主机的静态数组 */ static uint8_t pmem[CONFIG_MSIZE] __attribute__((aligned(4096))); /* guest 地址 - host 指针减去基地址 MBASE 就得到数组下标 */ static inline uint8_t *guest_to_host(paddr_t paddr) { return pmem paddr - CONFIG_MBASE; } /* 越界判断是 PA1 最容易漏掉的一行 */ static inline bool in_pmem(paddr_t addr) { return addr - CONFIG_MBASE CONFIG_MSIZE; } word_t paddr_read(paddr_t addr, int len) { if (likely(in_pmem(addr))) return host_read(guest_to_host(addr), len); return 0; /* 越界访问PA1 阶段返回 0 并打日志即可 */ }CONFIG_MSIZE是模拟内存大小常见默认 128MBCONFIG_MBASE是客户机内存起始地址riscv32 配置下一般是0x80000000x86 配置下是0x100000。len只能是 1、2、4host_read内部用switch分派到uint8_t / uint16_t / uint32_t的指针解引用。注意in_pmem用的是无符号减法地址低于MBASE时会回绕成一个大数自然被判为越界这比写两条比较更省事。这么设计的直接好处是调试器扫描内存时不需要区分“真实内存”和“模拟内存”x 16 0x80000000和p *(int *)0x80000000走的是同一条vaddr_read路径行为一致排查问题时少一层怀疑。2.2 sdb 与被调试程序一个进程里的两个视角很多人上手第一反应是“能不能用 gdb attach 上去”答案是没必要也不对路。NEMU 里被执行的客户程序不是独立进程它只是pmem里的一段机器码sdb和它是同一个进程里的两层命令层在主循环里等输入执行层在cpu_exec()里跑指令。所谓“暂停”就是cpu_exec提前 return把控制权交回命令循环。这个认知决定了后面所有功能的实现方式。断点不是int3指令而是cpu_exec循环里的一句if (cpu.pc bp_addr)单步不是ptrace而是把循环次数设成 1查看寄存器不是读/proc而是直接读cpu.gpr[]。把这条想通PA1 的实现路径就没有玄学了。2.3 目录结构与构建入口框架的目录分层很规整改代码前先认清自己在哪一层能省掉大量找文件的时间。路径作用PA1 阶段是否常改nemu/src/monitor/sdb/调试器命令实现、表达式求值高频主力战场nemu/src/monitor/monitor.c初始化流程、参数解析、主循环入口低频偶尔加初始化nemu/src/cpu/cpu-exec.c执行循环、断点与观察点检查中频加触发逻辑nemu/src/memory/物理内存数组、读写函数中频补越界检查nemu/src/isa/指令语义、寄存器结构、反汇编低频PA1 不改nemu/include/config.h由 Kconfig 生成的宏集合不手改自动生成nemu/Kconfig配置项定义make menuconfig的菜单来源低频加新开关时改构建链路是“Kconfig→.config→include/config.h→ 条件编译”。也就是说你在代码里写的#ifdef CONFIG_ITRACE能不能生效取决于菜单里有没有勾上对应项而不是你在.c文件里写没写。这条链路后面会反复咬人先记住。3. 核心实现把 sdb 命令一条条落到代码上这一章是 PA1 的主体工作量。按命令拆参数从哪来、边界怎么判、失败怎么提示都落到具体代码上。3.1 命令主循环与命令表sdb的入口是一个交互循环。常见做法是用一张静态命令表把“命令名—说明—处理函数”绑在一起主循环只负责切词和查表加新命令时只改表不动循环。/* nemu/src/monitor/sdb/sdb.c */ typedef struct { const char *name; const char *description; int (*handler)(char *args); /* args 为本行命令名之后的剩余串可为 NULL */ } cmd_t; static cmd_t cmd_table[] { {help, Print all commands, cmd_help}, {c, Continue until end or a trigger, cmd_c}, {si, Step N instructions, default 1, cmd_si}, {info, info r: registers / info w: watchpoints, cmd_info}, {x, Examine memory: x N EXPR, cmd_x}, {p, Evaluate expression EXPR, cmd_p}, {q, Quit NEMU, cmd_q}, }; void sdb_mainloop(void) { if (is_batch_mode) { cmd_c(NULL); return; } /* 批处理模式直接跑完 */ for (char *str; (str rl_gets()) ! NULL; ) { char *cmd strtok(str, ); if (cmd NULL) continue; for (cmd_t *c cmd_table; c-name ! NULL; c) { if (strcmp(c-name, cmd) 0) { c-handler(NULL); /* 真正的参数由 handler 内部继续 strtok */ break; } } } }rl_gets()内部用 readline 的readline((nemu) )返回的字符串由 readline 自己 malloc历史记录用add_history存空行要判掉。is_batch_mode来自启动参数-b批处理模式下不进交互循环直接跑c这也是自动评测的入口——很多人本地交互模式好好的一上批处理就卡死原因就是忘了这个分支。命令表这种写法的好处在扩展help自己遍历cmd_table打印即可新命令写一个cmd_xxx再加一行表项就完事不用改主循环的判断链。3.2si与info r单步执行和寄存器快照si是整个调试器最基础的一条它本质是把“执行多少条指令”这个参数传给执行层。static int cmd_si(char *args) { char *arg strtok(args, ); int n 1; if (arg ! NULL) { n atoi(arg); if (n 0) { printf(si: N must be a positive integer\n); return 0; } } cpu_exec(n); /* 最多执行 n 条遇到触发条件提前返回 */ return 0; }args传进来是NULL因为主循环只按命令名切了第一段后面的参数要在 handler 内自己继续切。n做非正数校验是因为cpu_exec的参数是无符号计数循环传 0 会变成不执行传负数经atoi转成负数再传给无符号参数会变成天文数字这是血泪经验里最典型的“一敲回车模拟器就假死”。info r的职责是把CPU_state里的内容打成一张表。寄存器结构体通常用匿名的联合体加结构体组织好处是既能按名字访问cpu.pc也能按下标遍历。/* nemu/src/isa/riscv32/include/isa-def.h 简化示意 */ typedef struct { word_t gpr[32]; /* 32 个通用寄存器 */ vaddr_t pc; /* 程序计数器 */ } riscv32_CPU_state; /* 名字表和寄存器下标一一对应打印时按下标循环 */ static const char *reg_name[] { $0, ra, sp, gp, tp, t0, t1, t2, s0, s1, a0, a1, a2, a3, a4, a5, a6, a7, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, t3, t4, t5, t6, }; void isa_reg_display(void) { for (int i 0; i 32; i) printf(%-6s 0x%08x\n, reg_name[i], cpu.gpr[i]); printf(%-6s 0x%08x\n, pc, cpu.pc); }用“名字表 下标循环”而不是逐个printf是为了让表达式求值里的寄存器查找复用同一张表p $t0 4时先拿$t0在这张表里做字符串匹配拿到下标再去cpu.gpr[i]取值两边不重复维护。改 ISA 配置时只需要换这张表和结构体调试器层不用动。3.3x命令与内存扫描把地址算对x N EXPR的语义是“从表达式 EXPR 求出的地址开始连续打印 N 个字”。注意第二个参数不是字面量地址是表达式——这意味着x 4 $sp和x 4 0x80000000走同一条求值路径。static int cmd_x(char *args) { char *n_str strtok(args, ); char *expr_str strtok(NULL, ); /* 表达式占剩余整段含空格 */ if (n_str NULL || expr_str NULL) { printf(Usage: x N EXPR\n); return 0; } int n atoi(n_str); if (n 0) { printf(x: N must be positive\n); return 0; } bool ok true; word_t addr expr(expr_str, ok); if (!ok) { printf(x: bad expression\n); return 0; } for (int i 0; i n; i) { word_t cur addr i * 4; /* 每次前进 4 字节与字长对齐 */ word_t data vaddr_read(cur, 4); /* 走统一的虚拟地址读接口 */ printf(0x%08x: 0x%08x\n, cur, data); } return 0; }这里有两处容易翻车。第一strtok(NULL, )只切到第一个空格x 4 $sp 8会把表达式切坏稳妥做法是自己找到第一个空格后的整段剩余串或者用strtok(args, )切出 N 之后把指针后面的整段塞给expr。第二步长写死 4 时地址不是 4 对齐的会读到跨字数据PA1 阶段可以不强制对齐但要在文档里写清“步长 4 字节”否则你自己半年后回来看也会疑惑。vaddr_read(addr, len)的len只接受 1/2/4内部先查是否越界越界返回 0 并打一条日志再转paddr_read最后host_read把字节拼成word_t。拼接顺序要按小端规则从低地址移低位写反了x打出来的数看着像随机值。3.4 表达式求值与触发条件表达式求值是 PA1 里代码量最大的一块p EXPR、x N EXPR、断点和观察点全都依赖它。常见实现是先词法切分token 数组再对一个区间做递归下降求值。/* 优先级从低到高|| /! /- */ 一元负号/解引用 括号与原子 */ static word_t eval(int p, int q, bool *ok); /* 处理 tokens[p..q] 这个闭区间 */ /* 原子数字或寄存器落在区间里只剩一个 token 时处理 */ static word_t eval_atom(int p, bool *ok) { if (tokens[p].type TK_NUM) return tokens[p].val; if (tokens[p].type TK_REG) { int idx find_reg(tokens[p].str); /* 复用前面的名字表 */ if (idx 0) { *ok false; return 0; } return (idx 32) ? cpu.pc : cpu.gpr[idx]; } *ok false; return 0; } /* 解引用*EXPR先求右边地址再读 4 字节 */ static word_t eval_deref(int p, int q, bool *ok) { if (tokens[p].type *) { word_t addr eval(p 1, q, ok); return ok ? vaddr_read(addr, 4) : 0; } return eval(p, q, ok); }expr()的入口要做三件事调用tokenize()填空格产生 token 数组、把*ok置为true、调用eval(0, nr_token - 1, ok)。任何失败分支都必须把*ok置false否则上层拿到的是“成功的 0”x 4 1这种残缺表达式会打出一片零比直接报错更误导人。断点和观察点放在cpu_exec的循环里做检查和表达式求值共用同一套东西void cpu_exec(uint64_t n) { g_print_step (n MAX_INST_TO_PRINT); /* 指令少时逐条打印多时闭嘴 */ if (nemu_state.state ! NEMU_RUNNING) { printf(Program has ended, no more steps\n); return; } nemu_state.state NEMU_RUNNING; for (; n 0; n--) { exec_once(); /* 取指、译码、执行、更新 pc */ if (nemu_state.state ! NEMU_RUNNING) break; /* 命中 trap 或 abort立即退出 */ if (check_watchpoint()) break; /* 观察点值变化停 */ if (hit_breakpoint(cpu.pc)) break; /* 断点命中停 */ } if (nemu_state.state NEMU_RUNNING) nemu_state.state NEMU_STOP; }check_watchpoint()遍历观察点链表对每个节点重新expr(node-expr, ok)求值与node-old_val比对不等就打印新旧值并更新。hit_breakpoint(pc)遍历断点数组比对地址。两者都返回bool命中即break绝不能在循环里continue——否则si 1会因为你设了个断点而多走一条指令这个偏差在调指令语义时足够让你怀疑人生。4. 跑起来从零编译、配置到第一次单步代码看懂了不等于能跑起来。这一章按实际操作顺序走一遍环境、配置、验证清单都给全。4.1 依赖与菜单配置宿主机是 Linux 最省事。依赖主要是编译工具链、readline交互输入和 SDL2设备模拟用PA1 不开也行。# Debian/Ubuntu 系 sudo apt install build-essential git device-tree-compiler sudo apt install libreadline-dev libsdl2-dev # 进入 NEMU 工程目录先跑一次菜单配置 cd nemu make menuconfig # 选 ISA如 riscv32、内存大小、是否开 ITRACE/设备 make # 生成 include/config.h 并编译 make run # 启动 sdbmake menuconfig第一次会因为生成配置文件而多花一点时间看到(nemu)提示符就成功。菜单里 PA1 至少确认三项ISA 类型决定CONFIG_MBASE和寄存器表、设备模拟开关不开也不影响 PA1、指令追踪开关调试断点用得上就开。配置文件保存在.configinclude/config.h是它的 C 语言投影两者不同步时编译出来的行为和你勾的不一致后面避坑章会细说。编译产物是build/nemumake run只是加了启动参数跑它。想手动控制参数就直接跑二进制./build/nemu -b # 批处理模式跑完内建程序自动退出 ./build/nemu -l build/nemu-log.txt # 把日志写到文件便于回看-b会跳过交互循环直接执行内建客户程序适合做自动化验证-l指定日志路径避免sdb的输出被淹掉。4.2 一次完整的验证清单启动后按顺序打这几条能一次性确认四条命令都活着。(nemu) si # 走一条pc 应该变化 (nemu) si 5 # 连走 5 条观察 pc 轨迹是否单调推进 (nemu) info r # 打寄存器表pc 应等于上一步 si 后的值 (nemu) x 8 0x80000000 # 扫 8 个字地址按 4 递增 (nemu) p $pc 4 # 表达式求值输出应为当前 pc 4 (nemu) q # 退出判读标准很直接si后info r里的pc必须和si之前不相等若相等说明exec_once里取指或更新 pc 出了问题x 8 0x80000000打出来的数应该是内建程序的首几条指令编码全零说明内存没初始化或地址映射错了p $pc 4报错先查寄存器名表里有没有pc这一项。批处理跑./build/nemu -b最后应该看到程序走到 good trap 的提示看不到就是内建程序或者cpu_exec的终止条件没接对。5. 避坑排查PA1 阶段最容易翻车的五处下面这些是我踩过、也见过别人反复踩的现象、原因、解决按顺序写对着现象找就行。5.1 编译报 readline 头文件找不到现象fatal error: readline/readline.h: No such file or directory。 原因宿主机装了 readline 运行库但没装开发头文件-lreadline能过链接检查头文件却缺。 解决装libreadline-dev后重新make。如果装了还报检查工程里是不是有一份自带的 readline 源码目录编译选项可能指到了错误路径。5.2 表达式求值永远返回 0现象p 1 2输出0也不报错。 原因expr()入口没把*ok置true函数返回后上层拿的是初始化的false或未初始化的栈值另一种是tokenize没跳空格1 2被切成无法识别的 token。 解决expr开头显式*success true;每个失败分支显式置false在tokenize里先while (isspace(*s)) s再判类型。5.3 设了断点但si 1多走了一条现象在0x80000010设断点si 1之后pc落在0x80000014。 原因cpu_exec循环里命中判断用了continue而不是break或者把n--放在了判断之后导致多消耗一次循环。 解决命中即break且在循环体末尾统一处理nemu_state不要在中间return否则g_print_step这类全局状态来不及复位。5.4x扫描内存直接段错误现象x 16 0x80000000一敲就 Segmentation fault进程退出。 原因paddr_read里省了in_pmem判断guest_to_host算出一个超出pmem数组的指针解引用时崩在宿主机上。 解决所有读写在paddr_read/paddr_write入口做一次in_pmem检查越界返回 0 并打日志调试器层再加一层printf提示让用户看到“地址越界”而不是看到一个崩溃。5.5 改了 Kconfig 但代码里的宏不生效现象菜单里勾了ITRACE代码里#ifdef CONFIG_ITRACE那块死活不编进去。 原因.config存了但include/config.h没重新生成或者编译时用了上次的产物缓存。 解决确认make menuconfig退出时选了保存grep CONFIG_ITRACE .config include/config.h两边对一下缺了就跑make clean make。这个链路翻车一次之后每次改配置我都会先 grep 一遍再编译。6. 二次开发把 sdb 扩成顺手的调试器PA1 交完不是终点这套骨架最适合拿来练“给工具加功能”。我自己的做法是先加命令再加自动化顺序别乱。第一步是把命令表当扩展点。加一个查看断点列表的info b、一个删除断点的d N、一个打印最近 N 条指令的bt N每个都只需要一个处理函数加一行表项。想加的能力改哪里关键点断点列表/删除sdb.c的cmd_table 断点数组遍历编号稳定删除后不重排观察点命中打印check_watchpoint内打印表达式原文、旧值、新值指令环形缓冲exec_once后写入定长数组只存 pc 和指令编码别存整条反汇编批处理脚本sdb_mainloop的is_batch_mode分支从文件读命令逐行喂给同一个 handler代码层面加一个“执行到某条指令前停下”的功能最值得练因为它把断点、表达式、执行循环三块串起来/* 在 exec_once 之前判断实现“执行前触发”的断点语义 */ for (; n 0; n--) { if (hit_breakpoint(cpu.pc)) { /* 停在指令执行之前 */ printf(Breakpoint hit at 0x%08x\n, cpu.pc); break; } exec_once(); if (check_watchpoint()) break; }注意这段和 3.4 的顺序差别exec_once之后再判 pc语义是“执行完停在下一条”执行之前判语义是“停在这条还没跑的时候”。两种都合理但必须和help里的说明一致也必须在si和c之间保持一致否则你自己调试时会反复怀疑断点位置偏了一条。验证方法很土但有效设一个断点在已知指令地址c过去后x 1 $pc看指令编码和反汇编对得上就说明语义没错。我吃过一次亏以为断点语义和执行顺序无关结果在调跳转指令时被“停在目标地址还是源地址”绕了半天最后靠写死的寄存器快照对比才发现是多走了一条。从那以后我每次动cpu_exec的循环结构都强制先用si 1加info r打一遍前后快照再make确认 pc 恰好前进一条才继续写别的。希望帮到你。本文还有配套的精品资源点击获取