☰
PA2(上)调试器实现:ptrace断点、单步与寄存器内存
2026/10/27 14:32:26 网站建设 项目流程

简介:《指令系统实现详解》是一份面向计算机体系结构课程学习者的PA2(上)实验配套PDF,聚焦指令系统从取指、译码到执行的完整实现链路,适合正在完成相关实验、需要攻克框架代码阅读与基本指令编写的中级学习者参考。文档围绕指令集设计原则、寄存器结构与初始化、译码模板选择、算术与跳转类指令的细节差异展开,并延伸至打印变量、打印栈帧等调试环节,对实验中的易错点与设计取舍有较完整的梳理。压缩包内仅含1个PDF文件,约1.46MB,单文件便于通读与检索。目前已有1045人学习下载,可作为实验前预习、实现时对照与卡点排查的案头材料,帮助读者理解框架组织方式、建立从指令编码到执行语义的映射,并逐步形成自己的实现与排错思路。

1. PA2(上) 的门槛不在写代码,在"让程序停在你想停的地方"

PA2(上) 这类带编号后缀的题目,编号像 173071301781 只是个仓库或提交标识,不影响技术内容;真正决定你熬几个通宵的,是它要求你把"观察一个正在跑的进程"这件事做出来。上半部分最常见的落点是:写一个几十条命令以内的小调试器,能加载目标程序、下断点、单步、看寄存器、读内存。很多人以为难点在 ptrace 的调用格式,其实调用格式照着手册抄就对了,翻车基本都翻在"停下来的那一刻,CPU 到底站在哪条指令上"。断点、单步、地址回退这套东西值得认真写一遍,因为它是所有动态分析工具的第一块地基,跟做不做这道题无关。

2. 拆骨架:一个能验收的调试器前端由哪四层组成

把 PA2(上) 当成一个完整软件来拆,比按命令一条条写要省事得多。常见做法是分成四层:输入层负责把一行文本变成"命令 + 参数";进程层负责 fork、exec、ptrace 和被跟踪进程的停止/继续;能力层是断点、单步、寄存器读写、内存读写这几件事;状态层维护一个"当前是否处于停止态"的标志和一张断点表。四层里最容易写歪的是输入层和状态层,因为这两层出错时往往不报错,只是行为变得诡异。

我一般先把输入层和进程层跑通,也就是能启动目标程序、能继续、能退出,再往上加能力层。这样任何一次翻车都能定位到是"进程没停住"还是"我的命令没被解析对",而不是面对一个 800 行的文件猜。

2.1 命令解析为什么不能用 scanf("%s") 硬啃

新手最容易写出的解析是这样的:scanf("%s", buf)读命令,需要地址时再scanf("%s", buf2)。这段代码在小样本上能跑,但有两个隐蔽问题。一是%s不消费行尾换行符,下一次读会先读到一个空行,命令序列就整体错位一格;二是没有宽度限制,输入一长就溢出,而这道题的命令往往是从脚本里喂进去的,出问题时你根本看不到是解析错了还是逻辑错了。

我的做法统一是fgets读整行,再用sscanf带宽度限制拆字段,返回值必须判断——返回值是成功匹配的字段数,用它区分"只有命令"和"命令带参数"两件事。

// parse.c —— 一行输入最多拆成「命令 + 一个参数」,够 PA2(上) 的绝大多数命令用 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> /* "0x401126" 和 "401126" 都按 16 进制解析;失败返回 -1,调用方看返回值即可 */ static int parse_addr(const char *s, unsigned long *out) { if (!s || !*s) return -1; errno = 0; char *end = NULL; unsigned long v = strtoul(s, &end, 16); /* base=16 时 0x 前缀会被自动吃掉 */ if (errno != 0 || end == s || *end != '\0') return -1; *out = v; return 0; } int main(void) { char line[256]; while (fgets(line, sizeof line, stdin)) { /* 一次读整行,不留残渣 */ if (line[0] == '\n' || line[0] == '#') continue; char cmd[16] = {0}, arg[64] = {0}; int n = sscanf(line, "%15s %63s", cmd, arg); /* 宽度必须写死,别用裸 %s */ if (n <= 0) continue; if (!strcmp(cmd, "b") && n == 2) { unsigned long a; if (parse_addr(arg, &a) == 0) printf("break at 0x%lx\n", a); else printf("bad address: %s\n", arg); } else if (!strcmp(cmd, "q")) { break; } else { printf("unknown or bad args: %s\n", cmd); /* n==1 时打 b 也走这里 */ } } return 0; }

