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";"Hello" 字符串本身在
.rodata。str 这个全局指针变量在
.data(因为它已初始化)。str 是局部变量,则 str 在
栈,但它指向的 "Hello" 仍然在
.rodata。经典布局如下:
高地址
+---------------------+
| Stack |
| ↓ |
| |
| |
| 空闲区域 |
| |
| |
| ↑ |
| Heap |
+---------------------+
低地址
设计成相向增长有几个原因:
rsp
等寄存器)即可分配和释放,堆由内存分配器管理。现代 Linux 上,由于 ASLR(地址空间布局随机化)、mmap
和多线程等机制,实际地址布局可能有所变化,但整体仍遵循上述结构。再加上一句让人哭笑不得的现实:
程序员画的内存布局永远比调试时看到的整齐,内核和动态链接器可没有义务配合你的示意图。
这里的”向上增长”指的是虚拟地址越来越大。
例如:
地址
0x600000
+-------------+
| Data |
+-------------+
| BSS |
+-------------+
| |
| Heap | malloc()
| |
+-------------+
0x700000
第一次:
malloc(100);可能得到
0x601000第二次:
malloc(100);得到
0x602000第三次
0x603000可以看到地址越来越大,这就是向上增长。
不是。
Heap 能增长多少,取决于很多因素:
(1)进程虚拟地址空间
例如:
虚拟地址空间
4GB其中:
都要共享这 4GB。
所以 Heap 最终一定会长到不能再长。
64 位程序则完全不同。
理论上:
2^64 Byte
≈16EB(Exabyte)Linux 实际一般只使用其中几十 TB。
对于普通程序来说:
基本可以认为虚拟地址”非常非常大”。
(2)物理内存
例如:
机器只有
16GB RAMHeap 不可能一直申请。
Linux 会:
malloc
↓
分配虚拟地址
↓
真正访问
↓
缺页异常(Page Fault)
↓
分配物理页如果:
RAM 用光再加上:
Swap 也没有Linux 可能:
OOM Killer直接把你的程序杀掉。
程序员:“为什么退出了?”
内核:“因为你太能吃了。”
(3)ulimit
还可以限制:
ulimit -d限制 heap 大小。
例如:
64MB那么:
malloc(100MB)
↓
失败很多教材画成:
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程序员看来:
都是 Heapglibc 内部:
来源已经不同了。这里容易混淆。
例如:
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 |
+-----------+
这里:
std::string
对象本身(指针、长度、容量等元数据)。所以很多 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
↓
保存
页表的地址
真正的页表还在内核内存里。
当调度器切换进程:
例如:
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_programLinux:
fork()
↓
execve()
↓
创建 mm_struct
↓
创建页表
↓
加载 ELF
↓
建立 text/data 的映射
↓
开始运行
以后:
malloc()或者:
mmap()Linux 都可能:
修改页表
例如增加:
VA
0x600000
↓
PA
0x12345000
或者删除映射。
可以把它们理解成三层关系:
用户进程
│
│(只能访问虚拟地址)
▼
mm_struct(内核中的进程内存描述)
│
│ 指向
▼
页表(内核维护,存放在物理内存)
│
│ 提供 VA → PA 映射
▼
真正的物理内存(RAM)
所以,页表不是进程空间里的数据,而是内核为每个进程维护的一套映射结构。进程只是在运行时”使用”这张页表,CPU 通过页表把进程看到的虚拟地址翻译成实际的物理地址。