如果你这学期的操作系统课也布置了“追踪系统调用”这个作业,大概率是这样一个场景:装好 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 验证追踪环境是否可用:第一个最小实验
环境搭建完不要急着跑复杂程序,先用系统里最基础的命令验证追踪功能是否正常。比如追踪 pwd:
strace -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 输出文件 目标程序最简单的使用场景是追踪 ls:
strace -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 trace=open,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 trace=open,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, ®s) == -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 或改用 VirtualBox |
Ubuntu 中提示gcc: command not found | 未安装编译工具链 | 安装 build-essential |
strace 输出全是ENOENT错误 | 程序默认路径不完整 | 检查工作目录,使用绝对路径执行目标程序 |
| tracer 运行后没有输出任何调用 | 忘记 PTRACE_TRACEME 或 execvp 失败了 | 代码里加入 perror 打印错误 |
| tracer 输出大量的 interrupted system call | 目标程序收到信号 | 忽略该错误码,或让子进程先屏蔽 SIGINT |
ptrace 报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 结果敷衍的原因。伸出手去敲代码,远比你想象的更有收获。