📝 博客写手专家

ELF程序加载与动态链接原理:从磁盘上的“0地址”到CPU取指执行的全链路拆解

——一堂把编译原理、文件系统、内存管理、进程加载串成线的硬核课


引言:为什么你写的C代码跑起来,远比“编译-运行”四个字复杂得多?

很多初学者以为,程序的生命周期不过是 gcc hello.c -o hello 然后 ./hello。但这堂课会把这一过程彻底拆开:你的代码是如何在磁盘上拥有“地址”的?可执行文件凭什么不是简单的“代码+数据”堆在一起?操作系统怎么知道该把哪段加载到哪里?动态库凭什么能被“共享”?以及,为什么你敲下的 main 函数,根本不是程序运行的真正起点?

这篇文章将基于课堂实录与板书,完整梳理从源码到进程、从静态链接到动态共享的每一个关键节点。我们会保留课堂上所有的实战命令(readelf、objdump、ldd)、经典示例(hello.c + code.c 链接实验)和那些容易被忽略的“零碎知识点”——包括为什么 .bss 段 Better Save Space,为什么内存管理的基本单位 4KB 决定了 Section 合并成 Segment 的必然性,以及 _start 函数在调用你的 main 之前到底偷偷干了多少脏活累活。


一、ELF文件格式:可执行程序不是“代码+数据”那么简单

1.1 四大组成部分与“双重视角”

一个 ELF(Executable and Linkable Format)可执行程序由四大块构成:

  1. ELF Header:文件的“户口本”,记录整个文件的管理信息。
  2. Program Header Table:给操作系统加载器看的“配送单”,描述哪些 Section 该合并成 Segment,以及加载到内存时的权限(读/写/执行)。
  3. Section Header Table:给编译器和链接器看的“施工图”,描述 .text、.data、.bss 等每个数据节的属性、偏移量和大小。
  4. 数据节(Section):真正存放代码、数据、符号表等内容的地方。

📌下面是ELF文件格式预览图:在这里插入图片描述


这里有一个贯穿全文的核心思想:ELF 文件存在两个视角。

  • 编译与链接视角:关心 Section Header Table。链接器把多个 .o 文件的 Section 合并,形成最终的可执行文件。
  • 执行与加载视角:关心 Program Header Table。操作系统不关心你有 30 个 Section,它只关心该把哪些东西打包成几个 Segment 塞进内存。
1.2 Section 与 Segment:为什么要“合并”?

课堂上老师用 readelf -S /usr/bin/ls 做了一个现场实验。输出显示 ls 命令足足有 30 个 Section(从 0 编号到 29)。但操作系统能直接把这 30 个小碎块分别加载进内存吗?

不能。因为内存管理的基本单位是 4KB(一页)。 磁盘与内存进行 I/O 交互时,基本单位也是 4KB。如果一个 .text 只有 1KB、一个 .rodata 只有 512 字节,各自独占一页,内存会迅速被碎片撑爆。老师举了一个精确的例子:

假设代码段实际占 4097 字节,只读数据占 512 字节。如果不合并,代码段要占两页(4096+1 各一页),只读数据独占一页,总共 3 页。但如果把 .text 和 .rodata 合并成一个 Segment,总共 4609 字节,只需 2 页 即可装下。

这就是 Section 合并为 Segment 的根本原因:减少页面碎片,提高内存利用率,同时基于权限实现访问控制(代码只读、数据可写、堆栈可执行等权限控制)。

用 readelf -l /usr/bin/ls 验证,原本的 30 个 Section 被合并成了 9 个 Program Header(Segment)。输出中的 Section to Segment mapping 清晰显示了 .text、.rodata 合并为只读可执行的代码段,.data、.bss 合并为可读写的数据段。

1.3 BSS 段:Better Save Space

课堂上对 .bss 的讲解非常生动。全局变量分两种:已初始化和未初始化。已初始化的必须记录具体值(如 int a = 10),存在 .data。但未初始化的(如 int b;),默认值全是 0。

如果定义了 50 个未初始化的 int,难道要在磁盘上记录 50 个 0 吗?没必要。.bss 在磁盘上只记录一个总大小(比如 50),加载到内存时再一次性开辟 50×4=200 字节并清零。

