库的概念

什么是库

库是写好的现有的,成熟的,可以复⽤的代码。现实中每个程序都要依赖很多基础的底层库,不可能 每个⼈的代码都从零开始,因此库的存在意义⾮同寻常。 本质上来说库是⼀种可执⾏代码的⼆进制形式,可以被操作系统载⼊内存执⾏。库有两种:

• 静态库 .a [Linux] 、 .lib[windows]

• 动态库 .so[Linux] 、 .dll[windows]

通俗的讲:只要把对应的.o文件+.h文件打包交给别人,就能实现库的效果。别人只需要将所有.o文件进行gcc/g++ *.o链接,再./a.out执行即可。

静态库

• 静态库(.a):程序在编译链接的时候把库的代码链接到可执⾏⽂件中,程序运⾏的时候将不再 需要静态库。

• ⼀个可执⾏程序可能⽤到许多的库,这些库运⾏有的是静态库,有的是动态库,⽽我们的编译默 认为动态链接库,只有在该库下找不到动态.so的时候才会采⽤同名静态库。我们也可以使⽤gcc 的-static 强转设置链接静态库。

实验步骤

准备两个源文件:

test1.c:

#include<stdio.h>                                                                                  
void Print_1()
{
    printf("Im test1.c\n");
}

test2.c:

#include<stdio.h>                                                                                  
void Print_2()
{
    printf("Im test2.c\n");
}
准备test.h

提一嘴:头文件的本质是对源文件方法说明的文档。

#pragma once

void Print_1();
void Print_2();
静态库生成

静态库在链接 时会被直接整合到可执行文件中,生成的可执行文件不再依赖该库文件。

