操作系统实验:用strace与gdb追踪系统调用,理解用户态与内核态

发布时间:2026/10/2 14:16:59
操作系统实验:用strace与gdb追踪系统调用,理解用户态与内核态 最近刚把操作系统课程的“追踪系统调用”实验完整做了一遍。这个实验看起来只是敲几条 strace 命令然后截几张图但真正做完你会发现系统调用是理解整个操作系统的钥匙进程管理、文件系统、内存映射、信号处理这些模块最终都会汇聚到系统调用这一层。作为 HNU 计算机系统方向的课后作业它也让我第一次把课本上讲的“用户态与内核态”真正落到了实处。这篇总结我会把环境搭建、系统调用原理、strace 深度用法、gdb 验证方法以及作业报告的高分技巧都捋一遍适合正在写操作系统作业的同学也想彻底搞懂系统调用背后那套机制的朋友都可以接着往下看。1. 实验前先想清楚这个作业到底要你做什么1.1 核心需求拆解“追踪系统调用”这类作业不同学校要求略有差异但本质是一件事让你通过工具观察一个用户程序在执行过程中如何与操作系统内核交互。换句话说老师想让你回答三个问题一个程序从启动到退出向内核“求助”了多少次每次“求助”都携带了什么参数、返回了什么结果这些“求助”分别对应操作系统的哪些能力作业最典型的做法是用 strace 工具跟随一个命令比如ls把输出整理成报告分析其中关键的系统调用。有的老师会进阶一步要求你用 gdb 在汇编层面对某次系统调用打断点查看寄存器内容。更硬核的版本则是要求你用 ptrace 自己写一个简化版追踪器。不管哪种形式核心知识点都跑不出“系统调用的机制与过程”这个范畴。1.2 环境准备Ubuntu 虚拟机是首选做这个实验环境选 Ubuntu 大概率不会错。我自己用的是 VMWare Workstation Player个人学习免费版本上装的 Ubuntu 22.04 LTS Server 版。如果你之前一直用桌面版Server 版在这次实验里反而更干净——没有大量图形界面进程干扰追踪出来的系统调用序列更容易分析。装好之后先确认几个基本信息这是写实验报告时必填的环境项uname -a cat /etc/os-release我的环境输出大致是Linux ubuntu 5.15.0-...-generic x86_64Ubuntu 22.04 LTS。这些信息决定了后面你要查系统调用号时找哪个头文件。接下来安装实验所需工具一条命令搞定sudo apt update sudo apt install strace gdb build-essential注意如果遇到网络慢或者 apt 源的问题可以先换成国内镜像源再继续。这个坑我踩过一次换源前后速度差别很大。1.3 追踪器是怎么“看见”系统调用的在动手之前有必要搞清楚 strace 工具本身的工作原理。它并不是直接偷窥内核日志而是基于 Linux 的ptrace系统调用实现的。ptrace 允许一个进程tracer附加到另一个进程tracee在 tracee 每次进入系统和退出系统调用时让它暂停然后 tracer 通过读取 tracee 的寄存器信息把系统调用号和参数解码成可读的文本最后再恢复执行。这也是为什么 strace 输出里能看到“系统调用名 参数 返回值”的完整一行它实际上是在系统调用入口和出口各截获了一次 CPU 状态。理解了这一点后面看到输出中某些字段缺失或行为异常时你会更容易定位原因。另外有个细节新版内核里像gettimeofday这类高频调用可能通过 vDSO 在用户态直接完成根本不陷入内核strace 自然也不会显示。这个点放到后面常见问题里详细说。2. 系统调用原理先搞懂用户态到内核态的完整链路2.1 用户态与内核态CPU 特权的分界操作系统之所以能把控全局硬件层面依赖 CPU 的特权级别。以 x86 架构为例CPU 从高到低有 ring0 到 ring3 四个权限级别Linux 只用两个内核跑在 ring0用户程序跑在 ring3。ring3 下的用户态程序不能直接访问内核内存、不能操作设备寄存器、不能修改页表。凡是涉及这些敏感资源的操作都必须通过系统调用这一合法入口交内核代理执行。用一个生活化类比用户程序就像餐厅顾客不能自己跑进后厨拿锅铲只有通过服务员系统调用接口下单后厨内核才按规则把菜做好端上来。系统调用就是这个“统一的下单窗口”。2.2 从 printf 到内核一条调用的完整旅途拿 C 语言里最常见的printf来说很多初学者分不清“库函数”和“系统调用”的边界我帮你理清这条链路printf是 C 标准库提供的函数它先在用户态完成格式字符串解析格式化结果最终会交给 glibc 内部的write封装函数write这个封装会执行一条syscallCPU 指令这个指令会触发 CPU 陷入内核态的异常处理流程内核根据“系统调用号”查表跳转到对应的内核函数比如sys_write内核函数完成实际写入把结果放到寄存器返回用户态。所以一条printf最终落到内核层面实际发生的是 write 系统调用中间隔着标准库这层“包装纸”。2.3 调用约定系统调用号和参数传递规则x86_64 架构下用户态要发起系统调用需要把“系统调用号”放进 rax 寄存器参数依次放进 rdi、rsi、rdx、r10、r8、r9。注意第四位参数用的是 r10 而不是普通函数调用约定里的 rcx理由是 syscall 指令本身就依赖 rcx 保存返回地址不能让它既传参又存返回地址。这些系统调用号可以在头文件里查到grep __NR_write /usr/include/x86_64-linux-gnu/asm/unistd_64.h比如在 x86_64 下__NR_write是 1__NR_openat是 257。这里有个很容易踩的坑不同架构的系统调用号完全不同你在 x86_64 上看到的 1 号是 write在 ARM64 里可能就不是。所以做实验时先搞清楚自己跑了什么架构写报告时也别把调用号刻舟求剑。3. 核心实操用 strace 追踪并解读系统调用3.1 从一个最简单的命令开始环境准备就绪后先追踪一个最简单的命令验证工具和工作链路是否正常strace /bin/truetrue命令的功能就是立即成功退出所以它的调用序列最短适合做第一次练手。你会看到一串几十行的输出最后以exit_group(0)结束。这里的exit_group(0)含义是“整个进程组退出退出码 0”它与exit系统调用的区别在于 exit 只退当前线程exit_group 会杀掉进程的所有线程。这一细节在作业分析里写出来能显得你是真读懂了输出而不是照抄。3.2 逐行解读 ls 的系统调用输出接着追踪教科书级的案例strace ls输出会非常多但它内在是有逻辑顺序的我把关键调用按执行阶段分组解释。第一段是程序加载与动态链接过程execve(/usr/bin/ls, [ls], 0x7ffd...)是整个程序之旅的起点它让内核把/usr/bin/ls这个可执行文件加载进内存并开始执行。括号里第一个是可执行文件路径第二个是命令行参数数组第三个是环境变量指针。接着一堆brk(NULL)是程序向内核申请扩展堆区NULL 参数表示“先查当前堆尾在哪里”。堆区是动态内存分配malloc 的内存的基础。access(/etc/ld.so.preload, R_OK)是动态链接器在检查预加载配置文件是否存在它决定要不要提前加载某些共享库。openat(AT_FDCWD, /etc/ld.so.cache, O_RDONLY|O_CLOEXEC)是打开动态链接缓存文件后面的 3表示内核返回的文件描述符是 3。描述符 0、1、2 默认分别是标准输入、标准输出、标准错误所以新打开的文件一般从 3 开始编号。read(3, ...)读取缓存内容读完close(3)关闭。第二段是加载共享库。ls 不是纯静态编译的它依赖 glibc 和一堆外部库动态链接器需要把这些.so文件逐一加载到内存。这个过程你会看到大量openat、mmap、close成对出现。mmap的作用是把文件或匿名内存映射到进程地址空间加载共享库本质就是把它映射进内存并在需要时换页。第三段才是 ls 真正干活的阶段openat(AT_FDCWD, ., O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC)打开当前目录。getdents64(3, ...)这就是“取目录条目”的系统调用它一次性把目录里的多个条目读出来返回的是字节数。ls 列目录内容的最终数据来源就是它。fstat(1, ...)查看标准输出描述符 1的设备类型等信息ls 需要根据输出目标是终端还是普通文件来决定是按列展示还是逐行展示。write(1, file1 file2\n, ...)把格式化后的文件名写到终端。最后close(1)关闭描述符exit_group(0)退出进程。读完这一段你应该能感受到一个程序的执行过程远不只是“跑起来”三个字背后是一连串精密的资源申请和释放动作。3.3 参数速查让 strace 更好地为你服务实际做作业时直接strace ls往往不够你需要根据分析目标切换参数。这几个是我实测下来最常用的参数作用典型应用场景-f跟踪 fork 出来的子进程追踪 bash、编译器等多进程程序-e traceopenat,write只显示指定系统调用过滤大量无关输出直击目标-c输出系统调用次数和耗时统计报告里的量化数据图-p PID附加到一个正在运行的进程排查运行中服务的卡顿问题-o 文件名输出写入文件结果太长时便于慢慢分析-s 长度控制字符串显示长度默认 32 字节路径较长时会被截断-T显示每次系统调用的耗时定位性能瓶颈在哪个调用上-y在描述符上显示对应路径快速看出每个 fd 关联的文件组合示例追踪 bash 执行一条命令时父进程和子进程各自做了什么strace -f -o /tmp/bash_trace.out bash -c echo hello打开输出文件你会发现 bash 先fork出一个子进程子进程再execve加载/bin/echo可执行文件子进程退出后 bash 用wait4回收子进程状态。这短短几条调用恰恰解释了 Linux 进程模型里“进程创建是 fork exec 两步”的含义。如果作业要求统计系统调用占比-c参数特别好用strace -c ls输出最后会出现一张汇总表列出每次调用的次数、出错次数、总耗时。你把它和/proc文件系统里的信息结合就能在报告里做很有意思的分析比如“ls 执行过程中 mmap 调用次数最多因为加载共享库需要建立多个内存映射区”。4. 进阶实操gdb 验证与自写追踪器4.1 用 gdb 在汇编层面观测系统调用strace 是黑盒观测能看到“发生了什么”但看不到“寄存器现场”。想真正理解系统调用参数如何传递推荐用 gdb 在系统调用处打断点。先写一个最简单的触发程序#include unistd.h int main(void) { write(1, hello\n, 6); return 0; }保存为hello.c编译后用 gdb 调试gcc -g -o hello hello.c gdb ./hello在 gdb 里执行catch syscall write run程序会在执行 write 系统调用之前停下来。此时查看寄存器info registers rdi rsi rdx正常情况下你会看到rdi是 1代表写入目标为 stdoutrsi指向字符串 hello\n 在内存中的地址rdx是 6即字符串长度。再用 gdb 的内存查看命令验证缓冲区内容x/2s $rsi你会看到地址附近的实际字节。这个过程直观地验证了前面讲过的“系统调用参数通过寄存器传递”的约定。做完这一步报告里如果再配上一张寄存器截图说服力会强很多。4.2 绕开 glibc 直接发起系统调用如果你想让老师知道你真的懂系统调用机制可以用syscall函数绕开标准库内部的复杂路径直接指定系统调用号发起调用#include unistd.h #include sys/syscall.h int main(void) { syscall(SYS_write, 1, direct syscall\n, 15); return 0; }编译后用 strace 追踪gcc -o direct direct.c strace ./direct对比printf版本你会发现输出里除了动态链接产生的一堆代码外写入动作非常干净地对应到一行write(1, direct syscall\n, 15) 15。这个对比能帮你深刻理解“库函数和系统调用不是一回事”因为你已经成功绕过库函数直接使用系统调用完成了输出。4.3 自己写一个最简系统调用追踪器有的操作系统实验会要求“不借助 strace用 ptrace 实现一个简易追踪器”。我给一个非常精简的骨架核心逻辑就是 fork 子进程、通过 ptrace 在每次系统调用处暂停、读取寄存器中的调用号#include stdio.h #include unistd.h #include sys/ptrace.h #include sys/wait.h #include sys/user.h int main(int argc, char **argv) { if (argc 2) return 1; pid_t pid fork(); if (pid 0) { ptrace(PTRACE_TRACEME, 0, NULL, NULL); execl(argv[1], argv[1], NULL); } else { int status; waitpid(pid, status, 0); ptrace(PTRACE_SYSCALL, pid, NULL, NULL); while (waitpid(pid, status, 0) 0) { if (WIFSTOPPED(status) WSTOPSIG(status) SIGTRAP) { struct user_regs_struct regs; ptrace(PTRACE_GETREGS, pid, NULL, regs); printf(syscall id: %lld\n, regs.orig_rax); ptrace(PTRACE_SYSCALL, pid, NULL, NULL); } if (WIFEXITED(status)) break; } } return 0; }代码逻辑不复杂子进程执行PTRACE_TRACEME声明“我要被父进程跟踪”然后 exec 目标程序。父进程用PTRACE_SYSCALL让子进程在每次进入或退出系统调用时暂停并触发 SIGTRAP父进程收到信号后用PTRACE_GETREGS读取寄存器从orig_rax里取出系统调用号。这个字段很特殊它在普通函数调用时无意义但在系统调用入口处保存了系统调用号是追踪器识别“当前执行哪种系统调用”的关键。你可以编译这个程序然后让它追踪lsgcc -o mytrace mytrace.c ./mytrace /bin/ls虽然它只打印数字而没有解码成名字但你只需要查一下 unistd_64.h就能把每个数字还原成系统调用名。把这个程序的功能再扩展一下在出口暂停时读取 rax 得到返回值、通过参数寄存器解析参数就基本还原了 strace 的核心机制。能走到这一步实验报告的分量完全不一样。5. 常见问题与排查技巧实录5.1 追踪结果为空或权限不足用strace -p PID附加到某个进程时如果看到Operation not permitted多半是权限问题。普通用户只能追踪自己拥有的进程追踪 root 或其他用户的进程需要 root 权限。另外 Linux 的/proc/sys/kernel/yama/ptrace_scope默认可能是 1禁止非父子关系进程互相追踪。开发环境的临时解决方案是sudo sysctl -w kernel.yama.ptrace_scope0提示生产环境别随便改这个值ptrace_scope 是安全机制放宽后任何同 uid 用户都能 attach 你的进程。这只是做实验时的临时操作。5.2 strace 输出太多不知道看哪里追踪一个动态链接的复杂程序输出动辄几百行。这时候不要硬读先明确你要回答的问题是什么。如果只关心文件操作用strace -e traceopenat,read,write,close -o only_file.out ls如果只关心进程相关的调用strace -f -e traceclone,fork,vfork,execve,wait4 -o proc_only.out bash -c echo histrace 提供了一组语义化参数名比如file表示所有与文件路径相关的调用process表示进程控制相关调用network表示网络相关调用memory表示内存映射相关调用。组合使用时直接写-e tracefile,process即可。上手之后你会发现这套过滤比逐个列系统调用名灵活得多。5.3 分不清库函数与系统调用的区别作业里特别容易出现的错误之一是拿strace的输出去和 C 源码里的fopen/fread一一对应发现对不上就懵了。原因是 strace 展示的是“系统调用层”不是 C 库函数层。fopen在用户态做了一堆缓冲管理最后真正向内核申请打开文件的动作是通过openat系统调用完成的而fread可能在缓冲区里就已经有数据了完全不触发 read 系统调用。所以如果你 strace 一个多次 fread 的程序发现只执行了一次 openat、读了几次缓冲就结束了这件事本身反而是很好的分析素材。还有一个直观验证方法用nm -D查看 glibc 动态库导出的符号你会发现库函数和系统调用的名字并不相同nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep write写报告时如果能主动区分“库函数层的优化让 read 系统调用次数减少”这一现象得分会明显高于单纯罗列调用。5.4 “消失”的系统调用与平台差异新内核允许一些常见调用在用户态通过 vDSO虚拟动态共享对象直接完成典型的是gettimeofday和clock_gettime。这些调用不需要通过 syscall 指令陷入内核因此 strace 里看不到。你别急着怀疑工具坏了这是正常现象。验证办法是写一个循环调用gettimeofday的程序strace 后对比耗时你会惊讶于它快得不像一次系统调用。另一个需要留意的是系统调用号因架构而异。同一个“打开文件”操作在 x86_64 上对应的系统调用是openat调用号 257在 ARM64 上又是另一套编号。实验的时间戳、环境信息、架构信息要在报告里说清楚否则你给出的数据别人无法复现。如果你在 Apple Silicon 的虚拟机上跑 Ubuntu ARM64尤其注意这一点不要直接把网上 x86_64 的调用号抄过来。5.5 静态编译与容器环境的额外影响如果你尝试strace一个静态编译的程序比如gcc -static -o hello_static hello.c strace ./hello_static你会发现调用序列比动态版短很多因为少了一大堆加载动态链接器和共享库的过程。这个对比实验适合放进报告用来证明“动态链接程序启动开销主要发生在 mmap 和 openat 上”。在 Docker 容器里做追踪也有坑默认 seccomp 策略可能屏蔽了部分系统调用或者容器不具备 ptrace 权限导致 strace 直接报Operation not permitted。快速排查方式是看容器是否能通过strace附加到自己strace -p $$如果不行检查容器是否以--privileged或添加--cap-addSYS_PTRACE运行。这不是作业重点但如果你正好在容器环境里摸这个问题能省不少时间。6. 作业报告的结构设计与高分要点6.1 报告结构别照课本抄实验报告最常见的低分原因是把实验目的和实验原理整段抄教材。老师天天看作业一眼就能认出哪些是复制粘贴。我建议采用“问题驱动”的结构实验目的用两三句话写清楚“希望通过追踪回答什么问题”环境信息操作系统版本、内核版本、架构、strace 版本用表格展示实验方法说明用了哪些命令、为什么选这些命令结果与分析放关键截图或输出片段逐条分析其含义结论总结系统调用的完整流程提出你观察到的规律。这里最加分的是“结果与分析”部分。不要整屏贴 strace 输出而是挑选有代表性的 10 到 20 行按执行阶段分组逐行说明它在做什么。比如你分析ls就可以把输出拆成“加载动态链接器”“加载共享库”“读取目录内容”“写出结果”四个阶段每阶段配一个简短的说明。6.2 数据化分析让报告更有说服力纯文字描述调用过程说服力有限。强烈建议用strace -c生成统计表然后围绕数字展开分析。比如你会看到某个命令在运行期间调用mmap的次数多、openat的次数多、而write的次数反而少这是因为输出经过了用户态缓冲。把这些数字做成表格并解释数字背后的机制是报告中最能体现思考深度的部分。也可以尝试对比实验分别追踪动态编译和静态编译的同一个程序比较系统调用的数量与类型。你会得到一个很直观的结论“动态链接让程序启动时需要额外加载若干共享库导致 openat 与 mmap 次数显著增加”。这种结论是自己亲手测出来的胜过大段引用课本原文。6.3 容易被忽略的高分细节有几个细节做起来几乎不花时间但能明显让报告显眼用uname -a输出精确的内核版本报告里注明实验时间方便复现提及 vDSO 机制解释为什么某些看似需要陷入内核的操作没有出现在 strace 输出里提到系统调用号的架构差异说明你理解“系统调用号不是全局常量”如果追踪过程中发现某次openat返回了ENOENT不要跳过解释这正是程序在尝试访问一个不存在的路径。至于“思考题”环节如果老师没有指定你也可以主动补上类似的讨论为什么说系统调用是用户程序与内核之间的唯一合法通道如果操作系统不提供系统调用接口应用层开发会面临什么问题这些问题虽然短但能看出你确实把课本知识串起来了。最后分享一点我自己的体会做这个作业最值的地方是把之前零散的知识点串成了一条完整的线。平时你写一句open、read感觉就是调了个库函数而已。真正用 strace 和 gdb 看完整个过程后你会看到动态链接器的介入、虚拟文件系统的路径解析、文件描述符的分配、页表的映射切换一大堆操作系统概念全在这一条调用链里活了过来。建议所有做这个作业的朋友别急着交差了事多花半小时把ls的每次调用连起来看再动手跑一跑那个简易 ptrace 追踪器收获比从命令行里复制一百行输出要大得多。