把章节变成可单步观察的过程
分类:计算机基础 / 内存系统
先说结论
程序里的指针通常是虚拟地址,页表把虚拟页映射到物理页框;栈和堆是运行时组织方式,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);
new 与 malloc 不能简单等同:
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 结束调用者所有权,不保证立即归还物理内存。
分配成功、页面驻留和未来访问必然成功不是同一件事。