前言:从“代码冗余”到“按需共享”
在前面的章节中,我们的程序都是静态链接的:每个可执行文件都包含了完整的libc副本。这意味着100个使用printf的程序就要在磁盘和内存中存储100份相同的printf实现。这不仅浪费空间,更致命的是无法独立更新:修复一个libc安全漏洞需要重新编译所有程序。
动态链接将这个问题优雅地分解为两层:加载时共享(多个进程映射同一物理页的只读代码段)和运行时绑定(通过PLT/GOT表延迟解析符号地址)。本章我们将实现一个最小但完整的动态链接器(ld.so),让你的OS支持.so共享库、符号解析和延迟绑定,迈出从“玩具系统”到“类Unix环境”的关键一步。
本章里程碑:
- ✅ ELF动态段解析:DT_NEEDED、DT_STRTAB、DT_SYMTAB、DT_PLTGOT等
- ✅ 共享库加载器:mmap .so文件,处理重定位
- ✅ PLT/GOT机制实现:首次调用触发resolver,后续直接跳转
- ✅ 符号解析器:支持STB_GLOBAL/STB_WEAK,处理版本与冲突
- ✅ 用户态ld.so入口:作为解释器被内核自动加载
- ✅ 验证:多个进程共享libc.so文本段,printf首次调用正确绑定
核心概念:PLT/GOT不是“技巧”,而是“位置无关性的代价”
为什么不能直接在代码中写绝对地址?
共享库的代码段必须在任意基址加载(ASLR、多进程共享)。如果call printf指令中硬编码了printf的绝对地址,那么每次加载基址变化都需要修改代码段——这破坏了只读共享的前提。解决方案是将“地址”从代码段剥离到数据段:代码段只包含对GOT表的相对引用(位置无关),而GOT表作为可写数据段,在加载时或首次调用时被填入真实地址。这就是PLT/GOT的本质:用一次间接跳转换取代码段的纯只读共享。
⚠️关键洞察:PLT(Procedure Linkage Table)是代码段中的跳板,GOT(Global Offset Table)是数据段中的地址槽。两者配合实现了延迟绑定:函数首次被调用时才解析地址,未调用的函数永远不付出解析开销。这对于大型程序启动性能至关重要。如果你的实现选择在加载时一次性解析所有符号(eager binding),就失去了延迟绑定的启动优势;如果完全不做PLT而要求加载时填充所有GOT,就无法支持真正的按需解析。
符号解析的“作用域链”
当libc.so内部调用malloc时,应该解析到自己的malloc还是主程序的malloc?POSIX定义了广度优先搜索顺序:先搜主程序,再按DT_NEEDED顺序搜依赖库。这允许主程序“覆盖”库函数(如malloc hook),但也意味着符号解析必须维护一个全局有序的作用域链。错误的搜索顺序会导致难以诊断的行为差异:同一个.so在不同程序中表现不同,仅仅因为主程序的符号表内容不同。
ld.so的双重身份
动态链接器本身也是一个共享库(通常为/lib/ld-linux.so.2),但它又是所有动态程序的“引导者”。内核在exec时检测到ELF头中的PT_INTERP段,就先加载ld.so,然后将控制权交给它,由它完成主程序和所有依赖库的加载、重定位、初始化,最后跳转到主程序入口。ld.so不能使用任何外部符号(因为它就是解析符号的人),必须是自包含的。这是实现中最容易踩坑的地方:不小心引用了未定义的libc函数,链接通过但运行时崩溃。
实战代码
ELF动态段结构定义
// include/elf_dyn.h #ifndef _ELF_DYN_H #define _ELF_DYN_H #include "elf.h" // Dynamic section entry types #define DT_NULL 0 #define DT_NEEDED 1 #define DT_STRTAB 5 #define DT_SYMTAB 6 #define DT_STRSZ 10 #define DT_PLTGOT 3 #define DT_JMPREL 23 #define DT_PLTRELSZ 2 #define DT_BIND_NOW 24 typedef struct { int32_t d_tag; union { uint32_t d_val; uint32_t d_ptr; } d_un; } Elf32_Dyn; // Relocation entries (for PLT) typedef struct { uint32_t r_offset; // GOT slot address uint32_t r_info; // symbol index + type } Elf32_Rel; #define ELF32_R_SYM(info) ((info) >> 8) #define ELF32_R_TYPE(info) ((unsigned char)(info)) #define R_386_JMP_SLOT 7 // PLT relocation type #endif共享库加载与符号解析
// kernel/dynlink.c #include "elf_dyn.h" #include "vfs.h" #include "memory.h" typedef struct shared_lib { char name[64]; uint32_t base; // mmap base address Elf32_Sym *symtab; char *strtab; uint32_t strsz; Elf32_Rel *pltrel; uint32_t pltrelsz; uint32_t *gotplt; // GOT.PLT base struct shared_lib *next; } shared_lib_t; static shared_lib_t *loaded_libs = NULL; // 全局已加载链表 // ★ 加载单个.so并解析其动态段 shared_lib_t *load_shared_library(const char *path) { file_t *f = vfs_open(path, O_RDONLY); if (!f) return NULL; // mmap整个文件(简化:实际应只映射PT_LOAD段) uint32_t base = do_mmap(f, 0, PROT_READ | PROT_EXEC); if (base == MAP_FAILED) { vfs_close(f); return NULL; } Elf32_Ehdr *ehdr = (Elf32_Ehdr *)base; Elf32_Phdr *phdr = (Elf32_Phdr *)(base + ehdr->e_phoff); shared_lib_t *lib = kmalloc(sizeof(shared_lib_t)); strncpy(lib->name, path, sizeof(lib->name)-1); lib->base = base; // ★ 遍历PT_DYNAMIC段提取关键信息 for (int i = 0; i < ehdr->e_phnum; i++) { if (phdr[i].p_type != PT_DYNAMIC) continue; Elf32_Dyn *dyn = (Elf32_Dyn *)(base + phdr[i].p_vaddr); for (; dyn->d_tag != DT_NULL; dyn++) { switch (dyn->d_tag) { case DT_SYMTAB: lib->symtab = (Elf32_Sym *)(base + dyn->d_un.d_ptr); break; case DT_STRTAB: lib->strtab = (char *)(base + dyn->d_un.d_ptr); break; case DT_STRSZ: lib->strsz = dyn->d_un.d_val; break; case DT_JMPREL: lib->pltrel = (Elf32_Rel *)(base + dyn->d_un.d_ptr); break; case DT_PLTRELSZ: lib->pltrelsz = dyn->d_un.d_val; break; case DT_PLTGOT: lib->gotplt = (uint32_t *)(base + dyn->d_un.d_ptr); break; } } break; } // 加入全局链表(头部插入 → 后加载的优先级高) lib->next = loaded_libs; loaded_libs = lib; vfs_close(f); return lib; } // ★ 符号解析:广度优先搜索 uint32_t resolve_symbol(const char *name) { for (shared_lib_t *lib = loaded_libs; lib; lib = lib->next) { // 遍历符号表查找匹配名称 // 简化:实际应使用hash table (DT_HASH/DT_GNU_HASH) Elf32_Sym *sym = lib->symtab; char *str = lib->strtab; // 注意:需要知道符号数量,此处简化假设以STN_UNDEF结尾 for (int i = 0; /* bound check */; i++) { if (sym[i].st_name == 0 && i > 0) break; if (strcmp(str + sym[i].st_name, name) == 0 && ELF32_ST_BIND(sym[i].st_info) == STB_GLOBAL && sym[i].st_shndx != SHN_UNDEF) { return lib->base + sym[i].st_value; } } } return 0; // 未找到 }PLT Resolver与GOT填充
// user/ld_so.c (运行在用户态,但属于ld.so自身) #include "elf_dyn.h" #include "syscall.h" // ★ PLT stub模板(每个未解析函数对应一个) // plt[n]: push n; jmp plt_resolver // 实际由链接器生成,此处展示resolver逻辑 extern shared_lib_t *_main_prog; // 主程序描述符 // ★ 通用PLT resolver:由PLT stub跳转至此 void __attribute__((regparm(1))) plt_resolver(uint32_t reloc_index) { // 获取当前正在解析的库(通过栈回溯或TLS传递,简化假设为主程序) shared_lib_t *lib = _main_prog; Elf32_Rel *rel = &lib->pltrel[reloc_index]; uint32_t sym_idx = ELF32_R_SYM(rel->r_info); const char *sym_name = lib->strtab + lib->symtab[sym_idx].st_name; // ★ 解析符号真实地址 uint32_t real_addr = resolve_symbol(sym_name); if (real_addr == 0) { // 符号未找到,打印错误并退出 write(2, "undefined symbol: ", 18); write(2, sym_name, strlen(sym_name)); write(2, "\n", 1); exit(127); } // ★ 填充GOT slot:下次调用直接跳转,不再经过resolver uint32_t *got_slot = (uint32_t *)(lib->base + rel->r_offset); *got_slot = real_addr; // ★ 跳转到真实函数(不返回PLT stub) // 通过修改返回地址或直接jmp实现 asm volatile ("jmp *%0" :: "r"(real_addr)); } // ★ ld.so入口点:内核将控制权交到这里 void _start_ld_so(uint32_t *user_stack) { // user_stack布局: [argc][argv...][NULL][envp...][NULL][auxv...] int argc = user_stack[0]; char **argv = (char **)&user_stack[1]; // 1. 加载主程序的所有DT_NEEDED库 // 2. 递归加载依赖库 // 3. 执行所有.init_array / .init函数 // 4. 跳转到主程序e_entry Elf32_Ehdr *main_ehdr = (Elf32_Ehdr *)get_main_program_base(); ((void (*)())main_ehdr->e_entry)(); // 不应到达此处 exit(0); }内核exec适配:检测PT_INTERP
// kernel/exec.c - execve中的动态链接检测 int do_execve(const char *path, char **argv, char **envp) { // ... 原有ELF加载逻辑 ... // ★ 检查是否存在PT_INTERP段 uint32_t interp_path = 0; for (int i = 0; i < ehdr.e_phnum; i++) { if (phdr[i].p_type == PT_INTERP) { interp_path = load_addr + phdr[i].p_vaddr; break; } } if (interp_path) { // ★ 动态程序:加载解释器并转移控制权 // 将原始程序的load_addr、argc、argv等通过auxv传递给ld.so uint32_t ld_base = load_elf((char *)interp_path); if (ld_base == 0) return -ENOEXEC; // 构造auxv向量(AT_PHDR, AT_ENTRY, AT_BASE等) setup_auxv(user_stack, ehdr, load_addr, ld_base); // ★ 跳转到ld.so入口而非主程序入口 frame->eip = ld_base + get_interp_entry(ld_base); } else { // 静态程序:直接跳转主程序入口(原有逻辑) frame->eip = load_addr + ehdr.e_entry; } return 0; }关键细节解析
1. 为什么PLT resolver要“跳转”而非“返回”到真实函数?
PLT stub的执行流是:call func→ PLT[n] → push reloc_idx → jmp resolver。如果resolver解析完后用ret返回,它会回到PLT[n]的下一条指令(通常是死循环或nop),而非真实函数。resolver必须直接jmp到真实地址,使得真实函数的ret直接回到原始caller。这要求resolver在jmp前不调用任何会破坏栈帧的函数,或者精心维护栈布局使真实函数看到的栈与直接调用一致。
2. 为什么符号解析必须区分STB_GLOBAL和STB_WEAK?
弱符号(weak)允许被强符号覆盖而不报错。例如libc提供默认的__pthread_key_create弱实现,如果主程序或另一个库提供了强实现,则使用强版本。如果解析器不区分强弱,就会错误地报告“重复定义”或使用错误的版本。正确的优先级是:强符号 > 弱符号,且同级别时先出现的胜出。忽略这一点会导致glibc兼容性问题和微妙的行为差异。
3. 为什么ld.so不能使用标准的libc?
因为ld.so的职责就是加载和初始化libc。如果它自己依赖libc,就形成了“鸡生蛋”的死锁。ld.so必须是自包含的:要么内联所有需要的函数(memcpy、strcmp等),要么使用编译器内置函数(__builtin_memcpy),要么直接使用syscall包装器。这也是为什么musl的ldso和glibc的rtld都有大量手写的汇编和内联工具函数。在教学实现中,建议将必要的字符串/内存操作函数直接编译进ld.so目标文件。
调试Checklist:动态链接排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| exec动态程序立即#PF | PT_INTERP路径错/ld.so未正确加载/auxv构造错误 | kprintf interp_path字符串;dump ld.so加载后的EIP和ESP;验证auxv中AT_BASE值等于ld.so基址 |
| undefined symbol错误 | 符号名拼写错/搜索顺序错/symtab索引计算错 | hexdump strtab确认符号名存在;添加resolve_symbol日志打印每步搜索的库和符号;验证ELF32_R_SYM提取的索引在symtab范围内 |
| 首次调用crash但后续正常 | GOT slot地址计算错/resolver栈不平衡/real_addr为0 | dump rel->r_offset + base是否指向有效GOT位置;在resolver入口/出口dump ESP差值;确认resolve_symbol返回值非零 |
| 第二次调用仍走resolver | GOT写入地址错/缓存一致性/mprotect阻止写入 | hexdump GOT slot内容确认已被填充;验证GOT所在段有PROT_WRITE权限;确认*got_slot赋值后无异常 |
| 符号解析到错误版本 | 搜索顺序不符合BFS/弱符号处理错/版本号忽略 | dump loaded_libs链表顺序;对比LD_DEBUG=bindings输出;确认STB_WEAK符号仅在无STB_GLOBAL时使用 |
| ld.so自身crash | 引用了未解析的外部符号/栈对齐问题/nostdlib未生效 | 用nm检查ld.so是否有U符号;确认编译选项含-nostdlib -fno-builtin;验证_start入口ESP 16字节对齐 |
🔧黄金法则:动态链接调试的终极武器是符号解析追踪器。在resolve_symbol中添加详细日志:打印搜索的每个库名、每个候选符号的名称/绑定类型/shndx、最终选择的符号及其来源。在PLT resolver中打印reloc_index、符号名、解析到的地址、GOT slot地址。动态链接bug几乎总是“某个符号在某个上下文中被解析到了错误的地址”:搜索顺序偏差、弱符号误判、基址计算溢出。只有完整的解析轨迹才能定位这些语义层面的错误。不要只看最终crash地址——那只是症状,病因在符号解析的决策链中。
本章小结与下一步
今天我们让程序从“自包含孤岛”进化为“共享生态”:
- ✅ 实现了ELF动态段解析与共享库加载器
- ✅ 构建了PLT/GOT延迟绑定机制,首次调用才付出解析开销
- ✅ 实现了广度优先符号解析器,支持全局/弱符号语义
- ✅ 内核支持PT_INTERP自动加载ld.so,透明切换动/静态程序
- ✅ 验证了多进程代码段物理共享与符号正确绑定
从此,你的操作系统拥有了类Unix的二进制兼容基础。当你第一次看到多个进程共享同一份libc.so文本段、printf首次调用自动绑定、自定义.so被正确加载时,你见证的是OS从“能运行程序”到“能运行真实软件生态”的决定性跃迁。
下一章预告:《终端会话与作业控制:fg/bg/jobs/Ctrl+Z》
当前的Shell只能串行执行命令,无法暂停、恢复、切换前台作业。下一章将实现进程组、会话、TTY作业控制协议,让你的Shell支持完整的交互式作业管理,真正成为可用的命令行环境。
参考资料
- System V ABI i386 Supplement: Dynamic Linking, PLT/GOT Specification
- Linux Kernel:
fs/binfmt_elf.c,arch/x86/kernel/vdso/ - Ulrich Drepper, "How To Write Shared Libraries" (2011)
- musl libc:
ldso/dynlink.c,ldso/dlstart.c - 本系列完整代码:[你的GitHub仓库链接](Commit:
d1y2n3l)
📝作者注:这是《从零手写操作系统》系列的第27篇。动态链接是整个教程中规范复杂度最高、工具链依赖最强的章节。ELF规范的模糊地带、链接器行为的隐式约定、ABI的细节差异,都会导致“理论上正确但实际不工作”的困境。强烈建议先用最简单的单库无依赖场景验证PLT/GOT通路,再逐步增加多库、符号覆盖、初始化顺序等复杂特性。把“库加载”、“符号解析”、“PLT绑定”分成三个独立里程碑,并用readelf/objdump反复验证每一步的ELF结构正确性,是避免在二进制格式泥潭中窒息的关键纪律。下一章,我们让Shell从“命令执行器”走向“交互式工作环境”!