一、动态链接与动态库加载
进程如何看到动态库?

进程间如何共享库的?

静态链接
本质是把库中相关代码,拷贝到你的程序当中。
- 静态链接 = 打包
你写了 main 函数,用到了 printf、sqrt 等函数。静态链接就是:把你的代码 + 库函数代码 → 打包成一个单独的 exe
- 打包完,不依赖任何外部文件
发给别人,直接能跑。不会提示 "缺少 xxx.dll"。
- 缺点就是:文件大、浪费空间
因为每个程序都把函数复制了一份。
- 静态链接:编译时打包
- 动态链接:运行时调用
- 静态链接:文件大、不依赖外部
- 动态链接:文件小、依赖库

静态库有没有加载的过程?
答:静态库没有运行时的加载过程,它的链接工作在程序加载到内存之前就已经完成了。链接早于加载,加载时无需处理静态库。
动态链接
进程创建的时候,先创建PCB,再加载程序,动态链接的程序,加载的时候,要先找到对应的库,再加载程序。
动态库也叫共享库,资源和代码数据,多个进程需要用到的资源,只需要在内存中形成一份。
那么系统怎么知道有些库已经加载了?
有很多的库加载到内存中,os要不要管理这些已经加载的库呢?
《先描述,再组织》~
动态链接实际上将链接的整个过程推迟到了程序加载的时候。
动态库是没有main函数的,动态库内部,包含了大量的方法,每一个方法都要有自己的地址。
只要知道库被加载到的起始虚拟地址,加上编译时就确定的函数偏移量,就能精确得到库中任意函数在当前进程中的虚拟地址。

动态库中的起始虚拟地址+方法在库中的偏移量==该方法(函数)在当前进程虚拟地址空间中的虚拟地址。
在程序运行期间,程序和库才慢慢关联的。这就叫懒加载,
问题:我们的程序是如何和库进行映射的?
第一阶段:进程启动与 ELF 解析
动作: 用户敲下 ./a.out,Shell 调用 execve() 系统调用。
内核行为:
- 销毁与重建:内核销毁当前进程的旧内存映射(如果有),清空页表。
- 读取 ELF 头 :内核代码 (
fs/binfmt_elf.c) 解析可执行文件的 ELF 头。 - 建立 VMA(虚拟内存区域) :
- 内核遍历 ELF 文件的
PT_LOAD段(代码段、数据段)。 - 在进程的
mm_struct结构体中创建vm_area_struct(VMA) 对象。此时,只是在内核数据结构里划了"虚拟地盘",还没有分配物理内存,也没有读取文件内容。 - 关键映射 :内核发现
.interp段,里面写着动态链接器的路径(如/lib64/ld-linux.so.2)。
- 内核遍历 ELF 文件的
第二阶段:映射动态链接器
动作: 内核需要先让动态链接器跑起来,它才能加载别的库。
内核行为:
- 映射 ld-linux.so:内核像处理可执行文件一样,将动态链接器的代码段和数据段映射到进程的虚拟地址空间。
- 栈的准备:内核为进程分配栈空间,并把命令行参数、环境变量压入栈。
- 移交控制权 :内核将 CPU 的指令指针寄存器(RIP)指向动态链接器的入口点
_start,进程第一次在用户态开始执行代码。
第三阶段:动态链接器请求映射库
动作: 动态链接器(运行在用户态)分析依赖,发现需要 libc.so。
内核行为:
- 系统调用
open+mmap:动态链接器调用mmap系统调用,请求内核映射libc.so。 - 内核处理
mmap:- 内核在进程的虚拟地址空间寻找空闲区域。
- 创建新的
vm_area_struct,记录这段虚拟地址的权限(如r-x可读可执行)。 - 建立文件关联 :内核将这个 VMA 与磁盘上的
libc.so文件关联起来(通过file结构体和address_space结构体)。 - 此时依然没有加载代码到物理内存,只是建立了"虚拟地址 <-> 磁盘文件块"的逻辑映射关系。
第四阶段:缺页中断------ 真正的物理映射
这是最核心的步骤,解释了"怎么读进内存"。
动作: 动态链接器尝试读取 libc.so 的头部信息,或者程序开始调用 printf。
内核行为:
- 触发缺页中断 :
- CPU 访问虚拟地址(比如
0x7f...1000)。 - MMU(内存管理单元)查页表,发现对应的物理页不存在(Present Bit = 0)。
- CPU 触发异常,陷入内核态。
- CPU 访问虚拟地址(比如
- 内核处理中断 :
- 内核发现这个虚拟地址对应一个映射到文件的 VMA。
- 物理页分配:内核分配一个空闲的物理页帧。
- 磁盘 I/O :内核启动磁盘驱动,从
libc.so文件对应的磁盘块读取数据,写入刚分配的物理内存。 - 页表映射 :内核更新进程的页表,建立映射:
虚拟地址 -> 物理地址。
- 返回用户态:CPU 重新执行刚才那条指令,这次能成功读到数据了。
第五阶段:符号解析与重定位
动作: 库已经在内存了,动态链接器需要修正函数调用地址。
内核行为(被动参与):
- 计算地址 :动态链接器读取
libc.so的符号表(这也是内存访问,触发缺页中断读入)。 - 修正 GOT 表 :
- 公式:
真实地址 = 基址(vm_start) + 偏移。 - 动态链接器将计算出的
printf真实地址,写入程序的 GOT 表(全局偏移表)。 - 写时复制:GOT 表位于数据段。当链接器第一次修改 GOT 表时,内核会触发"写时复制",为该进程分配一个私有的物理页,拷贝原始数据,然后在私有页上修改。这保证了其他进程看到的库数据段没有被污染。
- 公式:
第六阶段:运行时调用
动作: 程序调用 printf。
内核行为(硬件层面):
- 查表跳转:程序指令跳转到 PLT -> GOT,拿到之前填好的虚拟地址。
- 硬件寻址 :
- CPU 拿着虚拟地址发给 MMU。
- MMU 通过之前缺页中断建立的页表,迅速翻译成物理地址。
- CPU 从物理内存取指执行。
首先,加载动态库要先加载内核结构,动态库,最后是代码和数据。
全局偏移量表 GOT

1、这张图完整展示了Linux动态链接库(如libc.so)从磁盘加载到进程虚拟内存并实现函数调用的完整流程,具体步骤如下:
2、首先内核通过ext2_inode和EXT2_N_BLOCKS找到磁盘上libc.so的文件数据块,完成库文件的定位与加载;
3、接着将库文件加载到物理内存后,通过vm_area_struct和页表将库的代码与数据映射到进程的共享虚拟地址空间,得到库在当前进程中的起始虚拟地址;
4、随后动态链接器更新.got(全局偏移表),将编译时预先保存的函数在库内的偏移量与库的真实基地址相加,改写为函数在内存中的最终虚拟地址;
5、最后程序执行时通过查表调用.got中的地址,实现对动态库函数(如puts)的跳转执行,整个过程依托位置无关码(PIC)与动态链接机制,让程序在不确定库加载位置的情况下仍能正常运行。
由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不 同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的 每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以用CPU的相对寻址来找 到GOT表。
3. 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会 被修改为真正的地址。
4. 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修 改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给 编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
.Got里面存的是指针,指向的是动态库里的函数/变量在内存里的真实入口地址。.got存在的意义是:让程序在不知道动态库会被加载到哪的情况下,依然能找到并调用函数。
.got类似于一个中转站,它里面放的是指针,系统加载程序时,把真实地址填写进.got,你调用函数时,直接去.got里面取地址。