多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

深入解析ELF文件格式:从链接、加载到动态链接的完整指南

深入解析ELF文件格式:从链接、加载到动态链接的完整指南 1. 从可执行文件说起为什么需要ELF如果你在Linux下用GCC编译过一个简单的“Hello World”程序然后敲下ls -l a.out你会看到一个名为a.out的文件。这个文件就是你的程序吗是但也不完全是。你双击它并不能直接运行你需要通过终端输入./a.out来执行。这个看似简单的过程背后隐藏着操作系统加载和运行一个程序的完整逻辑链。而这一切的起点就是ELF文件格式。ELF全称Executable and Linkable Format即可执行可链接格式。它是Linux世界以及许多其他类Unix系统中二进制文件的“标准身份证”和“结构蓝图”。无论是你编译出的可执行程序、系统里的共享库.so文件还是编译中间产生的目标文件.o文件甚至内核本身大都采用ELF格式。理解ELF是理解程序如何在Linux上“活”起来的第一步。为什么是ELF而不是一堆纯粹的机器指令想象一下你要组装一个复杂的乐高模型。你收到的不是一个已经粘死的整体而是一袋袋零件代码段、数据段、一份拼装说明书文件头、节头表、以及一张指示哪些零件袋属于哪个步骤的清单程序头表。ELF文件就是这样一个高度结构化的包裹。这种结构化的设计主要解决了三个核心问题链接一个大型软件通常由多个源文件.c编译成多个目标文件.o这些目标文件需要被“缝合”在一起解决彼此间的函数调用、变量引用关系。ELF为目标文件提供了标准的“接口”和“标签”让链接器知道哪里需要缝合。加载操作系统不能直接把整个文件扔进内存。它需要知道哪些部分是必须的指令代码哪些是初始化的数据哪些是未初始化的数据运行时再分配空间以及这些部分应该被放到内存的什么地址。ELF的程序头表就是给操作系统加载器看的“装载指南”。动态链接现代程序很少把所有代码都打包进一个巨大的可执行文件。它们会依赖像libc.soC标准库这样的共享库。程序运行时这些库的代码需要被映射到进程的地址空间。ELF格式定义了如何记录这些依赖关系以及动态链接器如何查找和加载它们。所以当你面对一个Linux下的二进制文件时无论是分析崩溃的Core Dump还是逆向一个程序亦或是优化启动速度ELF都是你无法绕开的基础知识。接下来我们就用实际工具和例程一层层剥开ELF的外壳。2. 解剖ELF结构详解与实用工具要理解ELF最好的方式就是直接“看”。Linux提供了强大的工具链来帮助我们审视ELF文件最核心的就是readelf和objdump。让我们从一个最简单的程序开始。2.1 创建一个简单的例程并编译首先我们创建两个文件来模拟一个多文件编译链接的场景。main.c:#include stdio.h extern void hello_from_another(); // 声明外部函数 int global_init_var 84; // 已初始化的全局变量 int global_uninit_var; // 未初始化的全局变量 int main() { static int static_var 10; // 局部静态变量 hello_from_another(); printf(Hello, ELF! Global var: %d\n, global_init_var); return 0; }another.c:#include stdio.h void hello_from_another() { printf(Hello from another file!\n); }使用GCC分别编译并链接# 编译为目标文件 gcc -c main.c -o main.o gcc -c another.c -o another.o # 链接为可执行文件 gcc main.o another.o -o demo # 也可以一步到位 # gcc main.c another.c -o demo现在我们有了main.o、another.o可重定位目标文件和demo可执行文件。它们都是ELF格式但内部结构侧重不同。2.2 使用readelf查看ELF全局视图readelf是专门用于显示ELF文件信息的工具信息最全。我们先看文件头它描述了ELF文件的元信息。readelf -h demo输出会类似这样关键字段已加粗ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2‘s complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: **EXEC (Executable file)** Machine: Advanced Micro Devices X86-64 Version: 0x1 **Entry point address: 0x401040** Start of program headers: 64 (bytes into file) Start of section headers: 14664 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 13 Size of section headers: 64 (bytes) **Number of section headers: 31** Section header string table index: 30关键信息解读Type: EXEC表明这是一个可执行文件。如果是.o文件这里会是REL (Relocatable file)如果是.so文件则是DYN (Shared object file)。Entry point address程序执行的入口地址即_start函数的地址不是main_start是C运行时库的一部分负责初始化环境后调用main。Number of section headers节头表Section Header Table的条目数。节Section是链接视图的基本单位包含了代码、数据、符号表、重定位表等所有信息。链接器主要关心这个。接下来看节头表它详细列出了文件中所有的节。readelf -S demo | less你会看到一个很长的列表包含几十个节。几个最重要的节.text存放编译后的机器指令代码。.data存放已初始化的全局变量和静态变量如我们的global_init_var和static_var。.bss存放未初始化的全局变量和静态变量如global_uninit_var。注意.bss节在文件中不占实际空间它只是在节头表中声明“我需要这么多字节的零初始化内存”。.rodata存放只读数据比如字符串常量我们printf里的格式字符串。.symtab符号表记录所有函数和全局变量的名字、类型、所在节、偏移量等信息。注意默认情况下发布的可执行文件会去掉符号表用strip命令或gcc -s以减小体积。我们编译时没加-s所以还有。.strtab字符串表存放.symtab等节中用到的字符串如符号名。实操心得调试时如果遇到“core dumped”但没有行号信息很可能是因为可执行文件被strip了。生产环境为了安全性和体积通常会strip但测试环境建议保留调试信息gcc -g并不要strip以便定位问题。2.3 使用objdump进行反汇编与深入分析objdump功能更强大可以反汇编、查看符号、重定位信息等。查看代码段objdump -d demo | less这会输出demo中所有可执行节主要是.text的反汇编代码。你可以找到main函数和hello_from_another函数的汇编指令。查看符号表类似于readelf -s但格式不同objdump -t demo | grep -E ‘main|hello|global’这可以帮助你确认符号的地址和类型。对于目标文件.o最关键的是看它的重定位信息。目标文件中的地址都是临时的从0开始。链接器需要根据这些信息在合并时修正地址。objdump -r main.o输出会显示哪些地方需要被“重定位”。例如对于main.o中调用printf和hello_from_another的指令因为目标文件不知道这些函数最终在哪所以会生成一个重定位条目告诉链接器“在偏移量XX处有一个对符号printf/hello_from_another的引用请你链接时把这个地址填上。”2.4 ELF的两种“视图”节Section与段Segment这是理解ELF加载的关键。前面讲的.text、.data、.bss都是“节”这是链接视图面向链接器。链接器关心如何把多个目标文件的各个节合并同类项例如把所有.text节合并。当链接器生成可执行文件后它会根据一个链接脚本linker script的规则将多个属性相似的“节”打包成一个“段”Segment并生成程序头表。这是执行视图面向操作系统加载器。加载器不关心有多少个.text节它只关心需要把哪些“段”加载到内存以及这些段的读写执行权限。查看程序头表readelf -l demo输出如下简化Elf file type is EXEC (Executable file) Entry point 0x401040 There are 13 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x00000000000002d8 0x00000000000002d8 R 0x8 INTERP 0x0000000000000318 0x0000000000400318 0x0000000000400318 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x00000000000005c8 0x00000000000005c8 R E 0x1000 LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000 0x00000000000001f5 0x00000000000001f5 R 0x1000 LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000 0x0000000000000158 0x0000000000000158 R 0x1000 LOAD 0x0000000000002df0 0x0000000000403df0 0x0000000000403df0 0x0000000000000220 0x0000000000000228 RW 0x1000 ...其他段如动态链接相关段重点关注Type为LOAD的段。加载器就是把这些LOAD段映射到进程的虚拟地址空间。注意Flags列R E可读可执行。这通常对应包含.text节的段。R只读。对应包含.rodata等的段。RW可读可写。对应包含.data、.bss的段。注意.bss在文件中大小为0FileSiz但在内存中需要占用空间MemSiz加载器负责将这部分内存清零。核心原理为什么代码段.text通常不可写数据段.data不可执行这是现代操作系统安全机制如W^X即可写与可执行互斥的一部分可以防止缓冲区溢出攻击轻易地将数据段中的恶意代码执行。这种权限隔离在ELF的段级别就得到了体现和强制执行。3. 静态链接把零件组装成整体链接是将多个目标文件.o和库文件.a合并成一个可执行文件或共享库的过程。我们上面使用的gcc main.o another.o -o demo就触发了链接。链接器通常是ld主要做两件事符号解析和重定位。3.1 符号解析解决“谁是谁”的问题每个目标文件都有一个符号表.symtab记录了它定义的符号函数、全局变量和引用的符号外部函数、变量。链接器的任务就是对于每个被引用的符号在整个输入文件中找到唯一的定义。用nm工具可以方便地查看目标文件的符号nm main.o输出中U表示未定义Undefined的符号如hello_from_another和printfT表示在代码段定义的函数如mainD表示在初始化数据段定义的全局变量如global_init_varC或B通常表示未初始化数据common block或.bss如global_uninit_var。链接时如果hello_from_another在another.o中找到了定义T那么对这个符号的引用就解析成功。如果printf在所有.o和.a文件中都找不到定义链接器就会报“undefined reference”错误。3.2 重定位给所有东西一个最终地址目标文件中的代码和数据地址都是从0开始的虚拟地址。当所有节被合并到输出文件后它们被分配了最终的虚拟内存地址。链接器必须修改所有引用这些符号的指令和数据让它们指向正确的地址这个过程就是重定位。我们之前用objdump -r main.o看到的重定位条目就是告诉链接器“在.text节的偏移量0xXX处有一个四字节的数据它需要被替换成符号hello_from_another的最终地址。”链接器根据hello_from_another被分配到的地址计算出具体的值然后回填到那个位置。3.3 静态库.a是什么静态库.a文件本质上是一组目标文件.o的打包集合使用ar命令创建。例如C标准库的静态版本是libc.a。当你在链接时使用-lc链接器会去查找libc.a但只从中提取出那些被你的程序引用到的目标文件合并进最终的可执行文件。这就是为什么静态链接的可执行文件通常比较大因为它包含了所用库代码的副本。创建一个简单的静态库并使用的例程创建库源码mylib.c:int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; }编译成目标文件并打包成静态库gcc -c mylib.c -o mylib.o ar rcs libmymath.a mylib.o # r: 替换/插入 c: 创建 s: 建立索引编写主程序使用库use_static.c:#include stdio.h int add(int, int); // 声明 int main() { printf(34%d\n, add(3,4)); return 0;}编译链接gcc -c use_static.c -o use_static.o gcc use_static.o -L. -lmymath -o use_static # -L. 指定在当前目录查找库 -lmymath 链接 libmymath.a运行./use_static输出347。注意事项链接器对库的顺序是敏感的。因为它按命令行中出现的顺序解析符号。如果A.o依赖libB.a中的函数而libB.a又依赖libC.a那么命令行通常需要写成gcc A.o -lB -lC。如果顺序错了如gcc A.o -lC -lB链接器在处理A.o时发现未定义符号去libC.a里找找不到就报错而不会向后看libB.a。一个简单的规则是把基础库、被依赖的库放在后面。更稳妥的做法是如果循环依赖可以将库重复放置或者使用-(和-)进行分组如gcc -Wl,--start-group A.o -lB -lC -Wl,--end-group让链接器循环解析。4. 动态链接运行时才完成的拼图静态链接的缺点是每个程序都自带库的副本浪费磁盘和内存且库更新需要重新编译所有程序。动态链接解决了这个问题。4.1 共享库.so与动态链接器共享库.so Shared Object是编译好的、可在运行时被多个进程共享的代码和数据。可执行文件本身不包含库的代码只记录它依赖哪些共享库。程序启动时由动态链接器在ELF程序头INTERP段指定的通常是/lib64/ld-linux-x86-64.so.2负责将这些共享库加载到内存并完成最后的符号解析和地址重定位。创建一个简单的共享库并使用的例程创建库源码同上mylib.c。编译成位置无关代码PIC并创建共享库gcc -c -fPIC mylib.c -o mylib.o # -fPIC是关键生成位置无关代码 gcc -shared mylib.o -o libmymath.so-fPICPosition Independent Code使得库代码可以被加载到内存的任何位置而不需要修改。因为同一个库可能被映射到不同进程的不同地址。编译主程序动态链接gcc use_static.c -L. -lmymath -o use_dynamic注意虽然命令一样但链接器会优先查找libmymath.so动态库如果找不到才找.a。你可以用file命令验证file use_dynamic会显示dynamically linked。运行前的准备操作系统需要知道去哪找libmymath.so。方法一将.so文件放到标准库路径下如/usr/local/lib然后运行ldconfig更新缓存。方法二设置环境变量LD_LIBRARY_PATHexport LD_LIBRARY_PATH.:$LD_LIBRARY_PATH ./use_dynamic方法三编译时指定rpathgcc use_static.c -L. -lmymath -Wl,-rpath,‘$ORIGIN‘ -o use_dynamic_rpath-Wl,-rpath,‘$ORIGIN‘告诉链接器运行时先在可执行文件所在目录$ORIGIN查找库。这样发布程序时可以把.so放在同级目录。4.2 动态链接的底层机制PLT与GOT动态链接在程序启动时或函数第一次被调用时完成地址绑定这涉及到两个关键的数据结构过程链接表PLT和全局偏移表GOT。GOTGlobal Offset Table一个数组存放着所有动态符号如printf的绝对地址。数据段中可读写。PLTProcedure Linkage Table一小段存根代码stub。代码段中可执行。第一次调用动态函数的流程延迟绑定Lazy Binding程序调用printfplt实际上是PLT中的一个条目。printfplt的第一条指令跳转到GOT[n]中存储的地址。第一次调用时GOT[n]里存的并不是printf的真实地址而是printfplt中下一条指令的地址即“拉回”到PLT。于是执行流程回到PLT接着压栈一个标识符表示要找printf然后跳转到动态链接器的_dl_runtime_resolve函数。动态链接器根据标识符在已加载的共享库中查找printf的真实地址将其写回GOT[n]。最后跳转到printf的真实地址执行。此后再次调用printf调用printfplt。跳转到GOT[n]此时里面已经是printf的真实地址了。直接跳转执行。这个过程就是“延迟绑定”避免了程序启动时一次性解析所有动态符号带来的开销。你可以用objdump -d demo查看PLT的代码用readelf -r demo查看重定位条目其中类型为R_X86_64_JUMP_SLOT的就是需要动态链接器填充的GOT条目。4.3 查看动态依赖与调试ldd命令列出可执行文件或共享库所依赖的所有共享库。ldd use_dynamic输出会显示libmymath.so的路径如果找不到会显示not found。LD_DEBUG环境变量一个强大的调试工具。LD_DEBUGlibs ./use_dynamic 21 | head -20可以输出动态链接器查找、加载库的详细过程。其他有用的值还有symbols符号绑定、bindings绑定信息、files处理文件等。踩坑实录最经典的动态链接问题就是“找不到共享库”。除了上述的LD_LIBRARY_PATH和rpath还要注意库的ABI兼容性如果依赖的库版本升级导致ABI应用二进制接口不兼容比如函数签名变了即使找到了库也可能在运行时因符号解析失败而崩溃。错误信息可能是“undefined symbol: xxx”。直接运行 vs 通过脚本/服务运行LD_LIBRARY_PATH环境变量可能在某些上下文中如cron job、systemd服务、sudo环境不被继承或重置。对于系统服务更可靠的方式是将库路径配置在/etc/ld.so.conf.d/目录下的文件中并运行ldconfig。静态链接与动态链接混合如果一个库同时存在静态版.a和动态版.so链接器默认优先选择动态链接。如果你想强制静态链接某个库可以使用-static选项全部静态或-Wl,-Bstatic -lmylib -Wl,-Bdynamic部分静态。5. 程序的加载与运行从文件到进程当你在shell中输入./demo并回车后一个复杂的接力赛就开始了。5.1 内核的加载工作Shell解析与forkShell解析命令调用fork()创建一个新的子进程。execve系统调用子进程调用execve(“./demo”, …)。这个系统调用是加载的核心。内核检查文件内核检查./demo是否是一个有效的可执行文件通过魔数7f 45 4c 46识别ELF。读取程序头表内核读取ELF文件的程序头表找到所有LOAD类型的段。建立内存映射内核为进程创建新的虚拟地址空间页表然后根据每个LOAD段的Offset、VirtAddr、FileSiz、MemSiz、Flags将文件中的相应部分映射到虚拟内存的指定位置VirtAddr。对于.bss这种MemSiz大于FileSiz的段内核会映射匿名内存初始为0来补足。设置栈和堆内核还会为用户态栈stack和堆heap分配内存区域。栈的地址通常在高位向下增长堆在数据段之上向上增长。传递控制权内核将入口地址Entry point设置到ELF头中指定的地址如0x401040即_start并将CPU的控制权从内核态切换到用户态跳转到该地址开始执行。至此进程的“骨架”已经搭建好代码和数据都在内存里了但还不能直接执行main函数因为动态链接还没完成。5.2 动态链接器的接力还记得ELF程序头里的INTERP段吗它指定了动态链接器的路径/lib64/ld-linux-x86-64.so.2。内核在加载可执行文件后会把动态链接器也加载到内存它本身也是一个特殊的共享库并将控制权先交给动态链接器的入口点。动态链接器也称为ld.so开始工作自举完成自身的一些初始化。加载依赖库读取可执行文件的.dynamic段其中包含了依赖库列表如libc.so.6按照一定的搜索规则LD_LIBRARY_PATH、/etc/ld.so.cache、默认路径等找到这些库文件并将它们映射到进程的地址空间。重定位与符号解析对可执行文件和所有加载的共享库进行重定位填充它们的GOT表。这就是前面提到的延迟绑定的准备工作。调用初始化函数执行各共享库中的初始化代码如果有如C的全局对象构造函数。跳转到程序入口最终动态链接器跳转到可执行文件的入口点_start。5.3 从_start到main_start是C运行时库crt的一部分它负责设置一些最终的环境比如准备好main函数的参数argc,argv、环境变量指针envp然后调用__libc_start_main。这个函数是libc的它会做更多初始化工作如设置线程局部存储、初始化标准IO等最后才调用我们写的main函数。所以main并不是程序的起点而是“用户代码”的起点。当main函数返回后控制权会回到__libc_start_main它再调用exit()系统调用结束进程。一个简单的实验验证加载地址 我们可以写一个小程序来打印一些地址看看它们是否和ELF文件中描述的一致。addr.c:#include stdio.h #include stdlib.h int global_init 42; int global_uninit; const int global_const 100; int main() { int local_var 5; static int static_local 7; const int local_const 200; printf(main function address: %p\n, main); printf(global_init address: %p\n, global_init); printf(global_uninit address: %p\n, global_uninit); printf(global_const address: %p\n, global_const); printf(static_local address: %p\n, static_local); printf(local_var address: %p\n, local_var); printf(local_const address: %p\n, local_const); printf(malloc‘ed address: %p\n, malloc(100)); return 0; }编译运行gcc addr.c -o addr ./addr。你会看到main、global_const的地址通常在一个较小的数值范围如0x55...或0x40...这是代码段和只读数据段的地址。global_init、static_local的地址在另一个范围这是可读写数据段.data的地址。global_uninit的地址紧挨着可读写数据段属于.bss段。local_var、local_const的地址非常大如0x7ff...这是栈上的地址。malloc返回的地址在另一个很大的范围这是堆上的地址。这些地址的布局正是由ELF的程序头表和操作系统的加载器共同决定的。你可以用readelf -l addr查看LOAD段的VirtAddr会发现它们和程序打印出的地址范围是吻合的。理解ELF、链接和加载就像拿到了程序的“建筑图纸”和“施工流程”。无论是进行底层性能分析、安全研究还是解决诡异的运行时依赖问题这些知识都能为你提供清晰的路径和强大的工具。希望这篇结合了大量命令行实操的解析能帮你真正打通从源代码到运行进程的任督二脉。
返回列表