Re:Linux 系统篇(三十一):库的制作与原理Chapter2:静态链接与程序加载 —— 从磁盘 ELF 到运行中进程的完整旅程


观众老爷们大家好 这里是邪修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.ofunc.o 两个目标文件。此时 main.o 里调用了 add,但只知道有这个函数,不知道它的具体地址,标记为「未定义符号」。

1.2 静态链接的核心:合并 + 重定位

静态链接器(ld)做的核心工作,就是把多个 .o 目标文件,加上用到的静态库,合并成一个完整的可执行 ELF 文件。

两件核心事:

  1. 合并段 :把所有 .o.text 合并成一个大的代码段,.data 合并成一个大的数据段,符号表合并成一个完整符号表。
  2. 静态重定位:计算出每个函数、每个变量最终的虚拟地址,把代码里所有 "不知道地址" 的地方,全部修正为正确的地址。

执行链接命令:

复制代码
gcc main.o func.o -o myprog

得到的 myprog 就是完整的可执行 ELF 文件。此时里面的 mainadd 都有了确定的虚拟地址,所有函数调用都指向了正确的位置。

💡 什么是静态重定位?

就是在程序运行之前,链接阶段就把所有地址都计算好、修正完。程序加载到内存的时候,地址就固定了,不用再改。

这也是 "静态链接" 名字的由来:链接阶段就把地址关系全部静态确定。

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

可以看到 mainadd 都有了确定的虚拟地址,说明静态重定位已经完成。


二、第二步:操作系统加载 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、进程地址空间、页表的关系

我们把三层关系理清楚:

  1. 第一层(文件):ELF 的 Program Header 描述了有哪些段、要放什么虚拟地址
  2. 第二层(虚拟) :操作系统根据 ELF 建立进程虚拟地址空间,用 mm_structvm_area_struct 管理
  3. 第三层(物理):页表把虚拟地址映射到真实的物理内存


一句话串起来:

ELF 告诉操作系统「程序应该长什么样」,操作系统照着建起虚拟地址空间,再通过页表落到真实的物理内存上。


四、完整加载流程:从磁盘到 CPU 执行的全链路

现在我们把整个过程从头到尾串一遍,你就能清晰看到一个 ELF 文件是怎么变成运行的进程的。

  1. 读取 ELF 头部
    操作系统先读取 ELF 文件最开头的 Header,确认这是合法的 ELF 文件,找到 Program Header Table 的位置。
  2. 解析 Program Header
    遍历所有 Program Header,找出所有类型为 LOAD 的 Segment,规划好每个段要映射的虚拟地址、大小、权限。
  3. 创建进程内核结构
    操作系统创建 task_struct(PCB)、mm_struct 等内核数据结构,为这个进程分配资源。
  4. 建立虚拟内存映射
    根据 Segment 的信息,创建对应的 vm_area_struct,挂到 mm_struct 上,完成虚拟地址空间的布局。
    注意:这一步只是分配了虚拟地址范围,还没有分配真实的物理内存。
  5. 建立页表映射
    当程序真正执行、访问到对应的虚拟地址时,触发缺页中断,操作系统才会分配真实的物理页,在页表里建立虚拟页到物理页的映射。
    这就是著名的请求调页机制:用多少分配多少,不一次性把整个程序都加载进内存,节省内存。
  6. 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 不占文件空间

你会发现数据段的 MemSizFileSiz 大,多出来的部分就是 .bss 段。

.bss 段在文件里不占空间,加载到内存的时候才分配空间并自动清零,这就是为什么文件小、内存占用大一点的原因。


全文总结

  1. 静态链接 :把多个 .o 目标文件合并,完成静态重定位,所有函数变量地址在链接阶段就确定,生成完整可执行 ELF。
  2. 加载本质:不是把整个文件拷进内存,而是操作系统解析 Program Header,为进程建立虚拟地址空间。
  3. 三层结构 :ELF Segment(文件加载单元)→ 进程虚拟地址空间(mm_struct + vm_area_struct)→ 物理内存(页表映射)。
  4. 请求调页:不是一次性加载全部内容,访问时触发缺页中断才分配物理页,按需加载。
  5. 入口点:ELF 头部记录入口地址,加载完成后 CPU 跳转到这里开始执行,进程正式运行。

下篇预告:

静态链接我们搞懂了,下一篇我们来看动态链接:程序运行时才加载 .so,地址又是怎么重定位的?GOT/PLT 表又是干什么的?我们深入动态链接的底层原理。

相关推荐
geovindu1 小时前
rust: Factory Method Pattern
开发语言·设计模式·rust·工厂方法模式·创建型模式
eybk1 小时前
用Kivy制作手机相片分类局域网传送工具,还能传输数据库文件
开发语言·python
企业数字化笔记1 小时前
固定资产还有借用记录能报废吗?Java前置检查、停止折旧与SQL验收
java·开发语言·sql
A-刘晨阳1 小时前
普通摄像头怎么加上 AI 监控?用 Frigate 把识别和事件管理放到绿联 NAS
运维
云计算练习生1 小时前
什么是内核?操作系统内核到底管哪些事
linux·windows·操作系统·内核·shell
Doris__HE1 小时前
【元脑服务器NF8480G7-NF8480M7技术规格分享】
运维·服务器·数据库·缓存·性能优化
可涵不会debug1 小时前
公司外怎么连接管家婆?从 SQL Server 安装到 cpolar 固定 TCP 远程登录
linux·运维·ubuntu
wuyk5551 小时前
《WiFi 嵌入式物联网开发全套实战》| 第 12 章 Linux 系统 WiFi 常用命令全集:iw/iwlist/ip/wpa_cli 实战
linux·网络·stm32·物联网·tcp/ip
wuminyu1 小时前
纯轻量级锁体系下C2编译器锁粗化和消除机制剖析
java·linux·c语言·jvm·c++