逻辑说明:fgets保证每次拿到一整行,行尾的\n留在缓冲区里但不影响后续sscanf,因为%s会跳过前导空白。sscanf的返回值n是关键——b后面没跟地址时n等于 1,这时候要报错而不是复用上一轮arg里的旧值,很多"断点打到了上一次的地址"就是这么来的。parse_addr里三个判断缺一不可:errno判溢出,end == s判一个字符都没吃到,*end != '\0'判输入像0x401126xyz这种半截合法的情况。

参数说明:line[256]是单行上限,配合%15s与cmd[16](15 个字符加结尾)、%63s与arg[64]一一对应。strtoul的第三个参数写 16,表示无论有没有0x前缀都按十六进制读,避免用户写401126时被当成十进制。

有人喜欢用strtok切分,我一般不用。strtok会就地改写原字符串,连续分隔符会被合并,而且拿不到"参数是不是本来就没有"的信息——恰恰是这道题里最需要区分的那一点。宁可多写十行sscanf,也不要在这里赌。

2.2 fork + exec + ptrace 的最小闭环

进程层是整个 PA2(上) 的地基,写对了后面全是填空题。核心只有一句:子进程在 exec 之前声明自己被跟踪,父进程等第一次 stop,然后才拥有对这个进程发号施令的资格。顺序反了,父进程的 ptrace 请求会直接返回ESRCH,看起来像"断点打不上",其实是根本没建立起跟踪关系。

// launch.c —— 父进程拿到的第一份 status 来自 exec 产生的 SIGTRAP #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/ptrace.h> #include <sys/wait.h> #include <sys/types.h> static pid_t g_pid = -1; static int g_stopped = 0; static int launch(char **argv) { pid_t pid = fork(); if (pid < 0) return -1; if (pid == 0) { /* 子进程:先声明被跟踪,再 exec。这两行的顺序不能换 */ if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) _exit(127); execvp(argv[0], argv); _exit(127); /* 只有 exec 失败才会走到这里 */ } int status = 0; if (waitpid(pid, &status, 0) < 0) return -1; /* exec 成功 -> 收到 SIGTRAP */ if (!WIFSTOPPED(status)) return -1; /* 没停住说明启动阶段就挂了 */ g_pid = pid; g_stopped = 1; return 0; }

逻辑说明:PTRACE_TRACEME让当前进程成为"被跟踪者",此后它收到的信号会先交给父进程处理。execvp成功时内核投递一次SIGTRAP,父进程的waitpid因此返回且WIFSTOPPED为真——这一刻子进程已经加载完新映像,但一条用户指令都还没执行,正好是下断点的最佳时机。execvp失败才走_exit(127),不要在这里return,否则子进程会跑回父进程的主循环,出现两份输出。

参数说明:waitpid第三个参数给 0 表示阻塞等待;status必须用WIFSTOPPED/WIFEXITED/WEXITSTATUS这组宏解读,直接拿status和0比较是血泪经验级的错误。launch的入参是argv + 1,也就是从目标程序名开始。

g_stopped这个标志看着多余,实际是状态层的核心。所有 ptrace 请求——PEEKTEXT、POKETEXT、GETREGS、SETREGS、CONT——只有在被跟踪进程处于停止态时才合法。我一般让每个 handler 入口都检查它,违规就打印一行提示直接返回,而不是让系统调用自己报错。系统调用报错信息是"没权限"或"进程不存在",跟真实原因差着十万八千里。

2.3 表驱动的命令分发与"停止态"约定

命令一旦超过五六条,if-else链会开始咬人:加一条命令要改两处,删一条命令容易留下死分支。换成表驱动之后,加命令只是往数组里加一行,分发逻辑永远不动。这不是过度设计,是为了让你在调试最紧张的时候能安全地改代码。