.bss 的全称被老师戏称为 Better Save Space——它节省的是磁盘空间。加载时才真正展开并初始化清零,这也是为什么未初始化全局变量天生为 0 的底层机制。

1.4 ELF Header 的 Magic 与入口地址

用 readelf -h 读取 /usr/bin/ls,你会看到:

  • Magic:文件开头一串固定魔数(如 7f 45 4c 46),操作系统靠它判断“这货是不是 ELF”。几乎所有二进制格式(图片、音乐、视频)开头都有 Magic,类似于文件的“身份证”。在这里插入图片描述

  • Entry point address:程序的入口地址。对于 ls,它是一个类似 0x401430 的虚拟地址。这个地址指向的不是你的 main 函数,而是 _start。后文会详细展开。


下面是 ELF 文件的双重视角与 Section 合并为 Segment 的示意图:

合并 Section

按权限映射

内存中的 Segment

代码段(R E)
.text + .rodata 合并

数据段(RW)
.data + .bss 合并

执行与加载视角
(Program Header Table)

操作系统按 Segment 加载

编译与链接视角
(Section Header Table)

链接器合并 Section

磁盘上的 ELF 文件

30 个 Section
.text / .rodata / .data / .bss ...

图解: 磁盘上的 30 个 Section 通过两个视角被处理——链接器按 Section 合并,操作系统按 Segment 加载,最终在内存中形成只读的代码段与可读写的数据段。

二、静态链接:符号解析与地址重定位

静态链接的本质,是把多个 .o 文件和静态库“糊”成一个独立的可执行文件。课上用 hello.c 和 code.c 做了一个极简但足够说明问题的实验。

2.1 实验环境:两个 C 文件,三个编译阶段
/* hello.c */
int run();  /* 声明外部函数 */
int main() {
    printf("hello");
    run();
    return 0;
}

/* code.c */
int run() {
    printf("run func");
    return 0;
}

编译为 .o:

gcc -c hello.c -o hello.o
gcc -c code.c -o code.o

在这里插入图片描述


2.2 符号表:Defined vs Undefined

用 readelf -s hello.o 查看符号表:

  • main、printf(实际底层是 _fputs 等)在 hello.o 中标记为 UND(Undefined)——我不知道它们在哪。在这里插入图片描述

  • run 同样标记为 UND,因为 hello.c 里没有它的定义。

而在 code.o 中,run 被标记为 FUNC 且已定义。链接器的核心工作就是:拿着自己未定义的符号,去其他模块找定义。在这里插入图片描述


2.3 地址重定位:call 后面为什么先是 0?

用 objdump -d hello.o 反汇编,查看 main 函数里的调用:

call   0  <?printf>
call   0  <run>

在这里插入图片描述

老师强调:编译阶段根本不知道 printf 和 run 在内存的哪个位置,所以编译器只能把跳转地址暂时填成 0。这个 0 是一个占位符。

当执行链接:

gcc hello.o code.o -o main.exe

在这里插入图片描述

链接器完成两件事:

  1. 合并 Section:把 hello.o 和 code.o 的 .text 合并成一个大 .text,.data 合并成一个大 .data。
  2. 地址重定位:确定每个函数在最终可执行文件中的虚拟地址(比如 run 被安排在了 0x400520),然后把所有 call 0 修正为 call 0x400520。

链接后再 objdump -d main.exe,你会看到 call 指令后面的地址已经被填上了真实的虚拟地址。这个过程就叫“链接时地址重定位”。

也正因 .o 文件里的地址在链接时会被“重新修改”,.o 被称为可重定位目标文件(Relocatable Object File)。


下面是静态链接中符号解析与地址重定位的完整流程:

可执行文件 main.exe

链接阶段

编译阶段

源码

hello.c
main() 调用 printf、run

code.c
run() 定义

hello.o
main: call 0
call 0

code.o
run: FUNC 已定义

合并 Section
.text / .data

符号解析
UND → 找到定义

地址重定位
call 0 → call 0x400520

