☰
从零构建简易调试器:断点、单步与监视点机制
2026/10/28 4:34:19 网站建设 项目流程

简介:这份PA1-简易调试器实验指导PDF面向计算机系统、操作系统与程序调试方向的学习者,围绕在NEMU模拟器上构建具备基本调试功能的工具展开,适合需要完成课程实验或希望理解调试器内部机制的学生参考。压缩包仅含1个PDF文件,大小约1.53MB,内容集中,便于按实验阶段查阅。资料覆盖单步执行、打印寄存器状态、扫描内存三项基础功能,并延伸至表达式求值与监视点实现,同时交代实验耗时、代码量、提交与评分构成,以及NEMU模拟x86硬件、运行用户程序的层次关系。已有2182人学习,说明其在同类实验资料中具有一定参考价值;读者可据此建立从调试器功能设计到模拟器运行原理的整体认识,并借助实验报告与选做任务要求规划学习重点。

1. 程序跑到一半崩溃了,你怎么知道它停在哪

“程序跑飞了,打印出来的值全是乱的,我在代码里插了七八个 printf,重新编译一次要等十几秒,来回几轮之后连自己插到哪都忘了。”这是我带新人时最常听到的一句话。简易调试器本质上就是把「靠猜」换成「直接看」:让程序在指定位置停下来,把寄存器、内存、表达式的值直接显示出来。PA1 的第一部分,通常是让你在一个指令级模拟器上补齐这套能力——断点、单步、继续、寄存器与内存查看、表达式求值、监视点。它不走操作系统那套调试接口,而是自己维护执行循环,所以“停下来”这件事的每一步都看得见。适合已经写过 C、能看懂几行汇编、想搞明白调试器这个黑盒子里装了什么的从业者。照着走完,你手里应该有一个能天天用的最小版本,并且知道哪几个参数一动就容易翻车。

2. 把调试器拆成三块:执行控制、状态观测、表达式求值

刚接触这个题目的人容易一上来就写命令解析,写到一半发现后面全是窟窿。我更推荐先按职责切三块:执行控制决定“什么时候停”,状态观测决定“停下来能看什么”,表达式求值决定“能不能用一句人话描述你要看的东西”。这三块之间只有窄接口:执行控制只需要问断点表“这个 PC 该不该停”,状态观测只暴露“按编号读寄存器”“按地址读 N 字节”,表达式求值只依赖这两个读取原语。接口定死之后,三块可以并行推进,哪块出问题也好定位。

先说清楚为什么要在模拟器里做这件事。真实机器上,调试器要靠内核把被调试进程停下来,中间隔着一层你控制不了的黑盒;在指令级模拟器里,每条指令执行前后都是你的代码,没有信号、没有上下文切换,“停”就是循环里的一个break。这既是这个任务好上手的原因,也是它容易被想复杂的原因——很多人把真实调试器的复杂度照搬过来,结果卡在自己给自己挖的坑里。

2.1 执行控制:断点为什么优先用 PC 比较,而不是改写指令

断点有三种常见实现,选哪种直接决定后面一半代码的形态。

实现方式原理改造成本侵入性适合场景
改写指令把目标地址首字节替换成陷入指令,命中后再改回来高,要处理保存/恢复、多断点重叠会改内存,读内存时看到的是假指令真实进程调试
PC 比较执行前拿当前 PC 去断点表里查低,一个数组加一次遍历完全无侵入指令级模拟器、解释执行
硬件断点寄存器由硬件比较器触发依赖目标平台数量极少嵌入式在线调试

模拟器场景下基本只有一个答案:PC 比较。原因不复杂,模拟器的执行循环本来就是“取指、译码、执行、更新 PC”四步,你只需要在取指之前插一次查表。改指令那套方案在这里是纯粹给自己找麻烦:你的内存读取命令还得判断“这行看到的是不是被替换过的字节”,多出来的分支全是 bug 温床。

PC 比较唯一需要想清楚的是检查时机。放在指令执行前,断点语义是“到这儿就停,这条还没执行”;放在执行后,语义变成“这条执行完再停”,两次单步的观感会完全不一样。绝大多数人期望的是前者,因为你在源码里下断点,想看的就是“进这个函数之前寄存器长什么样”。

