Hang Zhengyang

C++ 程序生命周期:从可执行文件到页故障(分层心智模型)

1. 先建立一张图(总模型)

  1. 谁在管什么?CPU 在用户态执行;OS 建立/维护地址空间映射、处理 fault 并回收资源。
  2. 程序到底在操作什么?程序看到的是虚拟地址空间,并通过 load/store 访问某个虚拟地址。
  3. 真正慢的点在哪里?常见慢点出现在首次触碰新页、写时复制 COW、以及需要陷入内核的异常/权限路径。

stack、heap、global/static、mmap 只是同一套虚拟地址空间里的不同区域;它们的“布局差异”决定了“兑现到物理页时走哪条路径”。

2. 时间线总览:从磁盘映像到退出(严格按阶段走,不深入机制)

2.1 可执行文件“文件内容”长什么样

磁盘上的可执行文件不是一块内存,而是一组段/元信息(以 ELF 为例):

  • text:机器指令,只读可执行(通常 RX)
  • rodata:只读常量/字符串(通常 R)
  • data:已初始化全局/静态变量(通常 RW)
  • bss:未初始化全局/静态变量(逻辑上为 0,通常由内核按需零填充)
  • 重定位/符号/动态链接信息(例如 PIE/动态库解析、TLS 描述等)

这一步主要在“描述映射规则”:虚拟地址范围与权限怎么对应到文件/零页;物理页是否立即到位取决于 OS 的 demand paging/lazy allocation。

2.2 你执行程序时,内核第一性动作是什么

类 Unix 下常见路径可抽象成:

  • 父进程 exec(请求当前进程“换成”另一个程序映像)
  • 内核读取可执行文件头,创建新地址空间与页表根
  • 根据段头(如 PT_LOAD)建立虚拟区间的映射与权限(R/W/X)
  • 为用户栈准备初始布局(见下一节)
  • 记录入口点、ABI 所需启动参数,并为动态链接相关流程准备元信息

关键点:内核先做的是“地址空间描述 + 页表/映射关系”,不是把整份程序一次性复制到物理内存。

2.3 虚拟地址空间典型布局(逻辑区,不等于物理占用)

常见形态:

  • 低地址:代码/只读段/数据段
  • 中间大块:mmap 区域(共享库、文件映射、匿名映射)
  • 高地址:栈向下增长(通常伴随 guard page)
  • heap:通常在 data 段之后向上增长(实现上可能是 brk/sbrk 或 mmap 策略)

很多时候你看到的是“预留的虚拟范围”;物理页是否到位由你之后 touch 的访问模式决定。

2.4 ASLR 等随机化与共享映像

现代系统会通过 ASLR(地址空间随机化)打乱基址(PIE、共享库、栈、mmap 区等),从而降低攻击面;因此同一程序每次运行的虚拟地址通常不同。

同时,共享库映射往往出现:

  • 只读页在进程间共享(减少内存占用)
  • 私有写入在第一次修改时触发隔离(COW,见后文)

2.5 CPU 什么时候开始“真正参与”

内核把新进程准备好后,会设置关键 CPU 状态并切换到用户态:

  • PC/RIP:指向用户态入口(通常是 runtime/CRT 入口,而不是直接跳 main)
  • SP/RSP:指向初始用户栈
  • 页表基址寄存器:指向该进程页表根
  • 启动参数:从寄存器或初始栈读取 argc/argv/envp,以及 auxv 等 ABI 启动信息

随后 CPU 按页表解释虚拟地址并取指执行。此后用户态访问非法/未映射页会走 fault 路径回到内核。

2.6 程序入口不是 main(runtime/CRT 做的事)

C++ 常见路径是:

  • runtime/CRT 入口建立运行时环境
  • 初始化全局/静态对象(含 TU 级对象、namespace-scope 对象、function-local static 的相关机制)
  • 处理动态链接初始化(重定位、GOT/PLT 绑定、构造共享库的 init array 等)
  • 设置 TLS 元数据,为后续线程/线程局部访问准备支撑
  • 最终调用 main

