从内存区域划分(.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
阶段④ 链接 link
- 输入:一堆
.o目标文件 + 库文件(静态库 / 动态库)。 - 处理:链接器(如
ld、lld)把目标文件和库文件合并成一个可执行文件或库文件。具体工作包括:- 合并各个
.o的段(text、rodata、data、bss); - 符号解析,找到函数调用对应的函数定义,解决外部符号引用并把它们链接到正确的地址;
- 符号重定位,把相对地址改成虚拟内存地址。
- 合并各个
- 输出:可执行程序(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. 静态库核心特点
- 链接阶段直接把库里面的目标代码拷贝复制进最终可执行程序。
- 可执行程序体积会变大,库代码被打包进 exe。
- 生成静态库之后,原始
.cpp源码可以直接删除,只保留.a库即可编译链接。 - 程序运行的时候不再依赖原来静态库文件,exe 自身已经包含全部机器码。
- 多个程序使用同一个静态库,每个程序文件内部都会存一份库副本,磁盘占用会重复。
四、动态库 / 共享库(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 |
六、易错点汇总
- 栈大小固定,递归无终止条件直接栈溢出 Stack overflow。
.bss段磁盘文件不占大小,加载进内存操作系统才清零。- 汇编只检查汇编语法错误,C 语言业务逻辑错误汇编不会报错。
- 静态库链接存在库依赖顺序,编译器优化会改变链接行为。
- rodata 存放常量字符串,运行写 rodata 内存直接段错误崩溃。
- 动态库真正解析符号是程序运行阶段,不是 gcc 链接那一步。
- ASLR 地址随机化:同一个程序每次运行虚拟地址都不一样。
- text、rodata、data、bss 全部在构建阶段确定布局;栈、堆是运行时分配内存。
豫公网安备41108202000982号