第10讲 · 调试 C 标准库:Debug Info、crt1.o、setjmp 与 malloc

2026 南京大学《操作系统原理》第十讲 · 课堂实录重构

主讲 蒋炎岩(Yanyan Jiang)
视频 https://www.bilibili.com/video/BV1yu9cBCEAb/
课程主页 https://jyywiki.cn/OS/2026/
本讲讲义 https://jyywiki.cn/OS/2026/lect10.md
讲义 / 幻灯片作者 蒋炎岩,https://jyywiki.cn/
讲义 / 幻灯片许可 CC BY-NC 4.0
图片来源 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面

对应讲次:第10讲(南京大学 操作系统原理)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(lect10.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。


引子:一次「翻车」的复现与修复

(参考时间: 00:00)

本讲开头,讲师复盘了上一讲的翻车现场:他当时直接让豆包「帮我把 musl libc 编译并安装好」,豆包跑了一段很长的脚本、说「搞定了」,讲师也没有细看输出、只做了不严谨的测试——结果发现能调进一部分 crt1 的源码,就以为成功了。

真相是:他根本没有把自己的 libc 装进系统,musl-gcc 还是用 apt 装的那个,调试信息也用不起来。这一讲他学乖了:在豆包的每一条命令之后都看一眼它做了什么,最后确认这次是真的编译、安装、调试通过了。

编者说明:这是一次很生动的「AI 编程要盯住结果」的示范——豆包会幻觉般地告诉你「成功了」。这也呼应了本讲的主线:-g 到底做了什么、调试信息为什么能让你「看见真相」。

编者拓展|libc 的原理,其实你已经会了:在 freestanding 环境里,除了「我的代码」什么都没有——你控制了 _start,就控制了一切;再用 FFI / 内联汇编就能执行系统调用。所以「自己实现一个 libc」在原理上并不困难:入口 (_start) → 取 argc/argv/envp → 调 main → exit。


一、Debug Info:二进制里那些「额外的东西」

(参考时间: 00:08)

上一讲翻车的直接表现是:gdb 时告诉你「no source code available」——因为动态链接的 glibc 没有调试信息。你只要 gcc -g 编译自己的代码,就能看到源码;但一旦 step 进 libc,又看不到了。

编者拓展|-g 到底做了什么:gcc -g 会在 ELF 里塞进额外的 section:.debug_info、.debug_line、.debug_str、.debug_frame、.debug_ranges、.debug_loclists……用 readelf -a 就能看到。把这些东西丢给大模型,它会告诉你这就是 DWARF 调试标准。还有一个有趣的命名梗:DWARF 是「矮人」,ELF 是「精灵」——「里面有很多命名梗」。

翻车现场:gdb 提示没有可用的源代码

📷 翻车现场:gdb 提示没有可用的源代码
回到原视频 (00:08:30)

readelf -a:看看 -g 塞进了哪些 .debug_* section

📷 readelf -a:看看 -g 塞进了哪些 .debug_* section
回到原视频 (00:11:00)

-g 的意义,可以这样理解:

即使只有符号表(不用 -g),你也能在 gdb 里大致知道「现在在哪个函数里」:nm a.out 能看到 main 在 .text 的某个地址;PC 走到那个地址,就知道 main 开始执行了。再加上 addr2line(把地址转成源码行),你甚至可以自己实现一个调试器。

addr2line -e a.out -f 0x400218:地址反查源码行(实操)

📷 addr2line -e a.out -f 0x400218:地址反查源码行(实操)
回到原视频 (00:16:50)

gdb 调试带 -g 的 crt1.c:终于能看到源码

📷 gdb 调试带 -g 的 crt1.c:终于能看到源码
回到原视频 (00:10:00)

编者拓展|DWARF 里有一个「图灵完备的小虚拟机」:它不只是「行号 → 地址」的映射——它包含一个bytecode 指令集,能访问寄存器、做算术、分支,用来描述「在某个 PC 范围内,变量的值在什么地方、如何算出来」。为什么需要它?因为 int x 在优化后可能在寄存器里、在内存里、甚至被优化掉了(你调试时看到的 optimized out 就是它)。它也能用来实现 C++ 异常的 stack unwinding。

1.1 JavaScript 的 Debug Info:Source Map 与 Claude Code 泄漏

(参考时间: 00:20)

不同语言的 debug info 形态不同。以 JavaScript 为例:TypeScript 经过一层层 transpiler 转译成旧版 JS,最后在浏览器里运行——出错时你希望映射回源代码。但浏览器是沙盒,没有你的项目目录,于是 JS 用 Source Map(.map,一个 JSON),把调试信息连同源代码一起打包:

{
  "version": 3,
  "file": "bundle.js",
  "sources": ["src/index.js"],
  "names": ["map", "callbackFn"],
  "mappings": "AAAA, ...",
  "sourcesContent": ["const x = 1; ..."]   // 就是这个!
}

讲师顺势点评了最近的 Claude Code 源代码泄漏事件:cli.js.map 泄漏后「光速就有了能运行的版本」。他翻了一遍,觉得「挺屎山的」,但也发现了好玩的东西(比如一行 checks if input matches negative keyword patterns、一段没有调用链的死代码),还强调安全风险:来路不明的「能运行版本」如果 build 会执行脚本,可能投毒、泄露你的 token,要在干净沙盒里处理。他调侃:「他们的 CI 竟然没有用 Claude 检查一下『是否存在潜在内部信息泄漏』。」

cli.js.map:Source Map 里直接打包了完整源代码

📷 cli.js.map:Source Map 里直接打包了完整源代码
回到原视频 (00:23:00)

1.2 Debug Info 能做什么

(参考时间: 00:29)

有了「从二进制状态重构源代码状态」的能力,就能做很多事:

graph LR
    A["二进制状态<br>(PC / 寄存器 / 内存)"] -->|Debug Info| B["高级程序状态<br>(栈帧 / 变量 / 源码行)"]
    B --> C["backtrace / 火焰图 / crash dump / ASan"]

编者拓展|strace 里 musl 的「极简」:讲师对比了用 musl 和用(静态)gcc 编译的同一个最小程序——musl 的 strace 在 execve 之后只有一两个系统调用就进入程序;而 gcc 的版本还有 readlinkat、getrandom 等一堆。system calls 是操作系统提供的功能,而调试信息让你能「隔一段时间采样」它——这正是火焰图的基础。

strace 对比:musl 编译的程序系统调用极简,gcc 的多得多(实操)

📷 strace 对比:musl 编译的程序系统调用极简,gcc 的多得多(实操)
回到原视频 (00:30:00)

用 ASan 抓到 heap-use-after-free(源码级诊断)

📷 用 ASan 抓到 heap-use-after-free(源码级诊断)
回到原视频 (00:34:30)


二、调试 libc 的「隐秘角落」

(参考时间: 00:35)

从现在起,我们就把上一讲编译好的、带调试信息的 musl 拿出来真刀真枪地调。

2.1 crt1.o 与「初始进程栈」

(参考时间: 00:35)

从最小的 dummy.c(return 1)开始。strace 显示它 execve 之后只做了很少几件事就 exit 了。用 gdb 从第一条指令开始调:

我们可以在程序里完整地看到这一点——这也再次验证了 System V ABI 规定的初始栈布局。「至少我确实确定了:初始的栈是这样的。」

gdb 从 `_start` 逆向出 initial process stack:argc / argv / envp

📷 gdb 从 `_start` 逆向出 initial process stack:argc / argv / envp
回到原视频 (00:36:30)

接着往下调 __init_libc:environ = envp 就在这一行(上一讲用 watch point 看过的那个);然后它跳过所有环境变量、逐个处理辅助向量,再调用 libc_start_main_stage2。最后就是 libc 的「经典一句」:

exit(main(argc, argv, envp));
gdb 跟进 `libc_start_main_stage2`:`exit(main(argc, argv, envp))`

📷 gdb 跟进 `libc_start_main_stage2`:`exit(main(argc, argv, envp))`
回到原视频 (00:39:30)

编者说明:讲师建议调大一点的代码时别用命令行 gdb,用编辑器的可视化调试(配好 LSP 后可以自由跳转,看结构体、看变量都舒服得多)。

2.2 调试 printf 与「变参数」

(参考时间: 00:41)

在 printf 上打断点、step into,就进入了 musl 的 stdio.h/printf。可以看到:

gdb 进入 musl 的 printf:`stdout` 的 `fd=1`、buffer 等内部结构

📷 gdb 进入 musl 的 printf:`stdout` 的 `fd=1`、buffer 等内部结构
回到原视频 (00:42:00)

变参数(void foo(int n, ...)) 是另一个「机器级」的角落:

void foo(int n, ...) {
    intptr_t *vargs = (intptr_t *)&n;
    // vargs[0], vargs[1], ...
}
变参数:编译器与 libc 协作,把寄存器参数存到栈上

📷 变参数:编译器与 libc 协作,把寄存器参数存到栈上
回到原视频 (00:46:00)

编者拓展|你自己就能「逆向出调用约定」:讲师用一个小实验证明了这一点——在汇编层面可以看到:foo(1, 11) 时,1 写进 w0、11 写进 w1;而 libc 生成的「序言」代码把 x1/x2、x3/x4 一对一对 store 到 sp+56、sp+72……「这些值你不知道是什么,但你能明确看到:寄存器里的参数被存到了栈上,形成一个小数组,变参数才能实现。」

2.3 调试 setjmp / longjmp:给寄存器「刷漆」

(参考时间: 00:48)

为了搞清楚 setjmp/longjmp 到底保存/恢复了哪些寄存器,讲师让 AI 写了一段很巧妙的「刷漆实验」:

  1. 先把所有寄存器刷成一个值(红色);
  2. 执行 setjmp;再把所有寄存器刷成另一个值(蓝色);
  3. 执行 longjmp,看哪些寄存器变回了红色。

结果非常清楚:

setjmp 前给所有寄存器「刷漆」,观察哪些被保存

📷 setjmp 前给所有寄存器「刷漆」,观察哪些被保存
回到原视频 (00:51:00)

longjmp 之后:`x19`–`x28` 被恢复,`x0`–`x18` 仍是新值

📷 longjmp 之后:`x19`–`x28` 被恢复,`x0`–`x18` 仍是新值
回到原视频 (00:52:40)

编者拓展|为什么不用保存 x0–x18:因为调用约定保证——一旦你调用任何函数,x0–x18 里的值就不再需要了(编译器可以随便用它们)。所以 setjmp 只保存「被调用者必须负责保留」的那批寄存器就够。讲师还吐槽了那对很难记的术语:caller-saved / callee-saved——「中文翻译成『调用者保存/被调用者保存』之后更难反应过来了」,不如通过一个小实验去观察它的行为。

2.4 调试 gettimeofday:原来它不进入内核

(参考时间: 00:56)

用一个 gettimeofday 前后夹着 usleep(200ms) 的小程序测时间差。奇怪的是:strace 里只有 nanosleep,没有任何 gettimeofday——但打印出来的时间却是对的!

顺着 musl 的 gettimeofday 一路调下去,真相揭晓:它走的是 vDSO。

graph TD
    G["gettimeofday()"] --> C["__clock_gettime()"]
    C -->|"首次:lazy load"| A["解析 AUXV 里的<br>AT_SYSINFO_EHDR(vDSO 位置)"]
    A --> P["像加载器一样<br>遍历 vDSO 的 ELF program header / 符号表"]
    P --> F["找到 __kernel_clock_gettime 的地址"]
    F -->|"直接调用"| R["读 vDSO 里的 clock + CPU cycle count"]
    R --> S["seqcount 循环:读两次 seq,相等则成功"]
vDSO:`gettimeofday` 直接跳进进程地址空间里的 `__kernel_clock_gettime`

📷 vDSO:`gettimeofday` 直接跳进进程地址空间里的 `__kernel_clock_gettime`
回到原视频 (01:02:00)

编者拓展|「不陷入内核的系统调用」= vDSO:像 gettimeofday、clock_gettime、time 这类高频、只读的调用,内核把实现放进了用户态可执行的一段代码(vDSO),于是它们不需要 syscall 指令陷入内核,性能大幅提升。AUXV 里的 AT_SYSINFO_EHDR 指向 vDSO 的 ELF 头,libc 在首次调用时「像加载器一样」解析它、缓存函数地址。讲师甚至让 AI 生成了一段指令级 trace:从进入 __kernel_clock_gettime 到出来,没有一次陷入内核。

AI 生成的指令序列日志:`gettimeofday` 的 seqcount 循环

📷 AI 生成的指令序列日志:`gettimeofday` 的 seqcount 循环
回到原视频 (01:09:00)


三、动态内存分配:malloc / free

(参考时间: 01:11)

malloc/free 的 API 非常直观:ptr = malloc(n); free(ptr);。但问题是:

内存从哪里来?

操作系统不支持分配「一小段」内存:mmap 以页(4 KiB)为单位,sbrk 也是移动整个数据段的边界。所以 libc 的做法是:一次性向操作系统要一大块(用 mmap + MAP_ANONYMOUS,或早期的 sbrk),自己在这块内存上实现一个数据结构,再切出小段给 malloc。

strace 一次 malloc:背后就是 mmap / sbrk

📷 strace 一次 malloc:背后就是 mmap / sbrk
回到原视频 (01:14:00)

3.1 「十亿美元错误」与最小完备性

(参考时间: 01:14)

讲师把 malloc/free 称为「一个设计得很有问题的 API」,并引用了 Tony Hoare 的名言:

I call it my billion-dollar mistake. It was the invention of the null reference in 1965.

对比 open/write 这类系统调用——它们提供了最小完备性、把机制与策略分离(操作系统只管字节序列,语义由应用负责),设计得「没有太大问题」。但 malloc/free 引入了一个额外要求:

「就为了证明这个,人类不知道走了多少弯路」:managed runtime、RAII、Rust 的 ownership/borrowing……以及无穷无尽的 CVE(use-after-free、concurrent use-after-free、memory leak……)。

编者拓展|RAII 与所有权:C++ 用 RAII(Resource Acquisition Is Initialization)——把资源的获取放在构造函数、释放在析构函数,这样即使中途 return 也不会泄漏;Rust 则用所有权/借用在编译期拒绝这类代码。讲师的观点很尖锐:理想情况下,像 Rust 那样「指针一旦被拿走就再也访问不到」,才是对的。

3.2 实现高效的 malloc:先看 workload

(参考时间: 01:17)

如果要自己实现 malloc,基本思路是:

「如果你向这个方向思考,就会想到平衡二叉搜索树」——但这不是真实分配器的做法。讲师引出 1995 年的那篇 malloc 综述:「这才叫 research」——Segregated free lists 早在 1964 年就提出了;「停止无意义的『科研实践』,去做真正有价值的事情」。他引了那篇文章里的一段:

Understanding real program behavior still remains the most important first step in formulating a theory of memory management. ... We must try to discern what is relevant and characterize it; this is necessary before formal techniques can be applied usefully.

由此得出两条铁律:

  1. 「脱离 workload 做优化就是耍流氓」——在考虑性能之前,先理解你需要考虑什么样的性能;而 workload 去哪里找?当然是 paper(顺便白得一个方案);
  2. 现实系统中通常不考虑 adversarial worst case(现实应用是「正常」的)——但这给了很多 DoS 机会。

对 malloc 的观察:小对象(字符串、临时对象)创建/分配最频繁,所以「我们几乎只要管好小对象就好了」。又因为所有分配会在所有处理器上发生,小对象分配的 scalability 是主要瓶颈——用链表/区间树做 first-fit 可不是好主意。

3.3 Fast & Slow:Segregated Lists(Slab)

(参考时间: 01:19)

讲师借 Kahneman 的《思考,快与慢》类比:好的系统要两套路径——

空间换简洁的具体做法是 Segregated Lists(Slab):

graph TD
    M["malloc(n)"] --> F{"线程本地 slab<br>里有空闲块?"}
    F -->|"有(fast path)"| R["直接返回,O(1)"]
    F -->|"没有(slow path)"| S["向操作系统 mmap 一大块<br>切成该大小的 slab"]
    S --> R

编者拓展|「为 AI 搭舞台」:讲师再次回到那句 Heuristics is dead, Policy is Heuristics, LLM is Policy, Mechanism is King——为 AI 搭好舞台,让它在 design space 里探索,甚至允许扩充 design space。他说,让 PhD 学生做综述的意义,正是学会提取 design space 的能力:malloc/free 的 design space 是什么?(对象大小分类?线程本地?fast/slow 路径?分配/回收的复杂度?)


总结


附:官方参考与延伸阅读

本讲的讲义见课程主页,以下链接按书中出现顺序整理:

主题 链接
调试「小程序」(musl-demos) https://jyywiki.cn/OS/demos/virtualization/musl-demos
DWARF Debugging Standard https://dwarfstd.org/
Claude Code Source Map(泄漏的 cli.js.map) https://github.com/hangsman/claude-code-source/blob/main/cli.js.map
Claude Code 可运行复原版(示例,注意安全风险) https://github.com/oboard/claude-code-rev
Perfetto(trace/profiler) https://perfetto.dev/
Wilson et al., Dynamic Storage Allocation: A Survey and Critical Review(1995) http://jyywiki.cn/OS/manuals/malloc-survey.pdf
Mimalloc: Free List Sharding in Action(APLAS'19) https://www.microsoft.com/en-us/research/uploads/prod/2019/06/mimalloc-tr-v1.pdf
vDSO / 不陷入内核的系统调用(vdso(7)) https://man7.org/linux/man-pages/man7/vdso.7.html
getauxval(3) / 辅助向量 https://man7.org/linux/man-pages/man3/getauxval.3.html
addr2line(1) 手册 https://man7.org/linux/man-pages/man1/addr2line.1.html
malloc(3) / free(3) 手册 https://man7.org/linux/man-pages/man3/malloc.3.html
mmap(2) 手册(MAP_ANONYMOUS) https://man7.org/linux/man-pages/man2/mmap.2.html
AddressSanitizer https://clang.llvm.org/docs/AddressSanitizer.html
OSTEP:Operating Systems: Three Easy Pieces https://pages.cs.wisc.edu/~remzi/OSTEP/

阅读材料:在 AI 的帮助下调试 musl libc 中你感兴趣的函数;并阅读 1995 年那篇 malloc 综述,「体会一下什么叫真正严肃的科研」。

版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。