从内存区域划分(.text / .rodata / .data / .bss / 栈 / 堆)讲到构建四阶段(预处理 → 编译 → 汇编 → 链接),再到静态库与动态库的创建、链接行为差异,末尾汇总易错点。

一、内存布局在构建阶段就确定

Determined at Build Time:.text / .rodata / .data / .bss 的内存布局、大小在编译链接构建阶段就确定,不是程序跑起来才决定。这几段都属于 static lifetime 静态生命周期,由编译器自动管理分配与释放,程序员不能手动干预申请释放。

按数据类型、生命周期和作用域,C/C++ 程序的内存通常划分为五大区域:代码区、全局/静态存储区、栈区、堆区、常量区。其中代码区对应 .text,常量区对应 .rodata,全局/静态存储区对应 .data 与 .bss,栈区与堆区则在运行时分配。

1. .text 代码段(文本段 / 代码区)

  • 存储内容:编译之后的机器指令(machine code),即写出的函数、if/for 循环、main 函数等翻译成 CPU 能执行的指令。所有的函数定义、控制结构都会被转换成机器指令存放在这里。
  • 大小:Fixed size,构建阶段确定大小。
  • 权限:Read-only 只读,防止运行时篡改程序指令。这部分内存是共享的,在程序执行期间不会改变。
  • 生命周期:静态生命周期,进程整个运行期间都存在,进程退出由操作系统回收。
  • 管理:完全由编译器、链接器自动管理,程序员没有 API 手动分配释放本段内存。

编译时,main、func 等函数的代码被转换成机器指令存放在可执行文件的代码段中(此时还在磁盘上的可执行文件里);程序运行时,操作系统把这些指令加载到内存的代码段,CPU 再从内存的代码段中读取并执行。

2. .rodata 只读数据段(常量区)

  • 存储内容:初始化后的常量,比如字符串字面量 "hello world"、const char *p = "abc" 这类常量字符串,以及全局 const 常量。
  • 权限:只读 Read-only,程序运行不能修改,写操作会触发段错误。这部分内存同样通常在程序执行期间不会改变。

区分:const int a = 10; 声明在全局放 rodata;局部 const 变量本质还是在栈上。

用双引号括起来的字符串字面量通常存储在常量区;const 声明的全局变量如果值在编译期就已确定,也可能存储在常量区。直接访问常量区内存地址并不是标准 C++ 的做法——字符串常量是只读的,其存储位置取决于编译器和操作系统的实现。

3. .data 数据段(Non-zero initialized statics / globals)

存放非 0 初始化的全局变量、静态 static 变量:

int g_a = 100;       // 存放在 .data
static int s_b = 20; // 存放在 .data
  • 读写权限:Read / write 可读可写,运行时可以修改变量值。
  • 时机:变量大小在编译链接构建阶段确定,程序运行时直接可用。

只针对有初始值且不是 0 的全局、static 静态变量。

4. .bss 段(Uninitialized statics / globals)

存放未初始化全局变量、未初始化 static 静态局部变量:

int g_c;         // .bss
static int s_d;  // .bss
  • 特性:Zero-initialized。可执行文件磁盘上不占用实际存储,程序加载到内存的时候操作系统自动把这一块全部清零。
  • 读写权限:可读可写。

磁盘 exe 体积不会把 bss 的空间算进去,加载进内存才开辟对应内存并置 0。

5. 全局 / 静态存储区的共同特征

  • 存储全局变量和静态变量,这些变量在程序的整个运行期间都存在,但可见性和生命周期取决于声明它们的作用域。
  • 全局变量(函数外部声明的变量)和静态变量(用 static 声明的变量,无论写在函数内部还是外部)都落在这个区域。
  • 静态局部变量虽然声明在函数内部,同样存储在此区域,且只在第一次调用时初始化。

6. stack 栈内存

  • 存储内容:函数内部局部变量(不含静态变量)、函数形参、函数返回地址、函数调用帧,遵循后进先出 LIFO。函数被调用时其参数和局部变量被推入栈中,函数返回时从栈中弹出,占用的内存随之释放。
  • 大小:Fixed size,固定大小,操作系统给每个进程栈设置上限。
  • 读写权限:可读可写。
  • 分配释放:编译器自动管理,函数进入自动压栈分配,函数退出自动弹栈回收。程序员不能手动 malloc/free 栈。
  • ⚠️ 递归坑:递归调用必须要有终止条件,否则无限递归不断往栈压入栈帧,栈空间耗尽 → Stack overflow 栈溢出,程序直接崩溃。
  • 现代系统特性:栈会做保护机制,以空间换取安全性,降低程序出错的复杂度。

