《程序员的自我修养:链接、装载与库》第 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 本章学习目标 🎯

学完后,你应该能用自己的话回答:

  1. 进程的虚拟地址空间是什么,用户空间和内核空间为什么要分开?
  2. 栈、堆、可执行文件映像、动态库映射区和保留区分别解决什么问题?
  3. 为什么函数调用需要返回地址、参数、局部变量和寄存器保存区?
  4. 栈帧中的栈指针和帧指针各自帮助定位什么?
  5. 调用方和被调用方必须约定哪些规则,规则不一致会发生什么?
  6. 小返回值和大结构体返回值为什么可能走不同的路径?
  7. 运行库为什么不在每次 malloc 和 free 时都直接调用操作系统?
  8. Linux 的 brk、sbrk、mmap 与 Windows 的 VirtualAlloc、HeapAlloc 大致处于哪一层?
  9. 空闲链表、位图和对象池如何管理堆块,各自的优势和代价是什么?
  10. 为什么“堆总是向高地址增长”“malloc 得到的物理内存连续”等说法不能当成普遍规律?

3 整章知识地图 🗺️

程序运行时的内存

地址空间

虚拟地址

用户空间

内核空间

可执行文件映像

动态库映射区

保留区

函数调用

栈帧

返回地址

参数

局部变量

保存寄存器

进入与退出

ABI约定

参数传递

栈清理

名字修饰

返回值

动态分配

malloc/free

运行库批发

Linux brk/mmap

Windows VirtualAlloc/Heap

分配算法

空闲链表

位图

对象池

这张图从上到下读:先有进程能看到的地址空间,再看函数如何使用栈,最后看堆如何从操作系统获得并被运行库切分。调用惯例是连接“源代码函数调用”和“实际机器执行”的桥梁。

4 知识依赖与学习路径 🔗

第6章:可执行文件装载

进程虚拟地址空间

第9章:动态链接

栈与函数调用

堆与动态分配

第11章:运行库

第12章:系统调用与API

  • 前置: 本书第 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”通常表示程序访问了不允许访问、没有映射或权限不匹配的地址。

常见来源:

  1. 指针被初始化为 NULL,之后没有改成有效地址就解引用。
  2. 栈上的指针没有初始化,里面是不可预测的值。
  3. 指针指向的区域存在,但权限不允许当前操作,例如向只读区域写入。
  4. 地址看起来存在,但对应虚拟页尚未映射到实际物理页。

安全的排查思路是:先看指针的来源和生命周期,再看指针是否越界,最后检查该地址的读写执行权限。不要把“段错误”简单等同于“内存不够”。

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 视角:

  1. 调用方按约定准备参数,可能压栈,也可能使用寄存器。
  2. call 指令保存返回地址并跳到函数入口。
  3. 函数入口保存旧 ebp。
  4. 把 ebp 设置为当前 esp,建立相对稳定的帧基准。
  5. 通过减少 esp 的值为局部变量和临时数据腾空间。
  6. 需要时保存被调用方必须恢复的寄存器。
  7. 函数体执行。
  8. 恢复保存的寄存器,把 esp 恢复到 ebp,弹出旧 ebp。
  9. 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 一类选项可以省出一个寄存器,但会让调试器和栈回溯更困难;现代编译器也可能使用其他方式完成栈回溯。
被调用函数 调用方 被调用函数 调用方 准备参数 call,保存返回地址 保存旧帧指针并分配局部空间 执行函数体 恢复寄存器和栈帧 ret,回到返回地址

怎么看: 沿着消息顺序看,不要把“调用函数”理解成单纯的跳转。栈在进入、执行、退出三个阶段都参与了状态保存。

一句话总结: 栈帧把“这次函数调用需要的东西”集中管理,函数返回时必须把现场恢复到调用前的状态。

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)可以减少某些复制,但具体效果取决于编译器和优化。

函数返回值

对象是否适合寄存器

按ABI放入一个或多个寄存器

调用方准备临时对象

隐藏参数传入对象地址

被调用方写入临时对象

调用方复制或直接使用

怎么看: 分支点不是“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 会产生较大内部浪费。

足够

不足

malloc 请求

运行库库存足够?

从已有堆块切一块

brk/sbrk 或 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。实际分配器会随版本和线程模型变化,不能把这些阈值当成通用规则。

用户请求 n 字节

选择分配策略

空闲链表:查找并拆分

位图:分配多个固定块

对象池:取一个同尺寸块

返回可用块

free 后回收到管理结构

怎么看: 先看“请求大小和对象特征”决定策略,再看策略如何记录空闲状态。没有一种算法在所有负载下都最好。

