第11讲 · 可执行文件:链接与加载——从 a.out 到 ELF、动态链接与 LD_PRELOAD

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

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

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

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

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


引子:到底什么是「可执行文件」?

(参考时间: 00:00)

学操作系统之前,可执行文件就是「双击能弹出窗口的东西」(Steam、原神)。学完操作系统之后,你会对它有更深的理解:

为什么会「可执行」?因为 execve 这个名字就叫 execute program,它执行的 pathname 就是一个可执行文件;而这个文件描述了进程的初始状态:

graph LR
    A["操作系统(System V ABI)"] -->|"initial process stack:argc/argv/envp/auxv"| S["进程的初始状态"]
    B["可执行文件"] -->|"描述内存布局:代码/数据/BSS 加载到哪、权限如何"| S
hexdump 一个可执行文件:开头就是 `7f 45 4c 46`(ELF)

📷 hexdump 一个可执行文件:开头就是 `7f 45 4c 46`(ELF)
回到原视频 (00:02:00)

1.1 从 UNIX a.out 说起

(参考时间: 00:03)

最早接触的可执行格式,是 UNIX 的 a.out(assembler output)——这里的 a 不是随便取的,是 assembler。它的结构简单得惊人:

struct exec {
    uint32_t a_midmag;   // Machine ID & Magic
    uint32_t a_text;     // Text segment size
    uint32_t a_data;     // Data segment size
    uint32_t a_bss;      // BSS segment size
    uint32_t a_syms;     // Symbol table size
    uint32_t a_entry;    // Entry point
    uint32_t a_trsize;   // Text reloc table size
    uint32_t a_drsize;   // Data reloc table size
};

它甚至没有 offset——.text 加载到哪里,是平台/ABI 相关的常数,被硬编码在操作系统代码里。它也不支持:动态链接、调试信息、内存对齐、thread-local……于是后来有了 ELF。

编者拓展|「复杂的东西都是从简单演变过来的」:a.out 是一个人造物,它简单、直接。我们今天用的 ELF 之所以复杂,是因为每年都往里加需求(动态链接、调试信息、对齐、TLS……)。理解这一点,能让你不再把 ELF 当成「天降的怪物」,而是「一堆历史需求的叠加」。

a.out / ELF 的结构:一个描述进程初始状态的数据结构

📷 a.out / ELF 的结构:一个描述进程初始状态的数据结构
回到原视频 (00:05:00)

1.2 ELF 为什么「读起来想吐」

(参考时间: 00:07)

讲到 ELF,就是「被《计算机系统基础》支配的恐惧」:System V ABI 手册(Section 3.4 Process Initialization、Section 4 Object Files)非常难读,细节海量,考试要考,同学「都吐了」,连 CSAPP 那一章也「讲得不怎么样」——任课老师也在吐血。

为什么这么难? 因为 ELF 是为机器效率设计的:

编者说明:讲师给了一个「AI 时代读手册」的范式——把手册丢给 AI 做 summary,再追问(「这个格式支持链接吗?」)。有了手册和代码,大模型就不太会幻觉。


二、Funny Little Executable:把 ELF 变成「人能读」的东西

(参考时间: 00:12)

讲师的核心信条是:

ELF 不过是一个「描述」(进程的初始状态描述),而我们总是可以在等价的描述形式之间转换。

既然只是「描述」,那就可以:Step 1 把 ELF 转成一个容易阅读的格式;Step 2 实现一个加载器加载它——「反正可以 vibe coding,麻烦的事让 AI 搞定」。

这就是 Funny Little Executable(FLE):一个自定义的、能实现静态链接与加载的格式(复用 gcc/ld 做编译器/链接器,自己写格式与加载器)。FLE 直接把一段位置无关的可读可写可执行代码 + 数据映射进内存并跳转执行。

Funny Little Executable:把 ELF 转成对人类友好的 JSON/符号化格式

📷 Funny Little Executable:把 ELF 转成对人类友好的 JSON/符号化格式
回到原视频 (00:15:30)

2.1 如果再做一次:Markdown-based ELF 2.0

(参考时间: 00:17)

讲师说:如果再做一次,他也许会选 Markdown-based(人类可读绝对优先):

# ELF [class=64 endian=le osabi=sysv machine=x86_64 type=ET_DYN pie=true entry=_start]
## PT_LOAD [flags=R|X align=0x1000]
### .text [type=PROGBITS flags=A|X align=16]
_start:
    48 c7 c0 01 00 00 00             # mov rax,1   ; sys_write
    48 8d 35 {pcrel32: msg - . - 4}  # lea rsi,[rip+msg]
    0f 05                            # syscall

