ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++内存分区详解:堆区、栈区、数据段、bss段与段错误排查

C++内存分区详解:堆区、栈区、数据段、bss段与段错误排查 内存分区这个词我第一次真正当回事儿是在一个加班到凌晨的周四。当时在追一个随机崩溃的 bug代码逻辑翻来覆去看了三遍都没毛病gdb 里 backtrace 只有一层莫名其妙的地址。后来把几个全局变量的地址、堆上对象的地址、栈上局部变量的地址全打印出来一对照十分钟就锁定了一个数组越界——它悄无声息地踩掉了隔壁的堆块头。从那以后我就养成了一个习惯任何涉及指针的代码先把地址打出来看一眼。C 的堆区、栈区、数据段、bss 段、代码区这套内存布局很多人是在面试八股里第一次听说的背完就忘。但它的真正价值不在面试而在于排查崩溃、压缩内存占用、看懂链接脚本、理解程序加载过程。你写的每一个变量住在哪块区域直接决定了它的生命周期多长、能不能被修改、出错之后会以什么姿势炸掉。这篇内容适合三类人写过 C 但说不清变量到底存在哪儿的人被段错误折磨过、想搞明白怎么定位的人以及想从会写语法进阶到知道程序跑起来长什么样的人。我会把五个区域的职责、底层原因、验证手段、排查套路全部串起来讲代码都能直接跑命令都能直接抄。1. 五个区域的分工先把这张地图画清楚1.1 为什么一个变量住在哪决定了它能活多久内存分区这件事的本质回答的其实是三个问题这块内存在程序运行的哪个阶段被分配、谁负责回收、它是可读可写还是只读。搞明白这三点几乎所有关于生命周期的困惑都会迎刃而解。拿一段最常见的代码来说#include cstdio int g_data 42; // 数据段 int g_bss; // bss 段 static int s_data 7; int main() { int local 1; // 栈区 static int s_local 2; // 数据段 int* p new int(3); // 指针在栈区指向的内存在堆区 printf(%d\n, *p); delete p; return 0; }注意p本身和*p是两个世界的东西。p这个指针变量是局部变量函数一返回它就没了而*p那块 4 字节在堆上除非你delete它会一直挂着。这种变量和它指向的东西不在一起的事实是绝大多数内存 bug 的源头。很多人写 Java、Python 出身习惯了引用和对象都由运行时统一管理切到 C 之后第一件要重建的直觉就是把手动管理的内存和自动管理的内存分开看待。再往下想一层为什么栈上的东西函数返回就自动没了堆上的却要手动释放因为栈的分配释放就是一条sub rsp, N和add rsp, N指令编译器在编译期就把每个函数的栈帧大小算死了进出函数时移动一下栈指针就完成了分配和回收成本是纳秒级的。堆不行堆上分配的大小、时机、顺序在编译期根本不知道必须有一套运行时的分配器来记账。代价就是记账本身的成本以及忘记还账带来的泄漏。1.2 五个区域的职责与典型居民把五个区域横向拉出来对比脑子里会有张更清晰的地图区域存放内容生命周期谁分配/释放权限代码区 .text机器指令、部分常量整个进程链接器/加载器r-x数据段 .data已初始化且初值非零的全局/静态变量整个进程编译期确定rw-bss 段 .bss未初始化或零初始化的全局/静态变量整个进程加载时清零rw-堆 heapnew / malloc 出来的对象手动控制程序员rw-栈 stack局部变量、函数参数、返回地址函数作用域编译器生成指令自动管理rw-这张表里有两个容易忽略的点。第一.data 和 .bss 都属于数据段这个大概念很多资料把 bss 单拎出来说其实它俩只是按是否需要占用可执行文件空间做的进一步切分。第二代码区不只是指令字符串字面量这类只读常量通常也放在代码区附近的只读段.rodata里所以严格说现代 ELF 文件里能切出七八个段但归类到五大区的框架里都能装得下。我还想补一个小历史bss 是 Block Started by Symbol 的缩写最早来自上世纪五十年代 IBM 704 上的汇编器。这名字今天看毫无描述性但它的设计意图非常明确——给还没填过值的静态变量专门留一块地让可执行文件不用为它们浪费磁盘空间。这个思路一直沿用到今天的每一个主流平台。1.3 分区背后的三个推手链接、加载、虚拟内存为什么非要分区不能所有内存混在一起答案是三个角色各有一份需求。链接器需要知道每个符号属于哪一类。你在 A 文件里定义了int g_data 42;B 文件里extern int g_data;引用它链接阶段要把两边的地址对上。如果全局变量和代码、字符串混在一堆重定位表的条目会成倍膨胀。分区让链接器可以按段为单位批量处理整个 .data 段整体搬到某个基址段内偏移保持不变重定位一次搞定。加载器需要知道哪些内容要清零、哪些要设成只读。.bss 段在文件里可能只记了一个大小 1024 字节的条目加载到内存时必须真的划出 1024 字节并全部置零这是 C 标准对静态存储期变量的规定——不显式初始化的静态变量初值就是零。而 .text 段加载后会被页表标记为可执行但不可写你试着往函数指针指向的地址写一个字节立刻收到 SIGSEGV。虚拟内存是最后一道保障。现代操作系统给每个进程独立的地址空间段与段之间可以用不可访问的页隔开。这样当一个数组越界冲进隔壁区域时运气好能立刻触发段错误被发现运气不好只是踩掉了相邻变量导致数据错乱——两种情况都比随便写哪都行要可控得多。理解了这三方各自的诉求你就明白分区不是学院派的设计洁癖而是被工程现实逼出来的方案。2. 代码区、数据段、bss 段编译期就定下的三块地盘2.1 代码区 .text只读、共享、可执行代码区里放的是编译后的机器指令它是整个进程里唯一可执行但不可写的区域。这个权限组合是刻意的你写的每一行 C 逻辑最终都变成了这里的一串字节如果这块内存可写一次野指针就能把程序逻辑改成任意样子。代码区还有两个特性经常被忽略。一是只读意味着可以共享。同一个可执行文件即使被启动多次物理内存里这份指令通常只有一份页表各自映射到相同的物理页这也是为什么打开十个记事本不会占用十倍内存的底层原因之一。二是常量可能被放进只读段。你写const char* p hello;那个 hello\0 这 6 个字节通常落在 .rodata只读数据段和代码区一起被标记为只读。这就解释了一个新手常见的崩溃char* s hello; s[0] H; // 运行时直接崩段错误字面量字符串在只读段里写它必然触发保护。想改就老老实实复制一份到可写内存char s[] hello; // 数组在栈上内容是拷贝随便改 s[0] H; // 没问题注意C11 起字符串字面量的类型是const char[]直接绑到char*上属于废弃用法新版编译器GCC 11 / Clang 14 之后会直接报错。看到这类代码报错别去加-fpermissive糊弄那是在掩盖一个真实存在的越权写风险。2.2 .data已初始化的全局与静态变量.data段收留的是有初值且初值非零的静态存储期变量。包括全局变量、文件作用域的 static 变量、函数内部的 static 局部变量。它们在可执行文件里是实打实占空间的——因为初值必须被记录下来加载时才能还原。int g_a 42; // .data static int g_b 7; // .data int main() { static int local 100; // 也在 .data但作用域只在 main 内 }函数内 static 局部变量放在这里是很多人的知识盲点。它的作用域确实是局部的但存储位置是全局的所以它能跨调用保持状态也是线程不安全的重灾区。经典的单例写法、局部缓存的写法都依赖这个特性int next_id() { static int counter 0; // 只在第一次执行这一行时初始化 return counter; }C11 之后标准规定局部静态变量的初始化是线程安全的编译器会在背后生成一个 guard 变量保护初始化过程。如果你的场景里这成了性能热点就得自己改成一次性初始化或提前构造。我在一个高频调用的日志接口上实测过把这种局部静态的初始化检查换成显式的外部初始化之后单次调用开销从十几纳秒掉到了两纳秒左右。当然这种优化只在极端的场景下才值得做绝大多数业务代码完全不用管。2.3 .bss为什么未初始化变量几乎不占文件体积.bss段处理的是另一类变量没有显式初始化或者显式初始化为零的静态存储期变量。int g_x; // .bss int g_y 0; // 也在 .bss因为零初始化和不写是一样的 static char buf[4096]; // .bss但这里省下的空间很可观关键点来了.bss 段的变量在可执行文件里几乎不占磁盘空间。文件里只记录这个段有多大、从哪个地址开始这类元信息真正的内容是加载到内存时才分配的而且内核用的是零页技术——先让所有页指向同一个全零的物理页等某个页第一次被写入时再触发写时复制分配真实内存。这样一个 10MB 的全局数组如果全是零磁盘上不多占 1 字节物理内存也要等真的写入了才慢慢涨上去。为什么能这么省因为零是唯一一种不需要在文件里存储的值——只要规定加载时清零就行了。C 标准本来就规定静态存储期变量不显式初始化时按零处理那不如把这个规则的实现成本降到最低。设计对齐、填充、初始化顺序这些问题在链接脚本里都有对应的处理但对你写代码的人来说只需要记住一条经验大块的缓冲区声明成全局数组如果没有初值就写static char buf[N];而不是static char buf[N] {0};前者进 .bss 省空间后者在某些老编译器上会被塞进 .data 把可执行文件撑大。现代编译器基本都做了优化但保持这个习惯没坏处。用一条命令就能验证这个差异size a.out # text data bss dec hex filename # 1824 600 1048576 1051000 100978 a.outbss那一列显示一百多万字节data只有几百字节而文件本身可能只有二十几 KB。这个对比非常直观。2.4 动手验证用 size、objdump、nm 把段布局打印出来光看理论不如自己跑一遍。准备一个layout.cpp#include cstdio #include cstdlib int g_data 42; // .data int g_bss; // .bss static int s_data 7; // .data static int s_bss; // .bss static char big_buf[1024 * 512]; // .bss512KB int helper() { return 1; } int main() { printf(done\n); return 0; }编译并查看段大小g -O0 -o layout layout.cpp size layout输出里data应该在几百字节量级g_data、s_data 加上 CRT 自带的一些初始化数据bss至少要 524288 字节以上。接着看每个符号落在哪个段nm -C layout | grep -E g_data|g_bss|s_data|s_bss|big_buf|helper你会看到类似这样的输出字母含义T代码D已初始化数据BbssR只读数据0000000000004028 D g_data 0000000000004030 D s_data 0000000000004040 B g_bss 0000000000004044 B s_bss 0000000000004080 B big_buf 0000000000001169 T helper()再去readelf -S layout | head -40看节区表能看到.text、.rodata、.data、.bss各自的地址、大小、对齐和标志位。.bss那行的 TYPE 是 NOBITS意思是在文件里没有实际内容这正是它省空间的直接证据。提示nm输出的地址是虚拟地址在开启 PIE现代 GCC 默认的情况下这是文件内的相对偏移运行时还要加上一个随机的加载基址。这一点在后面讲 ASLR 的时候还会用到。3. 栈区函数调用的高速工作台3.1 一个函数调用到底在栈上发生了什么栈区的核心用途可以一句话概括记录函数调用的现场。每次调用一个函数就压入一个栈帧stack frame函数返回时弹出。一个栈帧里通常装着四样东西函数的参数超出寄存器能传的部分、返回地址、调用者保存的寄存器现场、以及函数内部的所有局部变量。在 x86-64 Linux 上按 System V ABI 的规定前 6 个整型/指针参数走寄存器rdi、rsi、rdx、rcx、r8、r9浮点参数走 xmm 寄存器更多的参数才压栈。函数入口处典型的三条指令是这样的push rbp ; 保存调用者的栈基址 mov rbp, rsp ; 建立新栈帧 sub rsp, 0x20 ; 为局部变量预留 32 字节函数返回时leave加上ret把一切还原。整个过程只有几条指令所以栈分配的成本几乎可以忽略不计。这也解释了为什么在 C 里优先把对象放在栈上是性能共识栈分配不需要分配器记账不需要加锁退栈时也不需要任何清理调用连缓存局部性都更好。顺带说一个实际影响栈帧大小在编译期就定死了所以变长数组和alloca会绕过一部分优化。它们会让栈指针动态移动编译器难以精确知道每个局部变量的偏移某些优化就直接放弃了。能用固定大小的数组就别用变长的能用堆就用堆这是我在性能敏感代码里一贯的选择。3.2 栈的生长方向、大小限制与栈溢出在 x86 和 ARM 的主流实现里栈是从高地址往低地址生长的。每压入一个栈帧栈指针减小返回时增大。而堆恰恰相反是从低地址往高地址长。两边相向而行中间留出的空间就是给共享库、大块 mmap 分配这些动态映射用的。这个布局在进程的地址空间图上非常直观低地址是代码和数据往上依次是堆、空闲区、共享库、栈最高地址是内核空间。栈的大小是有限的。Linux 上默认是 8MB可以用ulimit -s查看ulimit -s # 8192Windows 上默认通常是 1MB这个差异写过跨平台代码的人应该都踩过——同一段递归在 Linux 上跑得好好的到 Windows 上直接栈溢出。修改栈大小也简单Linux 下ulimit -s 32768 # 临时改成 32MB或者在线程层面通过pthread_attr_setstacksize指定。栈溢出是另一个经典问题一个极简的复现void recurse(int n) { char buf[1024]; buf[0] static_castchar(n); recurse(n 1); // 无限递归每个栈帧吃掉约 1KB }每层递归占用一个 1KB 的数组加上返回地址、保存的寄存器大约 1KB 出头。8MB 的栈大概撑到七千多层就崩了。崩溃的表现是 SIGSEGV而且 backtrace 可能非常长——因为 gdb 要一层层展开所有栈帧。遇到这种情况第一反应应该是检查有没有无终止条件的递归或者有没有在栈上开了超大数组。注意不要用alloca或者几百 KB 的局部数组。栈空间是稀缺资源一个char buf[1024*1024]就可能直接把栈撑爆而这种崩溃的位置往往离真正的错误点很远。大缓冲区一律走堆或者 static 存储。3.3 局部变量地址为什么不能带出函数这是栈区最容易犯的错误也是最经典的悬垂指针来源int* bad() { int local 42; return local; // 灾难返回栈上变量的地址 } int worse() { int local 42; return local; // 返回栈上对象的引用 }函数返回后栈帧被弹出local那块内存随时会被下一次函数调用覆盖。你可能侥幸读到正确值因为还没被覆盖也可能读到垃圾还可能读出看起来合理但完全错误的值。GCC 和 Clang 会对这类代码给出-Wreturn-local-addr警告默认就该把它当错误处理在 CI 里加上-Werrorreturn-local-addr一点都不亏。正确的做法有三种。返回堆上分配的对象调用方负责释放或者返回智能指针返回静态存储期的对象注意线程安全和重入问题或者最推荐的——按值返回让编译器去决定是走拷贝还是 RVO/NRVO 优化。现代编译器对返回值优化做得相当好大多数情况下按值返回不会产生额外拷贝。4. 堆区动态内存的主战场4.1 malloc 背后brk、mmap 与 chunk 头部堆区是程序员唯一能完全掌控的区域也是出事最多的地方。理解它的分配机制能省掉很多瞎猜的时间。Linux 上使用的分配器是 glibc 的 ptmalloc2很多项目会换成 jemalloc 或 tcmalloc但基本原理相通。它主要通过两种方式向系统要内存第一种是brk/sbrk用来移动堆顶位置适合小块内存。当你在程序里第一次调用malloc(16)时分配器往往会一次性向内核申请一大块比如 128KB然后自己在内部切分出售。这样做的好处是后续的分配释放都不用进内核纯用户态操作速度极快。第二种是mmap适合大块分配。默认阈值是 128KB可以通过mallopt的M_MMAP_THRESHOLD调整超过这个值的分配会直接映射一块独立内存释放时立刻还给内核不留在堆里。每次分配出去的内存块chunk头部长这样64 位下字段大小含义prev_size8 字节前一个 chunk 的大小仅当前一个 chunk 空闲时有效size8 字节本 chunk 大小低 3 位是标志位是否在使用、前一块是否空闲等用户数据可变返回给调用者的部分用户拿到的指针指向数据部分往前推 16 字节就能看到这两行元信息。这解释了为什么堆上的小数组越界会引发看起来毫不相关的崩溃你多写一个字节可能踩掉的是下一个 chunk 的 size 字段下一次free时分配器读到损坏的 size就会在完全无关的位置崩掉或者进入死循环。4.2 new/delete 与 malloc/free 的差异和配对禁忌C 里new和malloc的关系经常被简化成new 会调用构造函数这句话对但不全对。完整的区别至少有三条new负责计算大小、调用分配函数、调用构造函数、返回正确类型的指针malloc只负责按字节分配原始内存。new失败时抛std::bad_allocmalloc失败返回NULL。new会做类型检查和对齐保证malloc只保证满足max_align_t的基本对齐C17 之后才有了对齐版本的operator new。配对规则必须记牢而且顺序不能错int* a new int(1); delete a; // 正确 int* b new int[10]; delete[] b; // 必须用 delete[]否则行为未定义 void* c malloc(16); free(c); // 正确new[]配delete在带非平凡析构函数的类型上必然出错因为编译器需要在数组头部记录元素个数或者使用 cookiedelete不会去读那个记录。反过来malloc配delete也是未定义行为。注意混用在不同编译单元、不同 CRT 版本下还可能直接崩溃。Windows 上最典型的是 Debug 版 DLL 分配、Release 版 EXE 释放两边的堆根本不是同一个。跨模块传递内存所有权时一定要约定好由谁分配就由谁释放或者统一走一套分配接口。C 里现在更推荐的做法是直接上智能指针#include memory auto p std::make_uniqueint[](10); // 数组自动 delete[] auto q std::make_sharedstd::string(hi);unique_ptr零开销shared_ptr有一个引用计数块的开销但在绝大多数业务场景下这点开销换来的安全性是划算的。我自己维护的项目里只要不是极端的性能热点裸new基本已经绝迹了。4.3 堆上三类经典事故泄漏、悬垂、重复释放内存泄漏最好理解也最难根治。分配了没释放进程运行越久占用越高最后被系统杀掉。简单的泄漏用valgrind --leak-checkfull一跑就出来复杂的泄漏比如容器里不断追加对象但从不清理静态工具看不出来得靠堆快照对比。悬垂指针是释放之后继续用。表现非常随机因为那块内存可能被分配给别人了你看到的是一份完全无关的数据。这类问题用 AddressSanitizer 抓最省事g -fsanitizeaddress -g -O1 -o app app.cpp ./app它会精确告诉你这一行释放了内存这一行又在使用还有完整的分配栈和使用栈比肉眼看代码快十倍。重复释放是同一个指针delete两次。堆分配器内部维护着空闲链表重复释放会把同一个块挂进链表两次后续分配时出现两个用户拿到同一块内存的诡异现象。现在的 glibc 会检测一部分情况并报 double free or corruption但检测不保证覆盖所有情况。一个不算技巧但特别有效的习惯delete之后立刻把指针置空。delete nullptr是合法的无操作置空之后就再也不会重复释放了。用unique_ptr就更彻底连置空的步骤都省了。4.4 一段可复现的完整实操把五个区域地址全打出来理论讲完上代码。这段程序会把五个区域的典型地址全部打印出来你可以在自己的机器上跑一遍对照着看#include cstdio #include cstdlib int g_data 42; // .data int g_bss; // .bss const char* g_ptr literal; // 指针在 .data字面量在 .rodata int helper() { return 1; } int main() { int stack_var 1; // 栈 static int s_data 2; // .data static int s_bss; // .bss int* heap_small static_castint*(std::malloc(32)); // brk 区 int* heap_big static_castint*(std::malloc(1024 * 1024)); // mmap 区 const char* literal hello; // 字面量在 .rodata printf(main() : %p\n, (void*)main); printf(helper() : %p\n, (void*)helper); printf(g_data : %p\n, (void*)g_data); printf(g_bss : %p\n, (void*)g_bss); printf(s_data : %p\n, (void*)s_data); printf(s_bss : %p\n, (void*)s_bss); printf(literal str : %p\n, (void*)literal); printf(heap small : %p\n, (void*)heap_small); printf(heap big : %p\n, (void*)heap_big); printf(stack_var : %p\n, (void*)stack_var); std::free(heap_small); std::free(heap_big); return 0; }编译运行g -O0 -g -o layout layout.cpp ./layout在典型的 x86-64 Linux 上你会看到这样的分布规律main、helper的地址最低在 0x5555 开头的一段PIE 基址加偏移g_data、g_bss、s_data、s_bss紧随其后地址比代码高一点且 bss 通常排在 data 后面heap_small落在数据段之后的堆区地址比所有全局变量都高heap_big因为超过 mmap 阈值地址会跳到 0x7f 开头的共享库区域和小块分配完全不在一个区间literal指向只读段位置通常在代码段附近而不是数据段stack_var最高0x7fff 开头和其他所有区域拉开一大截这个分布不是巧合而是虚拟地址空间布局的标准形态。你可以顺手做个实验把malloc(1024*1024)的分配改成连续多次释放再分配观察小块分配的地址是不是在同一个区间里反复浮动。看过几次之后你对堆是向上长的、栈是向下长的这句话会有手感的。5. 那些容易被问到的边界问题5.1 字符串字面量为什么改了就崩前面提过一句这里展开说。字符串字面量在编译期就确定内容链接器把它放进只读段。程序加载时这部分页被映射为只读写操作触发页保护异常进程收到 SIGSEGV 直接终止。有意思的是同样的代码在不同编译器上表现可能不同。某些老编译器的默认选项会把字符串常量合并到可写段导致你不小心改成功了程序看起来一切正常——这种情况更危险因为代码本身就带着未定义行为换一个编译器或者换一个优化级别就炸了。顺便说一句字面量合并编译器会把内容相同的字面量合并成一份所以abc abc在某些实现下可能返回 true在某些实现下返回 false。永远不要用比较字面量和指针要比较就用std::string或strcmp。5.2 static 修饰的变量到底归哪个段static在 C 里至少有三种截然不同的含义按语境区分修饰全局变量或函数时表示内部链接作用域限制在本编译单元修饰局部变量时表示静态存储期生命周期贯穿整个进程修饰类成员时表示这个成员属于类而不属于某个对象。从内存分区的角度看只有前两种和变量住在哪有关。函数内的static int x 1;放在 .datastatic int y;放在 .bss。类静态成员变量同理它的定义写在类外的那个int MyClass::count 0;决定它落在 .data。有一个坑是静态初始化顺序。不同编译单元里的全局对象初始化顺序是不确定的如果 A 文件的全局对象构造函数里用到了 B 文件的全局对象而 B 还没构造完你就拿到了一个半成品。标准给的解法是首次使用时构造Logger logger() { static Logger instance; // C11 起线程安全首次调用时才构造 return instance; }这个写法比任何手写的锁加双检锁都简洁可靠我强烈建议所有单例都这么写。5.3 const 变量落在哪里const的位置取决于它修饰的是什么、在哪定义。全局const int N 100;在 C 里默认是内部链接这一点和 C 不同编译器很可能直接把用到 N 的地方替换成字面量 100变量本身被优化掉。如果你取了它的地址它会被放到只读段。局部const int n 5;通常就在栈上但如果只被读且能常量折叠也可能被优化掉连栈空间都不占。需要注意的是const和只读不是一回事。一个const修饰的全局变量放在只读段写它确实会崩但一个const修饰的局部变量放在栈上你通过指针强转去改它在某些情况比如被优化成寄存器里的常量下编译器根本不会去读内存你的修改就是无效的。所以const_cast只是解除编译期检查不能保证运行期一定生效用之前想清楚这是在标准定义的未定义行为边缘试探。5.4 编译器优化会不会把分区打乱会而且幅度可能超出你想象。开-O2之后你会发现局部变量被完全放进寄存器local这个操作会强制它落到栈上小的全局常量被常量折叠.data里根本没有它的位置内联函数不存在独立的代码全被塞进调用点相邻的小变量被重排、合并地址顺序和源码里完全不同死代码整段消失size输出可能小一大截这不是分区机制失效了而是分区描述的是内存布局的规则而优化改变的是哪些东西真的需要内存。所以做内存布局实验时一定要用-O0想看真实的段大小就用-Os或者发布配置。很多人在-O2下盯着优化后的地址琢磨半天其实看到的已经是另一回事了。6. 排查实录段错误、乱码地址和真实踩坑6.1 问题速查表先把常见现象和对应原因整理成一张表出问题的时候可以对着扫一遍现象大概率原因首选排查手段随机位置 SIGSEGV堆越界踩坏 chunk 元数据-fsanitizeaddress/ valgrind函数返回后数据错乱返回了栈上对象的指针或引用编译加-Wreturn-local-addr改字符串常量就崩写只读段改用char[]复制一份进程内存持续增长分配未释放、容器只增不减valgrind --leak-checkfullfree 时报 corruption重复释放或写入越界ASan检查所有权设计同一段代码在两台机器表现不同未定义行为 编译选项差异统一编译选项开-Wall -WextraWindows 上崩、Linux 正常栈默认大小不同1MB vs 8MB检查递归深度和大数组6.2 core dump gdb 定位越界的完整流程线上环境不方便装 AddressSanitizer 的时候core dump 是最可靠的手段。完整流程如下第一步允许生成 core 文件。默认很多时候是关闭的ulimit -c unlimited cat /proc/sys/kernel/core_pattern # 看看 core 文件落在哪第二步程序崩溃后拿到 core 文件用 gdb 打开gdb ./app ./core第三步在 gdb 里看现场(gdb) bt # 调用栈 (gdb) frame 2 # 切到出问题的那一帧 (gdb) info locals # 看局部变量 (gdb) info registers rsp rbp (gdb) x/8gx $rsp # 按 8 字节一组查看栈内容一个特别有用的技巧是看崩溃地址。如果崩溃地址形如0x0000000000000000或者0x0000000000000010八成是空指针加了一个偏移如果是一个很大的、看起来合法的地址那可能是悬垂指针或者越界写到了受保护区域。内存越界还有一个补救手段malloc 的调试环境变量。在程序启动前设置MALLOC_CHECK_3 ./app它会让 glibc 在每次分配释放时做额外校验能够更早地发现堆损坏。代价是性能下降只在排查阶段用。6.3 ASLR 让地址每次都不一样怎么对照前面那段打印地址的程序如果你连跑两次会发现地址完全不同——这是 ASLR地址空间随机化在起作用。现代系统默认开启每次加载可执行文件和共享库时基址都随机目的是让攻击者无法预测地址。做布局观察时ASLR 确实会干扰对照。有两种处理方式临时关闭仅限本机调试环境echo 0 | sudo tee /proc/sys/kernel/randomize_va_space或者不动系统配置用setarch只影响单个进程setarch $(uname -m) -R ./layout关闭之后你会发现代码和数据段的地址固定下来了每次运行都一样。注意这只是调试手段生产环境一定不要关。另外一个观察技巧是看相对地址而不是绝对地址。把打印出来的值两两相减你会发现段与段之间的间距是稳定的即使基址随机变化。这个相对关系才是程序布局中真正有意义的部分。6.4 我实际踩过的几个坑第一个坑在-O2下调试内存布局。之前为了确认某个局部数组的越界范围我在优化版本上反复打印地址结果发现相邻变量的排列和源码顺序毫无关系白白浪费了一个下午。教训是凡是涉及地址和布局的实验统统-O0 -g别偷懒。第二个坑Windows 和 Linux 栈大小的差异。一个深度递归的解析函数在 Linux 上跑得好好的移植到 Windows 上几百毫秒就崩了。查了半天是栈太小默认只有 1MB。这类问题的排查思路应该是崩溃位置在递归深处、崩溃地址贴着栈区上边界、函数本身没有堆操作——三条同时成立基本可以判定是栈溢出。当时的修法是把递归改成显式栈的迭代实现顺带把深度限制做成了可配置项。第三个坑以为std::vector的reserve能解决所有扩容问题。实际上reserve只减少重新分配的次数元素本身的构造析构开销一点没少。真正要控制内存峰值还得看对象是不是该用emplace_back原地构造、是不是该换成deque、是不是该分批处理。堆的管理永远不只是调一个函数那么简单。第四个坑也是最值得说的一个把内存没释放和内存泄漏混为一谈。有些程序 RSS 一直涨但 valgrind 报告零泄漏这不是 bug而是分配器把释放的内存留在了自己的池子里没有还给操作系统。glibc 的malloc_trim(0)可以手动触发归还或者通过M_TRIM_THRESHOLD调整归还阈值。搞清楚泄漏和驻留的区别能避免很多无效排查。我个人在这些年的调试经验里总结下来排查内存问题最有效的三个工具排序是AddressSanitizer、core dump gdb、valgrind。ASan 适合开发阶段直接在 CI 里开起来能把绝大多数问题挡在合并之前core dump 适合线上不依赖任何额外工具链valgrind 适合深入分析但速度慢通常只在小规模的复现用例上跑。至于内存分区的知识它不直接帮你修 bug但能让你在看到一个地址的时候立刻知道它应该属于哪块地盘、为什么会在那个位置、以及接下来该往哪个方向查。这种看到地址就有直觉的能力才是这几年里我觉得最值钱的东西。
返回列表