恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
《从零手写操作系统 (27):ELF动态链接——共享库与PLT/GOT延迟绑定》
首页
资讯中心
/
《从零手写操作系统 (27):ELF动态链接——共享库与PLT/GOT延迟绑定》
《从零手写操作系统 (27):ELF动态链接——共享库与PLT/GOT延迟绑定》
发布时间:2026/10/7 14:30:02
前言从“代码冗余”到“按需共享”在前面的章节中我们的程序都是静态链接的每个可执行文件都包含了完整的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的本质用一次间接跳转换取代码段的纯只读共享。⚠️关键洞察PLTProcedure Linkage Table是代码段中的跳板GOTGlobal Offset Table是数据段中的地址槽。两者配合实现了延迟绑定函数首次被调用时才解析地址未调用的函数永远不付出解析开销。这对于大型程序启动性能至关重要。如果你的实现选择在加载时一次性解析所有符号eager binding就失去了延迟绑定的启动优势如果完全不做PLT而要求加载时填充所有GOT就无法支持真正的按需解析。符号解析的“作用域链”当libc.so内部调用malloc时应该解析到自己的malloc还是主程序的mallocPOSIX定义了广度优先搜索顺序先搜主程序再按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动态程序立即#PFPT_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为0dump rel-r_offset base是否指向有效GOT位置在resolver入口/出口dump ESP差值确认resolve_symbol返回值非零第二次调用仍走resolverGOT写入地址错/缓存一致性/mprotect阻止写入hexdump GOT slot内容确认已被填充验证GOT所在段有PROT_WRITE权限确认*got_slot赋值后无异常符号解析到错误版本搜索顺序不符合BFS/弱符号处理错/版本号忽略dump loaded_libs链表顺序对比LD_DEBUGbindings输出确认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/CtrlZ》当前的Shell只能串行执行命令无法暂停、恢复、切换前台作业。下一章将实现进程组、会话、TTY作业控制协议让你的Shell支持完整的交互式作业管理真正成为可用的命令行环境。参考资料System V ABI i386 Supplement: Dynamic Linking, PLT/GOT SpecificationLinux 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从“命令执行器”走向“交互式工作环境”