【Linux】库的制作和使用(3)ELF&&动态链接

目录

[一 ELF加载与进程地址空间](#一 ELF加载与进程地址空间)

[1 虚拟地址/逻辑地址](#1 虚拟地址/逻辑地址)

[2 重新理解进程虚拟地址空间](#2 重新理解进程虚拟地址空间)

[三 动态链接与动态库加载](#三 动态链接与动态库加载)

[1 进程如何看到动态库](#1 进程如何看到动态库)

[2 进程如何共享库的](#2 进程如何共享库的)

[3 动态链接](#3 动态链接)

[四 总结](#四 总结)


一 ELF加载与进程地址空间

1 虚拟地址/逻辑地址

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

答案:

一个 ELF 程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用 "平坦模式" 进行工作。所以也要求 ELF 对自己的代码和数据进行统一编址,下面是objdump -S反汇编之后的代码

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

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

从 ELF 各个 segment 来,每个 segment 有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外再用详细地址,填充页表。
所以:虚拟地址机制,不光光 OS 要支持,编译器也要支持

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

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

bash 复制代码
$ gcc *.o
$ readelf -h a.out 
ELF Header:
 Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
 Class: ELF64
 Data: 2's complement, little endian
 Version: 1 (current)
 OS/ABI: UNIX - System V
 ABI Version: 0
 Type: DYN (Shared object file)
 Machine: Advanced Micro Devices X86-64
 Version: 0x1
 Entry point address: 0x1060//地址
 Start of program headers: 64 (bytes into file)
 Start of section headers: 14768 (bytes into file)
 Flags: 0x0
 Size of this header: 64 (bytes)
 Size of program headers: 56 (bytes)
 Number of program headers: 13
 Size of section headers: 64 (bytes)
 Number of section headers: 31
 Section header string table index: 30

当代计算机中,起始地址都是从全0开始的

在我们CPU内部,包含很多寄存器,有一种称为EIP,里面保存当前正在执行指令的下一条指令的地址;还有一种叫做IR:保存当前读到的指令;还有一种叫做CR3:是当进程的页表物理起始地址

EIP和CR3 是硬件上下文里最重要、最核心的一个寄存器(硬件上下文 = 当前进程正在使用的 CPU 寄存器全部状态)

CPU执行的第一条指令是谁?

Entry point address就被加载到EIP中

CPU 内部集成了一个硬件地址转换单元 ------MMU (内存管理单元),它的核心功能是根据页表,将虚拟地址转换为物理地址。

程序运行时,操作系统会把程序的入口点地址(Entry point address,虚拟地址)加载到 CPU 的指令指针寄存器EIP中。

EIP里的虚拟地址被交给 MMU,MMU 通过页表将其转换为物理地址。

CPU 根据转换后的物理地址,从内存中读取第一条指令,并将其存入指令寄存器 IR中。

同时,EIP会自动更新为下一条指令的虚拟地址,为取指周期做准备。

在后续的指令执行中,这个流程会循环往复:EIP中始终存放虚拟地址,每次取指前,都会交给 MMU 转换为物理地址,再通过总线访问内存获取指令。

因此,对 CPU 来说,它内部处理的都是虚拟地址;而从内存和总线的角度看,它们只能识别物理地址,无法理解虚拟地址

几个细节:
1 页表初始化:

程序数据的地址转换路径:

ELF文件中的逻辑地址 → 映射到进程的虚拟地址空间 → 最终加载到物理内存的物理地址

2 EIP 如何获取下一条指令的虚拟地址?

CPU 在加载一条指令时,会先解析并计算这条指令的长度

当当前指令被读取到IR (指令寄存器)后,CPU 会执行计算:

当前指令地址 + 当前指令长度 = 下一条指令的起始虚拟地址

计算结果会自动更新到EIP(指令指针寄存器)中,作为下一轮取指的地址

3 虚拟地址空间与 mm_struct 的初始化数据来源

mm_struct 是 Linux 内核中描述进程虚拟地址空间的核心数据结构,它的初始化数据全部来自进程对应的 ELF 文件:

虚拟地址空间的数据区:由 ELF 文件中的多个section(节)合并为segment(段)后映射而来,数据内容直接来自 ELF 文件的真实数据部分。
ELF 文件的两部分构成

(1)管理信息(如程序头表、节头表、段属性等元数据)

(2)真实数据(代码段、数据段、只读数据段等实际内容)

内核数据结构的初始化依据:进程虚拟地址空间、页表等管理结构的初始化配置,全部来源于 ELF 文件的管理信息部分,内核会根据这些信息建立地址映射关系

进程虚拟地址,不仅需要操作系统支持,也需要CPU本身在硬件上支持;比如CR3+MMU+页表,也需要编译器在编译上支持,例如统一编址


三 动态链接与动态库加载

1 进程如何看到动态库

(1)准备工作:

写的 C 程序里的 main 函数,并不是程序真正的入口点。

ELF 可执行文件的真正入口是 _start 函数

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

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

设置堆栈:为程序创建一个初始的堆栈环境。

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

结论:编译器会在你写的代码前后 "动手脚",自动链接 CRT 启动代码,这也是为什么程序不是从 main 直接开始执行的原因

1 编译器会对我们的可执行程序做手脚

2 动态库也是一个独立文件,和ELF共同配合,完成执行(和静态库不一样:静态库会在编译链接阶段直接合并到可执行文件中,成为程序的一部分;而动态库始终保持独立文件,仅在运行时被加载和共享使用)

3 动态库在未来,也是要加载到内存的,甚至要提前加载

(2)

仅将进程自身的代码加载到内存中,程序是无法正常运行的(动态链接场景下)。为了让进程能够调用动态库中的代码,系统需要额外完成以下步骤:

操作系统会将进程依赖的动态库文件加载到物理内存中,并在进程的虚拟地址空间里新增映射条目,把动态库映射到堆与栈之间的共享区域。通过上述映射,进程就能得到动态库在自身虚拟地址空间中的起始地址,后续即可通过地址偏移、符号解析等方式,正常访问和调用库中的代码与数据。

操作系统怎么知道要给进程映射,加载哪个库?怎么知道什么时候给?

start前面的代码的代码即使用来检测的

2 进程如何共享库的

如果存在多个进程,也想使用这个库怎么办?
都用!

动态库也被称为共享库,它的代码段在物理内存中只会被加载一次。多个进程会通过各自的页表,将同一块物理内存映射到自己虚拟地址空间的共享区域中。

动态库本身是代码和数据的集合,其代码段是只读的 ,天然支持被多个进程共享。
每个进程的虚拟地址空间中,都有一份动态库的映射,但它们对应的物理地址是同一块内存

这样就大大节省了内存空间

多进程可以共享同一个动态库 本质是把动态库映射到自己的虚拟地址空间中! 进程就可以找到动态库了!

动态库本身是独立的 ELF 文件 ,由文件内容和文件属性两部分构成。

动态库采用位置无关设计,库内函数的地址以相对于库起始位置的偏移量表示 (而非固定绝对地址)

访问原理:进程只要拿到动态库被映射到自身虚拟地址空间的基地址,加上函数的偏移量,就能计算出函数的实际地址,实现调用

3 动态链接

动态链接(Dynamic Linking)是指程序在编译链接阶段不将依赖库代码复制到可执行文件中,仅记录依赖关系,在程序运行时由操作系统动态加载库并完成地址绑定的链接方式。

动态链接的过程,实际上是边加载,边重定向

动态链接实际上将链接的整个过程推迟到程序加载的过程

库在进程虚拟地址空间对应的起始地址+偏移量=库函数的真实虚拟地址

库可以被映射到进程共享区的任意位置,映射对应的地址和位置无关

细节1:能call调用函数,但是该怎么返回?

函数调用时,CPU 会自动将返回地址压入栈中。

函数内部遵循统一的栈帧管理逻辑,执行完成后,会通过栈中保存的返回地址回到调用点。

因此,只要普通函数能正常返回,库函数的调用与返回流程也完全相同,不存在额外的差异

问题:代码区不是只读的吗?替换库名字,不就是修改代码吗?

数据区是可以被读写的,在数据区单独设计一个GOT表(GOT全称:全局偏移量表,本质也是数据),会把用到的符号,第三方函数....写到表中,中每⼀项都是本运行模块要引用的⼀个全局变量或函数的地址。 函数调用call使用GOT表中的地址。直接覆盖GOT表,代码区不修改,要修改,代码区帮我们查GOT表

编译区用GOT表通过二次跳转(代码段跳转→GOT 表→真实地址),解决问题

为了在不修改只读代码段的前提下,实现动态地址绑定,系统引入了GOT(全局偏移表)

  1. 编译阶段:编译器会生成 GOT 表,将程序用到的所有库函数符号记录其中,并把代码中调用库函数的指令,改为跳转到 GOT 表的对应条目。此时 GOT 表中仅为占位地址。
  2. 运行阶段:动态链接器会将库函数的真实地址写入 GOT 表,后续调用库函数时,会通过 GOT 表进行二次跳转,直接读取真实地址执行。
  3. 关键优势:整个过程只修改可读写的数据段(GOT 表),不修改只读的代码段,既保证了安全性,又实现了动态绑定。

细节:没有加载之前,编译时,也会链接,做了什么?

程序编译阶段的链接,是为后续动态调用做准备的 "预处理" 工作:

编译器会生成 GOT (全局偏移表),把程序用到的所有库函数符号记录到表中。

代码中调用库函数的指令,会被修改为跳转到 GOT 表的对应条目,而非直接调用库函数地址。

此时 GOT 表中存放的是占位地址(如 0),真实地址需要后续填充。

加载后运行时,动态链接,做了什么?

动态链接分为两步:编译阶段通过 GOT 表做预处理,运行阶段再完成地址解析与绑定,即 "磁盘级链接 + 内存动态链接" 的组合

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


四 总结

对比维度 静态链接(Static Linking) 动态链接(Dynamic Linking)
链接时机 编译 / 链接阶段一次性完成 程序运行时(加载阶段 / 运行阶段)完成
库代码处理 将库的目标代码直接复制、合并到可执行文件中 不复制库代码,仅记录依赖关系,运行时再加载
文件形式 库文件为静态库(.a / .lib 库文件为动态库(.so / .dll
可执行文件大小 较大(包含所有用到的库代码) 较小(仅含程序自身代码和依赖信息)
运行依赖 无外部依赖,可独立运行 依赖目标系统中存在对应版本的动态库
内存占用 每个进程都包含一份库代码副本,内存开销高 多个进程可共享同一份库的物理内存副本,内存开销低
更新方式 库更新后,所有依赖它的程序都需要重新编译链接 只需替换动态库文件即可,无需重新编译程序
启动性能 加载时无额外链接工作,启动速度较快 需加载库、解析符号,存在额外启动开销
调用性能 直接调用,无额外跳转,性能略高 通过 GOT/PLT 表间接跳转,存在微小性能损耗
典型应用场景 嵌入式系统、独立发布的工具、对性能要求极高的场景 通用系统程序、大型软件、插件化架构、需频繁更新的组件
  • 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件

  • 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。

  • 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过 GOT 方式进行调用(运行重定位,也叫做动态地址重定位)

相关推荐
CQU_JIAKE4 小时前
4.3【A]
linux·运维·服务器
AI周红伟4 小时前
OpenClaw是什么?OpenClaw能做什么?OpenClaw详细介绍及保姆级部署教程-周红伟
大数据·运维·服务器·人工智能·微信·openclaw
Elastic 中国社区官方博客4 小时前
当 TSDS 遇到 ILM:设计不会拒绝延迟数据的时间序列数据流
大数据·运维·数据库·elasticsearch·搜索引擎·logstash
qing222222224 小时前
Linux中修改mysql数据表
linux·运维·mysql
Alvin千里无风4 小时前
在 Ubuntu 上从源码安装 Nanobot:轻量级 AI 助手完整指南
linux·人工智能·ubuntu
TechWayfarer5 小时前
科普:IP归属地中的IDC/机房/家庭宽带有什么区别?
服务器·网络·tcp/ip
杨云龙UP5 小时前
Oracle 中 NOMOUNT、MOUNT、OPEN 怎么理解? 在不同场景下如何操作?_20260402
linux·运维·数据库·oracle
Amctwd5 小时前
【Linux】OpenCode 安装教程
linux·运维·服务器
KOYUELEC光与电子努力加油5 小时前
JAE日本航空端子推出支持自走式机器人的自主充电功能浮动式连接器“DW15系列“方案与应用
服务器·人工智能·机器人·无人机