Appearance
程序加载与进程内存映像:代码段、数据段、堆、栈
概念
lang/20~23 走完了"从 C 到可执行文件"。现在手里有一个 a.out 文件。
但文件只是文件。它怎么变成"一个正在运行的进程"?
中间只隔着一个系统调用:
两次关键的身份转换:
| 转换 | 前 | 后 |
|---|---|---|
| ① 从"文件"到"内存中的地址区间" | ELF 的节/段 | 进程里一段段虚拟地址范围(各带权限) |
| ② 从"程序"到"进程" | 磁盘上的静态代码 | 有自己地址空间、栈、寄存器状态的运行实例 |
一句最容易记错的话要先纠正:
所以"16 MiB 的可执行文件"启动后可能只占 264 KiB 物理内存——这件事的原理叫"按需分页",见例 3。
本篇在主线上的位置:这是 L3【语言 · 汇编与 C】的收口篇。 它把"从 C 到可执行文件"这条链的最后一环补上——文件如何变成进程——然后正式把接力棒交给 L4【操作系统】。
原理
一、execve 做了什么:七个动作
操作系统加载一个 ELF 程序,大致是这七步(顺序很重要):
| # | 动作 | 依据 |
|---|---|---|
| ① | 读 ELF 头,验证格式 | 魔数、e_type、e_machine、位数与字节序(lang/21) |
| ② | 找 PT_INTERP 段 → 先加载动态加载器 ld.so | 段类型 PT_INTERP(静态链接的程序没有这一段) |
| ③ | 遍历程序头表,把每个 PT_LOAD 段 mmap 进来 | 段类型 PT_LOAD——每个段带权限(R/W/X) |
| ④ | 把 p_memsz > p_filesz 的那部分清零 | 这就是 .bss 的"清零"发生在哪里 |
| ⑤ | 丢弃旧的地址空间(若是 exec 换程序) | fork 出来的地址空间被整体替换 |
| ⑥ | 在栈顶摆好 argv / envp / auxv / argc | 内核亲手写的(不是 C 运行时) |
| ⑦ | 跳到入口点 e_entry | 动态链接时入口点是 ld.so 的,它做完重定位再跳真正的 _start |
注意第 ② 与第 ⑦ 步的配合:动态链接的程序 e_entry 指向的不是你的 main,而是 ld.so 的入口——这也解释了 lang/23 里"动态链接的程序启动稍慢"。
mmap 而不是 read:第 ③ 步用的是"建立映射",不是"读数据"——这是"按需分页"能成立的前提。
二、程序头表:段是"映射单元"
只讲一次,务必分清(lang/21 的延续):
| 节头表(section) | 程序头表(program header) | |
|---|---|---|
| 给谁看 | 链接器 | 内核加载器 |
| 条目大小 | e_shentsize(64 字节) | e_phentsize(56 字节,ELF64) |
在 .o 里有吗 | 有 | 没有(e_phoff = 0) |
Elf64_Phdr 的 7 个字段(56 字节):
| 字段 | 长度 | 含义 |
|---|---|---|
p_type | 4 | PT_LOAD(要映射的段)、PT_INTERP、PT_DYNAMIC、PT_GNU_STACK … |
p_flags | 4 | R=4 / W=2 / X=1 的位或(例如 R-X = 5,RW- = 6) |
p_offset | 8 | 在文件里的偏移 |
p_vaddr | 8 | 在内存里的虚拟地址 |
p_paddr | 8 | 物理地址(一般不用) |
p_filesz | 8 | 文件里占多少字节 |
p_memsz | 8 | 内存里占多少字节(≥ p_filesz,差额就是 .bss) |
p_align | 8 | 对齐要求(典型 4096) |
一条铁律:
为什么? ——因为内核映射时是"整页搬运":文件里的第 k 页,必须正好对上内存里的第 k 页。 不满足这个同余关系的段没法直接 mmap(只能退化成"读进来再写")。例 1 会把它验算一遍。
三、进程内存映像:一张图
把"程序要的东西"摆到虚拟地址空间里,样子大致是这样(x86-64 Linux):
高地址
0xffff800000000000 ┌───────────────────────────────┐
│ 内核空间(用户态不可访问) │ 128 TiB
0x0000800000000000 ├───────────────────────────────┤ ← 用户空间上界
│ │
│ 栈 stack(向下生长 ↓) │ 默认 8 MiB
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │ 顶部已摆好
│ argv / envp / auxv(内核写的)│ argc·argv…envp…auxv
0x00007ffffffff000 ├───────────────────────────────┤
│ │
│ mmap 区(共享库、大块 malloc、│ 共享库在这里
│ 文件映射) │ 向上/下生长
0x00007f0000000000 ├───────────────────────────────┤
│ ┄┄ 空洞(大段未映射)┄┄ │
├───────────────────────────────┤
│ 堆 heap(向上生长 ↑,brk) │ malloc 小块要的
0x0000555555560000 ├───────────────────────────────┤
│ .bss (未初始化,加载清零) │
│ .data (已初始化全局量) │
│ .rodata(字符串字面量、常量) │
│ .text (机器码)+ ELF 头 │
0x0000555555554000 └───────────────────────────────┘ ← PIE 基址(随机)
│ │
0x0000000000010000 ├───────────────────────────────┤
│ NULL 保护页(不映射) │ 0x0 起 64 KiB 不映射
0x0000000000000000 └───────────────────────────────┘ ← 空指针解引用立刻崩
低地址(PIE 程序的三块基址每次运行都不同——这就是 ASLR;非 PIE 的 ET_EXEC 基址固定为 0x400000。)
四个必须记住的"生长方向":
| 区域 | 生长方向 | 谁在管 |
|---|---|---|
| 栈 | 向下(地址变小) | call/push 自动;ulimit -s 限定(默认 8 MiB) |
| 堆 | 向上(地址变大) | brk/sbrk(lang/14 的 malloc 小块) |
| mmap 区 | 通常向下(从高地址往下) | 共享库、malloc 的大块(≥128 KiB) |
.text/.data/.bss | 固定 | 加载时一次性建好 |
权限分离(W^X):能写的地方不能执行,能执行的地方不能写。
| 区域 | 权限 |
|---|---|
.text + .rodata | R-X(读 + 执行) |
.data + .bss + 堆 + mmap 数据 | RW-(读 + 写) |
| 栈 | RW-(现代系统通常 NX,不可执行) |
为什么必须这样分? ——因为"可写 + 可执行"是缓冲区溢出攻击的最佳跳板:把代码写进缓冲区然后跳过去执行。 拆开之后,注入的字节只能当数据、不能当指令。
还有一处细节:0x0 附近不映射(那 64 KiB 保护页)——所以"空指针解引用"会立刻段错误(SIGSEGV),而不是悄悄读到垃圾数据。 这是"让 bug 尽早暴露"的设计。
四、栈顶是谁摆好的:argc / argv / envp / auxv
main(int argc, char **argv) 的两个参数不是凭空来的——内核在建立地址空间时,就把它们放在栈的最高处(从栈顶向下依次摆):
栈顶(高地址)
┌─────────────────────────────┐
│ 环境变量字符串区(envp 指向)│ "PATH=/usr/bin\0" "HOME=/root\0"
│ 命令行参数字符串区(argv 指向)│ "app\0" "one\0" "two\0"
├─────────────────────────────┤
│ 对齐填充 │ (保证指针数组按 16 字节对齐)
├─────────────────────────────┤
│ argv[0..argc-1] 、NULL │ 指针数组
│ envp[0..n-1] 、NULL │ 指针数组
│ auxv[](AT_*, value 成对) │ 内核给的程序信息(页大小、入口点…)
├─────────────────────────────┤
│ argc │
├─────────────────────────────┤
│ (C 运行时把这里当返回地址槽) │ _start 从这里接手
└─────────────────────────────┘
栈底(低地址) → 往下是 local 变量、被调用函数的栈帧(lang/13)两个由此推出的事实:
| 事实 | 原因 |
|---|---|
| 环境变量越多,栈顶位置越低 | 字符串从栈顶往下摆,占的空间直接挤低栈顶 |
argv[argc] 一定是 NULL | 指针数组末尾内核摆了 NULL(所以遍历可以不传 argc) |
五、按需分页与写时复制(与 L2/L4 的接口)
"加载"不搬数据,"访问"才给页——这三件事是理解进程内存占用的钥匙:
| 机制 | 一句话 |
|---|---|
| 按需分页(demand paging) | 映射建立时只登记"这段虚拟地址对应文件哪一段";真正访问到某一页时才发生缺页中断、把那一页读进来 |
| 写时复制(COW,copy-on-write) | fork 后父子共享全部物理页(只读);谁写哪一页,才复制哪一页 |
| 文件页缓存(page cache) | 可执行文件与共享库的代码页来自同一份缓存——多个进程自然共享 |
这解释了两个常见困惑:
| 困惑 | 解释 |
|---|---|
"top 里 VSZ 好几十 GB,我这机器装不下啊" | VSZ 是"虚拟大小",只是地址区间的账;真正的物理占用看 RSS |
| "为什么两个进程的同一个库不占两份内存" | 只读代码页共享同一份页缓存(lang/23 的动态链接收益) |
示例
例 1:读程序头表,验算"文件偏移与虚拟地址同余"
任务:给一份程序头表,算出每个 PT_LOAD 段的映射区间、页对齐后的虚拟占用,并验证 p_offset ≡ p_vaddr (mod 4096)。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
PAGE = 0x1000 # 4 KiB 页
# Elf64_Phdr 的 7 个字段(56 字节):p_type, p_flags, p_offset, p_vaddr, p_filesz, p_memsz, p_align
# p_flags: R=4, W=2, X=1
PHDRS = [
("PT_PHDR", 4, 0x000040, 0x400040, 0x1c0, 0x1c0),
("PT_INTERP", 4, 0x000200, 0x400200, 0x1c, 0x1c),
("PT_LOAD#1", 5, 0x000000, 0x400000, 0x1a30, 0x1a30), # R-X:.text + .rodata
("PT_LOAD#2", 6, 0x002dd0, 0x402dd0, 0x248, 0x390), # RW-:.data + .bss
]
def perm(flags):
return ("R" if flags & 4 else "-") + ("W" if flags & 2 else "-") + ("X" if flags & 1 else "-")
print("=== ① 程序头表 ===")
print(" " + pad("类型", 11) + pad("权限", 6) + pad("p_offset", 10) + pad("p_vaddr", 10)
+ pad("p_filesz", 10) + pad("p_memsz", 10) + "memsz-filesz")
for t, fl, off, va, fs, ms in PHDRS:
diff = ms - fs
print(" " + pad(t, 11) + pad(perm(fl), 6) + pad("0x%06x" % off, 10) + pad("0x%06x" % va, 10)
+ pad("0x%x" % fs, 10) + pad("0x%x" % ms, 10)
+ ("0x%x(= .bss)" % diff if diff else "0"))
print()
print("=== ② 同余校验:p_offset ≡ p_vaddr (mod 页大小) ===")
print(" " + pad("段", 11) + pad("offset % 4096", 15) + pad("vaddr % 4096", 15) + "结论")
for t, fl, off, va, fs, ms in PHDRS:
a, b = off % PAGE, va % PAGE
print(" " + pad(t, 11) + pad("0x%x" % a, 15) + pad("0x%x" % b, 15)
+ ("✔ 同余" if a == b else "✘ 不同余"))
print()
print("=== ③ 每个 PT_LOAD 段实际映射多少页 ===")
tf = tm = tv = 0
for t, fl, off, va, fs, ms in PHDRS:
if not t.startswith("PT_LOAD"):
continue
start = va & ~(PAGE - 1) # 向下对齐到页首
end = (va + ms + PAGE - 1) & ~(PAGE - 1) # 向上对齐到页末
pages = (end - start) // PAGE
print(" %s:vaddr 0x%06x..0x%06x + memsz 0x%x" % (t, va, va + ms - 1, ms))
print(" 页对齐后映射 0x%06x..0x%06x = %d 页 = %d 字节" % (start, end, pages, pages * PAGE))
print(" 文件侧只需 %d 字节(差 %d 字节是页内零头,不会读文件)"
% (fs, pages * PAGE - fs))
tf += fs
tm += ms
tv += pages * PAGE
print()
print(" 文件侧合计 = %d 字节" % tf)
print(" 内存侧合计(memsz) = %d 字节(其中 .bss 贡献 %d)" % (tm, tm - tf))
print(" 虚拟占用合计(按页) = %d 字节 = 文件的 %.2f 倍" % (tv, tv / tf))
print(" → 注意:%d 字节是“虚拟地址区间”的账,不是“开机就吃这么多物理内存”" % tv)预期输出:
=== ① 程序头表 ===
类型 权限 p_offset p_vaddr p_filesz p_memsz memsz-filesz
PT_PHDR R-- 0x000040 0x400040 0x1c0 0x1c0 0
PT_INTERP R-- 0x000200 0x400200 0x1c 0x1c 0
PT_LOAD#1 R-X 0x000000 0x400000 0x1a30 0x1a30 0
PT_LOAD#2 RW- 0x002dd0 0x402dd0 0x248 0x390 0x148(= .bss)
=== ② 同余校验:p_offset ≡ p_vaddr (mod 页大小) ===
段 offset % 4096 vaddr % 4096 结论
PT_PHDR 0x40 0x40 ✔ 同余
PT_INTERP 0x200 0x200 ✔ 同余
PT_LOAD#1 0x0 0x0 ✔ 同余
PT_LOAD#2 0xdd0 0xdd0 ✔ 同余
=== ③ 每个 PT_LOAD 段实际映射多少页 ===
PT_LOAD#1:vaddr 0x400000..0x401a2f + memsz 0x1a30
页对齐后映射 0x400000..0x402000 = 2 页 = 8192 字节
文件侧只需 6704 字节(差 1488 字节是页内零头,不会读文件)
PT_LOAD#2:vaddr 0x402dd0..0x40315f + memsz 0x390
页对齐后映射 0x402000..0x404000 = 2 页 = 8192 字节
文件侧只需 584 字节(差 7608 字节是页内零头,不会读文件)
文件侧合计 = 7288 字节
内存侧合计(memsz) = 7616 字节(其中 .bss 贡献 328)
虚拟占用合计(按页) = 16384 字节 = 文件的 2.25 倍
→ 注意:16384 字节是“虚拟地址区间”的账,不是“开机就吃这么多物理内存”三条结论:
| 结论 | 说明 |
|---|---|
| 同余条件是硬要求 | p_offset % 4096 == p_vaddr % 4096 才能直接 mmap——这是"整页搬运"的前提 |
p_memsz - p_filesz 就是 .bss | 0x390 - 0x248 = 0x148——这段在文件里不存在,加载后由内核清零 |
| 虚拟占用总大于文件 | 因为按页取整——但要记住它是"地址区间"的账,真实物理内存由"按需分页"决定 |
例 2:把进程内存映像算成一张地址表
任务:给出一份典型布局,算各区域大小与占比,并把栈顶那 200 来字节算清。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
def human(b):
for unit, div in (("TiB", 1024 ** 4), ("GiB", 1024 ** 3), ("MiB", 1024 ** 2), ("KiB", 1024)):
if b >= div:
return "%.4g %s" % (b / div, unit)
return "%d B" % b
USER_TOP = 0x0000800000000000 # 用户空间上界(48 位地址空间的一半)
USER_SPACE = USER_TOP # 0 到上界 = 128 TiB
print("=== ① 地址空间总账 ===")
print(" 用户空间 = 0x%012x 字节 = %s(48 位地址里“下半边”)"
% (USER_SPACE, human(USER_SPACE)))
print(" 内核空间同样 128 TiB 在最高处(用户态不可访问)")
print()
REGIONS = [
("内核空间", 128 * 1024 ** 4, "用户态不可访问"),
("用户栈 stack", 8 * 1024 ** 2, "默认 8 MiB(ulimit -s),向下生长"),
("mmap 区", 128 * 1024 ** 4 // 2, "共享库、大块 malloc、文件映射"),
("空洞(未映射)", None, "大段留白,让栈和 mmap 区有生长余地"),
("堆 heap", 0, "起始为 0,按需 brk/mmap 长大"),
(".bss", 0x390 - 0x248, "文件里 0 字节,加载时清零"),
(".data", 0x1e0, "已初始化全局量与静态量"),
(".rodata", 0x2a0, "字符串字面量、常量表"),
(".text", 0x1a30 - 0x2a0 - 0x1e0, "机器码 + ELF 头"),
]
print(" " + pad("区域", 22) + pad("大小", 14) + "说明")
for name, size, note in REGIONS:
s = human(size) if size is not None else "(很大)"
print(" " + pad(name, 22) + pad(s, 14) + note)
stack = 8 * 1024 ** 2
print()
print(" 栈只占用户空间的 %.3e = %.6f%%" % (stack / USER_SPACE, 100.0 * stack / USER_SPACE))
print(" → 所以“地址空间大”不等于“内存多”;VSZ 与 RSS 是两本账")
print()
print("=== ② 栈顶那一段:内核摆了什么 ===")
argv = ["app", "one", "two"]
envp = ["PATH=/usr/bin", "HOME=/root"]
AUX_ENTRIES = 5 # 假设 5 个 auxv 项(另有 AT_NULL 结束项)
str_bytes = sum(len(s) + 1 for s in argv + envp)
argv_bytes = (len(argv) + 1) * 8 # 指针数组 + NULL
envp_bytes = (len(envp) + 1) * 8 # 指针数组 + NULL
auxv_bytes = (AUX_ENTRIES + 1) * 16 # 每项 = 类型(8) + 值(8),末尾 AT_NULL
argc_bytes = 8 # argc 占一个 8 字节槽
print(" " + pad("内容", 20) + pad("算法", 34) + "字节")
ROWS = [
("参数字符串区", " + ".join(str(len(s) + 1) for s in argv + envp) + "(含结尾 \\0)", str_bytes),
("argv 指针数组", "(%d + 1) × 8(含 NULL)" % len(argv), argv_bytes),
("envp 指针数组", "(%d + 1) × 8(含 NULL)" % len(envp), envp_bytes),
("auxv", "(%d + 1) × 16(含 AT_NULL)" % AUX_ENTRIES, auxv_bytes),
("argc", "8", argc_bytes),
]
tot = 0
for a, b, c in ROWS:
tot += c
print(" " + pad(a, 20) + pad(b, 34) + str(c))
print(" " + pad("合计", 20) + pad("", 34) + str(tot))
aligned = (tot + 15) // 16 * 16
print(" 向上对齐到 16 字节 = %d 字节(填充 %d 字节)" % (aligned, aligned - tot))
print()
print(" argv[%d] 一定是 NULL → 所以遍历参数可以不依赖 argc" % len(argv))
print(" 环境变量越多 → 字符串区越大 → 栈顶被挤得更低")
print()
print("=== ③ 三个方向、三套权限 ===")
print(" " + pad("区域", 18) + pad("生长方向", 12) + pad("权限", 8) + "为什么")
GROW = [
("栈", "↓ 向下", "RW-", "递归/局部变量要空间;NX 防注入执行"),
("堆", "↑ 向上", "RW-", "malloc 小块;brk 抬高上界"),
("mmap 区", "↓ 向下", "RW-", "共享库/大块 malloc;含只读 R-X 的库代码"),
(".text", "不动", "R-X", "代码必须可执行但不可写(W^X)"),
(".rodata", "不动", "R--", "字面量只读;与 .text 常合并在同一段"),
]
for a, b, c, d in GROW:
print(" " + pad(a, 18) + pad(b, 12) + pad(c, 8) + d)预期输出:
=== ① 地址空间总账 ===
用户空间 = 0x800000000000 字节 = 128 TiB(48 位地址里“下半边”)
内核空间同样 128 TiB 在最高处(用户态不可访问)
区域 大小 说明
内核空间 128 TiB 用户态不可访问
用户栈 stack 8 MiB 默认 8 MiB(ulimit -s),向下生长
mmap 区 64 TiB 共享库、大块 malloc、文件映射
空洞(未映射) (很大) 大段留白,让栈和 mmap 区有生长余地
堆 heap 0 B 起始为 0,按需 brk/mmap 长大
.bss 328 B 文件里 0 字节,加载时清零
.data 480 B 已初始化全局量与静态量
.rodata 672 B 字符串字面量、常量表
.text 5.422 KiB 机器码 + ELF 头
栈只占用户空间的 5.960e-08 = 0.000006%
→ 所以“地址空间大”不等于“内存多”;VSZ 与 RSS 是两本账
=== ② 栈顶那一段:内核摆了什么 ===
内容 算法 字节
参数字符串区 4 + 4 + 4 + 14 + 11(含结尾 \0) 37
argv 指针数组 (3 + 1) × 8(含 NULL) 32
envp 指针数组 (2 + 1) × 8(含 NULL) 24
auxv (5 + 1) × 16(含 AT_NULL) 96
argc 8 8
合计 197
向上对齐到 16 字节 = 208 字节(填充 11 字节)
argv[3] 一定是 NULL → 所以遍历参数可以不依赖 argc
环境变量越多 → 字符串区越大 → 栈顶被挤得更低
=== ③ 三个方向、三套权限 ===
区域 生长方向 权限 为什么
栈 ↓ 向下 RW- 递归/局部变量要空间;NX 防注入执行
堆 ↑ 向上 RW- malloc 小块;brk 抬高上界
mmap 区 ↓ 向下 RW- 共享库/大块 malloc;含只读 R-X 的库代码
.text 不动 R-X 代码必须可执行但不可写(W^X)
.rodata 不动 R-- 字面量只读;与 .text 常合并在同一段三条结论:
| 结论 | 说明 |
|---|---|
| 栈、堆、mmap 区各有方向 | 栈向下、堆向上、mmap 区通常向下——它们从两端向中间长,中间留大片空洞 |
| 权限必须分离(W^X) | 能写的不能执行——这是"缓冲区溢出不再等于任意代码执行"的关键 |
| 栈顶那 200 来字节是内核摆的 | 字符串 37 + 指针 56 + auxv 96 + argc 8 = 197 → 对齐到 208(这就是 argc/argv 的来源) |
例 3:按需分页与写时复制的三段账
任务:算清"虚拟大、物理小"与"共享库省内存"到底省多少。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
def mb(b):
return "%.2f MiB" % (b / 1024 / 1024)
PAGE = 4096
EXE = 16 * 1024 * 1024 # 可执行文件 16 MiB
TOUCH_CODE = 200 * 1024 # 实际执行到的代码
TOUCH_DATA = 64 * 1024 # 实际用到的数据
print("=== ① 按需分页:一个 16 MiB 的程序真正吃多少物理内存 ===")
print(" 可执行文件 = %s" % mb(EXE))
print(" 实际执行到: 代码 %d KiB + 数据 %d KiB" % (TOUCH_CODE // 1024, TOUCH_DATA // 1024))
code_pages = (TOUCH_CODE + PAGE - 1) // PAGE
data_pages = (TOUCH_DATA + PAGE - 1) // PAGE
rss = (code_pages + data_pages) * PAGE
print(" 页对齐后:代码 %d 页、数据 %d 页 → 物理内存约 %s"
% (code_pages, data_pages, mb(rss)))
print(" 若不按需分页(开机整块读入)→ %s" % mb(EXE))
print(" 省下 %s,相当于只用了 %.1f%%" % (mb(EXE - rss), 100.0 * rss / EXE))
print()
print("=== ② 共享库的只读代码页:3 个进程各用一个 2 MiB 的 libc ===")
N = 3
LIBC_CODE = 2 * 1024 * 1024 # 只读代码页
LIBC_DATA = 128 * 1024 # 每个进程私有的可写数据
print(" 每个进程:代码 %s(可共享)+ 私有数据 %s" % (mb(LIBC_CODE), mb(LIBC_DATA)))
no_share = N * (LIBC_CODE + LIBC_DATA)
with_share = LIBC_CODE + N * LIBC_DATA
print(" 不共享(静态链接的情形):%d × (%s + %s) = %s"
% (N, mb(LIBC_CODE), mb(LIBC_DATA), mb(no_share)))
print(" 共享只读代码页 :%s + %d × %s = %s"
% (mb(LIBC_CODE), N, mb(LIBC_DATA), mb(with_share)))
print(" 节省 %s,是原来的 %.1f%%" % (mb(no_share - with_share),
100.0 * with_share / no_share))
print(" ★ 能共享的只有“只读”的页;可写数据必须每进程一份")
print()
print("=== ③ 写时复制(COW):fork 之后写一页,复制几页? ===")
DATA = 100 * 1024 * 1024 # 父子共享 100 MiB 数据
WRITE_PAGES = 1 # 子进程只写了 1 页
print(" fork 之后:父子共享 %s(全部标为只读)" % mb(DATA))
print(" 子进程写 %d 页 → 内核只复制 %d 页 = %d 字节"
% (WRITE_PAGES, WRITE_PAGES, WRITE_PAGES * PAGE))
print(" 若“fork 就全量复制”:要复制 %s" % mb(DATA))
print(" 复制量之比 = %d B : %s → 差 %d 倍"
% (WRITE_PAGES * PAGE, mb(DATA), DATA // (WRITE_PAGES * PAGE)))
print(" → fork + exec 模式的性能全靠这一条:fork 几乎不搬数据")预期输出:
=== ① 按需分页:一个 16 MiB 的程序真正吃多少物理内存 ===
可执行文件 = 16.00 MiB
实际执行到: 代码 200 KiB + 数据 64 KiB
页对齐后:代码 50 页、数据 16 页 → 物理内存约 0.26 MiB
若不按需分页(开机整块读入)→ 16.00 MiB
省下 15.74 MiB,相当于只用了 1.6%
=== ② 共享库的只读代码页:3 个进程各用一个 2 MiB 的 libc ===
每个进程:代码 2.00 MiB(可共享)+ 私有数据 0.12 MiB
不共享(静态链接的情形):3 × (2.00 MiB + 0.12 MiB) = 6.38 MiB
共享只读代码页 :2.00 MiB + 3 × 0.12 MiB = 2.38 MiB
节省 4.00 MiB,是原来的 37.3%
★ 能共享的只有“只读”的页;可写数据必须每进程一份
=== ③ 写时复制(COW):fork 之后写一页,复制几页? ===
fork 之后:父子共享 100.00 MiB(全部标为只读)
子进程写 1 页 → 内核只复制 1 页 = 4096 字节
若“fork 就全量复制”:要复制 100.00 MiB
复制量之比 = 4096 B : 100.00 MiB → 差 25600 倍
→ fork + exec 模式的性能全靠这一条:fork 几乎不搬数据三条结论:
| 结论 | 说明 |
|---|---|
| "加载"只是建映射,不搬数据 | 16 MiB 的程序只碰了 264 KiB → 物理内存就按页给这么多 |
| 能共享的是只读页 | 共享库省的是"代码页 × 进程数";可写数据省不了 |
COW 让 fork 变得便宜 | 父子共享全部页,写哪页才复制哪页——这是"一个写一页 vs 复制 100 MiB"的差别 |
例 4:亲手看一遍地址(C 代码 + 输出示意)
任务:把各类对象打印出来,看它们的地址落在哪个区域。
c
/* layout_demo.c —— 用途:打印各类对象地址,观察内存映像分区 */
#include <stdio.h>
#include <stdlib.h>
int g_init = 1; /* .data :已初始化全局变量 */
int g_zero; /* .bss :未初始化全局变量(加载时清零) */
const char *g_msg = "hello"; /* .rodata 里的字符串字面量;指针本身在 .data */
int main(int argc, char **argv) {
int local = 0; /* 栈 */
int *heap = malloc(sizeof(int)); /* 堆(小块走 brk) */
if (!heap) return 1;
printf("&main = %p ← .text (代码)\n", (void *)main);
printf("g_msg = %p ← .rodata (字面量 "hello" 的地址)\n", (void *)g_msg);
printf("&g_init = %p ← .data\n", (void *)&g_init);
printf("&g_zero = %p ← .bss\n", (void *)&g_zero);
printf("heap = %p ← 堆 (malloc 返回)\n", (void *)heap);
printf("&local = %p ← 栈 (局部变量)\n", (void *)&local);
printf("&argv = %p ← 栈顶附近(参数指针数组本身在栈上)\n", (void *)&argv);
printf("argc = %d\n", argc);
free(heap);
return 0;
}输出示意(真实地址每次运行都不同——这正是 ASLR):
&main = 0x5612a3c01149 ← .text (代码)
g_msg = 0x5612a3c02004 ← .rodata (字面量 "hello" 的地址)
&g_init = 0x5612a3e00010 ← .data
&g_zero = 0x5612a3e00018 ← .bss
heap = 0x5612a3f0a2a0 ← 堆 (malloc 返回)
&local = 0x7ffd1b2c3a4c ← 栈 (局部变量)
&argv = 0x7ffd1b2c3b58 ← 栈顶附近(参数指针数组本身在栈上)
argc = 1读这张输出的三条线索:
| 线索 | 说明 |
|---|---|
.text/.rodata/.data/.bss 地址相近(同一片 0x56…) | 它们是同一个 PT_LOAD 组,基址相同(PIE 随机基址) |
heap 比它们高一大截 | 堆在数据段之上,且按需向上长 |
&local/&argv 在 0x7ffd… | 栈在最上方的高地址区;&argv > &local(栈顶在更高处) |
对照
lang/14的malloc:heap这个小地址(0x5612a3f0a2a0)来自brk抬高的堆区——如果malloc要 1 MiB,glibc会改用mmap,地址就会跳到0x7f…那片区域。 一次malloc走哪条路,可以在地址上直接看出来。
考点
考点
1. execve 的七个动作(按序背)
① 读 ELF 头验证 → ② 加载 PT_INTERP(ld.so)→ ③ 按程序头表 mmap 每个 PT_LOAD → ④ 清零 p_memsz > p_filesz 的部分(.bss)→ ⑤ 丢弃旧地址空间 → ⑥ 栈顶摆 argc/argv/envp/auxv → ⑦ 跳到入口点。
- "加载"不是"读整个文件":是建立虚拟地址映射;
.bss的清零发生在加载时(因为文件里根本没存);- 动态链接程序的入口点是
ld.so的,它完成重定位后才跳_start。
2. 节 vs 段(重复出现,必须不混)
| 节(section) | 段(segment) | |
|---|---|---|
| 用户 | 链接器 | 内核加载器 |
| 表 | 节头表(64 字节/项) | 程序头表(56 字节/项) |
.o 里有 | 有 | 没有 |
3. p_filesz 与 p_memsz(高频)
p_memsz ≥ p_filesz;- 差额 =
.bss的大小(例 1:0x390 - 0x248 = 0x148); - 同余条件:
p_offset ≡ p_vaddr (mod p_align)——不满足就无法直接mmap。
4. 进程内存映像的分区与方向
| 区域 | 位置 | 方向 | 权限 |
|---|---|---|---|
| 栈 | 最高(用户侧) | ↓ 向下 | RW-(NX) |
| mmap 区 | 高地址 | 通常 ↓ | RW- / R-X(库代码) |
| 堆 | 数据段之上 | ↑ 向上 | RW- |
.bss / .data | 数据段 | 不动 | RW- |
.rodata / .text | 最低(代码段) | 不动 | R-- / R-X |
0x0 起约 64 KiB | 最低 | 不映射 | 保护页:空指针立刻崩 |
5. 为什么权限必须分离(W^X)
- 可写 + 可执行 = 缓冲区溢出可以直接跳去执行注入的字节;
- 拆分后,注入的字节永远只能当数据;
- 注意例外:JIT(如 JavaScript 引擎)需要"先写后执行",它靠
mprotect在两种权限之间切换。
6. 栈顶是谁摆的
- 内核在建立地址空间时把
argc/argv[]/envp[]/auxv[]摆在栈顶; argv[argc] == NULL(所以可以不用argc遍历);- 环境变量占的空间会把栈顶挤得更低;
main的返回值、_start的交接:C 运行时(crt0)先跑,准备完再调main。
7. 按需分页 / COW / VSZ vs RSS
mmap只登记映射,不搬数据;访问到某页才缺页调入;fork用 COW:写哪页复制哪页——这是fork+exec模式高效的根本;- VSZ(虚拟大小)≠ RSS(常驻物理内存):"
top里 VSZ 几十 GB"是正常的; - 只读代码页可在多进程间共享——这是动态链接省内存的来源。
8. ASLR 与 PIE
- PIE(
ET_DYN)可执行文件:基址随机(例 4 输出里0x5612…就是随机基址); - 共享库映射到
0x7f…附近,也随机; - 非 PIE(
ET_EXEC)基址固定(通常0x400000)——便于调试、但不利于安全; setarch -R/personality(ADDR_NO_RANDOMIZE)可关掉随机化(调试试用)。
9. 高频易错点
- "代码段/数据段/堆/栈"是"运行时的虚拟地址区间",不是"文件里的节"——面试里被问"
.bss在哪个段",答"可写数据段(与.data合并)"; .text与.rodata通常在同一个PT_LOAD(R-X),不是两个段;- 栈是"自动管理"的(
call/push/局部变量),堆是"手动管理"的(malloc/free,见lang/14); malloc小块走堆(brk)、大块走mmap(阈值通常 128 KiB)——所以在地址上能看出两条不同的来源;.bss不占文件空间,但在内存里占(且会按页对齐);- 静态链接的程序没有
PT_INTERP,所以它能"拷贝到任何机器上就跑"(只要 CPU 架构与内核兼容); execve成功不返回(它替换整个地址空间)——返回了一定是失败。
小结
execve= 把文件变成进程:读 ELF 头 → 加载ld.so→ 按程序头表mmap各PT_LOAD→ 清零.bss→ 换掉地址空间 → 栈顶摆参数 → 跳入口点。- 加载不是"读文件":是"建立虚拟地址映射";真正搬页发生在"访问时"(按需分页)。
p_memsz - p_filesz=.bss;p_offset ≡ p_vaddr (mod p_align)是映射可行的前提。- 进程内存映像是"自上而下"排的:栈(↓)→ mmap 区(通常 ↓)→ 空洞 → 堆(↑)→
.bss→.data→.rodata→.text,最低处留 64 KiB 保护页。 - 权限按"用途"分离(W^X):代码 R-X、数据 RW-、栈 NX——这是缓解缓冲区溢出的结构性防线。
argc/argv/envp/auxv是内核摆在栈顶的;argv[argc] == NULL;环境变量越多,栈顶越低。- 按需分页 + COW 是"虚拟大、物理小"的两条腿:VSZ 不是内存占用,RSS 才是。
- ASLR/PIE 让基址随机:所以打印出来的地址每次不同——
0x56…一带是可执行文件、0x7f…一带是共享库、0x7ffd…一带是栈。 - 一句话记住这张图:"代码在最下、栈在最上、堆在中间往上长、库在高处往下放"。
回到主线:L3【语言 · 汇编与 C】到此收口。 回看这一层回答的问题:L2 给出了"指令怎么执行",L3 给出的是"人怎么写出这些指令"——从 MIPS 汇编的寄存器与栈帧,到 C 的指针与堆,再到"四步流水线"和"文件如何变成进程"。
L3 最后这几篇留下了三个明显的"越界"问题: ① 虚拟地址怎么变成物理地址?谁维护页表?② 一次缺页中断里,操作系统到底做了什么?谁来选"换出哪一页"?③ 一个进程跑着,CPU 凭什么"切到另一个进程"还能回来?
fork/调度/文件描述符又归谁管?——这些都不再是"语言"能回答的了。它们属于 L4【操作系统】。(其中"虚拟地址 → 物理地址"的硬件机制,L2 的arch/13-virtual.md已经讲过;L4 会从"操作系统怎么用它"的角度再走一遍。)
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。