DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 20
STEP 01 / CODE / LOGIC

先说结论

程序里的指针通常是虚拟地址,页表把虚拟页映射到物理页框;栈和堆是运行时组织方式,malloc 先向用户态分配器申请,并不等于每次都直接向操作系统要一页内存。

请求:malloc(24)
分配器大小类别:示意为 32 字节
返回虚拟地址:0x1008
首次写入所在页尚未映射:触发页错误
操作系统建立映射后重试写入
free:把块归还分配器,不保证立刻归还物理页

分类:计算机基础 / 内存系统

先说结论

程序里的指针通常是虚拟地址,页表把虚拟页映射到物理页框;栈和堆是运行时组织方式,malloc 先向用户态分配器申请,并不等于每次都直接向操作系统要一页内存。

一个 24 字节分配经历什么

请求:malloc(24)
分配器大小类别:示意为 32 字节
返回虚拟地址:0x1008
首次写入所在页尚未映射:触发页错误
操作系统建立映射后重试写入
free:把块归还分配器,不保证立刻归还物理页

32 字节大小类别只是教学示意,具体取决于分配器。虚拟地址、分配块和物理页属于不同层级,不能从 free(pointer) 推导“对应物理内存已经立即消失”。

生活化模型:酒店房号与真实房间

想象每个旅行团都拿到一本自己的酒店手册。

手册上写着:

101 号房
102 号房
103 号房

但不同旅行团手册里的“101”,可以由前台安排到不同的真实房间。旅行团只使用自己的房号体系,前台维护:

手册房号 -> 真实房间

虚拟内存的直觉类似:

程序发出虚拟地址。
处理器与操作系统维护的映射把它转换为物理内存位置。
不同进程可以看到相似的虚拟地址,却映射到不同物理页。

房间通常不是一个字节一个字节安排,而是按一批固定大小的房间组管理。这接近“页”。

malloc 更像旅行团内部的物资管理员:

管理员先取得一批可用空间,
再把其中不同大小的小区域分给各个请求。

所以:

malloc 不一定每次都直接请求操作系统映射新页。
free 也不一定立刻把物理内存归还操作系统。

先区分四个概念

虚拟地址空间

一个进程可使用的地址集合及其映射状态。

地址区间可能处于:

未映射
已保留
已映射但尚未实际驻留
已映射且物理页驻留
被换出或由文件支持

具体状态和术语随操作系统而异。

物理内存

机器实际内存资源。程序通常不能把普通指针直接解释成物理地址。

虚拟内存映射和保护的基本粒度之一:

虚拟页号 + 页内偏移

常规页大小、巨大页种类和支持方式由硬件与操作系统确定,不是 C++ 标准规定。

分配块

malloc 返回给调用者的一段连续虚拟地址区间。

它可以:

小于一页
跨越多页
来自分配器已经管理的空闲块

分配块边界不等于虚拟页边界。

地址转换怎样逐步发生

处理器执行一次内存访问:

load [virtualAddress]

概念上可拆成:

第一步:拆分虚拟地址

virtualAddress =
    virtualPageNumber + pageOffset

第二步:查询地址转换缓存

处理器通常先查询 TLB:

TLB 命中 -> 得到物理页框与权限。
TLB 未命中 -> 进行页表遍历或触发体系结构规定的处理。

TLB 是地址转换缓存。它的层级、容量和替换策略属于处理器实现。

第三步:检查页表项

页表项通常描述:

虚拟页映射到哪个物理页框
是否存在
是否可读、可写、可执行
用户态是否可访问
其他体系结构状态

第四步:形成物理地址

概念关系:

physicalAddress =
    physicalFrameNumber + pageOffset

页内偏移在映射过程中保持不变。

第五步:访问缓存或内存

地址转换完成后,数据访问还会经过处理器缓存层次。TLB 缓存的是转换,CPU 数据缓存缓存的是数据,两者不是同一个东西。

页错误不是一种单一故障

当页表状态不能直接完成访问时,处理器把控制权交给操作系统。常见情形:

首次触碰已经保留但尚未落实的匿名页
写入写时复制页面
访问尚未驻留的文件映射页
访问已经换出的页
违反读写执行权限
访问未映射地址

前几类可能由操作系统建立映射后继续执行。

后两类可能导致:

进程收到错误通知
访问异常
进程终止

具体表现由操作系统和运行环境决定。

“发生 page fault”不必然表示程序崩溃;但非法访问也不能假设总能恢复。

进程地址空间常见区域