// table.c —— 命令表:加命令只改数组,分发逻辑保持不变 typedef struct { const char *name; int need_arg; /* 1 表示必须带一个地址参数 */ void (*fn)(unsigned long arg); } cmd_t; static void do_continue(unsigned long _) { (void)_; resume(0); } static void do_step(unsigned long _) { (void)_; resume(1); } static void do_break(unsigned long a) { bp_add(a); } static void do_regs(unsigned long _) { (void)_; print_regs(); } static const cmd_t g_cmds[] = { { "c", 0, do_continue }, { "s", 0, do_step }, { "b", 1, do_break }, { "r", 0, do_regs }, }; static const int g_ncmd = sizeof(g_cmds) / sizeof(g_cmds[0]); static void dispatch(const char *name, int has_arg, unsigned long arg) { if (!g_stopped) { printf("target is running, wait\n"); return; } for (int i = 0; i < g_ncmd; i++) { if (strcmp(g_cmds[i].name, name) != 0) continue; if (g_cmds[i].need_arg != has_arg) { /* 参数个数必须严格一致 */ printf("%s expects %d arg\n", name, g_cmds[i].need_arg); return; } g_cmds[i].fn(arg); return; } printf("unknown command: %s\n", name); }

逻辑说明:dispatch先查停止态,再查名字,再查参数个数,最后才调用。参数个数检查放在调用前,是为了让"b后面忘了写地址"这种低级输入立刻被挡住,而不是让bp_add(0)把一个断点打到零地址上——零地址在目标进程里通常是未映射页,PEEKTEXT会返回-1,然后你会在几百行之外看到一次莫名其妙的失败。

参数说明:need_arg是硬约束而不是建议;fn统一签名收一个unsigned long,没有参数的用(void)_显式忽略,避免-Wunused-parameter刷屏。g_ncmd用sizeof算,别写死数字。

状态约定还有一条收尾规则:退出前必须把子进程处理掉。我一般kill(g_pid, SIGKILL)之后再waitpid一次,否则调试器自己退出了、目标进程还在后台挂着,下一次运行会看到一堆残留进程,误以为是断点逻辑出了问题。这一步在自动化脚本里尤其重要。

3. 断点:把 0xCC 写进代码段的完整链路

断点是 PA2(上) 的第一次真正考验,因为它同时踩在三个领域上:x86 指令编码、进程内存改写、CPU 状态回退。这三样任何一样没对齐,表现出来的症状都差不多——"程序跑飞了"。所以别急着写b命令,先把"一次加断点到底改了什么"想清楚。

在 x86 上,调试断点用的是单字节的int3,机器码0xCC。之所以不用两字节的int 0x03,是因为两字节写法会覆盖断点地址后面那条指令的第一个字节,等你还原时就得同时记两个字节的位置,出错面翻倍。单字节方案只动一个字节,剩下的七个字节原封不动,这是它最大的工程价值。

3.1 一次加断点涉及的三次 ptrace 调用

加一个断点的完整动作是:读回原字、改最低字节、写回内存。三次调用,缺一不可,而且读回来的那个字节必须存下来——它不是"调试信息",是后面恢复执行的唯一凭据。丢了它,程序执行完这条指令之后就会撞上一条被人为篡改过的指令,表现出来是随机崩溃。

// bp.c —— 加断点:读原字、改低字节、写回,三步都成功才算加上 typedef struct { unsigned long addr; /* 断点所在的线性地址 */ unsigned char saved; /* 被替换掉的那个字节,恢复时要用 */ int used; } bp_t; static bp_t g_bp[64]; static int g_nbp = 0; static int bp_add(unsigned long addr) { for (int i = 0; i < g_nbp; i++) /* 重复设置直接拒绝,不覆盖 */ if (g_bp[i].addr == addr) return -1; if (g_nbp >= 64) return -1; errno = 0; /* -1 可能是数据,也可能是错误 */ long word = ptrace(PTRACE_PEEKTEXT, g_pid, (void *)addr, NULL); if (word == -1 && errno != 0) return -1; /* 未映射页会走这里,errno=EIO */ unsigned char orig = (unsigned char)(word & 0xff); /* 小端:低字节在低地址 */ long patched = (word & ~0xffL) | 0xcc; /* 只动最低字节 */ if (ptrace(PTRACE_POKETEXT, g_pid, (void *)addr, (void *)patched) == -1) return -1; g_bp[g_nbp].addr = addr; g_bp[g_nbp].saved = orig; g_bp[g_nbp].used = 1; g_nbp++; return 0; }