1.编译为目标文件(.o

gcc -c 把每个 .c 源文件,编译成机器码目标文件(只编译不链接,不会生成可执行文件)

# 编译test1.c,生成test1.o
gcc -c test1.c -o test1.o

# 编译test2.c,生成test2.o
gcc -c test2.c -o test2.o

  • -c:核心作用是「只编译不链接」,把 C 代码转成机器码 .o 文件。
  • -o:指定输出的目标文件名,不写的话默认生成和源文件同名的 .o 文件。

这一步结束后,你会得到 test1.otest2.o 两个纯机器码文件。

2.用 ar 打包成静态库(.a

ar(archive 归档工具)把多个 .o 文件打包成一个静态库,命令格式:

ar rcs libmy_test.a test1.o test2.o

执行后会生成静态库文件 libmy_test.a

注意:

静态库的命名:必须以lib开头,必须以 .a 结尾,中间才是静态库名。

使用静态库
1.写一个主程序调用库

创建 main.c,调用库中的Print_1和Print_2:

#include"test.h"  //引入库的头文件
#include<stdio.h>
int main()
{
    Print_1();
    Print_2();

    return 0;
}
2. 编译并链接静态库
gcc main.c -o main -L. -lmy_test
  • -L.:告诉编译器,库文件的搜索路径包括当前目录(. 代表当前目录)。
  • -lmy_test:链接 libmy_test.a 静态库(-l 后面跟库名,自动补 lib 前缀和 .a 后缀)。
3.生成可执行文件:

4.运行:

动态库

核心特点

  1. 后缀:.so(共享库)
  2. 运行时才加载库文件,可执行文件体积小
  3. 运行必须依赖 .so 文件,删掉就无法运行
  4. 编译命令和静态库几乎一样

动态库生成

动态库在运行时被加载,多个程序可以共享同一份库文件,节省内存和磁盘空间。

准备源文件,头文件,主程序(和静态库通用)

test.h(头文件):

#pragma once

void Print_1();
void Print_2();

 test1.c:

#include<stdio.h>                                                                                  
void Print_1()
{
    printf("Im test1.c\n");
}

test2.c:

#include<stdio.h>                                                                                  
void Print_2()
{
    printf("Im test2.c\n");
}

 创建 main.c,调用库中的Print_1和Print_2:

#include"test.h"  //引入库的头文件
#include<stdio.h>
int main()
{
    Print_1();
    Print_2();

    return 0;
}
分步执行
1. 编译生成动态库(关键步骤)
# 生成位置无关的目标文件
gcc -c -fPIC test1.c test2.c
# 打包成动态库 libmy_test.so
gcc -shared -o libmy_test.so test1.o test2.o

  • -fPIC:必须加!生成位置无关代码(动态库要求)
  • -shared:生成动态库文件
2. 编译主程序,链接动态库
gcc main.c -o main -L. -lmy_test

  • 命令和静态库完全一样
  • -L.:找当前目录的库
  • -lmy_test:链接 libmy_test.so
3. 解决运行报错(最重要!)

直接运行 ./main 会报错:找不到动态库

执行这条命令:

gcc main.c -L. -lmy_test -Wl,-rpath=. -o main

注意

在编译 test 时添加 -Wl,-rpath=.,将当前目录路径嵌入可执行文件

如果不添加,则需要

1.拷贝.so文件到系统共享库路径下,一般指/usr/lib、/usr/local/lib、/lib64或者开篇指明的库路径等
2.向系统共享库路径下建立同名软连接
3.更改环境变量:LD_LIBRARY_PATH

此时链接器会寻找 libmy_test.so(优先于 .a 文件)

还有一个指定路径查找选项:

-I ./路径/ :告诉gcc/g++未来在编译时查头文件,除了在系统默认路径下查,也要去-I后面的指明的路径下查。

4. 运行程序
./main

⽬标⽂件和ELF⽂件

什么是ELF文件

ELF(Executable and Linkable Format)是 Linux 下可执行文件、目标文件、共享库的标准二进制格式。它有两个核心身份:

  • 链接阶段的「半成品 / 成品」:目标文件 .o、静态库 .a、动态库 .so、可执行文件,本质都是 ELF 格式。
  • 加载阶段的「指令手册」:可执行 ELF 里的「程序头表」,就是给操作系统加载器看的 “说明书”。

1.可重定位文件(RelocatableFile):即xxx.o文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
2.可执行文件(ExecutableFile):即可执行程序。
3.共享目标文件(SharedObjectFile):即xxx.so文件。
4.内核转储(coredumps),存放当前进程的执行上下文,用于dump信号触发。

一个典型的 ELF 文件由以下几个逻辑部分构成:

1.ELF 头(ELF Header):文件的“地图”。它描述了文件的类型、目标体系结构(如 x86-64、ARM)、程序入口点地址,以及后面各个段(Section Header Table 和 Program Header Table)的位置信息。

2.段头表(Section Header Table):用于链接。它列出了文件中所有的“节”(Section),比如代码节 .text、数据节 .data、只读数据节 .rodata、符号表 .symtab 等。链接器在将多个目标文件合并成可执行文件时,主要依赖这个表。

3.程序头表(Program Header Table):用于执行。它描述了如何将文件中的内容加载到内存中。操作系统加载器(如 exec 系统调用)通过读取这个表,知道应该把文件的哪部分映射到内存的哪个区域,并赋予何种权限(读、写、执行)。

4.节区数据(Section Data):文件中实际的代码和数据。
最常⻅的节:

• 代码节(.text):⽤于保存机器指令,是程序的主要执⾏部分。

• 数据节(.data):保存已初始化的全局变量和局部静态变量。

ELF形成可执⾏

step 1:编译源代码 → 目标文件(.o

你写的 .c 文件,经过 gcc -c 编译后,会生成 .o 目标文件:

  • 每个 .o 都是一个独立的 ELF 文件,里面用 Section(节) 组织数据:
    • .text:编译好的函数机器码
    • .data:初始化的全局变量
    • .rodata:只读常量(比如字符串)
    • .bss:未初始化的全局变量(不占文件空间)
  • 此时的 .o 是「半成品」:地址从 0 开始、符号未解析、没有可执行权限。

step 2:链接器合并多个 .o,生成最终 ELF 可执行文件

链接器(ld,被 gcc 调用)的核心工作,就是把多个 .o 拼起来,生成能运行的 ELF:

  • Section 合并:按「属性」分组链接器会把所有 .o属性相同的 Section 合并:

    • 所有 .text 合并成一个大的代码块(可执行、只读)
    • 所有 .rodata 合并成只读数据块(只读)
    • .data/.bss 合并成数据块(可读、可写)这一步的合并规则,完全由链接器控制,和你图片里说的 “相同属性合并” 一致。
  • 生成 Segment(段),写入「程序头表」合并后的 Section 会被打包成 Segment(段),比如:

    • 「代码段」:包含合并后的 .text + .rodata,权限是 读 + 执行
    • 「数据段」:包含合并后的 .data + .bss,权限是 读 + 写这些段的加载信息(虚拟地址、文件偏移、权限、大小),会被记录在 程序头表(Program Header Table) 中。
  • 完成符号重定位,剥离调试信息

    • 解析所有函数调用、变量引用,给它们分配最终的内存地址。
    • 默认剥离调试用的符号表,只保留动态链接需要的符号,让文件更小。

✅ 到这里,ELF 可执行文件就生成好了:它有 ELF 头、程序头表、合并后的段数据,以及可选的段头表(Section Header Table,仅用于调试 / 反编译,运行时可剥离)。

�注意:

• 实际合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并,此处不做 过多追究。

ELF可执⾏⽂件加载

当你输入 ./test 时,内核会按以下步骤加载并运行 ELF:

step 1:内核读取 ELF 头,定位「程序头表」

  • 内核首先读取 ELF 文件开头的 ELF Header,它就像文件的 “目录”,里面记录了程序头表的位置和大小。
  • 关键:加载器只关心「程序头表」,完全不看「段头表」,因为段头表是给链接器 / 调试器用的。

step 2:为新进程创建独立虚拟地址空间

  • 内核会为这个程序分配一个全新的虚拟地址空间,初始化页表,把进程和其他进程的内存隔离开。

step 3:按「程序头表」映射 Segment 到虚拟内存

内核会逐条读取程序头表中的段描述,把 ELF 文件中的段映射到虚拟地址空间:

  • 代码段:映射为 PROT_READ | PROT_EXEC 权限,从磁盘文件加载 .text.rodata 数据。
  • 数据段:映射为 PROT_READ | PROT_WRITE 权限,.data 加载初始化数据,.bss 不占文件空间,内核会直接清零这部分内存。
  • 这一步的映射规则,完全来自链接阶段写入的程序头表,和你图片里说的 “合并方式已确定” 对应。

step 4:动态链接程序的额外处理(如果是动态链接 ELF)

如果你的程序链接了动态库(比如 libc.so),内核会先把控制权交给动态链接器(ld-linux.so):

  • 动态链接器会根据 ELF 中的 .dynamic 段,找到所有依赖的动态库。
  • 加载这些动态库到进程地址空间,解析并重定位所有外部符号(比如 printf)。
  • 这就是为什么你之前需要设置 LD_LIBRARY_PATH:动态链接器需要找到 .so 文件。

step 5:跳转到程序入口点,开始执行

所有准备工作完成后,内核会把 CPU 的指令指针寄存器,设置为 ELF 头中记录的入口地址(通常是 _start,再调用你的 main 函数),程序就正式开始运行了。

从目标文件到可执行文件的关键变化

当使用 gcc main.o foo.o -o prog 进行链接后,从目标文件(可重定位)到最终的可执行文件,发生了以下几个关键变化:

维度 目标文件(.o) 可执行文件 通俗解释
核心用途 供链接器消费 供操作系统加载运行 .o 只是半成品,里面只有编译好的机器码,但地址没定、符号没解析,只能给链接器 “拼起来”;可执行文件是成品,操作系统能直接把它加载到内存跑起来。
地址 未定址,从 0 开始 最终内存地址(如0x400000 .o里的代码和数据,地址都是从 0 开始的虚拟地址,只是占位符;链接器会给它们分配最终的运行地址,比如代码段从0x400000开始,数据段从更高地址开始,这样 CPU 运行时才知道该跳去哪里。
结构侧重 段头表(Section Header)为主 程序头表(Program Header)为主 .o 用「段(Section)」来组织数据,比如.text代码段、.data数据段,链接器就是按这些段来合并的;可执行文件用「程序头表」告诉操作系统:“这几个段要加载到内存的哪个位置、权限是什么(读 / 写 / 执行)”,方便加载器一次性映射。
节合并 每个 .o 都有独立的 .text 多个 .o.text 被合并为一个段 你写的每个.c文件编译后,都有自己的.text(函数代码);链接器会把所有.o.text合并成一个大的代码段,.rodata/.data同理,这样程序运行时就是一个连续的内存块,效率更高。
符号 保留所有符号(包括未使用的) 默认剥离调试符号,仅保留动态符号 .o里的所有函数、变量符号都保留,链接器需要它们来解析引用;可执行文件默认会去掉调试用的符号(比如-g加的调试信息),只保留动态链接需要的符号,让文件更小。

从“节”到“段”的概念演变:链接器会将多个目标文件中相同属性的“节”合并,并映射为“段”(Segment)。例如,所有目标文件的 .text.rodata 可能合并为一个只读、可执行的代码段。程序头表描述的正是这些“段”,便于加载器一次性映射,提升效率。

总结

  • 链接阶段:把多个 .o 的 Section 按属性合并,打包成 Segment,写入程序头表,生成 ELF 可执行文件。
  • 加载阶段:内核读程序头表,按 Segment 映射内存,处理动态链接,跳转到入口点运行。

我们对应的可执行程序的形成,是把.o的库,.o所依赖的动、静态库,它们所对应的section对应的属性的节,进行合并,就能形成大的可执行文件。

合并EIF本质就是进行二进制文件的合并。

理解链接与加载

静态链接

无论是自己的.,还是静态库中的.o,本质都是把.o文件进行连接的过程
所以:研究静态链接,本质就是研究.o是如何链接的

静态库链接,是将主程序目标文件(.o)与静态库(.a,本质是打包的多个目标文件)链接,最终生成一个独立、可直接运行的 ELF 可执行文件的过程。链接的核心分为三个关键阶段:符号解析、节合并与地址分配、重定位

符号解析

符号解析的核心任务:将程序中对外部符号(函数、全局变量)的引用,和它的定义关联起来,确保每个符号都有明确的归属。

  • 符号是什么?目标文件中的函数名、全局变量名,本质都是 “符号”。比如主程序main.o里调用的my_add()、静态库libmy_cal.a中定义的my_add(),都是符号。
  • 解析过程:
    1. 链接器会先收集所有参与链接的目标文件(主程序.o + 静态库中被用到的.o)的符号表,建立全局符号表。
    2. 对主程序中引用的未定义符号(比如my_add()),去静态库中查找它的定义。
    3. 只有主程序用到的符号对应的目标文件,才会被从静态库中提取出来参与链接,未被引用的目标文件不会被打包进可执行文件,避免冗余。
  • 常见错误:如果找不到符号定义,链接器会报undefined reference错误,比如忘了链接包含my_add()的静态库。

节合并与地址分配

这一步的核心是把多个目标文件的 Section 按规则合并,并分配最终的虚拟内存地址,为后续的重定位和加载做准备。

  • 节合并:链接器会将所有参与链接的目标文件中,属性相同的 Section合并为一个整体:
    • 所有.text(代码节)合并为一个大的可执行代码节
    • 所有.rodata(只读数据)合并为只读数据节
    • 所有.data/.bss(数据节)合并为可读写数据节合并的逻辑和 ELF 加载时 Section 映射为 Segment 的规则一致,只是这一步在链接阶段完成。
  • 地址分配:目标文件中的地址都是从0开始的虚拟地址(仅为占位符),链接器会为合并后的节分配最终的运行虚拟地址:
    • 比如代码节的起始地址设为0x400000,数据节从0x600000开始,以此划分进程的虚拟地址空间。
    • 同时,链接器会根据合并后的节,生成 ELF 的程序头表(Program Header Table),记录每个 Segment 的地址、权限、大小,为后续操作系统加载做准备。

重定位

前面的符号解析找到了符号的定义,地址分配给了符号最终的虚拟地址,但目标文件中的指令和数据里,还是用的临时占位符(比如调用函数的地址还是 0),重定位就是把这些占位符替换成符号的最终地址,让程序能正确运行。

  • 重定位的依据:每个目标文件中都有「重定位表」,记录了哪些指令 / 数据需要修改、修改的位置、对应的符号名。链接器会根据这个表,结合符号的最终地址,完成修改。
  • 举个例子:主程序中call my_add这条指令,在main.o里的跳转地址还是临时值,链接器找到my_add的最终地址是0x400510,就会把指令中的跳转地址改成这个值,这样程序运行时,CPU 就能跳转到正确的函数地址执行。
  • 重定位完成后:所有指令和数据中的地址都被修正为最终的虚拟地址,可执行文件不再依赖任何外部文件,运行时可以直接执行。

静态链接三步骤总结

  1. 符号解析:解决 “符号在哪定义” 的问题,把引用和定义关联起来。
  2. 节合并与地址分配:解决 “数据怎么放、地址怎么定” 的问题,合并 Section 并分配最终地址。
  3. 重定位:解决 “指令里的地址怎么填” 的问题,把临时占位符改成最终地址,让程序可执行

原来静态链接时,除了合并Section,除了要地址重定位,把我们对应的call后的地址实例出来,写成一个具体的值。同时我们对应的所有代码、数据全部做统一编址,我们的每一个函数包含每一行代码,填入对应的call后面。

ELF 加载与程序地址空间

两个核心问题

问题 1:一个 ELF 程序,在没有被加载到内存的时候,有没有地址呢?

核心结论:有 “逻辑地址(虚拟地址)”,但没有 “物理内存地址”
  1. ELF 文件里的 “逻辑地址” 是什么?这些地址是在编译链接阶段就被链接器确定好的虚拟地址,以数值形式保存在 ELF 文件中:

    • 入口点地址:记录在 ELF 头的 e_entry 字段,比如非 PIE 程序的入口点可能是 0x400000
    • 段加载地址:记录在程序头表(Program Header Table)的 p_vaddr 字段,比如代码段要求加载到 0x400000,数据段加载到 0x402000。这些地址是预定的虚拟地址占位符,此时只是文件里的静态数值,没有任何实际的内存映射关系。
  2. 什么时候才会有物理内存地址?只有当程序被内核加载时,地址才会真正生效:

    • 内核根据 ELF 程序头表的信息,通过 mmap 系统调用,把 ELF 的段映射到进程的虚拟地址空间。
    • 内核为进程创建页表,把虚拟地址映射到实际的物理内存页。
    • 完成这一步后,ELF 文件里的 “逻辑地址” 才会成为进程虚拟地址空间的一部分,真正对应上物理内存地址。

问题 2:进程 mm_structvm_area_struct 在进程刚刚创建的时候,初始化数据从哪里来的?

先明确两个结构体的作用:
  • mm_struct:进程的内存描述符,代表整个进程的虚拟地址空间,是进程所有内存管理信息的总入口,每个进程唯一。
  • vm_area_struct:虚拟内存区域(VMA),代表虚拟地址空间里一段连续、属性相同的内存区域(比如代码段、数据段、栈、堆、共享库映射区),每个区域对应一个 vm_area_struct,挂在 mm_struct 的链表上。
初始化数据的两个关键来源:

mm_structvm_area_struct 的初始化,发生在进程创建 + ELF 加载两个阶段,数据来源分为两类:

  1. 内核基础初始化(进程创建阶段)当通过 fork()/execve() 创建新进程时,内核会先为进程创建基础的 mm_struct

    • 初始化基础字段:比如页目录指针、内存统计信息、进程的地址空间范围等。
    • 此时的 mm_struct 是一个 “空骨架”,还没有任何具体的 vm_area_struct(虚拟内存区域),因为还没有加载 ELF 程序。
    • 如果是 fork() 复制父进程,mm_struct 会复制父进程的结构(写时复制);如果是 execve() 加载新程序,会创建一个全新的 mm_struct
  2. ELF 程序头表(ELF 加载阶段,核心来源)当内核解析 ELF 文件的程序头表时,会根据每个 PT_LOAD 类型的段(Segment),创建对应的 vm_area_struct,这些 VMA 的初始化数据全部来自 ELF 文件本身

    • 起始 / 结束地址:来自 p_vaddr(段的虚拟地址)和 p_memsz(段的内存大小)。
    • 内存权限:来自 p_flags(比如代码段的 PF_R|PF_X,数据段的 PF_R|PF_W)。
    • 文件映射信息:来自 p_offset(段在文件中的偏移)和 p_filesz(段在文件中的大小),内核会用这些信息建立文件与虚拟内存的映射。
  3. 内核默认配置(匿名内存区域)除了 ELF 文件映射的区域,还有一些 VMA 是内核根据系统默认配置初始化的,不需要 ELF 文件提供数据:

    • 栈 VMA:用户栈的起始地址、大小、权限(读 + 写,自动扩展),由内核按默认规则创建。
    • 堆 VMA:进程堆的初始地址,通过 brk()/sbrk() 扩展,由内核管理。
    • VDSO/vsyscall 区域:内核提供的快速系统调用映射区,由内核创建。

重谈程序地址空间

如今我们又有三大地址:

程序中的地址(函数跳转地址、变量地址)、程序地址空间(虚拟地址空间)和物理内存地址

这三者关系可以概括为:

可执行文件 中记录的地址是 虚拟地址空间中的逻辑地址(链接时确定或预留)。

进程地址空间 是内核为该进程建立的虚拟地址映射表,它定义了这些虚拟地址如何分布。

物理内存 是实际存储数据和指令的硬件内存,虚拟地址通过 MMU(内存管理单元) 按页表转换成物理地址。

理解这个关系,需要区分 编译/链接时、加载时 和 运行时 三个阶段。

物理内存 是实际存储数据和指令的硬件内存,虚拟地址通过 MMU(内存管理单元) 按页表转换成物理地址。

 程序地址的“三位一体”
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 查询页表,将虚拟地址转换为物理地址,完成访存。

函数跳转地址和变量地址的演变
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 中填入的虚拟地址。

一个具体的例子

假设有如下 C 程序:

int global = 42;
int main() 
{ 
    return global;
}

编译并静态链接(gcc -static -no-pie):

1.链接后,可执行文件中:

1.main 虚拟地址 0x400100。

2.global 虚拟地址 0x600200(在数据段)。

3.指令 mov eax, [0x600200] 中直接写入了绝对地址 0x600200。

2.加载后,进程虚拟地址空间:

1.代码段 VMA:0x400000 - 0x401xxx,权限 r-x。

2.数据段 VMA:0x600000 - 0x601xxx,权限 rw-。

3.页表尚未建立物理映射。

3.首次访问:

1.CPU 执行到 mov eax, [0x600200],访问虚拟地址 0x600200。

2.缺页异常 → 内核分配物理页,将可执行文件数据段部分内容(包括 global 的初值 42)读入该物理页,更新页表。

返回用户态,重新执行指令,现在 MMU 将 0x600200 转换为物理地址,成功读取到 42。

总结:
阶段 地址形式 关系说明
编译 / 链接 虚拟地址(绝对值或偏移) 记录在 ELF 文件中,作为逻辑布局的蓝图,由链接器为各段分配固定虚拟地址或偏移量。
加载 虚拟地址(实际加载值) 内核创建 VMA,虚拟地址与文件中记录一致(或加偏移),但尚未映射物理页。
运行时访问 虚拟地址 → 物理地址 CPU 通过 MMU 和页表将虚拟地址转换为物理地址。页表由内核动态建立,按需分配物理页。
函数跳转 虚拟地址 跳转指令中使用虚拟地址(直接或间接),转换后指向实际物理内存中的指令。
变量访问 虚拟地址 变量地址是虚拟地址,通过页表转换成物理内存地址进行读写。

动态链接与动态库加载

进程如何看到动态库

进程只能看到自己的虚拟地址空间,动态库就像 “公共共享的代码段”,被映射到进程的虚拟地址空间中,进程调用库函数和调用自己的函数完全一样,只是背后的物理内存是共享的。

动态库的本质:磁盘上的 ELF 文件

动态库(.so)本身就是一个 ELF 文件,和可执行文件一样,它也有.text代码段、.rodata只读数据段、.data数据段,只是它是 “共享版” 的。

  • 它一开始只存在于磁盘上,进程启动时才会被加载。
  • 它的代码是位置无关代码(通过-fPIC编译),可以被加载到任意虚拟地址,方便多个进程共享。
步骤 1:映射到进程的虚拟地址空间(mmap建立关联)

进程启动时,动态链接器(ld-linux.so)会做第一件事:把动态库文件映射到进程的虚拟地址空间

  1. 动态链接器会先读取可执行文件的.dynamic段,找到所有依赖的动态库(比如libc.so)。
  2. 调用mmap()系统调用,在进程的虚拟地址空间中分配一块区域(比如共享库区域),把动态库文件的内容和这块虚拟地址关联起来。
    • 这一步只是建立了虚拟地址 → 磁盘文件的映射,还没有分配物理内存页(延迟加载)。
    • 多个进程映射同一个动态库时,它们的虚拟地址可以不一样,但内核会把它们映射到同一份物理内存页上,实现代码共享。
步骤 2:符号解析与重定位(PLT + GOT 的工作)

映射完成后,进程还不知道库函数的实际地址,这一步要解决 “调用printf()时,跳转到哪里” 的问题,核心是修正 GOT(全局偏移表)和 PLT(过程链接表)

概念 作用
PLT(过程链接表) 进程调用库函数的 “跳板”,是一段小的跳转代码,每个库函数对应一个 PLT 条目。
GOT(全局偏移表) 存储库函数的实际虚拟地址的表,进程通过 GOT 找到库函数的位置。

延迟绑定的工作流程(默认行为):

  1. 进程第一次调用库函数时,会先跳转到 PLT 条目。
  2. PLT 会去 GOT 里找函数地址,但此时 GOT 里还是占位符,所以 PLT 会把控制权交给动态链接器。
  3. 动态链接器解析这个库函数的实际地址,把地址写入 GOT 对应的条目。
  4. 后续再调用同一个库函数时,PLT 会直接从 GOT 里拿地址,不用再找动态链接器了。

这样设计的好处是:程序启动更快,不用一开始就解析所有库函数的地址,用到的时候再解析(延迟绑定)。


步骤 3:运行时访问(虚拟地址→物理地址的转换)

当进程执行库函数调用时,流程是这样的:

  1. 进程指令执行到库函数调用,跳转到 PLT 条目。
  2. PLT 从 GOT 中拿到库函数的实际虚拟地址(已经在步骤 2 中修正好了)。
  3. CPU 把这个虚拟地址交给 MMU(内存管理单元),MMU 通过页表把虚拟地址转换成物理地址。
  4. 如果这个物理页还没加载(延迟加载),会触发缺页中断,内核把动态库的代码从磁盘加载到物理内存,再更新页表。
  5. CPU 执行物理内存中的库函数代码,完成调用。

图示拆解:进程、虚拟地址、物理内存的关系
  • 左边:进程 A 的虚拟地址空间mm_struct是进程的内存管理结构,里面有代码区、数据区,还有共享区—— 动态库就被映射到这里。进程只能看到虚拟地址,以为动态库是自己地址空间里的一部分。
  • 中间:页表页表是虚拟地址和物理地址的桥梁,把进程的虚拟地址转换成物理地址。多个进程映射同一个动态库时,它们的虚拟地址不同,但页表都指向同一份物理内存页。
  • 右边:物理内存 + 磁盘物理内存里存着动态库的代码页,多个进程共享这一份;磁盘上的.so文件是原始文件,只有第一次访问时才会被加载到物理内存。

进程间如何共享动态库

多个进程同时使用同一个动态库时,物理内存中仅保留一份代码段,数据段通过「写时复制(COW)」实现 “初始共享、修改隔离”,既节省内存,又保证进程间数据互不干扰。下面分两部分拆解:

一、代码段的共享:只读,全程不复制

动态库的代码段(.text)是只读 + 可执行的,运行中不会被任何进程修改,因此可以被多个进程安全共享。

实现过程:

  1. 第一个进程加载库时内核会将动态库文件中的代码段内容,加载到物理内存页(或通过文件缓存直接映射),这是物理内存中唯一的一份库代码。

  2. 后续进程加载同一个库时内核不会重新读取文件、创建新的物理页,而是直接将同一组物理页,映射到新进程的虚拟地址空间中。

关键原理:

  • 多个进程的虚拟地址可以不同(比如进程 A 映射到0x400000,进程 B 映射到0x500000),但它们的页表都指向同一块物理内存
  • 动态库代码是位置无关代码(-fPIC编译),无论被映射到哪个虚拟地址,都能正常执行,因此多个进程共享同一份物理代码页不会冲突。
二、数据段的处理:写时复制(COW),平衡共享与隔离

动态库的数据段(.data.bss)是可写的,如果直接共享物理页,一个进程修改数据会影响其他进程,破坏进程间的数据隔离。因此内核采用「写时复制(Copy-On-Write, COW)」技术解决这个问题。

实现过程:

  1. 初始状态:共享物理页,标记为只读所有进程的数据段页表项,都指向同一组物理页,但这些物理页被标记为只读。此时所有进程共享同一份数据副本,节省物理内存。

  2. 触发写操作:缺页异常 + 复制新页当某个进程尝试修改库中的数据时,CPU 执行写指令会触发缺页异常(因为页是只读的,写操作被禁止)。内核会捕获这个异常,为该进程复制一份新的物理页,把原物理页的数据拷贝到新页中,然后修改该进程的页表项:

    • 指向新复制的物理页
    • 将页的权限改为可写
  3. 后续状态:进程私有副本生效触发写操作的进程,后续修改数据只会操作自己的私有物理页,不会影响其他进程;其他未修改数据的进程,仍共享原来的物理页,直到它们也触发写操作。

三、整体效果总结
类型 初始状态 修改时行为 最终效果
代码段 所有进程共享同一份物理页 不会被修改,无额外操作 物理内存仅保留一份,全程共享
数据段 所有进程共享同一份物理页(只读) 触发写操作时复制新页 修改过的页为进程私有,未修改的页仍共享

这种设计完美平衡了两个目标:

  • 节省物理内存:代码段全程共享,数据段未修改的页也保持共享,避免了 “每个进程一份库” 的内存浪费。
  • 进程数据隔离:修改数据时通过 COW 生成私有副本,进程间数据互不干扰,保证了程序的独立性。

动态链接全过程

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

我们的可执行程序被编译器动了手脚

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到main函数。实际上,程序的入口点是_start,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。

在_start函数中,会执行一系列初始化操作,这些操作包括:

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

路径通常为 /lib64/ld-linux-x86-64.so.2(x86-64 架构)。

本身也是一个共享库,但内核在加载可执行文件时会将其映射到内存,并将控制权交给它。

负责加载所有依赖的共享库、解析符号、执行重定位,最后将控制权转给应用程序。

动态链接的加载过程
内核加载阶段

· 用户执行 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。

Logo

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

更多推荐