所以“全局对象在程序开始时构造”不是玄学:它发生在进入 main 前的 runtime/CRT 初始化链里。

2.7 初始 stack:ABI 规定的“启动栈”

进程启动时,内核给一个初始栈区域并摆放:

  • argc
  • argv 指针数组
  • envp(环境变量指针数组)
  • auxv(辅助向量:系统关键信息,如页大小等)

注意:栈仍是虚拟内存。通常不意味着“启动时就把整块栈都映射成物理页”。常见是少量页先就绪,后续向下触碰按需增长,并通过 guard page 限制越界。

3. 地址翻译专题:页、页表、TLB、page walk、page fault 的闭环

这一节回答“虚拟地址链条”上的关键问题:虚拟地址怎么拆;PTE 里除了映射还有哪些位;TLB 为何必要;TLB miss 与 page fault 为何不是一回事;以及不同 fault 为什么会有不同开销。

3.1 虚拟地址怎么拆:页号 + 页内偏移

CPU 把虚拟地址按页面大小拆成两部分:

  • 页号(VPN):用于在页表中定位 PTE
  • 页内偏移(offset):用于定位页框里的具体字节

因此“访问一个地址”本质是“先翻译拿到页框,再在页框里加 offset 去访问”。

3.2 页表项(PTE)里除了映射还有什么

PTE 往往不仅包含“页框/物理地址”的信息,还包含与权限和状态相关的位,例如:

  • 是否 present/valid(决定翻译能不能成功)
  • 权限位(R/W/X、用户/内核可访问等)
  • 状态位(如 accessed/dirty,用于统计与回收策略)
  • 语义位(例如写保护用于实现 COW:写入会触发 fault 路径)

当 PTE 不满足“有效 + 权限允许”,翻译就失败并进入异常路径。

3.3 为什么需要 TLB

页表在内存中;每次都走 page walk 会很慢。TLB 是“翻译结果的缓存”,把“VPN -> 页框 + 权限语义”缓存起来:

  • TLB hit:直接得到物理地址,继续访存
  • TLB miss:需要走一次 page walk

3.4 TLB miss 和 page fault 不是一回事

当 CPU 访问某个虚拟地址时:

  1. 先查 TLB(有结果则直接继续)
  2. TLB miss 时做 page walk,读取对应 PTE
  3. 若 PTE 有效且权限允许:把结果写回 TLB,继续访问
  4. 若 PTE 不 present、权限违规或被语义位“拦截”(如 COW 写保护):触发 page fault,陷入内核

所以:TLB miss 更多是“缓存不命中”;而 page fault 是“翻译/权限链条失败,需要 OS 介入修复”。

3.5 page fault 类型(理解“为什么会慢/快”)

常见类型与直觉:

  • demand-zero:需要零页/尚未映射,通常较快(例如 bss 或新匿名页的零填充)
  • demand-from-storage:需要从文件/磁盘读取页数据,等待更久(可能出现 major fault)
  • protection fault:权限不允许(例如写只读段)
  • COW fault:共享只读页的私有写触发复制(写入路径更重)

minor vs major 的直觉是:是否只做就地补齐/更新,还是需要更贵的存储/换入等待。

4. 内存区域专题:heap、stack、global/static、mmap(按“最易混淆对象”横向对比)

4.1 heap:分配器先给虚拟地址,物理页随后按需兑现

从逻辑到实现:

通常 new/malloc 先得到“虚拟地址空间中的一段可用区间”,并不必然意味着对应物理页已经就绪。

  • 你调用 new/malloc
  • 分配器先从自身 arena/free list 查找空闲块
  • 不够时向 OS 申请更多虚拟内存(brk 或 mmap)
  • 直到你触碰到对应页,OS 才把物理页映射进来

所以 heap 在系统里通常不是“一块连续物理内存”,而是多个 arena、多个 mmap 区、多个页运行(page run)的组合。

4.2 stack:向下增长 + guard page 抑制越界

栈由调用关系驱动:

  • 调用发生:ABI/编译器压返回地址、保存寄存器、为局部变量腾空间并调整栈指针
  • 栈指针继续向低地址移动
  • touch 到尚未映射的页 → page fault → 内核映射新页

