A-程序员的自我修养:链接、装载与库之第 10 章:内存
《程序员的自我修养:链接、装载与库》第 10 章:内存 📖
推荐博主个人使用的中转站,新用户首充500及以下,充值多少送充值一半的额度。注册送免费的额度,可以免费试用。
博主测试了将近30多家,就这家各方面都挺不错的。
点我跳转进行注册
或者使用下面的链接
https://kakouai.com/register?aff=YNTYF6Q4S8PF
下面是测试绝不掺水。

[!note] 阅读范围
本笔记依据本地 EPUB 的第 10 章(10.1 程序的内存布局、10.2 栈与调用惯例、10.3 堆与内存管理、10.4 本章小结)整理。内容用自己的话解释,未编造页码。书中部分地址、寄存器、调用惯例和分配阈值来自 i386、旧版 Linux、Windows 或特定编译器示例,涉及现代工具链时要以实际环境核对。
[!tip] 一句话抓住本章
程序运行时并不是“直接在内存里随便放东西”:操作系统先给进程一张虚拟地址空间地图,函数调用主要借助栈,动态数据主要来自堆,而运行库负责把大块内存切成程序能使用的小块。
1 章节导航 🧭
进度:本章笔记已整理,学习进度由你勾选。
难度:⭐⭐⭐⭐ 预计阅读:50 分钟 预计理解与练习:100 分钟(均为估计)
- 看懂进程虚拟地址空间与常见区域
- 理解栈、栈帧和函数进入/退出
- 理解调用惯例与返回值传递
- 区分堆、虚拟内存和物理内存
- 了解 Linux/Windows 向运行库提供内存的方式
- 能说清空闲链表、位图、对象池的取舍
2 本章学习目标 🎯
学完后,你应该能用自己的话回答:
- 进程的虚拟地址空间是什么,用户空间和内核空间为什么要分开?
- 栈、堆、可执行文件映像、动态库映射区和保留区分别解决什么问题?
- 为什么函数调用需要返回地址、参数、局部变量和寄存器保存区?
- 栈帧中的栈指针和帧指针各自帮助定位什么?
- 调用方和被调用方必须约定哪些规则,规则不一致会发生什么?
- 小返回值和大结构体返回值为什么可能走不同的路径?
- 运行库为什么不在每次 malloc 和 free 时都直接调用操作系统?
- Linux 的 brk、sbrk、mmap 与 Windows 的 VirtualAlloc、HeapAlloc 大致处于哪一层?
- 空闲链表、位图和对象池如何管理堆块,各自的优势和代价是什么?
- 为什么“堆总是向高地址增长”“malloc 得到的物理内存连续”等说法不能当成普遍规律?
3 整章知识地图 🗺️
这张图从上到下读:先有进程能看到的地址空间,再看函数如何使用栈,最后看堆如何从操作系统获得并被运行库切分。调用惯例是连接“源代码函数调用”和“实际机器执行”的桥梁。
4 知识依赖与学习路径 🔗
- 前置: 本书第 6 章已经讨论可执行文件如何被装入内存,第 9 章讨论动态库如何映射到进程地址空间;本章把这些内容放到一张运行时地图中。
- 后续: 目标目录中已有 [[第11章 运行库]] 和 [[第12章 系统调用与API]];本章的堆、运行库和系统调用关系会在后两章继续展开。
- 学习顺序: 先建立“进程看到的是虚拟地址”这个总前提,再学栈帧和调用惯例,最后学堆分配算法。否则容易把栈地址、堆地址、物理内存和文件偏移混为一谈。
5 核心知识点 👩🏫
5.1 ⭐⭐⭐⭐⭐ 4.1 进程的内存布局:程序看到的是虚拟地址空间
一句话: 进程使用的是操作系统提供的虚拟地址空间;这个空间被划分为用户可用、内核保留、代码映像、栈、堆和各种映射区域。
大白话解释: 把进程想成住在一栋有门禁的楼里。程序拿到的是自己的“房间编号”(虚拟地址),不是直接拿着仓库里某块物理内存的真实位置。操作系统负责把房间编号映射到物理页,并决定哪些房间允许读、写或执行。
在 32 位系统的典型模型里,进程有 2 的 32 次方个虚拟地址,也就是 4GB 的地址范围。但这不表示程序一定能使用完整 4GB:一部分区域给内核,一部分被保留或映射给文件、动态库、栈和堆。现代 64 位系统的地址位数、布局和随机化策略不同,不能把 32 位地址图照搬过去。
常见区域:
| 区域 | 初学者理解 | 典型用途 | 边界 |
|---|---|---|---|
| 用户空间 | 普通应用能访问的地址范围 | 代码、数据、堆、栈和库 | 大小由系统和配置决定 |
| 内核空间 | 供操作系统内核使用的地址范围 | 内核代码、内核数据、设备管理 | 普通应用不能直接访问 |
| 可执行文件映像 | 可执行文件被装载后的内存表示 | 代码段、只读数据、已初始化数据等 | 具体段划分由文件格式和装载器决定 |
| 动态库映射区 | 共享库映射到进程的区域 | 复用 DLL 或 ELF 共享库 | 地址会受 ASLR、库数量和系统版本影响 |
| 栈 | 维护函数调用的动态区域 | 参数、返回地址、局部变量、保存寄存器 | 大小有限,布局受 ABI 影响 |
| 堆 | 运行库分配给程序的动态区域 | malloc、new 等得到的空间 | 不保证物理连续,也不保证只向一个方向增长 |
| 保留区 | 明确禁止或尚未映射的地址 | 防止空指针等错误悄悄写入 | 不是一段单独、固定大小的区域 |
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 小区门牌与真实土地 | 虚拟地址像门牌,物理页像真实土地 | 程序使用编号,系统负责映射 | 门牌映射可能变化,真实小区不是分页系统 |
| 办公楼分区 | 代码、栈、堆和库像不同功能区 | 理解不同区域有不同权限和用途 | 区域边界不是永远固定的墙 |
| 图书馆索引 | 程序拿索引号,管理员找到实际书架 | 理解虚拟地址到物理页的间接关系 | 进程地址不是图书编号 |
为什么会有保留区? 很多系统会让极小地址不可访问,例如空指针常用 0 表示。这样程序错误地解引用空指针时,更容易立刻失败,而不是静默修改某块有效数据。
[!warning] 地址示例是历史背景
原书中的 0x40000000、0xbfffffff 等地址用于说明旧版 Linux 进程布局,不是现代系统的固定承诺。ASLR、内核版本、链接方式、位数和加载的库都会改变地址。
图示说明:
怎么看: 这张图只表达“典型区域和相对关系”,不表达所有系统都采用完全相同的地址顺序。重点看:栈和堆是大小可变的区域;动态库与可执行文件也占用进程地址空间;内核空间不等于用户空间。
面试 / 表达题: 为什么同一个指针值在两个进程里可能指向不同内容?
答题骨架: 因为指针保存的是进程虚拟地址,操作系统为不同进程建立不同的页表映射;除非共享或特殊映射,否则同一数值地址不代表同一物理内存。
一句话总结: 进程使用的是有权限、有分区、可映射的虚拟地址空间,不是裸物理内存。
5.2 ⭐⭐⭐⭐ 4.2 段错误:非法访问是地址、权限或映射问题
一句话: 段错误或“地址不能 read/write”通常表示程序访问了不允许访问、没有映射或权限不匹配的地址。
常见来源:
- 指针被初始化为 NULL,之后没有改成有效地址就解引用。
- 栈上的指针没有初始化,里面是不可预测的值。
- 指针指向的区域存在,但权限不允许当前操作,例如向只读区域写入。
- 地址看起来存在,但对应虚拟页尚未映射到实际物理页。
安全的排查思路是:先看指针的来源和生命周期,再看指针是否越界,最后检查该地址的读写执行权限。不要把“段错误”简单等同于“内存不够”。
c
#include <stddef.h>
int read_value(const int *p)
{
if (p == NULL)
{
return 0;
}
return *p;
}
这个例子只展示“先检查指针再解引用”的基本习惯;它不能替代边界检查,也不能证明任意非空指针都有效。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 没有钥匙闯房间 | 指针有数值但没有访问权限 | 区分“地址存在”和“允许访问” | 操作系统权限不是门锁文本 |
| 地图上的未建成道路 | 虚拟地址没有映射到物理页 | 理解地址可能尚未提交 | 真实地图不会动态分页 |
| 把快递寄到随机门牌 | 未初始化指针可能指向任意地址 | 理解随机值的危险 | 编译器不一定能发现所有错误 |
一句话总结: 非空不等于有效;使用指针前要确认来源、生命周期、范围和权限。
5.3 ⭐⭐⭐⭐⭐ 4.3 栈:函数调用的工作台
一句话: 栈是遵守“后进先出”的动态内存区域;函数调用依靠它保存返回地址、参数、局部变量和需要恢复的寄存器。
先理解抽象栈:
- 入栈(push):把数据放到栈顶。
- 出栈(pop):取走栈顶数据。
- 后进先出:最后压入的数据最先取出。
- 在原书介绍的 i386 约定中,栈向低地址增长,栈顶由 esp 指向。
现代平台可能采用不同的寄存器命名、寄存器传参规则和栈对齐要求;这里先掌握“栈保存一次调用的上下文”这一稳定概念。
一个栈帧通常包含:
| 内容 | 作用 |
|---|---|
| 返回地址 | 函数执行完后回到调用方的哪条指令 |
| 参数 | 调用方传给被调用方的数据 |
| 非静态局部变量 | 函数内部临时使用的数据 |
| 编译器临时量 | 中间结果、对齐空间等 |
| 保存的寄存器 | 返回前恢复调用方仍需要的寄存器值 |
| 旧帧指针 | 让当前函数结束时恢复调用方的栈帧 |
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 叠书 | 最后放上的书先拿走 | 后进先出 | 计算机栈还保存地址和寄存器 |
| 临时工作台 | 进入函数时摆工具,离开时收走 | 局部变量和临时空间的生命周期 | 栈空间不是无限大的桌面 |
| 借用的记事本 | 函数把返回地址和旧状态记下来 | 理解调用上下文保存 | 实际布局由 ABI 和编译器决定 |
一句话总结: 函数不是“跳过去执行几行代码”这么简单,它还要在栈上建立和撤销一份调用记录。
5.4 ⭐⭐⭐⭐ 4.4 栈帧的进入与退出:从调用到返回发生了什么
一句话: 一次典型函数调用可拆成“准备参数 → 保存返回位置 → 建立栈帧 → 执行函数体 → 恢复现场 → 返回”。
原书的 i386 视角:
- 调用方按约定准备参数,可能压栈,也可能使用寄存器。
- call 指令保存返回地址并跳到函数入口。
- 函数入口保存旧 ebp。
- 把 ebp 设置为当前 esp,建立相对稳定的帧基准。
- 通过减少 esp 的值为局部变量和临时数据腾空间。
- 需要时保存被调用方必须恢复的寄存器。
- 函数体执行。
- 恢复保存的寄存器,把 esp 恢复到 ebp,弹出旧 ebp。
- ret 取出返回地址,跳回调用方;参数由调用方或被调用方按调用惯例清理。
典型的 i386 伪汇编:
asm
push ebp
mov ebp, esp
sub esp, local_size
; 函数体
mov esp, ebp
pop ebp
ret
这里的指令序列是帮助理解的历史模型,不是所有编译器、优化级别和 CPU 都会生成完全相同的代码。
esp 与 ebp 的区别:
- esp 始终跟着栈顶移动,函数执行过程中可能变化。
- ebp 在传统帧指针模型中指向栈帧的固定位置,用于通过偏移定位参数和局部变量。
- 使用 fomit-frame-pointer 一类选项可以省出一个寄存器,但会让调试器和栈回溯更困难;现代编译器也可能使用其他方式完成栈回溯。
怎么看: 沿着消息顺序看,不要把“调用函数”理解成单纯的跳转。栈在进入、执行、退出三个阶段都参与了状态保存。
一句话总结: 栈帧把“这次函数调用需要的东西”集中管理,函数返回时必须把现场恢复到调用前的状态。
5.5 ⭐⭐⭐ 4.5 调试填充值:帮助发现未初始化,但不是语言规则
一句话: 某些 Windows 调试构建会把新分配的栈空间填成 0xCC,把未初始化内容显示成特殊模式;这只是调试器或运行环境的辅助现象。
原书用 i386/VC 调试模式解释了“烫”字:连续的 0xCC 被当作字符时可能显示为特定汉字。它的学习价值是提醒你:调试填充值可以暴露“变量没有初始化”,但不能把 0xCC 当成 C 语言规定的默认值。
[!warning] 不要依赖填充值
发布构建、其他编译器、Linux 或现代优化设置都可能使用不同模式,甚至完全不填充。判断变量是否初始化,应看源代码和编译器诊断,而不是看内存里是否出现某个“熟悉的数字”。
5.6 ⭐⭐⭐⭐⭐ 4.6 调用惯例:调用方和被调用方必须说同一种“参数语言”
一句话: 调用惯例(Calling Convention)规定函数参数怎样传递、谁负责清理栈、函数名怎样修饰以及返回值怎样交接。
为什么需要它: 调用方和被调用方通常由不同的编译单元、库甚至语言编译。如果双方对参数顺序或传递位置理解不同,函数收到的值就会错位;如果名字修饰不同,链接阶段甚至找不到同一个函数。
一个调用惯例通常规定:
| 规则 | 要回答的问题 |
|---|---|
| 参数传递顺序 | 从左到右还是从右到左压栈?哪些参数放寄存器? |
| 栈维护方式 | 调用方清理参数,还是被调用方清理? |
| 寄存器责任 | 哪些寄存器调用前后必须保持不变? |
| 名字修饰 | 链接器如何区分不同调用惯例和函数签名? |
| 返回值通道 | 通过哪个寄存器、内存或隐藏参数返回? |
原书以 i386 下的 cdecl 为默认示例:参数通常从右到左压栈,调用方在返回后清理参数,函数名可能被修饰为带下划线的形式。stdcall、fastcall、naked call、thiscall 等规则不同;C++ 还需要处理重载、命名空间和成员函数。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 双方约定的语言 | 参数顺序和清理方式像语法 | 理解为什么双方必须一致 | ABI 还有寄存器、对齐和名字修饰 |
| 装箱清单 | 调用方按顺序装箱,被调用方按同样顺序拆箱 | 理解参数错位 | 寄存器传参不是真正的箱子 |
| 电话分机规则 | 名字修饰像分机号 | 理解链接器为何区分函数 | 现代符号名不一定带下划线 |
小实验的安全结论: 如果一个源文件按 fastcall 声明调用,而另一个源文件按 cdecl 实现,名字可能在链接阶段不匹配;即便通过动态库等方式绕过链接检查,参数也可能被错误解释。不要在生产程序里故意混用调用惯例。
一句话总结: ABI 是机器层面的接口合同,函数原型相同不代表调用惯例就一定相同。
5.7 ⭐⭐⭐⭐ 4.7 函数返回值:小值常走寄存器,大对象可能走隐藏地址
一句话: 返回值的传递方式由调用惯例决定;原书的 i386 示例中,小返回值常放在 eax,较大的整数/对象可能需要多个寄存器或一块临时内存。
典型路径:
- 不超过一个寄存器容量的小值:放入约定寄存器。
- 约 5–8 字节的对象:原书示例使用 eax 和 edx 联合返回。
- 更大的结构体:调用方先准备临时对象,把它的地址作为隐藏参数传给被调用方;被调用方写入临时对象,再返回该地址,调用方可能还要把临时对象复制到目标对象。
- C++ 对象还可能涉及拷贝构造、赋值和析构;返回值优化(RVO)可以减少某些复制,但具体效果取决于编译器和优化。
怎么看: 分支点不是“C 语言有没有返回值”,而是“当前 ABI 能不能直接放下这个值”。大对象的临时对象和复制过程可能带来性能开销。
[!warning] 可移植性边界
返回大对象的具体寄存器、隐藏参数和复制次数不是 C 标准规定的统一实现。不要依据一次 MSVC 或 GCC 反汇编,就断言所有平台都这样返回结构体。
一句话总结: 先看调用惯例和 ABI,再解释返回值放在哪里;源代码中的 return 不等于机器上只有一个简单寄存器动作。
5.8 ⭐⭐⭐⭐⭐ 4.8 堆:生命周期由程序主动管理的动态空间
一句话: 堆用于存放大小和生命周期在运行时才确定的数据;malloc 或 new 申请的空间,在程序主动释放前可以跨越函数调用继续存在。
为什么需要堆:
- 栈上的局部数据通常在函数返回时失效,无法承担跨函数、长生命周期的数据。
- 全局变量需要编译期确定,不能满足所有动态需求。
- 堆允许程序按运行时需求申请几个字节到很大的空间。
c
#include <stdlib.h>
int *make_value(void)
{
int *p = malloc(sizeof *p);
if (p != NULL)
{
*p = 42;
}
return p;
}
void use_value(void)
{
int *p = make_value();
if (p != NULL)
{
/* 使用 *p */
free(p);
p = NULL;
}
}
这段代码展示了“申请—检查—使用—释放”的生命周期;它不是本章分配器的完整实现。
运行库为什么要参与管理? 如果每次 malloc/free 都陷入内核,频繁的小分配会承受很大的系统调用开销。更常见的策略是:运行库先向操作系统批发较大的虚拟内存,再在用户态用分配算法切成小块出售给程序;库存不足时才再次向系统申请。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 仓库零售 | 运行库向系统批发,malloc 向程序零售 | 理解为什么不每次都找内核 | 运行库还有对齐、元数据和并发处理 |
| 租赁仓位 | 申请空间得到使用权,free 归还 | 理解生命周期由程序管理 | 归还后继续使用是错误,不是普通“空房” |
| 临时扩建的厂房 | 堆可以按需求扩大 | 理解动态容量 | 扩展受虚拟地址和系统资源限制 |
本章 Q&A 的关键结论:
- 同一块堆内存不能重复释放;堆实现可能检测并报错,但不要依赖检测。
- 堆不一定总向高地址增长;增长方向取决于操作系统和分配器实现。
- malloc 是否最终触发系统调用,取决于运行库手里的库存是否足够。
- 进程结束后,操作系统会回收进程的地址空间和相关资源,malloc 得到的空间不会跨进程继续存在。
- malloc 返回的虚拟地址块可以看作连续;对应的物理页不必连续。
一句话总结: 堆的核心不是“比栈大”,而是允许程序在运行时主动管理数据的生命周期。
5.9 ⭐⭐⭐⭐ 4.9 Linux 堆管理:brk/sbrk 与 mmap
一句话: 原书介绍的 Linux 运行库主要通过 brk/sbrk 或 mmap 向内核申请更大的虚拟地址空间,再把空间交给 malloc 算法切分。
brk 与 sbrk:
- brk 设置进程数据段结束地址;把结束地址向高地址移动,可以扩大可作为堆使用的区域。
- sbrk 用增量表示扩大或缩小多少空间,是对 brk 思路的包装。
- 这条路径适合管理传统数据段附近的堆,但具体行为受内核、地址空间和运行库实现影响。
mmap:
- mmap 可以申请一段虚拟地址空间,也可以把它映射到文件。
- 不关联文件时可得到匿名映射,匿名映射可以作为堆空间。
- 权限、映射类型、文件描述符和偏移等参数决定这段区域如何使用。
- 因为通常按页申请,过小的请求直接 mmap 会产生较大内部浪费。
阈值的边界: 原书以某个版本的 glibc 为例,描述小于约 128KB 的请求倾向于从现有堆中分配,大请求倾向于使用 mmap。这个数字不是 C 标准,也不是所有 glibc 版本的固定承诺。
原书还讨论 32 位 Linux 下 malloc 最大申请量与共享库地址、内核版本、ulimit、物理内存和交换空间之间的关系。这里应记住“最大值由多个资源共同限制”,不要背下某个 1.9GB、2.9GB 或固定地址作为通用结论。
一句话总结: brk/sbrk 和 mmap 是运行库向 Linux 批发虚拟空间的工具,malloc 的小块分配仍由运行库算法完成。
5.10 ⭐⭐⭐⭐ 4.10 Windows 堆管理:VirtualAlloc 与 Heap 管理器
一句话: Windows 将 VirtualAlloc 作为申请虚拟地址空间的底层接口,再由 HeapCreate、HeapAlloc、HeapFree、HeapDestroy 等接口管理更适合程序使用的堆。
分层理解:
| 层 | 作用 |
|---|---|
| VirtualAlloc | 向系统预留或提交虚拟地址空间,通常按页粒度处理 |
| HeapCreate | 创建一个堆 |
| HeapAlloc | 从指定堆中分配小块 |
| HeapFree | 释放堆块 |
| HeapDestroy | 销毁一个堆 |
| 运行库 malloc | 对底层堆接口的进一步包装,必要时创建额外堆 |
原书以 x86 Windows 为例说明:VirtualAlloc 的申请粒度受页大小约束,直接用它满足 4097 字节请求可能造成内部浪费;堆管理器通过批量申请和小块切分降低浪费。每个进程可能有默认堆和额外堆,堆空间不一定连续。
[!warning] 版本边界
原书提到的默认堆大小、DLL 地址和最大可申请空间属于书中 Windows 版本和 32 位地址空间背景。现代 64 位 Windows、提交策略、段堆和安全缓解机制都可能不同。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 批发市场与零售店 | VirtualAlloc 批发页,HeapAlloc 零售小块 | 理解接口分层 | 页面提交和商品批发不是同一机制 |
| 多个仓库 | 一个进程可以拥有多个堆 | 理解堆空间不必是一整块 | 选择哪个堆由 API 和运行库决定 |
| 货架对齐 | 页大小限制申请粒度 | 理解小请求的内部浪费 | 实际页大小和分配策略由系统决定 |
一句话总结: VirtualAlloc 负责更底层的虚拟空间,Heap 管理器负责把它组织成可分配、可释放的堆。
5.11 ⭐⭐⭐⭐ 4.11 堆分配算法:空闲链表、位图、对象池
一句话: 运行库需要在一大片空间中反复分配和释放不同大小的块;分配算法要在速度、碎片、额外元数据和稳定性之间取舍。
5.11.1 空闲链表
空闲链表把每个空闲块串成链表。每个空闲块的头部或尾部保存前后节点信息,分配时查找足够大的块并拆分,释放时再把块放回链表并尝试合并。
优点: 思路直观,适合展示分配和合并。
风险: 如果块大小元数据或链表指针被越界写破坏,整个堆可能失效;查找空闲块也可能变慢。
5.11.2 位图
位图先把整个堆划成大小相同的块,再用少量位记录每个块是空闲、分配区域头还是分配区域主体。例如每个块有三种状态,可以用两位表示。
优点: 元数据集中,访问位图时更容易利用缓存;单个块不需要携带完整链表信息。
代价: 请求大小通常要向上取整到整数个块,容易产生内部碎片;堆很大或块很小时,位图本身也会占空间。
5.11.3 对象池
如果应用反复申请几种固定大小的对象,可以把堆切成同样大小的小块,每次直接取一个空闲块。对象池不必搜索“足够大的任意块”,速度通常很快。
适用场景: 节点、消息、任务控制块等尺寸相对稳定的对象。
边界: 请求尺寸变化很大时,固定块会浪费空间。
| 算法 | 分配思路 | 优势 | 主要代价 |
|---|---|---|---|
| 空闲链表 | 在空闲块链表中查找并拆分 | 直观、块大小灵活 | 查找开销、元数据容易被越界破坏 |
| 位图 | 固定块 + 状态位 | 元数据集中、访问快 | 内部碎片、位图空间 |
| 对象池 | 固定尺寸直接取块 | 速度快、实现简单 | 尺寸不匹配时浪费 |
原书还以旧版 glibc 为例介绍不同大小请求可能采用不同策略,例如小对象偏向对象池式方法,中等对象使用适配算法,大对象直接 mmap。实际分配器会随版本和线程模型变化,不能把这些阈值当成通用规则。
怎么看: 先看“请求大小和对象特征”决定策略,再看策略如何记录空闲状态。没有一种算法在所有负载下都最好。
一句话总结: 堆分配器本质上是在用元数据管理空间,优化目标永远是速度、碎片、稳定性和额外开销的平衡。
6 本章完整核心过程 / 论证链 🔄
动画脑补:
- 镜头 1:操作系统先画出一整张虚拟地址空间地图,并给不同区域贴上权限和用途。
- 镜头 2:调用函数时,栈顶移动,返回地址、旧帧指针和局部空间依次进入栈帧。
- 镜头 3:malloc 请求到达运行库,运行库先从已有库存切块。
- 镜头 4:库存不足时,运行库向内核批发更多虚拟空间,再返回一块小空间给程序。
- 镜头 5:free 让块回到分配器管理结构,但不会自动让错误指针变得有效。
7 重点概念关系图 ✨
怎么看: 左侧的虚拟地址空间是程序可见的统一视图;页表把它映射到物理页。栈和堆都是虚拟地址空间中的使用方式,运行库分配器再向更底层的操作系统接口申请库存。这样就不会把 malloc 直接等同于一次系统调用,也不会把虚拟连续误认为物理连续。
8 易混淆概念与常见误区 ⚠️
| 错误理解 | 正确理解 | 为什么易错 | 如何判断 |
|---|---|---|---|
| 进程直接使用物理地址 | 程序通常使用虚拟地址,系统通过页表映射物理页 | 指针看起来像“内存真实地址” | 先问这是哪个进程的地址空间 |
| 用户空间和内核空间只是地址高低 | 它们还代表权限边界和不同的运行主体 | 只看地址图不看权限 | 关注访问权限和系统调用边界 |
| 栈就是局部变量 | 栈还保存返回地址、参数、寄存器和临时量 | 教材常只用局部变量举例 | 从栈帧组成看完整上下文 |
| 栈永远向低地址增长 | 原书以 i386 为例;方向是 ABI/实现细节 | 把历史实现当语言规则 | 查目标平台 ABI |
| 堆永远向高地址增长 | 堆的布局和增长方向取决于分配器和系统 | 旧 Unix 模型留下的印象 | 区分“分配器策略”和“地址图” |
| malloc 每次都进入内核 | 运行库通常先批发大块,再在用户态切分 | 看不到运行库内部库存 | 观察系统调用次数与分配大小 |
| malloc 返回的空间物理连续 | 虚拟地址块可连续,物理页可以不连续 | 把指针连续当成 RAM 连续 | 区分虚拟空间和物理页 |
| free 后指针自动失效为 NULL | free 只归还空间,不会修改调用者的指针变量 | 误把释放和清空混为一谈 | 需要手动把指针设为 NULL |
| 大结构体返回一定只复制一次 | ABI 可能使用隐藏参数和临时对象,具体不统一 | 源码只看到一个 return | 看目标平台反汇编,且不要据此写不可移植代码 |
| 调用惯例只影响参数顺序 | 还涉及寄存器、栈清理、名字修饰和返回值 | 只记住“从右到左” | 用完整调用合同检查 |
| 0xCC 或“烫”是未初始化变量的标准值 | 它只是特定调试环境的填充模式 | 把调试现象当语言语义 | 以源代码初始化和编译器诊断为准 |
| 重复 free 只是再次释放同一块 | 这是错误,可能破坏分配器元数据 | 以为“已经空了再 free 一次也没事” | 释放后立即放弃旧指针并避免再次释放 |
| 所有进程都能使用完整 4GB | 32 位地址空间还要扣除内核、映射、保留区和资源限制 | 把理论寻址能力当可用容量 | 查看进程位数和实际布局 |
9 题目与实践 🧪
9.1 自测:基础题(5 题)
- 什么是进程虚拟地址空间?
- 栈帧通常保存哪些信息?
- 调用惯例至少要规定哪三类规则?
- 堆为什么需要运行库分配器?
- 空闲链表、位图、对象池分别用什么方式记录可用空间?
9.2 自测:理解题(5 题)
- 为什么同一个虚拟地址在两个进程中可能对应不同的物理页?
- 为什么函数返回后,普通局部变量不能继续作为有效对象使用?
- 为什么调用方和被调用方必须使用一致的调用惯例?
- 为什么运行库批发大块内存可以减少系统调用开销?
- 为什么虚拟地址连续不等于物理内存连续?
9.3 自测:思考题(3 题)
- 一个程序频繁申请固定大小的消息对象,你会优先考虑哪种分配策略?为什么?
- 如果堆块的长度元数据被越界写覆盖,空闲链表和位图哪一种更容易维持部分可用性?请结合本章的优缺点讨论,不要求唯一答案。
- 为什么“最大 malloc 数值”不能脱离地址空间布局、内核版本和系统资源单独讨论?
9.4 自测:面试 / 表达题(5 题)
- 请用一分钟解释“虚拟地址、物理页、页表”三者的关系。
- 请描述一个 i386 风格函数从 call 到 ret 的栈变化,并说明哪些部分属于历史实现。
- cdecl 调用惯例通常规定哪些内容?如果调用方和被调用方不一致,会出现什么现象?
- 大结构体返回为什么可能需要隐藏参数和临时对象?
- malloc、运行库分配器和 brk/mmap 或 VirtualAlloc 之间是什么分层关系?
- 基础题答案: 虚拟地址空间是进程可见的地址和权限地图;栈帧包含返回地址、参数、局部变量、临时量和保存寄存器;调用惯例至少涉及参数传递、栈维护、名字修饰或返回值;运行库需要把大块库存切成不同大小的小块并减少系统调用;空闲链表用链表记录空闲块,位图用固定块状态位记录,对象池按固定对象尺寸取块。
- 理解题答案: 每个进程有自己的页表;栈帧随函数调用建立和销毁,普通局部对象的存储期通常随函数结束而结束;ABI 不一致会造成参数错位、栈破坏或链接失败;批发后用户态切分可减少频繁陷入内核;虚拟连续空间可由不连续物理页映射组成。
- 思考题评分点: 固定大小对象适合对象池;比较链表与位图时要同时提到元数据破坏、查找速度、内部碎片和缓存;最大申请量要结合虚拟地址空洞、库映射、栈、系统限制、物理内存和交换空间。
- 面试题评分点: 回答要先给结论,再说明映射或调用顺序,最后补平台边界;不能把 i386 的 esp/ebp、32 位地址和旧版 glibc 阈值说成所有系统的标准。
9.5 实践 1|只读观察进程内存地图
- 目标: 观察“一个进程由多个虚拟地址区域组成”,而不是一整块可随意读写的 RAM。
- 前提/环境: Linux 环境;命令只读取当前进程地图,不需要管理员权限。
- 预计时间: 15 分钟。
- 风险与提醒: 只执行读取命令,不修改 proc 文件,不向任意地址写数据。
步骤
- 在终端执行 cat /proc/self/maps。
- 观察输出中的可执行文件、共享库、栈、堆和权限标记。
- 记录至少三段区域的起始地址、结束地址和权限,例如只读、可执行或可写。
- 对照本章布局图,写出哪些区域是文件映射,哪些区域是匿名或动态区域。
预期观察: 不同区域拥有不同地址范围和权限;实际地址可能因系统版本、ASLR 和运行环境而变化。
复盘问题
- 为什么同一进程的代码区通常不能像堆一样随意写入?
- 为什么不能把一次实验中的绝对地址写成跨系统结论?
9.6 实践 2|观察函数调用产生的栈帧
- 目标: 把源代码函数调用和汇编中的进入/退出序列联系起来。
- 前提/环境: Linux、GCC 或 Clang;需要基本汇编阅读能力。
- 预计时间: 25 分钟。
- 风险与提醒: 只编译和查看汇编,不修改系统库,不运行未定义行为示例。
步骤
- 写一个包含 main、调用 helper、使用一个局部变量的最小 C 文件。
- 使用 gcc -O0 -fno-omit-frame-pointer -S 生成汇编;这些选项的目的分别是降低优化干扰、保留传统帧指针。
- 找到 helper 的函数入口和返回附近,标出保存帧指针、分配局部空间、恢复栈的指令。
- 再用 gcc -O2 -S 生成一次,比较优化后指令是否仍保持相同的表面形式。
预期观察: 未优化版本更容易看到传统栈帧;优化版本可能省略帧指针、内联函数或改变局部变量位置。
复盘问题
- 哪些内容是 C 语言语义,哪些只是当前 ABI 和编译器的实现选择?
- 为什么不能仅凭一段汇编推导所有平台的函数调用规则?
9.7 实践 3|安全地观察 malloc/free 生命周期
- 目标: 练习“申请—检查—使用—释放—放弃旧指针”的生命周期。
- 前提/环境: 支持 C99 的 C 编译器。
- 预计时间: 20 分钟。
- 风险与提醒: 只申请小块内存;不重复 free,不在 free 后解引用,不测试极大申请。
步骤
- 写一个程序,循环申请少量整数数组,检查 malloc 返回值。
- 给数组写入明确的初值,打印一个元素后调用 free。
- free 后把指针设为 NULL,并记录这一动作的目的。
- 把申请次数和块大小作为可控的小常量,不进行无限循环。
预期观察: 运行库可以重复复用已经释放的堆块;具体地址是否相同不是可移植保证。
复盘问题
- 为什么 free 不会自动把调用者的指针变量改成 NULL?
- 为什么“地址看起来没变”不能证明旧指针仍然有效?
9.8 实践 4|用表格模拟三种分配算法
- 目标: 不依赖操作系统,比较空闲链表、位图和对象池的空间取舍。
- 前提/环境: 纸笔或电子表格;不需要编译器。
- 预计时间: 25 分钟。
- 风险与提醒: 这是抽象模拟,不把模拟结果当成真实 malloc 性能数据。
步骤
- 设总空间为 1024 字节,依次请求 24、80、130、24 字节,再释放第二块。
- 用空闲链表记录块的起始位置、长度和前后链接。
- 用 32 字节为一个位图块,记录每个块是空闲、头还是主体。
- 用 24 字节对象池只处理 24 字节请求,记录不能直接复用的请求。
- 比较三种方案的内部碎片、查找步骤和元数据数量。
预期观察: 位图和对象池通常需要向固定粒度取整;空闲链表粒度灵活但需要遍历和维护块信息。
10 本章速查表 📌
| 概念 | 一句话解释 | 为什么重要 | 容易混淆什么 |
|---|---|---|---|
| 虚拟地址空间 | 进程可见的地址和权限地图 | 隔离进程并统一编程接口 | 物理内存 |
| 用户空间 | 应用程序通常运行和访问的区域 | 与内核权限边界相连 | 进程能用的全部地址 |
| 栈 | 保存函数调用上下文的动态区域 | 支撑函数、局部变量和返回 | 只有局部变量的区域 |
| 栈帧 | 一次函数调用的记录 | 定位参数、局部变量和返回地址 | 整个进程的栈 |
| esp / 栈指针 | 传统 i386 中指向栈顶的寄存器 | 反映栈顶变化 | 固定的帧基准 |
| ebp / 帧指针 | 传统 i386 中定位栈帧的寄存器 | 便于按偏移找数据 | 所有平台都保留的寄存器 |
| 调用惯例 | 调用双方共同遵守的 ABI 规则 | 防止参数错位和栈破坏 | 只有参数顺序 |
| 堆 | 程序运行时主动管理的动态空间 | 支持跨函数和动态大小数据 | 物理连续的大数组 |
| malloc | 向运行库申请一块堆空间 | 使用动态内存 | 每次都直接调用内核 |
| brk / sbrk | 调整 Linux 数据段边界的接口 | 传统堆批发路径 | C 标准函数 |
| mmap | 申请或映射虚拟地址空间 | 支持匿名大块空间和文件映射 | 只用于堆 |
| VirtualAlloc | Windows 虚拟空间接口 | 批发页粒度空间 | 细粒度堆分配器 |
| 空闲链表 | 用链表登记空闲块 | 灵活管理不同大小 | 没有元数据 |
| 位图 | 用状态位记录固定块 | 元数据集中、访问快 | 没有内部碎片 |
| 对象池 | 按固定对象尺寸快速取块 | 高频固定对象效率高 | 适合所有大小请求 |
| 虚拟连续 | 地址连续但物理页可分散 | 正确理解 malloc 返回空间 | 物理内存连续 |
| 保留区 | 受保护或未映射的地址区域 | 让错误访问更早失败 | 一块固定的独立内存 |
11 本章总结与下一步 🚀
今天真正要带走的结论:
- 程序运行在虚拟地址空间里。 栈、堆、代码映像、动态库和保留区是不同用途的区域,权限和地址布局由系统与装载器共同决定。
- 栈保存函数调用上下文。 返回地址、参数、局部变量、临时量和保存寄存器共同组成一次调用的栈帧。
- 调用惯例是机器接口合同。 参数顺序、寄存器、栈清理、名字修饰和返回值通道必须由双方一致理解。
- 堆让数据拥有运行时生命周期。 运行库先向操作系统批发较大空间,再用分配算法零售小块。
- Linux 和 Windows 的底层接口不同。 brk/sbrk、mmap、VirtualAlloc 和 Heap 管理器属于不同层次,不能与 malloc 混为一谈。
- 堆分配是取舍问题。 空闲链表灵活,位图集中,对象池快速;速度、碎片、稳定性和元数据开销需要一起考虑。
- 书中的地址与汇编是平台示例。 i386 的 esp/ebp、32 位布局、旧版 glibc 阈值和 Windows 默认大小都必须标记为实现背景。
下一步学习建议:
- 先复习 [[第6章 可执行文件的装载与进程]] 和 [[第9章 Windows 下的动态链接]],把装载、映射和本章的内存地图连起来。
- 再阅读 [[第11章 运行库]],理解 malloc、I/O、线程和程序启动如何依赖运行库。
- 阅读 [[第12章 系统调用与API]] 时重点核对 brk、mmap、文件映射和进程资源回收。
- 用实践 2 生成一份自己编译器的汇编,明确区分“语言语义”和“平台实现”。
[!tip] 30 秒复述挑战
程序先得到一张虚拟地址空间地图;函数调用把返回地址、参数和局部状态放进栈帧;动态数据从堆中申请,运行库先向操作系统批发大块空间,再切成小块给 malloc 使用。栈帧、调用惯例和堆分配器都依赖 ABI、操作系统和编译器,所以书中的 i386 地址和汇编不能当成所有平台的固定规则。
12 附录:本章问题索引
| 原书主题 | 笔记位置 |
|---|---|
| 程序地址空间、用户/内核空间、栈、堆、动态库映射和保留区 | 4.1 |
| 段错误、非法指针解引用、NULL 和未初始化指针 | 4.2 |
| 栈的后进先出、栈向下增长、esp、栈帧 | 4.3 |
| i386 函数进入/退出、ebp、esp 和省略帧指针 | 4.4 |
| VC 调试填充值 0xCC 与“烫”现象 | 4.5 |
| cdecl、stdcall、fastcall、naked call、thiscall | 4.6 |
| eax/edx、小返回值、大结构体隐藏参数和 RVO | 4.7 |
| 堆的用途、malloc/free、运行库批发零售模型 | 4.8 |
| Linux brk、sbrk、mmap 和 malloc 的实现边界 | 4.9 |
| Windows VirtualAlloc、HeapCreate、HeapAlloc、HeapFree、HeapDestroy | 4.10 |
| 空闲链表、位图、对象池和分配策略选择 | 4.11 |
| 本章 Q&A:重复 free、堆增长方向、进程结束回收、虚拟/物理连续性 | 4.8、4.9、4.10 |
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)