C++的内存布局

📅 2026/7/1 7:12:53
C++的内存布局
C 完整内存布局深度解析分为两大块进程全局虚拟内存分区和之前 Linux 虚拟地址图通用C/C 共用这套底层布局C 面向对象类 / 对象内存布局C 独有this、虚函数、继承、多虚继承核心一、C 程序进程级内存分区对应你最开始的地址图从低地址到高地址依次.text→.data→.bss→Heap→ 空闲区 →Stack→ 环境变量 → 内核1. 代码段 .text只读可执行存放所有函数机器码全局函数、类成员函数、静态成员函数字符串常量、const 字面量const char* s hello虚函数表 vtable常量只读存在 rodata 子段 特性多进程共享、不可写修改字面量直接段错误。const char* str test; *(char*)str x; // 崩溃修改只读段2. 数据段 .data已初始化全局 / 静态变量RW存放手动赋初值的全局变量、static 静态变量int g_a 100; // 全局data static double s_b 3.14; // 文件静态data void func() { static int cnt 0; // 函数内静态data }3. BSS 段未初始化全局 / 静态变量程序启动自动清零int g_b; // 默认0BSS static int s_c; // 默认0BSS void func() { static int x; // 默认0BSS }优势不占用 exe 磁盘体积运行时内核统一置 0。4. 堆 Heap程序员手动管理向上增长C 分配 / 释放new / delete底层封装malloc / free存放动态分配的对象、数组、缓冲区int* p new int[100]; // 堆内存 string* str new string();// string对象本体在堆 delete p; delete str;问题忘记 delete → 内存泄漏重复 delete、访问已释放 → 野指针。5. 栈 Stack编译器自动管理向下增长存放函数局部临时数据函数执行完自动回收局部基础变量、局部数组函数形参、返回地址、栈帧 ebp/esp局部对象生命周期仅限当前函数void test() { int a 10; // 栈 int arr[256]; // 栈数组 string s; // string对象在栈内部char缓冲区在堆 Demo obj; // obj整个实例在栈出函数自动析构 }限制栈空间极小默认 8MB超大局部数组、深度递归会栈溢出。6. 特殊补充静态成员变量所有类的static成员不在对象内存里统一存放在.data/.bss全局唯一一份所有对象共享。class A { public: static int num; // 不在A的实例中全局data段 int val; // 每个对象单独一份堆/栈随实例走 }; int A::num 1;二、C 类对象内存布局核心重点C 独有无基础规则普通成员变量按声明顺序占用内存受内存对齐padding影响普通成员函数不在对象内存全部存在.text代码段通过this指针访问成员虚函数对象头部多一个虚表指针 vptr8 字节 64 位 / 4 字节 32 位指向只读虚函数表 vtablestatic 成员完全脱离对象全局存储访问权限public/private/protected不影响内存布局仅编译期语法检查。场景 1无虚函数、无继承 普通类class Person { public: char c; // 1字节 int age; // 4字节 double d; // 8字节 void show() {} // 普通函数不占对象内存 static int cnt;// static不占对象内存 };内存排布64 位8 字节对齐0x00 | c (1B) 3B对齐填充padding 0x04 | age (4B) 0x08 | d (8B) 总大小16字节成员函数存在代码段调用时隐式传入this指针找到成员变量。场景 2含虚函数单虚表指针只要类有虚函数 / 继承含虚函数的父类对象首地址存放vptrclass Animal { public: int a; virtual void speak() {} // 虚函数生成vtable };对象内存布局64 位0x00 | vptr (8字节指向虚函数表) 0x08 | int a (4字节) 4字节对齐填充 总大小16字节虚表 vtable 结构存于只读 rodata 段每个拥有虚函数的类全局唯一一张虚表Animal::vtable [0] Animal::speak [1] 虚析构若定义virtual ~Animal()运行时多态原理Animal* p new Dog(); p-speak();通过 p 指向对象的 vptr 找到 Dog 的 vtable查表拿到 Dog::speak 地址动态调用实现多态。场景 3单一继承父类含虚函数class Animal { public: virtual void speak() {} int a 1; }; class Dog : public Animal { public: int b 2; void speak() override {} // 重写替换虚表项 };Dog 对象内存布局0x00 | vptrDog专属虚表 0x08 | 父类成员 int a 0x0C | 子类成员 int b 4字节padding 总大小16字节Dog 虚表[0] Dog::speak覆盖父类虚函数地址。场景 4多继承多个父类都带虚函数多个 vptrclass A { virtual void fa(); int a; }; class B { virtual void fb(); int b; }; class C : public A, public B { int c; };C 对象内存排布两个虚表指针0x00 | A_vptr 0x08 | A::a 0x10 | B_vptr 0x18 | B::b 0x1C | C::c 4B填充多继承会产生多份虚指针转换父类指针时会调整地址偏移。场景 5虚继承解决菱形继承数据冗余带间接虚基类指针 vbptr菱形继承经典问题Base / \ A B \ / C不用虚继承C 包含两份 Base 成员 使用virtual public Base虚继承仅存一份 Base通过vbptr 虚基类指针间接访问父类。 对象会新增 vbptr 指向虚基类表运行时查表偏移找到唯一 Base 实例。场景 6空类 / 空结构体C 规定任何对象不能占用 0 字节空类默认占 1 字节占位避免相同地址class Empty {}; sizeof(Empty) 1;一旦添加成员、虚函数大小按规则重新计算。场景 7栈对象 vs 堆对象对比struct Test { int x; virtual void f(){} }; int main() { Test t; // t实例整体在栈vptr、x都在栈内存 Test* p new Test(); // p指针本身在栈*p 完整对象vptrx分配在堆 return 0; }场景 8std::string / STL 容器内存模型string s hello world;string 对象本体内部指针、size、capacity存放在栈真正的字符缓冲区char[]动态分配在堆 容器vector/map同理容器对象栈存储的数据全部堆上。三、C 内存易错点总结局部对象在栈出函数自动析构不能返回局部对象指针int* bad() { int x 10; return x; // 危险函数结束栈帧销毁悬垂指针 }static 成员不属于任何对象全局唯一生命周期进程全程普通成员函数不占对象内存仅虚函数产生 vptr 增加对象体积虚析构函数必须加 virtual否则子类堆释放时只会调用父类析构内存泄漏内存对齐 padding 会增大类 sizeof成员顺序影响整体占用大小字符串字面量在只读代码段栈字符数组可修改new 出来的对象在堆必须配套 delete栈对象无需手动释放四、两张布局汇总简图1进程全局内存分层C 程序通用高地址 ─────────────────── 内核空间用户不可访问 环境变量/命令行参数 Stack 栈局部变量、局部对象向下增长 空闲虚拟地址空洞 Heap 堆new动态对象向上增长 BSS 未初始化全局/static Data 已初始化全局/static Text 代码段函数、vtable、字符串常量 低地址 ───────────────────2带虚函数子类对象内存简图64 位对象起始地址 ┌───────────────┐ │ vptr 虚表指针 │ → 指向只读全局虚函数表 ├───────────────┤ │ 父类成员变量 │ ├───────────────┤ │ 子类成员变量 │ └───────────────┘ 末尾自动填充padding满足对齐进程虚拟地址空间这张图描述的是类 UNIX 系统Linux/macOS下32 位进程的标准用户态虚拟地址空间布局。要真正理解它不能只停留在 “分区叫什么、存什么”必须从「硬件 - 内核 - 用户态」三层视角讲清为什么这么设计、底层如何实现、运行时如何变化、安全与性能如何权衡。一、先搞懂底层前提这是「虚拟地址」不是物理内存MMU Memory Management Unit 内存管理单元图中所有地址都是虚拟地址Virtual Address由 CPU 的内存管理单元MMU通过页表映射到真实物理内存。这是整个布局的根基每个进程都拥有独立完整的地址空间32 位下总大小为 2324 GB每个进程都 “以为” 自己独占全部 4GB 内存彼此完全隔离。一个进程崩溃不会污染其他进程的内存。地址空间 一张 “内存蓝图”图中大部分区域一开始只是 “地址范围”并没有对应真实物理页。只有当程序真正访问时内核才通过缺页中断分配物理内存、建立页表映射。特权级隔离高地址的内核空间运行在 CPU Ring 0 特权级用户空间运行在 Ring 3。用户态代码无权直接读写内核内存必须通过系统调用syscall陷入内核才能操作。二、从高地址到低地址逐段深度拆解1. 系统内核区OS Kernel这是整个地址空间的最高区域由所有进程共享同一份物理映射。经典划分32 位 Linux3GB 用户空间0x00000000 ~ 0xBFFFFFFF 1GB 内核空间0xC0000000 ~ 0xFFFFFFFF。Windows 默认是 2G 用户 2G 内核。64 位扩展x86_64 架构仅使用 48 位有效地址共 256TB高半部分为内核空间低半部分为用户空间中间是巨大的地址空洞彻底解决了 32 位地址不足的问题。内核空间存放的核心内容内核自身的代码段、数据段每个进程的描述符task_struct、页表、内核栈物理内存管理结构slab 分配器、伙伴系统设备驱动、文件系统缓存、网络协议栈关键设计所有进程共享内核物理页进程切换时无需刷新内核页表大幅降低 TLB快表失效开销。2. 环境变量 / 命令行参数区紧贴栈顶的一小块区域是进程启动时由内核预先布置的 “启动参数区”。加载过程执行execve系统调用创建进程时内核将父进程传入的argv命令行参数、envp环境变量逐字节拷贝到新进程栈的最顶端按 ABI 规范排列。程序如何读取程序入口_start从栈顶取出 argc/argv/envp 指针传递给main函数。限制系统存在ARG_MAX上限Linux 默认几 MB参数 / 环境变量过多会报 “参数列表过长” 错误。为什么放这里位于栈的起始顶端不会被栈向下增长覆盖同时加载时一次性布置符合栈的内存布局连续性。3. 栈Stack—— 函数调用的基石栈是程序运行时最高频、最精密的内存区域由编译器生成的指令自动管理无需人工干预。1栈帧的微观结构每调用一个函数就会在栈上压入一个栈帧Stack Frame从高地址向低地址依次包含高地址 ┌─────────────────┐ │ 函数返回地址 │ call指令自动压入函数结束后跳回这里 ├─────────────────┤ │ 旧栈基址ebp │ 保存上一层函数的栈底形成调用链 ├─────────────────┤ │ 被保护寄存器 │ 调用前后值不变的寄存器 ├─────────────────┤ │ 局部变量区 │ 函数内定义的局部变量、数组 ├─────────────────┤ │ 函数入参 │ 调用者压入的参数部分在寄存器 └─────────────────┘ 低地址 ← 栈顶 esp/rsp 向下增长2核心机制向下增长的设计原因只需一个栈顶寄存器esp/rsp跟踪边界与向上增长的堆共享中间空闲区域最大化地址空间利用率。按需分配物理页栈不是一开始就分配全部 8MB而是预留虚拟地址栈顶向下扩张时触发缺页中断内核动态分配物理页。栈底部设有警戒页Guard Page越界访问直接触发段错误。默认大小Linux 默认 8MBulimit -s查看Windows 默认 1MB递归过深、超大局部数组会直接栈溢出。3安全层面栈是内存攻击的重灾区现代系统的三层防护NX/DEP 位栈标记为 “不可执行”阻止注入的 shellcode 直接运行对应图中 “不可执行并且可修改”。栈金丝雀Canary栈帧中插入随机校验值返回前校验是否被覆盖检测栈溢出。ASLR 地址随机化栈的起始地址每次启动都随机攻击者无法预测返回地址位置。4. 中间未分配区域栈与堆之间的大片虚拟地址空洞是进程动态内存的 “蓄水池”。初始状态下没有任何物理页映射直接访问会触发段错误。双向扩张栈向下扩、堆向上扩都向中间这片区域生长。大内存分配区超过阈值glibc 默认 128KB的动态内存不会走堆扩张而是通过mmap匿名映射直接插在这片区域避免堆碎片化。5. 堆Heap—— 动态内存的核心堆是唯一由程序员手动管理生命周期的内存区域也是内存泄漏、野指针、碎片化问题的根源。1堆的两层实现很多人以为malloc是系统调用实际上堆管理分为两层内核层通过brk/sbrk系统调用移动 “堆边界program break”扩大或缩小堆的虚拟地址范围大内存则走mmap/munmap。用户态分配器malloc/free是 C 标准库函数如 glibc 的 ptmalloc在内核给的大块内存上做二次管理维护空闲块链表、做块分裂与合并。2分配器的核心工作用多个空闲链表bins管理不同大小的空闲块采用首次适配 / 最佳适配算法分配。释放内存时合并相邻空闲块缓解外部碎片。每个分配块附带元信息大小、前后指针这也是堆溢出攻击可以篡改的目标。3常见问题的本质内存泄漏程序丢失了指向堆块的指针分配器认为该块仍在使用无法回收再分配。野指针 / 悬垂指针内存已释放但指针未置空后续访问会读到脏数据或触发崩溃。内存碎片频繁小内存分配释放导致空闲内存不连续总空闲空间足够但没有连续大块可用。4C 补充new底层调用malloc分配内存再执行构造函数delete先执行析构函数再调用free释放内存。6. 未初始化数据段BSS全称Block Started by Symbol是最容易被误解的一段。存放内容未显式初始化的全局变量、静态变量。核心设计目的节省磁盘空间。BSS 段在可执行文件ELF/PE中不占用实际磁盘空间只记录总大小。进程加载时内核直接分配一整片匿名内存并全部清零因此全局变量默认值为 0。权限可读可写、不可执行生命周期伴随整个进程。7. 数据段Data Segment存放已显式初始化的全局变量、静态变量。与 BSS 相反初始值会保存在可执行文件中加载时直接从文件拷贝到内存占磁盘空间。内部还可细分.data读写数据段普通已初始化全局变量。.rodata只读数据段也就是图中的 “字面量池”存放字符串常量、const全局常量与代码段一同映射写入会触发段错误。常见误区函数内的static变量作用域仅限函数但存储位置仍在 Data/BSS 段全程不销毁、只初始化一次。8. 代码段Code / Text Segment地址空间最低处的只读可执行区域是程序逻辑的载体。存放内容编译后生成的机器指令if/for/ 函数调用等所有程序逻辑。核心特性只读 可执行指令只能被 CPU 执行不能被程序修改防止代码被恶意篡改。共享映射多个进程运行同一个程序时物理内存中只存一份代码通过页表映射到不同进程的虚拟地址大幅节省内存。页对齐按内存页4KB边界对齐配合 MMU 做权限控制。字面量池Literals segment即.rodata段字符串常量、数字字面量都存在这里只读不可写。经典面试题char *p hello; *p a;为什么崩溃因为 hello 位于只读的 rodata 段写入操作会触发内存访问违例。三、从文件到内存的完整加载一个 C 程序从编译好的 ELF 文件到形成图中的内存布局经历三步编译链接编译器生成.text.data.bss等段链接器确定各段的虚拟基地址。内核加载execve读取 ELF 头校验文件格式建立进程页表将代码段、数据段按页映射到对应虚拟地址为 BSS 段分配匿名页并清零在栈顶布置命令行参数、环境变量设置 CPU 指令寄存器PC指向程序入口_start开始执行。运行时动态扩展栈随函数调用向下生长堆随malloc向上生长共享库通过mmap映射到中间区域。四、关键设计思想总结这张经典布局不是随意划分的每一处设计都围绕四个核心目标权限隔离代码只读可执行、数据读写不可执行、内核用户态分离最小权限原则保障安全。动静分离静态确定的代码、全局数据放地址底部加载时一次性映射动态伸缩的栈、堆放中间运行时按需分配。空间高效栈向下、堆向上双向增长共享中间空闲区域最大化利用有限的地址空间。性能优化代码段共享、物理内存按需分配、内核空间共享在隔离性前提下尽可能降低开销。五、常见底层问题一句话答透为什么局部变量不初始化是乱值栈内存是复用的上一个函数栈帧的残留数据没有清零。为什么全局变量默认是 0BSS 段加载时由内核统一清零。为什么栈溢出会崩溃越过栈警戒页访问未映射内存内核发送 SIGSEGV 信号终止进程。为什么free(NULL)是安全的标准库直接返回不执行任何释放操作。64 位下这个图还成立吗分区逻辑一致但地址空间极大栈、堆、共享库之间有巨大地址空洞不再紧凑排列。