上节课我们学习了动静态库的制作与使用,这节内容我们来深入探讨一下:当程序运行时,这些库到底是如何被加载到内存中,又是如何被程序调用的


一.目标文件:

定义:

目标文件(Object File)是源代码编译后但尚未链接的中间文件,也就是变成可执行文件的中间文件(次品文件)。

简单理解就是:

 .c 源文件 -->编译器处理 --> 生成 .o 目标文件

多个 .o 文件 + 库-->链接器处理 --> 生成可执行文件

图示如下:

接下来我们深⼊探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使⽤原理。
先来回顾下什么是编译呢?

编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运⾏的机器代码。

⽐如:在⼀个源⽂件 hello.c ⾥便简单输出"hello world!",并且调⽤⼀个run函数,⽽这个函数被
定义在另⼀个原⽂件 code.c 中。这⾥我们就可以调⽤ gcc -c 来分别编译这两个原⽂件。

代码如下:

// hello.c
#include<stdio.h>
void run();
int main() {
	printf("hello world!\n");
	run();
	return 0;
}
// code.c
#include<stdio.h>
void run() {
	printf("running...\n");
}

同时进行编译:

编译完成后,生成了 hello.o 和 code.o 这两个 .o 文件,它们就是目标文件(也叫中间文件)。这时候有个好处:如果我只改了 hello.c,那就只需要重新编译 hello.c 生成新的 hello.o 就行,code.c 不用动。至于目标文件本身,它虽然已经是二进制格式,但还不能直接运行。它的格式叫 ELF,说白了就是给二进制代码加了一层"包装",让系统能认得出它里面装的哪些是代码、哪些是数据、哪些是符号表等等。

file命令⽤于辨识⽂件类型。


二. ELF⽂件:

要理解编译链链接的细节,我们不得不了解⼀下ELF⽂件。其实有以下四种⽂件其实都是ELF⽂件:

  1. 可重定位⽂件(Relocatable File) :即 xxx.o ⽂件。包含适合于与其他⽬标⽂件链接来创建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
  2.  可执⾏⽂件(Executable File) :即可执⾏程序。
  3. 共享⽬标⽂件(Shared Object File) :即 xxx.so⽂件
  4. 内核转储(core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。

⼀个ELF⽂件由以下四部分组成:

  •  ELF头(ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。
  • 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥记着每个开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。
  • 节头表(Section header table) :包含对节(sections)的描述。
  • 节(Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和

数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
最常⻅的节:

  • 代码节(.text):⽤于保存机器指令,是程序的主要执⾏部分。
  •  数据节(.data):保存已初始化的全局变量局部静态变量

三. ELF从形成到加载轮廓

3.1 ELF形成可执⾏

第一步:编译 --> 每个 .c 文件单独翻译成一个 .o 目标文件

第二步:链接 --> 把多个 .o 文件打包合并成一个可执行文件

链接器拿到 hello.o 和 code.o 之后,会做两件事:

合并节(Section):把两个 .o 文件中的 .text 合并成一个大的 .text(所有代码放一起),把 .data 合并成一个大的 .data(所有数据放一起),其他节也类似。


符号解析: hello.o 里调用了 run() 但不知道它在哪;链接器一看 code.o 里有个 run 函数,就把它们关联上,相当于"填坑"。

注意:实际合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并。

编译阶段各玩各的,每个 .c 独立生成自己的 .o;链接阶段大家合并同类项(所有 .text 合并、所有 .data 合并...),顺便把互相调用的函数地址填上,最终搞出一个能跑的可执行文件。


3.2 ELF可执⾏⽂件加载

  •  ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
  •  合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.
  •  这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
  • 很显然,这个合并⼯作也已经在形成 ELF 的时候,合并⽅式已经确定了,具体合并原则被记录在了 ELF 的 程序头表(Program header table) 中

1. 查看可执⾏程序的section

readelf -S code.o

2.查看section合并的segment

 readelf -l main.exe


-S 和 -l 的区别总结

命令 查看对象 适用文件 谁用这个信息
readelf -S Section(节) .o 和 可执行文件 都可以 链接器(ld)用
readelf -l Segment(段) 只有可执行文件(或 .so) 加载器(Loader)用

简单总结

  • -S(Section):看的是文件里有哪些零件——给链接器看的

  • -l(Program Header):看的是文件加载到内存时怎么放——给操作系统加载器看的

刚生成的 .o 文件只能用 -S 查看节信息,因为它还没链接,加载器用不上。等我们用 gcc hello.o code.o -o main.exe 链接成可执行文件后,再用 readelf -l main.exe 就能看到程序加载到内存时的段信息了。

一句话:-S 是有什么零件;-l 是怎么装上去

问题来了:为什么要将section合并成为segment

  • Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率。如果不进⾏合并,假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分为4097(4096+1)字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个⻚⾯。
  • 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。

对于 程序头表 和 节头表 ⼜有什么⽤呢,其实 ELF ⽂件提供 2 个不同的视图/视⻆来让我们理解这
两个部分:

  •  链接视图(Linking view) - 对应节头表 Section header table

1)⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的是链接视图,能够理解 ELF ⽂件中包含的各个部分的信息。
2)为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就⾼了,否则,很⼩的很⼩的⼀段,未来物理内存⻚浪费太⼤(物理内存⻚分配⼀般都是整数倍⼀块给你,⽐如4k),所以,链接器趁着链接就把⼩块们都合并了。

  • 执⾏视图(execution view) - 对应程序头表 Program header table

告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中,
⼀定有 program header table 。

说⽩了就是:⼀个在链接时作⽤,⼀个在运⾏加载时作⽤。

从 链接视图 来看:

  •  命令 readelf -S hello.o 可以帮助查看ELF⽂件的 节头表。
  •  .text节 :是保存了程序代码指令的代码节。
  •  .data节 :保存了初始化的全局变量和局部静态变量等数据。
  •  .rodata节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所
  • 以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
  • .BSS节 :为未初始化的全局变量和局部静态变量预留位置
  • .symtab节 : Symbol Table 符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
  • .got.plt节 全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。对于GOT的理解,我们后⾯会说。
  • 使⽤ readelf 命令查看 .so ⽂件可以看到该节。

从 执⾏视图 来看:

  • 告诉操作系统哪些模块可以被加载进内存。
  • 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的。

我们可以在 ELF头 中找到⽂件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。例如我们查看下hello.o这个可重定位⽂件的主要信息:

查看⽬标⽂件

// 查看⽬标⽂件
 readelf - h hello.o
ELF Header :
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 # ELF⽂件标识符(魔数)
Class : ELF64  # ⽂件类:64位架构
Data : 2's complement, little endian  # 数据编码:⼩端序⼆进制补码
Version : 1 (current) # ELF版本:当前版本(1)
OS / ABI: UNIX - System V  # 操作系统ABI: System V UNIX
ABI Version : 0  # ABI扩展版本:未扩展(0)
Type: REL(Relocatable file)  # ⽂件类型:可重定位⽂件(⽬标⽂件)
Machine : Advanced Micro Devices X86 - 64  # ⽬标平台:x86 - 64架构
Version : 0x1  # 对象⽂件版本:1
Entry point address :0x0  # ⼊⼝点地址:⽆(⽬标⽂件为0)
Start of program headers : 0 (bytes into file)  # 程序头表起始偏移:⽆(⽬标⽂件为0)
Start of section headers : 728 (bytes into file)  # 节头表起始偏移:728字节处
Flags :0x0  # 处理器特定标志:⽆标志(0)
Size of this header: 64 (bytes)  # ELF头⼤⼩:64字节
Size of program headers :0 (bytes)  # 程序头表条⽬⼤⼩:⽆(⽬标⽂件为0)
Number of program headers : 0  # 程序头表条⽬数:⽆(⽬标⽂件为0)
Size of section headers :64 (bytes)  # 节头表条⽬⼤⼩:64字节
Number of section headers : 14  # 节头表条⽬数:14个节
Section header string table index : 13 # 节名称字符串表索引:第13节(.shstrtab)

查看可执⾏程序

	$ gcc* .o
	$ readelf - h a.out
	Magic : 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class :ELF64# 64位架构
	Data :2's complement, little endian# ⼩端序⼆进制补码
Version :1 (current) # ELF版本:1(当前标准)
OS / ABI:UNIX - System V # 操作系统ABI:
System V UNIXABI Version :0# ⽆扩展ABI
Type :DYN(Shared object file) # ⽂件类型:动态共享库(.so)
Machine : Advanced Micro Devices X86 - 64# ⽬标平台:x86 - 64
Version:0x1 # 对象⽂件版本:1
Entry point :0x1060 # 程序⼊⼝点虚拟地址(动态链接后解析)
Start of program headers : 64 (bytes into file) # 程序头表起始偏移:64字节
Start of section headers : 14032 (bytes into file)# 节头表起始偏移:14768字节
Flags :0x0 # ⽆处理器特定标志
Size of this header: 64 (bytes) # ELF头⼤⼩:64字节
Size of program headers : 56 (bytes) # 程序头表每个条⽬⼤⼩:56字节
Number of program headers : 13 # 程序头表条⽬数:13(如LOAD、DYNAMIC等段)
Size of section headers : 64 (bytes)# 节头表每个条⽬⼤⼩:64字节
Number of section headeSection header string table index : 30# 节名称字符串表索引:第30节(.shstrtab)

对于 ELF HEADER 这部分来说,我们只⽤知道其作⽤即可,它的主要⽬的是定位⽂件的其他部分。

理解ELF区域和文件偏移量用表格说明

ELF 文件结构 标准表格

偏移地址 模块名称 核心内容 备注说明
0x0000 ELF Header ELF 文件头 固定 64 字节,记录文件整体属性、入口地址、段表 / 节表位置等核心元信息
0x0040 Program Header Table 程序头表(段的信息) 从该地址开始,记录程序运行时所需的段的加载规则、内存布局等信息
0x0100 .text 节 代码段 存放程序的可执行机器指令代码,是程序运行的核心执行逻辑
0x2000 .data 节 数据段 存放程序中已初始化的全局变量、静态变量等可读写数据
0x3000 .rodata 节 只读数据段 存放程序中的只读常量数据,运行过程中不可修改
0x3500 Section Header Table 节头表(节的信息) 存放 ELF 文件中所有节的元数据信息,用于节的索引、定位与管理

具体每个区域的偏移量

区域 文件偏移量(举例) 怎么知道的
ELF Header 0x0000(固定) 永远在文件开头
Program Header Table 0x0040 ELF Header 里的 e_phoff 字段记录
.text 节 0x0100 Section Header 里的 sh_offset 记录
.data 节 0x2000 Section Header 里的 sh_offset 记录
Section Header Table 0x4000 ELF Header 里的 e_shoff 字段记录

每个数字(0x0000、0x0040、0x2000...)就是文件偏移量,表示从文件开头数,第几个字节。

简单理解:

文件偏移量就是门牌号,ELF Header 记录了每个区域的门牌号,系统根据门牌号去文件里找对应的数据。

ELF Header 的作用就是告诉你:你要找的 Program Header 在文件偏移量 0x0040 处,Section Header 在文件偏移量 0x4000 处。


四.理解连接与加载

4.1静态链接

 ⽆论是⾃⼰的 .o , 还是静态库中的 .o ,本质都是把.o⽂件进⾏连接的过程
 所以:研究静态链接,本质就是研究 .o 是如何链接的

步骤如下:

查看编译后的.o⽬标⽂件

objdump -d 命令:将代码段(.text)进⾏反汇编查看
 hello.o 中的 main 函数不认识 printf和run 函数

$ cat hello.c
#include<stdio.h>
void run();
int main()
{
printf("hello world!\n");
run();
return 0;
}

code.o 不认识 printf 函数

$ cat code.c
#include<stdio.h>
void run()
{
printf("running...\n");
}

我们可以看到这⾥的call指令,它们分别对应之前调⽤的printf和run函数,但是你会发现他们的跳转地址都被设成了0。那这是为什么呢?
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,⽐如他们位于内存的哪个区块,代码⻓什么样都是不知道的。因此,编译器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正。(重点后面我会讲)


整个过程:

查看code.o的代码段

查看hello.o的代码段

⾄此就是之前的结论:多个.o彼此不知道对⽅


 读取code.o符号表

puts:就是printf的实现
UND就是:undefine,表⽰未定义说⽩了就是本.o⽂件找不到

读取hello.o符合表

puts:就是printf的实现, run就是我们⾃⼰的⽅法在hello.o中未定义(因为在code.o中)
UND就是:undefine,表⽰未定义说⽩了就是本.o⽂件找不到

查找的是编译+链接完成的文的文件符号表

两个.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只查看代码段信息,包含源代码

最终:
1. 两个.o的代码段合并到了⼀起,并进⾏了统⼀的编址
2. 链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进⾏相关call地址,完成代码调⽤

【补充】注意:

问题现象

使用 ls -l 命令能看到 main.exe 文件确实存在,但执行 objdump -d main.exe 时却报错说找不到这个文件。

原因分析

文件名包含不可见字符
文件名里可能包含空格、换行符或其他特殊字符,用肉眼看不出来,但 shell 在解析命令时会把这些字符当成文件名的一部分,导致匹配失败。

手动输入错误
直接敲文件名时,可能出现拼写错误、多打了空格、或者打错了大小写,导致输入的文件名和实际文件名不一致。

路径解析问题
当前目录不在 PATH 环境变量中,或者相对路径解析时出现了偏差。

解决方案

加引号
执行 objdump -d "./main.exe",引号告诉 shell 把引号里的内容原样传递,不做任何额外解析,这样能绕过特殊字符和空格的问题。

Tab 键自动补全
输入 objdump -d mai 然后按 Tab 键,shell 会自动补全正确的文件名,保证输入和实际文件完全一致,避免手敲出错。

换一个确定存在的文件
使用 objdump -d main 或 objdump -d a.out,这两个文件同样存在,可以绕过 main.exe 的问题。

小结一下:

静态链接就是把库中的.o进⾏合并,和上述过程⼀样所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。

readelf / objdump 常用指令总结

readelf 指令(查看 ELF 文件结构)

指令 作用 适用文件
readelf -h <文件名> 查看 ELF 头(文件类型、架构、入口地址等) .o / 可执行文件 / .so
readelf -S <文件名> 查看节头表(有哪些节,每个节的位置和大小) .o / 可执行文件 / .so
readelf -s <文件名> 查看符号表(函数名、变量名及其地址) .o / 可执行文件 / .so
readelf -l <文件名> 查看程序头(段的信息,加载到内存的映射关系) 仅可执行文件 / .so
readelf -r <文件名> 查看重定位信息(哪些地址待修正) 主要是 .o 文件

objdump 指令(查看二进制文件内容)

指令 作用 适用文件
objdump -d <文件名> 反汇编代码段(把机器码还原成汇编指令) .o / 可执行文件
objdump -h <文件名> 查看节头信息(类似 readelf -S) .o / 可执行文件
objdump -t <文件名> 查看符号表(类似 readelf -s) .o / 可执行文件
objdump -r <文件名> 查看重定位信息(类似 readelf -r) 主要是 .o 文件

其他常用指令

指令 作用 适用文件
file <文件名> 查看文件类型(ELF 可执行文件 / 目标文件 / 共享库等) 任何文件
ldd <文件名> 查看可执行文件依赖哪些动态库 仅可执行文件 / .so
nm <文件名> 查看符号表(简化版) .o / 可执行文件

所以,链接过程中会涉及到对.o中外部符号进⾏地址重定位

每个 .o 文件都是各玩各的,不知道别人在哪里。链接的时候,链接器把所有 .o 文件拼在一起,谁调用了谁、谁用了谁的变量,把所有地址都填准确,让它们能正常配合工作。


4.2ELF加载与进程地址空间

4.2.1 虚拟地址/逻辑地址

问题:
1)⼀个ELF程序,在没有被加载到内存的时候,有没有地址呢?
2)进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?