“栈自动增长”不等于“无限”。通常存在 guard page 与栈大小/资源限制,触发越界后导致 stack overflow(进程崩溃)。

4.3 global/static:地址逻辑先存在,物理按需落实

data/bss 的布局在虚拟空间中固定。程序启动后相关区域会进入地址空间,但物理页是否立刻准备:

  • 取决于 OS 的实现策略(例如 bss 的零页与按需零填充)
  • 取决于你何时写入/触碰具体对象

因此“静态对象逻辑上在程序开始时存在”成立;但“其所有物理内存都已就绪”不一定成立,尤其当对象较大或 bss/零页路径被延迟兑现时。

4.4 mmap:共享 vs 私有写(COW)

通过 mmap 把文件或匿名区域映射后,你后续触碰:

  • 未映射 → fault → 内核建立映射
  • 只读共享 → 读可直接共享、写触发 COW(写时才分裂出私有页)

这解释了为什么同样的访问模式,在“共享库页面共享/是否发生写时复制”不同的情况下,性能曲线会明显不同。

补充:同样的 COW 思路也常用于 fork 创建子进程时的“共享后按需分裂”。

5. OS 再次介入:程序并不是一直在用户态独跑

用户态观感是“CPU 一直在跑”,但现实是:大多数指令在用户态执行,遇到边界事件快速陷入内核再返回继续。

常见再次陷入内核的场景包括:

  • page fault(新页映射/零填充/权限或 COW 隔离)
  • 系统调用(如 read/write/mmap/brk/futex 等)
  • 时钟中断导致调度
  • 缺页换入换出(更昂贵的 major fault/IO 等)
  • 信号处理(信号送达与用户态信号栈切换)
  • 权限异常与其他异常路径

6. 并发视角:线程与进程如何共享/隔离前面的结构

6.1 进程 vs 线程:内存模型边界

  • 进程:独立的虚拟地址空间(独立页表根)
  • 线程:共享同一个进程地址空间,但每个线程拥有自己的:
    • 栈
    • 寄存器上下文(包括程序计数器/通用寄存器等)
    • TLS(线程局部存储)

因此:

  • heap/global/mmap 区通常共享
  • stack/TLS 独立

调度线程时主要切换寄存器与当前栈;切换到另一个进程时通常需要切换页表根,TLB/地址翻译状态也可能受到影响(现代 CPU 有 PCID/ASID 等机制减少“全量失效”,但本质仍是地址翻译上下文切换)。

7. 退出与回收:runtime 收尾 + kernel 整包销毁

7.1 用户态收尾(runtime)

main 返回或调用 exit 后,通常包括:

  • 运行静态对象析构(按语言与编译单元规则)
  • 刷新/关闭 stdio buffer
  • 执行 atexit 回调
  • 释放用户态资源句柄(仍可能依赖 OS 最终回收)

7.2 内核终结(kernel)

随后陷入内核,由内核完成:

  • 关闭文件描述符
  • 销毁进程控制块
  • 释放页表与相关映射
  • 回收虚拟地址空间与物理页引用(整包回收,不需要你逐块 free 才能“回到系统”)
  • 将退出状态返回给父进程(父进程可能通过 wait 回收 zombie)

重要点:你不 free 的 heap 内存不会“泄漏到系统永远回不来”,因为进程结束时地址空间整体销毁;真正的内存泄漏是“长生命周期进程运行中不释放且持续增长”。

8. 常见误区:把错模型打掉

  • “malloc/new 立刻拿到 RAM” → “通常先给虚拟地址区间;物理页在首次 touch 时才兑现”
  • “栈在启动时就给满一整块” → “常见是少量映射 + guard page + 向下触碰按需增长”
  • “静态对象必然意味着所有物理页已就绪” → “地址先存在;物理落实可能走零页/延迟兑现”
  • “多线程意味着每个线程独立 heap” → “heap/global/mmap 在同一进程地址空间内共享,只有 stack/TLS 独立”