【程序员自我修养】第六章:可执行文件的装载与进程——execve 之后内核做了什么
第六章:可执行文件的装载与进程
一句话核心结论:当你敲下
./a.out时,操作系统并没有把整个 ELF 文件”搬”进内存,它只是创建了一个”空头支票”——一张填满虚拟地址的表,等 CPU 真正访问某页时,才通过缺页中断把磁盘里的对应页”调包”进物理内存。这套按需分页机制,是现代操作系统所有”快启动、低内存、高安全”特性的根基。
故事开场:一段 double fork 守护进程代码引发的思考
我曾经在生产环境排查过一段经典的 daemon 化代码(用 fork 两次,脱离终端、脱离父进程组、独立运行):
1 | #include <stdio.h> |
编译运行:
1 | gcc double_fork.c -o double_fork |
一切看起来很常规对吧?但你是否想过:
fork()之后,子进程的地址空间从哪里来?是 复制 了父进程,还是 共用?- 当孙子进程执行
_exit(0)之外的代码(比如那个sleep(60)循环),它的.text段、.data段,究竟在物理内存的哪里? - 第一次
fork()后,父进程立刻_exit(0),子进程却 “继承” 了一切继续运行——操作系统是怎么做到 “无缝切换” 的? - 假如你用
strace跟踪double_fork,你会看到一长串mmap、mprotect、execve——这些系统调用背后,内核到底做了什么?
这一章,我们就把这层 “魔法” 彻底拆开,看看 execve 之后,内核到底做了什么。
前言:读完这一章你能得到什么?
- 彻底搞懂 32 位 vs 64 位 进程虚拟地址空间布局,包括 内核空间 与 用户空间 的划分
- 理解 ELF 段(Segment) 与 节(Section) 的本质区别——为什么装载用段、链接用节
- 掌握 页映射(Paging) 的核心思想:从
execve到第一次访问内存,内核到底懒加载了什么 - 看懂
execve调用的完整内核路径:sys_execve→do_execve→search_binary_handler→load_elf_binary - 理解 缺页中断(Page Fault) 的处理流程,以及 次要缺页 与 主要缺页 的差异
- 写一个 迷你 ELF 加载器,手把手演示装载过程
- 对比 Linux ELF 与 Windows PE 的装载差异
- 通过 6 个 Mermaid 图、50+ 代码块、25+ 表格,建立完整的知识网络
6.1 进程虚拟地址空间:每个进程都”看到”4GB/256TB 的世界
6.1.1 虚拟地址空间是什么?为什么需要它?
虚拟地址空间(Virtual Address Space) 是操作系统为每个进程提供的 “假象” —— 进程以为自己独占了全部内存,实际上它访问的每个地址,都要经过 MMU(内存管理单元) 翻译成真实的物理地址。
没有虚拟地址,世界会怎样?
设想一个 1980 年代程序员面对的”裸机”世界:
1 | // 没有 MMU 的世界:程序员必须自己管理物理地址 |
有了虚拟地址:
1 | #include <stdio.h> |
输出(典型):
1 | global at 0x55d4a3e02010 |
两个完全不同的虚拟地址,但 MMU 内部对应到同一块物理 RAM 的不同位置。进程不需要知道这些。
| 维度 | 物理地址(无虚拟) | 虚拟地址(有 MMU) |
|---|---|---|
| 地址冲突 | 程序 A 占用 0x1000,程序 B 必须避开 | 各自看到 0x1000,实际映射不同 |
| 内存保护 | 程序 A 可以改写程序 B 的内存 | 内核态与用户态严格隔离 |
| 内存超载 | 程序占用 = 物理 RAM 实际占用 | 虚拟地址 > 物理 RAM,通过换页扩展 |
| 进程隔离 | 必须手工实现 | 内核天然提供 |
| 代码定位 | 链接器必须指定绝对地址 | 链接器只关心虚拟地址,更灵活 |
一句话总结: 虚拟地址空间 = 给每个进程画一张独立的”内存地图”,内核和 MMU 负责”翻译”。
6.1.2 32 位 Linux 进程虚拟地址空间布局(经典 3G/1G 划分)
在 32 位 x86 Linux 上,虚拟地址空间总大小为 2^32 = 4GB,典型划分为:
- 0x00000000 ~ 0xBFFFFFFF (3GB) :用户空间
- 0xC0000000 ~ 0xFFFFFFFF (1GB) :内核空间
graph TB
subgraph "32位 Linux 进程虚拟地址空间 (3G/1G)"
A["0xFFFFFFFF<br/>内核空间起点"]
B["0xC0000000<br/>用户空间上限"]
C["0xBFFFFFFF"]
D["栈 stack<br/>8MB 默认<br/>从 0xC0000000 向下增长"]
E["mmap 区<br/>动态库/共享内存<br/>从 0x40000000 向上"]
F["堆 heap<br/>从 0x08048000 附近向上<br/>brk/sbrk 扩展"]
G[".bss"]
H[".data"]
I[".text<br/>代码段"]
J["0x08048000<br/>默认加载起点<br/>(ELF 头)"]
K["0x00000000<br/>空指针保护区<br/>mmap 失败时返回此处"]
end
A --> B --> C --> D --> E --> F --> G --> H --> I --> J --> K
style A fill:#E8D5F5,stroke:#CE93D8,color:#333
style B fill:#FFB3C6,stroke:#F48FB1,color:#333
style C fill:#F5F5F5,stroke:#9E9E9E,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#C7CEEA,stroke:#9FA8DA,color:#333
style F fill:#B5EAD7,stroke:#80CBC4,color:#333
style G fill:#FFF9C4,stroke:#F9A825,color:#333
style H fill:#FFF9C4,stroke:#F9A825,color:#333
style I fill:#FFB3C6,stroke:#F48FB1,color:#333
style J fill:#F5F5F5,stroke:#9E9E9E,color:#333
style K fill:#F5F5F5,stroke:#9E9E9E,color:#333关键地址含义表:
| 地址 | 区域 | 内容 | 大小 | 增长方向 |
|---|---|---|---|---|
0x00000000 | NULL 保护区 | 第一个 4KB 页,故意不可访问 | 4KB | — |
0x08048000 | 默认加载起点 | ELF 头、.text、.data、.bss | 视程序而定 | 向上 |
0x08048000 ~ brk | 堆 | malloc 分配的内存 | 动态 | 向上(brk) |
0x40000000 ~ | mmap 区 | 动态库、mmap 共享内存 | 动态 | 向上 |
0xC0000000 - 8M | 栈 | 局部变量、调用链 | 8MB | 向下 |
0xC0000000 ~ 0xFFFFFFFF | 内核空间 | 内核代码、数据、页表 | 1GB | — |
实验验证 1:打印各区段地址
1 | #include <stdio.h> |
编译运行(在 32 位环境下):
1 | # 编译为 32 位(需要 gcc-multilib) |
典型输出(32 位):
1 | === 各区段地址分布(32位 Linux) === |
对照 /proc/self/maps 看真实映射:
1 | cat > /tmp/show_maps.c << 'EOF' |
典型输出(32 位):
1 | 08048000-08049000 r-xp 00000000 08:01 1234 /tmp/show_maps |
6.1.3 64 位 Linux 进程虚拟地址空间布局(48 位有效地址)
64 位 x86-64 的虚拟地址虽然支持 2^64 字节,但目前 只用了低 48 位(256 TB),其中:
- 0x0000000000000000 ~ 0x00007FFFFFFFFFFF (128TB) :用户空间
- 0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF (128TB) :内核空间
- 中间一大块(0x0000800000000000 ~ 0xFFFF7FFFFFFFFFFF)为 “空洞”(non-canonical),访问会直接 #GP
graph TB
subgraph "64位 Linux 进程虚拟地址空间 (48位有效地址)"
A["0xFFFFFFFFFFFFFFFF<br/>canonical hole 上限"]
B["0xFFFF800000000000<br/>内核空间起点"]
C["0x00007FFFFFFFFFFF<br/>用户空间上限"]
D["空洞区<br/>non-canonical<br/>访问即 #GP"]
E["栈区<br/>从 0x7FFE... 向下<br/>默认 8MB"]
F["mmap 区<br/>0x7F... 向高地址<br/>动态库、mmap"]
G["堆区<br/>brk/向上<br/>malloc"]
H[".bss"]
I[".data"]
J[".text<br/>PIE: 0x55... 区域<br/>非PIE: 0x400000"]
K["0x0000000000000000<br/>NULL 保护区<br/>前 64KB 不可访问"]
end
A --> B --> C --> D --> E --> F --> G --> H --> I --> J --> K
style A fill:#E8D5F5,stroke:#CE93D8,color:#333
style B fill:#FFB3C6,stroke:#F48FB1,color:#333
style C fill:#F5F5F5,stroke:#9E9E9E,color:#333
style D fill:#F5F5F5,stroke:#9E9E9E,color:#333,stroke-dasharray: 5 5
style E fill:#FFDAB9,stroke:#FFAB76,color:#333
style F fill:#C7CEEA,stroke:#9FA8DA,color:#333
style G fill:#B5EAD7,stroke:#80CBC4,color:#333
style H fill:#FFF9C4,stroke:#F9A825,color:#333
style I fill:#FFF9C4,stroke:#F9A825,color:#333
style J fill:#FFB3C6,stroke:#F48FB1,color:#333
style K fill:#F5F5F5,stroke:#9E9E9E,color:#333关键差异表(32 位 vs 64 位):
| 维度 | 32 位 Linux | 64 位 Linux (x86-64) |
|---|---|---|
| 总虚拟地址 | 2^32 = 4GB | 2^48 = 256 TB(有效) |
| 用户空间上限 | 0xBFFFFFFF (3GB) | 0x00007FFFFFFFFFFF (128 TB) |
| 内核空间起始 | 0xC0000000 (1GB) | 0xFFFF800000000000 (128 TB) |
| 默认加载地址 | 0x08048000(非 PIE) | 0x400000(非 PIE)/ 0x55… (PIE) |
| 栈区位置 | 0xBFFF… | 0x7FFE… |
| mmap 区位置 | 0x40000000 ~ 0xBFFF… | 0x7F… 区域 |
| 堆区位置 | .bss 之上(brk) | .bss 之上(brk) |
| canonical hole | 不存在 | 0x00008000… ~ 0xFFFF7FFF… |
| NULL 保护区 | 0x00000000 单页 | 前 64KB(可配置) |
| ASLR 强度 | 较弱 | 强(16 位熵) |
实验验证 2:在 64 位环境再看一遍
1 | # 编译为 64 位 |
典型输出(64 位):
1 | === 各区段地址分布(64位 Linux) === |
注意 0x55... 开头(PIE 位置无关可执行文件)和 0x7f... 开头(栈、mmap)——这是 64 位的典型分布。
6.1.4 段(Segment)与节(Section):装载视角 vs 链接视角
这一对概念 经常被搞混,必须彻底分清:
| 维度 | 节(Section) | 段(Segment) |
|---|---|---|
| 英文 | Section | Segment(也译”程序段”) |
| 使用方 | 链接器(ld) | 装载器(kernel/ld.so) |
| 描述符 | Section Header Table(节头表) | Program Header Table(程序头表) |
| 数量 | 几十个(按功能细分) | 几个(按权限合并) |
| 粒度 | 细(.text、.data、.rodata、.bss、.symtab、.strtab…) | 粗(LOAD + 权限) |
| 典型数量 | 1 个 .text,1 个 .data,1 个 .bss… | 通常 2~4 个 LOAD 段 |
| 是否进内存 | 不一定(如 .symtab 只在调试时) | 装载时全部映射到内存 |
| 权限 | 链接器不关心 | 装载器按 PF_R/PF_W/PF_X 设置 |
实例对照(同一 ELF):
1 | gcc -O0 simple.c -o simple |
节视角(readelf -S):
1 | [ 1] .note.gnu.build-id NOTE 00000000004001c8 00 |
段视角(readelf -l):
1 | Elf file type is EXEC (Executable file) |
关键观察:
- 2 个 LOAD 段:第 1 个包含
.text/.rodata/.eh_frame等只读可执行段(flags=R E);第 2 个包含.data/.bss/.got/.dynamic等可写段(flags=RW) - 节的数量 是 段的 10+ 倍,但装载时内核只关心 LOAD 段
- 节和段的对应关系是”多对多”:一个段可以包含多个节;但一个节必须完全属于一个段(否则链接器会报错)
graph LR
subgraph "节 (Section) - 链接器视角"
S1[".text"]
S2[".rodata"]
S3[".eh_frame"]
S4[".data"]
S5[".bss"]
S6[".got"]
S7[".dynamic"]
end
subgraph "段 (Segment) - 装载器视角"
L1["LOAD #1<br/>R E<br/>0x400000"]
L2["LOAD #2<br/>RW<br/>0x600e10"]
L3["DYNAMIC<br/>0x600e10"]
end
S1 --> L1
S2 --> L1
S3 --> L1
S4 --> L2
S5 --> L2
S6 --> L2
S7 --> L3
style S1 fill:#FFB3C6,stroke:#F48FB1,color:#333
style S2 fill:#FFB3C6,stroke:#F48FB1,color:#333
style S3 fill:#FFB3C6,stroke:#F48FB1,color:#333
style S4 fill:#FFF9C4,stroke:#F9A825,color:#333
style S5 fill:#FFF9C4,stroke:#F9A825,color:#333
style S6 fill:#FFF9C4,stroke:#F9A825,color:#333
style S7 fill:#E8D5F5,stroke:#CE93D8,color:#333
style L1 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style L2 fill:#B5EAD7,stroke:#80CBC4,color:#333
style L3 fill:#E8D5F5,stroke:#CE93D8,color:#333为什么内核不按节装载?
答:性能 + 安全性。 节粒度太细,会导致页表项爆炸,而且同一页内可能有不同权限的节(比如 .rodata 和 .text 共用一页,但前者只读、后者可执行)。按”权限相同”合并成段,既减少页表开销,又便于 W^X(可写与可执行互斥)安全策略的实施。
6.1.5 进程虚拟地址空间全景图(含内核空间细节)
graph TB
subgraph "用户态可访问"
U1["🟦 用户栈<br/>~8MB<br/>向下增长<br/>env/argv 在最高地址"]
U2["🟦 mmap 区<br/>动态库、共享内存<br/>从 0x7F... 向下增长"]
U3["🟦 堆 heap<br/>brk/sbrk 扩展<br/>向上增长"]
U4["🟨 .bss 段<br/>未初始化全局<br/>起始 0x600e10"]
U5["🟨 .data 段<br/>已初始化全局<br/>起始 0x601000"]
U6["🟥 .text 段<br/>代码 + .rodata<br/>起始 0x400000"]
end
subgraph "内核态独占 (用户不可访问)"
K1["🟪 内核栈<br/>每线程 4~16KB<br/>位于此区"]
K2["🟪 内核 .text<br/>内核代码"]
K3["🟪 内核 .data<br/>内核数据"]
K4["🟪 直接映射区<br/>物理 RAM 1:1 映射<br/>用于内核访问用户页"]
K5["🟪 vmalloc 区<br/>vmalloc 分配区<br/>非连续物理页"]
end
U1 --> U2 --> U3 --> U4 --> U5 --> U6
K1 --> K2 --> K3 --> K4 --> K5
style U1 fill:#FFDAB9,stroke:#FFAB76,color:#333
style U2 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style U3 fill:#B5EAD7,stroke:#80CBC4,color:#333
style U4 fill:#FFF9C4,stroke:#F9A825,color:#333
style U5 fill:#FFF9C4,stroke:#F9A825,color:#333
style U6 fill:#FFB3C6,stroke:#F48FB1,color:#333
style K1 fill:#E8D5F5,stroke:#CE93D8,color:#333
style K2 fill:#E8D5F5,stroke:#CE93D8,color:#333
style K3 fill:#E8D5F5,stroke:#CE93D8,color:#333
style K4 fill:#E8D5F5,stroke:#CE93D8,color:#333
style K5 fill:#E8D5F5,stroke:#CE93D8,color:#333内核空间细分表(以 64 位 x86 Linux 为例):
| 区域 | 范围(近似) | 大小 | 用途 |
|---|---|---|---|
| 虚拟内存映射 | 0xFFFF880000000000 | 64TB | 内核直接映射所有物理 RAM(低端) |
| 虚拟内存映射 | 0xFFFFC90000000000 | 32TB | 实际使用 RAM 的高端 |
| vmalloc 区 | 0xFFFFC90000000000 | 32TB | vmalloc/ioremap 分配区 |
| 进程页表 | 0xFFFFEA0000000000 | 5TB | 每个进程的页表基址 |
| 固定映射 | 0xFFFFF00000000000 | 4MB | 固定映射特殊页 |
| 模块区 | 0xFFFFFFFFA0000000 | 1GB | 内核模块(insmod 加载) |
| CPU 入口 | 0xFFFFFFFF80000000 | 0.5GB | 内核代码、.text、.data |
实验验证 3:看内核虚拟地址布局
1 | # 内核启动日志中 |
6.2 装载的方式:从”全量复制”到”按需分页”
6.2.1 历史上的三种装载方式
| 方式 | 出现年代 | 思路 | 优点 | 缺点 |
|---|---|---|---|---|
| 覆盖装载 (Overlay) | 1960s | 程序分成多个 overlay,需要哪个装哪个 | 突破内存限制 | 程序员手动管理,痛苦 |
| 静态装载 (Static Loading) | 1970s | execve 时 一次性 全部读入内存 | 简单,运行时无 IO | 启动慢、内存浪费 |
| 按需分页 (Demand Paging) | 1980s+ | execve 只建映射,访问时按页调入 | 启动快、内存省、支持 COW | 实现复杂、有缺页延迟 |
6.2.2 覆盖装载:古老而痛苦的方案
在 MS-DOS、嵌入式等内存极小的场景,程序员必须自己把程序分成多个 覆盖块(Overlay),由程序员写一个 覆盖管理器(Overlay Manager) 决定什么时候把哪个块装入内存。
1 | // 概念代码,说明 Overlay 思想 |
覆盖装载的噩梦: 程序员必须精确知道 “哪块代码调用了哪块代码”,任何跨 overlay 的调用都必须通过覆盖管理器转发,调试极其痛苦。
6.2.3 静态装载:execve 一次性全部读入
最早的 Unix 装载方式。execve 系统调用把 ELF 全部内容读入内存,然后跳到入口点:
1 | // 简化的静态装载伪代码 |
问题:
- 启动慢:100MB 的程序,即使只用 1MB,也要读 100MB
- 内存浪费:多进程启动同一程序,每个进程都有一份副本(在 COW 出现前)
- 大程序崩溃:超过物理 RAM 装不下,即使程序实际只用 1MB
6.2.4 页映射:现代操作系统的标准答案
核心思想: execve 时 不读任何文件内容,只建立虚拟地址到文件的 “映射关系”(vma 结构),等 CPU 真正访问某页时,通过 缺页中断 才把对应的磁盘页读入物理内存。
sequenceDiagram
participant User as 用户进程
participant Exec as execve
participant Kernel as 内核
participant VMA as VMA链表
participant MMU as MMU/页表
participant Disk as 磁盘
User->>Exec: execve("./a.out")
Exec->>Kernel: sys_execve
Kernel->>Kernel: 读取 ELF 头
Kernel->>VMA: 为每个 LOAD 段创建 vm_area_struct
VMA->>MMU: 设置页表项(P=空, 但已映射)
Kernel-->>Exec: execve 返回
Exec->>MMU: CPU 开始执行 _start
MMU->>MMU: 访问 .text 第一页
MMU->>Kernel: 缺页中断!
Kernel->>Disk: 读 .text 第一页
Disk-->>Kernel: 返回数据
Kernel->>MMU: 填入物理页,设置 P=1
MMU-->>User: CPU 继续执行伪代码表示:
1 | // 现代 Linux 装载 ELF 的伪代码(load_elf_binary 简化版) |
页映射的三大好处:
| 好处 | 原理 | 效果 |
|---|---|---|
| 启动极快 | execve 只做”映射”,不做”拷贝” | 100MB 程序的启动时间 = 读 ELF 头 + 创建几个 VMA ≈ 1ms |
| 内存节省 | 多进程共享同一文件页(只读页) | 100 个 ls 进程只占用 1 份 ls 的 .text |
| COW 写时复制 | 共享页用 MAP_PRIVATE,写时再拷贝 | fork() 几乎零成本,只在写入时分配新页 |
6.3 从操作系统角度看可执行文件的装载
6.3.1 进程创建的”四步曲”
任何进程的创建,操作系统都要做四件事:
- 创建独立的虚拟地址空间:为进程分配
mm_struct,初始化页表 - 读取可执行文件头:校验 ELF,找到
e_entry、e_phoff、e_phnum - 建立虚拟地址空间与可执行文件的映射(VMA):为每个 LOAD 段创建
vm_area_struct - 设置 CPU 指令寄存器(PC):跳到
e_entry指向的入口点
1 | // 演示 fork + execve 的完整流程 |
编译运行:
1 | gcc process_create_4steps.c -o process_create_4steps |
典型 strace 输出(简化):
1 | clone(...) = 12345 # fork 实际调用 clone |
6.3.2 execve 系统调用:用户态到内核态的”传送门”
execve 是 进程装载的入口点,它的原型极其简洁:
1 |
|
参数表:
| 参数 | 含义 | 说明 |
|---|---|---|
pathname | 可执行文件路径 | 必须是 ELF 格式(或内核支持的格式) |
argv | 参数列表 | 以 NULL 结尾,argv[0] 通常是程序名 |
envp | 环境变量 | 以 NULL 结尾,如 HOME=/root |
| 返回值 | 成功:不返回;失败:返回 -1 并设置 errno | 成功 = 当前进程的”完全替换” |
关键点:execve 成功时不返回!
成功的 execve 会 完全替换 当前进程的地址空间、文件描述符表(部分)、信号处理等。原来的代码、数据、栈全部消失,新程序从入口点开始执行。
其他变体函数(glibc 包装):
| 函数 | 头文件 | 行为 |
|---|---|---|
execl(path, arg0, ..., NULL) | <unistd.h> | 列表形式,参数不定 |
execlp(file, arg0, ..., NULL) | <unistd.h> | 在 PATH 中搜索 file |
execle(path, arg0, ..., NULL, envp) | <unistd.h> | 自定义环境 |
execv(path, argv) | <unistd.h> | 数组形式 |
execvp(file, argv) | <unistd.h> | PATH 搜索 + 数组 |
execvpe(file, argv, envp) | <unistd.h> | GNU 扩展,自定义环境 |
全部最终都通过 execve 实现。 比如 execlp("ls", "ls", "-l", NULL) 的内部实现:
1 | // glibc 的 execlp 实现简化版 |
6.3.3 ELF 程序头表(Program Header Table):装载器的”地图”
程序头表 是内核装载 ELF 的”地图”,它告诉内核:”我有哪些段需要被加载到内存”。
结构体定义(<elf.h>):
1 | typedef struct { |
关键字段关系图:
graph LR
A["p_offset<br/>文件偏移"]
B["p_vaddr<br/>虚拟地址"]
C["p_filesz<br/>文件大小"]
D["p_memsz<br/>内存大小"]
E["p_align<br/>对齐"]
F["p_flags<br/>权限"]
A --> G["文件内容<br/>[p_offset, p_offset+p_filesz)"]
B --> H["内存映射<br/>[p_vaddr, p_vaddr+p_memsz)"]
C --> G
D --> H
E --> I["p_vaddr ≡ p_offset mod p_align"]
F --> J["决定 mmap 的 PROT_*"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#B5EAD7,stroke:#80CBC4,color:#333
style C fill:#FFDAB9,stroke:#FFAB76,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFF9C4,stroke:#F9A825,color:#333
style F fill:#FFB3C6,stroke:#F48FB1,color:#333
style G fill:#F5F5F5,stroke:#9E9E9E,color:#333
style H fill:#F5F5F5,stroke:#9E9E9E,color:#333
style I fill:#F5F5F5,stroke:#9E9E9E,color:#333
style J fill:#F5F5F5,stroke:#9E9E9E,color:#333p_filesz vs p_memsz:为什么可以不同?
1 | p_memsz >= p_filesz 始终成立 |
示例(看真实 ELF 的程序头):
1 | readelf -l /bin/ls |
典型输出:
1 | Elf file type is DYN (Shared object file) |
关键观察:
- 3 个 LOAD 段:第 1 个只读(R),第 2 个代码(R E),第 3 个数据(RW)
- 第 3 段:
p_filesz=0x2520<p_memsz=0x2770,差额0x250= 592 字节 =.bss大小 - INTERP 段:指定动态链接器路径
/lib64/ld-linux-x86-64.so.2
段类型表(常见值):
| 段类型 | 值 | 含义 | 内核处理 |
|---|---|---|---|
PT_LOAD | 1 | 可加载段 | 必须 mmap 进内存 |
PT_DYNAMIC | 2 | 动态链接信息 | 由 ld.so 处理 |
PT_INTERP | 3 | 动态链接器路径 | 内核把 ld.so 也 mmap 进内存 |
PT_NOTE | 4 | 辅助信息 | 拷贝到进程地址空间 |
PT_SHLIB | 5 | 保留 | 忽略 |
PT_PHDR | 6 | 程序头表自身 | 装载到 p_vaddr 位置 |
PT_TLS | 7 | 线程局部存储 | 内核为 TLS 分配空间 |
PT_GNU_EH_FRAME | 0x6474e550 | 异常处理 | 由 ld.so 处理 |
PT_GNU_STACK | 0x6474e551 | 栈权限 | 决定栈是否可执行 |
PT_GNU_RELRO | 0x6474e552 | 只读重定位 | 内核 mprotect 设为 R |
6.3.4 内核如何解析 ELF:从 sys_execve 到 load_elf_binary
完整调用链:
1 | 用户态: execve(path, argv, envp) |
核心代码:load_elf_binary 简化版
1 | // Linux 内核 fs/binfmt_elf.c 中的 load_elf_binary(简化) |
关键点:
- vm_mmap 实际就是
mmap,它把文件[p_offset, p_offset+p_filesz)映射到虚拟地址p_vaddr - 这里的 mmap 并没有把文件读入内存,只是建立了 “虚拟地址 → 文件偏移” 的关系
- 真正的读入 发生在第一次访问该页时,通过缺页中断完成
6.3.5 缺页中断(Page Fault)处理:按需分页的核心
缺页中断 是现代操作系统的”灵魂机制”,它把”装载”和”执行”解耦:
sequenceDiagram
participant CPU as CPU
participant MMU as MMU
participant TLB as TLB
participant IDT as 中断描述符
participant PF as page_fault_handler
participant VMA as find_vma
participant Alloc as alloc_page
participant Disk as 磁盘
participant PTE as 页表
CPU->>MMU: 访问虚拟地址 VA
MMU->>TLB: 查 TLB
TLB-->>MMU: 未命中(miss)
MMU->>PTE: 查页表
PTE-->>MMU: P=0(未映射)
MMU->>CPU: 触发 #PF 异常
CPU->>IDT: 查中断向量 14
IDT-->>CPU: 跳到 page_fault_handler
CPU->>PF: 进入缺页处理
PF->>PF: 读 CR2 = 缺页地址
PF->>VMA: find_vma(VA)
VMA-->>PF: 返回 vm_area_struct
alt 是匿名页(堆/栈)
PF->>Alloc: alloc_page(零页)
Alloc-->>PF: 返回新页
else 是文件映射(.text/.data)
PF->>Disk: file_read(file, offset, 4KB)
Disk-->>PF: 返回 4KB 数据
PF->>Alloc: alloc_page
Alloc-->>PF: 返回新页
PF->>PF: memcpy(from disk to page)
end
PF->>PTE: set_pte(VA → page)
PF->>TLB: invalidate
PF-->>CPU: iret 返回
CPU->>MMU: 重新执行访问
MMU-->>CPU: 访问成功!缺页处理函数(arch/x86/mm/fault.c):
1 | static void __kprobes |
次要缺页(Minor Fault)vs 主要缺页(Major Fault)
| 维度 | 次要缺页(Minor) | 主要缺页(Major) |
|---|---|---|
| 是否需要磁盘 IO | ❌ 否 | ✅ 是 |
| 场景 | 页已在内存,只是 TLB 失效 | 第一次访问,需从磁盘读 |
| 典型耗时 | 1~10 μs | 1~10 ms(差 1000 倍) |
| 示例 | 进程切换后回到同一页 | execve 后第一次访问 .text |
| 优化 | 无需优化 | readahead、预取 |
实验验证 4:统计缺页次数
1 | # 跑一个程序,统计缺页 |
6.4 进程栈初始化:从 _start 到 main 的”信封”
6.4.1 进程栈的”信封”结构
当 execve 完成后,内核在 栈的最高地址 准备好了一组”信封”,包含进程启动的所有信息。用户态的 _start 会在调用 __libc_start_main 之前,从栈中解析这些信息。
graph TB
A["栈底(高地址)<br/>环境字符串表 envp[]"]
B["argv 字符串表 argv[]<br/>如 ./a.out, -l, /tmp"]
C["辅助向量 auxv[]<br/>系统信息(架构、页大小等)"]
D["envp 指针数组<br/>envp[0]=&HOME, envp[1]=&PATH..."]
E["argv 指针数组<br/>argv[0]=&./a.out, argv[1]=&-l..."]
F["argc = 3<br/>(实际是第一个参数)"]
G["栈顶(低地址)<br/>%esp 初始位置"]
A --> B --> C --> D --> E --> F --> G
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C fill:#FFF9C4,stroke:#F9A825,color:#333
style D fill:#B5EAD7,stroke:#80CBC4,color:#333
style E fill:#B5EAD7,stroke:#80CBC4,color:#333
style F fill:#FFB3C6,stroke:#F48FB1,color:#333
style G fill:#F5F5F5,stroke:#9E9E9E,color:#333辅助向量(auxv)表:AT_ 系统常量*
| 类型 | 值 | 含义 | 例子 |
|---|---|---|---|
AT_NULL | 0 | 链表结束 | 必备 |
AT_IGNORE | 1 | 忽略 | — |
AT_EXECFD | 2 | 已被忽略 | — |
AT_PHDR | 3 | 程序头表地址 | 0x400040 |
AT_PHENT | 4 | 程序头条目大小 | 56(64位) |
AT_PHNUM | 5 | 程序头条目数量 | 9 |
AT_PAGESZ | 6 | 系统页大小 | 4096 |
AT_BASE | 7 | 动态链接器基址 | 0x7f.. |
AT_FLAGS | 8 | 标志 | 0 |
AT_ENTRY | 9 | 程序入口点 | 0x400430 |
AT_UID/AT_EUID | 11/12 | 用户 ID | 1000 |
AT_GID/AT_EGID | 13/14 | 组 ID | 1000 |
AT_PLATFORM | 15 | 平台字符串 | “x86_64” |
AT_HWCAP | 16 | CPU 特性位 | 0x… |
AT_CLKTCK | 17 | 时钟频率 | 100 |
AT_SECURE | 23 | 安全模式 | 0/1(非根) |
AT_RANDOM | 25 | 16 字节随机数指针 | 可用于 arc4random 种子 |
AT_HWCAP2 | 26 | 扩展 CPU 特性 | — |
AT_EXECFN | 31 | 完整可执行路径 | “/bin/ls” |
AT_SYSINFO_EHDR | 33 | vDSO 地址 | 0x7fff… |
AT_MINSIGSTKSZ | 51 | 最小信号栈 | 4608 |
实验验证 5:打印 auxv
1 | #include <stdio.h> |
编译运行:
1 | gcc print_auxv.c -o print_auxv |
典型输出:
1 | AT_PAGESZ = 4096 |
6.4.2 从 _start 到 main:内核与 libc 的”接力赛”
完整的启动流程:
sequenceDiagram
participant Kernel as 内核
participant _start as _start (用户态)
participant ld_so as ld-linux-x86-64.so.2
participant libc as libc.so
participant main as main
Kernel->>Kernel: execve 完成
Kernel->>Kernel: 设置栈指针 = stack_top
Kernel->>Kernel: 设置 PC = _start
Kernel-->>_start: iret 返回,跳到 _start
_start->>ld_so: 跳到动态链接器入口
ld_so->>ld_so: 解析 .dynamic、.got
ld_so->>libc: 装载 libc.so
ld_so->>_start: 链接完成,返回 _start
_start->>libc: 调用 __libc_start_main(main, argc, argv, ...)
libc->>libc: 调用 __cxa_atexit 注册退出处理
libc->>libc: 初始化 pthread、locale 等
libc->>main: 调用 main(argc, argv, envp)
main->>main: 用户代码执行
main->>libc: return 0
libc->>libc: 调用 exit,触发 atexit 处理
libc->>libc: 调用 _exit 系统调用关键函数源码:
1 | # _start 是程序入口,通常由 libc 或 ld.so 提供(glibc: sysdeps/x86_64/start.S) |
__libc_start_main 简化版(glibc):
1 | // glibc:csu/libc-start.c 简化 |
6.4.3 栈溢出与栈空间扩展
栈默认大小 8MB(ulimit -s),但不是预分配的,而是按页 “动态扩展”:
1 | #include <stdio.h> |
栈扩展机制(MM 的 VMA):
1 | // 在 page_fault 中处理栈增长 |
栈扩展 vs 堆扩展对比:
| 维度 | 栈(stack) | 堆(heap) |
|---|---|---|
| 增长方向 | 向下(向低地址) | 向上(向高地址) |
| 管理方式 | 自动(编译器插入指令) | 手动(malloc/free) |
| 扩展机制 | VMA + 缺页中断 | brk/sbrk 或 mmap |
| 大小限制 | ulimit -s(默认 8MB) | 受限于系统总内存 |
| 分配效率 | 极快(单条指令) | 较慢(可能需要系统调用) |
| 碎片 | 无 | 可能产生 |
| 线程安全 | 每线程独立栈 | 共享(需同步) |
6.5 Linux 内核装载 ELF 过程详解(源码级)
6.5.1 内核中的 ELF 支持模块
Linux 内核通过 binfmt_elf 模块支持 ELF 文件:
1 | // fs/binfmt_elf.c |
binfmt 链表: 内核支持多种可执行格式,通过链表组织:
| 模块 | 处理格式 | 魔数 |
|---|---|---|
binfmt_elf | ELF | \x7fELF |
binfmt_script | #! 脚本 | #! |
binfmt_misc | 自定义 | 用户注册 |
binfmt_aout | 旧版 a.out | \x07\x01\x00\x00 |
binfmt_flat | 嵌入式 FLAT | 自定义 |
binfmt_fdpic | FDPIC ELF | 类似 ELF |
装载过程核心:search_binary_handler
1 | // fs/exec.c |
6.5.2 Linux 二进制参数结构体(linux_binprm)
内核用 linux_binprm 临时保存 execve 的所有信息:
1 | // include/linux/binfmts.h |
6.5.3 详细装载流程(带源码注释)
Stage 1:do_execveat_common 准备 bprm
1 | // fs/exec.c 简化 |
Stage 2:load_elf_binary 装载 ELF
(前面已列出简化代码,这里补充关键决策点)
1 | // 关键决策点 1:动态链接 vs 静态链接 |
Stage 3:start_thread 切换到新程序
1 | // arch/x86/kernel/process_64.c |
关键:pt_regs 是内核栈上的”陷阱帧”,iret 时会恢复这些寄存器到 CPU!
6.5.4 完整流程图(从 fork 到第一个用户态指令)
sequenceDiagram
participant Parent as 父进程
participant K1 as sys_clone/fork
participant Child as 子进程(共享父页表)
participant K2 as sys_execve
participant Loader as load_elf_binary
participant MM as mm_struct 管理
participant CPU as CPU
Parent->>K1: clone() 系统调用
K1->>Child: 复制 task_struct
K1->>Child: 复制 mm_struct + 页表
K1->>Child: 共享 COW 标记
K1-->>Parent: 返回子 PID
K1-->>Child: 跳到返回点,继续
Note over Child: 共享父进程的整个地址空间<br/>(COW,写时复制)
Child->>K2: execve("./a.out")
K2->>K2: do_execve → 读 ELF 头
K2->>MM: 创建新 mm_struct
K2->>MM: 释放旧 VMA
K2->>MM: 为每个 LOAD 段创建 VMA
K2->>MM: 设置新栈(在 mmap 区)
K2->>MM: 设置 PC = _start
K2-->>Child: 返回用户态
Child->>CPU: 开始执行 _startfork + execve 完整代码演示:
1 | #include <stdio.h> |
编译运行:
1 | gcc fork_exec.c -o fork_exec |
典型 strace 输出:
1 | clone(child_stack=NULL, flags=CLONE_CHILD_SETTID|SIGCHLD, ...) = 12345 |
6.5.5 execve 的安全检查(AT_SECURE)
内核会根据进程是否”特权”(setuid/setgid)设置 AT_SECURE 标志:
| 条件 | AT_SECURE | 含义 |
|---|---|---|
| 程序有 setuid 位 | 1 | ld.so 会忽略 LD_PRELOAD 等 |
| 程序有 setgid 位 | 1 | 同上 |
| 文件有 capabilities | 1 | 同上 |
| 普通程序 | 0 | 正常环境 |
no_new_privs 标志 | 1 | 进程放弃了提权能力 |
AT_SECURE 的实际效果:
1 | # 1. 测试普通程序(AT_SECURE=0) |
内核代码:
1 | // fs/exec.c |
6.6 Windows PE 的装载:从 ELF 到 PE 的对比
6.6.1 PE 文件结构概览
PE(Portable Executable) 是 Windows 的可执行文件格式,和 ELF 在概念上对应,但细节差异很大。
graph TB
subgraph "PE 文件结构"
A["DOS 头<br/>(IMAGE_DOS_HEADER)<br/>e_magic = 'MZ'"]
B["DOS 存根<br/>(DOS STUB)<br/>显示 'This program cannot...'"]
C["NT 头<br/>(IMAGE_NT_HEADERS)<br/>Signature='PE\\0\\0'<br/>+ FileHeader<br/>+ OptionalHeader"]
D["节表<br/>(Section Table)<br/>描述每个节"]
E1[".text 节<br/>代码"]
E2[".data 节<br/>已初始化数据"]
E3[".rdata 节<br/>只读数据"]
E4[".bss 节<br/>未初始化(虚拟)"]
E5[".rsrc 节<br/>资源(图标、字符串)"]
E6[".reloc 节<br/>基址重定位表"]
E7[".debug 节<br/>调试信息"]
end
A --> B --> C --> D --> E1
D --> E2
D --> E3
D --> E4
D --> E5
D --> E6
D --> E7
style A fill:#FFB3C6,stroke:#F48FB1,color:#333
style B fill:#FFB3C6,stroke:#F48FB1,color:#333
style C fill:#E8D5F5,stroke:#CE93D8,color:#333
style D fill:#C7CEEA,stroke:#9FA8DA,color:#333
style E1 fill:#FFF9C4,stroke:#F9A825,color:#333
style E2 fill:#FFF9C4,stroke:#F9A825,color:#333
style E3 fill:#FFF9C4,stroke:#F9A825,color:#333
style E4 fill:#FFF9C4,stroke:#F9A825,color:#333
style E5 fill:#B5EAD7,stroke:#80CBC4,color:#333
style E6 fill:#FFDAB9,stroke:#FFAB76,color:#333
style E7 fill:#F5F5F5,stroke:#9E9E9E,color:#3336.6.2 Linux ELF vs Windows PE 关键对比
| 维度 | Linux ELF | Windows PE |
|---|---|---|
| 魔数 | \x7fELF (4 字节) | MZ (2 字节 DOS) + PE\0\0 (4 字节) |
| CPU 架构 | ELF e_machine(3=x86、62=x86_64) | IMAGE_FILE_MACHINE_*(I386=0x14c) |
| 入口点 | e_entry | AddressOfEntryPoint(RVA) |
| 段/节描述 | Program Header Table(装载) + Section Header Table(链接) | Section Table(同时用于装载和符号) |
| 段合并 | 内核按”权限”合并相邻段(LOAD) | 不合并,严格按节表粒度 |
| 基址 | 多数 PIE(随机) | 链接器默认 ImageBase=0x140000000 |
| ASLR | 默认开启(PIE 后) | Vista+ 默认开启 |
| 动态链接 | PT_INTERP + ld.so | PE 头 DataDirectory[IMPORT] + ntdll.dll |
| 重定位 | .rela.dyn / .rela.plt | .reloc 节 + IAT 表 |
| 栈/堆管理 | 栈向下、堆 brk 向上 | 栈 1MB 默认、堆 1MB 默认(进程头声明) |
| 子系统 | 不需要 | Subsystem(Console=3、GUI=2) |
| DLL 等价 | 共享库 .so | 动态链接库 .dll |
| API 调用 | glibc/syscall | Win32 API / ntdll |
| 创建进程 | fork() + execve() | CreateProcess() |
| 内存映射 | mmap | VirtualAlloc / MapViewOfFile |
| 装载器 | 内核 binfmt_elf | 内核 ntoskrnl + 用户态 ntdll |
6.6.3 PE 程序头结构(IMAGE_NT_HEADERS)
1 | // Windows SDK: winnt.h |
16 个数据目录(IMAGE_DATA_DIRECTORY)含义:
| 索引 | 名称 | 含义 |
|---|---|---|
| 0 | EXPORT | 导出表(.edata) |
| 1 | IMPORT | 导入表(.idata) |
| 2 | RESOURCE | 资源表(.rsrc) |
| 3 | EXCEPTION | 异常处理 |
| 4 | SECURITY | 安全证书 |
| 5 | BASERELOC | 基址重定位表(.reloc) |
| 6 | DEBUG | 调试信息 |
| 7 | ARCHITECTURE | 架构特定 |
| 8 | GLOBALPTR | 全局指针 |
| 9 | TLS | 线程局部存储 |
| 10 | LOAD_CONFIG | 装载配置 |
| 11 | BOUND_IMPORT | 绑定导入 |
| 12 | IAT | 导入地址表 |
| 13 | DELAY_IMPORT | 延迟导入 |
| 14 | COM_DESCRIPTOR | COM 描述符 |
| 15 | RESERVED | 保留 |
6.6.4 Windows 装载 PE 流程
graph TB
A["CreateProcess()<br/>用户态 API"]
B["ntdll!NtCreateUserProcess<br/>系统调用"]
C["内核 PspAllocateProcess<br/>创建 EPROCESS"]
D["内核 MmCreatePeb<br/>创建 PEB"]
E["内核 MmCreateSection<br/>创建段对象(文件映射)"]
F["内核 MmMapViewOfSection<br/>装载 .text/.data"]
G["创建初始线程<br/>ETHREAD"]
H["内核 KeStartThread"]
I["用户态 ntdll!LdrpInitialize"]
J["加载 import 表中的 DLL"]
K["调用 TLS 回调"]
L["跳到入口点 AddressOfEntryPoint"]
M["用户代码执行"]
A --> B --> C --> D --> E --> F --> G --> H --> I --> J --> K --> L --> M
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#E8D5F5,stroke:#CE93D8,color:#333
style D fill:#E8D5F5,stroke:#CE93D8,color:#333
style E fill:#E8D5F5,stroke:#CE93D8,color:#333
style F fill:#E8D5F5,stroke:#CE93D8,color:#333
style G fill:#E8D5F5,stroke:#CE93D8,color:#333
style H fill:#E8D5F5,stroke:#CE93D8,color:#333
style I fill:#FFDAB9,stroke:#FFAB76,color:#333
style J fill:#FFDAB9,stroke:#FFAB76,color:#333
style K fill:#FFDAB9,stroke:#FFAB76,color:#333
style L fill:#FFB3C6,stroke:#F48FB1,color:#333
style M fill:#B5EAD7,stroke:#80CBC4,color:#333关键差异点对比:
| 流程点 | Linux ELF | Windows PE |
|---|---|---|
| 装载触发 | execve(系统调用) | CreateProcess(API,内部用 NtCreateUserProcess) |
| 装载位置 | 内核 binfmt_elf 一气呵成 | 内核只做”段对象”映射,实际装载由 ntdll!LdrpInitialize 完成 |
| 段合并 | 按权限合并(LOAD) | 不合并,严格按节 |
| 重定位 | ELF 内核不做,留给 ld.so | 装载时若 ImageBase 被占用,执行 .reloc 表 |
| 导入表 | 动态节 .rela.plt、.got.plt | DataDirectory[IMPORT](0~13 多级表) |
| 入口点 | e_entry 指向 _start | AddressOfEntryPoint 通常指向 mainCRTStartup |
6.6.5 一个跨平台的”Hello”对照
Linux ELF (a.out)装载:
1 | // hello_linux.c |
Windows PE (hello.exe)装载:
1 | // hello_windows.c |
编译运行对比:
1 | # Linux |
用 readelf vs objdump 对照看头部:
1 | # Linux |
6.7 实战:写一个迷你 ELF 加载器
6.7.1 目标
写一个 C 程序 my_loader.c,它不调用 execve,而是手动解析 ELF 文件,把它的段映射到自己的地址空间,然后跳到入口点执行。
6.7.2 准备工作
1 | # 1. 准备一个待加载的 ELF 程序 |
6.7.3 完整加载器代码
1 | // my_loader.c - 一个极简的 ELF 加载器 |
编译运行:
1 | gcc -O0 my_loader.c -o my_loader |
预期输出:
1 | [loader] 装载 ./victim |
6.7.4 加载器的局限与改进
| 局限 | 原因 | 改进方向 |
|---|---|---|
| 不支持 PIE | 装载位置固定 | 加入 ET_DYN 处理 + 随机化 |
| 不支持动态链接 | 没有 ld.so | 检测 PT_INTERP,递归装载 |
| 栈布局简单 | 没传 envp/auxv | 完整构造 ELF 启动栈 |
| 不处理 relro | 没 mprotect | 装载后调用 mprotect |
| 错误处理粗糙 | 直接 exit | 返回错误码 |
| 不支持线程局部存储 | 没处理 PT_TLS | 解析 TLS 段并 arch_prctl(ARCH_SET_FS, ...) |
但这 50 行代码已经足够展示 ELF 装载的核心: “读文件头 → mmap 段 → 跳到入口点”。
6.8 高级话题:Copy-on-Write(写时复制)与 fork 的真实成本
6.8.1 fork 的”魔法”:为什么它快得不可思议
fork() 看起来”复制”了父进程的全部内存,但实际上:
- 不复制物理页,只复制
mm_struct和页表 - 父子共享所有物理页,但页表项标记为 只读(COW)
- 任何一方尝试写入时,触发 COW 缺页中断,才真正分配新页
graph LR
subgraph "fork 前"
P1["父进程<br/>mm_struct<br/>页表 PTE=RW"]
M1["物理页 A<br/>(R+W)"]
end
subgraph "fork 后 (子进程)"
C1["子进程<br/>mm_struct (新)<br/>页表 PTE=R (只读)"]
C2["物理页 A (新副本)<br/>尚未分配"]
end
M1 -.子写时分配.-> C2
P1 -.copy.-> C1
style P1 fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C1 fill:#B5EAD7,stroke:#80CBC4,color:#333
style M1 fill:#FFDAB9,stroke:#FFAB76,color:#333
style C2 fill:#F5F5F5,stroke:#9E9E9E,color:#333,stroke-dasharray: 5 5fork 的实际成本:
| 操作 | 成本 | 说明 |
|---|---|---|
分配 task_struct | 微秒级 | 内核分配 |
复制 mm_struct | 微秒级 | 直接 memcpy |
| 复制页表(顶层) | 微秒级 | 顶层的 PGD、PUD 几百项 |
| 标记所有 PTE 为只读 | 取决于虚拟内存大小 | 100MB 进程 = 25600 个 PTE ≈ 几百微秒 |
| 复制文件描述符表 | 微秒级 | 复制 fd 数组,共享 file 结构体 |
关键:不复制物理页内容!
6.8.2 COW 缺页中断处理
1 | // mm/memory.c 简化 |
6.8.3 fork + execve 的”组合拳”:为什么 shell 命令这么快
1 | // 测量 fork + execve 的总时间 |
典型输出:
1 | 平均每次 fork+execve+wait 时间: 850.0 μs |
其中:
- fork ≈ 100~200 μs(只复制页表,不复制物理页)
- execve ≈ 200~500 μs(读 ELF 头 + mmap 段,比 fork 慢)
true本身执行 ≈ 100~200 μs
对比 vfork(只复制页表,父进程阻塞):
1 | 平均每次 vfork+execve+wait 时间: 350.0 μs |
vfork 比 fork 快约 2~3 倍,因为它甚至不修改页表项的写权限(假设子进程马上 execve)。
6.9 性能与优化:装载的”瓶颈”在哪里?
6.9.1 启动时间分解(典型 Linux 程序)
pie title 程序启动时间分布(典型)
"execve 系统调用" : 5
"读 ELF 头 + 程序头" : 5
"创建 VMA + 设置页表" : 10
"缺页中断(次要)" : 20
"缺页中断(主要:从磁盘读)" : 30
"装载动态库" : 20
"符号重定位" : 5
"TLS 初始化" : 2
"构造函数" : 2
"跳到 main 之前" : 16.9.2 优化手段
| 优化方向 | 手段 | 效果 |
|---|---|---|
| 减少缺页 | 大页(HugePage 2MB/1GB) | 启动快 5~10% |
| 减少缺页 | 预取(readahead) | 顺序读场景提速 2x |
| 减少装载量 | 静态链接 + -Os | 减少 .text 段大小 |
| 减少装载量 | 去掉未用符号(-ffunction-sections -Wl,--gc-sections) | 减小 20~50% |
| 并行 | 并行 dlopen(ld.so 4+ 支持) | 多核机器提速 1.5x |
| 预加载 | LD_PRELOAD、preload 系统调用 | 应用启动前装载好 |
| 快照 | 容器快照(CRIU) | 跳过装载,直接恢复 |
| 缓存 | vmtouch、fadvise(POSIX_FADV_WILLNEED) | 提前把文件页加载到 page cache |
| 优化动态库 | --as-needed、-Wl,-z,now | 减少重定位开销 |
6.9.3 实验:看具体程序的装载开销
1 | # 1. 跟踪所有系统调用 |
6.10 关键问题 Q&A
Q1:为什么 PIE 文件的入口点接近 0,而不是 0x400000?
答: PIE(Position Independent Executable)文件,虚拟地址在编译时设为相对偏移(以 0 为基准),但 装载时 内核会选一个随机基址 + 这个偏移。e_entry=0x6540 表示”入口点距离装载基址 0x6540”,实际地址 = load_bias + 0x6540,其中 load_bias 是随机的。
非 PIE 文件的 e_entry=0x400430 是 绝对虚拟地址,装载时不能改变。
Q2:为什么不能直接 jmp e_entry,而要 start_thread 修改 pt_regs?
答: 几个原因:
e_entry假设在 用户态 执行,但当前 CPU 在 内核态(系统调用返回前)- 需要切换栈指针(从内核栈到用户栈)
- 需要恢复用户态的段寄存器(CS、SS、DS 等)
- 需要清空寄存器,防止信息泄露
修改 pt_regs + iret 是最干净的方式。
Q3:execve 失败时,原进程还能继续运行吗?
答:能! execve 只在成功时 替换进程,失败时(如文件不存在、权限不足)返回 -1,原进程的地址空间、文件描述符、状态完全不变,可以继续执行。
1 | pid_t pid = fork(); |
Q4:execve 后,文件描述符会关闭吗?
答:看是否设置了 O_CLOEXEC。 默认情况下,execve 会 保留 未设置 O_CLOEXEC 的 fd,但设置了的会关闭。
1 | int fd = open("file.txt", O_RDWR | O_CLOEXEC); // execve 后关闭 |
Q5:execve 能装载脚本吗?
答:能,靠 binfmt_script 解释 #!。 如果文件第一行是 #! interpreter [arg],内核会重新调用 interpreter,把脚本路径作为参数:
1 | # script.sh |
Q6:为什么现代 Linux 默认启用 PIE?
答:安全。 启用 PIE 后,.text 段地址随机化,攻击者难以预测函数地址,ROP(Return-Oriented Programming)攻击 的难度大幅提升。
关闭 PIE 的代价: 可执行文件加载到固定地址,极易被攻击。
Q7:execve 和 execveat 有什么区别?
答:execveat 是 execve 的扩展,接受一个 目录 fd,在该目录下用相对路径查找:
1 | int execveat(int dirfd, const char *pathname, |
flags:
AT_EMPTY_PATH:允许pathname=""并执行dirfd指向的文件(等价于fexecve)AT_SYMLINK_NOFOLLOW:不跟随符号链接
6.11 总结:这一章我们学到了什么?
6.11.1 知识结构图
graph TB
A["进程虚拟地址空间<br/>32位 4GB / 64位 256TB"]
B["段 vs 节<br/>装载 vs 链接"]
C["三种装载方式<br/>覆盖 / 静态 / 按需分页"]
D["execve 完整流程<br/>sys_execve → load_elf_binary → start_thread"]
E["ELF 程序头表<br/>PT_LOAD / PT_INTERP / PT_DYNAMIC"]
F["缺页中断处理<br/>次要 / 主要 / COW"]
G["进程栈初始化<br/>_start → __libc_start_main → main"]
H["Linux vs Windows<br/>ELF vs PE"]
I["fork + execve<br/>COW 写时复制"]
J["迷你加载器实战"]
K["性能优化<br/>大页 / 预取 / 静态链接"]
A --> B --> C --> D --> E --> F
D --> G
D --> H
D --> I
C --> J
D --> K
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#FFB3C6,stroke:#F48FB1,color:#333
style C fill:#FFDAB9,stroke:#FFAB76,color:#333
style D fill:#E8D5F5,stroke:#CE93D8,color:#333
style E fill:#B5EAD7,stroke:#80CBC4,color:#333
style F fill:#FFF9C4,stroke:#F9A825,color:#333
style G fill:#B5EAD7,stroke:#80CBC4,color:#333
style H fill:#FFB3C6,stroke:#F48FB1,color:#333
style I fill:#FFDAB9,stroke:#FFAB76,color:#333
style J fill:#FFF9C4,stroke:#F9A825,color:#333
style K fill:#B5EAD7,stroke:#80CBC4,color:#3336.11.2 关键术语表
| 术语 | 英文 | 一句话解释 |
|---|---|---|
| 虚拟地址空间 | Virtual Address Space | 进程看到的”假”地址空间 |
| MMU | Memory Management Unit | 硬件,负责虚拟地址到物理地址的翻译 |
| VMA | Virtual Memory Area | 内核中描述一块连续虚拟内存区域的结构体 |
| 装载 | Loading | 把可执行文件从磁盘映射到内存 |
| 按需分页 | Demand Paging | 访问时再调入,而不是 execve 时全读 |
| execve | execve | Linux 装载可执行文件的系统调用 |
| COW | Copy-on-Write | 写时复制,fork 优化的关键 |
| PT_LOAD | Program Header Type | 程序头中”可加载段”的类型 |
| ELF | Executable and Linkable Format | Linux/Unix 的可执行文件格式 |
| PE | Portable Executable | Windows 的可执行文件格式 |
| ASLR | Address Space Layout Randomization | 地址空间布局随机化,安全特性 |
| PIE | Position Independent Executable | 位置无关可执行,ASLR 的前提 |
_start | _start | 用户态程序入口(由 ld.so 提供) |
| 缺页中断 | Page Fault | 访问未映射页时触发的异常 |
| 辅助向量 | Auxiliary Vector | 栈上传递给用户态的系统信息(AT_*) |
6.11.3 关键系统调用表
| 系统调用 | 作用 | 头文件 |
|---|---|---|
execve(path, argv, envp) | 装载并执行新程序 | <unistd.h> |
fork() | 创建子进程(完整复制) | <unistd.h> |
vfork() | 创建子进程(共享地址空间) | <unistd.h> |
clone(flags, ...) | 通用进程创建 | <sched.h> |
mmap(addr, len, prot, flags, fd, off) | 内存映射 | <sys/mman.h> |
mprotect(addr, len, prot) | 修改内存权限 | <sys/mman.h> |
munmap(addr, len) | 取消映射 | <sys/mman.h> |
brk(addr) | 调整堆顶 | <unistd.h> |
madvise(addr, len, advice) | 内存使用建议 | <sys/mman.h> |
6.11.4 关键配置文件 / 工具
| 工具/文件 | 用途 |
|---|---|
/proc/$pid/maps | 进程的内存映射 |
/proc/$pid/status | 进程状态(VmRSS、VmSize 等) |
pmap -x $pid | 格式化的内存映射 |
readelf -l | 查看 ELF 程序头 |
readelf -S | 查看 ELF 节头 |
objdump -p | 查看 PE 头(MSYS2) |
strace | 跟踪系统调用 |
ltrace | 跟踪库调用 |
perf stat -e page-faults | 统计缺页 |
/bin/time -v | 详细的资源使用 |
LD_DEBUG=files | 跟踪动态库装载 |
6.11.5 关键内核源码路径
| 文件 | 内容 |
|---|---|
fs/exec.c | sys_execve、do_execve、search_binary_handler |
fs/binfmt_elf.c | load_elf_binary、load_elf_library、elf_format |
fs/binfmt_script.c | #! 解释器支持 |
arch/x86/kernel/process_64.c | start_thread(x86-64) |
mm/mmap.c | do_mmap、vm_mmap |
mm/memory.c | handle_pte_fault、do_anonymous_page、do_wp_page(COW) |
arch/x86/mm/fault.c | __do_page_fault |
include/linux/binfmts.h | linux_binprm、linux_binfmt 定义 |
include/linux/mm_types.h | vm_area_struct、mm_struct 定义 |
include/uapi/linux/elf.h | 用户态 ELF 头定义 |
6.12 行动建议与思考题
6.12.1 给你(读者)的行动建议
1. 入门者(刚开始学):
- ✅ 跑一遍本文的”实验验证 1-5”,用
cat /proc/$pid/maps看自己程序的真实映射 - ✅ 写一个
print_segments.c看自己程序的地址布局 - ✅ 用
strace -e execve,mmap ./program看 execve 真实发生的系统调用
2. 中级者(想深入):
- ✅ 编译本文的
my_loader.c,修改它支持 PIE - ✅ 用
gdb调试vfork + execve的子进程,观察 VMA 变化 - ✅ 比较
static链接和动态链接程序的readelf -l输出差异
3. 高级者(想挑战):
- ✅ 给
my_loader.c加上 动态链接 支持(检测 PT_INTERP 并递归装载 ld.so) - ✅ 阅读
fs/binfmt_elf.c完整源码,理解setuid程序的安全处理 - ✅ 用
perf分析大型项目(如firefox、nginx)的启动时间分布 - ✅ 写一个 共享库注入器(修改
LD_PRELOAD行为),理解 link_map 链表
6.12.2 思考题(附参考答案方向)
Q1: 如果你在 fork() 之后、execve() 之前修改了一个全局变量,会发生什么?这说明 fork 的什么特性?
答: 子进程看到的修改在
execve后会”消失”(因为execve替换地址空间),但在execve之前是 可见 的。这说明fork不是真正的”内存共享”,而是 COW 共享 + 写时复制——子进程的修改在物理内存上是独立的。
Q2: 假如你写了一个死循环,每次启动时打印 "Hello" 10000 次。execve 在装载时,内核会把这 10000 个 printf 的代码都读入内存吗?
答: 不会。
execve只建立虚拟地址到文件的映射,不读入文件内容。只有当 CPU 真的执行到printf对应的.text页时,才通过缺页中断读入那一页(4KB)。如果循环 10000 次,可能只读了 1~2 页.text就足够。
Q3: 32 位 Linux 默认只能装载 ~3GB 用户空间,为什么实际可装载的单个程序通常更小(约 2GB)?
答: 因为 32 位 Linux 默认把虚拟地址空间分成 3GB 用户 + 1GB 内核,但具体限制包括:
- 栈区 8MB 默认(可
ulimit -s调整)- mmap 区限制(
vm.overcommit_ratio、max_map_count等)- 堆大小受物理 RAM 限制
- 实际上用户可用 ≈ 2.5~2.7GB(扣掉栈、mmap、保留区)
Q4: 假如一个 ELF 文件没有 PT_INTERP 段,它还能是动态链接的吗?
答: 不能。
PT_INTERP是动态链接的”门票”,告诉内核要把哪个动态链接器(/lib64/ld-linux-x86-64.so.2)装入内存。没有PT_INTERP就是 纯静态链接,所有依赖都链接到可执行文件本身。
Q5: execve 之后,线程会发生什么?
答: 全部消失!
execve替换地址空间,所有 用户态线程 都跟着消失;内核态线程不会变。某些实现会保留特殊的”管理线程”(如posix_spawn的某些 flag),但标准execve是”单线程幸存”——只有调用execve的那个线程”活下来”成为新程序的唯一线程。
Q6: Windows 下的 .exe 是不是只能有 1 个入口点?Linux ELF 呢?
答: Windows PE 严格说只有 1 个
AddressOfEntryPoint,但通过 TLS 回调、DllMain等机制可以在主入口前执行代码。Linux ELF 同理,1 个e_entry,但 动态链接器 和.init_array、.fini_array提供了”多个入口”的能力。
Q7: execve 能装载一个已经在运行的程序吗?(比如把 nginx 的可执行文件用 execve 跑两遍)
答: 能!但会创建两个独立的进程,各自有独立地址空间。
execve不关心文件是否”被占用”,只关心格式对不对、权限够不够。两个进程可以共享.text段(因为MAP_PRIVATE+ 文件 inode 的页缓存),但.data、堆、栈是独立的。
6.13 一句话总结
从
./a.out到main之间的几毫秒,内核做了:创建进程、读 ELF 头、建虚拟映射、设置栈、跳到_start、跑过 ld.so、调用__libc_start_main、最后才轮到main执行。 —— 每一行都藏着几十年的设计权衡。
📚 程序员的自我修养 系列导航
本文是《程序员的自我修养》系列第 6/15 篇。
| 方向 | 章节 | 链接 |
|---|---|---|
| ◀ 上一篇 | 第五章:动态链接 | /05-动态链接/ |
| 当前 | 第六章:可执行文件的装载与进程 | /06-可执行文件的装载与进程/ |
| 下一篇 ▶ | 第七章:动态链接的实现 | /07-动态链接的实现/ |
📖 全部 15 篇目录(点击展开)
| # | 章节 | 链接 |
|---|---|---|
| 1 | 第一章:温故而知新 | /01-温故而知新/ |
| 2 | 第二章:编译和链接 | /02-编译和链接/ |
| 3 | 第三章:目标文件里有什么 | /03-目标文件里有什么/ |
| 4 | 第四章:静态链接 | /04-静态链接/ |
| 5 | 第五章:动态链接 | /05-动态链接/ |
| 6 | 第六章:可执行文件的装载与进程 | /06-可执行文件的装载与进程/ ← 当前 |
| 7 | 第七章:动态链接的实现 | /07-动态链接的实现/ |
| 8 | 第八章:Linux 共享库的组织 | /08-Linux共享库的组织/ |
| 9 | 第九章:内存管理 | /09-内存管理/ |
| 10 | 第十章:运行库 | /10-运行库/ |
| 11 | 第十一章:系统调用 | /11-系统调用/ |
| 12 | 第十二章:线程库 | /12-线程库/ |
| 13 | 第十三章:内存 | /13-内存/ |
| 14 | 第十四章:线程同步 | /14-线程同步/ |
| 15 | 第十五章:调试 | /15-调试/ |
📖 参考资料
- 《程序员的自我修养:链接、装载与库》—— 俞甲子、石凡、潘爱民
- Linux 内核文档
- System V ABI (x86-64)
- ELF 文件格式规范
- GNU Binutils 文档
- Microsoft PE/COFF 规范
- Linux man pages: execve(2)
- Linux man pages: mmap(2)
- Intel SDM Volume 3: Page Fault Handling
- Linux 内核源码: fs/binfmt_elf.c
最后更新:2026-06-16 | 维护者:Xu Qi