ELF
ELF (Executable and Linkable Format,可执行可链接格式) 是 Linux‑Unix 体系规定的一种二进制文件规范
静态库,可执行程序,.o文件都是ELF格式的
ELF 的作用
① 编译阶段
.c → 编译器输出 ELF 格式 .o 目标文件,保存代码、变量、符号
② 链接阶段
ld 链接器读取多个 ELF 目标文件
-
合并同名 section
-
解析函数符号、修补重定位地址
-
打包生成 Segment 内存段
-
产出最终 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)
- 父进程拥有一套独立
mm_struct(进程地址空间)+ 专属页表
父进程全局变量 g_val 的虚拟地址,通过父进程页表,映射到一块物理内存。
-
调用
fork()生成子进程此时父子两个进程的虚拟地址,指向同一块物理内存
-
子进程新建自己的
task_struct、全新的mm_struct -
子进程页表最开始直接复制父进程的页表项
-
并且内核会把这块物理页设置成 只读权限(COW写时拷贝)
-
3、发生修改的时候才拷贝物理页面
-
父进程 / 子进程任意一方准备写入
g_val -
MMU检查页表权限:页面只读 → 触发缺页中断
-
操作系统内核:复制一份全新的物理内存副本
-
修改触发写入的那一方的页表,映射到新物理页;另一方依旧指向旧页面
这就是图上红蓝两条线:写入之后父子g‑val虚拟地址相同、映射两块独立物理内存
4、关键知识点,帮你打通和刚刚权限控制的联系
- 每一个进程都有独立一套页表
两个进程可以拥有一模一样的虚拟地址,但是页表映射到不一样的物理页。
-
页表项PTE同时干两件事
-
保存虚拟→物理地址映射
-
存放页面读写、执行、用户态权限位(就是你刚刚问的访问控制)
-
-
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(只读字符串常量)
结论
-
多个 .o 的 .text 合并 → 得到 1 个 .text 节 (Section)
-
之后链接器把权限相同的多个 Section 打包进同一个 Segment
- 所以一个 Segment 里面可以装:
.text+.rodata
- 所以一个 Segment 里面可以装:
-
关系:
多个旧Section(各个o文件的.text) →合并→1个新的.text Section →和别的同权限Section打包→归属到一个Segment
内核创建虚拟地址空间完整流程
步骤 1:创建进程,生成进程 PCB (task_struct)
Linux 创建新进程,生成 task_struct,里面存放该进程专属的虚拟内存结构体 mm_struct,代表这个进程独立完整的虚拟地址空间。 此时地址空间是空的,没有任何代码、数据内存映射。
步骤 2:内核读取磁盘上 ELF 可执行文件,解析 Program Header(只读取程序段表,忽略 section 节头表)
内核循环遍历每一条 Program‑Header:
-
判断类型是不是
PT_LOAD(需要加载进内存的段) -
获取参数:
-
p_vaddr:该段起始虚拟地址
-
p_filesz:磁盘文件长度
-
p_memsz:内存占用总大小
-
p_flags:内存权限 可读、可写、可执行
-
-
内核调用内存映射函数,在当前进程 mm_struct 虚拟地址空间里面,申请对应大小的内存区间
步骤 3:分段映射
-
对于代码段 (.text、.rodata): 把磁盘 ELF 对应位置的数据,拷贝到 p_vaddr 起始的虚拟内存,设置权限 r‑x
-
对于 .data: 拷贝磁盘初始化数据到虚拟内存
-
对于 .bss: 磁盘文件没有存储内容,内核直接把 p_vaddr + filesz 往后剩下的
memsz‑filesz内存清零,作为未初始化全局变量空间
步骤 4:mm_struct 结构体维护全部内存区域
mm_struct 内部存放链表 vm_area_struct,每一个结点对应一段虚拟内存区间,记录起始地址、结束地址、读写执行权限。 至此进程的代码段、数据段、堆、栈等全部虚拟内存区域初始化完毕。
3、内核怎么知道各个段分配多大?
-
大小不是操作系统计算出来的,是静态链接器 ld 在编译链接阶段提前计算好,写进 ELF Program Header 的 p_memsz 字段
-
操作系统只是读取该字段,向进程虚拟地址空间申请对应尺寸内存
4、Section 和 Segment 在加载阶段的地位区别
-
Section (.text/.data):仅供编译器、链接器、gdb 调试使用;程序加载阶段内核完全看不见、也不关心节
-
Segment (Program Header):专门给操作系统加载器使用,是内核唯一的参考依据
ELF 的作用,分阶段说明
① 编译阶段
.c → 编译器输出 ELF 格式 .o 目标文件,保存代码、变量、符号
② 链接阶段
ld 链接器读取多个 ELF 目标文件
-
合并同名 section
-
解析函数符号、修补重定位地址
-
打包生成 Segment 内存段
-
产出最终 ELF 可执行文件
如果没有 ELF 标准,链接器不知道哪里是代码、哪里是变量
③ 程序加载(execve)
Linux 内核解析 ELF 程序头表 依照 Segment 信息,为进程分配虚拟内存、映射代码和数据,搭建独立虚拟地址空间
④ 调试
gdb 读取 ELF 的节头表、符号表、调试节,可以查到函数名、变量名、行号,进行断点调试
⑤ 动态链接
ELF 格式规定动态段结构,可以运行时加载 .so 动态库、绑定函数地址
流程回顾
C 源码 → ELF‑o 目标文件 → ld 合并 section、打包 Segment → ELF 可执行文件 → 内核读取 Segment 搭建虚拟内存 → 运行
Symbol Table(保存偏移量)

1、先搞懂 .symtab(符号表)和 .strtab(字符串表)的存储结构
.strtab字符串表:就是一大块字符数组,存放所有符号名字符串
"helloworld\0func\0libc\0a\0obj\0"
\0 当作每个名字的分隔符
-
"helloworld" 起始下标偏移 = 0
-
"func" 起始下标偏移 = 11
-
"libc" 起始下标偏移 = 16
.symtab符号表里每一条条目,不会存储完整的函数 / 变量名字符串。只保存一个整型数字:字符串在 strtab 里面的偏移下标,再附带符号类型、段偏移地址。
举个例子:函数
func符号表项:名字偏移 = 11,该函数在.text 代码段的偏移地址
2、符号名字只给编译器、链接器使用,程序运行根本不需要名字
① 编译‑链接阶段必须依靠符号名
当代码写 func();
-
编译器不知道 func 的地址,只能记住符号名称 "func"
-
链接器检索
.symtab,顺着偏移去 strtab 读出字符串func,找到该函数的内存偏移,把函数调用填充成对应的跳转地址
② 链接完成、生成 ELF 可执行文件以后
所有函数调用、全局变量访问全部已经换算成段内偏移 / 虚拟内存地址。 CPU 运行程序只需要跳转至对应地址,完全不需要知道这个地址叫什么名字(func、libc 只是人类识别用的字符串)。
举个大白话例子:
-
链接前:我要去找名叫 "func" 的房子
-
链接结束:我直接记住地址 0x400520,直接前往这个门牌号,再也不需要 "func" 这个名字
3、为什么可以删掉符号表(strip 命令)
-
符号表、字符串表属于调试、链接辅助段,加载程序的时候操作系统不会把 symtab、strtab 载入进程虚拟内存。
-
使用
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字节跳转
只需要偏移差值,不需要函数名字。
三、关键考点区分
- .symtab符号表、strtab字符串表 运行时不会加载进内存
只是磁盘上ELF文件的辅助节,用于调试、二次链接;程序跑起来完全不需要。
- 动态库
.dynsym动态符号表例外
程序运行之后才要查找库里面函数,所以动态符号表必须保留,strip删不掉
- 变量名字只服务人;硬件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 + 偏
核心区别
-
普通分段:段起始地址不为 0,多个段拥有各自独立的起始地址
-
平坦分段:所有段起始地址固定为 0,偏移直接等于线性地址
硬件底层依旧遵守「基址 + 偏移」的运算逻辑,只是基址被操作系统强制置零。平坦模式不是抛弃了 "起始 + 偏移",只是把起始固定成零
- 地址空间是否隔离
-
传统分段:CS、DS 是两块独立地址空间;同样偏移指向不同内存
-
平坦模式:CS/DS/SS 全部覆盖完整 4GB 空间;同一个偏移指向同一块内存
平坦模式编址:从0开始 ------> 虚拟地址也是从0开始编址的
所以说磁盘上的可执行程序,代码和数据编址其实就是虚拟地址的统一编址
ELF的虚拟地址:严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们认为起始地址是0。
也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统⼀编址了
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?
从ELF各个 segment 来,每个 segment 有自己的起始地址和长度,用来初始化内核结构中的 start,end 等范围数据,另外用详细地址,填充页表。
所以:虚拟地址机制,不光OS要支持,编译器也要支持。
ELF加载与进程地址空间


