一段C++的代码
1 | int gdata1 = 10; |
一个问题:这段代码经过编译链接产生可执行文件,运行时,这些变量分别放在内存的哪里?
进程的虚拟地址空间
任何编程语言写出的代码,无非产生两样东西:指令和数据。
程序编译链接完成后,在 Windows 上是一个 .exe 可执行文件,放在磁盘上。CPU 无法直接运行磁盘上的程序,需要先把它从磁盘加载到内存。这就产生了问题:加载到内存的哪里?内存有没有区域划分?划分后是什么样?
这里以x86 体系、32 位 Linux 环境为例进行说明:
程序运行时,Linux 内核会给当前进程分配一块大小为 2^32 的空间,也就是 4G。这块空间叫做进程的虚拟地址空间。
虚拟地址空间并不是物理内存上的一块真实空间。从底层原理上说,它只是内核创建的一系列数据结构而已。
⚠️ 虚拟内存 ≠ 虚拟地址空间,这是两个不同的词,不要混用。
另外,4G 是跟系统位数绑定的:如果是 64 位 Linux,每个进程的虚拟地址空间是 2^64。但是原理是一样的。
空间的划分:用户空间 + 内核空间

这 4G 空间默认被划分成两部分:
| 区域 | 范围 | 大小 |
|---|---|---|
| 用户空间 user space | 0x00000000 ~ 0xC0000000 |
3G |
| 内核空间 kernel space | 0xC0000000 ~ 0xFFFFFFFF |
1G |
32 位 Linux 下,系统默认内核空间占 1G、用户空间占 3G。这个比例可以通过修改操作系统配置来调整,但默认就是这样。
每一个进程都有这么一个虚拟地址空间。
用户空间的布局,从低地址到高地址
用户空间并不是从 0 地址就开始存放内容的,前面空了一段。
1. 预留区,0x00000000-0x08048000
从 0 地址开始的一段空间是预留的,不能够访问。
这一段在地址空间中的位置:
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈"]
S["加载共享库 *.dll *.so"]
H["heap 堆"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000-0x08048000
← 0 地址起,不可访问"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class A hot
如果把一个空的 char* 传给字符串函数,比如 strcpy(dest, src),从 0 地址开始拷贝字符串,遇到 '\0' 结束。程序运行会直接崩掉——因为这块空间不能读也不能写,访问它系统就报异常。
2. .text 段(指令段)
从 0x08048000 开始存放的是 .text 段(指令段)。
先看它在整个地址空间中的位置(下面各段同理,蓝色为当前讨论的段):
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈"]
S["加载共享库 *.dll *.so"]
H["heap 堆"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段
← 0x08048000 起,存放指令"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class T hot
3. .rodata 段(只读数据段)
.text 之后是 .rodata 段(read only,只读数据段)。
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈"]
S["加载共享库 *.dll *.so"]
H["heap 堆"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段
"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class R hot
1 | const char* p = "hello world"; |
函数里定义这样一个局部变量指针,指针 p 本身在栈上,但它指向的常量字符串 "hello world" 存储在 .rodata 段。
那能不能通过指针修改这个字符串的第一个字符?
1 | *p = 'a'; |
编译能通过,但运行会崩溃——因为 .rodata 段跟 .text 段都只能读、不能写。
较新的 C++ 编译器已经不允许用普通指针指向常量字符串,写的时候必须加
const修饰。指向被const约束后,这类不可预期的错误也就不会发生了。
4. .data 段 和 .bss 段(数据段)
再往上就是存放数据的地方,分两个段:
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈"]
S["加载共享库 *.dll *.so"]
H["heap 堆"]
B[".bss 未初始化/为0
← 未初始化或为 0 的数据,内核会清零"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class B hot
| 段 | 存放内容 |
|---|---|
.data |
初始化了的,且初始值不为零的数据 |
.bss |
未初始化的,以及初始化为零的数据 |
对应到开头那段代码:
gdata1 = 10、gdata4 = 11→ 初始化且不为零 →.datagdata2 = 0、gdata3、gdata5 = 0、gdata6→ 为零或未初始化 →.bsse = 13→ 静态局部变量,初始化且不为零 →.dataf = 0、g→ 静态局部变量,为零或未初始化 →.bss
为什么未初始化的全局变量打印出来是 0?
因为它存放在 .bss 段。程序运行时,操作系统内核给当前进程分配地址空间,把未初始化的数据放在 .bss。然后内核会自己负责把 .bss 段的数据全部清零——所以我们看到未初始化的全局变量打印出来是 0,这是操作系统对 .bss 段默认做的零初始化。
而局部变量 c 没初始化,打印出来肯定不为 0,因为它是栈上的无效值,跟 .bss 的零初始化不是一回事。
5. 堆(heap)
.bss 再往上的一块空间叫做堆。
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈"]
S["加载共享库 *.dll *.so"]
H["heap 堆
← new / malloc 时分配,向上增长"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class H hot
程序刚加载时这里还是空的,调用 new 或者 malloc 时才会在这里分配堆内存。堆的分配方向是从低地址到高地址增长。
6. 共享库(动态链接库)
再往上,程序运行过程中会加载一些共享库,也就是动态链接库。Windows 上是 .dll,Linux 下是 .so 库,都会映射到这一部分。
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈"]
S["加载共享库 *.dll *.so
← 动态链接库映射到此"]
H["heap 堆"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class S hot
7. 栈(stack)
再往上就是栈,函数运行时在栈上分配空间,每个线程都独有一份。
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈
← 函数运行时分配,向下增长"]
S["加载共享库 *.dll *.so"]
H["heap 堆"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class ST hot
栈有两点特别重要:
- 每个线程在当前进程私有的也只有栈
- 栈是从高地址往低地址增长(和堆的从低到高刚好相反)
8. 命令行参数和环境变量
栈再往上,靠近内核空间边界的地方,存放命令行参数和环境变量。
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)
← 命令行参数与环境变量"]
ST["stack 栈"]
S["加载共享库 *.dll *.so"]
H["heap 堆"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class C hot
比如 Linux 下运行 ./a.out 192.168.1.100 9090,传的 IP 地址和端口号这些命令行参数都会放在这里。环境变量则包含程序搜索头文件、搜索库文件时的默认路径。
内核空间的划分
内核空间简单分为三块:
flowchart TB
K["内核空间 1G
ZONE_DMA / NORMAL / HIGHMEM
← 1G,所有进程共享"]
subgraph USER["用户空间 3G"]
direction TB
C["命令行参数和环境变量
(高地址端)"]
ST["stack 栈"]
S["加载共享库 *.dll *.so"]
H["heap 堆"]
B[".bss 未初始化/为0"]
D[".data 已初始化且非0"]
R[".rodata 只读数据段"]
T[".text 指令段"]
A["未使用(不可访问)
0x00000000 起"]
C --> ST --> S --> H --> B --> D --> R --> T --> A
end
K -->|"0xC0000000 分界"| C
classDef hot fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef norm fill:#f8fafc,stroke:#cbd5e1,color:#64748b
class K hot
| 区域 | 说明 |
|---|---|
| DMA 区 | 大概 16M 大小 |
| ZONE_NORMAL | 比较大,约 800 多兆。进程的 PCB 块(task_struct) 就存放在这里,内核空间的线程、内核函数运行所依赖的栈空间也在这部分 |
| High Memory | 高端内存,用于映射高地址的物理内存做地址映射 |
用户空间私有,内核空间共享
值得注意的是:
每一个进程的用户空间是私有的,但内核空间是共享的。
画三个进程来理解:

- 每个进程(比如QQ、酷狗音乐、VS Code)各自都有各自的用户空间,用户空间是私有的
- 但内核空间是共享的,所有进程共用同一块
这也解释了操作系统里的进程间通信问题。进程之间通信比较难的原因,就是用户空间是隔离的——谁也访问不到谁,就像你在一个房间、我在另一个房间,根本没法说话。
而匿名管道通信的原理就清楚了:在内核空间划分一块内存,进程一往这块共享内存里写数据,进程二、进程三都能看得见;进程二或进程三往里写,其他进程也都能看得见。
回到代码
把开头那段代码加上注释再看一遍,现在应该都能看懂了:
1 | // 全局变量,不管是普通的还是静态的,都叫做数据,在符号表中会生成符号 |
符号表是什么
编译一个源文件时,编译器会把当前文件里定义的、能被别的文件”看见”的名字记下来,列成一张表,这张表就叫符号表。每个条目大致记三件事:
| 记录内容 | 例子 |
|---|---|
| 名字 | gdata1 |
| 类型和大小 | int,4 字节 |
| 所在位置 | 哪个段、什么偏移 |
它是干什么用的? 主要服务于链接。
一个程序往往拆成多个 .cpp,每个单独编译成 .o(目标文件)。链接器要把它们拼成可执行文件,还得解决”你这个文件用的 gdata1,在别的文件里定义”这类问题——靠的就是每个目标文件里的符号表。
1 | a.cpp ──编译──> a.o(含符号表) |
链接时如果报
undefined reference to 'xxx'或LNK2019这类错误,本质就是某个符号在别人的符号表里找不到。
所以,一个名字进不进符号表,取决于它需不需要被外部看见。而全局变量要被别的文件访问,自然得登记;出了函数就没人认得的局部变量,登记了也没用。
局部变量到底产生符号吗?
main 里的三个局部变量 a、b、c,在符号表中不会产生符号。
它们对应生成的是指令。在 x86 体系下,int a = 12; 生成的汇编是:
1 | mov dword ptr[a], 0Ch ; 12 = 0Ch |
它没有产生 a 的符号,而是生成一条 mov 指令。这三个局部变量的定义,最终产生的是三条 mov 指令,指令放在 .text 段。
那”局部变量在栈上”这句话错了吗?
没错,但要分清两个层面,这是很多人的概念性错误:
a = 12这条语句编译后生成一条指令,这条指令放在.text段- 当这条指令运行时,CPU 在当前进程的栈上开辟 4 字节空间,把 12 存进去
所以准确的表述是:**a 不是”在栈上”,而是”a = 12 这条语句生成的指令,运行时会在栈上开辟空间存放 12”。**
静态局部变量和局部变量不一样
静态局部变量也是放在数据段的,程序启动的时候不会初始化它们,而是第一次运行到它们的时候初始化。
所以 e = 13 放在 .data,而 f = 0、g 放在 .bss。
完整布局图

注:本文所有图统一采用与《深入理解计算机系统》一致的画法——低地址在下、高地址在上。
总结
| 内容 | 准确说法 | ❌ 不要这么说 |
|---|---|---|
| 指令 | .text 段 / 指令段 |
全局变量区、静态区 |
| 常量字符串 | .rodata 段 |
—— |
| 已初始化且非零的数据 | .data 段 |
静态区 |
| 未初始化 / 初始化为零 | .bss 段 |
全局变量区 |
参考
- 《深入理解计算机系统》(CSAPP)——第七章 链接