0%

进程的虚拟地址空间内存划分和布局

一段C++的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int gdata1 = 10;
int gdata2 = 0;
int gdata3;

static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;

int main()
{
int a = 12;
int b = 0;
int c;

static int e = 13;
static int f = 0;
static int g;
}

一个问题:这段代码经过编译链接产生可执行文件,运行时,这些变量分别放在内存的哪里?

进程的虚拟地址空间

任何编程语言写出的代码,无非产生两样东西:指令和数据。

程序编译链接完成后,在 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 → 初始化且不为零 → .data
  • gdata2 = 0、gdata3、gdata5 = 0、gdata6 → 为零或未初始化 → .bss
  • e = 13 → 静态局部变量,初始化且不为零 → .data
  • f = 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

栈有两点特别重要:

  1. 每个线程在当前进程私有的也只有栈
  2. 栈是从高地址往低地址增长(和堆的从低到高刚好相反)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 全局变量,不管是普通的还是静态的,都叫做数据,在符号表中会生成符号
int gdata1 = 10; // 初始值不为0,放在 .data 段
int gdata2 = 0; // .bss
int gdata3; // .bss

static int gdata4 = 11; // 初始值不为0,放在 .data 段
static int gdata5 = 0; // .bss
static int gdata6; // .bss

int main()
{
// 局部变量,不产生符号,生成指令 --- 放在 .text 段
int a = 12; // mov dword ptr[a], 0Ch
// 语句本身生成的是指令,放在 .text 段
// cpu在运行该指令时,会在栈上划出来一块四字节的空间,用来存放12
int b = 0;
int c; // 栈上的无效值,打印出来肯定不为0

// 静态局部变量,属于数据,放在数据段
// 程序启动的时候不初始化,第一次运行到它们时才初始化
static int e = 13; // .data
static int f = 0; // .bss
static int g; // .bss,打印出来是0,因为内核会把 .bss 段清零
}

符号表是什么

编译一个源文件时,编译器会把当前文件里定义的、能被别的文件”看见”的名字记下来,列成一张表,这张表就叫符号表。每个条目大致记三件事:

记录内容 例子
名字 gdata1
类型和大小 int,4 字节
所在位置 哪个段、什么偏移

它是干什么用的? 主要服务于链接。

一个程序往往拆成多个 .cpp,每个单独编译成 .o(目标文件)。链接器要把它们拼成可执行文件,还得解决”你这个文件用的 gdata1,在别的文件里定义”这类问题——靠的就是每个目标文件里的符号表。

1
2
3
4
5
6
a.cpp ──编译──> a.o(含符号表)
b.cpp ──编译──> b.o(含符号表)
↓
链接器:按符号表把名字对上号
↓
可执行文件 .exe / a.out

链接时如果报 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 段。

那”局部变量在栈上”这句话错了吗?

没错,但要分清两个层面,这是很多人的概念性错误:

  1. a = 12 这条语句编译后生成一条指令,这条指令放在 .text 段
  2. 当这条指令运行时,CPU 在当前进程的栈上开辟 4 字节空间,把 12 存进去

所以准确的表述是:**a 不是”在栈上”,而是”a = 12 这条语句生成的指令,运行时会在栈上开辟空间存放 12”。**

静态局部变量和局部变量不一样

静态局部变量也是放在数据段的,程序启动的时候不会初始化它们,而是第一次运行到它们的时候初始化。

所以 e = 13 放在 .data,而 f = 0、g 放在 .bss。

完整布局图

注:本文所有图统一采用与《深入理解计算机系统》一致的画法——低地址在下、高地址在上。

总结

内容 准确说法 ❌ 不要这么说
指令 .text 段 / 指令段 全局变量区、静态区
常量字符串 .rodata 段 ——
已初始化且非零的数据 .data 段 静态区
未初始化 / 初始化为零 .bss 段 全局变量区

参考

  • 《深入理解计算机系统》(CSAPP)——第七章 链接