call 0x400520
真实虚拟地址已填入

图解: 编译阶段 call 后的地址是占位符 0;链接阶段完成 Section 合并、符号解析与地址重定位,最终把真实虚拟地址填入 call 指令。

三、程序加载与虚拟地址空间:程序没有加载前,有地址吗?

这是课堂上抛出的一个核心思考题,也是很多操作系统的“分水岭”知识点。

3.1 有!磁盘上的逻辑地址,就是虚拟地址的“预览版”

用 objdump -s 反汇编任何一个可执行程序(如 main.exe),最左侧会看到一连串地址:0x4003c8、0x4003d0、0x4003f0……在这里插入图片描述

这些地址是什么?它们是程序在磁盘上就已经确定的编制。现代操作系统采用 平坦模式(Flat Model),即可执行程序内部从 0 开始统一编址。这个磁盘上的地址称为逻辑地址或线性地址。

在 Linux 系统下,逻辑地址(磁盘视角)、虚拟地址(操作系统视角)、物理地址(真实内存视角)三位一体。只是在磁盘上我们习惯叫逻辑地址,加载到内存语境后(在PCB等那边),它们就是虚拟地址。程序还没加载时,内部函数调用的地址就已经编好了——否则链接器凭什么重写 call 指令?

3.2 加载四步曲:从文件系统到 CPU 取指

老师用一张大图把加载流程串了起来:

第一步:文件系统定位
操作系统通过路径解析找到 inode,进而找到磁盘上的数据块(Data Block)。可执行程序本质上也是文件,也要走文件系统这一套。

第二步:创建进程骨架
内核创建 task_struct(PCB)、mm_struct(内存描述符)、vm_area_struct 链表(虚拟内存区域)。此时进程有了“户口”,但地址空间还是空的。

第三步:初始化地址空间与页表映射
操作系统读取 ELF 的 Program Header Table,用每个 Segment 的虚拟起止地址(start ~ end)去初始化 vm_area_struct 的各个区域(代码区、数据区、BSS 区等)。随后把代码和数据加载到物理内存,建立页表映射——页表左半边是虚拟地址,右半边是物理地址。

第四步:CPU 启动执行
读取 ELF Header 中的 Entry Point(即 _start 的虚拟地址,如 0x400430),把它填入 CPU 的指令寄存器(EIP/RIP)。CPU 开始取指执行。

3.3 权限管理与页表初始化

操作系统并不“认识”哪个区域是代码、哪个是数据,它只读 ELF 的 Program Header。如果某个 Segment 标记为 R E(只读、可执行),操作系统初始化页表时就把这段映射为只读;如果标记为 RW,则赋予读写权限。程序的权限管理,在加载那一刻就由 ELF 格式决定了。

3.4 一个极易忽略的细节:_start 才是入口

很多同学以为程序入口是 main。但通过 objdump 和 readelf -h 都能看到,Entry Point 指向的是 _start。_start 由链接器(或 C 运行时库)提供,它的职责包括:

  • 设置栈帧;
  • 初始化 .data 段(把初始化的全局变量搬到位);
  • 清零 .bss 段;
  • 打开标准输入、标准输出、标准错误;
  • 调用动态链接器,加载依赖的动态库;
  • 最后调用 libc_start_main,进而调用用户写的 main。

你的 main 函数执行完毕后,控制权回到 _start 的收尾逻辑,处理返回值并调用 exit 终止进程。main 只是“被调用者”。


下面是程序加载四步曲的完整流程,从文件系统定位到 CPU 取指执行:

CPU

ELF 解析

内核

文件系统

路径解析 → inode
找到磁盘数据块

创建 task_struct(PCB)
mm_struct、vm_area_struct

初始化地址空间
建立页表映射
虚拟地址 ↔ 物理地址

读取 Program Header Table
按 Segment 权限映射

读取 Entry Point
_start 虚拟地址

EIP/RIP 填入 _start 地址
开始取指执行

图解: 操作系统先通过文件系统定位 ELF,创建进程骨架,再按 Program Header 初始化地址空间与页表,最后把 _start 的虚拟地址填入 CPU 指令寄存器开始执行。

