文章

虚拟地址空间布局

虚拟地址空间布局

一直对内存布局只有模糊印象,虽然平时也够用,但疑惑的时候也是真的疑惑。这回直接学习整理了一波,虽说依旧不敢说“懂了”,但也够详细了。

经过近百轮追问和打磨(97次AI对话),无数次查阅对比参考资料,本文遂成。

图

原创图

图片为原创手绘图,非AI生图。

图片主要是依据AI对话整理绘制而成,此外,还参考了如下文档:

30.2. Memory Management — The Linux Kernel documentation(官方文档)

Kernel Address Space - Linux Kernel Internals

图片只求形成大致布局,省略了诸多细节,譬如空洞。


虚拟地址空间大观:

202609221105204.png


x86-32进程虚拟地址空间:

202609221105068.png


x86-64进程虚拟地址空间:

202609221106190.png

第三方图

来源:Reading/linux/Linux内核虚拟地址空间.md at master · Uyouii/Reading · GitHub


x86-32进程虚拟地址空间:

202609191055169.png


x86-64进程虚拟地址空间:

202609191055635.png

内核空间

1> 关于“页”的概念:

  • 页:虚拟内存和物理内存都按固定大小切块。虚拟内存那边的块叫虚拟页,物理内存那边的块叫物理页。“页框”一词通常专指物理页。
  • 页缓存:存储文件数据的物理页框集合。
  • 匿名页:存储非文件数据的物理页框。
  • 页表:记录虚拟页->物理页框映射关系的数据结构。

2> 内核调度器调度的基本单位是 task_struct,它分为两类:内核线程和用户线程。内核线程是内核直接创建、只在内核态运行且没有用户空间的调度实体;用户线程是用户程序请求内核创建、可在用户态和内核态运行且拥有用户空间的调度实体。“内核线程”与“内核态用户线程”是两回事,前者是内核线程,后者仍是用户线程。

3> 内存分配机制:

  • 页分配器(伙伴系统):内核最底层的物理页分配器,以页(通常 4KB)为单位管理所有物理内存。
  • slab分配器:建立在伙伴系统之上,把整页切成固定大小的对象缓存,用于分配内核小对象,减少碎片和开销。

4> 简单介绍几个区:

  • 直接映射区:内核虚拟地址中线性映射物理内存的区域,虚拟地址 = 物理地址 + 固定偏移。32位只映射低端内存,通常最多约896MB;64位有64TB,足够映射全部物理内存。
  • 持久映射区(pkmap区):32位下用于把高端内存页较长期地映射到内核虚拟地址,因为32位内核无法直接映射全部物理内存。
  • vmemmap区:存放 struct page 数组,每个物理页对应数组中的一个 struct page 元素,形成虚拟连续的元数据映射,便于从页框号快速找到对应的 struct page。
  • vmalloc/ioremap区:用于虚拟地址连续但物理地址可以不连续的内存分配,也用于映射设备的物理内存。

5> 页分配器(伙伴系统)管理所有物理内存,图中将其画在直接映射区是指该区域有虚拟地址映射到由其分配的物理内存,并列举了一些例子,并非指其它区域就不能有虚拟地址映射到由其分配的物理内存。32位中,页分配器(伙伴系统)并不只工作在低端物理内存,若其分配高端物理内存,内核就需要在持久映射区建立映射才能访问。无论是32位还是64位,slab缓存通常只位于直接映射区。

6> 有些内容的位置与环境配置有关:

  • 页缓存:32位既可能在低端内存直接映射区也可能在高端内存(需要 kmap() 等机制建立临时映射),64位全部在直接映射区。
  • 每线程内核栈:32位通常在直接映射区;64位通常启用 CONFIG_VMAP_STACK,在vmalloc区,否则在直接映射区。
  • DEFINE_PER_CPU():DEFINE_PER_CPU() 用于定义静态每CPU变量。32位通常采用 pcpu_embed_first_chunk() 方式,在直接映射区;64位通常采用 pcpu_page_first_chunk() 方式,在vmalloc区,若采用 pcpu_embed_first_chunk() 方式,则在直接映射区。

