《Linux ELF与C/C++底层运行机制全景揭秘》
第 1 章 导论
1.ELF 文件的三种基本形态 (.o 可重定位文件、.so 共享库、Executable 可执行文件)
1. 1可重定位文件 (Relocatable File, .o 文件)
- 诞生阶段:由编译器和汇编器生成。当你执行 gcc -c main.c -o main.o 时,得到的就是可重定位文件。静态库(.a 文件)本质上也就是把多个 .o 文件打包压缩在一起的集合(类似于零部件仓库)。
- 核心特征:
- 地址未定:在这个阶段,代码里所有的函数地址、全局变量地址全都是 0x00000000(或者相对偏移)。因为此时编译器不知道这个文件最终会被放在内存的哪个位置。
- 重定位表(Relocation Table):这是 .o 文件最重要的资产。它包含 .rela.text 和 .rela.data 这样的节(Section),里面记录着一张“待办事项表”:“请链接器在最终合并时,帮我把第 15 行的 printf 地址填上真正的内存地址。”
- 只有 SHT,通常没有 PHT:因为它不需要被操作系统直接加载到内存运行,所以不需要 PHT(程序头表)。它主要是给链接器 (Linker) 看的。
- ELF 头标志:e_type = ET_REL (值为 1)
1. 2 可执行文件 (Executable File, 通常无后缀)
- 诞生阶段:由链接器(ld)将多个 .o 文件(以及 .a 静态库)缝合在一起,完成地址分配和符号决议后生成。当你执行 gcc main.o utils.o -o my_app 时产生。
- 核心特征:
- 地址已绑定:所有的内部函数调用、全局变量访问的地址,都已经被链接器修正并固定下来了(或者通过相对地址固定)。
- 拥有入口点(Entry Point):ELF Header 中的 e_entry 字段不再为空,它指向程序的绝对起始指令地址(通常是 _start)。
- PHT 是绝对核心:它必须包含 Program Header Table。当你执行这个文件时,内核的 execve 系统调用完全依赖 PHT 来把文件的各个段(代码段、数据段)映射到虚拟内存中。
- ELF 头标志:
- 传统非随机化程序:e_type = ET_EXEC (值为 2)
- **【进阶注意】**现代 Linux 默认开启 PIE(位置无关可执行文件)安全机制,为了让可执行文件也能每次在内存中随机浮动,现代的 Executable 实际上被标记为 e_type = ET_DYN (值为 3)。
1. 3 共享库文件 (Shared Object File, .so 文件)
通俗比喻:城市里的公共加油站或共享充电宝。每辆车(每个进程)需要的时候都可以去用它,而且全城只需要建一份。
- 诞生阶段:通常通过传入特殊编译参数生成。例如 gcc -fPIC -shared math.c -o libmath.so。
- 核心特征:
- 位置无关代码(PIC, Position Independent Code):这是共享库的灵魂。共享库在磁盘上只有一份,在物理内存中也只有一份,但可能被 100 个不同的进程同时使用。每个进程把它映射到自己虚拟内存的地址都不一样(比如进程 A 映射在 0x7fff…,进程 B 映射在 0x7f11…)。代码必须写成“相对寻址”,无论被放在哪都能正常运行。
- 动态段(Dynamic Segment):包含 PT_DYNAMIC 段,里面有动态链接器需要的符号表、动态字符串表、GOT/PLT 依赖等信息,供程序在运行时去寻找和绑定函数。
- 既有 SHT 也有 PHT:它既能被链接器用于验证符号(需要 SHT),也需要在运行时被动态链接器加载到内存中(需要 PHT)。
- ELF 头标志:e_type = ET_DYN (值为 3)
1. 4 对比总结表
为了更直观地理解,你可以使用 readelf -h filename 命令来查看它们头部的细微差别:
| 对比维度 | 可重定位文件 (.o) | 可执行文件 (Executable) | 共享库文件 (.so) |
|---|---|---|---|
| 主要用途 | 供静态链接器 (ld) 组装使用 | 供操作系统 (内核) 直接执行 | 供动态链接器 (ld-linux.so) 运行时加载 |
| e_type (类型) | ET_REL (1) | ET_EXEC (2) 或 ET_DYN (3)[注1] | ET_DYN (3) |
| 内存基地址 | 尚未分配 (0x0) | 已确定起始地址 (如 0x400000) | 相对零地址,加载时才决定实际基址 |
| PHT (程序头表) | ❌ 不需要 (无法直接运行) | ✅ 必须有 (内核靠它映射内存) | ✅ 必须有 (动态装载器靠它映射内存) |
| SHT (节头表) | ✅ 必须有 (链接器靠它找代码和数据) | ⭕ 可选 (运行不需要,可用 strip 删掉) | ⭕ 可选 (但通常保留用于调试) |
| 未决符号 (SHN_UNDEF) | 大量存在 (等着去别的文件里找) | 仅保留动态链接的外部符号 (如 printf) | 包含对外暴露的符号和需要依赖的其他库符号 |
[注1] 进阶补充: 很多初学者会困惑:为什么我现在用 readelf -h a.out 查看编译出的可执行文件,它的类型显示是 DYN (Shared object file) 而不是 EXEC? 这是因为现代 gcc 编译器为了防范黑客攻击,默认开启了 PIE (Position Independent Executable) 机制。编译器把可执行文件也做成了“类似共享库”的样子,让它每次运行的内存地址都不一样,以此来对抗缓冲区溢出时的地址硬编码攻击。
第 2 章 静态解剖:ELF 文件在磁盘上的物理蓝图
1. ELF Header:全盘总控与“指纹鉴定” (Magic, Target ISA, Entry Point)
1. 1 ELF Header 核心
ELF Header:位于文件开头。包含魔数(Magic Number 0x7fELF)、目标架构、入口点地址(e_entry)以及程序头表偏移量(e_phoff)。
- 当你敲下回车执行 ./program 时,内核的第一步操作是:**从磁盘文件的偏移量 0(也就是最开头)开始,读取少量的字节(通常是前 128 或 256 字节)到内核自己的内存中。**在 64 位 Linux 系统中,这个标准的 ELF Header 结构体(Elf64_Ehdr)的大小其实是 64 字节,这64个字节,正是大名鼎鼎的 ELF Header(ELF头)。
- 内核拿到这 64 字节后,首先看前 4 个字节,这就叫魔数(Magic Number)。
- 既然确认了是 ELF 文件,内核就会把这 64 字节当作一个标准的 C 语言结构体(Elf64_Ehdr)来解析。在这个结构体中,藏着 3 个用来寻找 PHT 的至关重要的“坐标变量”:
- e_phoff (Program Header Offset):这是最核心的秘密! 它记录了 PHT 在这个磁盘文件中的绝对偏移量
- e_phentsize (Program Header Entry Size):它记录了 PHT 中每一个表项的大小(比如:每个表项占 56 个字节)。
- e_phnum (Program Header Number):它记录了 PHT 中一共有多少个表项(比如:一共有 13 个段需要处理)。
- PHT 的总大小 = e_phentsize × e_phnum (每个表项大小 × 表项总数)接着,内核向文件系统下达精准的读取指令:
1. 2 ELF Header 核心字段解析表 (64位)
| 字段名 (Elf64_Ehdr) | 占用大小 | 核心作用与意义 |
|---|---|---|
| e_ident | 16 字节 | 文件“指纹”。包含魔数(\x7fELF)、32/64位标志、大小端模式、OS ABI版本等。内核首先检查这部分来确定文件格式。 |
| ==e_type== | 2 字节 | ==文件类型。2 代表可执行文件,3 代表共享库 (.so),1 代表可重定位文件 (.o)。== |
| e_machine | 2 字节 | 目标架构。62 代表 x86-64。内核用它检查该文件是否能在当前的 CPU 硬件上运行。 |
| e_version | 4 字节 | 版本号。永远是 1 (EV_CURRENT)。 |
| ==e_entry== | 8 字节 | ==程序入口地址。程序启动后,CPU 执行的第一条指令的虚拟地址(通常指向 _start)。== |
| ==e_phoff== | 8 字节 | ==PHT 偏移量。告诉内核 PHT (程序头表) 在磁盘文件中的起始位置(通常紧跟在 Header 后面)。== |
| e_phnum | 2 字节 | PHT 项数。文件中有多少个“段 (Segment)”需要被加载或处理。 |
| e_phentsize | 2 字节 | PHT 项大小。每一个 PHT 表项的固定长度(64位系统标准为 56 字节)。 |
| e_shoff | 8 字节 | SHT 偏移量。SHT (节头表) 在文件中的位置。它主要用于调试和链接,运行时内核通常不看它。 |
| e_flags | 4字节 | 处理器标志。x86-64 下通常为 0;ARM 下包含 ABI 信息。 |
| e_ehsize | 2 字节 | 本头部大小。即 ELF Header 自身的大小,固定为 64 字节。 |
| e_shentsize | 2 字节 | SHT 项大小。每一个 SHT 表项的长度(标准为 64 字节)。 |
| e_shnum | 2 字节 | SHT 项数。文件中总共有多少个“节 (Section)”。 |
| ==e_shstrndx== | 2 字节 | ==【核心】节名字字符串表索引。它记录了 .shstrtab 这个节在 SHT 数组里的下标。= |
2. Section Header Table (SHT):链接视图的“零件清单”
2. 1 SHT 的本质定义
- 链接视图(Linking View)的基石:如果说 PHT 描述的是“如何运行”,那么 SHT 描述的就是“代码和数据是如何按逻辑分类存放的”。
- 零件清单:它是一张详细的索引表,记录了 ELF 文件中每一个“节(Section)”的名字、大小、在文件中的位置以及它的用途。
- 位置特性:虽然 ELF 标准允许 SHT 出现在文件的任何位置,但按照惯例,它通常被放置在 ELF 文件的末尾。
2. 2 SHT 的物理结构 (Elf64_Shdr)
在 64 位系统中,每个 SHT 表项占 64 字节。每一个表项(Entry)描述一个节。
| 字段名 (Elf64_Shdr) | 占用大小 (64位) | 含义 | 详细说明 |
|---|---|---|---|
| sh_name | 4 字节 | 节名称索引 | 一个偏移量,指向 .shstrtab 节,那里才存着真正的名字(如 .text)。 |
| sh_type | 4 字节 | 节类型 | 决定了这个节存的是什么(代码、符号表、重定位信息等)。 |
| sh_flags | 8 字节 | 节标志 | 定义了该节的属性(是否可写、是否在运行时占用内存、是否可执行)。 |
| sh_addr | 8 字节 | 虚拟地址 | 如果该节要在内存中运行,这里记录它在内存中的起始地址(不运行则为 0)。 |
| sh_offset | 8 字节 | 文件偏移 | 该节在磁盘文件中的起始位置。 |
| sh_size | 8 字节 | 节大小 | 该节在文件中占据的字节数。 |
| sh_link | 4 字节 | 链接索引 | 关联到另一个节的索引(例如:符号表节会链接到字符串表节)。 |
| sh_info | 4 字节 | 额外信息 | 根据节类型不同,含义不同。 |
| sh_addralign | 8 字节 | 地址对齐 | 该节的对齐要求(必须是 2 的幂)。 |
| sh_entsize | 8 字节 | 项大小 | 如果该节包含固定大小的项(如符号表),这里记录每项的大小。 |
3. 核心 Section 深度解析 (.text, .data, .bss, .rodata)
3. 1 .text (代码节):程序的“大脑”
- 里面装了什么? 编译器翻译出来的纯机器码指令。你写的所有的 if/else、for 循环、函数调用,最终都变成了 CPU 认识的二进制指令存在这里。
- 底层属性 (sh_type):SHT_PROGBITS(程序定义数据,意味着它在磁盘文件中实实在在地占有字节空间)。
- 内存权限:R-X(只读 + 可执行)。
- 深度细节:
- 为什么不能写(防篡改):为了防止“自修改代码(Self-modifying code)”和缓冲区溢出后的“代码注入攻击”。如果你试图在代码里用指针强行修改 .text 段的内容,CPU 会立刻抛出 Segmentation Fault。
- 多进程共享:当你同时打开 100 个 vim 编辑器时,物理内存中其实只有一份 vim 的 .text 节。操作系统通过页表,让 100 个进程共享这一块不可变的代码,极大地节省了内存。
3. 2..rodata (只读数据节):绝对的“真理”
-
里面装了什么? 只读(Read-Only)数据。通常包括:
- 字符串常量:比如 printf(“Hello World\n”); 中的 “Hello World\n”。
- const 修饰的全局变量(注意:局部 const 变量通常还在栈上或被优化到寄存器里)。
- C++ 里的虚函数表(vtable)、switch 语句生成的跳转表等。
-
底层属性:SHT_PROGBITS(在磁盘上占空间)。
-
内存权限:R–(只读)。它和 .text 的区别在于,它不可执行。
-
深度细节(经典面试题): 为什么这段代码会崩溃?
char *str = "Hello"; str[0] = 'M'; // 运行时引发 Segfault 崩溃!真相:“Hello” 这个字符串被链接器塞进了 .rodata 节。运行时该节的内存页被 MMU(内存管理单元)标记为只读。str 是一个指针,它指向了这块只读内存。试图修改它,就是试图挑战硬件级的内存保护。
3. 3 .data (已初始化数据节):带资进组的“富二代”
- 里面装了什么? 明确被初始化为非零值的全局变量和静态局部变量(static)。
- 底层属性:SHT_PROGBITS。因为它们有具体的值(比如 100、3.14),所以这些值必须被原封不动地保存在 ELF 的磁盘文件中。
- 内存权限:RW-(可读 + 可写)。
- 深度细节: 如果你的程序里有一个超级巨大的数组 int arr[1000000] = {1, 2, 3};,即使只有前三个元素被初始化,为了保持连续性,整个数组也会被放进 .data,这将导致你的 ELF 文件在磁盘上暴增 4MB(1000000 * 4字节)!
3. 4 .bss (未初始化数据节):“空手套白狼”的魔法
- 里面装了什么? 未初始化,或者被明确初始化为 0 的全局变量和静态变量。
- 底层属性:SHT_NOBITS(注意!这是它最伟大的地方)。
- 内存权限:RW-(可读 + 可写)。
- 深度细节(零成本魔法):
- 磁盘上 0 字节:对于 int arr[1000000];,ELF 文件在磁盘上根本不会为它分配 4MB 的空间!在 SHT 中,.bss 表项仅仅记录了一句话:“我需要 4MB 的空间”。ELF 文件大小几乎不受影响。
- 内存中全为 0:当程序启动时(execve),操作系统内核读到 .bss 的需求,会在进程的虚拟内存中找一块 4MB 的空地,将其映射到物理内存,并由内核强制清零。
3. 5 源码举例
让我们写一段 C 代码,然后化身编译器,看看每一行代码最终去了哪个 Section:
#include <stdio.h>
// ----------------- 全局区 -----------------
int a = 100; // 【.data】 :全局,已初始化为非零
int b = 0; // 【.bss】 :全局,初始化为 0(编译器优化,直接放进bss)
int c; // 【.bss】 :全局,未初始化
const int d = 314; // 【.rodata】:全局,被 const 修饰
// ----------------- 代码区 -----------------
void my_func() { // 【.text】 :函数的机器码指令在此
// ------------- 局部静态区 -------------
static int e = 200; // 【.data】 :静态,已初始化非零
static int f; // 【.bss】 :静态,未初始化
// ------------- 局部动态区 -------------
int g = 300; // 【栈 (Stack)】:普通局部变量,不在 ELF 的节里,运行时在栈上分配
// ------------- 经典陷阱区 -------------
char *str1 = "Apple"; // 指针 str1 在【栈】,但字符串 "Apple\0" 在【.rodata】
char str2[] = "Banana"; // 数组 str2 在【栈】,运行到这里时,把指令里的 "Banana" 拷贝到栈上。
} // (注意:str2 这种写法是可以修改内容的,因为是在栈上修改!)
4. 符号表 (.symtab) 与名字池 (.strtab / .shstrtab) 机制
在 ELF 文件的底层世界里,**“符号表(.symtab)”与“字符串名字池(.strtab / .shstrtab)”的设计,堪称 C 语言数据结构中“定长数组与变长数据分离”**的教科书级典范。
4. 1 .符号表 (.symtab) 与名字池 (.strtab / .shstrtab) 分离机制
在链接过程中,链接器需要频繁地遍历、查找所有的函数名和变量名。为了保证查找效率(O(1) 索引),符号表必须是一个“定长结构体数组”。 在 64 位系统中,每个符号表项(Elf64_Sym)严格占据 24 字节。
但这就带来了一个致命问题:人类起的名字长度是不固定的! 有的人写函数叫 main(4 个字符),有的人写 C++ 函数能起出长达 100 字符的逆天名字(特别是 Mangling 后的模板函数)。
- 如果把名字直接存在符号表结构体里,分配小了放不下,分配大了(比如每个符号固定给 256 字节存名字)又会造成极其恐怖的磁盘空间浪费。
ELF 的绝妙解法:引入字符串池(String Table)。 符号表的结构体里绝对不存真正的字符串,而是只存一个 st_name(4 字节的整数),这个数字代表该名字在“字符串池”里的字节偏移量(Offset)。
4. 2 . 名字池的内部构造(以 .strtab 为例)
所谓字符串表(.strtab),在物理磁盘上,就是一个首尾相连、中间用 \0(Null字符)隔开的巨大文本缓冲区块。
假设你的程序里有 main、printf、my_var 三个符号。.strtab 在十六进制编辑器里看起来是这样的:
| 偏移量 (Offset) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 字符 | \0 | m | a | i | n | \0 | p | r | i | n | t | f | \0 | m | y | _ | v | \0 |
- 第 0 个字节永远是 \0。这是为了方便处理空名字(如果一个符号没有名字,它的 st_name 就填 0,读出来正好是个空字符串)。
- 如果符号表里记录 main,它的 st_name 字段的值就是 1(从偏移 1 开始读,遇到 \0 结束)。
- 如果要找 printf,它的 st_name 字段的值就是 6。
【高级特性:后缀复用】 如果字符串池里有 “core_dump” 和 “dump” 两个名字,ELF 编译器会自动优化,把 “dump” 指向 “core_dump” 的后半部分。这样连一个字节都不会浪费!
4. 3 . 符号表(.symtab)的 24 字节定长解剖
当我们拿到偏移量后,回头再看符号表(Elf64_Sym),一切就豁然开朗了。每一条 24 字节的记录都像是一个“户籍证明”:
| 字段名 | 占用 (64位) | 核心含义 (链接器的红娘手册) |
|---|---|---|
| st_name | 4 字节 | 名字指针:拿着这个数字(如 6),去 .strtab 里就能把 printf 这个词捞出来。 |
| st_info | 1 字节 | 身份属性: 高 4 位表示 Binding(是 GLOBAL 全局的,还是 LOCAL 局部的?)。 低 4 位表示 Type(是 FUNC 函数,还是 OBJECT 变量?)。 |
| st_other | 1 字节 | 可见性 (Visibility):控制该符号是否允许被外部动态库调用(如 DEFAULT 或 HIDDEN)。 |
| st_shndx | 2 字节 | 归属地 (Section Index):这个符号实体存放在哪个 Section 里(比如 1 代表 .text,3 代表 .data)。如果是外部引用的,这里就是极其核心的魔数 SHN_UNDEF (0)。 |
| st_value | 8 字节 | 具体坐标:在 .o 中,它是相对于所在 Section 的偏移量;在可执行文件中,它是真正的绝对虚拟内存地址。 |
| st_size | 8 字节 | 占地面积:一个 double 变量这里就是 8,一个函数这里就是它内部所有机器码加起来的字节数。 |
4. 4. 孪生兄弟:.strtab 与 .shstrtab 的区别
初学者最容易搞混的是:为什么 ELF 文件里通常会有两个字符串池?
很简单,它们服务的“客户”不同:
.strtab (String Table - 普通符号字符串表)
- 服务对象:程序员。
- 内容:存储的是你代码里写的函数名(main)、全局变量名(g_count)以及库函数名(printf)。
- 配套总表:与 .symtab(符号表)配套使用。
- 生命周期:主要给静态链接器和调试器看。发布软件时,如果用 strip 命令,.strtab 会被无情抹除(不影响程序运行,但会导致反汇编出来全是没有名字的 sub_401000 这样的纯地址)。
.shstrtab (Section Header String Table - 节头字符串表)
- 服务对象:ELF 格式自身(系统工具)。
- 内容:专门存储各种 Section 的名字(如 .text、.data、.bss、.rodata)。
- 配套总表:与 **SHT(节头表)**配套使用。每一个节头(Elf64_Shdr)里的 sh_name 字段,存放的就是相对于 .shstrtab 的偏移量。
- 启动机制(“提着自己的头发把自己拔起来”): 你可能会问:如果所有节的名字都在 .shstrtab 里,那系统一开始是怎么知道 .shstrtab 本身在哪里的呢? 答案在最开头的 64 字节 ELF Header 中! ELF 头的最后 2 个字节 e_shstrndx,直接记录了 .shstrtab 这个节在 SHT 数组里的索引号!这就打破了“鸡生蛋还是蛋生鸡”的死循环。
4. 5. 寻址大挑战:还原一个函数名的完整过程
让我们模拟一下 readelf -s 命令是如何从二进制字节流中打印出 main 函数信息的:
- 找池子:读取 SHT(节头表),遍历寻找 sh_type == SHT_STRTAB 的节,拿到普通字符串池(.strtab)在文件中的绝对偏移地址(假设在文件的第 5000 字节处)。
- 读目录:读取 .symtab 节,把里面所有的 24 字节结构体读成一个大数组。
- 查字典:遍历这个数组,发现第 5 号表项的 st_name 等于 12。
- 拼名字:去文件的 5000 + 12 = 5012 字节处开始读字符,读到 m, a, i, n,下一字节是 \0。
- 组合信息:结合 st_value (地址) 和 st_size (大小),终端打印出: Num: 5, Value: 0x401126, Size: 43, Type: FUNC, Bind: GLOBAL, Name: main
5.【核心】SHN_UNDEF:未定义引用的本质与链接器符号决议
5. 1 . st_shndx 与 SHN_UNDEF的区别
我们在前面的符号表结构体(Elf64_Sym)中提到过,每个符号都有一个 2 字节的字段叫 st_shndx (所属节索引)。
这个字段是链接器用来判断“谁拥有实体数据”的唯一标准:
-
真正的地主(Defined Symbol):
如果在 utils.c 里写了 void print_hello() { … },那么在 utils.o 的符号表里,print_hello 的 st_shndx 可能等于 1(假设 1 代表 .text 节)。 含义:“这个函数是我亲生的,它的实体代码就在我的第 1 个节里。”
-
空手套白狼(Undefined Symbol):
在 main.o 的符号表里,print_hello 的 st_shndx 会被强行赋值为 SHN_UNDEF(宏定义为 0)。 含义:“我只是个伸手党,我需要用到这个符号,但我这里没有它的实体,请链接器大哥帮我去别的地方找找。”
extern 的核心使命只有一个:阻止编译器分配内存空间,强制其生成 SHN_UNDEF 符号,把寻找物理地址的工作全面甩锅给链接器。 它是跨文件共享全局变量的唯一正确姿势。
6. Section 的剥离 (strip) 与调试信息 (.debug / DWARF 简介)
第 3 章 静态链接:从“碎片”到“整体”的拼装艺术
1. 空间与地址分配 (相似 Section 提取与合并)
1. 1. 相似 Section 的提取与合并
合并策略:同类项合并
链接器采用的策略极其直截了当:“天下代码归代码,天下数据归数据”。
- 它会扫描输入的所有 .o 文件(如 a.o, b.o)。
- 将 a.o 的 .text 和 b.o 的 .text 抽取出来,首尾相接地拼装成一个巨大、连续的最终 .text 节。
- 对 .data、.bss、.rodata 执行同样的拼接操作。
/* 1. 宏观建筑图纸:定义 3 个物理集装箱 (Segment) */
PHDRS
{
boot PT_LOAD;
text PT_LOAD;
data PT_LOAD;
}
/* 2. 微观零件分配:将 Section 放入 Segment,并严格控制内存地址 */
SECTIONS
{
/* 定位符 (Location Counter) 设为 0x7C00 (BIOS 引导约定地址) */
. = 0x7C00;
/* ---------------------------------------------------- */
/* 集装箱 1:boot (启动代码) */
/* ---------------------------------------------------- */
.boot :
{
*(.boot_code)
} :boot
/* 准备进入内核主代码区,定位符跳转到 1MB (0x100000) 处 */
. = 0x100000;
/* ---------------------------------------------------- */
/* 集装箱 2:text (代码与只读常量) - 权限 R-X */
/* ---------------------------------------------------- */
/* 使用 ALIGN(4K) 确保这个大节的起点,绝对在 4096 的倍数地址上 */
.text : ALIGN(4K)
{
*(.text)
*(.rodata)
} :text
/* ---------------------------------------------------- */
/* 集装箱 3:data (可读写数据) - 权限 RW- */
/* ---------------------------------------------------- */
/* 【核心保命符】:再次强制 4K 页面对齐!
哪怕上面的 .text 只占了 1KB,这里也会用 3KB 的 0 填充跳过去。
这就保证了 .text 和 .data 绝对不会混在同一个 4KB 的内存页里! */
.data : ALIGN(4K)
{
*(.data)
} :data
/*
注意:.bss 被分配在和 .data 相同的段 (:data) 里。
因为它们的权限都是 RW-,所以它们之间 **不需要** 4K 页对齐隔离。
但是为了 CPU 读取数据的总线效率,通常会做 4 字节或 8 字节的小对齐。
*/
.bss : ALIGN(8)
{
*(.bss)
} :data
}
1. 2. 空间与虚拟地址分配 (VMA 分配)
合并完实体数据后,链接器面临一个关键问题:把这块巨大的数据放在虚拟内存的哪里?
在 .o 阶段,你会发现所有 Section 的虚拟地址(sh_addr)全都是 0x00000000。因为编译器并不知道未来自己会被放在哪。而到了链接阶段,一切都要“尘埃落定”。
1. 2. 1 寻找基石:链接脚本 (Linker Script)
链接器并不会瞎分地址。它手头有一份“最高指示”——链接脚本(如 GNU ld 的缺省脚本)。 在 x86-64 的 Linux 系统中,链接脚本默认规定:
- 可执行程序的起始虚拟地址(VMA) 通常从 0x400000 开始(传统非 PIE 程序)。
- 代码段(.text)放在最前面。
- 随后,加上一定的偏移与页对齐,放置数据段(.data / .bss)。
1.2. 2. “摊大饼”式地址计算算法
链接器会按顺序为每个合并后的巨大 Section 计算地址。这是一个简单的累加过程:
-
确定 .text 终极位置:
假设基址从 0x401000 开始。 a.o 的代码占 0x100 字节,b.o 的代码占 0x200 字节。
- 最终 .text 节的起始地址 = 0x401000,总大小 = 0x300 字节。
- a.o 代码段的起始地址变成了 0x401000。
- b.o 代码段的起始地址变成了 0x401000 + 0x100 = 0x401100。
-
对齐与跳跃:
为了保证内存页的权限隔离,代码区结束后,链接器会将地址向前跳跃到下一个 4KB 对齐的边界(或者通过 Padding 填充),然后再开始放置 .data 节。比如跳到 0x402000。
-
确定 .data 终极位置:
在 0x402000 接着往后排列 a.o 和 b.o 的数据。
1. 2. 3 . 符号地址更新(核心意义!)
地址分配最重要的一环,是更新全局符号表(GST)中所有符号的地址。
在合并之前,main 函数在 a.o 的符号表里,其地址(st_value)可能只是个局部的偏移量(比如 0x40)。 但在合并、并且整个大 .text 节被安置在 0x401000 之后: 链接器会执行极其重要的计算:
main 最终虚拟地址 = a.o 代码段在最终文件中的起始地址 + main 在 a.o 中的原始偏移 即:0x401000 + 0x40 = 0x401040
此时,程序里所有的函数(如 main, add)、所有的全局变量(如 g_count),都获得了独一无二、确凿无疑的绝对虚拟地址!
2. 符号决议 (Symbol Resolution):链接器的“寻人启事”
2. 1强符号 (Strong) 与弱符号 (Weak) 的覆盖规则 (attribute((weak)))
2. 1. 1链接器的“丛林法则”(三大覆盖规则)
当静态链接器(ld)在合并多个 .o 文件,发现全局符号表(GST)中存在名字完全相同的符号时,会严格执行以下三条仲裁法则:
规则 1:强 + 强 = 报错(一山不容二虎)
如果两个 .c 文件都定义了 void my_func() {},或者都写了 int count = 10;。
- 结果:链接器立刻崩溃,抛出致命错误:multiple definition of ‘my_func’。
规则 2:一强 + 多弱 = 强胜出(以强克弱)
如果 lib.c 中有一个被标记为 weak 的 my_func,而 main.c 中有一个普通的 my_func。
- 结果:链接器不会报错。它会将所有对 my_func 的引用,全部静默地指向 main.c 中的强符号地址。那个 weak 函数的代码被彻底抛弃(或成为死代码)。
规则 3:弱 + 弱 = 选体积最大的(或先到先得)
如果多个 .c 文件里都写了未初始化的全局变量 int count;(或者一个是 int count; 另一个是 double count;)。
- 结果:链接器不报错。为了防止内存越界,它通常会挑一个占用字节数最大的符号作为最终地址;如果大小一样,就随便挑最先扫描到的那个。
2. 1. 2 弱/强符号在 ELF 文件里的底层原理
链接器凭什么知道谁是弱符号?秘密就在我们之前讲过的 .symtab(符号表)里的 ==st_info== 字段。
高 4 位:Binding (绑定属性 / 作用域)
高 4 位回答了链接器一个极其关键的问题:“这个符号是对外公开的,还是私有的?是强还是弱?”
常用的宏定义有 3 个:
| 宏定义 (Bind) | 值 (十进制) | 含义与 C 语言映射 | 链接器行为 |
|---|---|---|---|
| STB_LOCAL | 0 | 局部符号。 对应 C 语言中加了 static 关键字的函数或全局变量。 |
隐身模式。链接器在对账时会完全无视 LOCAL 符号,别的 .o 文件绝对找不到它,完美避免命名冲突。 |
| STB_GLOBAL | 1 | 全局符号(强符号)。 对应 C 语言中普通的函数和全局变量。 |
张扬模式。对外可见。如果其他文件有同名的 GLOBAL,链接器直接报错 multiple definition。 |
| STB_WEAK | 2 | 弱符号。 对应加了 attribute((weak)) 的函数。 |
备胎模式。对外可见。遇到同名的 GLOBAL 符号会主动让路。 |
低 4 位:Type (符号类型)
低 4 位回答了调试器和装载器另一个问题:“这块内存地址里,到底存放的是什么东西?”
常用的宏定义有 5 个:
| 宏定义 (Type) | 值 (十进制) | 含义与代码映射 | 核心作用 |
|---|---|---|---|
| STT_NOTYPE | 0 | 未知类型。 通常是汇编代码里没有明确指定类型的标签,或者未定义引用(SHN_UNDEF)。 |
还没搞清楚,先占个位。 |
| STT_OBJECT | 1 | 数据对象。 对应 C 语言里的全局变量、数组。 |
告诉系统这是一块数据,别把它当指令去执行。 |
| STT_FUNC | 2 | 函数/代码。 对应 C 语言里的函数。 |
告诉动态链接器,如果在给它重定位时,可能需要用到 PLT(延迟绑定)机制。 |
| STT_SECTION | 3 | 节 (Section) 本身。 用来做重定位的基准点。 |
通常由编译器生成,对 C 程序员不可见。 |
| STT_FILE | 4 | 源文件名称。 对应产生该 .o 的源码文件名(如 main.c)。 |
辅助调试,标识后面的符号都属于哪个文件。 |
3. 静态重定位 (Relocation):修正 .o 文件中的“占位符”地址
3. 1.重定位表详解 (.rela.text / .rela.data)
在完成了前两步的“提取合并”和“符号决议(对账)”之后,链接器拿到了所有符号的最终绝对地址。
现在,链接器要执行静态链接的最后一步,也是最纯粹的物理操作:填坑(Patching)。
在编译器生成目标文件(.o)时,凡是遇到不知道地址的外部函数(如 printf)或外部变量,它都会在机器码中留下一个假的占位符(比如 00 00 00 00)。 重定位表(Relocation Table),就是编译器留给链接器的一份“施工修改图纸”,上面清楚地记录着:“在这个文件的第 X 个字节处,有一个占位符,请你以后查到 Y 符号的地址后,用 Z 公式把它填上。”
3. 1. 1重定位表的双子星:.rela.text 与 .rela.data
在一个标准的 .o 文件中,重定位表通常成对出现,它们各自负责不同区域的“填坑”工作:
- .rela.text (代码重定位表):
- 负责区域:专管 .text(机器指令)里的占位符。
- 典型场景:你调用了外部函数 print_hello(),或者访问了外部的全局变量。汇编指令中的跳转偏移量或寻址偏移量需要被修正。
- .rela.data (数据重定位表):
- 负责区域:专管 .data(已初始化数据)里的占位符。
- 典型场景:如果你写了这样的 C 代码:int* ptr = &global_var;。指针 ptr 存放在 .data 节中,它的初始值必须是 global_var 的绝对地址。这个地址在编译期是不知道的,所以需要 .rela.data 来指导链接器在最后时刻修改指针的值。
3. 1. 2 施工图纸的底层结构 (Elf64_Rela)
重定位表本质上是一个结构体数组。在 64 位系统中,每一个重定位表项(Elf64_Rela)固定占据 24 字节。
在 Linux 的 <elf.h> 中,它的结构极其精简,只有 3 个字段:
| 字段名 | 占用 (64位) | 核心含义 (链接器的施工说明) |
|---|---|---|
| r_offset | 8 字节 | 填坑位置 (Where)。 在 .o 文件中,它是相对于被修改节(如 .text)起始位置的字节偏移量。告诉链接器“切除手术在哪一寸肌肤上动刀”。 |
| r_info | 8 字节 | 找谁 (Who) + 怎么算 (How)。 这是一个被压缩的字段。高 32 位是目标符号在符号表中的索引号(拿着它去找地址);低 32 位是重定位类型(决定了使用哪套数学公式计算,见下一节)。 |
| r_addend | 8 字节 | 加数/补偿值 (Adjustment)。 有些计算需要额外的偏移。比如你引用了 arr[4],找到了 arr 的基址后,还需要在这个基址上加上额外的字节数,这个附加值就存在这里。 |
3. 1. 2 链接器的填坑全过程 (现场模拟)
让我们通过一个极简的例子,看看链接器是如何拿着这张图纸施工的。
C 源码 main.c:
extern void print_hello();
int main() {
print_hello(); // 调用外部函数
return 0;
}
第一阶段:编译期的“半成品” (main.o)
编译器把 main.c 翻译成机器码时,遇到了 print_hello() 调用:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: e8 00 00 00 00 callq 9 <main+0x9> # 注意这 4 个字节的 00 00 00 00!
9: b8 00 00 00 00 mov $0x0,%eax
编译器在偏移量 0x5(也就是 00 00 00 00 开始的地方)留下了坑,并在 .rela.text 中生成了一条记录:
- r_offset = 0x5 (坑的位置)
- r_info 高32位 = 指向 print_hello 的符号索引 (告诉链接器去找它的地址)
- r_info 低32位 = R_X86_64_PC32 (一种重定位类型,意思是“这是一个 32 位的相对地址跳转,请用相对公式计算”)
- r_addend = -4 (因为 x86 CPU 执行 call 指令时,PC 寄存器已经指向了下一条指令,需要减去 4 个字节修正) 因为链接器修改的地址(P,即操作数开始的地方)和 CPU 计算跳转时所站的地址(RIP,即操作数结束的地方)之间,恰好隔了那 4 个字节的偏移量本身。这 -4 就是为了补齐这中间的“四步时差”。
第二阶段:链接期的“完美填坑”
链接器启动,开始对账并分配地址。假设链接器最终决定:
- main 函数放在虚拟地址 0x401000。
- 于是,占位符所在位置的绝对虚拟地址 (P) = 0x401000 + 0x5 = 0x401005。
- print_hello 函数被分到了虚拟地址 0x401050。这个目标地址我们称为 (S)。
链接器读取 .rela.text 的记录,看到计算公式是 R_X86_64_PC32。 该类型的硬件标准数学公式为:最终填入的值 = S + A - P (目标地址 + 加数 - 坑所在的绝对地址)
链接器开始算算术:
- S (print_hello 地址) = 0x401050
- A (r_addend) = -4
- P (坑的绝对地址) = 0x401005
- 最终值 = 0x401050 + (-4) - 0x401005 = 0x47
链接器果断拿起电烙铁,把机器码修改为:
4: e8 47 00 00 00 callq ... # 00 00 00 00 被修改成了 47 00 00 00 (小端序)
当程序运行时,CPU 执行到 call 指令,会用当前的 PC 值(指令结束时的地址 0x401009)加上相对偏移量 0x47,正好等于 0x401050,精准命中 print_hello 的真实地址!
3. 2绝对寻址与相对寻址的修正公式
为了应对这些操作,ELF 规范(特别是 x86-64 ABI)定义了多达几十种重定位类型(写在 r_info 的低 32 位中)。但万变不离其宗,最核心、最基础的重定位数学公式只有两大门派:绝对寻址与相对寻址。
链接器在执行修改时,手里一直握着三个核心基础变量:
- S (Symbol):目标符号被分配到的最终绝对虚拟地址。
- P (Position):需要被修改的那几个字节(坑)的最终绝对虚拟地址。
- A (Addend):编译器在重定位表项(Elf64_Rela)中留下的附加值/补偿加数。
3. 2.1 第一大门派:绝对寻址 (Absolute Addressing)
核心思想:“我不管我现在身在何处,我只要那个东西的绝对坐标。” 经典重定位类型:R_X86_64_64 (64位绝对地址) 或 R_X86_64_32 (32位绝对地址) 链接器计算公式:最终填入值 = S + A
典型触发场景
在 C 语言中,最典型的触发场景是**“全局指针变量的初始化”**。
extern int global_var; // 声明外部变量
int* ptr = &global_var; // 场景:用外部变量的绝对地址来初始化指针 ptr
底层填坑推演
当编译器编译 int* ptr = &global_var; 时:
-
ptr 被放进了 .data 节,占用 8 个字节(64位指针)。
-
但 global_var 的地址在编译期是未知的。所以编译器在这 8 个字节里填入 00 00 … 00。
-
编译器在 .rela.data 中生成一条记录:
- r_offset = ptr 的位置
- Type = R_X86_64_64
- r_addend (A) = 0(不需要额外补偿)
链接器开工: 假设链接器最终把 global_var 安排在了虚拟地址 0x601000 (S)。 链接器读取记录,看到类型是 R_X86_64_64,直接套用绝对公式:
填入值 = S + A 填入值 = 0x601000 + 0 = 0x601000
链接器直接把 0x601000 这 8 个字节的绝对物理数据,硬塞进 ptr 所在的内存坑位中。当程序运行时,ptr 里面存放的就是准确无疑的绝对地址。
3. 2. 2第二大门派:相对寻址 (Relative Addressing / PC-Relative)
核心思想:“我不在乎世界上的绝对坐标在哪,我只关心目标在我的前面还是后面,距离我有多远。” 经典重定位类型:R_X86_64_PC32 链接器计算公式:最终填入值 = S + A - P
典型触发场景
现代计算机架构中最核心的寻址方式!包括函数调用 (call)、条件跳转 (jmp) 以及 x86-64 引入的RIP 相对数据寻址 (lea)。
extern void print_hello();
void test() {
print_hello(); // 场景:函数调用
}
相对寻址的本质( 为什么需要减去 P?)
正如我们前面深刻剖析过的,CPU 执行相对跳转指令时,它的运算逻辑是:目标地址 = CPU当前位置 (RIP) + 机器码里的相对偏移量。
既然机器码里要填的是“相对偏移量”,那它代表的物理意义就是两点之间的距离差。
- 终点坐标是 S (目标函数地址)。
- 起点坐标是 P (当前指令坑位的地址)。
- 两者的纯物理距离就是:S - P。
这就是为什么相对寻址的公式里必须有一个 - P。链接器用目标地址减去自身所处的地址,算出了“两地之间的距离”,然后把它填入机器码中。(当然,最后还要加上我们之前推导过的补偿值 A = -4,来弥补 CPU 取指完毕后的 RIP 滑动)。
自下而上的空间聚合 + 自上而下的地址分发 + 基于 Atom 数组的线性遍历,这就是现代静态链接器这座复杂工厂中,最清晰、最核心的三条流水线。
第 4 章 装载与映射:进程虚拟地址空间 (VAS)
1. 用户态虚拟内存全景剖析 (栈、堆、mmap、BSS、Data、Text)
2. Linux 内核空间布局概览 (Direct Mapping, vmalloc, Slab 内存池)
2. 1Linux 内核空间布局映射表 (x86_64)
| 起始地址 (VIRTUAL ADDRESS) | 结束地址 (END ADDRESS) | 区域大小 | 区域名称 (REGION NAME) | 详细用途描述 |
|---|---|---|---|---|
| 0xFFFF800000000000 | 0xFFFF87FFFFFFFFFF | 8 TB | GUARD HOLE | 防止越界的空洞(不可访问) |
| 0xFFFF888000000000 | 0xFFFFC87FFFFFFFFF | 64 TB | DIRECT MAPPING (PHYSMAP) | 直接映射区:物理内存与虚拟地址线性映射(VA = PA + Offset) |
| 0xFFFFC88000000000 | 0xFFFFC8FFFFFFFFFF | 0.5 TB | GUARD HOLE | 隔离空洞 |
| 0xFFFFC90000000000 | 0xFFFFE8FFFFFFFFFF | 32 TB | VMALLOC AREA | VMALLOC 区:用于 VMALLOC() 分配,物理不连续但虚拟连续的内存 |
| 0xFFFFE90000000000 | 0xFFFFE9FFFFFFFFFF | 1 TB | GUARD HOLE | 隔离空洞 |
| 0xFFFFEA0000000000 | 0xFFFFEAFFFFFFFFFF | 1 TB | VMEMMAP | 虚拟页表映射:存放 STRUCT PAGE 结构体数组,管理物理内存页 |
| 0xFFFFEB0000000000 | 0xFFFFFABFFFFFFFFF | ~16 TB | GUARD HOLE | 巨大的安全隔离区 |
| 0xFFFFF80000000000 | 0xFFFFFF7FFFFFFFFF | 1 TB | KASAN SHADOW | KASAN 影子内存:内核地址检测工具使用的专用内存(调试版) |
| 0xFFFFFFFF80000000 | 0xFFFFFFFF9FFFFFFF | 512 MB | KERNEL IMAGE | 内核镜像区:存放内核代码 (TEXT)、已初始化数据 (DATA)、BSS |
| 0xFFFFFFFFA0000000 | 0xFFFFFFFFFF5FFFFF | 1.5 GB | MODULES AREA | 内核模块区:存放动态加载的驱动程序 (.KO) |
| 0xFFFFFFFFFF600000 | 0xFFFFFFFFFF600FFF | 4 KB | VSYSCALLS | 早期系统调用加速区(现已基本废弃,由 VDSO 取代) |
| 0xFFFFFFFFFF800000 | 0xFFFFFFFFFFFFFFFF | 8 MB | FIXMAPS & CPU ENTRY | 固定映射区:用于中断向量表、APIC 寄存器映射等最后位置 |
2. 2 核心区域深度解析
2.2.1直接映射区
直接映射区(Direct Map,也常被称为 physmap) 是 Linux 内核空间中最简单、最强大,也是最核心的映射区域。
如果把内核空间比作一座办公大楼,直接映射区就是一楼的大厅,它按照 1:1 的比例物理内存“镜像”到了虚拟地址空间里。
2. 2. 1. 1 Direct Map 的核心定义
在 64 位系统上,直接映射区通过一个简单的线性偏移(Linear Offset),将系统中几乎所有的物理内存(RAM)直接映射到内核的虚拟地址中。
- 映射公式:虚拟地址 (VA) = 物理地址 (PA) + PAGE_OFFSET
- PAGE_OFFSET:在 x86_64 系统上通常是 0xffff888000000000。
这意味着,如果内核想要访问物理地址 0x1000 处的内存,它只需要访问虚拟地址 0xffff888000001000 即可。不需要复杂的页表查询,只需要一个简单的加法运算。
2. 2. 1. 2 Direct Map 的详细作用
A. 极速地址转换 (VA <-> PA)
这是 Direct Map 存在的最大意义。由于是线性映射,内核可以使用 virt_to_phys() 和 phys_to_virt() 宏在虚拟地址和物理地址之间快速切换,而不需要消耗 CPU 去查找多级页表(Page Table Walk)。
B. 支持核心内存分配器 (kmalloc)
当你使用 kmalloc() 申请内存时,内核返回的地址就在 Direct Map 中。
- 特点:kmalloc 申请的内存在虚拟地址上是连续的,在物理地址上也是连续的。
- 用途:这种连续性对于驱动程序(如网卡、磁盘驱动)进行 DMA(直接内存访问) 至关重要,因为硬件通常要求物理内存必须连续。
C. 管理所有物理页的“控制中心”
内核为每个物理页(4KB)都维护一个名为 struct page 的结构体。这些结构体存放在 vmemmap 区,但它们所代表的实际数据内容,内核是通过 Direct Map 来读写的。
D. 减少 TLB 刷新开销
在 64 位 Linux 中,直接映射区通常使用 1GB(Huge Pages)或 2MB(Large Pages) 的巨型页进行映射,而不是细碎的 4KB 页。
- 好处:极大地减少了 CPU TLB(快表)的条目占用,提高了整个系统的缓存命中率。
2. 2. 1. 3涉及的关键组件
Direct Map 并不是独立存在的,它与内核中多个内存子系统深度耦合:
① 伙伴系统 (Buddy System)
- 关系:伙伴系统是 Linux 的底层物理内存管理器,负责以“页”为单位分配物理内存。
- 协作:每当伙伴系统分配出一个物理页帧,这个页帧在 Direct Map 中对应的虚拟地址就已经准备好了,内核可以直接通过该地址读写。
② Slab / Slub 分配器
- 关系:这是内核用来管理小对象(如 task_struct, file 结构体)的分配器。
- 协作:Slab 分配器从伙伴系统拿到大块内存,这些内存都在 Direct Map 范围内。因此,内核中的绝大多数数据结构都驻留在 Direct Map 中。
③ DMA (Direct Memory Access) 引擎
- 关系:外设硬件(如 GPU、网卡)直接读写物理内存。
- 协作:驱动程序通过 Direct Map 准备好数据,然后将物理地址告知硬件。由于 Direct Map 是物理连续的,硬件可以非常方便地进行大批量数据传输。
④ 页表管理 (Page Table Structures)
- 关系:虽然 Direct Map 减少了页表查找,但 Direct Map 自身的映射关系仍需由内核页表(PGD, P4D, PUD, PMD, PTE)来维护。
- 协作:在系统启动(Booting)阶段,内核会扫描物理内存,并一次性建立好这个庞大的 Direct Map 映射表。
2. 2. 2 Vmalloc 区
在 64 位 Linux 系统中,Vmalloc 区通常占据了高达 32TB 的虚拟地址空间。它的存在是为了解决物理内存碎片化以及大块内存申请的问题。
2. 2. 2. 1Vmalloc 区的核心定义
Vmalloc 区 是专门为 vmalloc() 系统调用预留的内核虚拟地址空间。
- 核心特性:虚拟地址连续,但物理地址不连续。
- 对比 kmalloc:
- kmalloc 申请的内存在物理上必须是连续的(位于 Direct Map)。
- vmalloc 申请的内存由多个离散的物理页(4KB)拼凑而成,仅在虚拟地址空间上看起来是连续的。
2. 2. 2. 2Vmalloc 区的详细作用
A. 分配大块内存(绕过物理碎片)
随着系统运行时间的增加,物理内存会产生大量碎片。当内核需要一个 100MB 的缓冲区时,可能根本找不到连续的 100MB 物理空间。
- 作用:vmalloc 可以从物理内存的各个角落捡起零散的页,通过修改内核页表,将它们映射到一段连续的虚拟地址中。
B. 内核模块的加载 (Kernel Modules)
这是 vmalloc 最常见的用途之一。当你使用 insmod 加载驱动程序时:
- 作用:驱动程序的代码(Text 段)和数据会被放置在 Vmalloc 区域(或者紧邻的 Modules 区,其底层机制类似)。因为驱动程序的大小不一,使用 vmalloc 可以更灵活地分配空间,而不必苛求物理连续性。
C. I/O 内存映射 (ioremap)
虽然 ioremap 和 vmalloc 在逻辑上略有不同,但它们都共用 Vmalloc 这一块虚拟地址空间。
- 作用:内核通过 ioremap 将硬件寄存器(如网卡、显卡的寄存器)映射到 Vmalloc 区的某个虚拟地址上。这样,内核像读写内存一样就能控制硬件。
D. eBPF 程序和 JIT 编译代码
现代 Linux 中使用的 eBPF 程序,在经过 JIT(即时编译)后生成的机器码,通常也存放在这一区域,因为它需要灵活的内存分配支持。
2. 2. 2. 3涉及的关键组件
vmalloc 的实现是一个复杂的工程,涉及内核多个子系统的协作:
① 伙伴系统 (Buddy System) —— “面粉供应商”
- 协作方式:vmalloc 本身不管理物理内存。当调用 vmalloc(size) 时,它会多次调用伙伴系统,每次申请 一个物理页(4KB),直到凑够所需的大小。
② 内核页表 (Kernel Page Tables) —— “地图绘制者”
- 协作方式:这是 vmalloc 的核心。内核必须手动修改页表(修改 PGD、PUD、PMD、PTE),将这些零散的物理页地址填入,并映射到 Vmalloc 区的连续虚拟地址上。
- 延迟刷新:为了效率,vmalloc 在分配时可能不会立即同步所有 CPU 的页表,而是触发缺页异常(Page Fault)时再进行同步。
③ vmap_area 与 vm_struct —— “账本管理”
这是内核用来追踪 Vmalloc 区哪些地址已被占用的数据结构:
- vmap_area:管理虚拟地址空间的分段(红黑树组织),用于快速查找哪段虚拟地址是空的。
- vm_struct:描述一个具体的已分配内存块,记录了它包含了哪些物理页(有一个 struct page **pages 数组)。
④ TLB (Translation Lookaside Buffer) —— “性能瓶颈”
- 影响:由于 vmalloc 映射的物理页是不连续的,它无法利用“巨型页(Huge Pages)”优化。每次访问 vmalloc 内存都可能导致 TLB 缓存失效。
- 结果:访问 vmalloc 内存的速度通常比 kmalloc 慢。
2. 2. 3 虚拟页表映射区
在 64 位 Linux 内核中,虚拟页表映射区(Virtual Memory Map,简称 vmemmap) 是一个专门用来存放“内存元数据”的地址空间。
如果说 Direct Map 是存放数据的“仓库”,那么 vmemmap 就是这个仓库的“档案室”。这里存放着全系统每一个物理页帧(Page Frame)的身份证——struct page。
2. 2. 3. 1 vmemmap 的核心定义
在内核中,系统将物理内存划分为 4KB 大小的“页帧”。为了管理这些页帧,内核必须为每一个页帧创建一个 struct page 结构体(在 x86_64 上通常占 64 字节)。
- vmemmap 的作用:它将整个物理内存对应的 struct page 结构体数组映射到一段连续的虚拟地址空间中。
- 地址范围:在 x86_64 布局中,它通常占据 0xffffea0000000000 开始的 1TB 空间。
- 核心公式: struct page *page = vmemmap + pfn; (其中 pfn 是物理页帧号 Page Frame Number)。通过这个公式,内核可以在极短时间内完成“物理页号”到“结构体指针”的转换。
2. 2. 3. 2 vmemmap 的详细作用
A. 快速索引:PFN 与 struct page 的转换
内核经常需要在物理页帧号(PFN)和对应的 struct page 结构体之间快速切换。
- 过去(Flat Memory):直接用一个巨大的数组。但在内存不连续(有空洞)的系统中,会浪费大量空间。
- 现在(Sparse Memory + vmemmap):vmemmap 利用虚拟内存技术,即使物理内存是不连续的(比如中间空了几个 GB),在 vmemmap 虚拟区里,所有的 struct page 看起来依然是一个连续的数组。这让 page_to_pfn 和 pfn_to_page 的操作变成了简单的指针加减运算。
B. 支持内存热插拔 (Memory Hotplug)
在服务器领域,可以动态增加内存条。
- 当新内存插入时,内核会在 vmemmap 区域中分配新的虚拟地址,并将其映射到新内存中预留给元数据的物理空间。这种“按需映射”的能力使内存管理非常灵活。
C. NUMA 亲和性优化
在多路服务器(NUMA 架构)中,为了提高访问速度,struct page 结构体通常会被存放在它所描述的物理页所属的同一个内存节点上。
- vmemmap 虚拟地址虽然是连续的,但其背后的物理存储可以分布在不同的 CPU 节点上,从而实现“本地访问”,减少跨总线延迟。
==vmemmap 的存在,使得内核能够像操作数组一样极其高效地管理海量且可能不连续的物理内存页。==
2. 2. 4 内核镜像区
如果说 内核镜像区(Kernel Image Area) 是操作系统的“最高司令部”(存放原生自带的核心功能),那么 内核模块区(Modules Area) 就是司令部旁边的**“外包团队办公区”或“插件热插拔区”**。
在 64 位 x86_64 系统中,这个区域通常位于 0xffffffffa0000000 到 0xffffffffff5fffff,大小约为 1.5 GB。
当你使用 insmod 或 modprobe 命令动态加载一个驱动程序(.ko 文件)时,这个驱动的代码和数据就会被安放在这里。
2. 2. 4. 1内核镜像区的核心作用
很多人会问:既然加载模块也需要分配内存,为什么不直接把它丢进广阔的 Vmalloc区(32TB) 或者 直接映射区(64TB)?为什么要专门在虚拟地址空间的最顶部抠出一个区区的 1.5GB 的空间给它?
这背后隐藏着一个极其底层的 CPU 硬件指令集限制。
致命的 2GB 限制
在 x86_64 架构下,为了追求极致的执行速度和代码体积,编译器在生成函数调用指令(CALL)和跳转指令(JMP)时,默认使用的是**“32 位相对地址偏移量(32-bit relative offset)”**。
- 一个 32 位的有符号整数,最大能表示的范围是 ±2GB。
- 这意味着,当一个指令想要跳转到另一个地址时,目标地址必须在当前地址的 2GB 范围之内,否则这种高效的短跳转指令就够不着!
核心与模块的频繁交互
-
内核模块(比如显卡驱动、网卡驱动)虽然是后加载的,但它们需要极度频繁地调用内核核心功能。例如,模块需要调用司令部的 printk() 来打印日志,调用 kmalloc() 来分配内存。
-
如果把内核模块加载到几十 TB 之外的 Vmalloc 区,当模块试图调用核心里的 printk() 时,“32位相对跳转”就会因为距离太远而失效,导致内核崩溃。
-
解决方案:
- 内核规划出了一个紧挨着“内核镜像区(司令部)”的 1.5GB 专属区域。
- 因为司令部占据 512MB,模块区占据 1.5GB,两者加起来正好是 2GB。这就完美保证了:在这个区域内的任何一个模块代码,使用相对跳转指令,都能 100% 够得着司令部里的核心函数!
2. 2. 4. 2 内核镜像区的内部运作机制
A. 它是如何分配的?
当内核需要加载模块时,它会调用一个特殊的函数 module_alloc()。
- 本质上,module_alloc() 是 vmalloc 的一个特殊变体。
- 它背后的物理内存也是由一个个零散的 4KB 物理页拼凑而成的(物理不连续)。
- 但是,它强制要求:拼凑出来的虚拟地址,必须落在这个 1.5GB 的模块区范围内。
B. 模块区里的安全防护(W^X 与 KASLR)
和核心内核区一样,模块区也是黑客眼中的“肥肉”。因此,它的安保级别极高:
- 权限严格隔离:
- 当一个 .ko 驱动文件被加载到这里时,内核会像解析 ELF 文件一样,将它的代码段设为 R-X(只读可执行),数据段设为 RW-(可读写不可执行)。
- 严格禁止整个模块具备可读可写可执行的权限(防恶意代码注入)。
- 模块随机化 (Module KASLR):
- 不仅核心内核的地址会随机漂移,现代 Linux 也会在 1.5GB 的模块区内,随机决定把新加载的驱动放在哪个位置。
C. eBPF 与模块区的关系
在现代 Linux(尤其是服务器)中,有一个极度火热的技术叫做 eBPF。
- 当你编写了一段 eBPF 程序并在内核中运行时,内核内置的 JIT(即时编译器)会将你的代码编译成机器指令。
- 这些 JIT 生成的机器指令放在哪? 没错,通常也是放在这块 模块区 (Modules Area) 中!因为 eBPF 程序同样需要高效地调用内核核心提供的 Helper 函数。
2. 2. 4. 3 总结:内核空间两大“代码”执行区的对比
| 特性 | 内核镜像区 (Kernel Image) | 内核模块区 (Modules Area) |
|---|---|---|
| 角色比喻 | 最高司令部(核心代码) | 外包驻场团队(外挂驱动、eBPF) |
| 大小 (x86_64) | 512 MB | 1.5 GB |
| 位置关系 | 紧紧挨着,合并起来不超过 2GB | 紧紧挨着,合并起来不超过 2GB |
| 加载时机 | 开机引导时一次性映射 (Bootloader) | 运行时动态申请与释放 (insmod/rmmod) |
| 底层物理内存 | 通常物理连续,使用巨型页 (HugePage) | 物理不连续 (离散的 4KB 页拼凑) |
| 跳转指令支持 | 支持极其高效的 32位相对跳转 (CALL) | 支持极其高效的 32位相对跳转 (CALL) |
可以说,模块区是一个为了妥协硬件指令集限制,同时为了保持内核极致性能和灵活性而诞生的一块精妙领地。
2. 2. 5 固定映射区
它的核心理念极其特殊:虚拟地址在编译时就已经写死(固定),但它所指向的物理地址可以在运行时动态改变。
2. 2. 5. 1为什么需要固定映射区?
要理解 Fixmaps,我们必须回到计算机刚开机的最初几秒钟。
当 Linux 内核刚刚被加载到内存、刚刚开启虚拟内存(开启 CR0 寄存器的 PG 位)时,系统处于一种极其尴尬的“半瞎”状态:
- 此时,强大的内存分配器(伙伴系统、kmalloc、vmalloc)统统还没有初始化。
- 但是,内核为了初始化这些分配器,又必须读取主板上的硬件信息(比如 ACPI 表,用来知道系统插了多少内存、有多少个 CPU)。
- 而这些硬件信息位于特定的物理地址上。由于已经开启了虚拟内存,内核无法直接通过物理地址读取它们,必须建立页表映射。
- 死锁来了:建立映射需要分配内存写页表,但内存分配器还没准备好!
固定映射区(Fixmaps)就是为了打破这个死锁而诞生的。
2. 2. 5. 2固定映射区的核心机制
内核在编写代码(编译期)时,就提前预留了一小段常数级别的虚拟地址。这些地址通过一个 C 语言枚举类型(enum fixed_addresses)来定义,比如:
- FIX_APIC_BASE(固定给 APIC 用的索引)
- FIX_EARLYCON_MEM_BASE(固定给早期控制台打印日志用的索引)
- FIX_BTMAP_BEGIN(早期启动时的临时映射区)
它是怎么工作的?
- 因为这些虚拟地址是编译时固定的,内核在自带的 .bss 段里提前静态分配好了这几个地址对应的底层页表(PTE)。
- 当系统刚启动,需要读取主板上 0x7FE00000 处的 ACPI 硬件表时,内核不需要调用 kmalloc,而是直接调用一个底层函数 set_fixmap(FIX_ACPI_END, 0x7FE00000)。
- 这个函数会直接找到那个静态预留的页表项,把物理地址 0x7FE00000 塞进去。
- 瞬间,内核就可以通过固定的虚拟地址读取硬件表了!初始化完成后,再调用 clear_fixmap() 解除映射,把“车位”让出来。
2. 2. 5. 2 固定映射区里到底住了哪些“VIP 硬件”?
即使在系统完全启动、内存分配器正常工作后,Fixmaps 区域依然保留,专门用来映射一些极端关键、且 CPU 必须以极高频率或极快速度访问的硬件寄存器(MMIO)。
A. 本地 APIC (Local APIC) 寄存器
- (这就是上一节提到的 APIC!)每个 CPU 核心都有一个专门负责接收和发送中断的 Local APIC。
- 它的控制寄存器是被映射到内存中的。为了让内核的中断处理代码达到极致的效率,内核不使用动态的 ioremap,而是将 Local APIC 的物理地址永久地“钉”在 Fixmaps 区(索引为 FIX_APIC_BASE)。
- 这样,访问 APIC 的虚拟地址就成了一个编译期常量,CPU 可以直接用一条最简单的汇编指令完成读写,没有任何指针解引用的开销。
B. IO-APIC
- 主板上负责将外部硬件(键盘、鼠标、硬盘)的中断路由给各个 CPU 的全局芯片,同样映射在这里。
C. CPU Entry Area (CPU 入口区)
- 虽然在极现代的内核中,它被独立为一个紧挨着 Fixmaps 的区域,但逻辑上它们是一体的。
- GDT(全局描述符表)、IDT(中断描述符表)、以及我们之前讲过的**致命异常栈(IST,用于处理双重错误等)**都存放在这里。
- 这些都是 CPU 硬件在发生中断或系统调用时,硬件级自动去读取的数据结构。它们的虚拟地址必须是绝对固定且安全的(周围布置了 Guard Pages 守护页,防止任何溢出覆盖)。
D. 早期控制台 (Early Console)
- 在系统刚开机,显卡驱动和 tty 子系统还没加载时,屏幕上为什么能打印出早期的启动日志?
- 因为内核通过 Fixmaps,将 VGA 文本模式的显存地址(物理地址 0xB8000)临时映射到了这里,直接往里面填字符。
固定映射区(Fixmaps) 虽然面积极小(仅仅几 MB),但它处于 128TB 内核虚拟地址空间的最高处(“权力之巅”)。
它是操作系统的**“起搏器”,在系统最脆弱的启动阶段提供临时的内存映射能力;同时它也是系统的“神经中枢”**,以最快、最确定的硬编码虚拟地址,掌控着 APIC 等关乎 CPU 命脉的关键硬件。
3. Program Header Table (PHT):执行视图的“施工图纸”
3. 1PHT 的本质定义
- 执行视图(Execution View)的灵魂:ELF 文件有两种视角。Section Header Table (SHT) 是给链接器看的“零件清单”;而 PHT 是给操作系统(内核)看的“施工图纸”。它定义了如何将磁盘上的二进制数据按权限映射成进程的虚拟内存空间。
- 强制性:对于可执行文件和共享库(.so),PHT 是必须有的;对于可重定位文件(.o),PHT 通常不存在。
3. 2 PHT 的物理结构 (Elf64_Phdr)
在 64 位系统中,每个 PHT 表项占 56 字节,包含以下核心字段:
- p_type (段类型):决定了内核如何处理这一块数据(详见第三节)。
- p_offset (文件偏移):该段在磁盘文件中的起始位置。
- p_vaddr (虚拟地址):该段在进程虚拟内存中的目标起始地址(注意:在 PIE 程序中,它只是个相对偏移,见第四节详解)。
- p_paddr (物理地址):在现代 Linux 中通常被忽略(仅在某些嵌入式或裸机系统中有效)。
- p_filesz (文件大小):在磁盘文件中占用的字节数。
- p_memsz (内存大小):在虚拟内存中占用的字节数。
- 关键点:如果 p_memsz > p_filesz,多出来的部分就是 BSS 段,内核会自动将其分配物理页并填充为 0。
- p_flags (权限标志):
- PF_R (4): 可读
- PF_W (2): 可写
- PF_X (1): 可执行
- 例如:代码段通常是 R+X(5),数据段是 R+W(6)。
- p_align (对齐):段在内存和文件中的对齐要求(通常是 0x1000,即 4KB 页面大小)。
3. 3 核心段类型 (p_type) 详解
内核或动态链接器在解析 PHT 时,会根据 p_type 执行不同的逻辑:
-
PT_LOAD (可加载段):
- 作用:最重要。告诉内核:“把文件这部分搬进内存”。
- 数量:通常有 2~4 个。至少包含一个存放代码的只读执行段(R-X),和一个存放数据的读写段(RW-)。
-
==PT_INTERP (解释器段):==
- 内容:存放动态链接器的路径字符串(如 /lib64/ld-linux-x86-64.so.2)。
- 作用:内核看到它,就知道这个程序需要“外援”来加载动态库。
- 关键点:内核把控制权交给动态链接器,而不是主程序的 _start。
-
==PT_DYNAMIC (动态段):== 存放动态链接所需的各种表(符号表、重定位表、依赖库列表等)。动态链接器通过它来完成 .so 的加载。**==.dynamic 节==**的内容就是 PT_DYNAMIC 段所指向的数据实体。
-
依赖库列表 (Dependency List)
- 标签示例:DT_NEEDED
- 内容:==这一项通常存放的是字符串表中的偏移量,相对于动态字符串池( **.dynstr ** )的字节偏移量==。
- 作用:每一条 DT_NEEDED 都代表一个**==必须加载的库名字==**(如 libc.so.6),而不是具体的“函数名”。
-
符号与字符串基础设施 (The Foundation) : 这是链接的基础,动态段记录了它们的入口地址:
-
符号表 (Symbol Table):标签 DT_SYMTAB。指向 .dynsym 节。记录了所有函数和变量的名字、类型、定义位置。
st_value(地址字段)的核心逻辑: **情况一(静态/自身代码):**如自定义了 void my_func(),在 .symtab 中,其 st_value 存的是确定的地址/偏移(如 0x401000)。 情况二(动态/外部库):如调用了 printf,在主程序的 .dynsym 中,printf 的 st_value 通常是 0,且所属节索引 st_shndx 会被标记为 SHN_UNDEF(未定义)。因为受 ASLR 和多进程共享影响,编译器根本不可能预知 printf 在运行时的真实内存地址。
==在数据结构上,.dynsym 和 .symtab 完全一模一样!都是由 24 字节定长结构体 Elf64_Sym 组成的数组。==
-
字符串表 (String Table):标签 DT_STRTAB。指向 .dynstr 节。==一个巨大的、连续的文本缓冲区,里面存放着一个个以 \0(null)结尾的字符串。==
-
符号哈希表 (Hash Table):标签 DT_HASH 或 DT_GNU_HASH。作用:为了在几万个符号里快速找到 printf,链接器通过这个哈希表实现“秒找”。
-
-
三表联动寻找符号 (如 printf):
- 第一步(哈希表):计算 printf 的哈希值,查哈希表吐出一个索引数字:500。
- 第二步(符号表):直接跳到符号表数组的第 500 项(SymbolTable[500])。
- 第三步(字符串表校验):查看 SymbolTable[500].st_name 得到偏移量 123。去字符串表的 123 处读字符串,发现确实是 “printf”。==(这一步是为了防止哈希冲突造成的误判)。==
-
重定位“施工图” (Relocation Tables): 动态链接器根据这些表修改“空位”:
- 普通数据重定位表:标签 DT_RELA (.rela.dyn)。处理全局变量、静态变量等除函数调用外的地址修正。
- PLT专用重定位表:标签 DT_JMPREL (.rela.plt)。专门处理函数调用的重定位(Lazy Binding 延迟绑定)。
-
-
PT_TLS (线程局部存储段) 【现代多线程基石】:
- 作用:定义了被 __thread 或 thread_local 修饰的变量的初始状态。
- 关键点:它相当于一个**“模具”**。每次程序调用 pthread_create 开启一个新线程时,系统都会照着这个段的数据,在内存中“克隆”出一份私有副本,确保各线程的局部变量互不干扰。
-
PT_GNU_EH_FRAME (异常处理框架段):
- 作用:指向 .eh_frame_hdr。保存了 C++ try…catch 异常抛出时的调用栈展开(Stack Unwinding)信息,GDB 调试打出 bt 也是靠它。
-
PT_NOTE (批注/元数据段):
- 作用:保存编译器生成的 Build-ID 等指纹信息,供 Core Dump 时匹配调试文件使用。
-
PT_GNU_STACK (栈属性段):
- 作用:如果 p_flags 没有 X 权限,系统开启 NX(不可执行栈) 保护,防范缓冲区溢出执行 Shellcode 攻击。
-
PT_GNU_RELRO (重定位只读段):
- 作用:安全增强。告诉动态链接器在完成重定位后,将某些内存区域(如部分 GOT 表、动态段)设为只读,防止被恶意篡改(GOT Overwrite 攻击)。
3. 4 PHT 映射的精妙机制
1. 节(Section)到段(Segment)的合并
- 在磁盘文件(SHT 视图)里,有几十个节(.text, .rodata, .hash, .plt 等)。
- 为什么要合并? 内存管理是按“页”(4KB)为单位的。如果每个节单独占一页,会造成巨大的空间浪费。
- 合并逻辑:PHT 将权限相同的“节”打包成一个“段”。
- .text 和 .rodata 都是只读/可执行的 $\rightarrow$ 合并为一个 Code Segment (PT_LOAD R-X)。
- .data 和 .bss 都是可读写的 $\rightarrow$ 合并为一个 Data Segment (PT_LOAD RW-)。
2. BSS 段的“零成本”实现
- PHT 表项中,如果 p_memsz = 100 而 p_filesz = 40,内核会从文件读 40 字节,剩下的 60 字节直接分配物理内存页并强制清零。
- 这解释了为什么未初始化的全局变量不增加磁盘文件体积,但在内存里却实实在在存在,且默认值为 0。
3. 现代 PIE 程序的“基址漂移” (ASLR 配合)
- 传统程序 (非 PIE):PT_LOAD 中的 p_vaddr 就是绝对物理内存地址(如 0x400000),内核直接将数据映射到该固定位置。
- 现代程序 (PIE 机制):编译器生成位置无关可执行文件。此时 PHT 里的 p_vaddr 仅仅是一个相对偏移量(如 0x1000)。
- 装载魔法:内核启动程序时,会利用 ASLR(地址空间布局随机化)生成一个随机的加载基址 (Base Address)。真正的内存映射地址 = Base Address + p_vaddr。这极大提升了防止内存破坏漏洞的安全防御能力。
4. 延迟加载 (Lazy Binding) 的触发机制
对于动态链接的函数(如 printf),一开始并没有它的绝对地址,通过 PLT 和 GOT 配合实现按需解析。每个外部函数在 PLT 中都对应这样一段代码(以 x86_64 为例):
<printf@plt>:
1. jmp *0x602018(%rip) # 跳转到 GOT[printf] 中存储的地址
2. push $0x5 # 压入该函数在重定位表中的索引 (编号)
3. jmp 0x401020 # 跳转到 PLT[0] (公共入口,去叫链接器)
- 第 1 行 (jmp *GOT):
- 第一次运行:此时 GOT 还没填入真实地址,它故意存了第 2 行的指令地址。所以 CPU 执行完第 1 行,发现跳跃目标就是脚下的第 2 行,相当于“原地踏步”。这是触发器。
- 第 2 行 (push 编号):
- 意义:这是身份证。由于所有函数未解析前最后都会跳到同一个地方,链接器需要这个编号来区分:“哦,是 5 号员工 printf 需要我去找地址”。
- 第 3 行 (jmp PLT[0]):
- 意义:这是传话筒。跳到公共入口 PLT[0],把控制权交给动态链接器(_dl_runtime_resolve)。链接器解析出真实地址后,会覆写第 1 行指向的 GOT 表坑位。下次再调用 printf 时,第 1 行就会直接跳转到 libc.so 中的真实地址,不再执行 2、3 行。
4. 从 Section 到 Segment:权限合并 (R/W/X) 与内存页 (4KB) 对齐机制
5. 虚拟内存的映射魔法:mmap 系统调用与 BSS 段的“零成本”实现
现代 Linux 内核加载程序的真正核心魔法,全在一个系统调用上:mmap(内存映射)。而 .bss 段的“零成本”实现,正是这套魔法中最惊艳的魔术。
5. 1mmap 的欺骗艺术:是“映射”而不是“拷贝”
在执行 execve 加载程序时,内核读取了 PHT(程序头表)之后,根本不会去调用传统的 read() 函数搬运代码。它使用的是 mmap 机制。
1. 什么是 mmap?
mmap 的本质,是在进程的“虚拟地址空间”与“磁盘上的文件”之间,建立起一层逻辑上的连线(Mapping)。 内核在内存里创建了一个叫 vm_area_struct(虚拟内存区域,简称 VMA)的数据结构。这个结构体里只记下了一笔账:
“虚拟地址 0x401000 到 0x402000 这一段,对应着磁盘上 app 文件的第 0 到 4096 个字节,权限是只读可执行(R-X)。”
做完这笔记录后,execve 就直接宣布加载完成了! 此时,物理内存里其实一行你的代码都没有!这就是操作系统撒下的弥天大谎。
2. 谎言被戳穿时的救场:缺页中断 (Page Fault)
当内核把控制权交给程序,CPU 迫不及待地要去执行 0x401000 处的第一条指令。
- CPU 去查页表,发现 0x401000 这个虚拟地址根本没有对应的物理页。
- CPU 大怒,立刻触发一个硬件异常:缺页中断 (Page Fault)。
- 内核赶紧跳出来救场:“别急,我查下账本(VMA)。”
- 内核一看,原来这块地址对应的是磁盘文件 app 的某一块。于是,内核这才真正触发一次磁盘 I/O,把那 4KB 的代码从硬盘读到物理内存中,并把物理地址填入 CPU 的页表。
- 内核对 CPU 说:“弄好了,你再执行一次试试。” CPU 顺利执行。
极客结论:mmap 实现了**“按需加载(Demand Paging)”**。你的程序有 100MB 大小,但如果你只执行了 main 函数就退出了,内核其实只从磁盘读取了包含 main 函数的那一两个 4KB 的内存页。极大地节省了内存和启动时间!
5. 2 BSS 段的零成本魔法:p_memsz > p_filesz
理解了 mmap,我们再来看 .bss(未初始化全局变量)。它是如何做到“在磁盘不占空间,在内存里不仅有空间,还全都是 0”的?
在 PHT 的 PT_LOAD (RW-) 数据段表项中,记录着两个极度不对等的数字,例如:
- p_filesz (文件大小) = 0x200 字节(对应 .data 节的大小)
- p_memsz (内存大小) = 0x1200 字节(对应 .data + .bss 的大小)
当内核看到 p_memsz 比 p_filesz 多出了 0x1000(4096 字节)时,它的魔法开始了:
魔法第 1 步:文件映射 (File-backed Mapping)
内核先用普通的 mmap,把磁盘上的那 0x200 字节的 .data 数据,映射到进程的虚拟内存中。
魔法第 2 步:匿名映射 (Anonymous Mapping)
对于多出来的那 0x1000 字节(.bss 区),内核知道磁盘上没有数据可以给它。于是,内核使用了一种特殊的映射方式:匿名映射 (MAP_ANONYMOUS)。 “匿名”的意思是,这块内存没有对应的磁盘实体文件。内核直接在虚拟内存中划出 0x1000 的空间,并承诺给它物理内存。
魔法第 3 步:全局零页 (ZERO_PAGE) 与写时复制 (CoW) —— 【大师级内幕】
这里是最硬核的地方!你以为内核会傻傻地立刻去物理内存里找 4KB 空间,然后用 memset 把它填成 0 吗? 内核比你想的还要抠门!
- Linux 内核在系统启动时,早就偷偷在物理内存里准备好了一个特殊的页,里面填满了全是 0 的数据,这叫 全局零页 (ZERO_PAGE)。
- 当你的程序启动,分配 .bss 内存时,内核把这 0x1000 虚拟地址的页表项,全都偷偷指向了这个物理的 ZERO_PAGE!并且权限设为只读 (Read-Only)。
- 如果你的程序只是去读未初始化的变量,读到的全都是这个 ZERO_PAGE 里的 0。(此时不消耗任何额外的物理内存!)
- 只有当你的程序执行了 uninit_var = 100;,试图去写这块内存时:
- CPU 发现你竟然想写一块只读内存,再次触发缺页中断。
- 内核跑出来一看:“哦,这是匿名映射区,你真的要存数据了。”
- 内核这才乖乖地去物理内存里分配一页真实的 4KB 空白页,把它清零,然后让你把 100 写进去。把虚拟地址重新映射到这个新页上,并将权限改为可写。
这个机制叫做 CoW (Copy-on-Write,写时复制) 的变体保护。
6. Page Fault (缺页中断):延迟加载的底层驱动力
6. 1 Page Fault 发生全过程
缺页中断不是纯软件机制,它是 CPU 硬件和系统内核极其硬核的交互过程。假设程序现在要执行 0x401000 处的代码:
-
硬件拦截(MMU 查表失败):
CPU 准备执行指令,内存管理单元(MMU)去查硬件页表,发现 0x401000 这个地址对应的 PTE 是无效的(Valid = 0)。
-
触发异常(Exception 14):
MMU 立刻打断 CPU 的执行,触发一个编号为 14 的硬件异常(Page Fault)。同时,CPU 硬件会自动把引发错误的虚拟地址(0x401000)塞进一个特殊的寄存器 CR2 中。
-
内核抢管(软件接手):
CPU 权限提升为 Ring 0,跳入 Linux 内核的缺页中断处理函数(do_page_fault / exc_page_fault)。
-
内核查账(遍历 VMA):
内核从 CR2 寄存器里取出案发地址 0x401000,去查之前记下的 VMA 账本。
- 如果发现这个地址不在 VMA 账本里,或者程序想写一个只有 R(只读)权限的 VMA,内核直接宣判死刑,向进程发送 SIGSEGV 信号,程序报 Segmentation fault 崩溃。
- 如果账本里有,且权限合法,内核开始真正干活(分配物理页)。
-
修复页表与重试(现场还原):
内核在物理内存中找一个空闲页(4KB),把数据读进去。然后修改 CPU 的页表(PTE),把虚拟地址 0x401000 映射到这个真实的物理页上,并将 Valid 位置为 1。 最后,内核执行 iret 指令返回用户态。CPU 就像什么事都没发生过一样,重新执行刚才失败的那条指令。 这一次,顺利通过!
附录 (Reference)
附录 A:ELF 数据类型 (Elf64_Ehdr, Elf64_Shdr, Elf64_Phdr) 结构体备忘录
附录 B:System V AMD64 ABI (寄存器传参约定与函数调用栈帧)
附录 C:C/C++ 经典链接与运行时报错原因对照表 (Segmentation Fault, Core Dump, Undefined Reference)
附录 D:核心 Section 与 sh_type 对应速查表
在 ELF 的底层实现中,.text 等字符串名字(记录在 .shstrtab 中)其实主要是给人看的;而工具链和内核在解析解析文件时,真正依赖的是 Elf64_Shdr 结构体中的 sh_type (节类型标识枚举)。
以下是 ELF 文件中最核心的 Section 与 sh_type 的终极对应关系,按逻辑功能划分为五大阵营:
1. 程序的血肉之躯(执行与数据段)
这类 Section 存放着程序最核心的物理指令和数据,它们最终会被映射到虚拟内存中。
| 常见 Section 名称 | 底层标识 (sh_type) | 宏定义值 | 核心功能与特性说明 | 运行时是否加载 |
|---|---|---|---|---|
| .text | SHT_PROGBITS | 1 | 代码节。存放编译后的机器码。内核视其为普通的“程序定义数据”。 | ✅ (R-X) |
| .rodata | SHT_PROGBITS | 1 | 只读数据节。存放常量(如字符串、const 变量)。 | ✅ (R–) |
| .data | SHT_PROGBITS | 1 | 已初始化数据节。存放非零的全局/静态变量。 | ✅ (RW-) |
| .bss | SHT_NOBITS | 8 | 未初始化数据节。极特殊类型! 明确告知内核该节在磁盘中占 0 字节,但在内存中需要分配对应大小并清零。 | ✅ (RW-) |
2. 名字与档案管理(符号与字符串池)
这类 Section 是链接器“对账”的依据,包含了变量名、函数名等元数据。
| 常见 Section 名称 | 底层标识 (sh_type) | 宏定义值 | 核心功能与特性说明 | 能否被 strip 剥离 |
|---|---|---|---|---|
| .symtab | SHT_SYMTAB | 2 | 完整静态符号表。记录代码中所有全局/局部符号。这是链接器组装 .o 文件时的核心账本。 | ✂️ 会被剥离 |
| .strtab | SHT_STRTAB | 3 | 普通字符串池。配合 .symtab,存放代码中真正的函数名和变量名(如 “main”)。 | ✂️ 会被剥离 |
| .shstrtab | SHT_STRTAB | 3 | 节名字符串池。不可或缺! 专门存放所有 Section 自己本身的名字(如 “.text”)。 | 🛡️ 强制保留 |
3. 动态链接的魔法(运行时必需品)
现代程序赖以生存的动态链接基石。这一组的所有成员都绝对不能被 strip 删除,否则程序直接变砖。
| 常见 Section 名称 | 底层标识 (sh_type) | 宏定义值 | 核心功能与特性说明 | 交互对象 |
|---|---|---|---|---|
| .interp | SHT_PROGBITS | 1 | 解释器路径。存有动态链接器的绝对路径(如 /lib64/ld-linux.so.2)。 | 内核 execve |
| .dynamic | SHT_DYNAMIC | 6 | 动态链接总表。存放了依赖哪些 .so、重定位表在哪里等全局动态信息。 | ld-linux.so |
| .dynsym | SHT_DYNSYM | 11 | 动态符号表。.symtab 的精简版,只包含用于动态链接的导入/导出符号(如 printf)。 | ld-linux.so |
| .dynstr | SHT_STRTAB | 3 | 动态字符串池。为 .dynsym 提供符号名字的支持。 | ld-linux.so |
| .hash | SHT_HASH | 5 | 符号哈希表。提供 O(1) 的符号查找速度(现代通常被 SHT_GNU_HASH 替代)。 | ld-linux.so |
| .got / .plt | SHT_PROGBITS | 1 | 全局偏移表 / 过程链接表。包含指针和跳转指令。从结构上看它只是普通数据/代码,但逻辑上是动态链接的核心。 | ld-linux.so |
4. 补丁与重定位(“施工修改图”)
记录了代码和数据中哪些地址是“占位符”,需要在合并或加载时被修正。
| 常见 Section 名称 | 底层标识 (sh_type) | 宏定义值 | 核心功能与特性说明 | 运作时期 |
|---|---|---|---|---|
| .rela.text / .rela.data | SHT_RELA | 4 | 静态重定位表(带加数)。用于修正目标文件(.o)中代码和数据的绝对地址引用。 | 静态链接期 (ld) |
| .rela.dyn / .rela.plt | SHT_RELA | 4 | 动态重定位表(带加数)。运行时修正全局变量的引用,以及用于函数延迟绑定的 GOT 表填写。 | 动态链接期 (ld.so) |
| (32位老系统) .rel.xxx | SHT_REL | 9 | 静态/动态重定位表(无加数)。作用同上,但为节省空间,偏移加数直接覆写在指令内存处。 | 静态 / 动态链接期 |
5. 生命周期、元数据与调试信息
处理程序的生老病死,以及提供对人类友好的逆向与排错支持。
| 常见 Section 名称 | 底层标识 (sh_type) | 宏定义值 | 核心功能与特性说明 | 生命周期/状态 |
|---|---|---|---|---|
| .init_array | SHT_INIT_ARRAY | 14 | 构造函数指针表。存放 C++ 全局对象构造函数,在 main 执行前由 glibc 调用。 | 运行时必载 |
| .fini_array | SHT_FINI_ARRAY | 15 | 析构函数指针表。在 main 结束或 exit() 时调用,清理环境。 | 运行时必载 |
| .note.gnu.build-id | SHT_NOTE | 7 | 编译批注与构建指纹。存有唯一的 Hash ID,用于 Core dump 时精准匹配外部符号文件。 | 常驻磁盘 |
| .debug_* (如 .debug_info) |
SHT_PROGBITS | 1 | DWARF 调试信息。记录变量名、类型、源码行号与指令的映射。体积非常巨大。 | ✂️ 会被 strip 彻底抹除 |