常见示意:

可执行代码与只读数据
静态存储期数据
动态链接映射
内存映射区域
堆相关区域
线程栈
内核保留或不可访问区域

这只是常见布局,不是 C++ 标准规定的固定地址顺序。地址随机化、加载器、平台和构建方式都会改变实际布局。

栈到底是什么

“栈”至少有两种语境。

语言与调用语境

函数调用需要保存:

返回位置
部分寄存器状态
局部自动对象
参数或临时数据
异常展开信息

编译器可能建立栈帧,也可能:

把局部值只放在寄存器
内联函数而不产生独立帧
优化掉对象
使用平台 ABI 规定的其他位置

C++ 标准规定对象的存储期和语义,不要求每个自动变量都真实放在硬件栈内存里。

操作系统线程栈

每个线程通常拥有自己的栈地址区间:

保留范围
当前已提交或已映射范围
保护页
增长方向与上限

具体机制由操作系统、运行库和线程创建参数决定。

堆到底是什么

“堆”在这里指动态分配使用的内存区域,不是数据结构中的二叉堆。

C++ 动态存储期对象可能通过:

new T(...)
new T[count]

建立。

C 接口常见:

void* memory = std::malloc(byteCount);
std::free(memory);

newmalloc 不能简单等同:

new 表达式获得存储并构造对象。
delete 表达式析构对象并释放存储。
malloc 只提供满足要求的原始存储,不调用构造函数。
free 释放由兼容分配函数取得的块,不调用析构函数。

配对错误会产生未定义行为。

malloc 与操作系统的关系

典型分层:

应用调用 malloc
  ↓
用户态分配器查找可用块
  ↓
必要时向操作系统申请或映射更多虚拟地址空间
  ↓
操作系统在访问时或申请时建立页映射
  ↓
物理页按系统策略驻留

分配器可能维护:

大小类别
空闲链表
线程本地缓存
中心分配区
大块单独映射
块头元数据
对齐填充

具体实现可能使用不同操作系统接口。不能把某个分配器的阈值、arena 数量或系统调用路径当成 C/C++ 保证。

malloc 的逐步状态

一次小块申请可能是:

Requested
  调用者请求若干字节。

Classified
  分配器映射到内部大小类别。

Searching
  在线程缓存或共享空闲结构中查找。

Split
  必要时把更大的空闲块切分。

Allocated
  更新元数据并返回对齐地址。

InUse
  调用者拥有该块。

Freed
  调用者交还给分配器。

Cached 或 Coalesced
  分配器缓存,或与相邻空闲块合并。

ReleasedToOS
  满足实现策略时,部分页可能解除映射或被标记可回收。

并非每次申请都经过系统调用,也并非每次 free 都到达 ReleasedToOS

最小 C++ malloc 示例

#include <cstdlib>
#include <limits>
#include <new>

struct Record
{
    int id;
    double value;
};

Record* AllocateRecords(std::size_t count)
{
    if (count > std::numeric_limits<std::size_t>::max()
                    / sizeof(Record))
    {
        // 乘法会溢出,明确报告分配失败。
        throw std::bad_array_new_length();
    }

    const std::size_t bytes = count * sizeof(Record);
    void* raw = std::malloc(bytes);

    if (raw == nullptr && bytes != 0)
    {
        // malloc 失败通过空指针报告;这里转换成显式异常。
        throw std::bad_alloc();
    }

    return static_cast<Record*>(raw);
}

void ReleaseRecords(Record* records)
{
    std::free(records);
}

这个示例只适用于不需要显式构造和析构过程的简单使用方式。一般 C++ 对象应优先使用:

标准容器
智能指针
分配器感知容器
正确配对的 new/delete

如果在 malloc 返回的存储中用 placement new 构造对象,释放前必须按对象生命周期规则显式析构。

new 失败与 malloc 失败

常见标准接口:

malloc 失败返回空指针。
通常的 throwing new 失败抛出 std::bad_alloc。
nothrow new 失败返回空指针。

具体申请是否因为物理内存不足、地址空间耗尽、系统限制或分配器元数据问题失败,属于实现与操作系统诊断。

某些系统存在内存过量承诺:

申请虚拟地址空间时看似成功,
以后真正触碰页面时才无法满足。

因此“malloc 返回非空”不等于所有页面已经有物理内存保障。

free 之后发生什么

free(pointer) 表示:

调用者不再拥有该分配块。

之后该地址不能继续读写,也不能再次 free

分配器可以:

把块放进线程缓存
放回空闲链表
与相邻空闲块合并
让页面保持映射以服务未来申请
把合适页面归还操作系统

任务管理器中的进程内存不立刻下降,并不能单独证明 free 没有工作。

正常路径

程序请求一块存储
分配器从已有 arena 找到空闲块
更新元数据并返回地址
程序在对象生命周期内使用
程序调用匹配的释放接口
分配器回收该块供以后复用

整个过程可能完全不进入内核。

另一条正常路径:

分配器没有合适大块
请求新的虚拟映射
首次写入触发页面落实
释放后满足实现条件
映射被部分或全部归还

边界路径

零字节分配

标准允许 malloc(0)

返回空指针
或返回一个不可解引用但可传给 free 的唯一指针

调用方不能把其结果当成至少一个可写字节。

对齐

malloc 返回满足标准所保证的基本对齐要求的存储。

过度对齐类型可能需要:

对齐版本的 new
aligned_alloc
平台或库提供的对齐接口

精确规则取决于所用语言版本和接口。

大量小块与碎片

即使空闲字节总量足够,也可能:

没有足够大的连续虚拟块
空闲块分散在不同大小类别
内部对齐与元数据浪费空间

区分:

外部碎片:空闲空间分散。
内部碎片:分配块大于请求大小。

栈增长

深递归或大型自动对象可能接近线程栈上限。

是否自动扩大、保护页怎样移动、最终错误怎样报告,由平台决定。C++ 不提供可移植的栈剩余空间查询。

失败路径

空指针未检查

void* raw = std::malloc(bytes);
Write(raw); // raw 可能为空。

会把分配失败变成后续非法访问,丢失原始失败位置。

整数溢出

malloc(count * elementSize)

如果乘法先溢出,实际分配块比预期小,随后写入越界。必须在乘法前验证。

释放后使用

free(pointer)
继续读取 pointer

这是 use-after-free,属于未定义行为。块可能:

暂时看起来还在
已经被其他对象复用
分配器检测到损坏
触发访问异常

“偶尔还能读到旧值”不是合法保证。

重复释放

同一块交还两次可能破坏分配器元数据,属于未定义行为。基础分配器不能靠返回成功掩盖它。

配对错误

malloc 配 delete
new 配 free
new[] 配 delete

都是错误组合。必须使用与取得存储相匹配的释放机制。

栈溢出

当栈越过可用映射或保护边界,结果可能是访问异常或进程终止。此时不能假设普通 C++ 异常处理一定有足够栈空间恢复。

页共享与写时复制

进程创建或文件映射时,多个虚拟页可能暂时引用同一物理页。

写时复制的概念流程:

父子映射最初共享只读物理页
某一方尝试写入
触发页错误
操作系统复制页面
写入方映射到新物理页
另一方仍看到旧页

是否采用、适用于哪些映射,由操作系统接口和策略决定。

代价

虚拟内存提供隔离和灵活映射,但会带来:

页表内存
TLB 未命中
页表遍历
页错误处理
换入换出或文件读取
地址空间碎片

动态分配还会带来:

分配器元数据
锁或线程缓存协调
大小类别浪费
碎片
缓存局部性变化
系统调用和映射成本

栈分配通常只需调整栈指针,但不能由此推导所有自动对象都“零成本”;构造、析构、页面首次触碰和缓存行为仍然存在。

进阶:标准、操作系统与实现选择

C 与 C++ 标准可验证的部分

对象存储期和生命周期
malloc/free 接口契约
new/delete 行为与失败形式
越界、释放后使用和错误配对的语言后果
对齐和对象构造规则

操作系统与硬件负责的部分

进程虚拟地址空间
页表与页权限
页错误处理
物理页管理
文件映射、换页和写时复制
线程栈映射

具体实现必须确定的部分

页大小与页表层级
地址空间布局
malloc 的 arena、大小类别和缓存
何时向 OS 申请或归还页面
内存过量承诺策略
栈默认大小与增长机制
大块分配使用的系统接口

最后总结

指针通常保存进程虚拟地址。
页表把虚拟页映射到物理页框并附带访问权限。
TLB 缓存地址转换,数据缓存保存数据,两者职责不同。
栈与堆是常见运行时组织,不是 C++ 标准规定的固定地址布局。
malloc 通常先经过用户态分配器,不等于每次直接请求 OS。
free 结束调用者所有权,不保证立即归还物理内存。
分配成功、页面驻留和未来访问必然成功不是同一件事。