答案:
1)⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤
"平坦模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是objdump -S 反汇编之后的代码

最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执⾏程序进⾏统⼀编址了.

2)进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个
segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end]等范围数据,另外在⽤详细地址,填充⻚表.
所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持

就是脑子里得明白:

虚拟地址一开始就在 ELF 文件里写好了,编译器分配的。

加载时,操作系统照着 ELF 文件里的地址表,在进程地址空间里划出对应的区域,把代码和数据复制进去。

所以虚拟地址机制需要"编译器 + 操作系统"配合才能工作——编译器负责给程序"编地址",操作系统负责把这个地址映射到真实的物理内存。

4.2.2 重新理解进程虚拟地址空间

ELF 在被编译好之后,会把⾃⼰未来程序的⼊⼝地址记录在ELF header的Entry字段中:

图示:

一、进程的虚拟地址空间管理结构体

Linux 内核通过结构体管理进程的虚拟地址空间:

  • task_struct:进程控制块,描述进程的核心结构体,每个进程独有一份,内核通过它管理进程的全部资源。

  • mm_struct:内嵌在 task_struct 中,专门管理进程的虚拟地址空间,记录各段(.text.data.stack 等)的虚拟地址范围、权限和映射关系。

  • .text 段:进程虚拟地址空间中的代码段,存放机器指令,分配了虚拟地址 0x1060。这个地址只是逻辑编号,并不是真实的物理内存地址。