「这就是链接」:编译时留 4 个字节的零(占位常数),链接时所有符号都安排好了位置,把 msg - . - 4 算出来填进去——一个表达式而已。而加载就是:看到字面上写什么,就把它按可读可执行的方式搬到内存里(用 mmap)。

用 Markdown 描述 ELF:把 offset/指针结构彻底去掉,用「浪费空间」换「人好懂」

📷 用 Markdown 描述 ELF:把 offset/指针结构彻底去掉,用「浪费空间」换「人好懂」
回到原视频 (00:19:00)

编者拓展|重定位(relocation)到底在做什么:编译 a.c 里的 x = 1(extern int x),gcc -c 会生成一个带 relocation 的 .o:在 arm 指令里先放一个零(26 位立即数),链接时再把这个数字重新填进去。用 readelf -a a.o 能看到这些 relocations。只要你知道这一点,就可以用任何你想要的方式来表达它。

readelf -a 一个 .o:重定位信息就是「这里要填一个数」

📷 readelf -a 一个 .o:重定位信息就是「这里要填一个数」
回到原视频 (00:24:30)

编者说明:讲师借此反思教学——以前他花很多时间做「可视化」(汉诺塔、管道 fd 图……),但后来发现「聪明」的努力本质上就是一句话:「我们总可以在等价的描述形式之间转换,然后设计一个合适的描述形式,剩下的交给 AI。」 他还提到 Karpathy 的 LLM Wiki——「维护一个 facts 池子、不断消化」,以及他那句半玩笑的「我是人奸」:用字典枚举所有句子并 do_research,「几乎可以产生人类所有的知识」。


三、内核里的加载器

(参考时间: 00:29)

加载器是内核实现的一部分——execve(path, argv, envp) 由内核解析 path、完成加载。对应的就是 Linux 内核源码里的 fs/binfmt_elf.c(「有代码的地方就有 bug」,比如 CVE-2024-46826)。

讲师现场让 coding agent 直接打开 Linux 内核(9 万个文件),只给一句话就让它把加载器的关键流程讲清楚:校验 ELF 合法性 → 加载 program headers → 映射 LOAD 段 → 在栈上布置 create_elf_tables(argc/argv/envp/auxv)。

让 AI 直接读 9 万文件的 Linux 内核:定位 binfmt_elf.c 的加载流程

📷 让 AI 直接读 9 万文件的 Linux 内核:定位 binfmt_elf.c 的加载流程
回到原视频 (00:31:00)

编者拓展|为什么 AI 读 Linux 内核这么好用:讲师说,人类把项目维护得「一致性」很好,所以 AI 可以 in-context learning——它一边看代码一边就能做出正确判断。它甚至能顺着 arch/*/include 找到 AT_SYSINFO_EHDR(vDSO 入口) 是怎么被塞进 auxv 的。「一年以前我还得自己打开网页解释,现在我这么多年的努力好像都没了。」


四、Shebang:UNIX 对注释的「妙用」

(参考时间: 00:35)

加载器里还有一个隐藏特性——Shebang(#!)。你写的脚本文件(.sh、.py)为什么能当可执行文件运行?

/* Not ours to exec if we don't start with "#!". */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
        return -ENOEXEC;
binfmt_script.c:#! 作为 Magic Number

📷 binfmt_script.c:#! 作为 Magic Number
回到原视频 (00:38:00)

编者拓展|argv 是怎么被改写的:对 #!A B C 的脚本 S,执行 S x y 时,内核会调用 execve("A", ["A", "B C", "S", "x", "y"], envp)——argv[0] 是你写的解释器,脚本路径被插在前面,解释器(bash/python)据此知道「哪个是脚本文件」。

这里还有个 POSIX 没规定的坑:#! 后面的 B C(中间有空格)应该是一个参数还是两个?Linux 把剩下的整串当作一个参数,而 macOS(BSD)会把 B 和 C 当作两个。所以跨平台时要小心。

Shebang 的 argv 传递:让 AI/程序确认「脚本路径插在哪」

📷 Shebang 的 argv 传递:让 AI/程序确认「脚本路径插在哪」
回到原视频 (00:40:00)


五、为什么需要动态链接

(参考时间: 00:43)

静态链接的世界里,每个可执行文件都自带一份库。问题:/usr/bin 下就有两千多个文件,如果每个都链接一份几 MB 的 libc,就浪费了大量磁盘空间。而且:

动态链接(Dynamic Linking)解决的正是这些:运行库与应用代码分离,整个系统里只有一份 glibc 副本,可以独立升级,也能实现大型项目的模块化。

5.1 用 40 MB 的 bloat 证明「内存里只有一份」

(参考时间: 00:48)

为了证明「动态链接库在内存里也只有一份」,讲师做了个夸张的实验:

如果每个进程都有一份副本,就要 40 GB 内存,系统早炸了。结果:free 显示系统运行非常丝滑(只用了约 4.7 GB,还有 VS Code、Claude Code 在跑)。

sample 加载 40MB 的 libbloat.so,地址每次不同(ASLR / PIC)

📷 sample 加载 40MB 的 libbloat.so,地址每次不同(ASLR / PIC)
回到原视频 (00:50:30)

启动 1000 个 sample,系统内存并未达到 40GB → 动态库只共享一份

📷 启动 1000 个 sample,系统内存并未达到 40GB → 动态库只共享一份
回到原视频 (00:52:30)

编者拓展|lsof 是怎么知道「谁引用了某个文件」的:它遍历 procfs(/proc/<pid>/fd、/proc/<pid>/maps),找到所有引用了该文件的进程——「就像你们 PSP 实验里做的一样」。每一条命令背后,都藏着你做过的实验。

lsof:查看一个进程打开了哪些文件/动态库(实操)

📷 lsof:查看一个进程打开了哪些文件/动态库(实操)
回到原视频 (00:54:30)


六、动态链接程序是怎么加载的(ld.so)

(参考时间: 00:57)

这里有个先有鸡还是先有蛋的问题:

答案藏在 ELF 的 INTERP(interpreter)段:

$ readelf -a a.out | grep -i interpreter
[Requesting program interpreter: /lib/ld-musl-aarch64.so.1]   # musl
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] # glibc

关键事实:动态链接的 a.out,它的第一条指令不是程序自己的 _start,而是 interpreter 的 _start。(讲师说这题要是考试,至少一半同学会错。)

readelf:ELF 的 INTERP 段「写死」了解释器路径

📷 readelf:ELF 的 INTERP 段「写死」了解释器路径
回到原视频 (00:58:00)

用 gdb starti 调试可以看到:初始 PC 落在 ld.so 的 _start(如 _dl_start),然后它开始动态加载所需的库;等所有库加载完,才调用程序自己的 _start(crt1.o),再 __libc_start_main → main。用 info proc mappings(pmap)可以验证:进程第一条指令时,地址空间里只有 a.out + interpreter(+vdso/vvar),libc 还没被映射进来。

gdb starti:第一条指令在 ld.so 的 _start(不是程序的 _start)

📷 gdb starti:第一条指令在 ld.so 的 _start(不是程序的 _start)
回到原视频 (01:02:00)

编者拓展|INTERP 真的只是一个「字符串」:讲师现场把它从 ...aarch64... 改成 ...aarch65...——程序就无法运行了(找不到解释器);再把 ld-musl-aarch65.so.1 创建为一个符号链接,程序又能跑了。这证明「解释器没有任何神秘」,你就是可以换成任何一个自己实现的加载器(vibe code 一个动态加载器,真的能工作)。


七、ld.so 手册与 LD_PRELOAD

(参考时间: 01:09)

讲师说 man 8 ld.so 是一座「打开系统世界大门的金矿」——「仅仅是 man -P cat ld.so | claude Summarize,今天就值回票价了」。里面藏着:

环境变量 作用
LD_LIBRARY_PATH 动态库搜索路径(类似 PATH)
LD_DEBUG=libs ls 输出加载了哪些库的调试信息
LD_BIND_NOW=1 启动时解析所有符号(用于调试)
LD_SHOW_AUXV=1 打印 auxv 的内容
LD_PRELOAD 预加载一个 .so,覆盖任意符号
LD_DEBUG=libs:观察一个真实程序(如 code)加载了哪些库

📷 LD_DEBUG=libs:观察一个真实程序(如 code)加载了哪些库
回到原视频 (01:12:30)

编者拓展|LD_SHOW_AUXV 与「计算机世界没有魔法」:LD_SHOW_AUXV=1 能把辅助向量打印出来——你会看到 AT_SYSINFO_EHDR(vDSO 地址)每次运行都变(ASLR)、随机数种子也变。这再次印证:内核通过栈把「内存布局、CPU 能力、链接器位置」等信息传给运行时。

