这一关回答:从 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,在这里汇合。
同一个 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 可执行文件)。
fs/binfmt_elf.c 的 load_elf_binary(已验证)是内核侧 ELF 加载的全部:
ELFMAG 为 "\177ELF"(elf.h 已验证)——对不上直接拒载。PT_LOAD 段经 elf_map → vm_mmap 建映射(已验证)——这就是 M04 的回响:加载一个程序,本质上是一次带文件偏移的 mmap。p_memsz > p_filesz 的部分是 bss,内核补匿名零页。PT_INTERP,读出解释器路径字符串,用 bprm_open_interpreter() 打开 ld.so(已验证)。收尾两步: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,是动态链接器。
延迟绑定的流程只有四步: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)、延迟绑定——三次都是"先跑起来,用到再算"。懒惰不是偷懒,是操作系统最赚钱的设计模式。
静态链接:无 PT_INTERP、无 ld.so、无延迟绑定——启动快、行为自包含;代价是体积大,libc 出安全更新要重编整个二进制。动态链接:共享 libc、体积小;代价是 ld.so 的启动成本 + 首次调用的 resolver 开销。
内核视角的判据简单到只有一条:有无 PT_INTERP 段。没有它,elf_entry 就是 ELF 头里的 e_entry(程序自己的入口);有它,入口先交给解释器。静态/动态之分,在内核眼里就是"要不要多加载一个解释器"。
.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 的函数,加载直接失败。一句话:谁拥有地址空间,谁当链接器。
把二十关串成一条线: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)是内核私有的。用户态那套是为用户地址空间设计的,搬不过来——谁拥有地址空间,谁当链接器,没有例外。
execve 扔掉旧 mm 后,新地址空间的内容,正是 load_elf_binary 按 PT_LOAD 一段段 mmap 出来的。elf_map → vm_mmap;bss(p_memsz > p_filesz)是"文件后跟匿名零页"的经典实例;PT_GNU_RELRO 与 M04 的"页表权限位"呼应——权限最终都落在页表项上。exec init 的链条终点,就是一次 load_elf_binary。二十关的起点和终点是同一个系统调用。-O2 产出的 .o,最终也要过链接这一关才算成品。出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。
PT_LOAD=1、PT_DYNAMIC=2、PT_INTERP=3(include/uapi/linux/elf.h)。连带记:ET_REL=1、ET_EXEC=2、ET_DYN=3。
ET_REL=1 可重定位目标(.o/.ko),ET_EXEC=2 传统可执行文件,ET_DYN=3 动态库(含 PIE 可执行)。.ko 是 ET_REL——它不是可执行文件,是等着被内核链接的"半成品"。
能。load_elf_binary 全程只读 program header(PT_LOAD 建映射、PT_INTERP 找解释器),section header 是链接器的视图,对执行是可选的。readelf -S 为空但 -l 有内容,即可执行——这是"两种视图"最直接的铁证。
三步:读出解释器路径字符串 → bprm_open_interpreter() 打开 ld.so 并为它建映射 → START_THREAD 时 elf_entry 取解释器的入口而非程序的 e_entry。第一行用户态代码是 ld.so,不是 main。
第一次:call foo@plt → PLT 跳 GOT[n](初始指向 resolver)→ _dl_runtime_resolve 算出真实地址回填 GOT[n] → 跳过去。第二次:GOT[n] 已是真实地址,直达。GOT 是"地址缓存",resolver 只付费一次。
解决 GOT 可写带来的劫持面:攻击者改写 GOT 表项即可劫持函数调用。RELRO 在重定位完成后把该段 remap 只读。代价:启动时多一次权限切换,且"延迟绑定"要求重定位完成前必须可写——Full RELRO 干脆放弃延迟绑定,启动时全算完(启动慢、更安全)。
进程加载只需要"把段放到内存"——program header 够了;模块加载是链接,需要符号表做未定义符号解析(find_sec 找 .symtab/.strtab,find_symbol 查 __ksymtab)。加载要的是"放哪",链接要的是"叫什么"——视图跟着需求走。
加载失败。find_symbol 只在内核与已加载模块的导出符号(__ksymtab)里找,找不到未定义符号就无法完成重定位——模块加载在符号解析阶段直接报错,而不是等到运行时才崩。这是"链接期检查"的价值。
因为 main 依赖的 libc 还没准备好:符号没解析、GOT 没填、.so 还没加载——先有链接器,后有 main。内核交出入口是机制与策略分离:内核只负责"映射 + 跳转"(机制),"怎么算符号"(策略)是用户态的事,ld.so 随 libc 升级而内核不用动。
参考链条: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)、延迟绑定(本关)——二十关讲的其实是同一种智慧。
load_elf_binary、PT_INTERP 分支、elf_map/vm_mmapapply_relocate、find_sec、find_symbol(__ksymtab)PT_/ET_/SHT_ 常量、Elf64_Ehdr/Phdrexecve → search_binary_handler 的入口链条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 只付费一次。内核不参与这一切——地址空间搭好,内核就退场。
在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改;
这是第二十关,也是最后一关——二十关,全部打通。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。