二、从磁盘程序到物理内存:加载映射过程

磁盘上的 ELF 可执行程序存储了机器指令。程序运行时,操作系统加载器执行两步操作:

  1. 从磁盘文件中读取 .text 段的二进制指令;

  2. 将指令拷贝到物理内存,同时建立虚拟地址到物理地址的页表映射。

ELF 文件头中的 Entry point address: 0x1060 表示程序第一条指令的虚拟地址,而不是磁盘偏移或物理地址。

三、虚拟地址 --> 物理地址:MMU 地址转换过程

CPU 执行程序时,地址转换流程如下:

  1. CPU 执行指令:寄存器 EIP 存放下一条指令的虚拟地址 0x1060,CPU 只读写虚拟地址,感知不到物理内存。

  2. CR3 寄存器:存放当前进程页表在物理内存中的基地址,是 MMU 查找页表的入口。

  3. MMU(内存管理单元):接收 CPU 传来的虚拟地址,通过查询页表,将虚拟地址翻译成真实的物理地址,最终去物理内存读取指令。

  4. 页表输出的地址就是硬件能识别的真实物理内存地址,CPU 全程只使用虚拟地址。

四、虚拟地址空间的核心设计目的

  1. 进程隔离:每个进程拥有独立的虚拟地址空间,不同进程访问同一个虚拟地址,实际对应完全不同的物理内存,进程之间互不干扰。

  2. 地址解耦:编译时只分配虚拟地址,不用关心物理内存的实际位置,操作系统通过页表动态映射。

  3. 内存管理优化:支持缺页中断(按需加载)、内存换出(空间不足时换出到磁盘)、代码段共享(多个进程共用一份 .text)。

 4.3动态链接与动态库加载