单步则是断点的退化情形:把 n 设成 1,跑一条就返回。继续(continue)是把 n 设成无穷大,跑到命中断点、非法指令或程序结束为止。三个命令共用同一个执行函数,只是参数不同,这样行为一致性最好保证。

2.2 状态观测:寄存器与内存的读取路径要先立规矩

状态观测这块最容易写得随意,然后在整个项目里被反复返工。我的习惯是先定两条规矩。

第一条:寄存器一律通过编号访问,不要到处写cpu.eax。模拟器后端换成别的指令集时,只要改一张编号到字段的映射表,表达式求值、info r、监视点全都不用动。具体做法是一个reg_read(int idx)和一个reg_write(int idx, uint32_t val),上层只认编号。

第二条:内存读取必须带宽度和边界检查。读 1、2、4、8 字节是四种不同的行为,尤其是跨页、跨对齐边界的时候,很多错误实现会悄悄读到相邻数据。边界检查不是可选项——模拟器里越界读不会崩,它会返回一段垃圾值让你以为程序逻辑错了,这种错误最难查。

这两条规矩看起来是洁癖,实际上是给后面省时间。表达式求值要读寄存器,监视点要反复求值,info r要一次打印全部——如果每次都是直接摸结构体字段,等你发现“监视点读到的是上一条指令的旧值”时,改起来就是全项目搜索替换。

2.3 表达式求值与监视点:为什么手写递归下降比引库划算

p $pc + 4、p $sp - 8、p *(int *)0x1000这类命令,是这个任务里唯一带点“语言”味道的部分。很多人第一反应是找个现成的表达式求值库,教学环境里通常走不通,原因有两个:库不认识你的寄存器,也不认识你的内存模型,你得写一大堆回调把它缝进来;而且很多库的求值结果是浮点或long long,跟 32 位无符号的语义对不上,符号扩展的坑会一路传下去。

手写一个递归下降求值器,支持十进制/十六进制整数、寄存器名、+ - * /、括号、一元负号,一百五十行左右就够了。文法是分层的:

expr -> term (('+' | '-') term)* term -> unary (('*' | '/') unary)* unary -> '-' unary | primary primary -> NUM | REG | '(' expr ')'

每层只处理自己那一档优先级,primary负责最底层的原子,这样加一个新运算符(比如位运算、比较)只需要在对应层插一个分支,不用重排整个解析器。这个结构还有一个好处:报错位置很准,解析到哪一层失败,你就知道是缺右括号还是运算符打错了。

监视点是表达式的下游消费者。它的数据结构非常朴素:一条表达式字符串、上一次求出来的值、一个“是否已经求过值”的标记。每次程序停下来,把每条启用的监视点重新求值,跟上次的值比,不一样就打印出来,同时更新缓存。这里有个容易忽略的点:第一次求值只记录、不触发,否则你刚下监视点就会收到一条“变化”通知,纯属骚扰。

3. 最小可跑骨架:断点池、单步循环和命令分发

原理清楚了,落到代码上我更倾向于先写一个“一条命令能跑通”的最小闭环,再往里加功能。很多人反过来做,先把命令解析写全,结果每条命令都是空壳,调起来互相干扰。下面这套骨架跑通之后,断点、单步、继续三条路就都通了。

3.1 断点池:定长数组够不够用

先定接口。对外只暴露加、删、查、列四个操作,内部用什么容器是自由选择。

/* dbg.h —— 调试器对外暴露的最小接口 */ #ifndef DBG_H #define DBG_H #include <stdint.h> #include <stdbool.h> #define MAX_BREAKPOINTS 32 #define MAX_WATCHPOINTS 16 #define EXPR_MAX_LEN 128 typedef struct { uint32_t addr; /* 断点地址,必须落在指令起始位置 */ bool enabled; /* 允许临时禁用而不用删除,方便调试调试器 */ int hit_count; /* 命中次数,排查“为什么没停”时非常有用 */ } breakpoint_t; bool bp_add(uint32_t addr); bool bp_del(uint32_t addr); bool bp_hit(uint32_t pc); void bp_list(void); #endif

