【Linux】系统部分——ELF文件格式与动态库加载

19.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):保存已初始化的全局变量和局部静态变量。

  1. 了解ELF文件的大致格式之后,可以理解将若干个.o.a等ELF文件链接形成可执行程序(也是ELF结构)的过程可以粗略的理解为经一个个的section进行合并。但要注意:并不是这么简单的合并,也会涉及对库合并,此处不做过多追究。

  2. 对于程序头表(Program header table) 的理解:而我们在理解一个一个二进制文件的时候可以把这些文件的内容理解为一个巨大的"一维数组",标识文件中任何一个区域,可以用 "偏移量 + 大小" 方式。把不同区域的偏移量记录在文件中,当程序被加载到内存中时就可以根据这些偏移量的大小把不同区域的数据分别进行加载

  3. 可以使用readelf指令查看ELF格式文件的各部分信息

    readelf -h ELF Hander

    readelf -l Program header table

    readelf -S Section

shell 复制代码
[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:                             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:               0x400620							#程序代码执行的起始地址
  Start of program headers:          64 (bytes into file)
  Start of section headers:          6656 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  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
[user@iZ7xvdsb1wn2io90klvtwlZ stdio]$ readelf -l main

Elf file type is EXEC (Executable file)
Entry point 0x400620
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
                 0x00000000000009d4 0x00000000000009d4  R E    200000
  LOAD           0x0000000000000e00 0x0000000000600e00 0x0000000000600e00
                 0x000000000000024c 0x0000000000000250  RW     200000
  DYNAMIC        0x0000000000000e18 0x0000000000600e18 0x0000000000600e18
                 0x00000000000001e0 0x00000000000001e0  RW     8
  NOTE           0x0000000000000254 0x0000000000400254 0x0000000000400254
                 0x0000000000000044 0x0000000000000044  R      4
  GNU_EH_FRAME   0x00000000000008ac 0x00000000004008ac 0x00000000004008ac
                 0x0000000000000034 0x0000000000000034  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO      0x0000000000000e00 0x0000000000600e00 0x0000000000600e00
                 0x0000000000000200 0x0000000000000200  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 
[user@iZ7xvdsb1wn2io90klvtwlZ stdio]$ readelf -S main
There are 31 section headers, starting at offset 0x1a00:

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
#............#
       000000000000005a  0000000000000001  MS       0     0     1
  [28] .symtab           SYMTAB           0000000000000000  000010a8
       0000000000000660  0000000000000018          29    47     8
  [29] .strtab           STRTAB           0000000000000000  00001708
       00000000000001ec  0000000000000000           0     0     1
  [30] .shstrtab         STRTAB           0000000000000000  000018f4
       000000000000010c  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反汇编,截取其中一部分得到:

shell 复制代码
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  $0x0
  4005bb:	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  $0x1
  4005cb:	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  $0x2
  4005db:	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  $0x3
  4005eb:	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  $0x4
  4005fb:	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  $0x5
  40060b:	e9 90 ff ff ff       	jmpq   4005a0 <.plt>

#..............

0000000000400620 <_start>:
  400620:	31 ed                	xor    %ebp,%ebp
  400622:	49 89 d1             	mov    %rdx,%r9
  400625:	5e                   	pop    %rsi
  400626:	48 89 e2             	mov    %rsp,%rdx
  400629:	48 83 e4 f0          	and    $0xfffffffffffffff0,%rsp
  40062d:	50                   	push   %rax
  40062e:	54                   	push   %rsp
  40062f:	49 c7 c0 70 08 40 00 	mov    $0x400870,%r8
  400636:	48 c7 c1 00 08 40 00 	mov    $0x400800,%rcx
  40063d:	48 c7 c7 0d 07 40 00 	mov    $0x40070d,%rdi
  400644:	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)

#...............
  1. 每一条命令都有对应的逻辑地址,这个逻辑地址从0000...000到FFFF...FFF不是真整的磁盘中的地址(平坦地址)。这个逻辑地址用于初始化进程中mm_struct虚拟地址,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执⾏程序进⾏统⼀编址了

  2. CPU在调度的时候使用的也是虚拟地址(在上面的代码中有注释说明原因),虚拟地址中的代码加载到物理内存之后同样会占据物理内存的真实地址,这个虚拟地址和物理地址的映射关系会在页表中增加,CPU调度时会有一个CR3寄存器指向这个页表的物理地址,因此即使CPU使用的是虚拟地址也同样可以找到物理内存中这个地址下的命令。而查表这个工作是CPU中的MMU硬件完成的

  3. 虚拟值空间是操作系统,CPU,编译器共同协作下的产物。由于虚拟地址的存在,编译器在编译代码的时候就不需要考虑真实情况下的物理内存的地址,只需要使用虚拟地址从0000...000到FFFF...FFF直接编址,实现编译器和操作系统的解耦。

  1. 一个 mm_struct 对应一个进程的虚拟地址空间,其中包含多个 vm_area_struct,每个 vm_area_struct 描述该地址空间中的一个子区域,由于这种结构,虚拟地址空间的每个区域的划分就更加方便

动态库的加载,程序调用动态库

程序要使用动态库,动态库就要先加载到物理内存中,加载到物理内存中之后动态库也就有了相对应的虚拟地址与物理地址的映射,而进程又得到库的虚拟地址,访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法

图中最下面时动态链接的 "重定位" 过程(代码层面的地址修正),动态库的函数调用,需要通过 "编译时记录偏移 + 加载后重定位" 实现:

  • 编译阶段 :源代码中调用 puts(属于 libc.so),编译生成的汇编代码会记录 putslibc.so 内的偏移地址 (如 0x112233),此时代码为 call libc.so@0x112233(仅知道在库内的相对位置,不知道库的绝对虚拟地址)。
  • 加载 & 重定位阶段libc.so 被加载到进程虚拟地址空间后,系统会确定其起始虚拟地址 (如 0x4332211)。此时会对代码进行 "重定位",将 call libc.so@0x112233 修正为 call 0x4332211 + 0x112233,从而得到 puts 函数的实际虚拟地址,进程即可正确调用。

全局偏移表GOT:

根据上面的分析,简单总结一下就是:如果进程要用动态库中的某一个方法,就需要知道这个动态库的虚拟地址和这个方法在动态库中的地址偏移量(由可执行程序给出),但对于这个动态库的虚拟地址:

一个动态库在被不同的进程使用的时候不同进程中这个动态库映射得到的虚拟地址不一定是相同的,由于代码区的数据是只读的且进程运行时使用的是虚拟地址,我们不能通过直接修改代码区的虚拟地址,动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。

  1. 动态库的代码被所有链接这个动态库的进程共享,不能直接修改代码段,但有了GOT表 ,在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址
  2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表
  3. 反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表
  4. 这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT

总结

  1. 知道一个可执行程序是elf格式,在elf格式中,每个数据段天然被编成一个section。elf handler包含一个叫做Entry的字段,它将我们的可执行程序编制统一,程序在加载到内存之前已被编制,入口函数在elf头部中。我们自己的二进制程序的入口地址,在linux中一般叫做_start_start是编译器在调用main函数之前新增的那部分代码。
  2. 第二个重点是我们一定要知道,我们的可执行程序在磁盘上已经被以虚拟地址的方式进行编制,当它加载到内存时,内部函数跳转寻址时,所有的地址已经是虚拟地址。这样的内存天然具备物理地址,并提供给cpu。cpu看到的东西都是虚拟地址,它有虚拟地址,并且有一个cr3寄存器可以指向自己的页表,所以cpu读取指令时,第一条指令以虚拟页表、虚拟空间以及页表映射找到程序地址,cpu内部拿到的全都是虚拟地址,经过物理地址转化。虚拟地址到物理地址的转化通过查页表找到物理地址,找到存储位置,然后继续做这样的工作。经过这样的设计,虚拟到物理地址之间的逻辑链路就完整了。从虚拟地址读指令,指令中包含虚拟地址,再将虚拟地址通过页表转化到物理地址,找到下一条指令,重复这样的过程。
  3. 我们的地址空间中的ms struct内部包含一张链表vm_area_structvm_area_struct可以进一步把我们的地址空间进行区域划分。然后,我们就可以动态地实现地址空间的划分过程。
  4. 动态库加载,当程序加载时库也要加载进来,当程序需要调用库时,第一,我们调库方法是在我们的地址空间上的;第二,在代码中调用库函数用的是库名字和偏移量,当库实际映射到具体物理地址时,用库映射到地址空间上的虚拟地址修改。在我们正文部分的代码中加入库地址,批量添加库地址即可。这个过程称为地址重定位。当程序加载到内存时,对应的共享库映射到地址空间,需要进行重定位。地址重定位后,在正文部分调用函数时,可以采用我们的寻址方案,找到共享库中的函数。另外,我们说过代码不是只读的。因此,在设计可执行程序时,我们新增了一个全局偏移量表。该表被合并在数据区,相当于在正文部分找到这张表后,通过表的起始地址加上偏移量,即可找到对应的共享库函数。这样,正文部分就不需要改变,一旦进行地址映射,只需修改内存中的got表,通过查表方式即可跳转到共享库。
相关推荐
一匹电信狗5 小时前
【C++】C++11新特性第一弹(列表初始化、新式声明、范围for和STL中的变化)
服务器·开发语言·c++·leetcode·小程序·stl·visual studio
CharXL6 小时前
Linux性能分析工具和方法
linux·工具·技巧
爱吃烤鸡翅的酸菜鱼6 小时前
Linux常用命令行大全:14个核心指令详解+实战案例
linux·运维
板鸭〈小号〉8 小时前
Socket 编程 UDP
linux·网络协议·udp
m0_619731198 小时前
linux和RTOS架构区别
linux·运维·服务器
情深不寿3179 小时前
序列化和反序列化
linux·网络·c++·tcp/ip
青草地溪水旁10 小时前
linux修改权限命令chmod
linux·chmod
羑悻的小杀马特11 小时前
从Cgroups精准调控到LXC容器全流程操作:用pidstat/stress测试Cgroups限流,手把手玩转Ubuntu LXC容器全流程
linux·服务器·数据库·docker·lxc·cgroups