【Linux】系统部分——ELF文件格式与动态库加载
19.ELF文件格式与动态库加载
文章目录
- 19.ELF文件格式与动态库加载
- 动态库加载与进程地址空间(引入)
- ELF文件格式
- 进程地址空间的补充
- 动态库的加载,程序调用动态库
动态库加载与进程地址空间(引入)
动态库:程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。 在之前我们初步了解过进程地址空间,我们知道:为了避免程序直接操作物理内存地址,在一个进程的task_struct
中,操作系统在程序与物理内存之间引入了一个中间数据结构,其类型通常命名为 mm_struct
。操作系统为每个程序构建好这个专属的页表后,程序在运行过程中始终通过它来访问内存。当程序需要读写内存时,最终都必须经由自身这个页表结构来完成访问操作。
如果是动态库链接形成的可执行文件,在操作系统运行之后变成进程,在内存中有自己的代码和数据,动态库作为这个可执行程序的一部分,也会加载到内存当中。通过页表把动态库在内存中的地址映射到mm_struct
中的共享区,如图所示:
由于多个程序共享使用库的代码,所以如果不同进程都需要使用同一个动态库,这个动态库可以通过虚拟地址共享
ELF文件格式
想要理解动态库加载时进程地址空间中的具体操作还需要补充ELF文件格式这个内容
可执行程序是有规则的二进制文件,在Linux系统中格式为ELF,其实不仅仅是可执行文件,包括动静态库、.o
文件的格式都是ELF。
ELF文件的结构:
ELF头(ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。
程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。
节头表(Section header table) :包含对节(sections)的描述。
节(Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。最常⻅的节:代码节(.text):⽤于保存机器指令,是程序的主要执⾏部分。 数据节(.data):保存已初始化的全局变量和局部静态变量。
-
了解ELF文件的大致格式之后,可以理解将若干个
.o
和.a
等ELF文件链接形成可执行程序(也是ELF结构)的过程可以粗略的理解为经一个个的section进行合并。但要注意:并不是这么简单的合并,也会涉及对库合并,此处不做过多追究。 -
对于程序头表(Program header table) 的理解:而我们在理解一个一个二进制文件的时候可以把这些文件的内容理解为一个巨大的"一维数组",标识文件中任何一个区域,可以用 “偏移量 + 大小” 方式。把不同区域的偏移量记录在文件中,当程序被加载到内存中时就可以根据这些偏移量的大小把不同区域的数据分别进行加载
-
可以使用
readelf
指令查看ELF格式文件的各部分信息readelf -h
ELF Handerreadelf -l
Program header tablereadelf -S
Section
[user@iZ7xvdsb1wn2io90klvtwlZ stdio]$ readelf -h main
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: EXEC (Executable file)Machine: Advanced Micro Devices X86-64Version: 0x1Entry point address: 0x400620 #程序代码执行的起始地址Start of program headers: 64 (bytes into file)Start of section headers: 6656 (bytes into file)Flags: 0x0Size of this header: 64 (bytes)Size of program headers: 56 (bytes)Number of program headers: 9Size of section headers: 64 (bytes)Number of section headers: 31Section header string table index: 30
[user@iZ7xvdsb1wn2io90klvtwlZ stdio]$ readelf -l mainElf file type is EXEC (Executable file)
Entry point 0x400620
There are 9 program headers, starting at offset 64Program Headers:Type Offset VirtAddr PhysAddrFileSiz MemSiz Flags AlignPHDR 0x0000000000000040 0x0000000000400040 0x00000000004000400x00000000000001f8 0x00000000000001f8 R E 8INTERP 0x0000000000000238 0x0000000000400238 0x00000000004002380x000000000000001c 0x000000000000001c R 1[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]LOAD 0x0000000000000000 0x0000000000400000 0x00000000004000000x00000000000009d4 0x00000000000009d4 R E 200000LOAD 0x0000000000000e00 0x0000000000600e00 0x0000000000600e000x000000000000024c 0x0000000000000250 RW 200000DYNAMIC 0x0000000000000e18 0x0000000000600e18 0x0000000000600e180x00000000000001e0 0x00000000000001e0 RW 8NOTE 0x0000000000000254 0x0000000000400254 0x00000000004002540x0000000000000044 0x0000000000000044 R 4GNU_EH_FRAME 0x00000000000008ac 0x00000000004008ac 0x00000000004008ac0x0000000000000034 0x0000000000000034 R 4GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000000 0x0000000000000000 RW 10GNU_RELRO 0x0000000000000e00 0x0000000000600e00 0x0000000000600e000x0000000000000200 0x0000000000000200 R 1Section 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
[user@iZ7xvdsb1wn2io90klvtwlZ stdio]$ readelf -S main
There are 31 section headers, starting at offset 0x1a00:Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .interp PROGBITS 0000000000400238 00000238
#............#000000000000005a 0000000000000001 MS 0 0 1[28] .symtab SYMTAB 0000000000000000 000010a80000000000000660 0000000000000018 29 47 8[29] .strtab STRTAB 0000000000000000 0000170800000000000001ec 0000000000000000 0 0 1[30] .shstrtab STRTAB 0000000000000000 000018f4000000000000010c 0000000000000000 0 0 1
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)
进程地址空间的补充
在前面讲到:创建进程时会产生虚拟的进程地址空间,通过页表映射到实际的物理内存地址。但是管理进程地址空间的mm_struct
结构体中的初始化信息就是从可执行程序读取得到的。
因为在形成可执行程序的时候,在可执行程序内部就已经存在各部分代码的虚拟地址了,使用objdump
反汇编,截取其中一部分得到:
00000000004005a0 <.plt>:4005a0: ff 35 62 0a 20 00 pushq 0x200a62(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>4005a6: ff 25 64 0a 20 00 jmpq *0x200a64(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>4005ac: 0f 1f 40 00 nopl 0x0(%rax)00000000004005b0 <my_strlen@plt>:4005b0: ff 25 62 0a 20 00 jmpq *0x200a62(%rip) # 601018 <my_strlen>4005b6: 68 00 00 00 00 pushq $0x04005bb: e9 e0 ff ff ff jmpq 4005a0 <.plt>00000000004005c0 <mfopen@plt>:4005c0: ff 25 5a 0a 20 00 jmpq *0x200a5a(%rip) # 601020 <mfopen>4005c6: 68 01 00 00 00 pushq $0x14005cb: e9 d0 ff ff ff jmpq 4005a0 <.plt>00000000004005d0 <mfwrite@plt>:4005d0: ff 25 52 0a 20 00 jmpq *0x200a52(%rip) # 601028 <mfwrite>4005d6: 68 02 00 00 00 pushq $0x24005db: e9 c0 ff ff ff jmpq 4005a0 <.plt>00000000004005e0 <printf@plt>:4005e0: ff 25 4a 0a 20 00 jmpq *0x200a4a(%rip) # 601030 <printf@GLIBC_2.2.5>4005e6: 68 03 00 00 00 pushq $0x34005eb: e9 b0 ff ff ff jmpq 4005a0 <.plt>00000000004005f0 <__libc_start_main@plt>:4005f0: ff 25 42 0a 20 00 jmpq *0x200a42(%rip) # 601038 <__libc_start_main@GLIBC_2.2.5>4005f6: 68 04 00 00 00 pushq $0x44005fb: e9 a0 ff ff ff jmpq 4005a0 <.plt>0000000000400600 <mfclose@plt>:400600: ff 25 3a 0a 20 00 jmpq *0x200a3a(%rip) # 601040 <mfclose>400606: 68 05 00 00 00 pushq $0x540060b: e9 90 ff ff ff jmpq 4005a0 <.plt>#..............0000000000400620 <_start>:400620: 31 ed xor %ebp,%ebp400622: 49 89 d1 mov %rdx,%r9400625: 5e pop %rsi400626: 48 89 e2 mov %rsp,%rdx400629: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp40062d: 50 push %rax40062e: 54 push %rsp40062f: 49 c7 c0 70 08 40 00 mov $0x400870,%r8400636: 48 c7 c1 00 08 40 00 mov $0x400800,%rcx40063d: 48 c7 c7 0d 07 40 00 mov $0x40070d,%rdi400644: e8 a7 ff ff ff callq 4005f0 <__libc_start_main@plt> #内部调用的也是逻辑地址(虚拟地址)400649: f4 hlt 40064a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)#...............
-
每一条命令都有对应的逻辑地址,这个逻辑地址从0000…000到FFFF…FFF不是真整的磁盘中的地址(平坦地址)。这个逻辑地址用于初始化进程中
mm_struct
虚拟地址,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执⾏程序进⾏统⼀编址了 -
CPU在调度的时候使用的也是虚拟地址(在上面的代码中有注释说明原因),虚拟地址中的代码加载到物理内存之后同样会占据物理内存的真实地址,这个虚拟地址和物理地址的映射关系会在页表中增加,CPU调度时会有一个CR3寄存器指向这个页表的物理地址,因此即使CPU使用的是虚拟地址也同样可以找到物理内存中这个地址下的命令。而查表这个工作是CPU中的MMU硬件完成的
-
虚拟值空间是操作系统,CPU,编译器共同协作下的产物。由于虚拟地址的存在,编译器在编译代码的时候就不需要考虑真实情况下的物理内存的地址,只需要使用虚拟地址从0000…000到FFFF…FFF直接编址,实现编译器和操作系统的解耦。
- 一个
mm_struct
对应一个进程的虚拟地址空间,其中包含多个vm_area_struct
,每个vm_area_struct
描述该地址空间中的一个子区域,由于这种结构,虚拟地址空间的每个区域的划分就更加方便
动态库的加载,程序调用动态库
程序要使用动态库,动态库就要先加载到物理内存中,加载到物理内存中之后动态库也就有了相对应的虚拟地址与物理地址的映射,而进程又得到库的虚拟地址,访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法
图中最下面时动态链接的 “重定位” 过程(代码层面的地址修正),动态库的函数调用,需要通过 “编译时记录偏移 + 加载后重定位” 实现:
- 编译阶段:源代码中调用
puts
(属于libc.so
),编译生成的汇编代码会记录puts
在libc.so
内的偏移地址(如0x112233
),此时代码为call libc.so@0x112233
(仅知道在库内的相对位置,不知道库的绝对虚拟地址)。 - 加载 & 重定位阶段:
libc.so
被加载到进程虚拟地址空间后,系统会确定其起始虚拟地址(如0x4332211
)。此时会对代码进行 “重定位”,将call libc.so@0x112233
修正为call 0x4332211 + 0x112233
,从而得到puts
函数的实际虚拟地址,进程即可正确调用。
全局偏移表GOT:
根据上面的分析,简单总结一下就是:如果进程要用动态库中的某一个方法,就需要知道这个动态库的虚拟地址和这个方法在动态库中的地址偏移量(由可执行程序给出),但对于这个动态库的虚拟地址:
一个动态库在被不同的进程使用的时候不同进程中这个动态库映射得到的虚拟地址不一定是相同的,由于代码区的数据是只读的且进程运行时使用的是虚拟地址,我们不能通过直接修改代码区的虚拟地址,动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。
- 动态库的代码被所有链接这个动态库的进程共享,不能直接修改代码段,但有了GOT表 ,在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址
- 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表
- 反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表
- 这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT