【Linux】ELF与库加载

1、ELF格式的基本概念

1.1 编译的完整流程

.c源代码经过预处理,再经过编译生成汇编.s,最后汇编生成可重定位目标文件.o

.o全称:可重定位目标文件,不能直接运行,必须经过链接器链接

1.2 ELF的概念

ELF是 Linux 下标准的二进制文件格式,适用于可执行程序、目标文件(.o)、静态库(.a)和动态库(.so)

ELF是可执行可链接格式,Linux下二进制统一格式

核心宏观原理:.o、可执行程序、动静态库不是随意放在一起的二进制,它们有自己的结构和格式,它们全部都是ELF格式。所以链接器可以把多个ELF内部同类型区域合并,组装成更大的ELF文件

1.3 ELF四个组成部分

  1. ELF Header ELF 头部

文件最开头,固定大小。记录整个ELF文件全局信息,通过指令readelf -h hello.o 查看ELF头部的内容

!屏幕截图 2026-08-18 163857.png

ELF头部内容包含文件类型、硬件架构、entry point address程序入口虚拟地址(CPU 第一条指令从这里开始执行)、program header table 在文件中的偏移、大小、条目数量section header table在文件中的偏移、大小、条目数量

  1. Program Header Table(程序头表)

使用命令 readelf -l 查看程序头表,其中包含Section to Segment Mapping用来说明哪些节被合并到那个段

其中共包含9个段,每个段中包含一个一个的节

  1. Section Header Table(节头表)

使用命令readelf -S 查看节头表

通过节头表可以了解各节的名称、类型、偏移量、大小及权限等,用来定位文件里面每一个节在哪里

  1. Section(节)

节(section)和段(segment):节是编译时的概念,如 .text(代码)、.data(已初始化全局变量)、.bss(未初始化全局变量)、符号表、字符串表等,段是加载时的概念,由链接器将具有相同权限的多个节合并而成,以减少内存页浪费,所以ELF中没有一个一个的段,ELF存在一个一个的节,不同的节大小不一,权限可以有相同的,多个相同权限的节加载的时候由加载器帮我们进行节合并

合并的目的:内存以4KB页为单位分配,把权限相同的多个小section合并成一个segment,减少内存页的数量,节省内存

可使用objdump -s ,可反汇编并查看各节的具体内容

2、程序加载原理

2.1 创建进程顺序

进程创建时,先由内核创建PCB、虚拟地址空间、页表等内核数据结构,再加载ELF程序内容

  1. 先创建PCB、mm_struct、页表
  2. 再把磁盘ELF可执行程序信息
  3. 支持懒加载,不会一次性全部读磁盘,访问哪一块才加载哪一块

2.2 ELF提前会存好地址

链接器在编译结束时,就已经给每一行代码,全局变量定好了虚拟地址,直接写进磁盘上的ELF文件

很早的CPU没有虚拟内存,程序内部只写偏移,真正的物理内存地址 = 段寄存器的值 + 偏移,也叫逻辑地址,程序不能随便使用整片内存,现代Linux64位平坦模式中每个进程都有一整片完整的虚拟地址空间

完整加载流程:

  1. 内核先搭好架子

创建task_struct,mm_struct和全新空页表,mm_struct中是空的

  1. 打开磁盘ELF文件,只读头部信息

ELF中有两套头:

  • Program Header给操作系统加载器看的,内核只用这个。用来告诉操作系统哪一块内容要映射到哪个虚拟地址
  • Section Header:给编译器、调试工具 readelf 看的。程序运行加载的时候,内核直接无视这部分

读取开头ELF Header,从这里找到Program Header Table 在磁盘中的位置,遍历Program Header ,记录每行代码的内容要映射到进程的哪个虚拟地址,然后把mm_struct填写完整

  1. 建立映射:虚拟地址和磁盘文件物理地址的映射

内核操作页表建立关系,例如虚拟地址 0x400000 对应磁盘文件偏移 100 字节的地方

  1. 获取程序入口地址