四、动态链接与共享库:GOT/PLT 与延迟绑定

静态链接虽然简单,但有个致命缺陷:每个可执行文件都独立拷贝一份库代码。如果系统里运行着 20 个 C 程序,内存里就有 20 份 printf 的代码,浪费磁盘和内存。动态链接(Dynamic Linking)解决了这个问题。

4.1 动态库的宏观共享机制

动态库(.so,Shared Object)是独立文件。当进程 A 加载 libc.so 后,它通过页表映射到自己的共享区。如果进程 B 也需要 libc.so,内核只需把同一份物理内存映射到 B 的虚拟地址空间。代码段是只读的,所以多进程可以安全共享同一份物理副本。这也是为什么动态库又叫共享库。

4.2 动态链接器(ld-linux.so)

任何一个动态链接的 ELF 程序,除了依赖 C 标准库,还会默认依赖一个库:/lib64/ld-linux-x86-64.so.2(或类似名称)。这就是动态链接器。在这里插入图片描述

程序启动时,_start 会先调用它。动态链接器根据环境变量 LD_LIBRARY_PATH、/etc/ld.so.conf 配置文件以及系统默认路径,去搜索并加载程序依赖的所有 .so。只有当这些库都被找到、映射、重定位完毕后,才会把控制权交回给 main。

4.3 位置无关代码(PIC)与 GOT

动态库可以被加载到内存的任意位置,那么库内部函数互相调用时怎么办?如果写死绝对地址,库一旦被映射到不同基址就全乱套了。

解决方案是 PIC(Position Independent Code,位置无关代码)。库内部的编址也采用“从 0 开始的偏移量”。当库被映射到进程虚拟空间的某个起始地址(比如 0x7f1234000),库内某个函数的入口是在库内偏移 0x233 处,那么实际虚拟地址就是库起始地址 + 偏移量。

但这还不够。因为主程序调用库函数时,代码段是只读的,你不能直接在主程序的代码里写死库函数的最终地址。怎么办?

GOT(Global Offset Table,全局偏移表)登场。

  • GOT 存放在数据段(可读写),每个进程有自己独立的 GOT。
  • 可执行程序不在代码段直接写 call printf,而是 call printf@GOT。
  • 链接时,GOT 表项先记录库的偏移量和库名;加载完成后,动态链接器把库的实际起始虚拟地址填进 GOT。
  • 从此,代码段一行不改,只改数据段的 GOT 表,程序就能正确跳到库函数。

这完美解决了“代码段只读”与“需要动态修正地址”之间的矛盾。

4.4 PLT:延迟绑定(Lazy Binding)

如果一个程序依赖 10 个动态库、涉及 1000 个库函数,启动时就把这 1000 个地址全部解析并填入 GOT,会非常慢。很多函数可能整个生命周期都不会被调用。

PLT(Procedure Linkage Table,过程链接表) 实现了延迟绑定:

  • 代码中 call printf 实际上先跳到 printf@PLT。
  • 第一次调用时,PLT 是一段“桩代码”,它会把控制权交给动态链接器:“帮我找一下 printf 的真身在哪”。
  • 动态链接器找到真实地址后,把地址写回 GOT 表。
  • 第二次再调用 printf,直接通过 GOT 跳转到真实地址,不再经过 PLT 解析。

这就是“首次调用触发重定位,后续调用直查 GOT”的延迟绑定机制,兼顾了启动速度与运行效率。

下面是首次调用 printf 时延迟绑定的完整流程图,标注了代码段(只读)与数据段(可写)的边界,以及 GOT 表项地址的变化过程:

动态链接器
ld-linux.so

数据段(可写,RW)

代码段(只读,R E)

第一次:GOT 未解析

回填 GOT 表项

初始状态

第二次调用直接跳转

call printf@PLT

PLT 桩代码
jmp *GOT[printf]

push 重定位索引
(如 0x2)

jmp 动态链接器入口
PLT[0]

GOT[printf] = PLT+6
(初始指向下一条指令)

GOT[printf] = 0x7f...
(printf 真实地址)

查找 printf 符号
定位 libc.so 中真实地址

