第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、原神)。学完操作系统之后,你会对它有更深的理解:
- 它是操作系统里的一个对象——一个普通的文件(你可以
cat、hexdump、甚至用编辑器改); - 它是一个描述进程初始内存布局的数据结构——这才是更精确的定义。
为什么会「可执行」?因为 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)
回到原视频 (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 的结构:一个描述进程初始状态的数据结构
回到原视频 (00:05:00)
1.2 ELF 为什么「读起来想吐」
(参考时间: 00:07)
讲到 ELF,就是「被《计算机系统基础》支配的恐惧」:System V ABI 手册(Section 3.4 Process Initialization、Section 4 Object Files)非常难读,细节海量,考试要考,同学「都吐了」,连 CSAPP 那一章也「讲得不怎么样」——任课老师也在吐血。
为什么这么难? 因为 ELF 是为机器效率设计的:
- 它是给机器读的数据结构,要高效、紧凑:大量 bitfield、offset、交叉引用;
- 而人类偏向「信息立即可见」的平坦结构;
- 举例:符号
_start这个字符串不在原地,而是存在符号表里;引用它需要一个 offset(「在文件头+4587 字节处」)。人脑算不了 offset,于是讲着讲着你注意力就涣散了; - 权限也是 bitfield 编码的(代码 = 读+执行、数据 = 读+写),必须经过一层翻译才能被人理解。
编者说明:讲师给了一个「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/符号化格式
回到原视频 (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/指针结构彻底去掉,用「浪费空间」换「人好懂」
回到原视频 (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:重定位信息就是「这里要填一个数」
回到原视频 (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 的加载流程
回到原视频 (00:31:00)
编者拓展|为什么 AI 读 Linux 内核这么好用:讲师说,人类把项目维护得「一致性」很好,所以 AI 可以 in-context learning——它一边看代码一边就能做出正确判断。它甚至能顺着
arch/*/include找到AT_SYSINFO_EHDR(vDSO 入口) 是怎么被塞进 auxv 的。「一年以前我还得自己打开网页解释,现在我这么多年的努力好像都没了。」
四、Shebang:UNIX 对注释的「妙用」
(参考时间: 00:35)
加载器里还有一个隐藏特性——Shebang(#!)。你写的脚本文件(.sh、.py)为什么能当可执行文件运行?
strace ./a.sh会看到:内核用execve执行了#!后面的解释器,并把脚本路径作为参数传进去;- 这是 Linux 硬编码的:
fs/binfmt_script.c先读文件头,只有当buf[0]=='#' && buf[1]=='!'时,才把它当作 script 处理:打开#!后的解释器(「Magic Number」)。
/* Not ours to exec if we don't start with "#!". */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
return -ENOEXEC;
📷 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/程序确认「脚本路径插在哪」
回到原视频 (00:40:00)
五、为什么需要动态链接
(参考时间: 00:43)
静态链接的世界里,每个可执行文件都自带一份库。问题:/usr/bin 下就有两千多个文件,如果每个都链接一份几 MB 的 libc,就浪费了大量磁盘空间。而且:
- 想给 libc 打一个安全补丁?——一万个静态链接的程序都得重新编译,难以独立升级;
- 也希望能把大型项目分解(
libjvm.so、libart.so……),比如把 NEMU 编译成nemu.so(「把 CPU 插上主板」)。
动态链接(Dynamic Linking)解决的正是这些:运行库与应用代码分离,整个系统里只有一份 glibc 副本,可以独立升级,也能实现大型项目的模块化。
5.1 用 40 MB 的 bloat 证明「内存里只有一份」
(参考时间: 00:48)
为了证明「动态链接库在内存里也只有一份」,讲师做了个夸张的实验:
- 写一段内联汇编,生成 10M 条 NOP,编进
libbloat.so(arm 上一条 NOP 4 字节 → 40 MB); sample.c用dlopen加载它、调用bloat()、打印它的地址;- 启动 1000 个
sample。
如果每个进程都有一份副本,就要 40 GB 内存,系统早炸了。结果:free 显示系统运行非常丝滑(只用了约 4.7 GB,还有 VS Code、Claude Code 在跑)。
- 打印出来的
bloat地址每次不同(在0x7f...范围)——这是 ASLR,也说明动态库是位置无关代码(PIC); lsof libbloat.so显示有 1000 个进程打开/映射了它。
📷 sample 加载 40MB 的 libbloat.so,地址每次不同(ASLR / PIC)
回到原视频 (00:50:30)
📷 启动 1000 个 sample,系统内存并未达到 40GB → 动态库只共享一份
回到原视频 (00:52:30)
编者拓展|
lsof是怎么知道「谁引用了某个文件」的:它遍历 procfs(/proc/<pid>/fd、/proc/<pid>/maps),找到所有引用了该文件的进程——「就像你们 PSP 实验里做的一样」。每一条命令背后,都藏着你做过的实验。
📷 lsof:查看一个进程打开了哪些文件/动态库(实操)
回到原视频 (00:54:30)
六、动态链接程序是怎么加载的(ld.so)
(参考时间: 00:57)
这里有个先有鸡还是先有蛋的问题:
- 每个程序都有
_start(在crt1.o里,静态链接); _start会调用__libc_start_main——但这本身是库函数的代码,理应是动态链接的、系统里只有一份;- 那么,谁来加载动态链接的 libc?谁调用
__libc_start_main?
答案藏在 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 段「写死」了解释器路径
回到原视频 (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)
回到原视频 (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)加载了哪些库
回到原视频 (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)等
回到原视频 (01:14:30)
7.1 LD_PRELOAD:一个神奇的 hook 机制
(参考时间: 01:16)
动态链接时符号解析是「先到先得」:谁先被加载并首次满足未定义符号,谁就生效。所以只要用 LD_PRELOAD 在任何库之前加载一个 .so,就能覆盖任意符号——这就是 hook:
- 覆盖
malloc,在调用前后打印日志 → 非侵入式的 malloc/free trace(能发现 use-after-free); - 覆盖
glXSwapBuffers→ 透视外挂; - 劫持
rand()→ 可控的「随机」。
讲师现场用它实现了「变速齿轮」: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 明显加速
回到原视频 (01:19:30)
编者说明:这和上一讲的「变速齿轮」是同一件事——只要你能拦截 API 调用,你就能重定义程序的世界。上一讲用调试器打断点,这一讲用
LD_PRELOAD覆盖符号。
八、动态链接是怎么实现的:PLT 与 GOT
(参考时间: 01:22)
「所有计算机科学的问题,都可以通过增加一层间接(indirection)来解决。」编译器对 foo()(同 binary)和 printf()(动态库)一视同仁,都生成一条 call + pc-relative32 重定位。区别在于:
foo在同一个 binary 里,链接时能算出地址,直接填数字;printf的地址链接时不知道,而且 64 位地址空间根本不在 4 字节相对跳转范围内。
于是编译器生成一小段跳板代码 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")))告诉编译器「这个符号不用导出」,从而省掉这层间接。这也是现代系统设计的一个「无奈的地方」。
总结
- 可执行文件 = 进程初始状态的描述(一个文件、一个字节序列、一个数据结构);
execve的pathname描述的正是「把什么搬到哪、哪些需要重定位」。 - 从
a.out到 ELF:复杂的东西都是从简单演变来的;ELF 为机器效率设计(bitfield/offset/交叉引用),对人类极不友好。 - 你可以把 ELF 转成任何「等价但好读」的形式(FLE 的 JSON、甚至 Markdown-based ELF),再配一个加载器——重定位不过是「填一个数字」,加载不过是「
mmap到内存」。 - 加载器在内核里(
binfmt_elf.c):布置 initial process stack(argc/argv/envp/auxv)、映射PT_LOAD。Shebang 由binfmt_script.c实现,#!是 Magic Number,且argv的改写方式在 Linux/BSD 上不一致。 - 动态链接:库在磁盘和内存里都只有一份(40 MB × 1000 进程也不会炸)、可独立升级、便于模块化;动态库是 PIC,受 ASLR 影响。
- 动态程序的第一条指令在
ld.so(ELF 的 INTERP 段),它先加载所有库,再跳到程序的_start。 ld.so手册是金矿:LD_LIBRARY_PATH、LD_DEBUG、LD_BIND_NOW、LD_SHOW_AUXV(AT_SYSINFO_EHDR)、LD_PRELOAD。LD_PRELOAD是万能 hook:覆盖任意符号——能实现变速齿轮、透视外挂、可控随机、malloc trace……- PLT/GOT:代码用「两级跳转」(PLT 查 GOT)解决动态地址;数据则靠
-fPIC增加一层间接访问(可以调优,但很难根治)。
附:官方参考与延伸阅读
本讲的讲义见课程主页,以下链接按书中出现顺序整理:
| 主题 | 链接 |
|---|---|
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/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。