程序的入口_start由ELF文件提供,作为CPU开始执行的第一条指令的虚拟地址

  1. CPU启动执行

CR3寄存器:每个进程都有自己一套独立页表;当进程切换的时候,操作系统会修改 CR3 寄存器的值,CR3 里面存放的就是当前正在运行进程的页表在物理内存中的地址,进程切换本质动作之一:修改 CR3,指向新进程的页表

MMU:专门做地址翻译,将虚拟地址翻译成物理地址,地址翻译是硬件自动完成,不是操作系统软件做查表,操作系统只负责提前把页表建好放在内存

在main函数中调用普通函数流程:

例如在main()中调用我们自己写的func()

汇编:

nasm 复制代码
call   0x666   ; 0x666就是func函数的虚拟地址,编译链接时已经算好写进指令
  1. CPU 在 main 函数里执行 call 0x666 指令
  2. CPU 输出目标虚拟地址:0x666(func 函数虚拟地址)
  3. 把虚拟地址交给 CPU 内部硬件 MMU
  4. MMU 读取CR3 寄存器,拿到当前进程页表的物理地址
  5. MMU 硬件遍历多级页表做翻译
  6. 输出物理地址
  7. 访问内存条

2.3 动态库加载共享原理

动态库.so加载到进程虚拟地址空间的哪一块?

在虚拟地址空间中有一块共享库映射区,程序的代码段执行call指令,跳转到共享区里面库函数的虚拟地址,库函数执行完再跳转回来,完成库函数调用

动态库本质也是磁盘上的 ELF 文件,加载分两步:把磁盘上的.so库文件内容读到物理内存,然后映射,把物理内存映射到虚拟地址空间的共享区

3、动静态链接

3.1 程序的真正入口

程序的真正入口不是main,可执行ELF头部有字段entry point address ,这是程序的入口

Linux下入口是_start_start 函数由C标准库提供,在程序启动时首先执行,负责初始化环境,然后调用man函数

执行流程:_start → 调用库内启动函数 → 初始化环境、处理argc/argv/env → 调用我们写的main()

3.2 CPU指令集

CPU执行的是二进制指令,每条指令包含操作码和操作数,指令本身也记录了其长度

汇编语言就是机器指令的文本别名映射,CPU通过程序计数器(PC)指向下一条要执行的指令,当执行完当前指令后,PC会根据当前指令的长度自动递增,执行下一条命令

编译完整链路:xxx.c → 预处理 → 编译生成汇编.s → 汇编器汇编生成目标文件.o.o 是二级制ELF,但不能直接运行,需要链接库

3.3 静态链接

静态链接的核心步骤:

  • 合并与拷贝 :链接器将所有输入的.o文件(包括来自静态库.a里面用到的.o)中的代码节(.text)和数据节合并,拷贝到最终的可执行文件中
  • 统一编址:对合并后的所有代码和数据进行统一的虚拟地址分配,使每个函数和变量都有确定的地址
  • 地址重定位 :链接器扫描所有.o文件中的重定位表,找到那些调用地址为0的位置,并根据符号表查找到目标函数在新可执行文件中的实际地址,将其填入call指令之后,完成地址修正

静态库没有"运行时加载"的步骤,库代码会拷贝进可执行文件磁盘里

链接器ld做两件事:

节合并和空间分配:收集所有输入的.o ,所有.o.text 代码节合并成可执行程序的段,节合并后每一段分配虚拟地址

重定位:记录哪些指令位置需要改,要修改那个符号的地址

3.4 动态链接

不拷贝库代码,库是磁盘上独立.so 文件,链接工作推迟到程序运行加载时做,依靠PIC位置无关码和GOT跳板表实现多进程共享同一份库代码

库的代码段物理内存只存一份,多个进程页表映射到同一块物理内存,实现共享,节省磁盘空间和内存

但库的数据段每个进程都会有自己副本,不能共享

