本节摘要:C++ 程序的进程内存按记账方式分为五本账:代码区、常量区、全局/静态区、栈区、堆区。区域决定记账规则——谁开户、谁销户、生命周期多长。分清区域是全册一切内存讨论的前提,也是定位坏账时首先要回答的问题。
导读里那位追查 ProfileService 的工程师,第一句审计问话就是"这些字节登记在哪本账上"。要回答它,得先知道账本一共有几本。本节是全册的起点:上一级是导读的知识地图,下一级通往 1.3 的 new 与 delete——那里将反复用到这里的区域概念。
区域划分不该背表格,该亲眼验证。下面这段程序把五本账的代表地址都打印出来:
#include <cstdio> int g_counter = 0; // 全局区(数据段):程序启动即开户 static double g_ratio = 1.5; // 同样在全局区,static 只影响可见范围 const char* g_tip = "audit"; // 字面量在常量区,指针变量本身在全局区 int main() { int local = 42; // 栈区:随作用域开户销户 static int calls = 0; // 静态局部变量:仍在全局区,仅作用域受限 int* heap = new int(7); // 堆区:手工开户,账户就是 *heap std::printf("代码区 %p\n", reinterpret_cast<void*>(&main)); std::printf("常量区 %p\n", reinterpret_cast<const void*>(g_tip)); std::printf("全局区 %p\n", reinterpret_cast<void*>(&g_counter)); std::printf("栈区 %p\n", reinterpret_cast<void*>(&local)); std::printf("堆区 %p\n", reinterpret_cast<void*>(heap)); delete heap; // 手工销账 return 0; }
在某台 64 位 Linux 上的一次典型输出(地址值每次运行会变,相对次序稳定):
代码区 0x55f3a1c22169 常量区 0x55f3a1c24b55 全局区 0x55f3a1c25134 栈区 0x7ffd0c31e464 堆区 0x55f3a4de72a0
解读这段输出:代码区与常量区地址最低且相邻;全局区紧随其后;堆区离它们隔着一大片空隙;栈区地址最高,而且比其他区域高出一大截——它是从高地址向低地址生长的。两次运行地址不同是地址空间布局随机化(ASLR)在起作用,但相对布局每次都一样,这份次序感在后面读崩溃地址时会派上用场:看到 0x7f 开头的地址,多半是栈或映射区;看到与代码区相邻的地址,多半是全局数据。

代码区与常量区是两本只读账。 机器指令、字符串字面量、编译期确定的常量放这里,程序运行期间只读不写,写入会直接触发崩溃(这正是 const 正确性在硬件层面的后盾)。它们的开户发生在程序载入时,销户发生在进程退出时,中间不存在任何手工记账动作。
全局/静态区是程序级账本。 全局变量、静态变量(含静态局部变量)住在这里,开户于程序启动、销户于程序结束,生命周期横跨整个 main。值得注意的是静态局部变量:它的作用域在一个函数里,账却在全局账本上——"作用域小"和"活得短"是两回事,这是初学者最容易混的一对概念。
栈区是自动记账的活期账户。 函数的局部变量、参数、返回地址都在栈上,进函数开户、出函数销户,由编译器自动完成,速度极快(本质是挪一下栈指针)。但空间有限,常见配置在 1 到 8 MB 之间——在栈上开一个超大数组,账本当场就翻脸(栈溢出)。
堆区是手工记账的承重墙。 new 申请、delete 销账,空间大、寿命可控,代价是记账责任全在人。全册的坏账故事几乎都发生在这本账上,第 6 章的分配器审计也是围绕它展开。
| 区域 | 谁开户 | 谁销户 | 生命周期 | 速度 | 典型坏账 |
|---|---|---|---|---|---|
| 代码区/常量区 | 载入器 | 进程退出 | 程序全程 | 快 | 写只读内存崩溃 |
| 全局/静态区 | 程序启动 | 进程退出 | 程序全程 | 快 | 初始化顺序依赖 |
| 栈区 | 进入作用域 | 离开作用域 | 作用域内 | 极快 | 栈溢出、悬垂指针 |
| 堆区 | new / malloc | delete / free | 开户到销账 | 相对慢 | 泄漏、双重释放 |
C++ 文档里常见 free store(自由存储)这个词,很多人把它当成堆的别名。严格说,new/delete 操作的是自由存储,malloc/free 操作的是堆,两者在标准里是两套账目接口;主流编译器实现里,operator new 底层通常就调 malloc,于是两本账合成了同一本。实践中把它们当同义词问题不大,但审计时要记住分界:只要走 new 开的户,就必须走 delete 销账;走 malloc 开的户,必须走 free。跨接口销账(new 出来的用 free 销)是标准的未定义行为,账目直接烂掉。
还有一个常被追问的细节:全局区里没显式初始化的变量去哪了?它们住在数据段里的 BSS 部分,载入器负责清零,"全局 int 不初始化就是 0"这句老话的依据就在这里。而栈上的局部变量不享受这项服务,不初始化就是随机值——第一章的坏账家族里,"野指针"多半就是这么来的。
背景:同事反馈服务偶发崩溃,崩溃日志里只有一个地址 0x7ffd1a2b3c40,没有调用栈。
操作:拿到地址先做区域判读。0x7f 开头落在栈或映射区段位,而不是堆区常见的低段地址;再看代码,崩溃点前一行恰好把一个局部数组的地址存进了全局回调指针。
结果:定位为悬垂指针——回调指针指向的栈账户早随函数返回销户了,异步触发时通过旧凭证访问已销账的内存。
解读:这份定位效率来自区域布局的"段位感":不靠猜,靠的是 1.1 节打印地址时建立的空间直觉。堆、栈、全局各有典型地址段位,虽然 ASLR 让具体值漂移,段位次序不变。
变式:同样的判读流程可用于任何崩溃地址。若地址极低(接近 0),多半是空指针解引用;若地址落在代码区段位,多半是函数指针坏了;若地址在堆段位且反复出现,就该怀疑悬垂的堆对象。把这些判断写成团队的崩溃日志分析清单,新人也能照着走。
💡 关键直觉:区域不是按"数据长什么样"划分的,而是按"谁记账、活多久"划分的。判断变量归属时别看类型,看它的开户方式——这一问习惯养成了,后面学智能指针时"所有权"概念会顺畅得多。
下一节我们把镜头从账本下移到银行:虚拟地址与物理地址的兑换系统,解释为什么 new 出来的地址总是那么"体面",以及为什么刚开的大户头几乎不花钱。