把真实地址写回 GOT[printf]

流程解读:

  1. 第一次调用:call printf@PLT 进入 PLT 桩代码,此时 GOT[printf] 还指向 PLT 内部的下一条指令(PLT+6),于是继续 push 重定位索引并跳转到动态链接器。
  2. 动态链接器解析:根据重定位索引查找 printf 在 libc.so 中的真实虚拟地址。
  3. 回填 GOT:动态链接器把真实地址写回数据段的 GOT[printf],代码段一行未改。
  4. 第二次调用:call printf@PLT 再次进入桩代码,此时 GOT[printf] 已指向真实地址,直接 jmp 到 printf 本体,不再经过动态链接器。
4.5 库依赖库:每个 ELF 都有自己的 GOT

不仅是可执行程序调用动态库,动态库之间也可以相互依赖。比如库 A 调用库 B 的函数。因为每个 ELF(无论可执行文件还是 .so)都有自己的代码段和数据段,所以每个库都有自己的 GOT。库 A 通过查自己的 GOT,找到库 B 的方法地址。这也是 ELF 格式统一性的体现。


五、总结:一张图看清从源码到 CPU 的全链路

这堂课的终极意义,是把看似孤立的编译原理、操作系统文件系统、内存管理、进程加载串成了一条线:

  1. 编译与静态链接:多个 .c → .o → 合并 Section、解析符号、地址重定位 → 生成 ELF 可执行文件。此时程序内部已基于平坦模式从 0 编址(逻辑地址/虚拟地址)。
  2. 加载准备:操作系统通过文件系统找到 ELF,读取 ELF Header 拿到 Entry Point,读取 Program Header 初始化 mm_struct 和 vm_area_struct。
  3. 内存映射:Segment 被加载到物理内存,页表建立虚拟地址到物理地址的映射。权限由 ELF 的 Flag 位决定。
  4. 启动与动态链接:CPU 的 EIP 被填入 _start 虚拟地址。_start 初始化运行环境,调用动态链接器加载 .so,通过 GOT/PLT 完成运行时重定位。
  5. 进入 main:一切就绪后,最终调用 libc_start_main,进入用户编写的 main 函数。

虚拟地址空间是这一切的核心桥梁:它让编译器、链接器、操作系统、CPU 能用同一套“地址语言”对话。编译器在磁盘上按虚拟地址编好程序,操作系统用这些虚拟地址初始化进程空间,CPU 通过页表把虚拟地址翻译为物理地址取指执行。

下堂课,我们将进入进程间通信(IPC)的世界,开发环境也将切换为 C++ 与 VS Code。但那已经是另一个故事了。


附:课堂核心工具速查表

工具/命令作用
readelf -S <file>查看 Section Header Table(编译链接视角)
readelf -l <file>查看 Program Header Table 及 Section → Segment 映射(执行加载视角)
readelf -h <file>查看 ELF Header(Magic、Entry Point、文件类型等)
readelf -s <file>查看符号表(Defined / Undefined 符号)
objdump -d <file>反汇编代码段,查看 call 指令地址,验证重定位
ldd <executable>查看可执行文件依赖的动态库
gcc -c编译不链接,生成 .o 可重定位目标文件
./a.out 背后的 _start程序真正入口,负责初始化与动态链接调度
file <file>命令用于识别文件类型在这里插入图片描述

下面是整篇文章的完整链路总览图,从源码到 CPU 取指执行:

进入 main

启动与动态链接

内存映射

加载准备

编译与静态链接

多个 .c 文件

.o 可重定位目标文件

ELF 可执行文件
(逻辑地址已编好)

文件系统定位 ELF

读取 ELF Header
拿到 Entry Point

读取 Program Header
初始化 mm_struct

Segment 加载到物理内存

页表建立
虚拟地址 ↔ 物理地址

_start 入口

动态链接器加载 .so

GOT/PLT 运行时重定位

libc_start_main → main

图解: 从源码编译、静态链接生成 ELF,到操作系统加载、内存映射、动态链接,最终进入 main 函数——虚拟地址空间贯穿始终,是这一切的核心桥梁。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