逻辑说明:PTRACE_PEEKTEXT一次读一个机器字(64 位平台是 8 字节),返回类型是long。这里有个必须前置的细节:errno要在调用前清零,因为-1既可能是读到0xffffffffffffffff这样一个合法数据,也可能是真出错,唯一区分手段就是看errno有没有被置位。改成0xCC用的是位运算而不是直接赋0xCC,因为一个机器字里装着 8 个字节,直接赋值会把后面 7 个字节全清成 0,那才是真正的灾难。

参数说明:addr是线性地址,-no-pie编译时它就是链接地址;saved存的是原字节而不是原字,因为我们只改了低字节。g_bp用定长数组,64 个断点对上半部分足够,且省掉了动态内存管理这一整块风险——排查问题时少一个变量就是省半小时。

那段"小端:低字节在低地址"的注释不是废话。PTRACE_PEEKTEXT返回的long在内存里的排布,和从addr开始往上读 8 个字节是一致的,小端机上最低地址那一字节就是long的最低位。如果换到大端平台(或者你自己在脑子里把这段逻辑搬到别的架构),这个假设就不成立了,必须改成按字节拼装。写代码的时候顺手标一句,半年后回来看能救命。

3.2 命中后为什么必须把 rip 减一

这是 PA2(上) 上半部分最经典的一道坎,也是调试器新手最容易跳过的一步。int3是 1 字节指令,CPU 执行它的时候,rip已经指向了下一条指令,所以断点停下来时rip = addr + 1,不是addr。如果直接从这里继续,程序会从一条指令的第二个字节开始解码,运气好是合法但错位的指令,运气不好就是非法指令或者SIGSEGV。

更麻烦的是"继续"这件事本身需要四步配合:先把rip拨回addr,再把原字节还原回去,然后单步执行一条,最后把0xCC重新写回。顺序错一步就全错。

// resume.c —— 继续执行,并在断点命中时完成「回退-还原-单步-重打」 static bp_t *bp_find(unsigned long addr) { for (int i = 0; i < g_nbp; i++) if (g_bp[i].used && g_bp[i].addr == addr) return &g_bp[i]; return NULL; } static void bp_patch(unsigned long addr, unsigned char byte) { errno = 0; long word = ptrace(PTRACE_PEEKTEXT, g_pid, (void *)addr, NULL); if (word == -1 && errno != 0) return; word = (word & ~0xffL) | byte; /* 仍然只改最低字节 */ ptrace(PTRACE_POKETEXT, g_pid, (void *)addr, (void *)word); } static void resume(int single_step) { if (ptrace(single_step ? PTRACE_SINGLESTEP : PTRACE_CONT, g_pid, NULL, NULL) == -1) return; g_stopped = 0; int status = 0; if (waitpid(g_pid, &status, 0) < 0) return; if (!WIFSTOPPED(status)) { g_stopped = 1; printf("target exited\n"); return; } g_stopped = 1; struct user_regs_struct regs; ptrace(PTRACE_GETREGS, g_pid, NULL, &regs); if (single_step) { /* 用户主动单步:rip 就是真地址 */ printf("stopped at 0x%llx (step)\n", regs.rip); return; } unsigned long hit = regs.rip - 1; /* 断点命中:回退一个字节 */ bp_t *bp = bp_find(hit); if (!bp) { /* 不是断点停,可能是信号停 */ printf("stopped at 0x%llx (signal)\n", regs.rip); return; } regs.rip = hit; ptrace(PTRACE_SETREGS, g_pid, NULL, &regs); /* 1. rip 拨回断点地址 */ bp_patch(hit, bp->saved); /* 2. 还原被替换的原字节 */ ptrace(PTRACE_SINGLESTEP, g_pid, NULL, NULL); /* 3. 真执行这一条 */ waitpid(g_pid, &status, 0); bp_patch(hit, 0xcc); /* 4. 重新打上,供下次命中 */ ptrace(PTRACE_GETREGS, g_pid, NULL, &regs); printf("stopped at 0x%lx (hit)\n", hit); }