对应的实现:

/* dbg_bp.c —— 断点池的实现 */ #include "dbg.h" #include <stdio.h> static breakpoint_t g_bp[MAX_BREAKPOINTS]; static int g_bp_num = 0; bool bp_add(uint32_t addr) { if (g_bp_num >= MAX_BREAKPOINTS) { printf("breakpoint pool full (%d)\n", MAX_BREAKPOINTS); return false; } for (int i = 0; i < g_bp_num; i++) { if (g_bp[i].addr == addr) return true; /* 重复下断点视为成功 */ } g_bp[g_bp_num].addr = addr; g_bp[g_bp_num].enabled = true; g_bp[g_bp_num].hit_count = 0; g_bp_num++; return true; } bool bp_hit(uint32_t pc) { for (int i = 0; i < g_bp_num; i++) { if (g_bp[i].enabled && g_bp[i].addr == pc) { g_bp[i].hit_count++; return true; } } return false; }

逻辑说明:用定长数组而不是链表或哈希表,是因为断点总数天然很少,同时下十个断点已经算重度使用,线性扫描的开销在执行循环里完全看不出来。重复插入返回成功而不是报错,是为了让批处理脚本可以无脑重放命令序列,不用先删再下。

参数说明:MAX_BREAKPOINTS取 32 是给常见用量留三倍余量;addr必须是指令起始地址,这条约束在第 5 章会变成一个具体的坑。hit_count看起来是冗余字段,排查“到底有没有命中”的时候能省掉大量猜测。

3.2 单步的边界:一条指令的执行范围怎么切

执行循环是整个调试器的发动机,写错一个位置后面全乱。

/* cpu_exec.c —— 执行至多 n 条指令,返回实际执行的条数 */ int cpu_exec(uint32_t n) { uint32_t executed = 0; while (executed < n) { uint32_t pc = cpu.pc; if (bp_hit(pc)) { /* 检查必须在执行之前 */ printf("Breakpoint hit at 0x%08x\n", pc); break; } if (!isa_exec_once()) { /* 取指、译码、执行、更新 pc */ printf("Invalid instruction at 0x%08x\n", pc); break; } executed++; if (check_watchpoints()) { /* 监视点每条指令后都查 */ printf("Watchpoint triggered\n"); break; } } return executed; }

逻辑说明:isa_exec_once()承担一条指令的全部工作,包括最后把cpu.pc推进到下一条指令。执行函数本身不碰 PC,这是刻意的——只要有一处代码绕过这个函数直接改 PC,断点行为就会变得没法解释。

参数说明:n是本次最多执行多少条。continue传一个极大值,si传 1,si 10传 10。返回实际执行条数主要是给上层判断“是不是被断点中断了”,比如 continue 返回 0 就说明当前 PC 上就有断点。

3.3 命令分发:为什么 p 命令不能走 strtok

命令循环看似琐碎,但它是唯一一个人机交互面,错误信息写得好,后期能少查一半问题。

/* cmd.c —— 命令主循环与分发 */ static int cmd_dispatch(char *line) { char *save = NULL; char *cmd = strtok_r(line, " \t\n", &save); if (cmd == NULL) return 0; if (strcmp(cmd, "c") == 0) return cmd_continue(); if (strcmp(cmd, "si") == 0) return cmd_step(atoi_or(save, 1)); if (strcmp(cmd, "b") == 0) return cmd_break(save); if (strcmp(cmd, "d") == 0) return cmd_delete(save); if (strcmp(cmd, "info") == 0) return cmd_info(save); if (strcmp(cmd, "p") == 0) return cmd_print(save); /* 吃整行剩余部分 */ if (strcmp(cmd, "w") == 0) return cmd_watch(save); if (strcmp(cmd, "q") == 0) return -1; printf("Unknown command: %s\n", cmd); return 0; } int cmd_loop(void) { char line[256]; while (1) { printf("(dbg) "); fflush(stdout); if (fgets(line, sizeof(line), stdin) == NULL) break; if (cmd_dispatch(line) < 0) break; } return 0; }