一句话总结: 堆分配器本质上是在用元数据管理空间,优化目标永远是速度、碎片、稳定性和额外开销的平衡。

6 本章完整核心过程 / 论证链 🔄

不够

程序启动

获得进程虚拟地址空间

代码/数据/库映射到地址空间

建立栈和线程调用环境

运行库准备堆的库存

函数调用建立栈帧

保存参数、返回地址、局部变量

函数返回并恢复现场

malloc 请求

运行库库存够吗

分配器切出堆块

brk/mmap 或 VirtualAlloc 批发

程序使用并最终 free

动画脑补:

  1. 镜头 1:操作系统先画出一整张虚拟地址空间地图,并给不同区域贴上权限和用途。
  2. 镜头 2:调用函数时,栈顶移动,返回地址、旧帧指针和局部空间依次进入栈帧。
  3. 镜头 3:malloc 请求到达运行库,运行库先从已有库存切块。
  4. 镜头 4:库存不足时,运行库向内核批发更多虚拟空间,再返回一块小空间给程序。
  5. 镜头 5:free 让块回到分配器管理结构,但不会自动让错误指针变得有效。

7 重点概念关系图 ✨

虚拟地址空间

页表映射

物理页

栈:调用上下文

堆:动态对象

运行库分配器

操作系统批发接口

Linux brk/mmap

Windows VirtualAlloc

怎么看: 左侧的虚拟地址空间是程序可见的统一视图;页表把它映射到物理页。栈和堆都是虚拟地址空间中的使用方式,运行库分配器再向更底层的操作系统接口申请库存。这样就不会把 malloc 直接等同于一次系统调用,也不会把虚拟连续误认为物理连续。

8 易混淆概念与常见误区 ⚠️

错误理解 正确理解 为什么易错 如何判断
进程直接使用物理地址 程序通常使用虚拟地址,系统通过页表映射物理页 指针看起来像“内存真实地址” 先问这是哪个进程的地址空间
用户空间和内核空间只是地址高低 它们还代表权限边界和不同的运行主体 只看地址图不看权限 关注访问权限和系统调用边界
栈就是局部变量 栈还保存返回地址、参数、寄存器和临时量 教材常只用局部变量举例 从栈帧组成看完整上下文
栈永远向低地址增长 原书以 i386 为例;方向是 ABI/实现细节 把历史实现当语言规则 查目标平台 ABI
堆永远向高地址增长 堆的布局和增长方向取决于分配器和系统 旧 Unix 模型留下的印象 区分“分配器策略”和“地址图”
malloc 每次都进入内核 运行库通常先批发大块,再在用户态切分 看不到运行库内部库存 观察系统调用次数与分配大小
malloc 返回的空间物理连续 虚拟地址块可连续,物理页可以不连续 把指针连续当成 RAM 连续 区分虚拟空间和物理页
free 后指针自动失效为 NULL free 只归还空间,不会修改调用者的指针变量 误把释放和清空混为一谈 需要手动把指针设为 NULL
大结构体返回一定只复制一次 ABI 可能使用隐藏参数和临时对象,具体不统一 源码只看到一个 return 看目标平台反汇编,且不要据此写不可移植代码
调用惯例只影响参数顺序 还涉及寄存器、栈清理、名字修饰和返回值 只记住“从右到左” 用完整调用合同检查
0xCC 或“烫”是未初始化变量的标准值 它只是特定调试环境的填充模式 把调试现象当语言语义 以源代码初始化和编译器诊断为准
重复 free 只是再次释放同一块 这是错误,可能破坏分配器元数据 以为“已经空了再 free 一次也没事” 释放后立即放弃旧指针并避免再次释放
所有进程都能使用完整 4GB 32 位地址空间还要扣除内核、映射、保留区和资源限制 把理论寻址能力当可用容量 查看进程位数和实际布局

9 题目与实践 🧪

9.1 自测:基础题(5 题)

  1. 什么是进程虚拟地址空间?
  2. 栈帧通常保存哪些信息?
  3. 调用惯例至少要规定哪三类规则?
  4. 堆为什么需要运行库分配器?
  5. 空闲链表、位图、对象池分别用什么方式记录可用空间?

9.2 自测:理解题(5 题)

  1. 为什么同一个虚拟地址在两个进程中可能对应不同的物理页?
  2. 为什么函数返回后,普通局部变量不能继续作为有效对象使用?
  3. 为什么调用方和被调用方必须使用一致的调用惯例?
  4. 为什么运行库批发大块内存可以减少系统调用开销?
  5. 为什么虚拟地址连续不等于物理内存连续?

