审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
main 之前,ld.so 先替你算完地址——PLT/GOT 是"先跑起来,再慢慢算地址"的懒惰。
2026-09-26 · ELF, Dynamic Linking and Kernel Modules

第 20 关 · 链接与加载:ELF、动态链接与内核模块

定位

这一关回答:从 execve 到 main 之间到底发生了什么?为什么动态链接程序的"入口"不是 main?答案只有三句话:内核只做三件事——按 PT_LOAD 建映射、发现 PT_INTERP 就去加载解释器、把入口点让给解释器;真正的符号解析、重定位、库加载,全是 ld.so 在用户态干的;PLT/GOT 延迟绑定是"第一次调用才算地址"的懒惰——和 COW、缺页一样,"先跑起来,用到再算"是内核世界观里的通用解法。内核模块是同一套 ELF 机制的内核侧复用:.ko 是 ET_REL,内核自己当链接器(apply_relocate)。

这一关的机制支点:ELF 双视图(program header 执行视图 vs section header 链接视图)、load_elf_binary 的 PT_INTERP 分支(bprm_open_interpreter)、START_THREAD 交出 elf_entry、PLT/GOT 延迟绑定、内核模块加载(kernel/module/main.c)。这是第二十关,也是收官——M01 的 execve、M04 的 mmap 建映射、M02 的 init,在这里汇合。

核心机制深讲

20.1 ELF 的两种视图:链接器看左边,内核看右边

同一个 ELF 文件有两种视图。链接视图(section header):.text/.data/.symtab/.rel.*——给链接器看的(include/uapi/linux/elf.h 已验证:SHT_PROGBITS=1、SHT_SYMTAB=2、SHT_NOBITS=8、SHF_ALLOC=0x2)。执行视图(program header):PT_LOAD、PT_DYNAMIC、PT_INTERP——给加载器看的(同文件已验证:PT_LOAD=1、PT_DYNAMIC=2、PT_INTERP=3)。

铁证:load_elf_binary 全程只读 program header——把 section header 整个 strip 掉,程序照样跑。文件类型同样三段式(已验证):ET_REL=1(可重定位:.o/.ko)、ET_EXEC=2(传统可执行)、ET_DYN=3(动态库,以及 PIE 可执行文件)。

20.2 load_elf_binary:内核只做三件事

fs/binfmt_elf.c 的 load_elf_binary(已验证)是内核侧 ELF 加载的全部:

收尾两步:create_elf_tables 把 argc/argv/envp/auxv 摆上用户栈(auxv 里有 AT_PHDR、AT_ENTRY、AT_BASE 等,已验证);最后 START_THREAD(elf_ex, regs, elf_entry, bprm->p)(已验证)——有解释器时,elf_entry 是 ld.so 的入口。动态链接程序的第一行用户态代码不是 main,是动态链接器。

20.3 PLT/GOT:第一次调用才算账

延迟绑定的流程只有四步:call foo@plt → PLT 跳 GOT[n] → 首次 GOT[n] 指向 resolver(_dl_runtime_resolve:算出真实地址,回填 GOT[n])→ 下次调用直达。第一次付费,后面免单——这就是 deck 里"先跑起来,再慢慢算地址"的懒惰。

分工的边界要划清:这是 ld.so(用户态)的机制,内核不参与。内核把地址空间搭好就退场,符号怎么算、GOT 怎么填,是用户态链接器的事。代价有两笔:首次调用的 resolver 开销;GOT 可写带来的安全面——补丁是 PT_GNU_RELRO(elf.h 已验证):重定位完成后把 GOT 所在段标只读。

连起来看:COW(M01)、缺页分配(M04)、延迟绑定——三次都是"先跑起来,用到再算"。懒惰不是偷懒,是操作系统最赚钱的设计模式。

20.4 静态 vs 动态:没有免费午餐

静态链接:无 PT_INTERP、无 ld.so、无延迟绑定——启动快、行为自包含;代价是体积大,libc 出安全更新要重编整个二进制。动态链接:共享 libc、体积小;代价是 ld.so 的启动成本 + 首次调用的 resolver 开销。