4.3.1进程如何看到动态库

解读这个图:

1.动态库的加载与使用

动态库(比如 XXX.so)在磁盘上就是一个文件,里面放着库的代码和数据。进程刚开始运行的时候,动态库还没加载,所以进程是看不到它的。当进程运行到需要调用动态库中的函数时(比如调用 printf),系统就会把这个库加载到物理内存中。

2.进程通过什么方式看到动态库

进程能看到动态库,靠的是页表映射。具体来说,动态库被加载到物理内存之后,操作系统会在进程的页表里添加一条映射记录,把动态库所在的物理内存地址,映射到进程虚拟地址空间的共享区(mmap 区)。这样一来,进程访问共享区里的某个虚拟地址时,就能通过页表找到物理内存中动态库的位置。

3.内核结构体的作用

  • task_struct 是进程的“身份证”,里面包含了进程的所有信息。通过它可以找到 mm_struct

  • mm_struct 记录着进程的虚拟地址空间划分,包括共享区的起始地址和结束地址。进程通过 mm_struct 知道共享区在哪里。

  • 页表记录了虚拟地址和物理地址的映射关系。mm_struct 和页表共同配合,让进程能够访问到动态库。

4.多个进程共享同一个动态库

如果多个进程同时使用了同一个动态库,物理内存中只需要存一份库的代码段就行。每个进程通过自己的页表,把这个库映射到自己虚拟地址空间的共享区。这样既省内存,又让每个进程都能正常调用库里的函数。

过程:

步骤一:程序里调用了库函数

假如你的代码里写了 printf("hello"),编译后,printf 被标记成“外部符号”,程序不知道它的地址在哪。

步骤二:系统把 .so 文件加载到物理内存

当程序第一次执行到 printf 的时候,操作系统发现这个函数还没加载,就把磁盘上的 libc.so 加载到物理内存中。

步骤三:建立页表映射

操作系统在进程的页表里加上一条记录,把物理内存中 libc.so 的地址,映射到进程虚拟地址空间的共享区(mmap 区)。

步骤四:CPU 通过虚拟地址访问

程序访问共享区里某个虚拟地址(比如 0x7f1234560000),CPU 通过 MMU 查页表,找到物理内存中 libc.so 的位置,成功调用 printf

4.3.2进程间如何共享库的

过程:

步骤一:磁盘上的动态库

磁盘上有一个 XXX.so 文件,里面存放着库的代码区和数据区。此时它只是文件,还没有被任何进程使用。

步骤二:进程A先运行,加载动态库

  • 进程A运行过程中调用了动态库中的函数(比如 printf),操作系统发现 XXX.so 还没加载,就从磁盘把 XXX.so 加载到物理内存中。物理内存里现在有了库的代码和数据。
  • 操作系统在进程A的页表里添加一条映射记录,把物理内存中 XXX.so 的地址,映射到进程A虚拟地址空间的共享区。
  • 进程A通过 task_struct 找到 mm_struct,知道共享区的虚拟地址范围。当它访问共享区中库对应的虚拟地址时,CPU 通过 MMU 查询页表,找到物理内存中的 XXX.so,成功调用库函数。

步骤三:进程B也开始运行,使用同一个动态库

  • 进程B运行到需要调用同一个动态库中的函数时,操作系统发现物理内存中已经有一份 XXX.so 了,所以不需要再从磁盘加载一次
  • 操作系统直接在进程B的页表里添加映射记录,把物理内存中 XXX.so 的地址,映射到进程B虚拟地址空间的共享区。
  • 进程B同样通过 task_struct → mm_struct 知道共享区的位置,通过页表访问到物理内存中的 XXX.so

简单说动态库在物理内存里只存一份,多个进程通过各自的页表映射,在自己的共享区“看到”同一份库。共享的是物理内存,独立的是虚拟地址。


4.4动态链接

4.4.1.概要

动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下 hello 这个可执⾏程序依赖的动态库,会发现它就⽤到了⼀个c动态链接库:

这⾥的 libc.so 是C语⾔的运⾏时库,⾥⾯提供了常⽤的标准输⼊输出⽂件字符串处理等等这些功
能。

