Linux ELF文件加载与内存管理揭秘

ELF

ELF (Executable and Linkable Format,可执行可链接格式) 是 Linux‑Unix 体系规定的一种二进制文件规范

静态库,可执行程序,.o文件都是ELF格式的

ELF 的作用

① 编译阶段

.c → 编译器输出 ELF 格式 .o 目标文件,保存代码、变量、符号

② 链接阶段

ld 链接器读取多个 ELF 目标文件

  1. 合并同名 section

  2. 解析函数符号、修补重定位地址

  3. 打包生成 Segment 内存段

  4. 产出最终 ELF 可执行文件

如果没有 ELF 标准,链接器不知道哪里是代码、哪里是变量

③ 程序加载(execve)

Linux 内核解析 ELF 程序头表 依照 Segment 信息,为进程分配虚拟内存、映射代码和数据,搭建独立虚拟地址空间

节(Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。

最常见的节:

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

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

链接器是按照节 (section) 名称、属性归类合并

ELF形成可执行

  • step-1:将多份C/C++源代码,翻译成为目标.o⽂件 + 动静态库(ELF)

  • step-2:将多份.o⽂件section进行合并,形成可执行

ELF文件的加载
  • ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment

  • 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等

  • 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起

  • 很显然,这个合并工作也已经在形成 ELF 的时候,合并方式已经确定了,具体合并原则被记录在了ELF的程序头表(Program header table) 中 ------> 用于ELF加载

区分两个概念

  • Section(节):属于编译‑链接阶段,按名字、属性归类合并

  • Segment(段):链接后期,链接器把多个属性相近的节打包成一个内存段,给到操作系统用来内存映射(如纯代码和只读的数据区)

为什么要将section合并成为segment

  • Section合并的主要原因是为了减少**⻚⾯** 碎**⽚** ,提⾼内存使⽤效率。如果不进**⾏** 合并, 假设**⻚⾯⼤⼩** 为4096字节(内存块基本**⼤⼩** 内存页4KB,加载,管理的基本单位),如果.text部分 为4097字节,.init部分为512字节,那么它们将占**⽤** 3个**⻚⾯** ,**⽽**合并后,它们只需2个页面

  • 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的 segment,这样就可以实现不同的访问权限,从**⽽**优化内存管理和权限访问控制(与页表有关)

先拆解这张图 + 厘清核心原理
1、页表 就是 虚拟地址 → 物理内存 地址 的映射桥梁

MMU硬件查询当前进程的页表,把虚拟地址转换成物理地址,访问真正的物理内存。

2、解读图里 fork() 创建子进程全过程(写时拷贝COW)
  1. 父进程拥有一套独立 mm_struct(进程地址空间) + 专属页表

父进程全局变量 g_val 的虚拟地址,通过父进程页表,映射到一块物理内存。

  1. 调用 fork() 生成子进程

    此时父子两个进程的虚拟地址,指向同一块物理内存

    1. 子进程新建自己的 task_struct、全新的 mm_struct

    2. 子进程页表最开始直接复制父进程的页表项

    3. 并且内核会把这块物理页设置成 只读权限(COW写时拷贝)

3、发生修改的时候才拷贝物理页面
  • 父进程 / 子进程任意一方准备写入 g_val

  • MMU检查页表权限:页面只读 → 触发缺页中断

  • 操作系统内核:复制一份全新的物理内存副本

  • 修改触发写入的那一方的页表,映射到新物理页;另一方依旧指向旧页面

这就是图上红蓝两条线:写入之后父子g‑val虚拟地址相同、映射两块独立物理内存

4、关键知识点,帮你打通和刚刚权限控制的联系
  1. 每一个进程都有独立一套页表

两个进程可以拥有一模一样的虚拟地址,但是页表映射到不一样的物理页。

  1. 页表项PTE同时干两件事

    1. 保存虚拟→物理地址映射

    2. 存放页面读写、执行、用户态权限位(就是你刚刚问的访问控制)

  2. fork写时拷贝就是修改页表权限实现的

初始共享页面标记只读;一旦写操作触发异常,再复制物理页、重置页表映射

总结

虚拟地址只是进程内部的编号;

真正去哪里找物理内存,全部由当前进程的页表说了算;

fork不会立刻复制庞大的物理内存,只复制页表,依靠页表权限拦截写入、延迟拷贝。

5、区分几个结构体
  • task_struct:PCB进程控制块,进程本体

  • mm_struct:描述整个进程虚拟地址空间(代码段、data、bss、堆、栈)

  • 页表:属于mm_struct下属,负责虚拟地址→物理地址映射、权限管控

如果你需要,我可以给你梳理一遍fork‑写时拷贝完整时序流程。

第一步:连接器合并 Section(节)
复制代码

code1.o(.text) + code2.o(.text) + main.o(.text) → 拼接 = 全新唯一 .text‑Section

这一步只是把所有目标文件同名的代码节拼合成单独一块 .text 节 现在仅仅只是一个 Section

第二步:打包成 Segment(程序段)

链接器后续做打包: 权限:可读、可执行 (r‑x)

复制代码

Segment(r-x) ├─ 合并之后的 .text(机器指令) └─ .rodata(只读字符串常量)

结论
  1. 多个 .o 的 .text 合并 → 得到 1 个 .text 节 (Section)

  2. 之后链接器把权限相同的多个 Section 打包进同一个 Segment

    1. 所以一个 Segment 里面可以装:.text + .rodata
  3. 关系:

复制代码

多个旧Section(各个o文件的.text) →合并→1个新的.text Section →和别的同权限Section打包→归属到一个Segment

内核创建虚拟地址空间完整流程

步骤 1:创建进程,生成进程 PCB (task_struct)

Linux 创建新进程,生成 task_struct,里面存放该进程专属的虚拟内存结构体 mm_struct,代表这个进程独立完整的虚拟地址空间。 此时地址空间是空的,没有任何代码、数据内存映射。

步骤 2:内核读取磁盘上 ELF 可执行文件,解析 Program Header(只读取程序段表,忽略 section 节头表)

内核循环遍历每一条 Program‑Header:

  1. 判断类型是不是 PT_LOAD(需要加载进内存的段)

  2. 获取参数:

    1. p_vaddr:该段起始虚拟地址

    2. p_filesz:磁盘文件长度

    3. p_memsz:内存占用总大小

    4. p_flags:内存权限 可读、可写、可执行

  3. 内核调用内存映射函数,在当前进程 mm_struct 虚拟地址空间里面,申请对应大小的内存区间

步骤 3:分段映射

  1. 对于代码段 (.text、.rodata): 把磁盘 ELF 对应位置的数据,拷贝到 p_vaddr 起始的虚拟内存,设置权限 r‑x

  2. 对于 .data: 拷贝磁盘初始化数据到虚拟内存

  3. 对于 .bss: 磁盘文件没有存储内容,内核直接把 p_vaddr + filesz 往后剩下的 memsz‑filesz 内存清零,作为未初始化全局变量空间

步骤 4:mm_struct 结构体维护全部内存区域

mm_struct 内部存放链表 vm_area_struct,每一个结点对应一段虚拟内存区间,记录起始地址、结束地址、读写执行权限。 至此进程的代码段、数据段、堆、栈等全部虚拟内存区域初始化完毕。


3、内核怎么知道各个段分配多大?

  1. 大小不是操作系统计算出来的,是静态链接器 ld 在编译链接阶段提前计算好,写进 ELF Program Header 的 p_memsz 字段

  2. 操作系统只是读取该字段,向进程虚拟地址空间申请对应尺寸内存

4、Section 和 Segment 在加载阶段的地位区别

  1. Section (.text/.data):仅供编译器、链接器、gdb 调试使用;程序加载阶段内核完全看不见、也不关心节

  2. Segment (Program Header):专门给操作系统加载器使用,是内核唯一的参考依据

ELF 的作用,分阶段说明

① 编译阶段

.c → 编译器输出 ELF 格式 .o 目标文件,保存代码、变量、符号

② 链接阶段

ld 链接器读取多个 ELF 目标文件

  1. 合并同名 section

  2. 解析函数符号、修补重定位地址

  3. 打包生成 Segment 内存段

  4. 产出最终 ELF 可执行文件

如果没有 ELF 标准,链接器不知道哪里是代码、哪里是变量

③ 程序加载(execve)

Linux 内核解析 ELF 程序头表 依照 Segment 信息,为进程分配虚拟内存、映射代码和数据,搭建独立虚拟地址空间

④ 调试

gdb 读取 ELF 的节头表、符号表、调试节,可以查到函数名、变量名、行号,进行断点调试

⑤ 动态链接

ELF 格式规定动态段结构,可以运行时加载 .so 动态库、绑定函数地址

流程回顾

C 源码 → ELF‑o 目标文件 → ld 合并 section、打包 Segment → ELF 可执行文件 → 内核读取 Segment 搭建虚拟内存 → 运行

Symbol Table(保存偏移量)
1、先搞懂 .symtab(符号表)和 .strtab(字符串表)的存储结构
  1. .strtab 字符串表:就是一大块字符数组,存放所有符号名字符串
复制代码

"helloworld\0func\0libc\0a\0obj\0"

\0 当作每个名字的分隔符

  • "helloworld" 起始下标偏移 = 0

  • "func" 起始下标偏移 = 11

  • "libc" 起始下标偏移 = 16

  1. .symtab 符号表里每一条条目,不会存储完整的函数 / 变量名字符串。只保存一个整型数字:字符串在 strtab 里面的偏移下标,再附带符号类型、段偏移地址。

举个例子:函数 func 符号表项:名字偏移 = 11,该函数在.text 代码段的偏移地址


2、符号名字只给编译器、链接器使用,程序运行根本不需要名字
① 编译‑链接阶段必须依靠符号名

当代码写 func();

  1. 编译器不知道 func 的地址,只能记住符号名称 "func"

  2. 链接器检索 .symtab,顺着偏移去 strtab 读出字符串func,找到该函数的内存偏移,把函数调用填充成对应的跳转地址

② 链接完成、生成 ELF 可执行文件以后

所有函数调用、全局变量访问全部已经换算成段内偏移 / 虚拟内存地址。 CPU 运行程序只需要跳转至对应地址,完全不需要知道这个地址叫什么名字(func、libc 只是人类识别用的字符串)。

举个大白话例子:

  • 链接前:我要去找名叫 "func" 的房子

  • 链接结束:我直接记住地址 0x400520,直接前往这个门牌号,再也不需要 "func" 这个名字


3、为什么可以删掉符号表(strip 命令)
  1. 符号表、字符串表属于调试、链接辅助段,加载程序的时候操作系统不会把 symtab、strtab 载入进程虚拟内存。

  2. 使用 strip a.out 就可以直接删除可执行文件的符号表节,减小磁盘文件体积,程序依旧可以正常运行。


4、一句话总结核心

符号名称只是给人、链接器辨认的文字;机器 CPU 只认内存偏移地址。链接完毕后名字就完成使命,运行全程只用偏移寻址。

为什么机器 CPU 只认内存偏移地址,运行全程只用偏移寻址怎么用
一、先说最核心结论

CPU完全看不懂 func、g_val、main 这种字符串名字

CPU 唯一能识别的只有数字地址(二进制)

函数名、变量名只是写给程序员、编译器、链接器看的文字别名。

下面分阶段完整讲清楚从名字 → 偏移 → 虚拟地址,还有寻址全过程。

1、编译阶段:.o目标文件,只用段内偏移

当编译生成 main.o

  • .text 代码段:main函数、func函数的机器指令

  • 此时还没有最终的内存地址,链接器还没合并所有目标文件

  • func 只拥有 相对于 .text 头部的偏移量,例如 func 在text偏移 0x80

.symtab符号表记录:

符号名func → .text段偏移0x80

CPU此时不能直接运行.o,它不知道func真实地址。

2、链接阶段:把段偏移换算成最终虚拟地址

链接器把多个 .o 的 .text 合并成一个完整代码段,并且指定代码段起始虚拟地址,举个例子:

.text 起始虚拟地址 = 0x400000

复制代码

func 最终虚拟地址 = 0x400000 + 0x80 = 0x400080

链接器扫一遍所有函数调用 func();,把call指令里面空白的地址,直接填上 0x400080

链接结束之后,代码里面再也不需要 "func" 字符串

call 指令直接存放数字地址 0x400080

CPU执行 call 0x400080,直接跳转到该内存地址运行指令,压根不需要知道这个地址叫func。

二、什么叫「偏移寻址」,CPU是怎么使用它
1、ELF分段本质都是相对偏移

ELF每个段(.text /.data /.bss)都有自己的起始地址

所有变量、函数 = 段首地址 + 段内偏移

复制代码

.data起始地址:0x600000 全局变量g_val 在data段偏移是0x10 g_val虚拟地址 = 0x600000 + 0x10 = 0x600010

2、CPU常见两种偏移寻址方式

① 直接地址寻址

call 0x400080,硬编码完整虚拟地址

② 相对偏移寻址(最常见)

x86‑64 的 call、jmp 大多是相对当前指令的偏移

jmp +0x20:从当前指令往后偏移0x20字节跳转

只需要偏移差值,不需要函数名字。

三、关键考点区分
  1. .symtab符号表、strtab字符串表 运行时不会加载进内存

只是磁盘上ELF文件的辅助节,用于调试、二次链接;程序跑起来完全不需要。

  1. 动态库 .dynsym 动态符号表例外

程序运行之后才要查找库里面函数,所以动态符号表必须保留,strip删不掉

  1. 变量名字只服务人;硬件CPU只操作二进制数字地址
四、完整时间线梳理
复制代码

C语言代码 func(); → gcc编译:记录符号名func、段内偏移 → main.o → ld链接:计算func最终虚拟地址,填充call指令 → ELF可执行文件,call指令存储数字地址 → OS加载程序,开启页表映射虚拟地址到物理地址 → CPU读取call后的地址,跳转执行指令,全程和"func"字符串无关

逻辑地址
传统分段模式:「逻辑地址 = 段选择子 + 段内偏移」

x86 保护模式原生逻辑地址格式:段寄存器(选择子):偏移

寻址公式:线性地址 = 段基址 + 偏移量

举个例子

  • CS 代码段基址 = 0x100000

  • 偏移 = 0x2000

  • 实际线性地址 = 0x100000 + 0x2000 = 0x102000

这里段基址不是固定 0。 CS、DS、SS、ES 可以设置完全不同的起始地址。

CS:0x2000、DS:0x2000 是两块不一样的内存。 这就是标准分段式逻辑地址。

平坦模式依旧是「起始 + 偏移」,只是起始地址强制等于 0

平坦模式操作系统配置 GDT:

代码段、数据段、栈段的段基址全部 = 0

于是寻址公式变为:

线性地址 = 0 + 偏

核心区别
  1. 普通分段:段起始地址不为 0,多个段拥有各自独立的起始地址

  2. 平坦分段:所有段起始地址固定为 0,偏移直接等于线性地址

硬件底层依旧遵守「基址 + 偏移」的运算逻辑,只是基址被操作系统强制置零。平坦模式不是抛弃了 "起始 + 偏移",只是把起始固定成零

  1. 地址空间是否隔离
  • 传统分段:CS、DS 是两块独立地址空间;同样偏移指向不同内存

  • 平坦模式:CS/DS/SS 全部覆盖完整 4GB 空间;同一个偏移指向同一块内存

平坦模式编址:从0开始 ------> 虚拟地址也是从0开始编址的

所以说磁盘上的可执行程序,代码和数据编址其实就是虚拟地址的统一编址

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

也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统⼀编址了

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

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

所以:虚拟地址机制,不光OS要支持,编译器也要支持。

ELF加载与进程地址空间

三个极易混淆的地址
  1. ELF 文件偏移(磁盘上):代码在硬盘可执行文件里面的文件偏移;

  2. 链接时分配的虚拟地址:链接器指定运行时代码所处的虚拟内存地址;程序加载之后,这个地址不会改变;

  3. 物理内存地址:真实内存条上的地址,每次程序启动,内核分配的物理页地址大概率每次都不一样。

内核加载程序的时候:

  1. 把磁盘里的 .text 段读取到随机分配的物理内存页;

  2. 修改当前进程页表,把链接规定的虚拟地址,映射到刚刚分配好的物理页。

完整分步执行顺序(ELF 可执行文件加载全过程)
步骤 1:文件系统定位可执行程序
  1. shell 输入命令,内核通过文件系统找到磁盘上 ELF 可执行文件;

  2. 读取 ELF 头部、程序头表 Program‑Header,获知 .text.data、bss 等段信息、入口虚拟地址 0x1060

内核定位 ELF 可执行文件全过程

我们以 Linux、shell 输入 ./a.out 或者 ls 举例,从用户态一直下沉到磁盘块,整条链路讲清楚。

前置常识

Linux 文件系统本质:文件名只是目录项,真正操控文件依靠 inode;磁盘被切割成一个个块 (4KB),所有文件数据存放于磁盘块。

1:用户发起调用

  1. Shell(bash)接收你输入的命令 ./test

  2. shell 调用系统调用 execve("./test",argv,envp),陷入内核态。

传给内核的只是字符串路径名

2:内核路径解析(逐层遍历目录)

内核函数 filename_lookup() 负责解析路径,拿 ./test 举例

  1. . 代表当前工作目录,进程 PCB (task_struct) 里面存储当前目录 inode 指针

  2. 读取当前目录对应的目录磁盘块 目录块存放一张「文件名 → 子文件 inode 编号」对照表

  3. 在目录项内搜索名字 test,拿到该 ELF 文件的 inode‑num(inode 编号)

目录本身只是一张索引表,不会存储文件内容,只会记录文件名对应几号 inode。

3:读取该 ELF 的 inode

  1. 通过 inode 编号,找到磁盘分区上的 inode 结构体

  2. inode 储存核心信息:

    1. 文件大小、权限

    2. 文件数据块指针(直接块、间接块、多级间接块) inode 不会存文件名,只管理磁盘上的数据块位置。

4:读取 ELF 头部

  1. 内核依靠 inode 里面的块指针,找到磁盘最开头的数据块

  2. 读出头部,校验魔数 0x7f ELF,确认这是合法 ELF 可执行程序

  3. 读取程序头表 Program Header,得到 .text/.data/.bss、程序入口虚拟地址

步骤 2:操作系统创建进程,构建进程内核对象
  1. 调用 execve,内核新建进程:分配 task_structmm_struct(你的示意图左侧结构体)

  2. mm_struct 就是该进程的虚拟地址空间管理器

  3. 创建页表:CR3 最终存放顶级页表的物理地址,此刻页表是空的

操作系统先构建进程、地址空间、空页表

步骤 3:建立虚拟地址 → 物理页的映射关系(优先建映射,之后再加载代码)

Linux 使用按需分页(懒加载),这点是绝大多数人顺序搞错的地方:

  1. 内核根据 ELF 程序头,预先在进程页表建立映射:

    1. 链接器预先设定好的虚拟地址 (VMA) 绑定到磁盘文件

    2. 此时还没有拷贝代码进物理内存,页表标记该页为「缺页」

  2. .bss 段(无初始值全局变量)映射匿名物理页

步骤 4:CPU 开始运行程序、RIP 指向入口虚拟地址(0x1060)
  1. CPU 取指令,访问虚拟地址 0x1060

  2. 查询页表,发现页面不在物理内存 → 触发缺页异常

步骤 5:缺页中断服务程序,真正把磁盘代码加载进物理内存
  1. 内核分配空闲物理页

  2. 将磁盘 ELF 的 .text 段拷贝至刚刚分配的物理内存

  3. 修改页表:该虚拟地址现在映射到此物理页

  4. 退出异常,CPU 重新取回指令、正常执行

相关推荐
三言老师7 小时前
K8s集群运行时自动化运维全覆盖落地实操(下)
linux·运维·服务器·网络
Super 含8 小时前
Android 启动优化(五):线程、GC 与 IO 为什么会拖慢启动?
java·服务器·数据库
ltl8 小时前
HAProxy HTX 与 HTTP 路径:内部表示、改写落点与协议分叉
linux
ltl9 小时前
可拆分 OS:CXL 内存池化如何动摇本地 DRAM 假设
linux
ltl9 小时前
向量检索引擎选型:决策树、RAG 回链与开放问题
数据库
80s7779 小时前
住宅代理解析:动态住宅IP与静态住宅IP如何选型?
网络·网络协议·tcp/ip
ltl9 小时前
Tetragon 强制执行:Override 与 SIGKILL 各自保证什么
linux
坚持学习前端日记9 小时前
Python SQLAlchemy ORM 从0到1精通实战手册(基础到复杂高阶)
数据库·python·oracle
灯澜忆梦9 小时前
【MySQL17】进阶篇 | InnoDB引擎
数据库·mysql