逻辑说明:strtok_r会就地写\0,所以第一个 token 之后的save指针指向剩余部分,表达式求值这类“整行都要”的命令直接把save传下去,不用再拼一次。fflush(stdout)是为了在管道喂命令时不出现提示符和输出错位。

参数说明:line缓冲 256 字节,够放一条完整的表达式;atoi_or是个小辅助函数,参数缺省时返回默认值 1,让si和si 5都能用。退出用返回码 -1 而不是exit(),方便以后嵌入别的宿主程序。

4. 监视点和表达式求值:让调试器盯住一个值

断点解决“停在哪”,监视点解决“为什么变”。这两个能力叠起来,才算是一个能查 bug 的调试器。

4.1 词法层:把一行字符串切成 token

词法分析器要做的事非常有限:跳过空白、认出数字(支持0x前缀)、认出寄存器名、认出单字符运算符。

/* expr.h */ #include <stdint.h> #include <stdbool.h> typedef enum { TK_NUM, TK_REG, TK_OP, TK_END, TK_ERR } token_kind_t; typedef struct { token_kind_t kind; uint32_t num; /* TK_NUM 时的值 */ int reg; /* TK_REG 时的寄存器编号 */ char op; /* TK_OP 时的运算符 */ } token_t; uint32_t expr_eval(const char *s, bool *ok);

这里有一个设计选择值得说:寄存器名直接映射成编号,而不是保留字符串。求值阶段只认编号,字符串到编号的解析全部集中在词法层。这么做的好处是错误处理集中——不认识的寄存器名在词法阶段就能报错,不用等到求值。

字面量的解析用strtoul(s, &end, 0),第三个参数给 0 表示自动识别十进制、八进制、十六进制前缀,省掉手写分支。但它对负数不友好,-0x10会被切成两个 token,靠后面的 unary 层处理负号,这样语义反而更清晰。

4.2 递归下降:三层文法与求值合并

解析和求值可以合在一起做,不需要先建语法树再遍历。表达式求值没有副作用(除了读寄存器),边走边算是安全的。

/* expr.c —— 词法 + 递归下降求值 */ #include <ctype.h> #include <stdlib.h> #include "expr.h" #include "reg.h" static const char *g_p; /* 当前扫描位置 */ static token_t g_tok; /* 当前 token */ static void next_token(void) { while (*g_p == ' ' || *g_p == '\t') g_p++; if (*g_p == '\0') { g_tok.kind = TK_END; return; } if (isdigit((unsigned char)*g_p)) { char *end; g_tok.kind = TK_NUM; g_tok.num = (uint32_t)strtoul(g_p, &end, 0); g_p = end; return; } if (isalpha((unsigned char)*g_p)) { g_tok.kind = TK_REG; g_tok.reg = reg_lookup(*g_p); g_p++; return; } g_tok.kind = TK_OP; g_tok.op = *g_p++; } static uint32_t parse_expr(void); static uint32_t parse_primary(void) { if (g_tok.kind == TK_NUM) { uint32_t v = g_tok.num; next_token(); return v; } if (g_tok.kind == TK_REG) { uint32_t v = reg_read(g_tok.reg); next_token(); return v; } if (g_tok.kind == TK_OP && g_tok.op == '(') { next_token(); uint32_t v = parse_expr(); if (!(g_tok.kind == TK_OP && g_tok.op == ')')) { g_tok.kind = TK_ERR; return 0; } next_token(); return v; } g_tok.kind = TK_ERR; return 0; } static uint32_t parse_unary(void) { if (g_tok.kind == TK_OP && g_tok.op == '-') { next_token(); return (uint32_t)(-(int32_t)parse_unary()); /* 补码取负 */ } return parse_primary(); } static uint32_t parse_term(void) { uint32_t v = parse_unary(); while (g_tok.kind == TK_OP && (g_tok.op == '*' || g_tok.op == '/')) { char op = g_tok.op; next_token(); uint32_t rhs = parse_unary(); if (op == '*') v = v * rhs; else if (rhs == 0) { g_tok.kind = TK_ERR; return 0; } else v = v / rhs; } return v; } static uint32_t parse_expr(void) { uint32_t v = parse_term(); while (g_tok.kind == TK_OP && (g_tok.op == '+' || g_tok.op == '-')) { char op = g_tok.op; next_token(); uint32_t rhs = parse_term(); v = (op == '+') ? v + rhs : v - rhs; } return v; } uint32_t expr_eval(const char *s, bool *ok) { g_p = s; next_token(); uint32_t v = parse_expr(); *ok = (g_tok.kind == TK_END); /* 必须恰好消费完整个串 */ return v; }

