【Linux系统编程】ELF与动静态链接与加载

发布时间:2026/8/4 4:43:01
【Linux系统编程】ELF与动静态链接与加载 1. ELF文件要理解编译链接的细节就要先了解一下ELF文件以下四种文件都是ELF文件可重定位文件Relocatable File即 xxx.o 文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。可执行文件Executable File即可执行程序。共享目标文件Shared Object File即 xxx.so文件。内核转储(core dumps)存放当前进程的执行上下下用于dump信号触发。每个ELF文件又由以下四部分组成ELF头(ELF header) 显示ELF文件的文件头信息。文件头包含了ELF文件的基本信息比如文件类型、机器类型、版本、入口点地址、程序头表和节头表的位置和大小等它的主要目的是定位文件的其他部分。程序头表(Program header table)列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移offset、长度毕竟这些段都是紧密的放在⼆进制文件中需要段表的描述信息才能把他们每个段分割开。节头表(Section header table)包含对节(sections)的描述。节Section ELF文件中的基本组成单位包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中如代码节存储了可执行代码数据节存储了全局变量和静态数据等。查看ELF Header# readelf -h main ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 #ELF文件标识符魔数 Class: ELF64 # ⽂件类64位架构 Data: 2s complement, little endian#数据编码 ⼩端序 ⼆进制补码 Version: 1 (current) # ELF版本当前版本 OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x400640 # ⼊⼝点地址 Start of program headers: 64 (bytes into file) # 程序头表起始偏移 Start of section headers: 7048 (bytes into file) # 节头表起始偏移 Flags: 0x0 Size of this header: 64 (bytes) # ELF头⼤⼩64字节 Size of program headers: 56 (bytes) # 程序头表条⽬⼤⼩ Number of program headers: 9 #程序头表目数 Size of section headers: 64 (bytes) #节头表条目大小 Number of section headers: 31 #节头表条目数 Section header string table index: 30 #节名称字符串表索引对于 ELF HEADER 这部分来说我们只用知道其作用即可它的主要目的是定位文件的其他部分。查看ELF Program Header Table# readelf -l main Elf file type is EXEC (Executable file) Entry point 0x400640 There are 9 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x00000000000001f8 0x00000000000001f8 R E 8 INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238 0x000000000000001c 0x000000000000001c R 1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x0000000000000d24 0x0000000000000d24 R E 200000 LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x0000000000000254 0x0000000000000258 RW 200000 DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28 0x00000000000001d0 0x00000000000001d0 RW 8 NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254 0x0000000000000044 0x0000000000000044 R 4 GNU_EH_FRAME 0x0000000000000b34 0x0000000000400b34 0x0000000000400b34 0x000000000000005c 0x000000000000005c R 4 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 10 GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x00000000000001f0 0x00000000000001f0 R 1 Section to Segment mapping: Segment Sections... 00 01 .interp 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 04 .dynamic 05 .note.ABI-tag .note.gnu.build-id 06 .eh_frame_hdr 07 08 .init_array .fini_array .jcr .dynamic .got查看ELF Section Header Table# readelf -S main There are 31 section headers, starting at offset 0x1b88: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .interp PROGBITS 0000000000400238 00000238 000000000000001c 0000000000000000 A 0 0 1 [ 2] .note.ABI-tag NOTE 0000000000400254 00000254 0000000000000020 0000000000000000 A 0 0 4 [ 3] .note.gnu.build-i NOTE 0000000000400274 00000274 0000000000000024 0000000000000000 A 0 0 4 。。。 [10] .rela.plt RELA 0000000000400498 00000498 00000000000000d8 0000000000000018 AI 5 24 8 [11] .init PROGBITS 0000000000400570 00000570 000000000000001a 0000000000000000 AX 0 0 4 。。。 [23] .got PROGBITS 0000000000600ff8 00000ff8 0000000000000008 0000000000000008 WA 0 0 8 。。。 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), l (large), p (processor specific).text节是保存了程序代码指令的代码节。.data节保存了初始化的全局变量和局部静态变量等数据。.rodata节保存了只读的数据如一行C语言代码中的字符串。由于.rodata节是只读的所以只能存在于⼀个可执行文件的只读段中。因此只能是在text段不是data段中找到.rodata节。.BSS节为未初始化的全局变量和局部静态变量预留位置。.symtab节: Symbol Table 符号表就是源码里面那些函数名、变量名和代码的对应关系。.got.plt节全局偏移表-过程链接表.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导入的共享库函数的访问入口由动态链接器在运行时进行 修改。查看具体的Section信息# objdump -S main main: file format elf64-x86-64 Disassembly of section .init: 0000000000400570 _init: 400570: 48 83 ec 08 sub $0x8,%rsp 400574: 48 8b 05 7d 0a 20 00 mov 0x200a7d(%rip),%rax 40057b: 48 85 c0 test %rax,%rax 40057e: 74 05 je 400585 _init0x15 400580: e8 ab 00 00 00 callq 400630 .plt.got 400585: 48 83 c4 08 add $0x8,%rsp 400589: c3 retq Disassembly of section .plt: 0000000000400590 .plt: 400590: ff 35 72 0a 20 00 pushq 0x200a72(%rip) 400596: ff 25 74 0a 20 00 jmpq *0x200a74(%rip) 40059c: 0f 1f 40 00 nopl 0x0(%rax) 00000000004005a0 writeplt: 4005a0: ff 25 72 0a 20 00 jmpq *0x200a72(%rip) 4005a6: 68 00 00 00 00 pushq $0x0 4005ab: e9 e0 ff ff ff jmpq 400590 .plt 00000000004005b0 printfplt: 4005b0: ff 25 6a 0a 20 00 jmpq *0x200a6a(%rip) 4005b6: 68 01 00 00 00 pushq $0x1 4005bb: e9 d0 ff ff ff jmpq 400590 .plt 00000000004005c0 closeplt: 4005c0: ff 25 62 0a 20 00 jmpq *0x200a62(%rip) 4005c6: 68 02 00 00 00 pushq $0x2 4005cb: e9 c0 ff ff ff jmpq 400590 .plt 00000000004005d0 __libc_start_mainplt: 4005d0: ff 25 5a 0a 20 00 jmpq *0x200a5a(%rip) 4005d6: 68 03 00 00 00 pushq $0x3 4005db: e9 b0 ff ff ff jmpq 400590 .plt查看编译后的.o目标文件$ objdump -d hello.o hello.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 main: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: bf 00 00 00 00 mov $0x0,%edi 9: e8 00 00 00 00 callq e main0xe e: b8 00 00 00 00 mov $0x0,%eax 13: e8 00 00 00 00 callq 18 main0x18 18: b8 00 00 00 00 mov $0x0,%eax 1d: 5d pop %rbp 1e: c3 retq图一图二ELF形成可执行过程step1将多份C/C源代码翻译成目标文件动静态库step2将多份文件Section进行合并如图二ELF可执行加载过程⼀个ELF会有多种不同的Section在加载到内存时也会进行Section合并形成segment。合并原则相同属性比如可读可写可执行需要加载时申请空间等。这个合并工作在形成 ELF 的时候合并方式就已经确定了具体合并原则被记录在了 ELF 的 程序头表(Program header table) 中。为什么要将Section合并成段segmentSection合并的主要原因是为了减少页面碎片提高内存使用效率。此外操作系统在加载程序时会将具有相同属性的section合并成⼀个大的segment这样就可以实现不同的访问权限从而优化内存管理和权限访问控制。2. 理解链接和加载2.1 静态链接研究静态链接本质就是研究.o是如何链接的。// hello.c #includestdio.h void run(); int main() { printf(hello world!\n); run(); return 0; } // code.c #includestdio.h void run() { printf(running...\n); } // 编译两个源⽂件 $ gcc -c hello.c $ gcc -c code.c $ ls code.c code.o hello.c hello.o2.1.1 静态链接过程查看编译后的.o文件我们可以看到hello.c中的main函数完全不认识printf和run函数这两个的函数地址设为0。这是因为在编译 hello.c 的时候编译器是完全不知道 printf 和 run 函数的存在的。因此编译器只能将这两个函数的跳转地址先暂时设为0。这个地址会在链接的时候进行修正为了让链接器将来在链接时能够正确定位到这些被修正的地址在代码块.data中还存在⼀个重定位表这张表将来在链接的时候就会根据表里记录的地址将其修正。读取code.o的符号表puts就是printf的实现UND就是undefine说白了就是.o文件找不到读取hello.o的符号表也一样如果将两个.o进行合并并进行统一的编址链接的时候会修改.o中没有确定的函数地址。所以链接其实就是将编译之后的所有目标文件连同用到的⼀些静态库运行时库组合拼装成⼀个独立的可执行文件。其中就包括我们之前提到的地址修正当所有模块组合在⼀起之后链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量从而修正它们的地址。这其实就是静态链接的过程。链接过程中会涉及到对.o中外部符号进行地址重定位所以.o文件又叫可重定位文件。2.1.2 ELF加载与进程空间地址逻辑地址/虚拟地址逻辑地址起始地址偏移量一个ELF程序在没有被加载到内存的时候本身就会有地址当代计算机工作都是采用“平坦模式”所以也要求ELF对自己的代码和数据进行统一编址如下图反汇编之后代码最左侧的就是ELF的虚拟地址其实严格意义上应该叫做逻辑地址(起始地址偏移量), 但是我们一般认为起始地址是0。也就是说其实虚拟地址在我们的程序还没有加载到内存的时候就已经把可执行程序进行统一编址了。进程mm_struct、vm_area_struct在进程刚刚创建的时候初始化数据从ELF各个segment来每个segment有自己的起始地址和自己的长度用来初始化内核结构中的[start, end]等范围数据另外在用详细地址虚拟地址和加载到内存后的物理地址填充页表。所以虚拟地址机制既要OS支持也要编译器支持。重新理解进程虚拟地址空间ELF 在被编译好之后会把自己未来程序的入口地址记录在ELF header的Entry字段中将文件加载到物理内存后获取原本在磁盘上就有的逻辑地址和物理内存上的地址填充到页表建立映射关系mm_struct里面的其他数据则从segment当中获取。在CPU内部存在几种硬件EIP存放下一条指令的地址指令也有长度当前地址当前指令长度下一条指令地址MMUEIP内部实际上放的是虚拟地址需要MMU通过页表找到真实的物理地址再交给IRIR存放当前正在执行指令的地址CR3存放当前进程的页表首地址这些统称为当前进程的硬件上下文。所以我们可以看到虚拟空间地址还需要CPU的支持才可以。2.2 动态链接和动态库加载进程如何看到动态库把库文件映射到当前进程的共享区进程间如何共享库的本质是把动态库映射到自己的虚拟地址空间中动态库也叫共享库2.2.1 动态链接静态链接最大的问题在于生成的文件体积大并且相当耗费内存资源。这个时候动态链接的优势就体现出来了我们可以将需要共享的代码单独提取出来保存成⼀个独立的动态链接库等到程序运行的时候再将它们加载到内存这样可以节省空间因为同⼀个模块在内存中只需要保留⼀份副本就可以被不同的进程所共享。那么动态链接是如何工作的动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行⼀个程序操作系统会首先将程序的数据代码连同它用到的⼀系列动态库先加载到内存其中每个动态库的加载地址都是不固定的操作系统会根据当前地址空间的使用情况为它们动态分配⼀段内存。当动态库被加载到内存以后⼀旦它的内存地址被确定我们就可以去修正动态库中的那些函数跳转地址了。在C/C程序中当程序开始执行时它首先并不会直接跳转到 main 函数。实际上程序的入口点是 _start 这是⼀个由C运行时库通常是glibc或链接器如ld提供的特殊函数。在 _start 函数中会执行⼀系列初始化操作这些操作包括设置堆栈为程序创建⼀个初始的堆栈环境初始化数据段将程序的数据段如全局变量和静态变量从初始化数据段复制到相应的内存位置并清零未初始化的数据段。动态链接这是关键的⼀步 _start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库shared libraries。动态链接器会处理所有的符号解析和重定位确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。动态链接器动态链接器如ld-linux.so负责在程序运行时加载动态库。当程序启动时动态链接器会解析程序中的动态库依赖并加载这些库到内存中。环境变量和配置文件Linux系统通过环境变量如LD_LIBRARY_PATH和配置文件如/etc/ld.so.conf及其子配置文件来指定动态库的搜索路径。这些路径会被动态链接器在加载动态库时搜索。缓存文件为了提高动态库的加载效率Linux系统会维护⼀个名为/etc/ld.so.cache的缓存文件。该文件包含了系统中所有已知动态库的路径和相关信息动态链接器在加载动态库时会首先搜索这个缓存文件。调用 __libc_start_main⼀旦动态链接完成 _start 函数会调用__libc_start_main 这是glibc提供的⼀个函数。 __libc_start_main 函数负责执行⼀些额外的初始化⼯作比如设置信号处理函数、初始化线程库如果使用了线程等。调用 main 函数最后 __libc_start_main 函数会调用程序的 main 函数此时程序的执行控制权才正式交给用户编写的代码。处理 main 函数的返回值当 main 函数返回时 __libc_start_main 会负责处理这个返回值并最终调用 _exit 函数来终止程序。动态库中的相对地址动态库为了随时进行加载为了支持并映射到任意进程的任意位置对动态库中的方法统⼀编址采用相对编址的方案进行编制的(其实可执行程序也⼀样都要遵守平坦模式只不过exe是直接加载的)。库被映射到了当前进程的地址空间中我们也知道库的虚拟起始地址也知道库中每一个方法的偏移量通过这两点我们可以访问库中的任意方法整个过程从代码区跳转到共享区再回到代码区都是再进程地址空间中完成的。我们还需要对加载到内存中的程序调用的库函数进行地址修改在内存中二次完成地址设置叫做加载地址重定位但是代码区不能修改所以这里的做法是在.data中专门留一片区域来存放函数的跳转地址它也被叫做全局偏移表(Global Offset Table)表中的每一项都是本运行模块要引用的一个全局变量或函数地址因为,data区域是可读写的所以支持动态修改。由于代码段只读我们不能直接修改代码段。但有了GOT表代码便可以被所有进程共享。但在不同进程的地址空间中各动态库的绝对地址、相对位置都不同。反映到GOT表上就是每个进程的每个动态库都有独立的GOT表所以进程间不能共享GOT表。在单个.so下由于GOT表与 .text 的相对位置是固定的我们完全可以利用CPU的相对寻址来找到GOT表。在调用函数的时候会首先查表然后根据表中的地址来进行跳转这些地址在动态库加载的时候会被修改为真正的地址当第二次调用这个函数时就可以直接使用了。这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说我们的动态库不需要做任何修改被加载到任意内存地址都能够正常运行并且能够被所有进程共享这也是为什么之前我们给编译器指定-fPIC参数的原因PIC相对编址GOT。对于GOT表它并不是加载的时候就立即修改的有点写时拷贝的感觉按需修改这样就不会影响进程加载的效率了。GOT是如何进行工作的第一阶段延迟绑定Lazy Binding—— 第一次调用步骤 1代码执行 call printfplt通过过程链接表 PLT 跳转。步骤 2PLT 跳转到 GOT 中对应 printf 的条目。步骤 3此时 GOT 里存的不是printf地址而是指向下一行指令的地址一个“桩”函数。步骤 4执行“桩”函数触发动态链接器去查找 printf 的真实内存地址。步骤 5动态链接器找到地址后把它写入 GOT 对应的条目中覆盖掉原来的“桩”地址。步骤 6跳转执行真正的printf。第二阶段直接跳转 —— 后续调用再次执行 call printfplt 时PLT 再次跳转到 GOT。此时 GOT 里存的已经是 printf的真实地址了直接跳转执行不再触发链接器。为了让共享库.so能被加载到内存的任意地址都能运行编译时需要加上-fPIC参数生成位置无关代码。