9.3 自测:思考题(3 题)

  1. 一个程序频繁申请固定大小的消息对象,你会优先考虑哪种分配策略?为什么?
  2. 如果堆块的长度元数据被越界写覆盖,空闲链表和位图哪一种更容易维持部分可用性?请结合本章的优缺点讨论,不要求唯一答案。
  3. 为什么“最大 malloc 数值”不能脱离地址空间布局、内核版本和系统资源单独讨论?

9.4 自测:面试 / 表达题(5 题)

  1. 请用一分钟解释“虚拟地址、物理页、页表”三者的关系。
  2. 请描述一个 i386 风格函数从 call 到 ret 的栈变化,并说明哪些部分属于历史实现。
  3. cdecl 调用惯例通常规定哪些内容?如果调用方和被调用方不一致,会出现什么现象?
  4. 大结构体返回为什么可能需要隐藏参数和临时对象?
  5. malloc、运行库分配器和 brk/mmap 或 VirtualAlloc 之间是什么分层关系?
点击查看答案与评分点
  1. 基础题答案: 虚拟地址空间是进程可见的地址和权限地图;栈帧包含返回地址、参数、局部变量、临时量和保存寄存器;调用惯例至少涉及参数传递、栈维护、名字修饰或返回值;运行库需要把大块库存切成不同大小的小块并减少系统调用;空闲链表用链表记录空闲块,位图用固定块状态位记录,对象池按固定对象尺寸取块。
  2. 理解题答案: 每个进程有自己的页表;栈帧随函数调用建立和销毁,普通局部对象的存储期通常随函数结束而结束;ABI 不一致会造成参数错位、栈破坏或链接失败;批发后用户态切分可减少频繁陷入内核;虚拟连续空间可由不连续物理页映射组成。
  3. 思考题评分点: 固定大小对象适合对象池;比较链表与位图时要同时提到元数据破坏、查找速度、内部碎片和缓存;最大申请量要结合虚拟地址空洞、库映射、栈、系统限制、物理内存和交换空间。
  4. 面试题评分点: 回答要先给结论,再说明映射或调用顺序,最后补平台边界;不能把 i386 的 esp/ebp、32 位地址和旧版 glibc 阈值说成所有系统的标准。

9.5 实践 1|只读观察进程内存地图

  • 目标: 观察“一个进程由多个虚拟地址区域组成”,而不是一整块可随意读写的 RAM。
  • 前提/环境: Linux 环境;命令只读取当前进程地图,不需要管理员权限。
  • 预计时间: 15 分钟。
  • 风险与提醒: 只执行读取命令,不修改 proc 文件,不向任意地址写数据。

步骤

  1. 在终端执行 cat /proc/self/maps。
  2. 观察输出中的可执行文件、共享库、栈、堆和权限标记。
  3. 记录至少三段区域的起始地址、结束地址和权限,例如只读、可执行或可写。
  4. 对照本章布局图,写出哪些区域是文件映射,哪些区域是匿名或动态区域。

预期观察: 不同区域拥有不同地址范围和权限;实际地址可能因系统版本、ASLR 和运行环境而变化。

复盘问题

  1. 为什么同一进程的代码区通常不能像堆一样随意写入?
  2. 为什么不能把一次实验中的绝对地址写成跨系统结论?

9.6 实践 2|观察函数调用产生的栈帧

  • 目标: 把源代码函数调用和汇编中的进入/退出序列联系起来。
  • 前提/环境: Linux、GCC 或 Clang;需要基本汇编阅读能力。
  • 预计时间: 25 分钟。
  • 风险与提醒: 只编译和查看汇编,不修改系统库,不运行未定义行为示例。

步骤

  1. 写一个包含 main、调用 helper、使用一个局部变量的最小 C 文件。
  2. 使用 gcc -O0 -fno-omit-frame-pointer -S 生成汇编;这些选项的目的分别是降低优化干扰、保留传统帧指针。
  3. 找到 helper 的函数入口和返回附近,标出保存帧指针、分配局部空间、恢复栈的指令。
  4. 再用 gcc -O2 -S 生成一次,比较优化后指令是否仍保持相同的表面形式。

预期观察: 未优化版本更容易看到传统栈帧;优化版本可能省略帧指针、内联函数或改变局部变量位置。

复盘问题

  1. 哪些内容是 C 语言语义,哪些只是当前 ABI 和编译器的实现选择?
  2. 为什么不能仅凭一段汇编推导所有平台的函数调用规则?

