news 2026/11/3 19:56:04

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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时卡住:它真正要你交出的东西,是一个能自解释的调试器sdb(Simple 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是模拟内存大小(常见默认 128MB),CONFIG_MBASE是客户机内存起始地址,riscv32 配置下一般是0x80000000,x86 配置下是0x100000。len只能是 1、2、4,host_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 # 启动 sdb

make menuconfig第一次会因为生成配置文件而多花一点时间,看到(nemu)提示符就成功。菜单里 PA1 至少确认三项:ISA 类型(决定CONFIG_MBASE和寄存器表)、设备模拟开关(不开也不影响 PA1)、指令追踪开关(调试断点用得上就开)。配置文件保存在.config,include/config.h是它的 C 语言投影,两者不同步时编译出来的行为和你勾的不一致,后面避坑章会细说。

编译产物是build/nemu,make 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 恰好前进一条才继续写别的。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 19:53:59

Java8与MyBatis持久不衰的技术逻辑解析

1. Java生态的保守现象解析 在Java开发者社区中&#xff0c;一个有趣的现象持续存在&#xff1a;尽管Java语言版本已经迭代到20&#xff0c;大量开发者仍坚守Java8&#xff1b;尽管现代ORM框架层出不穷&#xff0c;许多项目依然采用MyBatis配合XML编写SQL。这种技术选择的保守性…

作者头像 李华
网站建设 2026/11/3 19:52:19

AI创意预测技术解析与广告行业应用

1. 项目概述&#xff1a;AI创意分析行业的战略并购阳狮集团近期完成对AdgeAI的收购&#xff0c;标志着传统广告巨头向数据驱动型创意决策的转型迈出关键一步。作为全球Top3的传播集团&#xff0c;这次并购并非简单的资本运作&#xff0c;而是对"创意生产-效果预测"全…

作者头像 李华
网站建设 2026/11/3 19:50:42

分布式系统容错设计:三层防御与混沌工程实践

1. 分布式系统容错设计的核心价值 当你在电商大促时完成支付却看到"系统繁忙"提示&#xff0c;或是深夜提交文档突然遭遇协同办公平台崩溃——这些场景背后都指向同一个技术命题&#xff1a;分布式系统如何应对故障。作为支撑现代互联网服务的基石&#xff0c;分布式…

作者头像 李华
网站建设 2026/11/3 19:50:01

电商系统架构设计与分布式优化实战

1. 电商系统架构设计实战解析 最近几年参与过多家大厂Java技术面试&#xff0c;发现电商系统架构设计始终是考核重点。今天我就结合自己作为面试官和被面试者的双重经验&#xff0c;聊聊电商系统架构设计的核心要点和分布式优化的实战技巧。 电商系统作为典型的高并发分布式系…

作者头像 李华
网站建设 2026/11/3 19:49:41

量化CTA策略解析:趋势跟踪、截面套利与风控边界

简介&#xff1a;《量化CTA策略&#xff1a;理念、框架与实战》是一份面向量化投资研究者、CTA策略学习者与资产配置从业者的专题PDF&#xff0c;系统梳理管理期货策略的理念、研究框架与实战要点&#xff0c;适合用于策略入门、投研参考和组合配置思路整理。包内仅含1个PDF文件…

作者头像 李华
网站建设 2026/11/3 19:49:11

RCD钳位二极管选型:从公式到实测的Trr与Rsn判断

简介&#xff1a;面向开关电源设计者与反激电源调试人员的一份实战经验整理&#xff0c;聚焦RCD钳位电路中二极管D的选型难题&#xff1a;为何按公式算出的Rsn与实测参数相差甚远&#xff0c;以及不同开关速度的二极管如何影响钳位电压与电阻能耗。文中以UF4007、FR107、1N4007…

作者头像 李华