GOT是每个进程的虚拟地址空间,每个进程都有自己的GOT,进程之间不能共享GOT

程序启动流程:

  1. 程序的入口是_start函数,执行_start后先搭建环境,初始化栈和数据段等,调用动态链接器
  2. _start调用__libc_start_main初始化信号、多线程环境
  3. __libc_start_main调用用户写的main()函数
  4. main 返回之后回到__libc_start_main,调用_exit()结束进程

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

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

动态库的相对地址:

静态库是编译时直接把代码拷贝进你的程序。

动态库.so是运行时才加载,操作系统可以把这个库映射到进程虚拟地址空间任意位置,每次运行加载地址都可能不一样,运行动态库时全部采用相对编址,所有访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法

那么调用库时地址怎么填?

调用库需要把程序里调用printf、malloc这些库函数的地方填上这次运行真实的函数地址,这个过程叫加载时地址重定位

但代码段.text是只读的,不能直接修改机器指令,不能直接改代码里的跳转地址,所以不在只读代码段改,在可读写的数据段开辟一张表:GOT 全局偏移表,GOT 表里每一项是 8 字节(64 位),专门存外部函数 / 全局变量本次运行真实虚拟地址

调用库的流程:

  1. 代码段(只读)里的指令:不去直接跳printf真实地址,而是跳转到GOT 表的某一项
  2. 程序刚加载完:GOT 里面还没填真实地址。
  3. 动态链接器,把printf本次运行真实内存地址,写到 GOT 表对应的表项里面(因为 GOT 可读写,可以修改)。
  4. 之后程序执行,跳转到 GOT,读取表里面已经填好的真实地址,再跳过去执行函数

动态库的相对地址:

静态库是编译时直接把代码拷贝进你的程序。

动态库.so是运行时才加载,操作系统可以把这个库映射到进程虚拟地址空间任意位置,每次运行加载地址都可能不一样,运行动态库时全部采用相对编址,所有访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法

那么调用库时地址怎么填?

调用库需要把程序里调用printf、malloc这些库函数的地方填上这次运行真实的函数地址,这个过程叫加载时地址重定位

但代码段.text是只读的,不能直接修改机器指令,不能直接改代码里的跳转地址,所以不在只读代码段改,在可读写的数据段开辟一张表:GOT 全局偏移表,GOT 表里每一项是 8 字节(64 位),专门存外部函数 / 全局变量本次运行真实虚拟地址

调用库的流程:

  1. 代码段(只读)里的指令:不去直接跳printf真实地址,而是跳转到GOT 表的某一项
  2. 程序刚加载完:GOT 里面还没填真实地址。
  3. 动态链接器,把printf本次运行真实内存地址,写到 GOT 表对应的表项里面(因为 GOT 可读写,可以修改)。
  4. 之后程序执行,跳转到 GOT,读取表里面已经填好的真实地址,再跳过去执行函数
相关推荐
WarPigs44 分钟前
Unity未激活的物体无法响应事件?
java·unity·游戏引擎
十五喵源码网1 小时前
基于SpringBoot2+vue2的公寓报修管理系统
java·毕业设计·springboot·论文笔记
珍珠先生1 小时前
第16章 集合框架:List 与 Set
java
梦诺1 小时前
LINUX定时执行脚本
linux·运维·服务器
Dovis(誓平步青云)1 小时前
心情日记的安全编辑流程:输入校验、删除确认与状态回收
android·运维·服务器·开发语言·windows·安全
wazmlp0018873691 小时前
控制器管理
linux·运维·服务器
薛定猫AI1 小时前
【深度解析】DeepSeek Harness:可插拔智能体运行时如何连接大模型与真实计算机
java·运维·网络
学linux的QQ蛋1 小时前
Linux 文件 IO:系统调用 open/read/write 完整总结
linux·运维·算法
rannn_1111 小时前
【力扣hot100】回溯专题|全排列、子集、字母组合、组合总和、括号生成、单词搜索、分割回文串、N皇后
java·算法·leetcode·回溯