资讯动态

PA2(上)调试器实现:ptrace断点、单步与寄存器内存

发布时间:2026/10/29 11:27:33 来源:尧图企业网站定制
简介《指令系统实现详解》是一份面向计算机体系结构课程学习者的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); /* base16 时 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); /* n1 时打 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; /* 未映射页会走这里errnoEIO */ 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让内核替你管这件事。有一点必须记住单步停止和断点停止投递的是同一个信号SIGTRAPwaitpid的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(rip0x%llx rsp0x%llx rbp0x%llx\n, r.rip, r.rsp, r.rbp); printf(rax0x%llx rbx0x%llx rcx0x%llx rdx0x%llx\n, r.rax, r.rbx, r.rcx, r.rdx); printf(rdi0x%llx rsi0x%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就 本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