逻辑说明:三个解析函数各自负责一档优先级,parse_expr收左结合加减,parse_term收乘除,parse_unary收一元负号,parse_primary落到底层。除零直接置TK_ERR而不是返回 0 加标志位,因为错误状态统一挂在 token 上,上层只需要看一个地方。

参数说明:最后的*ok判断是整个函数最容易被省略、也最容易出事的一行。少了它,1+2abc会返回 3 并显示成功,用户完全不知道后面那段被无视了。要求 token 流恰好走到TK_END,才能保证“整行被完整解释”。

4.3 监视点池与检查时机

监视点的实现就是把表达式求值包一层缓存和比较。

/* watch.c */ typedef struct { char expr[EXPR_MAX_LEN]; uint32_t last; bool enabled; bool valid; /* 是否已经完成过首次求值 */ } watchpoint_t; static watchpoint_t g_wp[MAX_WATCHPOINTS]; static int g_wp_num = 0; bool check_watchpoints(void) { bool triggered = false; for (int i = 0; i < g_wp_num; i++) { if (!g_wp[i].enabled) continue; bool ok = false; uint32_t now = expr_eval(g_wp[i].expr, &ok); if (!ok) continue; /* 表达式失效就跳过,不误报 */ if (!g_wp[i].valid) { /* 首次只记录,不触发 */ g_wp[i].last = now; g_wp[i].valid = true; continue; } if (now != g_wp[i].last) { printf("Watchpoint %d: %s 0x%08x -> 0x%08x\n", i, g_wp[i].expr, g_wp[i].last, now); g_wp[i].last = now; triggered = true; } } return triggered; }

逻辑说明:valid字段存在的唯一理由是压掉首次误报。检查放在每条指令执行之后,而不是只在断点命中时检查——这是监视点能不能用的分水岭,只在下断点那一刻比较,等于只能看到两个采样点,中间怎么变的完全丢失。

参数说明:MAX_WATCHPOINTS比断点池小是合理的,监视点每次停下都要重新求值,数量多了会明显拖慢执行循环。表达式串长度上限 128 字节,够写$pc + 4这类常见形式。

5. 排查清单:五个让我停下来想半天的坑

调试器最难受的地方在于它是“用来查 bug 的工具”,它自己有 bug 的时候,你连个能用的工具都没有。下面五条是这类项目里出现频率最高的,基本按“现象 → 原因 → 解决”的顺序记。

5.1 断点明明下了,程序一路跑到底

现象:b 0x1000提示成功,c之后直接跑到结束,没有任何命中输出。

原因:断点地址不在指令起始位置。模拟器按指令边界推进 PC,如果实际指令从0x1004开始,PC 永远不会等于0x1000,比较自然不成立。另一种原因是断点检查放到了执行之后,而那条指令恰好是跳转,PC 早飞走了。

解决:加一个地址对齐校验,插入断点时按指令长度向下取整,或者干脆拒绝非对齐地址并提示。检查时机统一放到isa_exec_once()之前,同时在bp_hit里把hit_count打出来,能立刻区分“没命中”和“命中了但没打印”。

5.2 单步之后 PC 没变,再单步还是原地

现象:si执行一次,info r里 PC 跟之前一样,连着敲几次都不动。

原因:两种可能。一种是遇到自跳转指令(jmp .这类),PC 确实回到了原值,行为是对的,只是看起来像卡住。另一种是非法指令路径没有推进 PC,isa_exec_once返回失败但没更新任何状态,循环又从头开始,形成假死。

解决:先在执行循环里打印每条指令的 PC 和长度,看是不是长度算成 0 了。指令长度计算要跟译码严格对应,别在不同地方各算一遍。对自跳转这种合法情况,可以考虑加一个“同一 PC 连续执行超过 N 次就提示”的软保护,避免手滑把死循环当成调试器坏了。

