1.1 五本分类账:内存区域划分


1.1 五本分类账:内存区域划分

本节摘要: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 开头的地址,多半是栈或映射区;看到与代码区相邻的地址,多半是全局数据。

图 1-1 典型 64 位进程的地址空间五本账

图 1-1 典型 64 位进程的地址空间五本账

一、五本账各自的记账规则

代码区与常量区是两本只读账。 机器指令、字符串字面量、编译期确定的常量放这里,程序运行期间只读不写,写入会直接触发崩溃(这正是 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 开户、delete 销账,责任在人,坏账高发,是后续章节的主战场。
  • 地址段位感:代码区最低、栈区最高、堆与映射区居中,ASLR 只动值不动次序,崩溃地址判读靠它。
  • 接口配对:new 配 delete、malloc 配 free,跨接口销账是未定义行为。
  • BSS 清零:全局未显式初始化变量由载入器清零,栈变量无此待遇,读未初始化栈变量就是野值。

下一节我们把镜头从账本下移到银行:虚拟地址与物理地址的兑换系统,解释为什么 new 出来的地址总是那么"体面",以及为什么刚开的大户头几乎不花钱。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U