7. heap 堆内存

  • C 语言:malloc() / free();C++ 语言:new / delete。
  • 存储:程序员手动申请的动态内存。用 new 分配的对象或数组,内存来自堆区。
  • 分配释放:完全由程序员手动管理,申请后不 free 会内存泄漏;free 多次会堆破坏。
  • 大小:灵活,没有栈那样的硬性固定上限(受操作系统总内存限制)。
  • 读写权限:可读可写。
  • 实际项目中,比手写 new/delete 更复杂的内存管理策略(如智能指针)往往更合适。

现代 32 位操作系统:ASLR 地址随机化,同一个可执行程序每一次运行,加载到虚拟内存的位置都不一样,用来对抗漏洞攻击。

8. 各区域对应的代码示例

#include <iostream>
#include <cstring>  // 用于 strlen

// 全局变量,存储在全局/静态存储区
int globalVar = 10;

// 静态变量,也存储在全局/静态存储区,但仅在其声明的文件或函数内部可见
static int staticVar = 20;

void func() {
    // 局部变量,存储在栈区
    int localVar = 30;

    // 静态局部变量,虽然声明在函数内部,但存储在全局/静态存储区,且只在第一次调用时初始化
    static int staticLocalVar = 40;

    std::cout << "Inside func:" << std::endl;
    std::cout << "localVar = " << localVar << std::endl;
    std::cout << "staticLocalVar = " << staticLocalVar << std::endl;

    // 通过动态内存分配在堆区分配内存
    int* heapVar = new int(50);
    std::cout << "heapVar = " << *heapVar << std::endl;

    // 释放堆区内存(实际使用中不要忘记释放不再使用的堆内存)
    delete heapVar;
}

int main() {
    std::cout << "Inside main:" << std::endl;
    std::cout << "globalVar = " << globalVar << std::endl;
    std::cout << "staticVar = " << staticVar << std::endl;

    // 调用函数,展示栈区和堆区的使用
    func();

    // 字符串常量通常存储在常量区
    const char* strConst = "Hello, World!";
    // 不要尝试修改 strConst 指向的内容,它是只读的
    std::cout << "strConst = " << strConst << std::endl;
    // 获取字符串常量的长度(这不会修改常量区的内容)
    std::cout << "Length of strConst = " << strlen(strConst) << std::endl;

    return 0;
}

二、程序完整生命周期:构建阶段 + 加载运行阶段

整体流程:

源码文件 → 预处理 → 编译 → 汇编 → 链接(生成可执行 exe / 库)
        → 操作系统加载器 Loader 加载程序进内存 → 运行 main 函数

过程中生成的中间文件:.i(预处理文件)、.s(汇编文件)、.o(目标文件 / 对象文件)。

