Linux库制作与原理(2):理解链接与加载

静态链接

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

静态链接底层核心流程

1. 收集文件 + 拆解静态库

  • 链接器读取所有 .o 文件
  • 拆解静态库 .a

2. 空间与地址分配

  • 按类型合并所有节
    所有 .text(代码)→ 合并成一个大代码节
    所有 .data(数据)→ 合并成一个大数据节
    所有 .bss(未初始化数据)→ 合并
  • 为每个节分配虚拟内存地址
    给合并后的节确定:在内存中存哪里、占多大空间

3. 符号解析

  • 扫描所有 .o 的符号表
  • 未定义符号 (如 printf、你写的函数)绑定到定义符号(函数的实现地址)
  • 找不到定义 → 直接报错

4. 重定位

  • 目标文件里的指令都是相对地址 / 占位地址
  • 链接器根据第二步分配的地址,修正所有指令中的地址
    ◦ 函数调用:call printf → 替换为真实内存地址
    ◦ 变量访问:直接指向内存位置

收尾:生成可执行 ELF

链接器按内存权限 将 Section 打包成 Segment,写入程序头表(内核加载用),输出纯静态可执行文件

验证符号解析

objdump -d 文件名:将代码段(.text)进行反汇编查看

总结:

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

ELF加载与进程地址空间

虚拟地址/逻辑地址

一个ELF程序,在没有被加载到内存的时候,本来就有地址。当代计算机使用平坦模式 (连续无分段的统一地址空间),因此 ELF 文件必须将代码和数据放在同一个连续空间内统一编址 。ELF 文件内部的相对地址、偏移、重定位基准,全部从 0 开始计算

最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们认为起始地址是0

进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?

答:从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的start, end等范围数据,另外在用详细地址,填充页表

编译器负责:把虚拟地址「写进 ELF」,操作系统负责:把 ELF 里的虚拟地址「映射进内存」。所以:虚拟地址机制,不光光OS要支持,编译器也要支持

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

ELF 在被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry字段中:

补充:ELF程序是如何转换成为进程的逻辑地址、物理地址、虚拟地址的?

  • 逻辑地址 :ELF 文件内部的原始偏移地址 ,平坦模式下从 0 开始统一编址,也叫文件偏移 / 相对地址
  • 虚拟地址 :进程独立拥有的平坦虚拟地址空间地址 ,用户程序、CPU 执行时唯一能看到的地址。→ 存在于进程运行时,由操作系统管理
  • 物理地址 :真实内存条的硬件地址,用户进程完全不可见,由内核 + CPU 的 MMU 单元管理。→ 存在于物理硬件中

磁盘上的 ELF 用逻辑地址

ELF 文件在磁盘上时,所有代码、数据的地址都是平坦模式 0 基址的逻辑地址

  • 目标文件 .o:纯逻辑地址,从 0x00 开始
  • 可执行 ELF:链接器已经完成重定位,逻辑地址被绑定到固定的虚拟地址基址
  • ELF 的 程序头表(Program Header) 记录了:逻辑地址偏移 + 虚拟地址

链接阶段 → 逻辑地址 → 虚拟地址

连接器把 ELF 中 0 基址的逻辑地址,映射到进程虚拟地址空间的固定位置:

合并所有 .o 的逻辑地址,为整个 ELF 分配虚拟地址基址 (如 0x400000),完成地址重定位逻辑地址 → 虚拟地址,将最终虚拟地址写入 ELF 程序头表

加载阶段 → 虚拟地址 → 进程地址空间映射

当你执行 ./main,内核完成:创建进程,分配独立的平坦虚拟地址空间 ;读取 ELF 程序头表,通过 mmap 建立映射:磁盘 ELF 段进程虚拟地址。此时程序拥有了完整的虚拟地址,但还没有物理地址

运行阶段 → 虚拟地址 → 物理地址

CPU 执行指令时,由 MMU(内存管理单元) 完成最终转换:

  1. CPU 发出 虚拟地址
  2. MMU 查询进程页表
  3. 两种结果:
    命中 :直接转换为物理地址 ,访问内存条
    缺页异常:内核将 ELF 数据加载到物理内存,更新页表,再转换

总结:

动态链接与动态库加载

动态库是如何和我们的可执行程序关联

进程看不到磁盘上的动态库文件,也看不到物理内存 ,仅能识别自己的虚拟地址空间。动态链接器 + 内核通过 mmap,将磁盘上的动态库映射到进程的虚拟地址空间(mmap 区域) ,并创建对应的 vm_area_struct 挂载到 mm_struct。运行时对进程而言,动态库就是自身虚拟地址空间中一段合法的、可直接访问的代码 / 数据区域。

进程间如何共享库的

  • 进程间共享动态库,物理内存只存储一份(内核页缓存中),所有进程复用
  • 每个进程拥有独立的虚拟地址、独立的 mm_struct/VMA、独立的页表,严格保证进程隔离
  • 内核通过 mmap 私有映射,将不同进程的虚拟地址 映射到同一块物理内存,实现共享
  • 只读代码段永久共享,可写数据段通过写时复制保证隔离

