
操作系统课布置了一个叫“追踪系统调用”的作业自己实现一个小工具当一个进程发起系统调用时把它截住输出系统调用号、参数、返回值再放行。说白了就是手写一个极简版strap。我当时第一反应是“这不就是复习一下ptrace吗”真动起手来才发现这个作业把用户态和内核态的切换、进程模型、信号处理、寄存器约定串了个遍任何一个环节没吃透程序就跑不出正确结果。这篇就完整记录我的实现过程、代码和踩坑给你写作业做个参考也理一理“追踪器”背后的操作系统原理。1. 看着像写strace其实作业考的是这三块硬骨头1.1 系统调用到底是什么“门”先说底层。系统调用是用户程序请求内核服务的唯一正规通道。用户态程序不能直接读写磁盘、创建进程、分配内存硬件把CPU分成特权级你用户态那点权限根本碰不到关键资源。想访问就得把控制权交给内核通过syscall指令x86_64或老式的int 0x80陷入内核CPU跳到内核预先设置好的入口点查系统调用表执行对应的内核函数最后回到用户态。这个“陷入”过程不只是切换权限还涉及栈切换、寄存器保存、返回地址处理成本不低。这也是为什么getpid()这种极简函数也比普通函数调用慢一大截。课程作业让你追踪系统调用本质上就是逼你去理解这条路径上每一个环节入口在哪、参数怎么传、返回值怎么出来、出错时怎么回到用户态。1.2 作业真正要验收的三件事老师布置“追踪系统调用”我认为真正想验收的就三件事你能不能捕获系统调用事件。也就是找到一个机制在进程发起系统调用进入内核前先让你看一眼。你能不能读取当时的寄存器内容。因为系统调用号放在rax里参数放在rdi/rsi/rdx/r10/r8/r9里返回值还是放在rax里拿不到寄存器就什么都干不了。你能不能区分系统调用的进入和退出。一个调用要触发两次捕获进入时看参数退出时看返回值这要求你对进程的状态变化特别敏感。我见过不少同学代码跑起来输出一堆乱码数字就是没分清这两次停止。后面我会专门讲这个坑。2. ptrace为什么能做到“看别人的寄存器”2.1 一次捕获的两道闸门Linux里实现进程追踪的机制就是ptrace。strace、gdb、各种Ftrace前端排查工具底子都是它。ptrace的核心思路并不复杂父进程作为追踪者子进程作为被追踪者追踪者可以暂停被追踪者、读写被追踪者的寄存器和内存、甚至伪造信号。像一个调试器握着程序的大动脉。用来追踪系统调用的是PTRACE_SYSCALL这个请求。你每次对子进程发出PTRACE_SYSCALL子进程就会继续跑但内核保证它在下一次系统调用进入时停一下以及在该调用返回用户态前再停一下。所以“追踪系统调用”不是靠轮询而是内核主动在系统调用路径上设了两道闸门。为什么是两次进入时停一次你能抓参数真正执行完、准备回到用户态时又停一次你能抓返回值。strace那句经典的write(1, hello\n, 6) 6前半段来自入口stop后半段来自退出stop。2.2 谁申请了被定向跟踪PTRACE_TRACEME作业场景通常是你启动一个程序从头跟踪它这时候用PTRACE_TRACEME最合理。子进程先调用ptrace(PTRACE_TRACEME, ...)主动声明“我愿意被父进程跟踪”然后再执行exec。内核在exec过程中发现这个进程带上了追踪标记就发一个SIGTRAP信号停下它把控制权交给父进程。父进程的waitpid拿到这个stop就可以调用PTRACE_SETOPTIONS设置选项、然后正式进入“每道闸门都停一下”的循环。另一种方式是父进程直接PTRACE_ATTACH去附着到正在运行的进程那更像“外挂”。但它受权限限制不能随便attach别人的进程而且还要处理目标进程可能正在执行系统调用的并发问题。课程作业一般用TRACEME就够了。2.3 syscall-stop和普通信号stop要分清这个坑特别容易踩。被追踪进程不是只在系统调用处才停下它收到普通信号也会停下比如SIGCHLD、SIGALRM甚至追踪器自己发送的SIGTRAP。它们的waitpid状态看起来都是“信号停止”但性质完全不同。Linux提供一个选项叫PTRACE_O_TRACESYSGOOD。设置之后真正的syscall-stop的WSTOPSIG(status)会变成SIGTRAP | 0x80也就是0x85十进制133。普通信号stop就还是原来的信号值。这样你就能在代码里干净利落地过滤出“这次停下是因为系统调用边界”而不是被各种信号打扰。很多初学者忘了这一行后面打印的全是数字错位。3. 一个最小追踪器的完整实现从fork开始3.1 程序骨架设计整个程序结构不复杂父进程fork出子进程子进程主动申请被跟踪然后exec父进程进入一个“无限循环”——每次调用PTRACE_SYSCALL让子进程跑waitpid等它停检查stop类型读寄存器打印信息再放跑。我用C语言实现在普通Linux x86_64环境编译即可。完整代码如下#include stdio.h #include stdlib.h #include unistd.h #include sys/ptrace.h #include sys/user.h #include sys/wait.h static void run_target(const char *path) { // 子进程先声明愿意被追踪再执行目标程序 if (ptrace(PTRACE_TRACEME, 0, 0, 0) 0) { perror(PTRACE_TRACEME); _exit(1); } execl(path, path, NULL); perror(execl); _exit(1); } int main(int argc, char **argv) { if (argc 2) { fprintf(stderr, usage: %s path-to-program\n, argv[0]); return 1; } pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { run_target(argv[1]); } int status; // 第一次waitpid等待子进程在exec时停下来 waitpid(pid, status, 0); // 只有处于stop状态时才能设置选项 ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESYSGOOD); int entering 1; // 1表示等待入口stop0表示等待出口stop for (;;) { ptrace(PTRACE_SYSCALL, pid, 0, 0); // 放跑到下一个系统调用边界再停 if (waitpid(pid, status, 0) 0) break; if (WIFEXITED(status) || WIFSIGNALED(status)) { printf(target terminated\n); break; } if (!WIFSTOPPED(status)) continue; // 判断是否为syscall-stop设置TRACESYSGOOD后WSTOPSIG是SIGTRAP|0x80 if (WSTOPSIG(status) ! (SIGTRAP | 0x80)) continue; struct user_regs_struct regs; if (ptrace(PTRACE_GETREGS, pid, 0, regs) 0) break; long syscall_no regs.orig_rax; if (syscall_no 0) // orig_rax为-1说明这不是系统调用stop continue; if (entering) { // 入口stop打印系统调用号和前6个参数 printf(%ld(, syscall_no); printf(%lld, %lld, %lld, %lld, %lld, %lld), (long long)regs.rdi, (long long)regs.rsi, (long long)regs.rdx, (long long)regs.r10, (long long)regs.r8, (long long)regs.r9); fflush(stdout); entering 0; } else { // 出口stop打印返回值 printf( %lld\n, (long long)regs.rax); entering 1; } } return 0; }3.2 关键代码段解释第一次waitpid非常重要。子进程是在exec的时候被迫停下来不是在任何系统调用路口。这时的status是普通SIGTRAP没有0x80标志。后续设置PTRACE_SETOPTIONS必须在tracee处于stop状态时进行所以不能漏掉这个waitpid。PTRACE_GETREGS会把当前所有通用寄存器读进struct user_regs_struct。注意系统调用号不在rax寄存器而是orig_rax。原因为当系统调用退出后内核会把返回值写进rax如果你只看rax进入时是参数、退出时是返回值会弄混。orig_rax专门保存“触发系统调用时的原始rax”对追踪器来说是个非常友好的字段。第4个参数的寄存器要填r10而不是rcx这是新手极容易写错的地方。x86_64下syscall指令执行时会用rcx保存返回地址内核不能用它传参数所以系统调用传参约定里第4个参数固定放r10。你从用户态寄存器里读rcx读到的往往是用户态return address残影不是调用参数。3.3 entering状态机的意义为什么用一个entering布尔来区分入口和出口因为PTRACE_SYSCALL导致的stop严格按“入口、出口、入口、出口”的顺序交替出现。理论上只要数次数就行。但被追踪进程如果同时收到信号还会夹杂普通stop。设置TRACESYSGOOD后我们continue滤掉了所有非syscall-stop剩下的stop序列就是严格的交替。用布尔变量标记状态代码最直观。在打印入口参数后要让stdout及时flush否则输出会和目标程序的输出掺在一起乱排。4. 从系统调用号到符号名查表和参数解码4.1 数字转符号名的常规操作现在程序能打印出系统调用号了比如59(、1(、257(但老师多半希望你打印成execve(、write(、openat(这样可读的名字。这个转换本质是查表。Linux内核源码里有一张权威表arch/x86/entry/syscalls/syscall_64.tbl。它列了每个系统调用的编号、名字和入口函数。你可以根据它生成一个数组。作业规模不必把几百个全列出来覆盖常用系统调用即可#define SYSCALL_NAME(name) [SYS_##name] #name, static const char *syscall_names[] { SYSCALL_NAME(read) SYSCALL_NAME(write) SYSCALL_NAME(open) SYSCALL_NAME(close) SYSCALL_NAME(stat) SYSCALL_NAME(fstat) SYSCALL_NAME(mmap) SYSCALL_NAME(brk) SYSCALL_NAME(execve) SYSCALL_NAME(exit_group) // ... }; #undef SYSCALL_NAME这里用[SYS_xxx] xxx这种指定初始化器编译期会自动填到正确下标比手写0,1,2安全得多。查不到的名字就打印“syscall_数字”至少不会崩。4.2 要不要读参数里的字符串很多人在参数解码上纠结。系统调用参数大部分是数字——文件描述符3、长度6、偏移量0x1000。但像write第二个参数是用户态缓冲区地址只打印指针地址老师会觉得不够完善。想打印字符串内容可以用PTRACE_PEEKDATA一个一个word读取目标进程内存。核心思路以8字节为单位从地址开始读逐个字组合成字符串注意越界和\0截断。我写了一个简版static void read_string(pid_t pid, unsigned long addr, char *out, size_t len) { size_t i 0; while (i len - 1) { errno 0; long word ptrace(PTRACE_PEEKDATA, pid, (void *)(addr i), 0); if (errno ! 0) break; for (size_t j 0; j sizeof(long); j) { char c (char)((word (8 * j)) 0xff); out[i] c; if (c \0 || i len - 1) return; } } }实测读write(1, hello\n, 6)时能输出带引号的字符串效果立刻逼近真实strace。但也别陷进去作业核心是“追踪系统调用机制”字符串解码是锦上添花。如果时间紧先把数字参数打对再考虑美化。5. 实测环节让追踪器跟着ls跑一遍5.1 编译和基本追踪编译很简单gcc -o mytrace mytrace.c先跟踪一个安静的程序/bin/true避免输出混杂。./mytrace /bin/true你会看到类似59(0, 0, 0, 0, 0, 0) 0 12(140737488346304, 3, 0, 0, 0, 0) 140737488346304 ...第一行大概率是execve(20)接口对上了。但后面一排数字看着费劲把系统调用号换成名字后再跑就能看到动态链接器的活动openat、read、mmap、mprotect、brk、fstat……你会直观理解“一个程序从启动到退出背后发生了多少系统调用”。5.2 跟踪一个有输出的程序跟踪echo hello时默认stdout会被目标程序的字符串“污染”./mytrace /bin/echo hello你的追踪信息可能和hello交错打印。这不是程序逻辑错是输出流混了。规避办法很简单把追踪器的输出和目标的输出分开。要么把追踪器的打印写到stderr要么shell里分别重定向./mytrace /bin/echo hello 2trace.log这样终端只留下hello系统调用记录都在trace.log里。写作业报告时这招很实用。5.3 和系统自带strace对比系统里一般有strace命令拿它跟我们的结果对拍strace -f /bin/true 2ref.log ./mytrace /bin/true 2our.log你会发现主要系统调用序列一致但细节有差异。比如strace能显示execve的完整参数数组、能跟随子进程我们的实现只是单进程追踪。这个差距正好是报告的拓展点PTRACE_O_TRACEFORK选项可以自动追踪子进程加上它就能覆盖fork后的孙进程。当时我把这个作为“下一步扩展”写进了作业总结明显加分。6. 填坑笔记追踪器写一半容易掉的陷阱6.1 漏掉PTRACE_O_TRACESYSGOOD这个坑我印象最深。不设置该选项时所有stop的WSTOPSIG(status)都是普通信号值你没法区分是被信号打断还是到了系统调用边界。结果就是程序跑着跑着输出行对不上whis的“参数”和“返回值”串到别的调用上。加了PTRACE_SETOPTIONS后syscall-stop的sig固定是133滤一下世界清净。6.2 PTRACE_SYSCALL被误当成“运行一次”PTRACE_SYSCALL不是“运行一步”而是“运行到下一个系统调用边界”。系统调用进入和退出分别停一次所以一次write会触发两次stop。我第一次写的时候每次stop都打印一次信息结果一个系统调用打两行。后来才想明白入口stop是打印“调用参数”出口stop是打印“返回值”两者要配对。6.3 orig_rax什么时候是-1除了真正的系统调用边界某些特殊stop也会触发PTRACE_GETREGS此时orig_rax可能是-1。最常见的就是exec刚停下时。如果你不检查这个值一上来就打印输出会多出垃圾行。代码里加一行if (syscall_no 0) continue;就行。6.4 平台差异换到32位或ARM全变这个作业在不同硬件上细节完全不同。x86_64是rax放调用号、rdi/rsi/rdx/r10/r8/r9放参数i386是eax放调用号、参数从栈上传ARM64是x8放调用号参数在x0-x5。你要是拿网上别人的实验代码不看清平台直接编译寄存器全读错输出就完全没法看。我先确认了自己虚拟机是64位Linuxuname -m看见x86_64才动手。6.5 权限和内核版本用PTRACE_ATTACH去追踪别的进程在默认安全配置下很容易EACCES因为普通用户不能attach不属于自己的进程管理员或CAP_SYS_PTRACE才有权限。所以课程作业老老实实选PTRACE_TRACEME fork既能绕开权限问题逻辑也简单。另外PTRACE_GETREGS在不同架构上字段名略有差异编译报错时优先检查头文件sys/user.h的版本。最后说点个人看法。写完这个追踪器我最大的收获不是会敲ptrace的几个API而是搞懂了一个程序从加载到退出的完整脉络。execve进来、mmap建内存映射、brk堆扩展、write写输出、exit_group收场这些平时被框架和库藏起来的东西全部在你眼前缓缓流过。以后你用任何性能工具、调试工具都会多一层判断力这工具到底在哪一层工作、采集了什么信息、有没有可能干扰程序本身。想明白这几点这作业的价值就远远超过“做个strace简化版”了。