那为什么编译器默认不使⽤静态链接呢?静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对是吧?

答案
静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。


这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。

动态链接到底是如何⼯作的??

⾸先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。

4.4.2我们的可执⾏程序被编译器动了⼿脚

在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 main 函数。实际上,程序的⼊⼝点是 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数
在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:

1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位
置,并清零未初始化的数据段。
3. 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。

什么是动态连接器呢?

动态链接器:

  • 动态链接器(如ld-linux.so)负责在程序运⾏时加载动态库。
  • 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。

环境变量和配置⽂件:

  •  Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如/etc/ld.so.conf及其⼦配置⽂件)来指定动态库的搜索路径。
  • 这些路径会被动态链接器在加载动态库时搜索。

缓存⽂件:

  • 为了提⾼动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存⽂件。
  • 该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会⾸先搜索这个缓存⽂件。

4. 调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤__libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。


5. 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执⾏控制权才正式交给⽤⼾编写的代码。


6. 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回值,并最终调⽤ _exit 函数来终⽌程序。

上述过程描述了C/C++程序在 main 函数之前执⾏的⼀系列操作,但这些操作对于⼤多数程序员来说是透明的。程序员通常只需要关注 main 函数中的代码,⽽不需要关⼼底层的初始化过程。然⽽,了解这些底层细节有助于更好地理解程序的执⾏流程和调试问题。(重要)

4.2.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.2.4 我们的程序,怎么和库具体映射起来的

注意:

  • 动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的
  • 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,通过虚拟地址进⾏跳转访问的,所以需要把动态库映射到进程的地址空间中

图示:

1. 定位磁盘上的动态库文件

程序运行过程中调用了动态库里的函数,操作系统需要先找到这个库对应的磁盘文件,查找链路如下: 首先通过当前进程的task_struct(进程描述符),找到管理该进程虚拟地址空间的mm_struct;再从mm_struct遍历得到描述一段虚拟内存区域的vm_area_struct。 在vm_area_struct结构体里有成员vm_file,它会指向一个file文件结构体;而file结构体中的f_path.dentry目录项,就能定位到磁盘里存放的动态库文件,比如/lib64/shturl.。 依靠这条完整的结构体链表,系统就能确定程序要使用的动态库存放在磁盘哪个路径下。

2. 把库文件加载进物理内存缓冲区

系统依靠file结构体锁定磁盘上的库文件后,会调用文件系统提供的读写接口,将整个动态库文件从磁盘读取,存放到物理内存的内核缓冲区中。 文件对应的ext2_inode索引节点里保存了成员i_block,里面记录了该库文件在磁盘上占用的所有数据块编号,操作系统依靠这些块号,精准读取库的代码段、数据段内容到内存。

3. 建立物理内存与进程虚拟空间的映射关系

当动态库内容加载到物理内存之后,操作系统会搭建页表映射:将物理内存里存放的库代码、数据,映射到当前进程虚拟地址空间的共享库区域。 这段映射信息会保存在新生成的vm_area_struct中:

  1. vm_startvm_end:记录这段共享库在进程虚拟地址空间的起始、结束虚拟地址;
  2. vm_file:依旧指向该动态库对应的file结构体,标记这片虚拟内存对应的磁盘文件。

4. 程序通过虚拟地址调用库函数

程序可以直接使用共享库区域的虚拟地址(范围在vm_start ~ vm_end之间),访问动态库内部的函数与全局数据。 当 CPU 发起内存访问、读取这个虚拟地址时,MMU 内存管理单元会查询页表,完成虚拟地址到物理内存地址的转换,最终读取物理内存中存放的库代码,完成动态库函数的调用。

醒目的说法:

进程通过 task_struct -->mm_struct --> vm_area_struct 找到共享区的虚拟地址范围;通过 vm_file -->dentry -->inode 找到磁盘上的库文件;通过页表把库的物理地址和共享区的虚拟地址映射起来。三个链条缺一不可。


4.2.5我们的程序,怎么进⾏库函数调⽤

注意:

  •  库已经被我们映射到了当前进程的地址空间中
  • 库的虚拟起始地址我们也已经知道了
  • 库中每⼀个⽅法的偏移量地址我们也知道
  • 所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法
  • ⽽且:整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程完
  • 全在进程地址空间中进⾏的

看这个图:

这个图讲的是动态库的地址映射和函数调用过程,想说:

通过 task_struct -->mm_struct -->vm_area_struct --> file --> dentry -->inode 找到磁盘上的 libc.so;

把 libc.so 加载到物理内存;

通过页表映射到进程的共享区;

程序调用 puts 时,用库的起始虚拟地址 + 函数在库中的偏移量算出实际地址;

但是:

图里说:假设 libc.so 映射后起始虚拟地址是 0x44332211,puts 函数在库中的偏移是 0x112233,那么调用 puts 的地址就是 0x44332211 加上 0x112233。这个理解不对。实际程序根本不会自己去算这个地址。

因为:

动态库被加载到内存中的起始地址,是在程序运行时才确定的。程序编译的时候根本不知道库会被映射到哪个地址,所以不可能提前算好 puts 的具体位置。如果按图里说的方式直接算地址、直接跳转,程序根本做不到,因为编译时不知道基址是多少。

那怎么做呢?

程序不自己算地址,而是通过 PLT 和 GOT 这两个东西来帮忙。

大致理解下面的说法:

程序里调用 puts 的时候,并不是直接调用 puts,而是调用一个叫 puts@plt 的跳板函数。这个跳板函数会去查 GOT 表。GOT 表里存的是 puts 在内存中的真实地址。

第一次调用 puts 的时候:
GOT 表里还没有 puts 的地址,动态链接器就去 libc.so 里找到 puts 的真实地址,然后填到 GOT 表里。

第二次及以后调用 puts 的时候:
GOT 表里已经有 puts 的地址了,直接拿来用就行,不用再解析了。

同时:很重要的一带你点:图里说程序自己算出 0x44332211 + 0x112233 = 0x44335544,然后直接 call 这个地址。但问题是:这个地址是运行时才能算出来的!

程序代码段(.text)在编译时就已经写死了,里面的每条指令都是固定的二进制数据。程序运行后,代码段是只读的,不能修改。所以程序根本不可能在运行时去修改 call 指令里的地址。

4.2.6 全局偏移量表GOT(global offset table)

注意:

  1. 也就是说,我们的程序运⾏之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
  2.  然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中⼆次完成地址设置(这个叫做加载地址重定位)

 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?

所以:动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。

简单说:代码段只读,不能填地址。所以系统在数据段里开了一张 GOT 表专门存函数地址,程序运行时动态链接器把地址填进去,程序通过查表找到函数。代码段不改,只改数据段里的 GOT 表。

 因为.data区域是可读写的,所以可以⽀持动态进⾏修改

$ readelf -S a.out
...
 [24] .got PROGBITS 0000000000003fb8 00002fb8 
 0000000000000048 0000000000000008 WA  0 0 8
...
$ readelf -l a.out   # .got在加载的时候,会和.data合并成为⼀个segment,然后加载在⼀起
...
   05  .init_array .fini_array .dynamic .got .data .bss
...

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

补充:

PLT是什么?

PLT(过程链接表)是程序自己代码段里的一组"跳板函数",每个库函数对应一个,负责跳转到 GOT 表里记录的真正地址。

咱还是从"代码段不能改"说起:

程序调用 printf 的时候,不能直接写 call printf,因为 printf 的地址运行时才知道,而且代码段不能改。之前我们说了,真正的地址放在 GOT 表里,那程序怎么去 GOT 表里取地址呢?总不能让程序员手动写代码去查表吧?

所以编译器和链接器自动生成了一段"跳板代码",放在 PLT 表里。

PLT 表里有什么

PLT 表本质上就是一段段非常短的小代码,每个库函数对应一小段,放在程序的代码(.plt 节)里,长得差不多这样:

printf@plt:
    jmp *GOT[printf]    # 跳转到 GOT 表里存的 printf 地址

4.3.7 库间依赖(大家了解即可,我简单说明)

注意:

  •  不仅仅有可执⾏程序调⽤库
  •  库也会调⽤其他库!!库之间是有依赖的,如何做到库和库之间互相调⽤也是与地址⽆关的呢??
  •  库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家为什么都是ELF的格式!

由于GOT表中的映射地址会在运⾏时去修改,我们可以通过gdb调试去观察GOT表的地址变化。在这⾥我们只⽤知道原理即可:
 由于动态链接在程序加载的时候需要对⼤量函数进⾏重定位,这⼀步显然是⾮常耗时的。为了进⼀步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。

思路是:GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码/stup。在我们第⼀次调⽤函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调⽤函数的时候,就会直接跳转到动态库中真正的函数实现。

咱就用快递取件来方便大家理解

你从网上下单买了个组装书架(程序),包裹到了菜鸟驿站。包裹里有说明书和一堆配件,但安装时需要一把螺丝刀(动态库),而螺丝刀不在包裹里,是你自己家里的工具。

第一次装(第一次运行):

你拿着说明书需要拧螺丝,发现自己没有带螺丝刀(还没加载)

你得先回家拿螺丝刀(加载动态库到内存)

拿起螺丝刀把螺丝拧好(调用库函数)

书架的每个螺丝孔都试一遍,哪些需要用到螺丝刀(解析所有库依赖)