动态链接

动态链接是程序运行时 ,由动态链接器 将主程序中未确定的函数 / 变量符号 ,与动态库(.so)中的真实虚拟地址完成绑定的过程

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

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

ldd 是 Linux 系统自带的查看动态链接程序 / 动态库依赖关系的工具

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

_start 会完成所有初始化工作:

  • 设置栈、堆、寄存器清零 :搭建 main 运行的底层环境
  • 初始化数据段 :将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零 .bss
  • 启动动态链接器:动态链接器会查找并加载所有依赖库,完成符号解析、重定位
  • 调用 __libc_start_main :负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。最终调用 main(),接收 main 的返回值
  • 处理返回值,退出进程main 执行完毕,返回 0 给 __libc_start_main,函数返回给 _start_start 调用系统调用 exit(0),内核回收进程资源(task_structmm_struct、物理内存)

动态库中的相对地址

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法统一编址。动态库的相对地址 = 相对于「当前指令位置」的偏移量

我们的程序,怎么和库具体映射起来的

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

我们的程序,怎么进行库函数调用

  • 内核通过 mmap 将库映射到进程虚拟空间的起始虚拟地址库内固定偏移量在动态库编译时生成,永久写死在 ELF 文件中
  • 所以,访问库中任意方法,只需知道动态库的起始虚拟地址 + 方法的固定偏移量即可定位库中的方法。而且,整个调用过程,是从代码区跳转到共享区,调用完毕再返回到代码区,整个过程完全在进程地址空间中进行的

我们的程序运行之前,动态库被内核 mmap 映射到随机虚拟基地址 后,动态链接器执行的核心操作:根据 实际加载基地址 + 符号固定偏移量,计算出函数 / 变量的真实虚拟地址 ,并修正地址引用。(这就是加载地址重定位

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

全局偏移量表 GOT(Global Offset Table)

GOT 的本质 :ELF 文件数据段 中的一个可写数组

作用:专门存储动态库中函数 / 变量的「真实虚拟地址」

为什么要有 GOT?

  • 动态库代码段(.text)是只读 + 多进程共享
  • 绝对不能在运行时修改代码段里的地址
  • 必须单独开辟一个可写的数据段(GOT),存放重定位后的真实地址
  • 程序通过间接读取 GOT拿到地址,不破坏代码段
  • 所有进程的 GOT 独立(虚拟地址不同),但指向同一块物理库代码
  • 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表
  • 这种方式实现的动态链接就被叫做 PIC 位置无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享。这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC = 相对编址 + GOT

PLT(过程链接表)

本质:一段存放在代码段(.text)的只读汇编跳板指令集合

两个既定规则

  1. 全局变量:程序启动时就完成重定位
  2. 函数 :如果启动时一次性重定位所有函数,会严重拖慢程序启动速度

Linux 的解决方案:延迟绑定(Lazy Binding) ,函数第一次被调用 时,才去计算真实地址、填充 GOT;不调用的函数,永远不做重定位。PLT 就是实现「延迟绑定」的唯一硬件 / 软件载体

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

工作逻辑

  • 第一次调用 :PLT 当跳板,触发动态链接器计算地址 + 填充 GOT
  • 后续调用:PLT 直接从 GOT 取真实地址,秒跳执行

库间依赖

库间依赖 = 一个动态库(.so)本身,又依赖了其他动态库

  • 动态链接器 /lib64/ld-linux-x86-64.so.2库间依赖的唯一管理者,它的工作不是只加载程序的直接依赖,而是递归遍历所有依赖
  • 动态库也是PIC 位置无关代码
  • 动态库调用其他库函数,同样用 PLT 跳板 + GOT 存地址
  • 动态库也有自己的重定位表、符号表
  • 动态库的加载、重定位、寻址规则,和程序100% 相同

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

总结

  • 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
  • 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
  • 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。
相关推荐
Cat_Rocky1 小时前
Gitlab安装与配置
linux·运维·gitlab
志栋智能1 小时前
超自动化巡检:降低运维总成本(TCO)的有效路径
大数据·运维·网络·人工智能·自动化
爱讲故事的1 小时前
操作系统第一讲复习:为什么学习操作系统,以及操作系统到底在做什么?
linux·开发语言·windows·学习·ubuntu·c#
荒--1 小时前
kali安装与下载、设置(2026)
linux·服务器
Yang96111 小时前
一站式网络检测 鼎讯信通网络综合测试仪科普
运维·服务器·网络·能源
越强越不秃2 小时前
大模型驱动的PoC脚本自动化生成:从挑战到实践
运维·自动化·安全工程师
sulikey2 小时前
个人Linux操作系统学习笔记4 - makefile
linux·makefile·make·构建
_童年的回忆_2 小时前
【php】在linux下PHP安装amqp扩展
linux·开发语言·php
sxlishaobin2 小时前
linux 自动清除日志 脚本
linux·服务器·前端