内核视角的判据简单到只有一条:有无 PT_INTERP 段。没有它,elf_entry 就是 ELF 头里的 e_entry(程序自己的入口);有它,入口先交给解释器。静态/动态之分,在内核眼里就是"要不要多加载一个解释器"。

20.5 内核模块:内核自己当链接器

.ko 是 ET_REL(已验证 ET_REL=1)——可重定位目标,不是可执行文件。这里有个漂亮的反转:进程加载用 program 视图,模块加载用 section 视图——因为模块需要 .symtab/.strtab 做符号解析(kernel/module/main.c 的 find_sec 按节名找节,已验证)。

模块加载就是一次"内核侧的动态链接":apply_relocate / apply_relocate_add 给模块做重定位(已验证)——用户态的重定位是 ld.so 做的,模块的重定位是内核自己做的;find_symbol 走 __ksymtab 查内核导出符号(已验证)——引用了未 EXPORT_SYMBOL 的函数,加载直接失败。一句话:谁拥有地址空间,谁当链接器。

20.6 收官:execve("/sbin/init") 的完整链条

把二十关串成一条线:M01 execve → search_binary_handler → binfmt_elf.load_elf_binary → M04:PT_LOAD 段经 vm_mmap 建映射(文件页 + 匿名 bss)→ PT_INTERP:bprm_open_interpreter 加载 ld.so → START_THREAD 跳进 ld.so → ld.so 重定位自身、加载 libc、PLT 延迟绑定 → 跳到 main → main 是 init(M02)→ PID 1 诞生。

二十关就此闭环:进程(M01)→ 地址空间(M04)→ 第一个用户态程序(M02)→ 编译器(M18/M19)→ 链接加载(M20)。ELF 是"静态的二进制",进程是"跑起来的 ELF"——这一关讲的,就是"跑起来"的那个瞬间。

为什么这样设计

为什么内核把动态链接丢给用户态 ld.so,而不是自己做符号解析?

机制与策略分离:符号解析的规则(版本选择、搜索路径、作用域)是"策略",常变且复杂;内核只保留"机制"(映射 + 跳转)。ld.so 随 libc 升级,内核不用动——内核保持最小,用户态保持灵活。

为什么 PLT 要延迟绑定,而不是启动时一次全算完?

大程序的外部符号成千上万,eager binding 的启动延迟高得离谱;而且很多符号一次都用不到——懒惰省的是"算了白算"。代价是首次调用开销和 GOT 可写面(PT_GNU_RELRO 补)。又是熟悉的权衡:启动速度 vs 确定性。

为什么模块用 ET_REL + 内核重定位,不直接复用用户态动态链接那套?

模块要链进内核地址空间,只有内核知道最终地址;且模块依赖的符号表(__ksymtab)是内核私有的。用户态那套是为用户地址空间设计的,搬不过来——谁拥有地址空间,谁当链接器,没有例外。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

1.(记忆)PT_LOAD、PT_DYNAMIC、PT_INTERP 的值各是多少?

PT_LOAD=1、PT_DYNAMIC=2、PT_INTERP=3(include/uapi/linux/elf.h)。连带记:ET_REL=1、ET_EXEC=2、ET_DYN=3。

2.(记忆)ET_REL / ET_EXEC / ET_DYN 各是什么?.ko 是哪一种?

ET_REL=1 可重定位目标(.o/.ko),ET_EXEC=2 传统可执行文件,ET_DYN=3 动态库(含 PIE 可执行)。.ko 是 ET_REL——它不是可执行文件,是等着被内核链接的"半成品"。

3.(推理)把一个 ELF 的 section header 全部 strip 掉,它还能执行吗?为什么?

能。load_elf_binary 全程只读 program header(PT_LOAD 建映射、PT_INTERP 找解释器),section header 是链接器的视图,对执行是可选的。readelf -S 为空但 -l 有内容,即可执行——这是"两种视图"最直接的铁证。

4.(场景)readelf 看到某程序有 INTERP 段,指向 /lib64/ld-linux-x86-64.so.2。这对 load_elf_binary 意味着哪几步?