拧完所有需要拧的螺丝后,你可能发现有些配件根本不需要螺丝刀(延迟绑定——只解析实际用到的函数)

第二次装同款书架(第二次运行,或者另一个程序也用这个库):

你家里已经有螺丝刀了(内存里已经有动态库了)

直接拿起来用就行(多个进程共享同一份动态库)

图示:

总⽽⾔之,动态链接实际上将链接的整个过程,⽐如符号查询、地址的重定位从编译时推迟到了程序的运⾏时,它虽然牺牲了⼀定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利⽤磁盘空间和内存资源,以极⼤⽅便了代码的更新和维护,更关键的是它实现了⼆进制级别的代码复⽤。

解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程.


4.5 总结

  1.  静态链接的出现,提⾼了程序的模块化⽔平。对于⼀个⼤的项⽬,不同的⼈可以独⽴地测试和开发⾃⼰的模块。通过静态链接,⽣成最终的可执⾏⽂件。
  2.  我们知道静态链接会将编译产⽣的所有⽬标⽂件,和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
  3.  ⽽动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进⾏调⽤(运⾏重定位,也叫做动态地址重定位)

【问题补充】

问题一:程序在磁盘上有没有地址?

回答:有!程序在磁盘上的时候,就已经有地址了。

解释如下:

你把代码编译成可执行程序(比如 main),这个程序在磁盘上还是一个文件的时候,里面就已经给每个函数、每个变量编好地址了。这些地址,在程序还没加载到内存的时候,就已经写在文件里了。

为什么叫逻辑地址?

之前我说的“平坦模式编址”,意思是:程序在编译的时候,就假设整个地址空间从 0 开始,然后依次往后排

main 函数在 0x1160

run 函数在 0x1149

某个变量在 0x2000

这些地址是逻辑地址(也叫虚拟地址),不是物理内存的真实地址。程序不管物理内存怎么安排,只管自己从 0 开始编号就行。等程序加载到内存后,操作系统通过页表把这个逻辑地址映射到真实的物理地址。

辑地址、虚拟地址、物理地址,三者的关系

逻辑地址是程序自己编的地址,从0开始编号,每个程序都觉得自己独占整个地址空间。比如main函数在0x1160,run函数在0x1149,这些地址在程序还没加载到内存的时候,就已经写在磁盘上的可执行文件里了。

虚拟地址跟逻辑地址在Linux下基本就是一回事,它指的是CPU和MMU看到的地址。CPU执行程序时访问的就是虚拟地址,完全感知不到物理内存的存在。

物理地址才是内存硬件上的真实地址,是数据在内存条上实际存放的位置。

程序在磁盘上编的是逻辑地址,加载到内存后,CPU访问的还是这个虚拟地址,通过MMU查询页表转换成物理地址,最后去物理内存取指令。

三个的关系是:逻辑地址等于虚拟地址,虚拟地址通过页表映射成物理地址,CPU全程只跟虚拟地址打交道,硬件负责完成转换


还有就是:

所有程序都是独立从 0 开始编址的!每个程序编译时,都觉得自己独占整个地址空间,从 0 开始编号。比如:

A 程序的 main 在 0x1160

B 程序的 main 也在 0x1160

这两个 0x1160 是虚拟地址,不是物理地址 A 和 B 各自有独立的页表,0x1160 会通过页表映射到不同的物理内存位置,所以互不干扰。

问题二:解释名词:

符号表

符号表就是记录程序里所有函数名、变量名和它们对应地址的一张清单。

举个例子,你的 hello.c 里定义了 main 函数,code.c 里定义了 run 函数。在编译成目标文件时,编译器会把函数名和变量名统统收集起来,做成一张表,这就是符号表。

readelf -s hello.o 就能看到这张表,里面记录了 main 函数的地址、run 函数的地址(在 hello.o 里还是未定义的,标记为 UND),以及 printf 这种外部函数的引用。

符号表的作用就是让链接器能够找到每个函数和变量的位置,把多个目标文件拼接在一起时,知道谁调用了谁。

字符串表

字符串表就是一块连续的内存空间,把所有用到的字符串(函数名、变量名、节名等)一个接一个地塞在一起,中间用 \0 隔开。

比如你有 hellomainprintf 这些名字,字符串表里就是 "hello\0main\0printf\0..." 这样的格式。

为什么需要字符串表?因为符号表里记录的都是地址和长度,不存名字本身。比如符号表里有一条记录,说“这里有个符号,名字在字符串表的第 5 个字节开始”,系统就去字符串表里找到那个位置,把名字读出来。

这样做的好处是节省空间。所有需要用到名字的地方,都用索引去字符串表里查,而不是每个地方都存一份完整的字符串

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