上篇文章:Linux:深入解析ELF文件结构
目录
[4.4全局偏移量表GOT(global offset table)](#4.4全局偏移量表GOT(global offset table))
[4.4.1动态库的地址无关性:PIC 编译](#4.4.1动态库的地址无关性:PIC 编译)
1.静态链接
无论是自己的.o,还是静态库中的.o,本质都是把.o文件进行连接的过程。所以,研究静态链接,本质就是研究.o是如何链接的。
$ gcc -c *.c
$ gcc *.o -o main.exe
查看编译后的.o目标文件
解释命令:
objdump -d 命令:将代码段(.text)进行反汇编查看
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # f <run+0xf>
f: 48 89 c7 mov %rax,%rdi
12: e8 00 00 00 00 call 17 <run+0x17>
17: 90 nop
18: 5d pop %rbp
19: c3 ret
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # f <main+0xf>
f: 48 89 c7 mov %rax,%rdi
12: e8 00 00 00 00 call 17 <main+0x17>
17: b8 00 00 00 00 mov $0x0,%eax
1c: e8 00 00 00 00 call 21 <main+0x21>
21: b8 00 00 00 00 mov $0x0,%eax
26: 5d pop %rbp
27: c3 ret
hello.o中的main函数不认识printf和run函数,code.o不认识printf函数:
$ cat hello.c
#include <stdio.h>
void run();
int main()
{
printf("hello world!\n");
run();
return 0;
}
$ cat code.c
#include <stdio.h>
void run()
{
printf("running...\n");
}
但是,我们通过观察上述call指令,它们分别对应之前调用的printf和run函数,但是它们的跳转地址都是0,这是为什么?

原因是:在编译hello.c时,编译器完全不知道printf和run函数的存在,比如它们位于内存的哪个区块,代码长什么样都是不知道的。因此,编译器只能将这两个函数的跳转地址暂时设为0.
这个地址会在链接时被修正。为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接时,就会根据表里记录的地址将其修正。
注意:printf涉及到动态库。
读取hello.o符号表
$ readelf -s hello.o
Symbol table '.symtab' contains 7 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata
4: 0000000000000000 40 FUNC GLOBAL DEFAULT 1 main
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
6: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run
puts:就是printf的实现
UND就是:undefine,表示未定义(只.o文件找不到)
run:就是我们自己的方法在hello.o中未定义(通常在code.o中)
整个过程速览
查看code.o和hello.o代码段:

此时结论:多个.o相互不知道彼此
读取code.o和hello.o符号表:

读取mian.exe符号表:

两个.o进行合并后,在最终的可执行程序中,就找到了run
0000000000001149就是地址。
FUNC:表示run符号类型是个函数
16:就是run函数所在的section被合并最终的那一个section中,16就是下标。
读取可执行程序最终的所有的section清单

hello.o和code.o的.text被合并了,是main.exe的第16个section
证明:关于hello.o或code.o call后面的00 00 00 00有没有被修改为具体的最终函数地址?
使用命令:
$ objdump -d main.exe # 反汇编main.exe只查看代码段信息,包含源代码


最终,两个.o的代码段合并到了一起,并进行了统一的编址。链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进行相关call地址,完成代码调用。
静态链接就是把库中的.o进行合并,和上述过程一样。
所以,链接就是将编译后的所有目标文件连同用到的一些静态库运行时库组合,拼装为一个独立的可执行文件。其中就包括之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。
这就是静态链接的过程!

所以,链接过程中会涉及到对.o中外部符号进行重定位。
2.ELF加载与进程地址空间
2.1虚拟地址/逻辑地址
问题
- 一个ELF程序,在没有被加载到内存的时候,有没有地址?
- 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来?
答:
一个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用了"平坦模式"进行工作。所以,也要求ELF对自己的代码和数据进行统一编址。观察下方objdump -S反汇编之后的代码:

最左侧的就是ELF的虚拟地址,其实,严格意义上应该是逻辑地址(起始地址+偏移量) ,但是我们认为起始地址是0,也就是说,虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了。
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从ELF各个segment来,每个segment都有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外在用详细地址填充页表。
所以:虚拟地址机制,不光光要操作系统支持,编译器也要支持。
2.2深入理解进程虚拟地址空间
ELF在被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry字段中:


根据下图理解:

在我们cpu中,有一个寄存器叫EIP(也叫做PC指针),它保存着当前代码的下一条指令的地址。
还有一个寄存器叫IR(指令寄存器),保存当前指令的地址。
不论哪行指令执行,都需要将其加载到进程中,所以,CPU不论执行哪一行代码,其本质就是执行当前进程。
CR3是保存当前进程的页表首地址。
在X86芯片中这三种寄存器被统称为:当前进程的硬件上下文。
所以进程虚拟地址空间,不仅仅要OS支持,也需要CPU本身要在硬件上支持,比如CR3+MMU+页表,也需要编译器在编译上支持,比如统一编址。

细节
- 页表初始化:其初始数据从ELF(逻辑地址)虚拟地址,加载从而具有物理地址
- 指令集:指令本身具有长度,为当前地址 + 当前指令长度 = 下一条指令的地址
- 虚拟地址空间(内核数据结构):mm_struct,其初始数据从ELF来
3.动态库加载
动态库(.so文件)本质上是一个符合ELF格式的二进制文件,进程要使用它,前提是让动态库被加载到内存并映射到进程的虚拟地址空间中。
3.1进程如何看到动态库
进程本身并不能直接识别磁盘上的动态库文件。当程序运行时,操作系统会根据程序的依赖信息找到对应的 .so 文件并打开,随后通过 mmap 等映射机制,将动态库的代码段、数据段等映射到进程虚拟地址空间的共享区。 此时,进程在虚拟地址层面就能"看到"并访问动态库的内容了。

3.2进程间如何共享库
Linux系统中,如果进程 A 和进程 B 都依赖了 libc.so,它们会在物理内存中加载两份副本来浪费空间吗?绝对不会!
操作系统通过虚拟内存的页表映射机制实现了完美的共享:
-
进程 A 和进程 B 的虚拟地址空间中,都有各自的"共享区"。
-
它们各自的页表会将共享区中对应
libc.so的虚拟地址,映射到同一块物理内存上。 -
这样,无论多少个进程依赖同一个动态库,物理内存中只需保留一份只读代码段的副本。这极大节省了物理内存资源,是动态链接的核心优势。

4.动态链接
动态链接的使用率比静态链接常用的多。
动态链接的核心思想是:**将符号解析和地址重定位从编译链接阶段,推迟到程序运行阶段。**编译器编译生成可执行程序时,并不会将动态库的函数地址、变量地址直接写入程序,而是记录下依赖的动态库和符号信息,当程序运行时,动态链接器会完成符号的解析和地址的重定位,让程序调用动态库中的函数。
4.1简介
查看一个可执行程序依赖的动态库:
$ ldd main.exe
linux-vdso.so.1 (0x00007ffd0f1d8000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000758501400000)
/lib64/ld-linux-x86-64.so.2 (0x0000758501800000)
ldd命令用于打印程序或者库文件所依赖的共享库列表。
这里的libc.so是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等功能。
为什么不使用静态链接?
因为静态链接最大的问题是生成的文件体积大,并且相当耗费内存资源。不同软件可能包含相同的功能和代码,如果是静态链接,显然会浪费大量的硬盘空间。
而如果是动态链接,我们可以将需要共享的代码单独提取出来,保存一个独立的动态链接库,在程序运行时将它们加载到内存,这样不但可以节省空间,而且同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
4.2程序的真正入口与加载准备
$ ldd /usr/bin/ls
linux-vdso.so.1 (0x00007fff7e7e4000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007edf1a034000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007edf19e00000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007edf19d66000)
/lib64/ld-linux-x86-64.so.2 (0x00007edf1a08e000)
在C/C++程序中,当程序开始执行时,它首先不会直接跳转到main函数。实际上,程序运行时的真正起点是链接器提供的_start函数。在进入main之前,底层暗流涌动:
- 设置堆栈:为程序创建一个初始的堆栈环境
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
- 动态链接(关键!!!):_start函数会调用动态链接器(如ld-linux.so)的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确的映射到动态库中的实际地址。
- 动态链接器:
- 动态链接器负责在程序运行时加载动态库。当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
- 环境变量和配置文件:
- 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函数来终止程序。
4.3动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法统一编址,采用相对编址的方案进行编址(可执行程序也一样,都要遵循平坦模式,只不过exe是直接加载)
# ubuntu下查看任意⼀个库的反汇编
$ objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less
# Cetnos下查看任意⼀个库的反汇编
$ objdump -S /lib64/libc-2.17.so | less
4.3.1程序与库的具体映射
动态库本身也是一个ELF文件(内容+属性),要访问也是要被先加载,要加载也是要被打开的。
让我们的进程找到动态库的本质:文件操作,不过我们访问库函数,是通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中。

4.3.2程序进行库函数调用
前提:库已经被我们映射到了当前进程的地址空间中,库的虚拟起始地址我们也已经知道了,库中每个方法的偏移量地址我们也知道了,
所以,访问库中任意位置的方法:通过库的起始虚拟地址+方法偏移量,即可定位库中的方法。而且整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行。

4.4全局偏移量表GOT(global offset table)
根据前述,我们可知,在我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道,然后再对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(此操作为加载地址重定位),那么是如何进行修改的呢?毕竟代码区在进程中是只读的呀,会怎么修改?
动态链接采用的做法是在.data(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数地址。
因为.data区域是可读可写的,所以可以支持动态进行修改。
输入命令:
readelf -S a.out

输入命令:
readelf -l a.out


4.4.1动态库的地址无关性:PIC 编译
因为动态库在运行时才加载,它的虚拟地址是不固定 的。为了让动态库能在虚拟地址空间的任意位置运行,它必须采用位置无关代码(Position Independent Code,PIC)编译(即 gcc -fPIC 参数)。
PIC 的核心是相对编址 :动态库中的所有方法和数据,都使用相对于当前指令的偏移量来编址,而不是写死的绝对地址。
- 由于代码段只读,我们不能直接修改代码段,但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
- 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找 到GOT表。
- 在调用函数的时候会首先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会 被修改为真正的地址。
这种方式实现的动态链接就被叫做 PIC 地址无关代码 。 换句话说,我们的动态库不需要做任何修 改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给 编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
命令:
objdump -S a.out


4.4.2库间依赖
不仅仅由可执行程序调用库,库也会调用其他库。库之间是由依赖的,而库中也有.GOT,和可执行一样,这也是为什么大家都是ELF格式的原因。

4.5过程链接表(PLT):延迟绑定优化
虽然有了 GOT,但如果程序一启动,动态链接器就要去解析成百上千个外部函数并重定位,启动速度会慢得令人发指(很多函数可能到程序结束都没被调过)。 于是,Linux 引入了延迟绑定(Lazy Binding) ,其核心实现就是 PLT。
-
核心思路 :把真实地址的解析推迟到函数第一次被调用时。
-
第一次调用 :GOT 表中的地址默认指向一段辅助代码(桩代码/stub)。调用函数时触发这段代码,它负责去动态链接器中查询真正的函数地址,并将真实地址更新到 GOT 表中。
-
后续调用:再次调用该函数时,查 GOT 表直接拿到真实地址并跳转,没有任何额外开销!
思路是:GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。

5.总结
-
静态链接 :将编译产生的所有目标文件(
.o)和用到的各种静态库,直接在编译阶段合并成一个独立的可执行文件(静态重定位)。- 痛点:生成文件极其臃肿,极其浪费磁盘和物理内存空间。
-
动态链接:将链接过程推迟到程序加载时。可执行程序只记录依赖信息,运行时将所需的动态库加载并映射到进程地址空间,通过 GOT/PLT 方式进行调用(运行重定位/动态重定位)。
- 优势:极大地节省了内存和磁盘资源,方便代码的更新维护,实现了二进制级别的代码复用。