26.深入解析ELF文件:编译链接与加载全揭秘

发布时间:2026/6/9 11:34:57

26.深入解析ELF文件:编译链接与加载全揭秘 ELF文件要理解编译链链接的细节我们不得不了解⼀下ELF文件。有以下四种文件其实都是ELF文件•可重定位文件Relocatable File 即xxx.o文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。•可执行文件Executable File 即可执行程序。•共享目标文件Shared Object File 即xxx.so文件。•内核转储(core dumps)存放当前进程的执行上下文用于dump信号触发。ELF文件组成•ELF头(ELF header)描述文件件的主要特性。其位于文件件的开始位置它的主要目的是定位文件的其他部分。• 程序头表(Program header table)列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移offset、长度毕竟这些段都是紧密的放在⼆进制文件中 需要段表的描述信息才能把他们每个段分割开。• 节头表(Section header table)包含对节(sections)的描述。给链接器、调试器、工具看的把代码、数据、符号表分开存放到每个节中。• 节Section ELF文件中的基本组成单位包含了特定类型的数据。ELF文件的各种信息和 数据都存储在不同的节中如代码节存储了可执行代码数据节存储了全局变量和静态数据等。ELF从形成到加载轮廓ELF形成可执行• step-1将多份 C/C 源代码翻译成为目标 .o 文件动静态库(ELF)• step-2将多份 .o 文件section进行合并注意这里是在链接的时候合并区分加载到内存时的合并合并规则按逻辑功能合并多个.o里同名字段拼接所有 obj 的.text拼成最终单个.text、所有.rodata拼成单个.rodata、.data归.data、.bss归.bss此时.text 和.rodata 依旧是两个独立 section在 ELF 文件里位置分开只是各自内部合并不会合并成一块ELF可执行文件加载到内存• ⼀个ELF会有多种不同的Section在加载到内存的时候也会进行Section合并形成segment • 合并原则相同属性比如可读可写可执行需要加载时申请空间等.• 这样即便是不同的Section在加载到内存中可能会以segment的形式加载到⼀起• 这个合并工作作也已经在形成 ELF 的时候合并方式已经确定了具体合并原则被记录在 了 ELF 的 程序头表(Program header table) 中参数全称作用适用文件-h--file-headerELF 文件头类型 (ET_REL/EXEC/DYN)、入口地址、程序头 / 节头偏移.o/ 可执行 /.so全支持-l--program-headers程序头表 (段 Segment)加载映射信息.o 没有可执行 /.so 才有可执行、.so-S--sections节头表 (Section).text/.data/.symtab全节目录所有 ELF 都有全类型 ELF-e--headers-h -l -S一次性打印三大头部同上-s--symbols查看 ELF 文件里的【符号表】同上readelf -S a.out看节头表SectionSection Headers主要字段w字段释义[Nr]节编号索引从 0 开始Name节名字.text/.data/.symtab等Type节类型代码 / 数据 / 符号表 / 重定位等Address运行虚拟地址.o 文件该字段全 0未重定位Offset节在 ELF 文件内的字节偏移从文件开头算Size当前节占用字节大小Flags属性标志W 可写 / A 占用内存 / X 可执行这里可以把节头表看作就是存放所有节描述的结构体数组Nr 是数组索引。我们有了相对文件开始地址的偏移量加上该节的地址长度就能知道每一个section的开始和结束进而划分一个又一个的节了eg符号表这个节在文件中的真实位置 ELF文件起始地址 sh_offset符号表节区的偏移在这里挑有一些重要的节来看一下.text节 是保存了程序代码指令的代码节。• .data节 保存了初始化的全局变量和局部静态变量等数据。• .rodata节 保存了只读的数据如一行C语言代码中的字符串。由于.rodata节是只读的所 以只能存在于⼀个可执行文件的只读段中。因此只能是在text段不是data段中找到.rodata 节。 • .BSS节 为未初始化的全局变量和局部静态变量预留位置• .symtab节 :SymbolTable符号表就是源码里面那些函数名、变量名和代码的对应关系。• .got.plt节 全局偏移表-过程链接表.got节保存了全局偏移表。.got节和.plt节⼀起提供 了对导⼊的共享库函数的访问⼊⼝由动态链接器在运⾏时进⾏修改。对于GOT的理解我们后面会说。.BSS细节1.bss不占磁盘空间只在程序运行时占内存默认值全是 0。2.bss段在磁盘上不存储任何数据只记录需要多大内存char buffer[1024 * 1024]; // 1MB 未初始化全局数组这个 1MB 变量不会让你的 .o 文件变大 1MB只会在文件里写一句话“运行时请帮我分配 1MB 内存全部填 0”这就是.bss最大的意义节省磁盘空间SymbolTable符号表本质就是一张固定格式的结构体数组每个元素 名字 偏移 类型只存 4 样核心东西符号名函数名、变量名偏移量在代码段 / 数据段的位置类型函数变量外部依赖所在段代码段 / 数据段通过起始地址和偏移量就能找到其存的每个元素。所以ELF 符号表就相当于程序的 “通讯录”专门用来记录程序里函数、变量的名字、位置、类型让链接器、加载器、调试器能找到并使用它们 —— 没有它程序根本没法编译、运行、调试readelf -l a.out看程序头表Program header table其中可以看到segment sections中一共划分了8个segment并且.text和.rodata划分到了同一个segment为什么要将section合并成为segment• Section合并的主要原因是为了减少页面碎片提高内存使用效率。如果不进行合并 假设页面大小为4096字节内存块基本大小加载管理的基本单位4kb如果.text部分 为4097字节.init部分为512字节那么它们将占用3个页面一个4096一个1一个512而合并后它们只需2个页面。• 此外操作系统在加载程序时会将具有相同属性的section合并成⼀个大的 segment这样就可以实现不同的访问权限从而优化内存管理和权限访问控制。对第2点理解1如果不合并、每个 section 单独一页.text一页、.rodata一页要分别设置两次页权限零散难管控。2OS 管理简化进程虚拟空间只需要维护少数几个 Segment代码段、数据段、栈段不用管理十几个零散小节页表项变少、内核调度 / 缺页异常处理效率更高。readelf -h hello(看ELF头我们可以在 ELF头 中找到文件件的基本信息以及可以看到ELF头是如何定位程序头表和节头表的。例如我们查看下hello.o这个可重定位文件的主要信息截取一部分通过链接视角和执行视角分别看一看程序头表和节头表作用区分两个合并链接视图(Linking view) -对应节头表 Section header table◦文件结构的粒度更细将文件按功能模块的差异进行划分静态链接分析的时候⼀般关注的 是链接视图能够理解ELF文件中包含的各个部分的信息。◦ 为了空间布局上的效率将来在链接目标文件件时链接器会把很多节section合并规整 成可执行的段segment、可读写的段、只读段等。合并了后空间利用率就高了节省物理内存页物理内存页分配⼀般都是整数倍⼀块给 你比如4k所以链接器趁着链接就把小块们都合并了。• 执行视图(execution view) -对应程序头表 Program header table告诉操作系统如何加载可执行文件完成进程内存的初始化。所以一个可执行程序的读写或者什么什么权限就是从这来的⼀个可执行程序的格式中 ⼀定有 program header table 。• 说白了就是⼀个在链接时作用⼀个在运行加载时作用。也就解释了为什么.o文件没有程序头表因为是节头表才在链接时发挥作用呀、理解连接与加载静态链接• 无论是自己的 .o ,还是静态库中的 .o 本质都是把.o文件进行连接的过程•所以研究静态链接本质就是研究 .o 是如何链接的先认识一个指令objdump -d 命令将代码段.text进行反汇编查看两个函数hello.c code.c.c (C源码) - (gcc -S) .s (汇编代码) -(gcc -c) .o (二进制目标文件) - (ld 链接) 可执行文件 (a.out)把 .o反汇编 变回 .s汇编代码我们可以看到这里的call指令它们分别对应之前调用的printf和run函数但是你会发现他们的跳转地址都被设成了0。那这是为什么呢其实就是在编译 hello.c 的时候编译器是完全不知道 printf 和 run 函数的存在的比如如他们位于内存的哪个区块代码长什么样都是不知道的。因此编译器只能将这两个函数的跳转地址先暂 时设为0。一直到链接的时候才会修正这个地址读取code.o的符号表 readelf -s code.o其中puts就是printf的底层实现UND就是undefined找不到读取hello.o的符号表 readelf -s hello.orun就是我们自己的方法在hello.o中未定义(因为在code.o中)也显示UND读取main.exe的符号表 readelf -s main.exe这里主要截取要说明的部分两个.o进行合并之后在最终的可执行程序中就找到了run 0000000000040052d其实是地址后面说 FUNC表示run符号类型是个函数 。13就是run函数所在的section被合并最终的那⼀个section中了,13就是下标所以readelf -S main.exe,可以看到[13]就是.text节现在我们再来看main.exe的反汇编objdump -d main.exe同样这里只截取要重点说明的部分发现地址已经被修改了即两个.o的代码段合并到了⼀起并进行了统⼀的编址所以链接的时候会修改.o中没有确定的函数地址在合并完成之后进行相关call地址就完成代码调用总结静态链接时.o 里的外部符号调用别的函数 / 变量一开始只有 “名字”没有真实地址。链接器要把这些 “名字” 替换成真正的内存地址这个过程就叫地址重定位。即.o 不知道外部函数在哪 → 留空等链接时链接器找到地址 → 填上填地址 重定位ELF加载与进程地址空间先说结论磁盘上的可执行程序代码和数据的编址其实就是虚拟地址的统一编址1.磁盘阶段ELF 里的地址是什么编译链接时链接器会给 .text代码段、.data数据段等每个 seg段分配一个逻辑上的基地址然后给函数、变量编址。比如图里fun 函数在 .text 段里地址是 0x50变量 a 在 .data 段里地址是 0x40这些地址本质上是段内偏移也就是「逻辑地址」即逻辑地址 段基址 段内偏移2. 但如果现在假设图里的段基址起始地址都为 0如果每个 seg 的开始地址都是 0 呢这是一个简化视角把所有段的基址都看作 0那么函数、变量的地址就等于它们在整个程序里的偏移量。比如fun 的地址就是 0x50从 0 开始偏移 0x50a 的地址就是 0x2222 0x40从 0 开始偏移 0x2262这种视角下ELF 文件里的地址看起来就像是「从 0 开始编址的虚拟地址」即虚拟地址也就是图里说的平坦模式编址。所以平坦模式简化了地址模型让进程看到一个从 0 开始的连续虚拟地址空间所以我们可以直接把 ELF 里的地址当成虚拟地址来理解。磁盘 → 内存 → CPU 执行磁盘阶段ELF 文件里就定好了「起点」ELF 文件头里有一个字段叫e_entryEntry Point Address入口点地址图里标红的0x1060就是它。这个地址是链接器在编译时就定好的虚拟地址它是程序的第一条指令_start函数在代码段里的偏移。加载阶段内核把 ELF 映射到进程虚拟地址空间内核创建进程分配mm_struct初始化虚拟地址空间。解析 ELF 的 Program Header例如把.text段映射到虚拟地址空间里就是mm_struct,p_vaddr段的虚拟基址 偏移就得到了.text段的入口点的虚拟地址0x1060。内核为进程创建页表把虚拟地址0x1060映射到物理内存里的某个地址比如图里的0x1000系列地址从而建立虚拟地址和物理地址的映射关系。运行阶段CPU 拿到入口点开始执行内核把进程的页表地址写入 CR3 寄存器。内核把 ELF 文件里的入口点地址0x1060写入 CPU 的EIP 寄存器。CPU 取指令1EIP 里的虚拟地址0x1060→ MMU 查页表 → 翻译成物理内存地址比如0x10002CPU 从物理内存里取出指令执行执行完后指向下一条指令。由于上面在说加载阶段时只宏观的谈论了mm_struct所以下面这张图注重说一下加载过程的细节涉及到vm_area_struct加载阶段内核解析 ELF创建vm_area_struct内核读取 ELF 的 Program Header里面定义了.text代码段的p_vaddr期望虚拟地址和长度。内核会为.text段创建一个vm_area_struct记录vm_start代码段的起始虚拟地址比如0x1060所在的区域vm_end代码段的结束虚拟地址vm_file指向磁盘上的可执行文件这个vm_area_struct会被挂到进程的mm_struct链表上成为进程虚拟地址空间的一部分建立页表映射也是根据vm_area_struct)内核根据vm_area_struct为这段虚拟地址创建页表项映射到物理内存中加载好的代码段。图里的页表就是这个映射关系的体现总结ELF 文件里的地址是段内偏移逻辑地址链接时变成虚拟地址运行时通过页表翻译成物理地址。进程间如何共享库的•代码段共享共享库的代码段是只读的所以多个进程可以安全地共享同一份物理内存副本不会互相干扰。• 数据段私有图中进程 A 数据、进程 B 数据是分开的说明共享库的数据段比如全局变量会被每个进程复制一份是进程私有的避免了多进程的数据冲突。• 虚拟地址独立进程 A 和进程 B 的共享区虚拟地址可以不同但通过各自的页表最终都会指向同一段物理内存总结动态共享库的代码段在物理内存中只存一份被多个进程通过各自的虚拟地址空间共享访问而数据段则是每个进程私有这样既节省了内存又保证了进程间的数据隔离我们的可执行程序被编译器动了手脚在C/C程序中当程序开始执行时它首先并不会直接跳转到 main 函数。实际上程序的入口点是 _start 这是⼀个由C运行时库通常是glibc或链接器如ld提供的特殊函数_start主要完成1. 设置堆栈为程序创建⼀个初始的堆栈环境。2. 初始化数据段将程序的数据段如全局变量和静态变量从初始化数据段复制到相应的内存位 置并清零未初始化的数据段。3. 动态链接这是关键的⼀步_start 函数会调用动态链接器的代码来解析和加载程序所依赖的 动态库shared libraries。动态链接器会处理所有的符号解析和重定位确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。下面再来说一下多态链接器动态链接器如ld-linux.so负责在程序运行时加载动态库。当程序启动时动态链接器会解析程序中的动态库依赖并加载这些库到内存中。动态链接1.先了解动态库中的相对地址动态库为了随时进行加载映射到任意进程的任意位置对动态库中的方法统⼀编址采用相对编址的方案进行编制的后面具体说明2.动态库加载到虚拟地址空间步骤 1找到磁盘上的库文件数据块1.从 task_struct进程控制块出发通过 struct file → struct path → struct dentry解析出 libc.so 的路径。 struct dentry目录项会帮你找到文件对应的 struct inode。2. 定位磁盘数据 struct ext2_inode 里的 EXT2_N_BLOCKS 数组记录了 libc.so 在磁盘上的数据块位置。 内核通过这个数组就能找到 libc.so 在磁盘上的具体数据块。步骤 2内核把库加载到物理内存文件缓存内核把磁盘上libc.so的数据块加载到物理内存的内核缓冲区文件缓存 中。步骤 3建立虚拟地址与物理内存的映射mmap1.创建vm_area_struct单个进程每个共享库只有 1 个vm_area_struct对应 1 个库起始地址。2.建立页表映射和前面一样这里不重复写了步骤 4进程得到库的起始虚拟地址1.映射完成后vm_area_struct的vm_start就是libc.so在进程虚拟地址空间中的起始地址。2进程后续调用库函数时会用「库起始虚拟地址 库中方法的偏移量」来计算函数的虚拟地址再通过页表访问物理内存中的库代码。整个调用过程是从代码区跳转到共享区调用完毕在返回到代码区整个过程完全在进程地址空间中进行的.总结【步骤 1】定位数据块 → 【步骤 2】加载到物理内存 → 【步骤 3】建立虚拟地址映射 → 【步骤 4】进程通过库起始地址偏移调用函数区分entrypoint和vm_start的起始地址主程序从 entrypoint 开始运行执行代码时发现要调用printf/read等库函数。动态链接器把共享库映射到进程虚拟空间生成一个专属 vma。取出这个 vma 的vm_start库起始地址结合库内函数偏移算出函数真实虚拟地址。主程序通过这个地址跳转执行库函数。再来看一张图下面部分展示了一个printf调用从「源码」到「真正的调用地址」的完整过程1.编译链接阶段编译链接时链接器只记录了printf在libc.so里的偏移量比如图里写的0x112233此时还不知道libc.so会被加载到进程虚拟地址空间的哪里所以还不能算出最终地址。2.运行阶段运行真实调用时动态链接器通过mmap把libc.so加载到进程虚拟地址空间得到库起始虚拟地址0x44332211就是vm_area_struct的vm_start。用公式计算printf的真实虚拟地址printf 地址 库起始地址 库内偏移量 0x44332211 0x112233执行call printf时实际就是跳转到这个计算好的地址调用printf函数修改的是代码区不是说代码区在进程中是只读的现在怎么修改了代码区呢所以这应该不是真正的过程(PIC)其实动态链接采用的做法是在 .data 可执行程序或者库自己中专门预留⼀片区域用来存放函数 的跳转地址它也被叫做全局偏移表GOT表中每⼀项都是本运行模块要引用的⼀个全局变量或函数的地址。因为.data区域是可读写的所以可以支持动态进行修改1.编译链接阶段编译器 / 链接器不知道puts最终的虚拟地址只知道它在libc.so里的偏移量比如图里的0x112233。所以它不会直接写死call 0x...而是在主程序里创建一个GOT 表全局偏移表为puts预留一个表项。把call puts改成call 到 GOT 表中的偏移地址相当于 “我要去表里找它的真实地址”。此时 GOT 表里的puts项只记录了puts(0x112233libc.so)还没有真实地址。2.库加载成功动态链接器修改 GOT 表前面的流程已经完成了libc.so的加载和映射拿到了库的起始虚拟地址比如0x44332211。动态链接器根据「库起始地址 函数偏移」算出puts的真实虚拟地址puts 真实地址 0x44332211 0x112233把这个真实地址填入主程序的 GOT 表中puts对应的表项。此时GOT 表的内容就从puts(0x112233libc.so)变成了puts(0x112233libc.so(0x44332211))也就是图里箭头右边的样子。3.运行时函数查表调用当主程序执行puts(hello)时执行call指令跳转到 GOT 表中puts对应的表项地址。从表项里取出之前动态链接器填好的真实地址0x44332211 0x112233。CPU 跳转到这个地址执行puts函数的代码。这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说我们的动态库不需要做任何修 改被加载到任意内存地址都能够正常运行并且能够被所有进程共享这也是为什么之前我们给 编译器指定-fPIC参数的原因PIC相对编址GOT几点理解1.能够被所有进程共享因为.text代码段是只读的没有任何进程会修改它所以多个进程可以映射到同一份物理内存副本只有数据段里的 GOT 表会被修改。2.pic的实现为什么说加载到任意内存地址都可以正常运行相对寻址相对编制指令 → GOT 表 的访问方式相对寻址是CPU 找 GOT 的方式eg这里的*0x2f75(%rip)就是相对寻址%rip是当前指令地址加上偏移0x2f75就能找到 GOT 表项。不管库被加载到哪个地址rip和 GOT 表的相对位置永远不变所以总能找到表项所以虽然加载到内存的任意地址但有了相对寻址就肯定能找到GOT有了GOT就能找到库的方法实现小知识点静态链接重定位和动态链接重定位区别静态链接重定位链接时就直接已经把库代码和主程序合并成一个文件地址在编译链接阶段就完全固定运行时不再处理。动态链接重定位链接时只记录偏移库代码在运行时才被加载地址由动态链接器在进程启动后计算才会修正。

相关新闻