
观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇 上一篇我们整体认识了 ELF 格式:知道了它是 Linux 二进制文件的统一规范,里面按功能划分成 .text、.data 等 Section,还有描述加载信息的 Program Header。
但很多同学学到这里会有一个断层:磁盘上这个 ELF 文件,到底是怎么一步步变成内存里跑起来的进程的?中间经历了哪些环节?
本篇我们就把这个过程完整串起来:先通过静态链接生成可执行 ELF,再由操作系统解析加载,建立进程地址空间,最终 CPU 开始执行。我们会结合代码实操、工具验证,把「文件 → 内存 → 进程」的链路彻底讲通。
一、第一步:静态链接 ------ 把多个模块拼成完整可执行 ELF
1.1 先回顾:从源文件到目标文件
我们写的 .c 源文件,不能直接运行,第一步要经过编译,生成 .o 目标文件。
每个 .o 都是独立的 ELF 格式文件,里面有自己的代码段、数据段、符号表,但它是 "不完整" 的:
- 自己调用的外部函数(比如
printf),还不知道地址 - 自己的函数、变量最终在程序里的什么地址,也还没确定
举个例子,我们写两个文件:
// func.c
int add(int a, int b)
{
return a + b;
}
// main.c
#include <stdio.h>
int add(int a, int b);
int main()
{
int res = add(3, 5);
printf("result = %d\n", res);
return 0;
}
分别编译:
gcc -c main.c -o main.o
gcc -c func.c -o func.o
得到 main.o 和 func.o 两个目标文件。此时 main.o 里调用了 add,但只知道有这个函数,不知道它的具体地址,标记为「未定义符号」。
1.2 静态链接的核心:合并 + 重定位
静态链接器(ld)做的核心工作,就是把多个 .o 目标文件,加上用到的静态库,合并成一个完整的可执行 ELF 文件。
两件核心事:
- 合并段 :把所有
.o的.text合并成一个大的代码段,.data合并成一个大的数据段,符号表合并成一个完整符号表。 - 静态重定位:计算出每个函数、每个变量最终的虚拟地址,把代码里所有 "不知道地址" 的地方,全部修正为正确的地址。
执行链接命令:
gcc main.o func.o -o myprog
得到的 myprog 就是完整的可执行 ELF 文件。此时里面的 main、add 都有了确定的虚拟地址,所有函数调用都指向了正确的位置。
💡 什么是静态重定位?
就是在程序运行之前,链接阶段就把所有地址都计算好、修正完。程序加载到内存的时候,地址就固定了,不用再改。
这也是 "静态链接" 名字的由来:链接阶段就把地址关系全部静态确定。
1.3 动手验证:用 readelf 查看符号地址
我们可以用 readelf 工具查看可执行文件的符号表,验证地址已经确定:
readelf -s myprog | grep -E "main|add"
输出类似:
64: 0000000000401126 21 FUNC GLOBAL DEFAULT 15 main
53: 0000000000401110 22 FUNC GLOBAL DEFAULT 15 add
可以看到 main 和 add 都有了确定的虚拟地址,说明静态重定位已经完成。
二、第二步:操作系统加载 ELF ------ 从文件到进程地址空间
很多人有个误解:运行程序就是把整个文件拷贝进内存就完事了。
完全不是。操作系统拿到可执行 ELF 之后,会做一套非常精细的工作:解析文件 → 创建进程 → 映射内存 → 建立页表。
2.1 操作系统先看什么?Program Header
上一篇我们讲过,ELF 里有 Program Header Table(程序头表),它就是给操作系统看的「加载说明书」。
里面记录了每一个 Segment(加载段)的信息:
- 这个段在文件里的哪个位置、多大
- 要加载到进程虚拟地址空间的哪个地址
- 权限是读、写、还是可执行
操作系统加载程序的第一步,就是读取 Program Header,按照里面的描述,给进程规划内存布局。
我们可以用命令查看程序头:
readelf -l myprog
输出里会看到 Type 为 LOAD 的段,就是需要加载到内存的 Segment。通常至少有两个:
- 一个是代码段:权限
R E(读 + 执行),对应.text等 Section - 一个是数据段:权限
R W(读 + 写),对应.data、.bss等 Section
2.2 对应我们熟悉的进程虚拟地址空间
学到这里就可以和之前「进程地址空间」的知识点串起来了。
我们之前说,每个进程都有自己独立的虚拟地址空间,从低地址到高地址大概是:
高地址
┌──────────────┐
│ 栈 │
├──────────────┤
│ ... │
├──────────────┤
│ 堆 │
├──────────────┤
│ .data/.bss │ ← 数据段
├──────────────┤
│ .text │ ← 代码段
└──────────────┘
低地址
现在你就知道这个布局是怎么来的了:
不是操作系统随便画的,是照着 ELF 文件的 Program Header 来建的。
ELF 说代码段要放哪个地址、多大,操作系统就对应在虚拟空间里划出一块区域;数据段同理。
💡 教学提示:这里可以停下来对应一下
Section 是文件里的功能划分,Segment 是加载时的合并单元;操作系统不关心 Section,只按 Program Header 里的 Segment 来映射内存。
相同权限的 Section 会被合并到同一个 Segment,一起映射到内存。
2.3 内核里的对应数据结构
操作系统为每个进程维护一套内存管理结构,最核心的两个:
mm_struct:进程内存管理的总结构,描述整个进程地址空间的信息,比如代码段起止、数据段起止、栈的位置等。vm_area_struct:虚拟内存区域结构体,每一个连续的、属性相同的内存区域,对应一个vm_area_struct。比如代码段一个、数据段一个、堆一个、栈一个。
加载 ELF 的过程,本质就是:
读取 ELF 的 Program Header → 根据每个 Segment 创建对应的
vm_area_struct→ 挂到进程的mm_struct上
这样,进程的地址空间就建好了。
三、页表:虚拟地址到物理内存的桥梁
3.1 两个地址的区别
这里有两个层级的地址,初学者很容易混:
- 虚拟地址 :进程看到的地址,是我们程序里用的地址,比如上面看到的
0x401126。每个进程都有自己独立的一套虚拟地址,互不干扰。 - 物理地址:内存条上真实的硬件地址,CPU 最终访问内存用的是物理地址。
程序里用的都是虚拟地址,但 CPU 真正读写内存的时候,必须转换成物理地址。这个转换工作,就靠页表。
3.2 页表是怎么工作的
页表就像一张「地址映射对照表」,记录了:哪一块虚拟地址,对应哪一块物理内存。
- 操作系统以「页」为单位管理内存,通常一页 4KB
- 页表记录每一个虚拟页号 → 物理页号的映射
- CPU 访问虚拟地址时,硬件自动查页表,转换成物理地址再访问
3.3 ELF、进程地址空间、页表的关系
我们把三层关系理清楚:
- 第一层(文件):ELF 的 Program Header 描述了有哪些段、要放什么虚拟地址
- 第二层(虚拟) :操作系统根据 ELF 建立进程虚拟地址空间,用
mm_struct、vm_area_struct管理 - 第三层(物理):页表把虚拟地址映射到真实的物理内存
一句话串起来:ELF 告诉操作系统「程序应该长什么样」,操作系统照着建起虚拟地址空间,再通过页表落到真实的物理内存上。
四、完整加载流程:从磁盘到 CPU 执行的全链路
现在我们把整个过程从头到尾串一遍,你就能清晰看到一个 ELF 文件是怎么变成运行的进程的。
- 读取 ELF 头部
操作系统先读取 ELF 文件最开头的 Header,确认这是合法的 ELF 文件,找到 Program Header Table 的位置。 - 解析 Program Header
遍历所有 Program Header,找出所有类型为LOAD的 Segment,规划好每个段要映射的虚拟地址、大小、权限。 - 创建进程内核结构
操作系统创建task_struct(PCB)、mm_struct等内核数据结构,为这个进程分配资源。 - 建立虚拟内存映射
根据 Segment 的信息,创建对应的vm_area_struct,挂到mm_struct上,完成虚拟地址空间的布局。
注意:这一步只是分配了虚拟地址范围,还没有分配真实的物理内存。 - 建立页表映射
当程序真正执行、访问到对应的虚拟地址时,触发缺页中断,操作系统才会分配真实的物理页,在页表里建立虚拟页到物理页的映射。
这就是著名的请求调页机制:用多少分配多少,不一次性把整个程序都加载进内存,节省内存。 - CPU 从入口地址开始执行
ELF Header 里有一个字段叫Entry point address(入口点地址),记录了程序第一条指令的虚拟地址。
一切准备好之后,CPU 跳转到这个入口地址,开始执行程序的第一条指令,进程就真正跑起来了。
💡 关键结论
静态链接解决的是「程序运行前,把代码合并好、地址算好」;
ELF + Program Header 帮助操作系统把磁盘上的文件,加载成一个真正的、有独立地址空间的进程。
五、动手验证:查看入口地址与加载段
我们用工具实际验证一下上面讲的知识点。
5.1 查看 ELF 入口点
readelf -h myprog
输出里会有一行:
Entry point address: 0x401040
这就是程序的入口虚拟地址,CPU 加载完就从这里开始执行。
5.2 查看程序头(加载段)
readelf -l myprog
输出中 LOAD 类型的段就是要加载进内存的 Segment,你会看到:
- 第一个 LOAD 段:权限
R E,对应代码段 - 第二个 LOAD 段:权限
R W,对应数据段
每个段都有 Offset(文件内偏移)、VirtAddr(虚拟地址)、FileSiz(文件中大小)、MemSiz(内存中大小)。
这就是操作系统加载内存的依据。
5.3 验证 .bss 不占文件空间
你会发现数据段的 MemSiz 比 FileSiz 大,多出来的部分就是 .bss 段。
.bss 段在文件里不占空间,加载到内存的时候才分配空间并自动清零,这就是为什么文件小、内存占用大一点的原因。

全文总结
- 静态链接 :把多个
.o目标文件合并,完成静态重定位,所有函数变量地址在链接阶段就确定,生成完整可执行 ELF。 - 加载本质:不是把整个文件拷进内存,而是操作系统解析 Program Header,为进程建立虚拟地址空间。
- 三层结构 :ELF Segment(文件加载单元)→ 进程虚拟地址空间(
mm_struct+vm_area_struct)→ 物理内存(页表映射)。 - 请求调页:不是一次性加载全部内容,访问时触发缺页中断才分配物理页,按需加载。
- 入口点:ELF 头部记录入口地址,加载完成后 CPU 跳转到这里开始执行,进程正式运行。
下篇预告:
静态链接我们搞懂了,下一篇我们来看动态链接:程序运行时才加载
.so,地址又是怎么重定位的?GOT/PLT 表又是干什么的?我们深入动态链接的底层原理。