栈区

1> 如下内容存储在栈底(高地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
高地址(栈底)
┌─────────────────────────────────────┐
│ 环境变量字符串                        │  如 "PATH=/usr/bin"、"HOME=/home/user"
│ 命令行参数字符串                      │  如 "ls"、"-l"
│ (字符串内容,以 '\0' 分隔)           │
├─────────────────────────────────────┤
│ 辅助向量 auxv                         │  如 AT_PAGESZ、AT_RANDOM、AT_PLATFORM
│ (以 AT_NULL 结尾)                   │
├─────────────────────────────────────┤
│ envp[] 指针数组                       │  指向环境变量字符串
│ (以 NULL 结尾)                      │
├─────────────────────────────────────┤
│ argv[] 指针数组                       │  指向命令行参数字符串
│ (以 NULL 结尾)                      │
├─────────────────────────────────────┤
│ argc                                │  参数个数
└─────────────────────────────────────┘ ← 初始 ESP/RSP 指向这里
低地址(栈顶,后续栈帧从此向下增长)

mmap动态映射区

1> mmap() 映射流程简述:

  • 文件映射
    1. 分配一块虚拟内存,只是口头承诺,此时物理内存尚未被分配。
    2. 首次访问时触发缺页异常,内核分配物理内存,然后加载文件数据至内存。
    3. 填充页表,把虚拟内存映射至物理内存。
  • 匿名映射
    1. 分配一块虚拟内存,只是口头承诺,此时物理内存尚未被分配。
    2. 首次访问时触发缺页异常,内核分配物理内存并清零。
    3. 填充页表,把虚拟内存映射至物理内存。

2> “mmap动态映射区”的“动态”即“运行时”的意思,本文为什么要加“动态”二字呢?.bss, .data, .rodata, .text 等段的加载依靠 mmap(),却不被归入“mmap映射区”,因其是在ELF文件启动时被“静态”加载。所以,常说的“mmap映射区”其实指的是“运行时的mmap映射区”,即“mmap动态映射区”。

3> 说一下 fopen() 和 mmap() 打开文件的区别:

先说下文件数据究竟存在哪里。内核伙伴系统为文件数据分配物理内存,然后内核通过直接映射区访问这片内存,将文件数据加载进其中。称这片物理内存为页缓存,文件数据就存储在此处,若从虚拟地址空间的角度看,文件数据就存储在内核直接映射区中那些映射到页缓存的虚拟内存之中。

对于 fopen() 系列的情况,FILE 管理结构和IO缓冲区都在用户态堆区,fread(), fwrite() 只是与用户态IO缓冲区交互,在必要时机,比如缓冲区空或满时才会通过系统调用将文件数据由页缓存拷贝至缓冲区或由缓冲区拷贝至页缓存,即数据-用户态缓冲区-页缓存。

对于 mmap() 的情况,如上所述,它将开辟一块虚拟内存,然后将这块虚拟内存直接映射到页缓存,即数据-页缓存。相比 fopen() 系列,数据流没有额外的拷贝,此即“零拷贝”。

并非 mmap() 一定更好,二者各有优缺点。若需像操作内存一样随机访问文件内容或跨进程共享数据,mmap() 也许更好;若只是顺序读写或追求简单可靠,则 fopen() 系列也许更好。

4> 每个动态库在mmap动态映射区获得一块区域,它自身也会被分成 .bss, .data, .rodata, .text 等,但没有独立的栈和堆,它将使用当前线程的栈和进程的堆。可以这样想,每个动态库就是一个被搬进mmap动态映射区的迷你ELF。

5> malloc() 大内存(通常≥128KB)会走 mmap() 匿名映射机制,位于mmap动态映射区,而不是堆区。

全局区、常量区、代码区

1> .bss, .data, .rodata, .text 段实际上是ELF文件中的段,这些段会被加载到内存中。

2> 因为存储在 .bss 段中的变量没有初始化值,所以 .bss 段不占用磁盘空间,仅有其占位大小等必要信息被记录在ELF的特定元区域中。ELF被启动后,操作系统为 .bss 段分配该占的内存并自动初始化为0。

3> 存储在 .rodata 段的内容举例:

1
2
3
4
5
6
7
8
const int global = 200;  // .rodata
static const int global = 500;  // .rodata
const char *global = "hello world";  // global: .data, "hello world": .rodata
const int local = 30;  // stack
static const int local = 99;  // .rodata
// 40: .text
// 40 is directly written into the instruction as an immediate value.
if (a > 40)

私有与共享

1> 内存生命周期大观:

内核启动后,内核空间就存在了,一直存在,直到关机。在内核空间被分配的内存,除非代码手动释放,或存在自动回收机制,否则将永远不被回收,哪怕分配它的线程已死。

进程可以被创建和销毁,其用户空间也跟着被创建和销毁。用户空间被销毁时,用户空间内存将被内核全部释放。进程共享同一个内核空间,进程被销毁时,内核空间自然不可能也跟着被销毁。

用户空间内存泄漏还有内核兜底,进程一死,内存回收;内核空间内存泄漏没有兜底,因为内核一直活着。

2> 虚拟地址空间大观:

  • 所有线程(包括内核线程、用户线程)共享内核空间。
  • 进程私有自己的用户空间,共享同一个内核空间。
  • 同一个进程下的用户线程共享该进程的用户空间。

3> 私有与共享资源具体总结:

  • 线程私有:线程ID、寄存器、用户栈、内核栈、task_struct。
  • 进程内线程共享:进程ID、环境变量、命令行参数、fd[]、mmap动态映射区、堆区、全局区、常量区、代码区。
  • 所有线程共享:页缓存、内核映像、内核模块等内核全局资源。

私有与共享是逻辑上的,实现方式可能多种多样。比如,线程逻辑上私有寄存器,是靠着上下文切换时保存和恢复现场实现的,实际在物理上,寄存器只有一份,内核更没有实现过“线程私有的虚拟寄存器”这种概念。

4> 文件访问逻辑关系图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
用户态:
  进程1 线程1: FILE *fp1 ──→ fd = 3 (data.txt)
  进程1 线程2: FILE *fp2 ──→ fd = 4 (log.txt)
  进程2 线程3: FILE *fp3 ──→ fd = 3 (data.txt)

内核态:

进程1 (tgid=1000)
  ┌──────────────────────────────┐
  │ task_struct (线程1, pid=1001) │
  │   files ─────────────────────┼──┐
  └──────────────────────────────┘  │
  ┌──────────────────────────────┐  │
  │ task_struct (线程2, pid=1002) │  │
  │   files ─────────────────────┼──┤
  └──────────────────────────────┘  │
                                    ▼
                     files_struct (进程1私有)
                     ┌──────────────────────────────┐
                     │ fdtable                      │
                     │   fd[]:                      │
                     │     fd[0] ──→ stdin          │
                     │     fd[1] ──→ stdout         │
                     │     fd[2] ──→ stderr         │
                     │     fd[3] ──→ struct file A  │──→ data.txt
                     │     fd[4] ──→ struct file B  │──→ log.txt
                     └──────────────────────────────┘

进程2 (tgid=2000)
  ┌──────────────────────────────┐
  │ task_struct (线程3, pid=2001) │
  │   files ─────────────────────┼──┐
  └──────────────────────────────┘  │
                                    ▼
                     files_struct (进程2私有)
                     ┌──────────────────────────────┐
                     │ fdtable                      │
                     │   fd[]:                      │
                     │     fd[0] ──→ stdin          │
                     │     fd[1] ──→ stdout         │
                     │     fd[2] ──→ stderr         │
                     │     fd[3] ──→ struct file C  │──→ data.txt
                     └──────────────────────────────┘

struct file A ──┐
                ├──→ dentry(data.txt) ──→ inode(data.txt) ──→ address_space ──→ 页缓存(data.txt)
struct file C ──┘

struct file B ──→ dentry(log.txt) ──→ inode(log.txt) ──→ address_space ──→ 页缓存(log.txt)

说明:

  • 用户态的流 struct FILE 中也保存着 fd,fd 联系起用户态和内核态。

  • 进程私有 fd[],进程内线程共享之。
  • struct file 可以理解为内核中文件的管理结构,内部有文件偏移量、访问模式等属性,每次 open() 都会新建之。
  • 文件数据就一份(页缓存),所有线程共享之。

了解fork()与execve()

本来是不写这部分内容的,但AI给我提了一嘴,干脆也了解了一下,内容可能有误。

fork()

fork() 用于创建一个子进程,它会复制调用进程的整个用户空间,新建或复制部分内核资源。

fork() 流程一图流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
用户态 fork()
   │
   ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 硬件行为(执行 syscall 指令时自动完成)                                       │
│   1. 用户态 RIP → RCX         RCX 现在保存 syscall 下一条指令地址             │
│   2. 用户态 RFLAGS → R11                                                      │
│   3. RIP ← IA32_LSTAR(内核入口地址 entry_SYSCALL_64)                        │
│   4. CS ← IA32_STAR[47:32](内核代码段)                                      │
│      SS ← IA32_STAR[47:32]+8(内核数据段)                                    │
│      RFLAGS ← RFLAGS & ~IA32_FMASK                                            │
│   5. 不压栈,不切换栈                                                         │
│   【注】IA32_LSTAR / IA32_STAR / IA32_FMASK 是 CPU 内部 MSR,                 │
│         由内核在启动时用 wrmsr 写入,硬件从 MSR 读取,不访问内存或软件常量     │
│   【注】此时 RSP 仍指向用户栈                                                 │
│   【注】TSS 不参与 syscall 的 CS/SS 切换,仅 int 0x80/中断 使用 TSS 获取 RSP0 │
└─────────────────────────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 内核入口软件行为(entry_SYSCALL_64)                                          │
│   1. swapgs                                                                  │
│   2. 用户 RSP 保存到 TSS_sp2(临时保存)                                       │
│   3. 切换到内核 CR3                                                          │
│   4. RSP ← cpu_current_top_of_stack(切换到内核栈)                           │
│   5. 软件压入内核栈,构造 pt_regs:                                           │
│                                                                             │
│      压入内容          最终成为 pt_regs 字段   来源                           │
│      ─────────────────────────────────────────────────────────────────────   │
│      用户 SS           ss                  常量 __USER_DS=0x2b(编译时常量)  │
│      用户 RSP          rsp                 来自 TSS_sp2                       │
│      用户 RFLAGS       rflags              来自 R11                           │
│      用户 CS           cs                  常量 __USER_CS=0x33(编译时常量)  │
│      用户 RIP          rip                 来自 RCX(syscall 下一条指令)      │
│      orig_rax          orig_rax            系统调用号                         │
│      rdi, rsi, rdx     rdi, rsi, rdx       通用寄存器                         │
│      rcx, rax, r8~r11  rcx, rax, r8~r11    通用寄存器                         │
│      rbx, rbp, r12~r15 rbx, rbp, r12~r15   通用寄存器                         │
│                                                                             │
│   【注】pt_regs 从低地址到高地址:                                             │
│        r15, r14, r13, r12, rbp, rbx, r11, r10, r9, r8,                       │
│        rax, rcx, rdx, rsi, rdi, orig_rax, rip, cs, rflags, rsp, ss           │
└─────────────────────────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ copy_process()                                                              │
│   ├─ 复制 mm_struct(COW),用户空间全部复制                                  │
│   ├─ 分配子进程内核栈                                                         │
│   ├─ 复制 pt_regs 到子进程内核栈,设置 ax = 0                                 │
│   ├─ 构造 inactive_task_frame:                                               │
│   │     ret_addr = ret_from_fork  软件设置,写入子进程内核栈                  │
│   │     bp = 0, bx = 0, r12~r15 = 0                                          │
│   └─ 子进程 task_struct->thread.sp ← 指向 inactive_task_frame                 │
└─────────────────────────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ wake_up_new_task():子进程加入就绪队列                                        │
│   【注】从此刻起,调度器就可能选中子进程                                       │
└─────────────────────────────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 分叉点:调度器可能先调度子进程,也可能先让父进程继续返回                      │
└─────────────────────────────────────────────────────────────────────────────┘
   │
   ├───────────────────────────────────┬───────────────────────────────────────┐
   ▼                                   ▼
【父进程路径】                        【子进程路径】
   │                                   │
   ▼                                   ▼
父进程返回路径                       调度器选中子进程
   │                                   │
   ▼                                   ▼
pt_regs->ax = 子进程 PID              __switch_to_asm(父→子)
   │                                   │
   ▼                                   │   ── 保存父进程内核态现场 ──
继续执行返回用户态收尾代码             │   ├─ call __switch_to_asm:
   │                                   │   │   硬件自动把返回地址压入父进程内核栈
   ▼                                   │   ├─ 软件 push rbp, rbx, r12~r15 到父进程内核栈
从父进程内核栈恢复 pt_regs            │   └─ 软件把 rsp 保存到父进程 task_struct->thread.sp
   │                                   │
   ▼                                   │   ── 恢复子进程内核态现场 ──
执行 iretq/sysretq                      │   ├─ 从子进程 task_struct->thread.sp 加载 rsp
   │                                   │   ├─ pop rbp, rbx, r12~r15(值全为 0)
   ▼                                   │   └─ ret:软件指令从子进程内核栈弹出
父进程返回用户态                       │        ret_addr = ret_from_fork 到 RIP
   │                                   │
   ▼                                   ▼
RIP = syscall 下一条指令              ret_from_fork
   │                                   │   ├─ schedule_tail()
   ▼                                   │   ├─ 检查信号 / resched
检查 rax(子进程 PID)                 │   ├─ 从子进程内核栈的 pt_regs 恢复用户态寄存器
   │                                   │   │   (pt_regs->rip = syscall 下一条指令)
   ▼                                   │   └─ iretq/sysretq
从 fork 包装函数返回                   │
   │                                   ▼
   ▼                               子进程返回用户态
给局部变量赋值:pid = 子进程 PID        │
   │                                   ▼
   ▼                               RIP = syscall 下一条指令
用户代码继续执行                       │
                                       ▼
                                   检查 rax(0)
                                       │
                                       ▼
                                   从 fork 包装函数返回
                                       │
                                       ▼
                                   给局部变量赋值:pid = 0
                                       │
                                       ▼
                                   用户代码继续执行


fork() 流程一图流(为防止文本截断错乱,留个图片版):

202610061452317.png


多线程父进程 fork() 后的地址空间与内核栈布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
多线程父进程 fork 后的地址空间与内核栈布局

┌─────────────────────────────────────────────────────────────────────────────┐
│ 父进程 (PID 1000)                                                            │
│                                                                             │
│ 用户空间 (mm_struct)                                                        │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 传统栈区(主线程用户栈)                                                  │ │
│ │   环境变量、argv、主线程栈帧                                              │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ mmap 动态映射区                                                          │ │
│ │   动态库映射                                                             │ │
│ │   文件映射                                                               │ │
│ │   线程1用户栈                                                            │ │
│ │   线程2用户栈  ← 调用 fork()                                             │ │
│ │   线程3用户栈                                                            │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ 堆                                                                       │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ 数据段 .data / .bss                                                      │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ 代码段 .text / .rodata                                                   │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      │ fork()
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 子进程 (PID 2000)                                                            │
│                                                                             │
│ 用户空间 (mm_struct) 复制自父进程                                            │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 传统栈区(复制自父进程主线程栈)                                          │ │
│ │   内容相同,但子进程主线程不使用                     【闲置】              │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ mmap 动态映射区(复制)                                                   │ │
│ │   动态库映射(复制)                                                      │ │
│ │   文件映射(复制)                                                        │ │
│ │   线程1用户栈(复制)                                【闲置】              │ │
│ │   线程2用户栈(复制)← 子进程主线程用户栈              【活跃】          │ │
│ │   线程3用户栈(复制)                                【闲置】              │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ 堆(复制)                                                               │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ 数据段(复制)                                                           │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ 代码段(复制)                                                           │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      │ 共享同一份内核虚拟地址空间
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 内核空间(所有进程共享,唯一)                                                │
│                                                                             │
│ 父进程的内核对象                                                             │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐                       │
│ │ task_struct   │ │ task_struct   │ │ task_struct   │                       │
│ │ 父进程主线程   │ │ 父进程线程2   │ │ 父进程线程3   │                       │
│ │ 内核栈        │ │ 内核栈        │ │ 内核栈        │                       │
│ └───────────────┘ └───────────────┘ └───────────────┘                       │
│                                                                             │
│ 子进程的内核对象                                                             │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ task_struct  子进程主线程                                                │ │
│ │ 内核栈(新分配,仅复制了父进程线程2的 pt_regs)                            │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘


fork() 后父子进程的 fd 与 struct file 关系图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
进程 1000(多线程)
┌─────────────────────────────────────────────────────────────────────────────┐
│ task_struct 线程1 ──┐                                                       │
│ task_struct 线程2 ──┼──→ files_struct(进程内共享)                          │
│ task_struct 线程3 ──┘         │                                             │
│                               ▼                                             │
│                            fdtable                                          │
│                            fd[]:                                            │
│                              fd[0] → struct file 0(stdin)                 │
│                              fd[1] → struct file 1(stdout)                │
│                              fd[2] → struct file 2(stderr)                │
│                              fd[3] → struct file A(线程2打开的文件A)       │
│                              fd[4] → struct file B(线程3打开的文件B)       │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      │ 线程2 fork()
                                      ▼
子进程 2000(单线程,主线程 = 线程2 的副本)
┌─────────────────────────────────────────────────────────────────────────────┐
│ task_struct 新主线程                                                        │
│   files ──→ 新的 files_struct(复制自父进程,独立)                          │
│                │                                                            │
│                ▼                                                            │
│             fdtable                                                         │
│             fd[]:                                                           │
│               fd[0] → struct file 0(与父进程共享,refcnt+1)               │
│               fd[1] → struct file 1(与父进程共享,refcnt+1)               │
│               fd[2] → struct file 2(与父进程共享,refcnt+1)               │
│               fd[3] → struct file A(与父进程共享,refcnt+1)               │
│               fd[4] → struct file B(与父进程共享,refcnt+1)               │
└─────────────────────────────────────────────────────────────────────────────┘

共享对象:
┌─────────────────────────────────────────────────────────────────────────────┐
│ struct file 0(stdin) ──→ inode(终端设备) ──→ 设备(字符设备,无页缓存)   │
│ struct file 1(stdout)──→ inode(终端设备) ──→ 设备(字符设备,无页缓存)   │
│ struct file 2(stderr)──→ inode(终端设备) ──→ 设备(字符设备,无页缓存)   │
│ struct file A ──→ inode A ──→ 页缓存 A                                      │
│ struct file B ──→ inode B ──→ 页缓存 B                                      │
└─────────────────────────────────────────────────────────────────────────────┘


COW(写时复制):fork() 后父子进程的页表指向同一份物理页,仅当任一方尝试写入某页时,内核才复制该页并让写入方指向新页。只复制被写的那一页(或几页),不是整个用户空间。未写的页继续共享,直到被写。execve() 后旧用户空间被整体丢弃,新程序重新建立独立地址空间,因此父子进程不再共享任何用户空间物理页。COW只作用于 fork() 后到 execve() 前这段时间。

execve()

execve 用新程序替换当前进程的用户空间,但保留内核中的进程身份和资源(PID、fd 表等),使同一进程改头换面继续运行。


execve() 干了啥:

  1. 丢弃旧用户空间:拆除旧程序代码段、数据段、堆、栈、mmap 区。
  2. 加载新程序:读取可执行文件,映射 .text/.rodata/.data/.bss。
  3. 重建栈:把 argv 和 envp 字符串复制到新栈高地址端,重建指针数组。
  4. 重置寄存器:设置入口点、栈指针等。
  5. 保留内核资源:PID、fd 表等。
  6. 关闭 FD_CLOEXEC 的 fd。
  7. 返回用户态,从新程序入口执行。


示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/wait.h>

int main() {
    // 父进程:标准流 fd 0、1、2 已打开(通常从 shell 继承)
    // 再打开一个文件,得到 fd 3
    int fd3 = open("parent_file.txt", O_RDONLY);   // fd3 = 3

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程继承了父进程的 fd 0、1、2、3
        // 此时 fd 3 已被占用,所以新打开的文件会得到 fd 4
        int fd4 = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0666);
        // fd4 = 4,且设置了 O_CLOEXEC,execve 时会自动关闭

        // 重定向 stdout 到 out.txt:让 fd 1 也指向 out.txt
        dup2(fd4, STDOUT_FILENO);   // fd 1 现在指向 out.txt

        // 手动关闭从父进程继承但不需要的 fd 3
        close(3);

        // 准备 argv 和 envp
        extern char **environ;
        char *argv[] = { "ls", "-l", NULL };

        // execve 成功:不返回,旧程序被新程序完全替换。
        // execve 失败:返回 -1,设置 errno,旧程序继续执行下面的代码。
        execve("/bin/ls", argv, environ);

        // 只有 execve 失败时才会执行到这里。
        // 这里用 127 作为失败标志(shell 惯例中 127 表示命令未找到,126 表示命令无法执行;
        // shell 是根据 errno 区分这两者的,本示例不区分具体原因,统一用 127)。
        // 父进程据此区分“execve 失败”与“新程序正常终止后的退出码”。
        // 注意:新程序应避免返回 127,否则会与 execve 失败混淆。
        perror("execve");
        _exit(127);
    }

    // 父进程
    // waitpid 接到的是子进程最终终止时的状态
    int status;
    waitpid(pid, &status, 0);

    // WIFEXITED(status):判断子进程是否“正常终止”(通过 exit/_exit 或 main 返回)。
    //                    若子进程被信号杀死(如 SIGSEGV、SIGKILL),则“异常终止”,WIFEXITED 返回假。
    // WEXITSTATUS(status):仅当 WIFEXITED 为真时有效,取出子进程正常终止时的退出码。
    //                      常见值如 0(正常退出)、1、2 等(异常退出),具体含义由程序自定义。
    if (WIFEXITED(status)) {
        int code = WEXITSTATUS(status);
        if (code == 127) {
            printf("execve 失败\n");
        } else if (code == 0) {
            printf("新程序正常终止,正常退出(退出码 0)\n");
        } else {
            printf("新程序正常终止,异常退出(退出码 %d)\n", code);
        }
    } else if (WIFSIGNALED(status)) {
        // WIFSIGNALED(status):判断子进程是否“异常终止”(被信号杀死)。
        // WTERMSIG(status):取出导致终止的信号编号。
        printf("子进程异常终止,信号 = %d\n", WTERMSIG(status));
    } else {
        // 在 waitpid(pid, &status, 0) 下,理论上不会进入这里。
        // 仅当使用了 WUNTRACED、WCONTINUED 等标志时,才可能进入此分支。
        printf("子进程状态未知\n");
    }

    close(fd3);   // 父进程关闭自己的 fd 3
    return 0;
}

孤儿进程与僵尸进程

父进程先退出时,子进程成为孤儿进程,由PID 1(systemd)收养并继续运行,终止时由systemd循环 wait 回收,不会产生僵尸。

子进程先退出而父进程未 wait 时,子进程成为僵尸进程,用户空间资源已释放,但内核仍保留 task_struct、退出状态和PID,等待父进程回收;若父进程一直不 wait 且还在不断创建新的子进程,僵尸会累积并可能耗尽PID。

父进程退出时,内核会将其全部子进程(含僵尸)改挂到PID 1(systemd)名下,由systemd兜底回收。

其它

1> 没有“内核进程”这个说法。进程=用户进程,私有自己的用户空间,共享内核空间。

2> 在48位有效位的情况下,规范地址要求64位虚拟地址(63~0)的63~48号位必须全部等于47号位,故非规范地址空洞范围是 [0x0000800000000000, 0xffff7fffffffffff]。

本文由作者按照 CC BY 4.0 进行授权

热门标签