阶段① 预处理 preprocess

  • 输入:.c / .cpp 源码。
  • 处理:预处理器(通常是 cpp)读取源代码文件,进行宏展开、条件编译、文件包含(#include)等处理,并删除注释。
  • 输出:预处理后的文本文件,通常具有 .i 后缀(这一步并非所有编译器都自动生成 .i 文件,可能需要特定选项)。
gcc -E main.c -o main.i

阶段② 编译(狭义编译)

  • 输入:预处理后的文件(如果有的话,否则直接是源代码文件)。
  • 处理:编译器(如 g++、clang++)执行词法分析、语法分析、语义分析、中间代码生成、代码优化等任务,把 C/C++ 源码翻译成汇编语言代码。这是编译过程的核心。
  • 输出:.s(或 .asm)汇编文件。
gcc -S main.c -o main.s

阶段③ 汇编 assemble

  • 输入:汇编文件。
  • 处理:汇编器(如 as、gas)将汇编代码翻译成机器语言指令(目标代码),但这些指令仍针对特定架构,尚未链接成可执行文件。
  • 输出:目标文件 .o(Linux)/ .obj(Windows)。

重点:汇编阶段不会做业务逻辑错误检查,只检查汇编语法。C 语言的逻辑错误在这里不会报错。

gcc -c main.c -o main.o

多文件同时编译出多个目标文件:

gcc -c myadd.cpp mymultiply.cpp main.cpp
# 输出 myadd.o  mymultiply.o  main.o
  • 输入:一堆 .o 目标文件 + 库文件(静态库 / 动态库)。
  • 处理:链接器(如 ld、lld)把目标文件和库文件合并成一个可执行文件或库文件。具体工作包括:
    1. 合并各个 .o 的段(text、rodata、data、bss);
    2. 符号解析,找到函数调用对应的函数定义,解决外部符号引用并把它们链接到正确的地址;
    3. 符号重定位,把相对地址改成虚拟内存地址。
  • 输出:可执行程序(Linux ELF 可执行、Windows .exe)或者新的库。

链接有依赖顺序:A 库依赖 B 库,则顺序要注意;这个依赖顺序会受编译器优化选项影响。

三、静态库(Static Library)

Linux 后缀:.a;Windows 静态库:.lib。本质:把很多 .o 目标文件打包归档成一个文件,不是可执行文件。

1. 创建静态库

使用 ar 归档工具打包:

ar rcs libmymath.a myadd.o mymultiply.o
  • r:插入替换模块
  • c:创建库
  • s:生成索引,加速链接

2. 使用静态库链接生成程序

gcc main.o -L. -lmymath -o program
  • -L.:告诉链接器,在当前目录搜索库文件
  • -lmymath:代表链接 libmymath.a

3. 静态库核心特点

  1. 链接阶段直接把库里面的目标代码拷贝复制进最终可执行程序。
  2. 可执行程序体积会变大,库代码被打包进 exe。
  3. 生成静态库之后,原始 .cpp 源码可以直接删除,只保留 .a 库即可编译链接。
  4. 程序运行的时候不再依赖原来静态库文件,exe 自身已经包含全部机器码。
  5. 多个程序使用同一个静态库,每个程序文件内部都会存一份库副本,磁盘占用会重复。

四、动态库 / 共享库(Shared Library)

Linux:.so;MacOS:.dylib;Windows:.dll。

1. 编译生成动态库

编译目标文件需要位置无关代码,再生成共享库:

# 生成动态库
gcc -shared -o libmymath.so myadd.o mymultiply.o
# 链接可执行文件
gcc main.o -L. -lmymath -o my-program

2. 链接阶段发生什么

重点:链接的时候不会把动态库机器码拷贝进可执行文件。可执行程序内部仅仅写入标记、符号表,记录"运行时需要加载哪个动态库"。

真正的加载和符号解析工作不在链接阶段,而是推迟到程序运行的时候。操作系统的 Loader 加载器读取 exe 内的标记,找到磁盘上的动态库文件,加载进进程虚拟内存,解析符号地址,完成函数调用绑定。

  • 特性 1:内存中可以做到多进程共享同一份动态库代码段,节省物理内存。
  • 特性 2:动态库运行加载时,可以映射到虚拟内存任意地址(配合 ASLR)。
  • 运行依赖:运行环境必须存在对应动态库文件,如果丢失 dll / so / dylib,程序直接启动失败。

五、静态库 vs 动态库对比总结

项目静态库 .a/.lib动态库 .so/.dll/.dylib
库代码拷贝时机链接阶段拷贝代码进 exe链接阶段只写标记;运行时加载库
可执行文件大小体积大,内置库代码体积小,不含库机器码
运行时依赖库文件不需要,exe 自给自足必须要有对应库文件,缺失无法启动
内存多进程每个进程有独立副本多个进程共享内存中同一份库代码
更新库重新链接整个程序替换动态库文件即可,不需要重编译 exe

六、易错点汇总

  1. 栈大小固定,递归无终止条件直接栈溢出 Stack overflow。
  2. .bss 段磁盘文件不占大小,加载进内存操作系统才清零。
  3. 汇编只检查汇编语法错误,C 语言业务逻辑错误汇编不会报错。
  4. 静态库链接存在库依赖顺序,编译器优化会改变链接行为。
  5. rodata 存放常量字符串,运行写 rodata 内存直接段错误崩溃。
  6. 动态库真正解析符号是程序运行阶段,不是 gcc 链接那一步。
  7. ASLR 地址随机化:同一个程序每次运行虚拟地址都不一样。
  8. text、rodata、data、bss 全部在构建阶段确定布局;栈、堆是运行时分配内存。