三步:读出解释器路径字符串 → bprm_open_interpreter() 打开 ld.so 并为它建映射 → START_THREAD 时 elf_entry 取解释器的入口而非程序的 e_entry。第一行用户态代码是 ld.so,不是 main。

5.(推理)PLT 调用 foo:第一次和第二次走的路径有何不同?GOT 表项里写的是什么?

第一次:call foo@plt → PLT 跳 GOT[n](初始指向 resolver)→ _dl_runtime_resolve 算出真实地址回填 GOT[n] → 跳过去。第二次:GOT[n] 已是真实地址,直达。GOT 是"地址缓存",resolver 只付费一次。

6.(权衡)PT_GNU_RELRO 把 GOT 所在段标只读,解决什么问题?付了什么代价?

解决 GOT 可写带来的劫持面:攻击者改写 GOT 表项即可劫持函数调用。RELRO 在重定位完成后把该段 remap 只读。代价:启动时多一次权限切换,且"延迟绑定"要求重定位完成前必须可写——Full RELRO 干脆放弃延迟绑定,启动时全算完(启动慢、更安全)。

7.(推理)为什么进程加载用 program 视图,而内核模块加载反而用 section 视图(找 .symtab)?

进程加载只需要"把段放到内存"——program header 够了;模块加载是链接,需要符号表做未定义符号解析(find_sec 找 .symtab/.strtab,find_symbol 查 __ksymtab)。加载要的是"放哪",链接要的是"叫什么"——视图跟着需求走。

8.(场景)一个 .ko 引用了内核里未 EXPORT_SYMBOL 的函数,insmod 时会发生什么?哪一步报错?

加载失败。find_symbol 只在内核与已加载模块的导出符号(__ksymtab)里找,找不到未定义符号就无法完成重定位——模块加载在符号解析阶段直接报错,而不是等到运行时才崩。这是"链接期检查"的价值。

9.(设计)为什么动态链接程序的"入口点"是 ld.so 而不是 main?内核为什么心甘情愿交出入口?

因为 main 依赖的 libc 还没准备好:符号没解析、GOT 没填、.so 还没加载——先有链接器,后有 main。内核交出入口是机制与策略分离:内核只负责"映射 + 跳转"(机制),"怎么算符号"(策略)是用户态的事,ld.so 随 libc 升级而内核不用动。

10.(收官·难)从 execve("/sbin/init") 到 init 的 main 执行,列出完整调用链,并标出每一步的"懒惰"。

参考链条:execve → search_binary_handler → load_elf_binary:验 ELFMAG → PT_LOAD 经 elf_map/vm_mmap 建映射(懒惰1:文件页按需调入,bss 匿名零页按需分配)→ PT_INTERP → bprm_open_interpreter 加载 ld.so → create_elf_tables 摆栈 → START_THREAD 跳进 ld.so → ld.so 自举、加载 libc(懒惰2:PLT 延迟绑定,第一次调用才算地址)→ 跳 main → PID 1。全链三个"先跑起来,用到再算":缺页(M04)、COW(M01)、延迟绑定(本关)——二十关讲的其实是同一种智慧。

延伸阅读

ELF 的两种视图(同一个文件)
链接视图(section header)            执行视图(program header)
.text / .data / .bss                   PT_LOAD      可读可执行段
.symtab / .strtab                      PT_LOAD      可读写段
.rel.* / .rela.*                       PT_INTERP →  /lib64/ld-linux-…
.debug                                 PT_DYNAMIC → .dynamic

ld 看左边,内核只看右边。
strip 掉左边,程序照样跑——这是铁证。

进程加载是"放"(program 视图),模块加载是"链"(section 视图)——视图跟着需求走。

PLT/GOT 延迟绑定
第一次调用 printf:
  call printf@plt
    → PLT: jmp *GOT[n]        GOT[n] = resolver 地址
    → _dl_runtime_resolve     算出真实地址,回填 GOT[n]
    → jmp 真实 printf
第二次调用 printf:
  call printf@plt
    → PLT: jmp *GOT[n]        GOT[n] = 真实地址,直达

GOT 是"地址缓存",resolver 只付费一次。内核不参与这一切——地址空间搭好,内核就退场。

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改;
这是第二十关,也是最后一关——二十关,全部打通。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。