三个极易混淆的地址
-
ELF 文件偏移(磁盘上):代码在硬盘可执行文件里面的文件偏移;
-
链接时分配的虚拟地址:链接器指定运行时代码所处的虚拟内存地址;程序加载之后,这个地址不会改变;
-
物理内存地址:真实内存条上的地址,每次程序启动,内核分配的物理页地址大概率每次都不一样。
内核加载程序的时候:
-
把磁盘里的
.text段读取到随机分配的物理内存页; -
修改当前进程页表,把链接规定的虚拟地址,映射到刚刚分配好的物理页。


完整分步执行顺序(ELF 可执行文件加载全过程)
步骤 1:文件系统定位可执行程序
-
shell 输入命令,内核通过文件系统找到磁盘上 ELF 可执行文件;
-
读取 ELF 头部、程序头表 Program‑Header,获知
.text、.data、bss 等段信息、入口虚拟地址0x1060。
内核定位 ELF 可执行文件全过程
我们以 Linux、shell 输入 ./a.out 或者 ls 举例,从用户态一直下沉到磁盘块,整条链路讲清楚。
前置常识
Linux 文件系统本质:文件名只是目录项,真正操控文件依靠 inode;磁盘被切割成一个个块 (4KB),所有文件数据存放于磁盘块。
1:用户发起调用
-
Shell(bash)接收你输入的命令
./test -
shell 调用系统调用 execve("./test",argv,envp),陷入内核态。
传给内核的只是字符串路径名
2:内核路径解析(逐层遍历目录)
内核函数 filename_lookup() 负责解析路径,拿 ./test 举例
-
.代表当前工作目录,进程 PCB (task_struct) 里面存储当前目录 inode 指针 -
读取当前目录对应的目录磁盘块 目录块存放一张「文件名 → 子文件 inode 编号」对照表
-
在目录项内搜索名字
test,拿到该 ELF 文件的 inode‑num(inode 编号)
目录本身只是一张索引表,不会存储文件内容,只会记录文件名对应几号 inode。
3:读取该 ELF 的 inode
-
通过 inode 编号,找到磁盘分区上的 inode 结构体
-
inode 储存核心信息:
-
文件大小、权限
-
文件数据块指针(直接块、间接块、多级间接块) inode 不会存文件名,只管理磁盘上的数据块位置。
-
4:读取 ELF 头部
-
内核依靠 inode 里面的块指针,找到磁盘最开头的数据块
-
读出头部,校验魔数
0x7f ELF,确认这是合法 ELF 可执行程序 -
读取程序头表 Program Header,得到 .text/.data/.bss、程序入口虚拟地址
步骤 2:操作系统创建进程,构建进程内核对象
-
调用
execve,内核新建进程:分配task_struct、mm_struct(你的示意图左侧结构体) -
mm_struct 就是该进程的虚拟地址空间管理器
-
创建页表:CR3 最终存放顶级页表的物理地址,此刻页表是空的
操作系统先构建进程、地址空间、空页表
步骤 3:建立虚拟地址 → 物理页的映射关系(优先建映射,之后再加载代码)
Linux 使用按需分页(懒加载),这点是绝大多数人顺序搞错的地方:
-
内核根据 ELF 程序头,预先在进程页表建立映射:
-
链接器预先设定好的虚拟地址 (VMA) 绑定到磁盘文件
-
此时还没有拷贝代码进物理内存,页表标记该页为「缺页」
-
-
.bss 段(无初始值全局变量)映射匿名物理页
步骤 4:CPU 开始运行程序、RIP 指向入口虚拟地址(0x1060)
-
CPU 取指令,访问虚拟地址 0x1060
-
查询页表,发现页面不在物理内存 → 触发缺页异常
步骤 5:缺页中断服务程序,真正把磁盘代码加载进物理内存
-
内核分配空闲物理页
-
将磁盘 ELF 的 .text 段拷贝至刚刚分配的物理内存
-
修改页表:该虚拟地址现在映射到此物理页
-
退出异常,CPU 重新取回指令、正常执行