从strace到ptrace:手写系统调用追踪器与实验报告实战
如果你这学期的操作系统课也布置了“追踪系统调用”这个作业大概率是这样一个场景装好 Ubuntu 虚拟机打开终端对着 strace 的输出一头雾水屏幕滚过去几十行 read、write、mmap数据是有了但不知道该怎么组织成一份能过审的实验报告。这篇文章就是围绕这个作业写的我会把“系统调用到底怎么追”这件事拆开讲清楚顺便把我自己踩过的坑、被老师追问过的点、以及作业报告该怎么写一次性交代完。适合你的情况有几种一是刚开始接触 Linux 系统调用连 strace 都没用过二是作业要求写一个自定义的追踪工具卡在了 ptrace 上;三是已经在命令行跑通了但实验报告不知道怎么写才能拿高分。这篇按从易到难的路线推进先讲原理和 strace 的使用再给一份可以直接编译运行的 C 语言追踪器最后补充作业报告的写法与常见故障排查。1. 作业背后的底层逻辑为什么操作系统课要让你追踪系统调用1.1 系统调用是什么程序与内核之间的“官方接口”系统调用system call是用户态程序请求内核服务的唯一合法入口。你可能写过无数个 printf但 printf 本身不是系统调用它最终会通过 write 这个系统调用把数据交给内核由内核驱动终端或文件系统完成实际输出。这个过程可以理解为应用程序是顾客内核是营业大厅系统调用就是那个唯一开放的窗口——你想操作文件、创建进程、申请内存、读取键盘全部要在这个窗口递单子。以 x86_64 Linux 为例系统调用的具体流程是程序把系统调用号放入 rax 寄存器参数依次放入 rdi、rsi、rdx、r10、r8、r9然后执行 syscall 指令。执行这条指令时CPU 会从用户态切换到内核态根据系统调用号在 sys_call_table 中查到对应的内核函数执行完成后把返回值放入 rax再切换回用户态。你根本不需要记住每个细节但脑里要有这个画面一次普通的文件读操作背后就包含了一次陷入内核、一次内核态处理、一次返回用户态的完整时空穿梭。1.2 为什么作业要选“追踪”作为切入点这是整个作业最有价值的地方。一个程序从启动到退出会经历数十个甚至上百个系统调用追踪这些调用本质上就是在给程序做“X光透视”。你不光能看到程序调用了几次 read、write还能看出程序是怎么加载动态库的、是怎么申请内存的、是怎么创建子进程的。很多同学做完作业只会交一张 strace 截图但实际这个作业的隐藏考点有三个第一你是否理解用户态与内核态的切换代价——每次系统调用都是一次宝贵的状态切换频繁调用会拖慢性能第二你是否能从追踪结果反向推断程序的启动流程——比如 execve 之后紧跟一堆 openat 和 mmap那是在找动态链接库第三你是否知道库函数和系统调用的区别——printf 不是系统调用write 才是这个区分比想象中重要。实验课老师考的不是你会不会敲 strace 命令而是你有没有真正理解系统调用在整个操作系统中的作用。1.3 这个作业的扩展方向从追踪到拦截再到自定义行为基础作业是“追踪”但如果你想拿更高分通常会延伸出两条支线。一条是过滤统计把大量追踪数据按系统调用类型聚合分析哪个调用最频繁进而提出优化方案。另一条是“拦截修改”不只是看还要在系统调用发生前后插入自己的逻辑比如打印额外参数、修改返回值。后者已经具备“逆向工程”“沙箱”“调试器”的基本形态很多安全工具的核心原理也在这。所以这个作业绝不是一个孤立的命令练习它是通往进程控制、性能剖析和工具开发的起点。2. 环境准备Ubuntu 虚拟机与基础工具链2.1 虚拟机方案选择VMware 还是 VirtualBox绝大多数课程作业要求在 Linux 环境完成如果你本身不是 Linux 主力机最省事的方案就是虚拟机。我在开始做之前选型对比过几套方案VMware Workstation 是中文界面友好对新手比较友好VirtualBox 是开源的没有授权困扰但性能上同一台机器差距其实不大特别对于这种纯 CPU 密集型的小实验差别可以忽略。如果是 Windows 11 系统注意在“Windows 功能”里关闭 Hyper-V否则 VMware 加载 Ubuntu 时容易报“VMware Workstation 与 Device/Credential Guard 不兼容”的错误。Ubuntu 版本建议选择 22.04 LTS 或 24.04 LTS不要选 26.04 之类的 development 分支实验环境稳定性第一。安装时虚拟机内存给到 4GB 以上硬盘 40GB 以上CPU 至少双核。如果你的机器配置一般这个环境也足够用了。2.2 安装必要的工具链与追踪工具开机进入 Ubuntu 后第一件事是把编译工具链和追踪工具装齐。打开终端执行以下命令sudo apt update sudo apt install build-essential gcc gdb strace linux-tools-generic -ybuild-essential 包含 gcc、make 等编译工具strace 是后面要用的第一级追踪工具linux-tools-generic 提供部分性能追踪工具不是必需但建议安装。装完之后用版本号验证环境uname -a gcc --version strace -V如果这几条命令都能正常输出说明你的 Ubuntu 环境已经就绪。整个过程一般不超过 5 分钟但很多同学在安装 headless 版本时反而卡在没装 gcc 这种基础问题上再强调一次先 build-essential再谈其他。2.3 验证追踪环境是否可用第一个最小实验环境搭建完不要急着跑复杂程序先用系统里最基础的命令验证追踪功能是否正常。比如追踪 pwdstrace -o /tmp/pwd.trace pwd cat /tmp/pwd.trace这个命令会列出执行 pwd 期间的所有系统调用。正常情况下你能看到 openat、fstat、getcwd、write、close 等调用。看到这些输出说明你的系统、内核和追踪工具都正常工作。这一步的价值在于把“环境问题”和“作业问题”隔离开后面再出问题就大概率是你自己的程序和逻辑问题了。3. 最省力的追踪路径strace 命令速成与实验数据获取3.1 strace 的基本用法与输出解读strace 是 Linux 自带的高位格式化的系统调用追踪器它的原理是内嵌在 ptrace 之上的一个封装我们第四部分会自己实现一个简化版现在先把它当黑盒用。基本格式是strace -o 输出文件 目标程序最简单的使用场景是追踪 lsstrace -o /tmp/ls.trace ls head -30 /tmp/ls.trace你会看到类似下面的输出execve(/usr/bin/ls, [ls], 0x7ffd...) 0 brk(NULL) 0x5577... openat(AT_FDCWD, /etc/ld.so.cache, O_RDONLY|O_CLOEXEC) 3 fstat(3, {st_mode...}) 0 mmap(NULL, 132096, PROT_READ, MAP_PRIVATE, 3, 0) 0x7f... close(3) 0每一行的结构是系统调用名(参数列表) 返回值。比如 openat 返回 3说明内核分配了一个新的文件描述符 3 给这个打开的文件。如果你看到返回负数比如 -1那往往表示调用失败后面括号里会带一个 errno 码。3.2 三个最常用的追踪选项过滤、跟随子进程、统计纯基础实验只需要 strace 默认行为就足够但如果你的作业要求分析“某个程序整个生命周期”的全部行为很可能用到以下几个选项。我把它们整理成一个速查表方便你直接抄作业选项作用使用示例说明-f同时追踪子进程strace -f -o out.txt ./prog不使用时只追踪主进程-e trace指定追踪的调用集合strace -e traceopen,read,write ./prog让输出聚焦减少噪音-c按系统调用汇总统计strace -c ./prog生成调用次数、耗时统计表-o输出到文件strace -o log.txt ./prog防止追踪输出与程序输出混合-T显示每次调用耗时strace -T ./prog用于分析性能瓶颈我自己的使用习惯是第一遍用strace -f -o trace.txt ./program全量采集第二遍用strace -c做统计如果作业要求分析某个特定行为比如文件访问再用-e traceopen,openat做定向追踪。三步走下来实验数据基本上就齐了。3.3 数据怎么变成实验报告内容追踪完成后作业报告最核心的“实验结果”部分一般就是两张表加一张图。第一张是系统调用频率统计表用strace -c的输出往上贴第二张是程序关键行为路径从全量 trace 中筛选出有代表性的关键调用按时间顺序排列说明每个调用在完成什么动作。如果还需要图可以把strace -c的结果手动录入 Excel 或 gnuplot画一个柱状图或者饼图。这里提醒一下实验报告不是流水账不要全量贴 trace 日志。老师想知道的是你有没有看懂数据比如追踪ls时你会发现调用次数最多的是mmap和openat这时候你就应该在报告里解释程序启动需要加载动态链接库ld.so而加载库涉及文件打开、内存映射这正好印证了程序动态链接的原理。这种从数据到原理的对应关系才是高分的分水岭。4. 硬核路线用 ptrace 手写一个系统调用追踪器4.1 ptrace 原理系统调用追踪的“地基”如果你以为操作系统作业只是敲敲 strace 命令那很可能低估了课程设计要求。不少学校明确要求“实现一个简单的系统调用追踪器”或者追问“strace 底层是怎么做到的”。答案核心就是 ptrace 系统调用。ptrace 提供了一种机制允许一个进程观察和控制另一个进程的执行并读取被观察进程的寄存器与内存。最常见的使用模式是父进程 fork 一个子进程子进程调用 ptrace(PTRACE_TRACEME) 声明自己愿意被父进程追踪然后执行目标程序父进程用 waitpid 等待子进程因各种事件陷入停止再用 PTRACE_GETREGS 读取它的寄存器从而实现“看到每一次系统调用的进出瞬间”。这种模式正是调试器 GDB、系统调用探查工具 strace 的共同基础。4.2 追踪器的核心工作流程fork、exec、wait 与事件判断手写追踪器的总体逻辑分为四步创建子进程并在子进程中先调用ptrace(PTRACE_TRACEME)再execvp执行目标程序。父进程waitpid等待子进程停止。每次子进程因系统调用而停止时父进程用PTRACE_GETREGS读取寄存器。通过寄存器中的orig_rax获取系统调用号通过rax获取返回值打印输出后让子进程继续。关键在于ptrace 下每次系统调用会触发两次停止事件第一次是在系统调用进入内裤之前syscall-enter-stop第二次是在系统调用返回用户态之前syscall-exit-stop。所以父进程必须记住当前是“进入”还是“退出”状态。一个实用的判断方法是用一个布尔变量交替切换。当进入事件发生时读取orig_rax得到系统调用号当退出事件发生时读取rax得到返回值。4.3 可直接编译运行的 C 语言追踪器代码下面这份代码我在 Ubuntu 22.04 上测试过可以原样保存为tracer.c并使用gcc -o tracer tracer.c编译。代码用 x86_64 Linux 的寄存器命名如果你用的是 32 位系统需要换成 eax/ebx 那一套但今天绝大多数课程环境都是 64 位。#include stdio.h #include stdlib.h #include unistd.h #include sys/ptrace.h #include sys/wait.h #include sys/user.h #include signal.h const char *syscall_name(long nr) { switch (nr) { case 0: return read; case 1: return write; case 2: return open; case 3: return close; case 9: return mmap; case 10: return mprotect; case 12: return brk; case 39: return getpid; case 56: return clone; case 59: return execve; case 60: return exit; case 79: return getcwd; case 158: return arch_prctl; case 257: return openat; default: return unknown; } } int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s program [args...]\n, argv[0]); return 1; } pid_t pid fork(); if (pid 0) { ptrace(PTRACE_TRACEME, 0, NULL, NULL); execvp(argv[1], argv[1]); perror(execvp); exit(1); } int status; waitpid(pid, status, 0); int in_syscall 1; while (1) { if (ptrace(PTRACE_SYSCALL, pid, NULL, NULL) -1) { break; } if (waitpid(pid, status, 0) -1) break; if (WIFEXITED(status) || WIFSIGNALED(status)) break; if (!WIFSTOPPED(status)) continue; struct user_regs_struct regs; if (ptrace(PTRACE_GETREGS, pid, NULL, regs) -1) continue; if (in_syscall) { long nr regs.orig_rax; printf([进入] 调用号 %ld (%s)\n, nr, syscall_name(nr)); } else { long nr regs.orig_rax; long ret regs.rax; printf([退出] %s 返回值 %ld\n, syscall_name(nr), ret); } in_syscall !in_syscall; } return 0; }代码逻辑不复杂但有三个容易写错的地方需要特别留意。第一是子进程必须在 execvp 之前调用 ptrace(PTRACE_TRACEME)不然后续追踪不到任何事件。第二是父进程第一轮 waitpid 拿到的是“子进程因 exec 而停止”的事件此时子进程还没进入第一个系统调用所以 in_syscall 变量要初始化为 1。第三是 PTRACE_SYSCALL 每次只能推进一次事件你想持续追踪就得不断设置新事件并用 waitpid 配合。4.4 运行追踪器并解读结果编译并运行gcc -o tracer tracer.c ./tracer pwd运行后会看到类似下面的输出[进入] 调用号 12 (brk) [退出] brk 返回值 94709773307904 [进入] 调用号 257 (openat) [退出] openat 返回值 3 [进入] 调用号 1 (write) [退出] write 返回值 8 ......这个结果和 strace 输出虽然排版不同但信息本质一致进入了哪个系统调用带什么返回值。如果作业要求显示参数比如打开的文件名你还需要使用 ptrace 的 PTRACE_PEEKDATA 或 process_vm_readv 去读取被追踪进程的内存地址。这里先不展开因为多数基础作业只要求调用名和返回值如果你想追加参数显示可以在进入系统调用时拿到 rdi/rsi/rdx 等寄存器再用 PTRACE_PEEKDATA 按字节拼接字符串。思路不难但是是个相对独立的小工程。5. 操作系统实验报告怎么写从数据到高分结论5.1 报告结构逻辑完整比花哨重要很多同学实验做的很认真但报告拿不到高分主要问题是结构混乱。这里提供一个通用框架实验目的、实验环境、实验原理、实验步骤、实验结果与分析、问题与心得、结论与参考。实验目的不要抄大而空的句子直接写“本实验要求追踪一个 Linux 程序执行时的全部系统调用并通过数据分析其行为”实验原理部分写清楚系统调用机制和 strace/ptrace 的原理不要写流水账实验结果是图表和关键输出分析部分是重点至少占全报告的三分之一篇幅。5.2 数据呈现的三个技巧表格化、聚焦化、原理解释第一数据要表格化不要丢大段 terminal 日志。把strace -c的结果整理成调用次数、耗时、占比的表格一眼就能看出系统调用的分布。第二结果要聚焦化从追踪数据里挑出三到五个代表性系统调用比如 execve 启动进程、openat 打开库文件、mmap 映射内存、write 输出结果逐个解释它们在程序生命周期里的角色而不是把几十行调用全贴进去。第三结论必须有原理解释。比如用你手写追踪器去追踪一个空的 C 程序你一定会发现 brk 和 mmap 出现在比较靠前的位置这就是 C 运行时初始化堆内存和加载动态链接器的过程把它和课堂上的“程序加载与执行”知识点对应起来老师一看就知道你理解了。5.3 心得与加分项把“踩坑”变成亮点一份有价值的报告还要有“问题与心得”板块。这里面最好的素材就是你在这份作业中真实遇到的问题比如虚拟机里 Ubuntu 没有安装 build-essential 导致的编译失败或 ptrace 追踪 shell 脚本时报参数错误把这些写进去并说明你是如何排查和解决的这属于加分项。另外一个非常加分的点是把你的追踪器功能扩展一下。比如在代码中加入统计接口统计每个系统调用出现的次数或者加入时间戳测量每次系统调用耗时。这两项几乎不需要额外引入复杂机制只需要在 while 循环加一个计数器和gettimeofday调用但写进报告之后你的作业就从“实现了”变成了“实现并分析了”深度完全不是一个等级。6. 常见问题排查与避坑实录6.1 问题速查表高频故障与解决方案问题现象可能原因解决方法虚拟机启动提示与 Hyper-V 不兼容Windows 11 开启了 Hyper-V关闭 Hyper-V 或改用 VirtualBoxUbuntu 中提示gcc: command not found未安装编译工具链安装 build-essentialstrace 输出全是ENOENT错误程序默认路径不完整检查工作目录使用绝对路径执行目标程序tracer 运行后没有输出任何调用忘记 PTRACE_TRACEME 或 execvp 失败了代码里加入 perror 打印错误tracer 输出大量的 interrupted system call目标程序收到信号忽略该错误码或让子进程先屏蔽 SIGINTptrace 报Operation not permitted目标程序是 setuid 程序换一个普通程序进行追踪不建议做复杂绕过操作追踪 shell 脚本无效strace 默认只追踪 sh 进程用-f追踪子进程6.2 我踩过的三个大坑与应对方法第一个坑是子进程行为不可控。早期我在子进程里先打印再用 execvp结果追踪到的全是 printf 的 write 调用真正的目标程序反而没跑起来。这里的关键是子进程除 ptrace 和 exec 之外不要让任何其他逻辑运行一切调试信息都放在父进程里打印。第二个坑是寄存器读取失败。当时我在 32 位虚拟机上跑 64 位程序直接导致 PTRACE_GETREGS 返回错误。后面统一改用 64 位 Ubuntu 系统并且用 uname -m 确认环境这个问题再没出现。所以你在动手前先确认“追踪器位数”和“目标程序位数”一致。第三个坑是 PTRACE_SYSCALL 与信号处理混在一起。子进程运行到一半收到 SIGINT 时父进程 waitpid 返回的状态会混合系统调用停止事件和信号停止事件如果不判断信号会导致后续追踪事件错乱。解决办法是遇到WIFSTOPPED时进一步检查status 8的值区分是 SIGTRAP系统调用事件还是其他信号再将其他信号转发给子进程。这个属于进阶处理但遇到一次之后你就会记住。6.3 后续还能怎么扩展从作业到真正工具这个作业做完如果你还有兴趣可以在此基础上做几件很有价值的事。一个是实现“系统调用参数解析”用PTRACE_PEEKDATA去读被追踪进程的内存把字符串参数真实显示出来这就非常接近 strace 的日常行为了。另一个是做成“系统调用性能分析器”统计每个系统调用耗时与频次明显是给服务端性能优化做准备的。还有一种是结合 eBPF 跟踪在 Linux 5.x 内核上使用 BPF Type Format 获取调用堆栈不过这个已经跳出本科生作业的普遍范围了。我个人在实际操作中的体会是这个作业最好的学习方式就是先不管三七二十一写一个最简陋的 ptrace 追踪器能打印一次系统调用就算成功。然后再跑一个真实程序看到屏幕上那些熟悉的名字——write、openat、mmap 一个接一个闪现的时候你对“操作系统到底在干什么”的理解会在那一刻变得非常具体。这也是我仍然推荐你亲手做一遍而不只是拿 strace 结果敷衍的原因。伸出手去敲代码远比你想象的更有收获。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →