【Linux笔记】动态库与静态库的理解与加载
🔥个人主页🔥:孤寂大仙V
🌈收录专栏🌈:Linux
🌹往期回顾🌹:【Linux笔记】动态库与静态库的制作
🔖流水不争,争的是滔滔不
- 一、ELF文件
- 二、ELF的形成与加载
- ELF的形成
- ELF可执行文件加载
- 三、理解加载与链接
- 静态链接
- ELF加载与进程地址空间
- 动态链接与动态库加载
- 全局偏移量表GOT(global offset table)
一、ELF文件
ELF 文件是一种二进制文件格式,它定义了文件的结构和内容,使得操作系统、链接器和调试器等工具能够正确地处理和解释文件。这种格式具有良好的可移植性和扩展性,能够适应不同的硬件平台和操作系统。
ELF 文件主要由以下几个部分组成:
-
ELF 头(ELF Header)
位于文件的开头,包含了文件的基本信息,如文件类型(可执行文件、目标文件等)、机器架构(如 x86、ARM 等)、入口地址(程序开始执行的地址)等。ELF 头还指明了程序头表和节头表的位置和大小。 -
程序头表(Program Header Table)
由多个程序头项组成,每个程序头项描述了一个需要加载到内存中的段(Segment)。段是一组具有相同属性(如访问权限)的节的集合,程序头表告诉操作系统的加载器如何将文件内容映射到内存中,并设置相应的内存访问权限。表里记着每个段的开始的位置和位移(offset)、长度。 -
节头表(Section Header Table)
由多个节头项组成,每个节头项对应文件中的一个节(Section)。节是文件中具有特定用途的数据块,如代码段(.text)存放程序的可执行代码,数据段(.data)存放已初始化的全局变量和静态变量,符号表(.symtab)存储文件中的符号信息等。节头表为链接器、调试器等工具提供了文件内部结构的详细信息。包含对节(sections)的描述。 -
节(Sections)和段(Segments)
节:是 ELF 文件中最基本的数据单元,每个节都有特定的用途。除了上述提到的代码段和数据段,还有.bss节存放未初始化的全局变量和静态变量,.rodata节存放只读数据,.rel.text节存放代码段的重定位信息等。
段:是为了满足程序在内存中的加载和执行需求而对节进行的分组。例如,代码段通常包含.text节和.rodata节,它们都具有只读和可执行的属性;数据段包含.data节和.bss节,它们具有读写属性。
节头表(Section Header Table)
- 面向文件内部:用于描述 ELF 文件自身的节(Section)结构,如代码段.text、数据段.data、符号表.symtab等。
- 主要服务于工具链:链接器、调试器、反汇编工具等通过节头表解析文件内部的详细信息,例如符号地址、重定位信息等。
- 存在于所有 ELF 文件:目标文件、可执行文件、共享库中均可能包含节头表(但发布版本的可执行文件可能会剥离节头表以减小体积)。
程序头表(Program Header Table)
- 面向外部执行环境:用于描述 ELF 文件在内存中的布局和加载方式,将文件内容映射到进程地址空间。
- 主要服务于操作系统:加载器(如 Linux 的ld-linux.so)通过程序头表确定哪些段(Segment)需要加载到内存、内存权限(可读 / 写 / 执行)等。
- 仅存在于可执行文件和共享库:目标文件通常不含程序头表,因为其尚未确定最终的内存布局。
类比理解
- 节头表:类似 “文件内部的目录”,记录文件内部各部分的详细信息(如房间的用途、大小、位置)。
- 程序头表:类似 “建筑的框架图”,指导如何将文件内容 “搭建” 成可运行的程序(如承重墙的位置、楼层高度等)。
通过这两个表,ELF 文件同时满足了编译链接(节头表)和运行加载(程序头表)的双重需求。
二、ELF的形成与加载
ELF的形成
粗粒度的将ELF的合并理解为:
将多份 C/C++ 源代码,翻译成为目标 .o 文件
将多份 .o 文件section进行合并
ELF可执行文件加载
# 查看节(section)信息
$ readelf -S a.out
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 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
[ 4] .gnu.hash GNU_HASH 0000000000400298 00000298
000000000000001c 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 00000000004002b8 000002b8
0000000000000048 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000400300 00000300
0000000000000038 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 0000000000400338 00000338
0000000000000006 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000400340 00000340
0000000000000020 0000000000000000 A 6 1 8
[ 9] .rela.dyn RELA 0000000000400360 00000360
0000000000000018 0000000000000018 A 5 0 8
[10] .rela.plt RELA 0000000000400378 00000378
0000000000000018 0000000000000018 AI 5 24 8
[11] .init PROGBITS 0000000000400390 00000390
...
# 查看节合并后的段(segment)信息
$ readelf -l a.out
Elf file type is EXEC (Executable file)
Entry point 0x4003e0
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
0x0000000000000744 0x0000000000000744 R E 0x200000
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x0000000000000218 0x0000000000000220 RW 0x200000
DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
0x00000000000001d0 0x00000000000001d0 RW 8
NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254
0x0000000000000044 0x0000000000000044 R 4
GNU_EH_FRAME 0x00000000000005a0 0x00000000004005a0 0x00000000004005a0
0x000000000000004c 0x000000000000004c R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
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 .got.plt
链接时,形成的section head table 把所有section数据节进行全部描述。program haeder table把相同属性的segment拿到,segment就是合并的相同属性的数据结。将来找到这个程序的时候,操作系统就会读取program header table,然后根据program header table所描述的内容,找到需要加载的内容在文件当中的位置把需要加载内容的若干个数据节加载到内存空间。
Section Header Table 和 Program Header Table 在 ELF文件中扮演着不同但又相互关联的角色。Section Header Table侧重于描述文件内部的结构,为链接器和调试器提供详细信息;Program Header Table则关注程序在内存中的布局和加载,帮助操作系统将程序正确加载到内存中并执行。
⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment
合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等.
这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的 程序头表(Program header table) 中
为什么要将section合并成为srgment?
- Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。
- 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
对于程序头表(Program Header Table)和节头表(Section Header Table)在和操作系统交互时的作用
上面说过一个是在链接时候起作用,一个是在运行加载时候起作用。
链接视图(Linking view) - 对应节头表 Section header table
- 文件件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候⼀般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。
- 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否则,都是很小的很小的⼀段,未来物理内存页浪费太大(物理内存页分配⼀般都是整数倍⼀块给你,比如4k),所以,链接器趁着链接就把小块们都合并了。
• 执行视图(execution view) - 对应程序头表 Program header table
- 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。⼀个可执行程序的格式中,⼀定有 program header table 。
链接视图
# 查看节(section)信息
$ readelf -S a.out
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 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
[ 4] .gnu.hash GNU_HASH 0000000000400298 00000298
000000000000001c 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 00000000004002b8 000002b8
0000000000000048 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000400300 00000300
0000000000000038 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 0000000000400338 00000338
0000000000000006 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000400340 00000340
0000000000000020 0000000000000000 A 6 1 8
[ 9] .rela.dyn RELA 0000000000400360 00000360
0000000000000018 0000000000000018 A 5 0 8
[10] .rela.plt RELA 0000000000400378 00000378
0000000000000018 0000000000000018 AI 5 24 8
[11] .init PROGBITS 0000000000400390 00000390
...
- .text节 :是保存了程序代码指令的代码节。
- .data节 :保存了初始化的全局变量和局部静态变量等数据。
- .rodata节 :保存了只读的数据,如⼀行C语言代码中的字符串。由于.rodata节是只读的,所以只能存在于⼀个可执行文件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
- .BSS节 :为未初始化的全局变量和局部静态变量预留位置
- .symtab节 : Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。
- .got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问入口,由动态链接器在运行时进行修改。对于GOT的理解,我们后面聊。
执行视图
# 查看节合并后的段(segment)信息
$ readelf -l a.out
Elf file type is EXEC (Executable file)
Entry point 0x4003e0
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
0x0000000000000744 0x0000000000000744 R E 0x200000
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x0000000000000218 0x0000000000000220 RW 0x200000
DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
0x00000000000001d0 0x00000000000001d0 RW 8
NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254
0x0000000000000044 0x0000000000000044 R 4
GNU_EH_FRAME 0x00000000000005a0 0x00000000004005a0 0x00000000004005a0
0x000000000000004c 0x000000000000004c R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x00000000000001f0 0x00000000000001f0 R 1
- 告诉操作系统哪些模块可以被加载进内存。
- 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。
三、理解加载与链接
静态链接
无论是自己的.o, 还是静态库中的.o,本质都是把.o文件进行连接的过程
hello.o
#include<stdio.h>
void run();
int main()
{
printf("hello world!\n");
run();
return 0;
}
code.o
#include<stdio.h>
void run()
{
printf("running...\n");
}
code.o中包含hello.o中用的run方法的实现,这两个.o文件需要合并
objdump -d 命令:将代码段(.text)进行反汇编查看
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:
Address | Machine Code | Disassembly | Comments
--------------------------------------------------------------
0x0000 | f3 0f 1e fa | endbr64 | 64位安全指令前缀
0x0004 | 55 | push %rbp | 保存基址指针
0x0005 | 48 89 e5 | mov %rsp, %rbp | 设置栈帧
0x0008 | 48 8d 3d 00 00 00 00 | lea 0x0(%rip), %rdi | 计算字符串地址(实际地址=0x000f)
0x000f | e8 00 00 00 00 | callq 0x14 | 调用函数(占位符,链接时重定位)
0x0014 | 90 | nop | 空操作
0x0015 | 5d | pop %rbp | 恢复基址指针
0x0016 | c3 | retq | 返回
$ objdump -d hello.o
hello.o: file format elf64-x86-6
Disassembly of section .text:
0000000000000000 <main>:
Address | Machine Code | Disassembly | Comments
--------------------------------------------------------------
0x0000 | f3 0f 1e fa | endbr64 | 64位安全指令前缀
0x0004 | 55 | push %rbp | 保存基址指针
0x0005 | 48 89 e5 | mov %rsp, %rbp | 设置栈帧
0x0008 | 48 8d 3d 00 00 00 00 | lea 0x0(%rip), %rdi | 计算字符串地址(实际地址=0x000f)
0x000f | e8 00 00 00 00 | callq 0x14 | 调用函数(占位符,链接时重定位)
0x0014 | b8 00 00 00 00 | mov $0x0, %eax | 设置返回值为0
0x0019 | e8 00 00 00 00 | callq 0x1e | 调用函数(占位符)
0x001e | b8 00 00 00 00 | mov $0x0, %eax | 设置返回值为0
0x0023 | 5d | pop %rbp | 恢复基址指针
0x0024 | c3 | retq | 返回
发现main函数里 调用函数调用的目标地址全是0
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,比如他们位于内存的哪个区块,代码长什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在链接的时候修正,为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。
最终形成的可执行程序的符号表,main函数和run函数等都被确定。两个.o进行合并之后,在最终的可执行程序中,就找到了run。下图为可执行程序的section信息
hello.o或者code.o call后面的00 00 00 00有没有被修改成为具体的最终函数地址呢
下面查看可执行程序文件的反汇编
objdump -d
两个.o的代码段合并到了一起,并进行了统一的编址
链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进行相关call地址,完成代码调用
静态链接就是把库中的.o进行合并,和上述过程⼀样
所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成⼀个独立
的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我
们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这
其实就是静态链接的过程。
一旦合并之后,形成一个大的ELF文件,目标函数就会进行统一编址因为一旦合并之后目标函数的位置就确定了。修改完之后call之后的地址全零改为最终指定函数的起始地址。
所有链接过程中会涉及到对.o中外部符号进行地址重定位。
ELF加载与进程地址空间
引入问题
一个ELF程序在没有加载到内存的时候,有没有地址?
有地址。
⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用"平坦模式"进行工作。所以也要求ELF对自己的代码和数据进行统⼀编。下面是 objdump -S 反汇编之后的代码。
可执行程序在编译好之后已经是虚拟地址了。
最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们
认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执
行程序进行统一编址了。
可执行程序ELF文件在没有加载到内存之前,在编译链接节点就已经完成了线性编址,线性编址完成后虚拟地址也有了。现在把这个区域加载到物理内存中,(我们这里研究只代码区)。加载之后操作系统就会形成进程创建进程PCB和mm_struct,mm_struct中有了代码区就要找到代码区的起始地址,mm_struct代码区的起始地址用线性编址的起始位置做初始化,如图就有了1060做起点,然后根据线性编址的偏移量找到终点,start就是1060end就是108f。磁盘中的代码和数据加载到物理内存也会占用物理内存的物理空间,所有每一行代码也有其对应的物理地址。有了虚拟地址和物理地址,页表就会形成映射。然乎CPU开始执行程序。
Entry point adress 是程序的入口地址。Entry point adress是elf可执行程序的入口地址。CPU中的EIP拿到可执行程序的入口地址,CPU开始调度当前进程。CR3寄存器指向当前进程的页表,MMU硬件单元,通过这些通过虚拟地址找到物理地址,CPU把物理内存的指令拿到。
动态链接与动态库加载
进程如何看到动态库
这里的进程A数据和进程A代码也是,通过磁盘中的ELF可执行程序文件加载进内存的,和静态库的加载一样。磁盘中的库加载到内存中,库也是ELF也有物理地址和虚拟地址,会通过页表建立物理地址与虚拟地址的映射,然后把动态库映射到当前的地址空间上。动态库一旦映射到地址空间上的代码区,代码在调用库方法时只需要从代码区跳转到共享区中的某个地址处然后把库方法调用完然后再返回就完成了库方法的调用。
进程间如何共享库的
与上述过程一样,库映射到多个进程的地址空间,然后进行调用
动态链接其实远比静态链接要常用得多。比如我们查看下 hello 这个可执行程序依赖的动态库,会发现它就用到了⼀个c动态链接库:
$ ldd hello
linux-vdso.so.1 => (0x00007fffeb1ab000)
libc.so.6 => /lib64/libc.so.6 (0x00007ff776af5000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff776ec3000)
# ldd命令⽤于打印程序或者库⽂件所依赖的共享库列表。
这里的 libc.so是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能。
那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成⼀个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对
是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们
的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘
空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独
立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模
块在内存中只需要保留⼀份副本,可以被不同的进程所共享。
动态链接实际上将链接的整个过程推迟到了程序加载的时候。 比如我们去运行⼀个程序,操作系统会首先将程序的数据代码连同它用到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配⼀段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
在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是直接加载的)。
在库加载到内存的时候我们还拿到了库中方法的偏移量。
库已经被我们映射到了当前进程的地址空间中
库的虚拟起始地址我们也已经知道了
库中每⼀个方法的偏移量地址我们也知道
所有:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的.。
在动态链接的情况下,代码区的代码在找库中共享区的函数方法时,通常在编译链接阶段是不知道每个函数在共享区的具体地址的,但会知道库中方法相对于某个基地址的偏移量。
在程序运行时,动态链接器会负责将动态链接库加载到内存中的合适位置,并根据偏移量来计算出每个函数的实际地址,然后将这些地址信息填充到程序的相关数据结构中,使得程序能够正确地调用共享区中的函数。这样,通过偏移量和动态链接器的工作,实现了代码对共享库函数的动态定位和调用。所以这时就需要全局偏移量表。
全局偏移量表GOT(global offset table)
动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留⼀片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的⼀个全局变量或函数的地址。
- 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
- 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找
到GOT表。 - 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
- 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修
改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
那在代码区call的时候有这个表的起始地址,然后加上具体库函数的偏移量然后就可以实现调用了。