
💡Yupureki:个人主页
✨个人专栏:《C++》 《算法》《Linux系统编程》《高并发内存池》《MySQL数据库》
🌸Yupureki🌸的简介:

目录
[1. 库的概念](#1. 库的概念)
[1.1 什么是库?](#1.1 什么是库?)
[1.2 静态库](#1.2 静态库)
[1.2.1 静态库生成](#1.2.1 静态库生成)
[1.2.2 使用静态库](#1.2.2 使用静态库)
[1.3 动态库](#1.3 动态库)
[1.3.1 动态库生成](#1.3.1 动态库生成)
[1.3.2 使用动态库](#1.3.2 使用动态库)
[2. 目标文件和ELF文件](#2. 目标文件和ELF文件)
[2.1 什么是目标文件?](#2.1 什么是目标文件?)
[2.2 什么是ELF文件](#2.2 什么是ELF文件)
[2.3 从目标文件到可执行文件的关键变化](#2.3 从目标文件到可执行文件的关键变化)
[3. 理解链接与加载](#3. 理解链接与加载)
[3.1 静态库链接](#3.1 静态库链接)
[3.1.1 符号解析](#3.1.1 符号解析)
[3.1.2 节合并与地址分配](#3.1.2 节合并与地址分配)
[3.1.3 重定位](#3.1.3 重定位)
[3.2 ELF加载与程序地址空间](#3.2 ELF加载与程序地址空间)
[3.2.1 两个问题](#3.2.1 两个问题)
[3.3.3 重谈程序地址空间](#3.3.3 重谈程序地址空间)
[3.3.3.1 程序地址的"三位一体"](#3.3.3.1 程序地址的“三位一体”)
[3.3.3.2 函数跳转地址和变量地址的演变](#3.3.3.2 函数跳转地址和变量地址的演变)
[3.3.3.3 一个具体的例子](#3.3.3.3 一个具体的例子)
[3.3 动态链接与动态库加载](#3.3 动态链接与动态库加载)
[3.3.1 进程如何看到动态库](#3.3.1 进程如何看到动态库)
[3.3.2 进程间如何共享动态库](#3.3.2 进程间如何共享动态库)
[3.3.3 动态链接全过程](#3.3.3 动态链接全过程)
[3.3.3.1 我们的可执行程序被编译器动了手脚](#3.3.3.1 我们的可执行程序被编译器动了手脚)
[3.3.3.2 动态链接的加载过程](#3.3.3.2 动态链接的加载过程)
1. 库的概念
1.1 什么是库?
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
- 静态库.a[Linux]、.lib[windows]
- 动态库.so[Linux]、.dll[windows]
1.2 静态库
先准备好两个函数
cpp
int my_add(int x,int y)
{
return x + y;
}
cpp
int my_del(int x,int y)
{
return x - y;
}
把他们分别放进my_add.c和my_del.c中
然后准备my_cal.h
cpp
int my_add(int x,int y);
int my_del(int x,int y);
1.2.1 静态库生成
静态库在链接时会被直接整合到可执行文件中,生成的可执行文件不再依赖该库文件。
制作步骤
1. 编译为目标文件
bash
gcc -c my_add.c -o my_add.o
gcc -c my_del.c -o my_del.o
-c 选项表示只编译不链接,生成目标文件 my_add.o和my_del.o。
2. 使用 ar 打包成静态库
bash
ar rcs libmy_cal.a my_add.o my_del.o
生成静态库文件 libmy_cal.a。
-
r:将文件插入或替换到库中 -
c:创建库(若不存在) -
s:创建索引(相当于ranlib)
注意
静态库前缀必须得带lib ,后缀必须得带**.a**
1.2.2 使用静态库
cpp
#include <stdio.h>
#include "my_cal.h"
int main()
{
int x = 1;int y = 2;
printf("%d + %d = %d\n",x,y,my_add(x, y));
printf("%d - %d = %d\n",x,y,my_del(x, y));
return 0;
}
编译链接时指定库路径和库名:
bash
gcc test.c -L. -lmy_cal -o test
-
-L.:添加当前目录到库搜索路径 -
-lmylib:链接libmylib.a(去掉前缀lib和后缀.a)

1.3 动态库
1.3.1 动态库生成
动态库在运行时被加载,多个程序可以共享同一份库文件,节省内存和磁盘空间。
1. 编译为位置无关的目标文件
bash
gcc -c -fPIC my_add.c -o my_add.o
gcc -c -fPIC my_del.c -o my_del.o
-fPIC 表示生成位置无关代码(Position Independent Code),这是动态库必须的。
2. 生成动态库
bash
gcc -shared my_add.o my_del.o -o libmy_cal.so
也可一步完成:
bash
gcc -shared -fPIC my_add.c my_del.o -o libmy_cal.so
1.3.2 使用动态库
编译测试程序(同样使用 test.c):
cpp
gcc test.c -L. -lmy_cal -Wl,-rpath=. -o test
注意
在编译
test时添加-Wl,-rpath=.,将当前目录路径嵌入可执行文件如果不添加,则需要
- 拷贝.so文件到系统共享库路径下,一般指/usr/lib、/usr/local/lib、/lib64或者开
篇指明的库路径等- 向系统共享库路径下建立同名软连接
- 更改环境变量:LD_LIBRARY_PATH
此时链接器会寻找 libmylib.so(优先于 .a 文件)。

2. 目标文件和ELF文件
2.1 什么是目标文件?
在 Linux 中,"目标文件"特指 ELF 可重定位文件 (通常扩展名为 .o),它是编译过程产生的中间产物,尚未经过链接。
2.2 什么是ELF文件
要理解编译链链接的细节,我们不得不了解一下ELF文件。其实有以下四种文件其实都是ELF文件:
- 可重定位文件(RelocatableFile):即xxx.o文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
- 可执行文件(ExecutableFile):即可执行程序。
- 共享目标文件(SharedObjectFile):即xxx.so文件。
- 内核转储(coredumps),存放当前进程的执行上下文,用于dump信号触发。
ELF(Executable and Linkable Format,可执行与可链接格式) 是 Linux 系统中用于表示目标文件、可执行文件和共享库的标准二进制格式。
一个典型的 ELF 文件由以下几个逻辑部分构成:
-
ELF 头(ELF Header):文件的"地图"。它描述了文件的类型、目标体系结构(如 x86-64、ARM)、程序入口点地址,以及后面各个段(Section Header Table 和 Program Header Table)的位置信息。
-
段头表(Section Header Table) :用于链接 。它列出了文件中所有的"节"(Section),比如代码节
.text、数据节.data、只读数据节.rodata、符号表.symtab等。链接器在将多个目标文件合并成可执行文件时,主要依赖这个表。 -
程序头表(Program Header Table) :用于执行 。它描述了如何将文件中的内容加载到内存中。操作系统加载器(如
exec系统调用)通过读取这个表,知道应该把文件的哪部分映射到内存的哪个区域,并赋予何种权限(读、写、执行)。 -
节区数据(Section Data):文件中实际的代码和数据。
最常见的节:
- 代码节(.text):用于保存机器指令,是程序的主要执行部分。
- 数据节(data):保存已初始化的全局变量和局部静态变量。

2.3 从目标文件到可执行文件的关键变化
当使用 gcc main.o foo.o -o prog 进行链接后,从目标文件(可重定位)到最终的可执行文件,发生了以下几个关键变化:
| 维度 | 目标文件 (.o) | 可执行文件 |
|---|---|---|
| 核心用途 | 供链接器消费 | 供操作系统加载运行 |
| 地址 | 未定址,从 0 开始 | 最终内存地址(如 0x400000) |
| 结构侧重 | 段头表(Section Header) 为主 | 程序头表(Program Header) 为主 |
| 节合并 | 每个 .o 都有独立的 .text | 多个 .o 的 .text 被合并为一个段 |
| 符号 | 保留所有符号(包括未使用的) | 默认剥离调试符号,仅保留动态符号 |
从"节"到"段"的概念演变 :链接器会将多个目标文件中相同属性的"节"合并 ,并映射为"段"(Segment)。例如,所有目标文件的 .text、.rodata 可能合并为一个只读、可执行的代码段。程序头表描述的正是这些"段",便于加载器一次性映射,提升效率。
3. 理解链接与加载
3.1 静态库链接
无论是自己的.,还是静态库中的.o,本质都是把.o文件进行连接的过程
所以:研究静态链接,本质就是研究.o是如何链接的
静态链接是将一个或多个目标文件(.o)及静态库(.a)合并成一个独立的可执行文件的过程。这个可执行文件包含了程序运行所需的全部代码和数据,不再依赖外部共享库。整个链接过程由链接器(如 ld)完成,主要分为 符号解析 、节合并与地址分配 、重定位 三个核心阶段。
3.1.1 符号解析
假设有这样一个.c文件
cpp
#include<stdio.h>
void run();
int main()
{
printf("hello world!\n");
run();
return 0;
}
编译后并没有报错,即便没有run函数的具体实现
几个.o文件互相不认识对方,不知道对方具体实现了哪些函数。编译器即便只看到了声明,没有看到定义也不会报错,会把这个函数的跳转地址默认设置为0。因为编译器相信其他的.o文件实现了具体的方法 ,在链接 过程中会修正这次函数的跳转地址,这就是符号解析
链接器首先遍历所有输入的目标文件,建立全局符号表,并确定每个符号的定义和引用。
关键规则:
-
符号定义:每个符号(函数或全局变量)在某个目标文件中被定义一次(强符号)或多次(弱符号,如未初始化的全局变量、C++ 的弱定义)。
-
符号引用:如果一个符号被引用但未在当前文件中定义,称为"未定义符号"。
-
解析过程:
-
链接器从命令行顺序扫描输入文件(包括静态库)。
-
对于每个目标文件,将其定义的符号加入全局符号表;如果发现重复的强符号定义,报错"multiple definition"。
-
对于静态库,链接器检查库中每个成员目标文件,看它是否提供当前未定义的符号。如果是,则将该成员提取出来参与链接(就像直接输入该
.o文件一样)。否则,跳过该成员。 -
最终所有未定义符号都必须被解决,否则链接失败。
-
示例:
cpp
// main.c
extern int foo; // 引用
void bar(); // 引用
int main() { bar(); return foo; }
// foo.c
int foo = 42;
// bar.c
void bar() {}
链接器会扫描 main.o,发现未定义符号 foo 和 bar。然后从静态库或后续目标文件中寻找定义。如果 foo.o 和 bar.o 被提供,解析成功。
3.1.2 节合并与地址分配
链接器将多个输入目标文件中相同属性的节合并为输出文件中的 节(Section),并根据链接脚本为每个输出节分配最终的虚拟地址。
合并规则:
-
通常所有
.text节合并为输出文件的.text节。 -
所有
.data节合并为.data节。 -
所有
.bss节合并为.bss节。 -
其他节(如只读数据
.rodata、调试信息)也按类型合并。
地址分配:
-
链接器脚本(默认或用户指定)决定了输出节在虚拟地址空间中的顺序和起始地址。
-
对于非 PIE 可执行文件(
-no-pie),典型起始地址为0x400000(x86-64)。 -
对于 PIE 可执行文件(
-pie),地址从0开始,最终由内核在加载时随机化。 -
每个输入节在合并后的输出节中获得一个偏移量,从而得到其最终的运行时地址。
符号地址确定:
-
每个定义符号(函数名、全局变量名)在合并后的节中有一个固定偏移,加上输出节的基地址,就得到了该符号的最终虚拟地址。
-
例如,
main函数在.text节的偏移0x100,如果.text基地址为0x400000,则main的地址为0x400100。
3.1.3 重定位
在符号地址确定后,链接器需要修正代码和数据中那些原来被标记为"待重定位"的引用。
重定位表:
-
每个可重定位目标文件中的
.rel.text、.rel.data等节记录了重定位项。每一项包含:-
重定位地址(在节内的偏移)。
-
符号索引(指向哪个符号)。
-
重定位类型(如
R_X86_64_PC32表示相对寻址,R_X86_64_32表示绝对寻址)。
-
重定位过程:
-
遍历所有输入目标文件的重定位表。
-
根据符号索引找到该符号在最终输出中的地址(已经在地址分配阶段计算好)。
-
根据重定位类型,计算需要写入到该位置的数值:
-
绝对地址重定位:直接将符号的最终地址写入指令或数据。
-
相对地址重定位 :计算
符号地址 - (当前指令地址 + 当前指令长度),写入相对偏移。
-
-
将修正后的值写入输出文件的相应位置(内存中或文件中)。
3.2 ELF加载与程序地址空间
3.2.1 两个问题
问题:
- 一个ELF程序,在没有被加载到内存的时候,有没有地址呢? 进程mm_struct
- vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?
ELF 程序在没有加载到内存时有没有地址?
答:有"逻辑地址",但没有"物理内存地址"。
ELF 文件中包含的地址信息是在编译链接阶段确定的,它们属于 虚拟地址空间中的逻辑地址 ,保存在 ELF 头(e_entry)和程序头表(p_vaddr)中。例如:
-
非 PIE 可执行文件的入口点可能是
0x400000。 -
代码段要求加载到
0x400000,数据段加载到0x402000等。
这些地址是 固定的逻辑地址 ,但它们只是文件中的数值,并没有真正映射到物理内存。当程序被加载时,内核会根据这些值在进程的虚拟地址空间中创建对应的映射(通过 mmap),使虚拟地址与文件内容关联起来。
因此:
-
从"文件内容"角度看,ELF 文件中确实存储着地址值(占位或具体数值)。
-
从"运行时内存"角度看,这些地址尚未生效,只有在加载后才会成为进程虚拟地址空间的一部分。
进程 mm_struct 和 vm_area_struct 在进程刚创建时,初始化数据从哪里来?
进程内存管理结构(mm_struct 和 vm_area_struct)的初始化发生在两个关键阶段:
1. 创建新进程(fork / clone)
-
当内核通过
fork()创建子进程时,子进程的mm_struct是从父进程 复制 而来的。 -
此时采用 写时复制(Copy-on-Write, COW) 技术:父进程的
vm_area_struct结构体会被复制一份(浅拷贝),但物理内存页暂时共享,直到某一方写入时才会真正复制。 -
所以,子进程刚创建时的内存布局完全继承自父进程,包括代码段、数据段、堆、栈等所有虚拟内存区域。
2. 加载新程序(execve)
当进程执行 execve 系统调用时,内核会销毁旧的内存映射(释放原来的 mm_struct 中的 VMA),然后根据新程序(ELF 文件)重新构建内存布局:
-
解析 ELF 头,获取程序头表。
-
遍历每个
PT_LOAD段 ,根据其p_vaddr、p_filesz、p_memsz、p_flags等信息,通过内核内部的mmap操作创建新的vm_area_struct区域。 -
这些新创建的 VMA 描述了进程虚拟地址空间中每一段的起始地址、大小、权限(读/写/执行)以及映射的文件内容(如果映射了文件)。
-
同时,内核会设置堆的起始地址(通常从数据段末尾开始),以及栈的初始地址(由架构和 ASLR 决定)。
-
最后,将程序入口点(
e_entry)写入进程的task_struct中的->thread相关字段,以便在切换到用户态时从该地址开始执行。
补充说明
mm_struct是描述整个进程地址空间的顶级结构,它包含一个mmap链表(或红黑树)来组织所有的vm_area_struct,以及页表根指针、代码段起始/结束、数据段起始/结束、堆顶、栈顶等信息。无论是
fork复制还是execve重建,这些结构体的初始化数据都来源于:
fork :父进程已有的
mm_struct和 VMA。execve:ELF 文件的程序头表、内核默认的栈/堆布局规则,以及(可能的)环境变量和命令行参数。
3.3.3 重谈程序地址空间
如今我们又有三大地址:
程序中的地址(函数跳转地址、变量地址)、程序地址空间(虚拟地址空间)和物理内存地址
这三者关系可以概括为:
-
可执行文件 中记录的地址是 虚拟地址空间中的逻辑地址(链接时确定或预留)。
-
进程地址空间 是内核为该进程建立的虚拟地址映射表,它定义了这些虚拟地址如何分布。
-
物理内存 是实际存储数据和指令的硬件内存,虚拟地址通过 MMU(内存管理单元) 按页表转换成物理地址。
理解这个关系,需要区分 编译/链接时 、加载时 和 运行时 三个阶段。
- 物理内存 是实际存储数据和指令的硬件内存,虚拟地址通过 MMU(内存管理单元) 按页表转换成物理地址。
3.3.3.1 程序地址的"三位一体"
1. 可执行文件中的地址(存储时)
ELF 文件中包含的地址信息是 虚拟地址(VA)的静态描述,它们以数值形式保存在:
-
ELF 头 :
e_entry指定程序入口虚拟地址。 -
程序头表 :每个
PT_LOAD段有p_vaddr指定该段应被加载到的虚拟地址基址。 -
符号表 :函数和全局变量的虚拟地址(如
main的地址0x400100)。 -
重定位表 :待修正的地址引用(如跳转指令中的占位
0x0)。
对于 非 PIE 可执行文件 ,这些地址是绝对地址(如 0x400000 开始)。
对于 PIE 可执行文件 ,这些地址是相对基址(如 0x0 开始),加载时加上随机偏移。
2. 进程地址空间中的地址(运行时)
当程序被加载后,内核根据 ELF 的程序头表 在进程的虚拟地址空间中创建对应的 虚拟内存区域(VMA)。此时:
-
虚拟地址空间中的地址就是 ELF 文件中记录的地址(非 PIE)或
基址 + 偏移(PIE)。 -
进程中的函数指针、变量地址 就是这些虚拟地址。
-
代码执行时,PC 寄存器存放的就是虚拟地址。
3. 物理内存地址(真实存储)
物理内存是实际的 RAM 芯片,数据最终存放在物理页框中。虚拟地址通过 页表 映射到物理地址:
-
内核在加载程序时,只为部分页面(如代码段)分配物理页,采用 按需分页(demand paging),即建立虚拟地址到物理页的映射关系(页表项),但物理页可能还未分配(缺页异常时再分配)。
-
当 CPU 执行指令访问某个虚拟地址时,MMU 查询页表,将虚拟地址转换为物理地址,完成访存。
3.3.3.2 函数跳转地址和变量地址的演变
1. 编译时
编译器生成的目标文件(.o)中,函数调用和变量访问使用的是 相对地址 或 占位符:
-
函数调用:
call 0x0(占位),重定位表记录需修正的位置和符号名。 -
全局变量:
mov eax, [0x0](占位)。
2. 链接时(静态链接)
链接器完成 符号解析 和 重定位,将最终虚拟地址写入指令:
-
函数
main的虚拟地址确定为0x400100。 -
函数
foo的虚拟地址确定为0x400200。 -
指令
call foo被修正为call 0x400200(或相对偏移,如call 0xFB,实际是相对于当前 PC 的偏移)。
此时,可执行文件中已经包含了 绝对虚拟地址 (非 PIE)或 相对偏移(PIE 使用相对寻址)。
3. 加载时(内核)
-
对于非 PIE:内核直接按
p_vaddr创建 VMA,虚拟地址与文件中记录的地址完全一致。 -
对于 PIE:内核选择一个随机基址
base,将p_vaddr加上base作为实际加载地址,同时修正 ELF 头中的入口点等。
注意 :即使是 PIE,文件中指令里使用的大多是 相对寻址 (如 call 使用 PC 相对偏移),因此加载时不需要修改指令,只需修正全局数据地址(如 GOT 表)。
4. 运行时(动态链接)
对于动态链接的程序,某些函数和变量的最终地址在 运行时 才确定:
-
全局偏移表(GOT):存放全局变量和外部函数的实际虚拟地址。
-
过程链接表(PLT):存放对共享库函数的跳转存根。
-
当第一次调用
printf时,通过 PLT 跳转到动态链接器,由动态链接器解析printf的真实地址并填入 GOT,后续调用直接通过 GOT 跳转。
此时,函数跳转地址(如 call printf@plt)最终指向的是 GOT 中填入的虚拟地址。
3.3.3.3 一个具体的例子
假设有如下 C 程序:
cpp
int global = 42;
int main() { return global; }
编译并静态链接(gcc -static -no-pie):
-
链接后,可执行文件中:
-
main虚拟地址0x400100。 -
global虚拟地址0x600200(在数据段)。 -
指令
mov eax, [0x600200]中直接写入了绝对地址0x600200。
-
-
加载后,进程虚拟地址空间:
-
代码段 VMA:
0x400000-0x401xxx,权限 r-x。 -
数据段 VMA:
0x600000-0x601xxx,权限 rw-。 -
页表尚未建立物理映射。
-
-
首次访问:
-
CPU 执行到
mov eax, [0x600200],访问虚拟地址0x600200。 -
缺页异常 → 内核分配物理页,将可执行文件数据段部分内容(包括
global的初值 42)读入该物理页,更新页表。 -
返回用户态,重新执行指令,现在 MMU 将
0x600200转换为物理地址,成功读取到 42。
-
-
总结:
| 阶段 | 地址形式 | 关系说明 |
|---|---|---|
| 编译/链接 | 虚拟地址(绝对值或偏移) | 记录在 ELF 文件中,作为逻辑布局的蓝图。 |
| 加载 | 虚拟地址(实际加载值) | 内核创建 VMA,虚拟地址与文件中记录一致(或加偏移),但尚未映射物理页。 |
| 运行时访问 | 虚拟地址 → 物理地址 | CPU 通过 MMU 和页表将虚拟地址转换为物理地址。页表由内核动态建立,按需分配物理页。 |
| 函数跳转 | 虚拟地址 | 跳转指令中使用虚拟地址(直接或间接),转换后指向实际物理内存中的指令。 |
| 变量访问 | 虚拟地址 | 变量地址是虚拟地址,通过页表转换成物理内存地址进行读写。 |
3.3 动态链接与动态库加载
3.3.1 进程如何看到动态库
从进程的角度看,动态库就像它自己的代码和数据一样,位于其虚拟地址空间中的某个区域。这个过程由动态链接器完成:
-
映射到虚拟地址空间
当进程启动时,动态链接器将共享库的 ELF 文件映射到进程的虚拟地址空间。它根据库的程序头表调用
mmap,在进程的虚拟地址空间 中分配一段区域,并与库文件的内容建立关联。 -
符号解析与重定位
动态链接器解析可执行文件对库中函数和变量的引用,修正全局偏移表和过程链接表。此后,进程的代码通过 PLT 存根或直接通过 GOT 间接调用库函数。
-
运行时访问
进程的指令执行时,遇到对库函数的调用,会跳转到 PLT 中的代码,再通过 GOT 获得函数的实际虚拟地址(延迟绑定后)。由于该虚拟地址位于库映射的区域,CPU 通过 MMU 和页表找到对应的物理内存页,从而执行库代码或访问库数据。

简而言之:进程通过虚拟地址空间"看到"库,就像看到自己的代码段一样,只是这些地址对应的物理页可能与其它进程共享。
3.3.2 进程间如何共享动态库
多个进程同时使用同一个动态库时,物理内存中通常只保留一份库的 代码段 ,而 数据段 通常为每个进程独立(写时复制)。这是如何实现的?
1. 代码段的共享
-
当第一个进程加载该库时,内核将库文件中的代码段内容读入物理内存页(或通过文件页缓存映射)。
-
第二个进程加载同一个库时,内核不会重新读取文件到新的物理页,而是将 同一组物理页 映射到第二个进程的虚拟地址空间。
2. 数据段的处理
-
共享库的数据段(
.data、.bss)通常是 可写 的。如果多个进程共享同一个物理页,一个进程修改数据会影响到其他进程,这通常不是期望的行为。 -
因此,内核采用 写时拷贝(COW) 技术:
-
初始时,所有进程的数据段页表项都指向同一组物理页(只读)。
-
当某个进程尝试写入时,触发缺页异常,内核为该进程复制一份新的物理页,并将其页表项改为可写。
-
此后,该进程拥有自己的数据副本,其他进程仍共享原物理页(直到它们也写入)。
-
-
这样,每个进程的数据段是独立的,但未修改的页面仍共享物理内存。

示例演示
假设有两个进程 A 和 B 都使用 libc.so.6。
-
代码段共享 :
libc.so.6的代码段物理页框号P1被映射到 A 的虚拟地址0x7f1234567000和 B 的虚拟地址0x7f567890a000。两个进程的页表项都指向P1,且页属性为只读+执行。 -
数据段 COW :
初始时,
libc.so.6的数据段物理页框号P2被映射到两个进程的相应虚拟地址(页表项为只读)。如果 A 修改了errno(位于数据段),触发缺页异常,内核为 A 分配新的物理页P3,复制P2的内容,并将 A 的页表项指向P3且改为可写。B 仍使用P2且仍为只读,直到 B 也写入时再复制。
3.3.3 动态链接全过程
首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
3.3.3.1 我们的可执行程序被编译器动了手脚
在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到main函数。实际上,程序的入口点是_start,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。
在_start函数中,会执行一系列初始化操作,这些操作包括:
- 设置堆栈:为程序创建一个初始的堆栈环境。
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
- 动态链接:这是关键的一步,start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(sharedlibraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址
- 调用__libc_start_main:一旦动态链接完成,_start函数会调用
_libc_start_main(这是glibc提供的一个函数)。__libc_start_main函数负责执行
一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。 - 调用main函数:最后,__libc_start_main函数会调用程序的main函数,此时程序的执
行控制权才正式交给用户编写的代码。 - 处理main函数的返回值:当main函数返回时,_libc_start_main会负责处理这个返回
值,并最终调用_exit函数来终止程序。
动态链接器(Dynamic Linker)
路径通常为
/lib64/ld-linux-x86-64.so.2(x86-64 架构)。本身也是一个共享库,但内核在加载可执行文件时会将其映射到内存,并将控制权交给它。
负责加载所有依赖的共享库、解析符号、执行重定位,最后将控制权转给应用程序。
3.3.3.2 动态链接的加载过程
内核加载阶段
-
用户执行
execve,内核打开 ELF 文件,读取 ELF 头。 -
检查
PT_INTERP段,如果存在,则将该段指定的动态链接器(也是一个 ELF 文件)也映射到内存(mmap)。 -
内核根据程序头表映射可执行文件的所有
PT_LOAD段(此时只是建立 VMA,未进行符号解析)。 -
设置进程的堆栈、参数、环境变量,并将控制权转交给动态链接器的入口(而不是程序的
_start)。
动态链接器启动(自举)
-
动态链接器本身是位置无关的,它首先完成自身的重定位,因为它的代码可能依赖自己 GOT 中的地址。
-
它需要知道自己的加载基址(通过
_start入口时栈中保存的辅助向量),然后修正自身内部的符号引用。
加载依赖库
-
动态链接器读取可执行文件的
.dynamic段,遍历DT_NEEDED条目,获取依赖库列表(如libc.so.6)。 -
根据搜索路径(
LD_LIBRARY_PATH、/etc/ld.so.cache、/lib、/usr/lib)查找每个共享库文件。 -
对每个共享库,递归加载其依赖的库(避免重复加载)。
-
每个共享库同样是一个 ELF 文件,动态链接器将其映射到进程地址空间(通过
mmap)。
符号解析与重定位
-
动态链接器遍历所有模块(可执行文件 + 共享库)的
PT_DYNAMIC段中的重定位表。 -
对于每个重定位项:
-
找到符号名(通过
.dynsym和.dynstr)。 -
在所有已加载模块的符号表中查找该符号的定义(采用广度优先搜索,遵循依赖顺序)。
-
如果符号是变量,则需要修正 GOT 中的条目;如果符号是函数,且采用延迟绑定,则可能只填充 PLT 相关信息,实际绑定推迟到首次调用。
-
-
重定位类型分为相对重定位(如
R_X86_64_RELATIVE)和绝对重定位(如R_X86_64_GLOB_DAT用于 GOT 条目,R_X86_64_JUMP_SLOT用于 PLT 条目)。
执行初始化
-
遍历所有模块的初始化函数(
.init节或DT_INIT、DT_INIT_ARRAY),按依赖顺序执行。 -
例如,C++ 全局对象的构造函数在这个阶段调用。
转移控制权
- 动态链接器将控制权交给可执行文件的入口点(
_start),程序开始执行main。