9.7 实践 3|安全地观察 malloc/free 生命周期

  • 目标: 练习“申请—检查—使用—释放—放弃旧指针”的生命周期。
  • 前提/环境: 支持 C99 的 C 编译器。
  • 预计时间: 20 分钟。
  • 风险与提醒: 只申请小块内存;不重复 free,不在 free 后解引用,不测试极大申请。

步骤

  1. 写一个程序,循环申请少量整数数组,检查 malloc 返回值。
  2. 给数组写入明确的初值,打印一个元素后调用 free。
  3. free 后把指针设为 NULL,并记录这一动作的目的。
  4. 把申请次数和块大小作为可控的小常量,不进行无限循环。

预期观察: 运行库可以重复复用已经释放的堆块;具体地址是否相同不是可移植保证。

复盘问题

  1. 为什么 free 不会自动把调用者的指针变量改成 NULL?
  2. 为什么“地址看起来没变”不能证明旧指针仍然有效?

9.8 实践 4|用表格模拟三种分配算法

  • 目标: 不依赖操作系统,比较空闲链表、位图和对象池的空间取舍。
  • 前提/环境: 纸笔或电子表格;不需要编译器。
  • 预计时间: 25 分钟。
  • 风险与提醒: 这是抽象模拟,不把模拟结果当成真实 malloc 性能数据。

步骤

  1. 设总空间为 1024 字节,依次请求 24、80、130、24 字节,再释放第二块。
  2. 用空闲链表记录块的起始位置、长度和前后链接。
  3. 用 32 字节为一个位图块,记录每个块是空闲、头还是主体。
  4. 用 24 字节对象池只处理 24 字节请求,记录不能直接复用的请求。
  5. 比较三种方案的内部碎片、查找步骤和元数据数量。

预期观察: 位图和对象池通常需要向固定粒度取整;空闲链表粒度灵活但需要遍历和维护块信息。

10 本章速查表 📌

概念 一句话解释 为什么重要 容易混淆什么
虚拟地址空间 进程可见的地址和权限地图 隔离进程并统一编程接口 物理内存
用户空间 应用程序通常运行和访问的区域 与内核权限边界相连 进程能用的全部地址
保存函数调用上下文的动态区域 支撑函数、局部变量和返回 只有局部变量的区域
栈帧 一次函数调用的记录 定位参数、局部变量和返回地址 整个进程的栈
esp / 栈指针 传统 i386 中指向栈顶的寄存器 反映栈顶变化 固定的帧基准
ebp / 帧指针 传统 i386 中定位栈帧的寄存器 便于按偏移找数据 所有平台都保留的寄存器
调用惯例 调用双方共同遵守的 ABI 规则 防止参数错位和栈破坏 只有参数顺序
程序运行时主动管理的动态空间 支持跨函数和动态大小数据 物理连续的大数组
malloc 向运行库申请一块堆空间 使用动态内存 每次都直接调用内核
brk / sbrk 调整 Linux 数据段边界的接口 传统堆批发路径 C 标准函数
mmap 申请或映射虚拟地址空间 支持匿名大块空间和文件映射 只用于堆
VirtualAlloc Windows 虚拟空间接口 批发页粒度空间 细粒度堆分配器
空闲链表 用链表登记空闲块 灵活管理不同大小 没有元数据
位图 用状态位记录固定块 元数据集中、访问快 没有内部碎片
对象池 按固定对象尺寸快速取块 高频固定对象效率高 适合所有大小请求
虚拟连续 地址连续但物理页可分散 正确理解 malloc 返回空间 物理内存连续
保留区 受保护或未映射的地址区域 让错误访问更早失败 一块固定的独立内存

11 本章总结与下一步 🚀

今天真正要带走的结论:

  1. 程序运行在虚拟地址空间里。 栈、堆、代码映像、动态库和保留区是不同用途的区域,权限和地址布局由系统与装载器共同决定。
  2. 栈保存函数调用上下文。 返回地址、参数、局部变量、临时量和保存寄存器共同组成一次调用的栈帧。
  3. 调用惯例是机器接口合同。 参数顺序、寄存器、栈清理、名字修饰和返回值通道必须由双方一致理解。
  4. 堆让数据拥有运行时生命周期。 运行库先向操作系统批发较大空间,再用分配算法零售小块。
  5. Linux 和 Windows 的底层接口不同。 brk/sbrk、mmap、VirtualAlloc 和 Heap 管理器属于不同层次,不能与 malloc 混为一谈。
  6. 堆分配是取舍问题。 空闲链表灵活,位图集中,对象池快速;速度、碎片、稳定性和元数据开销需要一起考虑。
  7. 书中的地址与汇编是平台示例。 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
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