5.3 监视点挂上去,从头到尾一次都不触发

现象:w $pc下好之后继续执行,PC 明显在变,但没有任何输出。

原因:检查点放错了位置。很多实现只在下断点命中时调一次check_watchpoints,而这条路径上根本没有断点,函数压根没被调用过。第二种原因是监视点的valid一直没被置位,每次都走“首次记录”分支,永远不比较。

解决:把检查固定挂在指令执行之后,跟断点检查分开,两个位置各自独立。再加一条info w命令打印每条监视点的当前缓存值和valid状态,一眼就能看出是没求值还是没比较。

5.4 表达式结果差了一大截,像是符号位被吃掉了

现象:p $sp - 8返回一个接近 4G 的数,而不是期望的栈顶减八。

原因:32 位无符号和有符号混用。减法在无符号域里做,结果回绕到很大;或者中间某处用了int承接uint32_t,高位被截断后符号扩展,值变得面目全非。

解决:全链路统一成uint32_t,需要取负的时候显式转成int32_t再转回来,别让编译器替你决定。打印统一用%08x十六进制,十进制显示只是锦上添花,判断数值对不对时十六进制永远不会骗人。

5.5 p 命令只认第一个词,后面的表达式被吞了

现象:p $pc + 4报语法错误,单独敲p $pc又正常。

原因:命令分发用了strtok,它把空格全换成了\0,表达式在第一个空格处就断了。这类命令需要的是“第一个 token 之后的原始剩余串”,不是下一个 token。

解决:用strtok_r拿到第一个 token 之后,把save指针直接传给cmd_print,不要再用strtok(NULL, ...)去取。同时给cmd_print加一条“参数为空就报用法”的检查,比一句笼统的语法错误有用得多。

6. 让它真正用起来:批处理、日志和自测

一个只能手敲命令的调试器,用两天就会开始怀疑人生——每改一次代码,你要重新敲一遍下断点、继续、看寄存器。我的习惯是第一天就把批处理通道留出来:fgets读的是stdin,只要支持从管道喂命令,就能写脚本。真正的价值在于回归测试:调试器本身的 bug 往往是“某条命令在某种状态下行为不对”,而这种组合靠手敲几乎不可能覆盖全。

下面这个脚本把命令序列喂进调试器,比对关键输出,跑在每次改完代码之后:

# run_test.py —— 把命令序列喂给调试器,比对关键输出 import subprocess, sys CASES = [ # (命令序列, 输出里必须出现的关键片段) (["b 0x1000", "c", "info r", "q"], ["Breakpoint hit at 0x00001000"]), (["p $pc + 4", "q"], ["0x"]), (["w $pc", "si", "q"], ["Watchpoint 0"]), ] def run(cmds): p = subprocess.run(["./dbg"], input="\n".join(cmds) + "\n", text=True, capture_output=True, timeout=10) return p.stdout fail = 0 for cmds, expects in CASES: out = run(cmds) for e in expects: if e not in out: print("FAIL", cmds, "missing", e) fail += 1 print("done, failures =", fail) sys.exit(1 if fail else 0)

参数说明:timeout=10不是可选项,调试器一旦进入死循环,没有超时的测试脚本会一直挂着;capture_output=True保证输出不进终端,方便在流水线里跑;text=True省掉字节串解码。断言只查“关键片段是否出现”,不比对整段输出——提示符格式、寄存器打印顺序这类东西会频繁变,全量比对会把测试变成维护负担。

三个用例分别覆盖了断点命中、表达式求值、监视点触发三条主路径,改写执行循环之后先跑一遍,能挡住绝大多数“改一处坏三处”的回归。

说个我自己的教训:这个项目我前两次都栽在同一个地方——急着加功能,跳过“先用脚本把现有行为钉住”这一步。第三次改监视点时,因为顺手调了执行循环里检查的顺序,断点行为跟着变了,而我花了整整一个下午才意识到问题不在监视点。从那以后我的顺序固定成三步:先写最小骨架跑通一条命令,再把这条命令的行为写进测试,最后才动下一条命令。调试器是查别人的工具,它自己的行为必须先被钉死。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询