LD_SHOW_AUXV=1:auxv 里能看到 AT_SYSINFO_EHDR(vDSO)等

📷 LD_SHOW_AUXV=1:auxv 里能看到 AT_SYSINFO_EHDR(vDSO)等
回到原视频 (01:14:30)

7.1 LD_PRELOAD:一个神奇的 hook 机制

(参考时间: 01:16)

动态链接时符号解析是「先到先得」:谁先被加载并首次满足未定义符号,谁就生效。所以只要用 LD_PRELOAD 在任何库之前加载一个 .so,就能覆盖任意符号——这就是 hook:

讲师现场用它实现了「变速齿轮」:AI 帮他覆盖了所有和时间相关的函数(gettimeofday、clock_gettime、usleep、alarm、select、poll……),在启动时记下时间、维护一个10 倍速的虚拟时间:

$ LD_PRELOAD=./libwheel.so ./nsnake    # 贪吃蛇明显变快
$ LD_PRELOAD=./libwheel.so c matrix    # 黑客帝国字母掉得飞快,终端都卡了
$ LD_PRELOAD=./libwheel.so sleep 1     # 0.1 秒就返回
LD_PRELOAD 实现的变速齿轮:nsnake / cmatrix 明显加速

📷 LD_PRELOAD 实现的变速齿轮:nsnake / cmatrix 明显加速
回到原视频 (01:19:30)

编者说明:这和上一讲的「变速齿轮」是同一件事——只要你能拦截 API 调用,你就能重定义程序的世界。上一讲用调试器打断点,这一讲用 LD_PRELOAD 覆盖符号。


八、动态链接是怎么实现的:PLT 与 GOT

(参考时间: 01:22)

「所有计算机科学的问题,都可以通过增加一层间接(indirection)来解决。」编译器对 foo()(同 binary)和 printf()(动态库)一视同仁,都生成一条 call + pc-relative32 重定位。区别在于:

于是编译器生成一小段跳板代码 printf@plt(PLT,Procedure Linkage Table):

graph LR
    C["call printf@plt"] --> P["printf@plt:查表<br>call *GOT[printf]"]
    G["GOT(Global Offset Table)<br>存放 printf 的真实地址<br>(需要重定位)"] --> P

PLT 里查表(GOT),再从 GOT 拿到真实地址跳过去。

编者拓展|PLT 没解决「数据」的问题:代码可以「两级跳转」,但数据不行。extern int x; x = 1; 里,x 可能在另一个 .so: - 同库:mov $1, x(%rip)(高效); - 跨库:mov GOT[x], %rdi; mov $1, (%rdi)(低效)。

-fPIC 默认会为所有外部数据增加一层间接访问,带来性能损耗;可以用 __attribute__((visibility("hidden"))) 告诉编译器「这个符号不用导出」,从而省掉这层间接。这也是现代系统设计的一个「无奈的地方」。


总结


附:官方参考与延伸阅读

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

主题 链接
UNIX a.out(5) 手册(FreeBSD) https://man.freebsd.org/cgi/man.cgi?a.out(5)
System V ABI https://jyywiki.cn/OS/manuals/sysv-abi.pdf
Funny Little Executable(FLE) https://jyywiki.cn/OS/demos/virtualization/fle
Karpathy 的 LLM Wiki 思路 https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
Linux fs/binfmt_elf.c https://elixir.bootlin.com/linux/latest/source/fs/binfmt_elf.c
Linux fs/binfmt_script.c https://elixir.bootlin.com/linux/latest/source/fs/binfmt_script.c
CVE-2024-46826(ELF 加载器) https://cvefeed.io/vuln/detail/CVE-2024-46826
Shebang Demo https://jyywiki.cn/OS/demos/virtualization/shebang
动态链接库是共享的吗(bloat) https://jyywiki.cn/OS/demos/virtualization/bloat
变速齿轮(wheel) https://jyywiki.cn/OS/demos/virtualization/wheel
ld.so(8) 手册 https://man7.org/linux/man-pages/man8/ld.so.8.html
ldd(1) 手册 https://man7.org/linux/man-pages/man1/ldd.1.html
OSTEP:Operating Systems: Three Easy Pieces(第 17 章) https://pages.cs.wisc.edu/~remzi/OSTEP/

阅读材料:教科书 Operating Systems: Three Easy Pieces 第 17 章(Free Space Management);第 18–23 章(分页、TLB、交换等)为选读。

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