ARTICLE DETAIL

资讯详情

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

C++进程内存布局详解:代码区、数据段、BSS、堆区与栈区

C++进程内存布局详解:代码区、数据段、BSS、堆区与栈区 1. 进程跑起来之后内存被切成了几块刚工作那会儿我接了个接手别人的项目程序跑着跑着突然就 Segmentation faultgdb 里 bt 一敲栈回溯全是问号。折腾了整整一个下午才发现问题出在一个返回了局部数组地址的函数上。那是我第一次真切意识到C 里堆区、栈区、数据段、bss段、代码区这几个概念不是面试八股而是决定你半夜能不能睡着觉的东西。你写下的每一行代码最终都会落到进程某一段特定的内存里落在哪一段决定了它的生命周期、可见性也决定了你踩坑时的排查方向。这篇文章我想聊的是一个 C 程序编译链接成可执行文件、被加载进内存、跑起来之后虚拟地址空间到底是怎么被切分的堆区和栈区分别管什么数据段和 bss段 的区别到底在哪为什么要有两个段代码区是不是真的只读这些问题看着基础但真要讲透得从链接器、加载器、函数调用约定一路讲下来。不管你是刚学 C 的新手还是写了几年代码但一直没系统梳理过内存布局的老手看完应该都能把脑子里那张模糊的图补完整。我会尽量少讲空话多给能上手验证的命令和代码你可以边看边敲。1.1 一张楼层图帮你建立整体印象先不谈细节我把 Linux x86-64 下一个典型进程的虚拟地址空间从高地址到低地址大致描一遍你脑子里先有个楼层图。最顶上靠近 0x7fff 开头那片区域是栈区函数调用时压栈、返回时弹栈它从高地址往低地址长。往下一大片留白是 mmap 区域动态库、大块 malloc 常用的就是这里。再往下是堆区从低地址往高地址长和栈区遥遥相对中间那段空隙就是它们各自扩张的空间。再往下就是可执行文件映射进来的部分依次是bss段、数据段、只读数据段、代码区地址最低。这个顺序不是随便定的是链接脚本linker script规定的。你可以把它理解成一栋楼地下室里放着图纸和固定不动的东西代码区、只读数据、数据段、bss段一楼大厅是堆越往上盖越高的楼层是栈。地下室的布局在链接时就定死了一楼大厅的大小运行期才变顶楼栈的高度也是运行期才变。真正灵活的只有堆和栈这两块其余几段基本在程序启动时就确定了大小。值得强调一点这里说的所有地址都是虚拟地址不是物理内存地址。每个进程都以为自己独占一整块连续的地址空间实际上操作系统用页表把它们映射到物理内存的不同页上。理解这一点很关键后面讲 bss段为什么可以不占文件空间、讲堆和栈为什么能相向而行都要靠虚拟内存这层机制撑着。你要是把这层忘了很多现象就解释不通。1.2 为什么要分区从链接器和加载器说起很多人会问搞这么复杂干嘛全放一块内存里不行吗答案是不行因为不同数据的属性不一样。代码区要求只读、可被多个进程共享这样多个实例跑同一个程序时物理内存里只需要存一份指令。数据段和 bss段 要求可读可写每个进程一份独立的副本因为它们存的是全局状态A 进程改了不能影响 B 进程。栈要求每个线程独立因为每个线程的执行流不一样。链接器的活儿就是把这些属性相同的东西归到同一个 section 里比如.text放机器指令.rodata放只读常量.data放已初始化的可写数据.bss放未初始化的可写数据先占个位加载时清零。加载器再把这些 section 按照程序头表program header的指示映射到进程地址空间同时设置好对应的页权限代码段映射成r-x可读可执行不可写数据段映射成rw-可读写不可执行。这样划分带来的直接好处有两个。第一是安全改代码段会直接触发段错误攻击者想注入指令就没那么容易数据段不可执行也拦住了一部分攻击手段。第二是内存效率共享库的代码段在物理内存里只有一份几十个进程用它也只占一份而它们各自的数据段是分开的。你在命令行敲cat /proc/self/maps就能看到这些权限位最右边那几列r-xp、rw-p就是它。2. 代码区和只读数据程序里最死的那部分2.1 代码区到底放了什么代码区对应节名.text放的是编译器把源码翻译成的机器指令。它有几个非常鲜明的特性只读、可执行、可共享。你在程序里写int a 1 2;编译优化后可能连指令都不剩直接算出 3你写一个循环循环体的那些指令就躺在代码区里CPU 靠程序计数器PCx86-64 上是 rip一条条取出来执行。代码区在运行期是大小不变的。因为编译链接时所有函数的指令都已经生成好了运行时不会有新的机器码冒出来JIT 那种把数据当代码跑的情况属于特例也要先把内存改成可执行权限。函数指针指向的地址就是代码区里的地址。你打印一个函数名得到的值大概在 0x55 或 0x56 开头的区间PIE 开启时或者 0x40xxxx 这样的固定地址未开 PIE都属于代码区。有一类东西值得单独拎出来说函数指针和虚函数表。虚函数表vtable本身是编译器生成的只读数据结构通常被放进.data.rel.ro重定位后只读或者.rodata段对象的虚表指针则存在于对象自身所在的内存里——对象在栈上虚表指针就在栈上对象在堆上指针就在堆上。所以同一句话虚函数存在哪答案得分两层看别混为一谈。2.2 字符串字面量为什么改了就崩这是新手最常撞的墙之一。看这段代码#include cstdio int main() { char* p hello; // 字面量存在 .rodata p[0] H; // 运行到这里直接崩 printf(%s\n, p); return 0; }编译能过运行时给你一个 Segmentation fault。原因就是hello这个字符串字面量被放进了只读数据段.rodata页权限是只读的你写它就触发了保护异常。正确的写法是char p[] hello;这样会在栈上开一个 6 字节的数组含结尾的\0把字面量内容拷贝进去这时你改 p 才合法。.rodata里除了字符串字面量通常还放const修饰的全局常量、switch 的跳转表、浮点常量的位模式等等。注意一个常见误解hello的类型在 C 里是const char[6]在 C 里是char[6]历史遗留所以 C 里char* p hello不报警告但一样会崩。C11 之后这个区别基本被堵死了编译器会直接拦住把字面量赋给非 const 指针的写法。注意sizeof(hello)是 6 不是 5别漏掉结尾的\0。用字符串长度算数组大小时忘了加一是缓冲区溢出的经典来源。2.3 亲手看一眼代码区的反汇编光说没用动手看一眼最有感觉。写个最简程序// text_demo.cpp int add(int a, int b) { return a b; } int main() { int r add(3, 4); return r; }编译并反汇编g -O0 -c text_demo.cpp -o text_demo.o objdump -d text_demo.o | head -40你会看到add和main两个符号下面跟着一串十六进制的机器码和对应的汇编指令地址从 0 往上排。这就是代码区在目标文件里的雏形。链接成可执行文件后这些指令会被统一搬到最终的.text段里。想更直观一点用readelf看段表g -o text_demo text_demo.cpp readelf -S text_demo | grep -E text|rodata|data|bss输出的Flg列里.text会带AX可分配、可执行.rodata带A只读.data和.bss带WA可写、可分配。这几组权限位就是前面说的属性相同的东西放一起的落地结果。你在纸上把这张表画一遍比看十遍文字都管用。3. 数据段与 bss段全局变量的两个仓库3.1 .data被赋了初值的全局/静态变量数据段.data存的是已经初始化且初值不为零的全局变量、静态变量、静态成员变量。比如int g_a 10; // .data static int g_b 20; // .data class C { static int s; }; int C::s 30; // .data这些变量在可执行文件里是真实占用空间的因为它们的初值必须被保存下来程序启动时加载器要把这些初值从文件里读出来写进内存。你打开可执行文件.data段是有实打实字节的值就是你写的那几个数。为什么叫数据段而不是别的名字历史原因早期汇编里.data就是存放数据的段。这里要顺带纠正一个常见混淆内存里的数据段和网络里的数据包数据帧比特流完全是两码事。后者是通信协议层面的概念前者是进程内存布局的概念名字撞车而已别在脑子里把它们搅到一起。同理数据段也不是一段数据这种泛泛的说法它特指可执行文件里那个可写的、有初值的 section。.data有一个容易被忽略的特性它是每个进程独立的。哪怕你用fork创建子进程写时复制copy-on-write机制也会在子进程修改这些变量时为它单独复制一份物理页。所以父子进程各自的全局变量互不影响这是进程隔离的基础。3.2 .bss为什么专门给零开一个段bss 全称是 Block Started by Symbol源自早期汇编器的一条伪指令。它存的是未初始化或者初值为零的全局/静态变量int g_x; // .bss int g_y 0; // .bss初值为零也归这里 static int g_z; // .bss char g_buf[4096]; // .bss关键问题来了既然.data存有初值的变量那.bss里全是零是不是可以不用存答案是对确实不用存。因为如果全零加载器只要在内存里找一块区域用mmap匿名映射出来内核会自动帮你填零这是安全要求防止读到别的进程残留数据根本不需要从可执行文件里读任何字节。所以.bss段在可执行文件里只记录大小不占文件空间。这就是.data和.bss最本质的区别一个占文件空间一个不占。这个区别不是设计者拍脑袋定的而是省空间省出来的智慧。你想想一个程序声明char buffer[10 * 1024 * 1024];如果放在.data可执行文件直接胖 10MB放在.bss文件大小几乎不变加载时内核给块清零的页就行。链接器把这个优化做到极致是很有道理的。提示现代链接器有时会把显式初始化为零的变量也优化进.bss所以int g 0;和int g;在大小上通常没区别。但语义上二者不同前者是初始化过的后者是未初始化的在涉及静态初始化顺序时会有微妙差别。3.3 一个1MB的数组实验看懂文件大小的玄机空口无凭我们做个小实验用文件大小说话。准备两个源文件// big_bss.cpp char buf[1024 * 1024]; // 未初始化1MB int main() { return buf[0]; }// big_data.cpp char buf[1024 * 1024] {1}; // 显式初始化1MB int main() { return buf[0]; }分别编译然后看文件大小和段大小g -o big_bss big_bss.cpp g -o big_data big_data.cpp ls -l big_bss big_data size big_bss big_data典型的输出会是这样big_bss只有十几 KBbig_data超过 1MB。size命令输出的第一列是text第二列data第三列bss。你会看到big_bss的bss那列是 1048576 左右而data很小big_data反过来data那列涨上去了bss很小。这就把bss 不占文件空间实锤了。这里有个细节值得提醒char buf[1024*1024] {1};只把第一个元素赋成 1其余还是 0但整个数组会被归进.data因为它是显式初始化的。所以哪怕只有一个字节非零整个 1MB 都得老老实实写进文件。这是个很实在的教训——大缓冲区尽量别显式初始化除非你真需要非零初值。我在一个项目里就因为把一个static char table[256*1024] {0};写成了显式初始化白白胖胖了 256KB后来改成不初始化bss 自动清零文件立刻瘦身。3.4 静态局部变量到底算哪一段静态局部变量是个特殊物种很多人搞不清它到底在栈上还是数据段。看这段void f() { static int counter 0; // 不在栈上 static int inited 42; // 也不在栈上 counter; }counter和inited都是静态存储期它们不在栈上而是和数据段的全局变量一样inited进.datacounter初值为零进.bss。它们的生命周期是整个程序运行期只是作用域被限制在函数内部而已。每次调用f()counter用的都是同一份内存所以它能累加。那初始化只执行一次是怎么实现的C11 之后标准要求静态局部变量的初始化是线程安全的。编译器通常会在.bss或.data里额外放一个隐藏的 guard 变量类似__cxa_guard_acquire机制第一次进入函数时检查 guard没初始化就初始化并置位之后再进来直接跳过。所以你看到的static int x expensive_init();那个expensive_init()只会跑一次哪怕多线程同时调用f()也不会跑两遍。这个机制有轻微开销高频调用的函数里值得注意。常见坑静态局部变量如果初始值依赖另一个全局变量的值而那个全局变量在别的编译单元里、初始化顺序靠后就可能读到未初始化的值。这就是著名的静态初始化顺序问题跨编译单元的全局对象初始化顺序是未定义的别让它们互相依赖。4. 栈区函数调用最繁忙的舞台4.1 栈帧长什么样栈为什么向下长栈区是函数调用的主战场。每次调用一个函数就会在栈上压入一个栈帧stack frame里面装着这个函数的参数、返回地址、保存的寄存器、局部变量和临时对象。函数返回整个栈帧被弹掉内存自动回收——这就是为什么局部变量不需要手动释放。x86-64 的栈是向低地址增长的。栈顶指针rsp始终指向当前栈的顶部最低地址调用函数时先减rsp腾空间返回时加rsp收回。一个典型的栈帧从高地址到低地址长这样调用者的栈帧、传入的参数超过 6 个的部分、返回地址、旧的rbp、保存的寄存器、局部变量、临时空间。注意参数和返回地址是调用者帮忙压进去的局部变量是被调用者自己开的。x86-64 的调用约定用寄存器传前 6 个整型/指针参数rdi、rsi、rdx、rcx、r8、r9浮点参数用xmm0到xmm7。超出的部分才压栈。返回值放rax整型/指针或xmm0浮点。你在 gdb 里info registers看到的就是这套东西。理解调用约定对读反汇编、排查崩溃很有帮助——比如你发现崩在某个读rdi的地方大概率是参数传了个野指针。栈帧的具体布局用objdump -d反汇编一个简单函数就能看清楚。函数开头通常有push %rbp; mov %rsp,%rbp; sub $0x10,%rsp这三板斧分别对应保存旧帧指针、建立新帧、给局部变量腾空间。结尾是leave; ret或者mov %rbp,%rsp; pop %rbp; ret。看熟了这套模板你读任何 C 函数的反汇编都会快很多。4.2 栈大小能改吗溢出了怎么办Linux 下每个线程的默认栈大小一般是8MB可以用ulimit -s查看单位是 KB默认输出 8192。线程创建时也可以单独指定栈大小pthread_attr_setstacksize就是干这个的。栈大小定了之后函数调用的嵌套深度、局部变量占用空间都受它限制一旦越过边界就会撞到别的内存区域触发段错误。最常见的栈溢出场景是无限递归和超大局部数组。比如void boom() { char buf[1 20]; // 每次调用吃 1MB 栈 boom(); // 无限递归很快就炸 }跑起来会直接 Segmentation fault因为十来次调用就把 8MB 栈吃穿了。诊断这种问题的思路看崩溃时的栈回溯gdb 的bt如果发现同一函数重复出现了成百上千次那八成是递归没有终止条件或者递归深度远超预期。栈大小是可以调的。临时调大用ulimit -s 65536改成 64MB但要注意这是针对当前 shell 及其子进程的不是永久生效。永久修改要写进/etc/security/limits.conf。不过我不太建议靠调大栈来解决问题更靠谱的做法是把大数组放到堆上std::vector或new或者把递归改成迭代。栈空间本来就该留给短生命周期的小对象把它当仓库用是设计问题。实操心得局部数组最好别超过几 KB超过 64KB 就该警惕了。我在一个图像处理项目里见过有人写unsigned char line[1920*1080]单帧 2MB函数一嵌套就崩。后来改成std::vectorunsigned char放堆上问题立消。4.3 返回局部变量指针这个经典坑这是文章开头我自己踩过的那个坑值得单独说。看代码int* bad() { int x 42; return x; // 危险x 在栈上函数返回后栈帧就没了 } char* bad2() { char buf[64]; strcpy(buf, hello); return buf; // 同样危险 }函数返回后x和buf所在的栈帧被弹出那块内存随时可能被下一次函数调用覆盖。你拿到的指针看着还有值但这只是内存还没被擦掉的假象可能下一次调用一进来数据就变了。这种 bug 最恶心的地方是它有时能跑对有时崩有时结果莫名奇妙取决于编译器的优化级别和调用序列调试起来极其痛苦。正确处理方式有三种一是在栈上开在调用者把指针作为参数传进去让被调函数填二是用堆内存new/malloc并明确所有权三是返回值语义std::string、std::vector让容器自己管理。现代 C 里返回值语义和移动语义已经非常高效返回std::string并不会真的拷贝一大坨别因为怕拷贝就返回裸指针。提示编译器通常会给return x;一个警告-Wreturn-local-addr把这个警告打开并当错误处理能帮你挡掉一大半这类 bug。我习惯在编译选项里加-Wall -Wextra -Werror让警告直接拦住编译。5. 堆区灵活的背后是一堆麻烦5.1 从malloc说起堆内存是怎么批发来的堆区是给程序动态分配内存用的malloc、calloc、realloc、new拿到的内存都在这里。堆从低地址向高地址增长但增长方式不是要多少给多少而是内存分配器allocator向操作系统批发一大块然后零售给程序。glibc 用的分配器是 ptmalloc它维护着一堆空闲链表bin小的内存块从空闲链表里切大的内存块通过brk或mmap向内核要。一个常被问到的分界线是128KB默认的M_MMAP_THRESHOLD。小于这个值的分配通常走brk扩展堆顶从堆里切大于这个值的分配直接mmap一块独立的内存free时直接还给内核。这个阈值是可以调的mallopt能改。为什么要分这两条路因为brk扩展的堆内存只有在堆顶的空闲块被释放时才能缩小容易产生顶层块被卡住的问题而大块内存用mmap独立管理还起来更干脆。每次malloc拿到的内存块前面分配器会藏一个头部chunk header记录这个块的大小和状态标志。所以你malloc(24)实际占用的内存不止 24 字节还有头部和可能的对齐填充。malloc_usable_size能看到实际可用大小它会比你申请的大一点。这也解释了为什么频繁申请释放小对象会浪费内存——头部开销占比太高。5.2 碎片、泄漏、悬空三个最常见的翻车现场内存泄漏最容易理解new了不deletemalloc了不free程序跑久了内存一直涨。排查思路是用工具valgrind --leak-checkfull能列出每一处泄漏的调用栈或者用-fsanitizeaddressASan在运行时检测。自己用top看 RSS 一直涨也是个粗筛办法但不够精确因为可能只是正常的缓存增长。内存碎片分两种。内部碎片是分配器给的块比你要的大剩下的空间浪费掉了外部碎片是空闲内存总量够但没有一块连续的大空间能满足你的申请。外部碎片在长期运行的服务里特别明显典型症状是内存没泄漏但占用一直不降新申请还可能失败。缓解办法包括用内存池、对象池或者换用 jemalloc、tcmalloc 这类针对碎片优化的分配器。悬空指针dangling pointer是free之后继续用那块内存。比如int* p new int(5); delete p; *p 10; // 悬空指针未定义行为这块内存可能已经被分配给别人了你这一写就破坏了别人的数据也可能分配器已经把头部信息写进去你一写就把空闲链表搞乱下次new时崩在莫名其妙的地方。更恶心的是double free同一块内存释放两次分配器的元数据被破坏报错信息通常是free(): double free detected或者malloc(): corrupted top size。还有一种破坏堆的经典操作是数组越界写。int* a new int[4]; a[4] 1;写到了数组外的 chunk 头部等下次free检查头部时发现 size 字段不对直接 abort。这类问题的报错位置和真正出错的位置往往差很远所以定位时要有耐心用 ASan 能精确定位到越界那一行。注意free之后把指针置为nullptrdelete也一样是个好习惯能挡住一部分重复释放和悬空访问。但置空不能解决所有问题多个指针指向同一块内存时置空一个不够用根本上还是要理清所有权。5.3 一段代码看清堆上数据的生命周期#include cstdio #include cstring struct Node { int value; Node* next; }; int main() { Node* head new Node{1, nullptr}; // 堆上 head-next new Node{2, nullptr}; Node* p head; while (p) { printf(%d\n, p-value); p p-next; } // 释放 while (head) { Node* tmp head; head head-next; delete tmp; } return 0; }这段代码创建了一个简单的堆上链表。head本身是栈变量一个指针8 字节它指向的Node对象是堆内存。链表节点之间的连接关系存在堆里靠指针串起来。释放的时候要一个个delete因为堆内存不会自动回收。如果你写Node a{1, nullptr};放栈上那a的作用域一结束就自动销毁但你也拿不到它栈变量的生命周期和它的作用域严格绑定。一个很能说明问题的对比栈上数组int arr[100];出了作用域自动回收快堆上数组int* arr new int[100];需要手动delete[]慢一点但灵活能在函数间传递所有权。选哪个看生命周期需求不是看哪个高级。栈上对象在性能上通常更优因为分配就是移动rsp释放就是移动回去几乎没有开销堆分配要走分配器的逻辑还可能有系统调用慢得多。6. 把内存布局看出来命令行工具实战6.1 readelf、size、nm三件套光靠记忆容易记混用工具把可执行文件的段结构打出来最直观。先写一个覆盖各种数据类型的样例// seg_demo.cpp #include cstdio int g_init 10; // .data int g_zero 0; // .bss int g_uninit; // .bss static int s_init 20; // .data static int s_uninit; // .bss const int c_global 30; // 一般进 .rodata const char* msg hi; // 指针在 .data字符串在 .rodata int main() { int local 1; // 栈 int* heap new int(2); // 堆 printf(%d %d\n, local, *heap); delete heap; return 0; }依次跑这几条命令g -o seg_demo seg_demo.cpp size seg_demo readelf -S seg_demo | grep -E Name|text|rodata|data|bss nm -C seg_demo | grep -E g_init|g_zero|g_uninit|s_init|s_uninit|c_global|msgsize给你各段的总字节数readelf -S给你每个 section 的地址、偏移、大小和权限标志nm给你每个符号落在哪个段D表示 .dataB表示 .bssR表示 .rodataT表示 .textU表示未定义/外部引用。把这三样结合起来你就能精确知道每个变量住在哪。nm的输出很值得反复看。比如g_init会显示D g_initg_zero和g_uninit显示B g_zeroc_global显示R c_globalmain显示T main。至于local和heap指向的对象它们根本没有符号因为在编译期就不确定地址nm自然看不到。这正是栈和堆与前三段的本质区别——地址在运行期才定。6.2 /proc/self/maps进程视角的真实地图前面看的都是文件里的静态布局想看运行期真实的内存映射得看/proc/pid/maps。最简单的办法是在程序里读自己#include fstream #include iostream #include string int main() { std::ifstream f(/proc/self/maps); std::string line; while (std::getline(f, line)) { std::cout line \n; } return 0; }运行后会看到一堆行每行格式是起始地址-结束地址 权限 偏移 设备 inode 路径。你会看到带可执行权限r-xp的几行主程序代码段、动态库代码段、vdso。带rw-p的行数据段、bss、堆。标注[heap]的那行就是堆区地址从低往高。标注[stack]的那行就是主线程栈地址在很高位0x7fff 附近。还有[vdso]、[vvar]这些内核相关映射。这份 maps 是排查内存问题最有用的东西之一。看到内存异常增长时数一数有多少个匿名rw-p映射如果数量很多可能是大量mmap分配没释放看到[heap]那行一直涨可能是堆上有泄漏。把 maps 抓下来做差集对比程序启动时抓一次运行一段时间再抓一次增长的部分就是嫌疑对象。提示/proc/self/smaps比maps更详细会给出每块映射的实际物理内存占用Rss、脏页、是否共享等。排查内存占用高的问题时看smaps比看maps准。几十个进程的smaps加起来略慢但值得。7. 排查实录那些年我踩过的内存坑7.1 常见问题速查表下面这张表是我这些年遇到频率最高的一些症状和对应思路可以当成随身速查表用。报错/症状大概率原因排查方向Segmentation fault出现在写字符串字面量改了.rodata的内容检查是否对...或const全局量赋值free(): invalid pointerfree的指针不是malloc返回的或已被修改检查指针有没有发生过偏移、有没有被写坏free(): double free detected同一块内存释放两次用 ASan看两次free的调用栈malloc(): corrupted top size数组越界写破坏了堆 chunk 头部用 ASan定位越界那一行函数返回指针调用方偶发读到乱码返回了局部变量/局部数组地址检查是否有-Wreturn-local-addr警告程序 RSS 一直涨不降内存泄漏或碎片严重用valgrind或 ASan 查泄漏看 maps 变化大数组的全局变量导致可执行文件巨大显式初始化塞进了.data去掉初始化让它进.bss栈溢出崩溃无限递归或超大局部数组bt看是否有重复栈帧调大栈或改堆分配表格里的每一条我都至少踩过一次。印象最深的是堆 chunk 头部被写坏这类报错位置和真实越界位置可能隔着几十行代码第一次遇到时我盯着free那一行看了半天完全想不通它为什么错。后来学会用 ASan一行就定位到了越界写入那种原来如此的感觉特别爽。7.2 ASan和valgrind两种定位思路ASanAddressSanitizer是编译期插桩的方案编译时加-fsanitizeaddress -g运行时会拦截每一次内存访问越界、使用已释放内存、栈溢出都能精确报出来还会给出一段内存映射的示意图告诉你哪块是红区、哪块是合法内存。它的优点是快大概拖慢 2 倍左右缺点是必须重新编译。g -fsanitizeaddress -g -o seg_demo_asan seg_demo.cpp ./seg_demo_asanvalgrind 是运行时动态翻译的方案不用重新编译直接跑现成二进制。它对内存泄漏的检测非常细致会告诉你哪块内存是谁申请的、大小多少、在哪里泄露的。代价是慢通常慢 10 到 30 倍适合对着小规模重现用例跑。valgrind --leak-checkfull --show-leak-kindsall ./seg_demo两种工具我都用。开发阶段自己写代码优先开 ASan因为它编进去就一直生效随手就能发现问题拿到别人的二进制或者线上抓下来的 core用 valgrind 更方便。补充一个体验ASan 对栈溢出的检测需要额外开-fsanitizeaddress配合detect_stack_use_after_return默认不一定全开可以查文档按需配置。8. 几个绕不开的经典疑问8.1 const全局变量到底在哪一段这个问题没有一句话答案取决于链接属性和初始化形式。先说结论const int a 10; // 通常 .rodataC 内部链接 extern const int b 20; // 通常 .data 或 .rodata取决于实现 const char* const p x; // 指针本身可能在 .data.rel.ro在 C 里命名空间作用域的const变量默认是内部链接相当于加了static编译器知道它的值且在编译期可见时可能不分配内存直接把用到的地方替换成常量连段都不用进。如果你取了它的地址它就得有实体通常放.rodata。加了extern之后变成外部链接多个编译单元可能引用同一个链接器会把它放到某个可写或只读段具体位置看编译器实现用nm一看便知。在 C 语言里规则不一样文件作用域的const默认是外部链接行为和普通全局变量接近一般进.rodata如果没被优化掉。这也是为什么很多人从 C 转到 C 后发现 const 的行为变了。实际写代码时别去猜nm和readelf一看就清楚。8.2 全局变量初始化顺序为什么会出问题全局对象的构造函数在main之前执行析构函数在main之后执行更准确地说在exit流程里。同一个编译单元内的全局对象初始化顺序按定义顺序来但不同编译单元之间初始化顺序是未定义的。这就导致一个经典问题// a.cpp extern std::vectorint g_vec; struct Init { Init() { g_vec.push_back(1); } }; Init g_init; // 构造时 g_vec 可能还没构造 // b.cpp std::vectorint g_vec; // 可能在 g_init 之后才构造如果b.cpp里的g_vec在a.cpp的g_init之后才构造g_init的构造函数里访问g_vec就是访问未初始化对象行为未定义可能崩可能静默出错。这种 bug 特别难查因为绝大多数时候顺序是对的偶尔换个编译顺序就出问题。规避办法有几种用函数内静态局部变量C11 保证首次调用时初始化天然有序或者把全局对象改成返回引用的函数就是所谓的 Meyers Singleton或者彻底避免全局可变状态。我在实际项目里偏向最后一种全局对象越少这类问题越少。8.3 栈和堆到底谁快从分配开销看栈快得多分配栈内存就是移动rsp一条指令释放也是堆分配要走分配器的链表查找、可能的系统调用慢几十上百倍很正常。从访问速度看两者都是内存访问理论上差不多但栈内存的局部性更好函数频繁访问的局部变量挤在一起缓存命中率高堆内存分布零散缓存不友好的概率更大。但这不意味着堆就是慢能不用就不用。堆给你的是灵活的生命周期和大小可变的容器这是栈给不了的。栈空间有限默认 8MB生命周期和作用域绑定跨函数传递大对象不合适。选哪个应该基于需求短生命周期的小对象放栈生命周期跨越函数、大小运行期才定的放堆。现代 C 里std::vector、std::string、智能指针把堆管理得挺好你不需要为了性能刻意回避堆。我自己的经验是先保证正确再谈性能。我见过太多人为了避免堆分配写出返回局部变量的代码结果程序崩溃最后改成堆分配后既正确又稳定。性能优化要先用 profiler 找到真正的热点很多地方你以为的瓶颈其实根本不是瓶颈。最后分享一个我自己一直保持的小习惯每写一个涉及指针和动态内存的函数都在纸上或注释里画一下谁申请、谁释放、生命周期到哪结束。这个小动作帮我避免了很多悬空指针和泄漏。内存布局这些概念看一遍记不住真正让你记住的是你亲手用readelf查过一次段、用 ASan 抓到过一次越界、用 gdb 看过一次栈回溯。建议你拿自己手头的一个小项目练一遍把size、nm、/proc/self/maps都跑一次比读十篇文章都管用。要是你正在啃 C 基础把这几段内存的概念和new/delete、智能指针、移动语义串起来理解会发现很多东西是一根线串着的。
返回列表