逻辑说明:single_step参数决定了停止之后要不要做"减一"判定。用户敲s单步时,停下来的rip就是下一条要执行的指令地址,减一会算错;只有在PTRACE_CONT停下来时才需要判断是不是断点。第 2 步和第 3 步的顺序尤其不能换——先还原再单步,如果反过来,单步执行的还是0xCC,你会看到程序原地不动,还以为是SINGLESTEP失效了。第 3 步的单步必须用独立的waitpid等它结束,不能复用外层那个,否则第 4 步会在进程还在跑的时候改内存。

参数说明:PTRACE_CONT与PTRACE_SINGLESTEP的第三个参数都可以传一个信号值,传 0 表示不注入任何信号;PTRACE_GETREGS/PTRACE_SETREGS的第三个参数是struct user_regs_struct *,不是值。regs.rip类型是unsigned long long,打印用%llx,混用%lx在某些平台会打出截断值,看起来像地址算错了。

3.3 断点表:重复设置、地址对齐与上限

断点表虽然简单,但几个边界条件必须提前想清楚,否则后面排查会非常痛苦。

字段类型作用常见写错的地方
addrunsigned long断点所在线性地址存成rip而不是用户输入的地址
savedunsigned char被替换掉的原字节存成整个机器字,还原时覆盖 7 字节
usedint删除断点后置 0,避免压缩数组删除后直接g_nbp--,导致地址错位

重复设置同一个地址,我的处理是直接返回失败并打印提示,不做覆盖。理由很实际:覆盖意味着旧的saved丢失,而那个字节此刻已经被改成0xCC了,你再"保存"一次存下来的就是0xCC本身,之后还原时把0xCC写回去,程序永远停在原地。这种 bug 的表现是"断点删不掉",排查方向完全跑偏。

地址落在未映射页时,PTRACE_PEEKTEXT返回-1且errno被置成EIO。这个错误码要单独识别并打印成人话,比如"0x… not mapped",而不是让用户去看系统调用的原始错误。上半部分最常见的输入错误就是把函数名当地址输进去,有了这行提示,五秒钟就能反应过来。

上限 64 是拍脑袋定的,但对这道题绰绰有余。真要扩容,改成链表或者开放寻址都行,只是别忘了在扩容时保留saved字段——这是唯一不能丢的东西。

4. 单步、寄存器与内存:PA2(上) 里最容易漏掉的三块

断点做完之后,剩下三块内容看起来都是"照着文档抄",但它们恰恰是验收时最容易露馅的地方。单步的停止语义、寄存器的字段类型、内存读取的一次读多少字节,每一项都有一到两个不写清楚就会翻车的细节。这一章把这三块拆开讲,每一块都给出可以直接落地的写法。

4.1 单步执行与 PTRACE_SINGLESTEP 的停止语义

单步的本质是让 CPU 执行一条指令后立刻陷入。实现上有两条路:手动改EFLAGS的TF位(bit 8,掩码0x100),或者直接用PTRACE_SINGLESTEP。前者看起来更"底层",实际上要自己处理GETREGS/SETREGS两次调用、自己恢复标志位、还要保证目标进程的EFLAGS不被污染,出错面大得多。常见做法是直接用PTRACE_SINGLESTEP,让内核替你管这件事。

有一点必须记住:单步停止和断点停止投递的是同一个信号SIGTRAP,waitpid的status看上去一模一样。区分它们只能靠上下文——你是用PTRACE_CONT继续的还是用PTRACE_SINGLESTEP继续的。这就是为什么上一章的resume要带一个single_step参数,而不是在停止之后再去猜。我见过有人试图从status里读出区别,写了半天位运算,最后得到的结论是"读不出来"。

