进程虚拟地址空间

Linux 下一个进程的虚拟地址空间(Virtual Address Space)通常可以画成下面这样(高地址在上,低地址在下):

                 高地址 (High Address)
+--------------------------------------------------+
|                 Kernel Space                     |
|        内核空间(用户态不可访问)                  |
+--------------------------------------------------+
|                                                  |
|                  Stack (栈)                      |
|        函数调用、局部变量、返回地址               |
|        每个线程都有自己的栈                      |
|                  ↓ 向下增长                      |
|                                                  |
+--------------------------------------------------+
|                                                  |
|                  Memory Mapping                  |
| mmap() 映射区域                                  |
| - 动态库 (.so)                                   |
| - 文件映射                                       |
| - 匿名内存                                       |
| - 大块内存分配                                   |
|                                                  |
+--------------------------------------------------+
|                                                  |
|                     Heap (堆)                    |
| malloc/new 分配                                  |
| free/delete 释放                                 |
|                  ↑ 向上增长                      |
|                                                  |
+--------------------------------------------------+
|                 BSS Segment                      |
| 未初始化全局变量                                 |
| int g;                                           |
| static int x;                                    |
+--------------------------------------------------+
|                 Data Segment                     |
| 已初始化全局变量                                 |
| int g = 10;                                      |
| static int x = 5;                                |
+--------------------------------------------------+
|              Read Only Data (.rodata)            |
| 字符串常量                                       |
| const 全局变量                                   |
| 虚函数表(vtable)                                 |
+--------------------------------------------------+
|                 Text Segment                     |
| 程序机器码                                       |
| 函数代码                                         |
| 通常只读、可执行                                 |
+--------------------------------------------------+
|                 Reserved / NULL                  |
| 地址0附近,防止空指针访问                        |
+--------------------------------------------------+
                 低地址 (Low Address)

                0xFFFFFFFFFFFFFFFF
+--------------------------------------------+
|               Kernel Space                 |
+--------------------------------------------+
|                  Stack                     |
|                (向下增长)                  |
|                   ↓                        |
+--------------------------------------------+
|                                            |
|             argv / envp                    |
|     命令行参数、环境变量                   |
+--------------------------------------------+
|                                            |
|          mmap / Shared Libraries           |
|      libc.so                               |
|      libstdc++.so                          |
|      libpthread.so                         |
|      文件映射                              |
+--------------------------------------------+
|                                            |
|                 Heap                       |
|             malloc/new                     |
|                  ↑                         |
+--------------------------------------------+
|                BSS                         |
+--------------------------------------------+
|               Data                         |
+--------------------------------------------+
|              .rodata                       |
+--------------------------------------------+
|               .text                        |
+--------------------------------------------+
|                NULL                        |
+--------------------------------------------+
0x0000000000000000
区域 内容 生命周期 是否可写
Text 程序机器码 整个进程
rodata 字符串、const 整个进程
Data 已初始化全局变量 整个进程
BSS 未初始化全局变量 整个进程
Heap malloc/new 动态
mmap 动态库、文件映射 动态 一般可写
Stack 局部变量、函数调用 函数结束释放

一个例子

#include <iostream>

int g1 = 10;          // Data
int g2;               // BSS

const char* str = "Hello"; // 指针(Data),字符串(rodata)

int main()
{
    int local = 5;                // Stack

    int* p = new int(100);        // Heap

    static int s = 50;            // Data

    std::cout << local << std::endl;

    delete p;
}

对应的位置:

Text
 ├── main()
 ├── operator new()
 └── cout相关代码

rodata
 └── "Hello"

Data
 ├── g1
 ├── str (指针本身)
 └── s

BSS
 └── g2

Heap
 └── *p = 100

Stack
 ├── local
 ├── p (指针变量本身)
 └── 函数调用信息

注意一个容易混淆的点:

const char* str = "Hello";

堆和栈为什么相向增长

经典布局如下:

高地址
+---------------------+
|        Stack        |
|          ↓          |
|                     |
|                     |
|      空闲区域       |
|                     |
|                     |
|          ↑          |
|        Heap         |
+---------------------+
低地址

设计成相向增长有几个原因:

  1. 最大化利用虚拟地址空间。堆和栈按需扩展,中间空闲区域谁需要谁占用,不必预先划分固定大小。
  2. 减少内存浪费。程序栈很小的时候,堆可以占更多空间;反之亦然。
  3. 实现简单高效。栈只需修改栈指针(rsp 等寄存器)即可分配和释放,堆由内存分配器管理。

现代 Linux 上,由于 ASLR(地址空间布局随机化)、mmap 和多线程等机制,实际地址布局可能有所变化,但整体仍遵循上述结构。再加上一句让人哭笑不得的现实: 程序员画的内存布局永远比调试时看到的整齐,内核和动态链接器可没有义务配合你的示意图。

Heap 向上增长是什么意思

这里的”向上增长”指的是虚拟地址越来越大

例如:

地址

0x600000
+-------------+
|   Data      |
+-------------+
|   BSS       |
+-------------+
|             |
|    Heap     |  malloc()
|             |
+-------------+
0x700000

第一次:

malloc(100);

可能得到

0x601000

第二次:

malloc(100);

得到

0x602000

第三次

0x603000

可以看到地址越来越大,这就是向上增长

Heap 是无限的吗

不是。

Heap 能增长多少,取决于很多因素:

(1)进程虚拟地址空间

例如:

虚拟地址空间
4GB

其中:

都要共享这 4GB。

所以 Heap 最终一定会长到不能再长。


64 位程序则完全不同。

理论上:

2^64 Byte
16EB(Exabyte)

Linux 实际一般只使用其中几十 TB。

对于普通程序来说:

基本可以认为虚拟地址”非常非常大”。


(2)物理内存

例如:

机器只有

16GB RAM

Heap 不可能一直申请。

Linux 会:

malloc


分配虚拟地址



真正访问



缺页异常(Page Fault)



分配物理页

如果:

RAM 用光

再加上:

Swap 也没有

Linux 可能:

OOM Killer

直接把你的程序杀掉。

程序员:“为什么退出了?”

内核:“因为你太能吃了。”


(3)ulimit

还可以限制:

ulimit -d

限制 heap 大小。

例如:

64MB

那么:

malloc(100MB)



失败

Heap 真的是连续的吗

很多教材画成:

Heap

↑↑↑↑↑↑

实际上现代 glibc 已经不是这样。

早期:

malloc()



brk()



Heap

只有这一块。

现代:

malloc()



小对象


brk()

但是:

malloc(100MB)



mmap()



完全另一块地址

例如:

Heap

0x600000
......
0x900000


突然:

malloc(1GB)

↓

mmap

0x7f223000000

所以:

malloc 返回的地址未必来自 Heap。

很多其实来自 mmap。


那为什么还叫 Heap?

因为:

Heap 是一种逻辑概念

表示:

动态分配出来的对象。

并不是:

一定来自某一块连续地址。

例如:

new



malloc



可能来自

Heap(brk)

或者

mmap

程序员看来:

都是 Heap

glibc 内部:

来源已经不同了。

进程里存的是堆的什么

这里容易混淆。

例如:

int* p = new int(100);

内存其实是这样的:

Stack                     Heap

+--------+                +-----------+
| p      |--------------->|    100    |
+--------+                +-----------+

栈(Stack)

变量 p

保存的是:

0x602010

也就是:

堆对象的地址(指针)

真正的数据:

100

在 Heap。


再例如:

struct Person {
    int age;
};

Person* p = new Person;
Stack

p
│
│
▼

Heap

+----------------+
| age = 18       |
+----------------+

栈里只有:

8 Byte

(指针)

真正对象:

几十 Byte

甚至几 KB

都在 Heap。


一个对象在进程里到底包含什么

例如:

std::string s = "Hello";

可能是:

Stack

+--------------------+
| string对象         |
| ptr --------------+--------------------+
| size=5            |                    |
| capacity=15       |                    |
+--------------------+                    |
                                          |
                                          ▼
Heap                                +-----------+
                                     | H e l l o |
                                     +-----------+

这里:

所以很多 C++ 对象实际上是:

对象本身在栈(或其他存储区),对象管理的数据在堆

谁维护页表

Linux 内核维护。

页表(Page Table)不是放在进程里面,而是放在内核管理的内存中,由操作系统内核维护。

不过,每个进程都有属于自己的页表,所以经常会说”进程拥有自己的页表”。

用户程序:

int *p = new int(100);

实际上不会直接修改页表。

整个流程是:

用户程序
    │
    │ malloc()
    ▼
glibc malloc
    │
    │ brk()/mmap() 系统调用
    ▼
Linux 内核
    │
    │ 修改页表
    ▼
CPU(MMU)

也就是说:

否则任何程序都可以把自己的虚拟地址映射到别人的物理内存,整个系统就毫无安全可言了。

页表存放在哪里

页表本身也是一块普通的物理内存。

例如:

物理内存(RAM)

+--------------------------------------+
| Linux Kernel                         |
|                                      |
| PageTable(Process A)                 |
| PageTable(Process B)                 |
| PageTable(Process C)                 |
|                                      |
+--------------------------------------+

+--------------------------------------+
| 用户数据                             |
+--------------------------------------+

+--------------------------------------+
| 文件缓存(Page Cache)                 |
+--------------------------------------+

所以:

页表也是 RAM 里的数据。

只是:

进程里面有什么

每个进程都有一个内核中的描述结构。

Linux 里面叫:

task_struct

其中包含:

task_struct


mm_struct

    ├── heap
    ├── stack
    ├── mmap
    ├── code
    └── 页表根地址(pgd)

关系可以画成:

Process

task_struct
      │
      ▼
 mm_struct
      │
      ├──── heap范围
      ├──── stack范围
      ├──── mmap列表
      ├──── code范围
      │
      ▼
 Page Table

注意:

进程并不”存放”页表。

而是:

mm_struct

↓

保存

页表的地址

真正的页表还在内核内存里。

CPU 怎么找到页表

当调度器切换进程:

例如:

Process A

↓

切换

↓

Process B

Linux 会:

CR3寄存器

=

ProcessB 页表根地址

于是 CPU 就知道:

“以后所有虚拟地址,都从这张页表开始翻译。”

所以:

CPU

↓

CR3

↓

Page Table

↓

Physical Memory

为什么每个进程都有自己的页表

例如两个程序:

int a = 10;

它们看到的地址可能都是:

0x7fffd1234000

但是:

程序 A:

0x7fffd1234000

↓

物理

0x12345000

程序 B:

0x7fffd1234000

↓

物理

0x99887000

因为:

A

CR3

↓

PageTable A
B

CR3

↓

PageTable B

所以:

相同虚拟地址

可以映射到

不同物理地址

这也是进程隔离的基础。

页表什么时候建立

例如:

./my_program

Linux:

fork()

↓

execve()

↓

创建 mm_struct

↓

创建页表

↓

加载 ELF

↓

建立 text/data 的映射

↓

开始运行

以后:

malloc()

或者:

mmap()

Linux 都可能:

修改页表

例如增加:

VA

0x600000

↓

PA

0x12345000

或者删除映射。

可以把它们理解成三层关系:

用户进程
    │
    │(只能访问虚拟地址)
    ▼
mm_struct(内核中的进程内存描述)
    │
    │ 指向
    ▼
页表(内核维护,存放在物理内存)
    │
    │ 提供 VA → PA 映射
    ▼
真正的物理内存(RAM)

所以,页表不是进程空间里的数据,而是内核为每个进程维护的一套映射结构。进程只是在运行时”使用”这张页表,CPU 通过页表把进程看到的虚拟地址翻译成实际的物理地址。