单步还有一个容易被忽略的场景:目标程序里本来就有SIGTRAP指令(比如某些语言的断点实现)。这时候单步停下来,rip指向的位置会让人误以为是自己的断点命中了。所以bp_find必须查表而不是凭地址猜,查不到就老老实实打印"signal stop",让使用者自己判断。

4.2 读寄存器:user_regs_struct 的字段与打印格式

寄存器读取本身只有一次调用,坑全在打印格式上。struct user_regs_struct里所有字段都是unsigned long long,在 64 位平台上用%llx打;写成%lx在部分平台上只会打印低 32 位,你会看到一个像0x401126这样"看起来正常"的rip,实际高 32 位被截掉了——静态链接的程序里这没什么问题,一旦地址超过 4G 就开始出现诡异行为。

// regs.c —— 打印常用寄存器,格式统一用 %llx static void print_regs(void) { struct user_regs_struct r; if (ptrace(PTRACE_GETREGS, g_pid, NULL, &r) == -1) { printf("getregs failed\n"); return; } printf("rip=0x%llx rsp=0x%llx rbp=0x%llx\n", r.rip, r.rsp, r.rbp); printf("rax=0x%llx rbx=0x%llx rcx=0x%llx rdx=0x%llx\n", r.rax, r.rbx, r.rcx, r.rdx); printf("rdi=0x%llx rsi=0x%llx rdx=%llu\n", r.rdi, r.rsi, r.rdx); }

逻辑说明:PTRACE_GETREGS把目标进程当前所有通用寄存器和段寄存器一次性取出来,比逐个PTRACE_PEEKUSER快得多,也不会出现"取到一半进程跑了"的中间态。打印时把rip放在第一行,因为排查断点问题时第一个要看的就是它。

参数说明:r.rdx那一行同时用%llx和%llu打了两遍,是刻意留的——调函数前后经常需要看同一个寄存器作为有符号数还是无符号数的值,养成按需切换的习惯比事后猜要省时间。PTRACE_GETREGS第三个参数必须是结构体指针,传&r,传r会编译不过,传NULL在某些老内核上不会报错但结果未定义。

寄存器这一块还有个语义层面的提醒:rsp在函数入口和函数体内是不同的,bump刚进去时rsp指向返回地址,push rbp之后就往下走 8 字节。验收脚本如果对比rsp的绝对值,一定对不上;对比rsp的相对变化才有意义。

4.3 读内存:PTRACE_PEEKDATA 的 -1 陷阱与越页问题

读内存最省事的接口是PTRACE_PEEKDATA,一次读一个机器字。它有两个必须处理的边界:返回值-1的二义性,以及跨页读取导致的EIO。前一个问题在断点那章出现过,处理方式一样——调用前清errno,拿到-1之后看errno有没有被置位。后一个问题更隐蔽:你从0x…ffc开始读 8 字节,会跨到下一页,如果下一页没映射,整个读取直接失败,哪怕前 4 个字节是合法的。

// mem.c —— 按机器字读内存,越页就退化成逐字节读 static int peek_word(unsigned long addr, unsigned long *out) { errno = 0; long w = ptrace(PTRACE_PEEKDATA, g_pid, (void *)addr, NULL); if (w == -1 && errno != 0) return -1; *out = (unsigned long)w; return 0; } static void cmd_x(unsigned long addr, int nbytes) { for (int i = 0; i < nbytes; i += 8) { unsigned long w; if (peek_word(addr + i, &w) == 0) { printf("0x%lx: %016lx\n", addr + i, w); } else { printf("0x%lx: <not mapped>\n", addr + i); /* 别直接退出,继续下一行 */ } } }

逻辑说明:cmd_x以 8 字节为步长打印,每一行对应一个机器字,越界的那一行打印占位符后继续。这一点很重要——内存里夹着一两个未映射页是正常现象,比如栈和堆之间就有大片空洞。如果一遇到EIO就 `

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

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

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

立即咨询