Linux系统编程(十一):深入理解Linux进程地址空间
本文将带你从一段简单的 C 代码出发,逐步深入到 Linux 内核源码,彻底搞懂进程地址空间的本质。你将明白虚拟地址与物理地址的区别,理解操作系统如何通过页表实现内存隔离,以及内核中的 mm_struct 和 vm_area_struct 是如何管理这一切的。

📝 前言
作为一名 C/C++ 开发者,你一定无数次写过打印指针地址的代码。当你运行程序时,屏幕上会输出一连串看似杂乱无章的十六进制数。但你有没有想过:这些地址真的是物理内存的地址吗?为什么它们的分布如此有规律?如果两个进程打印出了相同的地址,它们真的访问了同一块内存吗?
很多人学了很久 C 语言,却对"地址"的本质一知半解——这是学习操作系统底层原理时最大的障碍之一。
不过不用担心——本文将带你从一段简单的 C 代码出发,逐步深入到 Linux 内核源码,彻底搞懂进程地址空间的本质。你将明白虚拟地址与物理地址的区别,理解操作系统如何通过页表实现内存隔离,以及内核中的 mm_struct 和 vm_area_struct 是如何管理这一切的。
通过本文,你将掌握:
| 技能 | 应用场景 |
|---|---|
| 理解进程地址空间的布局 | 分析程序内存分布、排查内存越界问题 |
| 区分虚拟地址与物理地址 | 理解进程隔离机制、编写多进程程序 |
| 掌握页表与缺页中断原理 | 理解内存分配延迟策略、优化内存使用 |
| 理解 mm_struct 和 VMA | 阅读 Linux 内核源码、进行内核开发 |
📌 前置知识: 基本的 C 语言编程基础,了解 Linux 常用命令
文章目录
一、🔍 从一段代码开始:C语言中的地址之谜
让我们先运行这段经典的内存布局探测代码,看看它会输出什么:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval; // 未初始化全局变量
int g_val = 100; // 已初始化全局变量
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld"; // 字符串常量
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
static int test = 10; // 静态局部变量
char *heap_mem = (char*)malloc(10);
char *heap_mem1 = (char*)malloc(10);
char *heap_mem2 = (char*)malloc(10);
char *heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem);
printf("heap addr: %p\n", heap_mem1);
printf("heap addr: %p\n", heap_mem2);
printf("heap addr: %p\n", heap_mem3);
printf("test static addr: %p\n", &test);
printf("stack addr: %p\n", &heap_mem); // 指针变量本身的地址
printf("stack addr: %p\n", &heap_mem1);
printf("stack addr: %p\n", &heap_mem2);
printf("stack addr: %p\n", &heap_mem3);
printf("read only string addr: %p\n", str);
for(int i = 0; i < argc; i++) {
printf("argv[%d]: %p\n", i, argv[i]);
}
for(int i = 0; env[i]; i++) {
printf("env[%d]: %p\n", i, env[i]);
}
free(heap_mem);
free(heap_mem1);
free(heap_mem2);
free(heap_mem3);
return 0;
}
在 64 位 Linux 系统上,运行结果如下(你的地址可能不同,但分布规律完全一致):
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4819
env[0]: 0x7ffd0f9a4821
观察这些输出,我们可以发现几个惊人的规律:
- 地址从低到高有序分布:代码段地址最低,然后是全局变量、静态变量,接着是堆,最后是栈和命令行参数/环境变量
- 堆地址递增:
heap_mem到heap_mem3的地址依次增大(0x1791010 → 0x1791030 → 0x1791050 → 0x1791070) - 栈地址递减:
&heap_mem到&heap_mem3的地址依次减小(0x7ffd0f9a4368 → 0x7ffd0f9a4360 → ...) - 静态变量和全局变量地址接近:
test的地址(0x601038)和g_val(0x601034)、g_unval(0x601040)挨在一起 - 字符串常量和代码段地址接近:
"helloworld"的地址(0x400800)和main函数的地址(0x40055d)非常接近
💡 这一切都不是巧合。这些地址的分布,完美对应了 Linux 进程地址空间的标准布局。
二、🏗️ 进程地址空间的完整布局
进程地址空间(Process Address Space)是操作系统为每个进程抽象出来的一个连续的、私有的虚拟内存范围。它不是真实的物理内存,而是一个逻辑上的概念。
在 32 位 Linux 系统中,每个进程都拥有 4GB 的虚拟地址空间(2^32 字节)。其中:
- 高 1GB(
0xC0000000 ~ 0xFFFFFFFF)是内核空间,由所有进程共享 - 低 3GB(
0x00000000 ~ 0xBFFFFFFF)是用户空间,每个进程独立拥有
64 位系统的虚拟地址空间则大得多,通常使用 48 位虚拟地址,理论上可以寻址 256TB 的空间,用户空间和内核空间各占一半左右。
从低地址到高地址,用户空间的标准布局如下:
| 区域名称 | 地址范围(32位示例) | 存储内容 | 权限 | 增长方向 |
|---|---|---|---|---|
| 正文代码段(Text) | 0x08048000 附近 | 程序的机器指令、只读常量 | 只读、可执行 | 固定 |
| 初始化数据段(Data) | 代码段之后 | 已初始化的全局变量、静态变量 | 可读、可写 | 固定 |
| 未初始化数据段(BSS) | 数据段之后 | 未初始化的全局变量、静态变量 | 可读、可写 | 固定 |
| 堆(Heap) | BSS段之后 | 动态分配的内存(malloc/new) | 可读、可写 | 向上(地址增大) |
| 共享库映射区 | 堆和栈之间 | 动态链接库的代码和数据 | 依段而定 | 向下(地址减小) |
| 栈(Stack) | 0xBFFFFFFF 附近 | 局部变量、函数参数、返回地址 | 可读、可写 | 向下(地址减小) |
| 命令行参数&环境变量 | 栈的最顶端 | 命令行参数 argv、环境变量 env | 可读、可写 | 固定 |
2.1 栈与堆:方向相反的"生长者"
最容易混淆的就是栈和堆的增长方向。从代码运行结果可以清晰地看到:
- 栈向下增长:后定义的局部变量地址更低。栈是从高地址向低地址分配内存的,每次函数调用,栈帧都会被压入栈顶(地址更低的位置);函数返回时,栈帧弹出
- 堆向上增长:后 malloc 的内存地址更高。堆是从低地址向高地址分配内存的,每次 malloc 都会在当前堆的末尾申请新的空间
⚠️ 这种相反的增长方向,最大化地利用了中间的空闲地址空间。栈和堆向中间生长,直到它们相遇,此时内存耗尽。
2.2 静态变量的"错觉":为什么它看起来在代码段?
很多初学者会误以为静态变量存储在代码段,因为它的地址和代码段很接近。但实际上:
- 静态局部变量和全局变量存储在数据段(Data 或 BSS),而不是代码段
- 代码段是只读的,而静态变量是可写的,这就决定了它不可能在代码段
- 它们地址接近,只是因为数据段紧跟在代码段之后
在我们的例子中:
g_val = 100(已初始化全局变量)和static int test = 10(已初始化静态变量)都存储在初始化数据段(Data)g_unval(未初始化全局变量)存储在未初始化数据段(BSS)。BSS 段的特点是在程序加载时会被内核自动初始化为 0,这就是为什么未初始化全局变量默认值是 0 的原因
2.3 字符串常量:真正的只读区域
字符串常量 "helloworld" 存储在只读数据段(rodata),它通常和代码段合并在一起,拥有相同的只读权限。如果你尝试修改字符串常量的内容:
char *str = "helloworld";
str[0] = 'H'; // 段错误(Segmentation fault)
程序会立即崩溃,触发段错误。这是因为页表中标记了这个区域为只读,任何写入操作都会被操作系统拦截。
三、🧪 虚拟地址的铁证:fork 的"魔法"
现在,让我们运行第二段代码,它将彻底颠覆你对"地址"的认知:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 0;
int main()
{
int id = fork();
if(id == 0) {
// 子进程
while(1) {
printf("子进程---g_val:%d---&g_val:%p---pid:%d---ppid:%d\n",
g_val, &g_val, getpid(), getppid());
g_val++;
sleep(1);
}
} else {
// 父进程
while(1) {
printf("父进程---g_val:%d---&g_val:%p---pid:%d---ppid:%d\n",
g_val, &g_val, getpid(), getppid());
sleep(1);
}
}
return 0;
}
运行结果会让你大吃一惊:
父进程---g_val:0---&g_val:0x60104c---pid:11887---ppid:3807
子进程---g_val:0---&g_val:0x60104c---pid:11886---ppid:11887
父进程---g_val:0---&g_val:0x60104c---pid:11887---ppid:3807
子进程---g_val:1---&g_val:0x60104c---pid:11886---ppid:11887
父进程---g_val:0---&g_val:0x60104c---pid:11887---ppid:3807
子进程---g_val:2---&g_val:0x60104c---pid:11886---ppid:11887
...
核心现象:
- 父子进程打印出的
&g_val地址完全相同(都是0x60104c) - 子进程不断修改
g_val的值,但父进程的g_val始终是 0
这在物理内存中是绝对不可能发生的!如果两个指针指向同一个物理地址,那么一个修改,另一个必然会看到变化。
唯一的解释:我们在 C/C++ 中看到的所有地址,都是虚拟地址(Virtual Address),而不是物理地址(Physical Address)。
四、🎯 虚拟地址空间的本质:操作系统的"大饼"
4.1 大富翁与私生子的故事
为了理解虚拟地址空间,我给你讲一个经典的比喻:
操作系统是一个大富翁,拥有整个城市的土地(物理内存)。每个进程都是大富翁的私生子。
大富翁不想让私生子们互相打架,也不想让他们知道自己有多少兄弟,更不想让他们随意闯入自己的豪宅(内核空间)。于是,他给每个私生子都画了一张一模一样的城市地图(4GB 虚拟地址空间),并告诉他们:“这整个城市都是你的,你可以随便用。”
每个私生子都信以为真,以为自己独占了整个城市。但实际上,大富翁只在他们真正需要盖房子(访问内存)的时候,才会给他们分配一块真实的土地(物理内存页),并在他们的地图上做个标记(页表映射)。
如果两个私生子都在地图的同一个位置(相同虚拟地址)盖了房子,大富翁会给他们分配不同的真实土地,让他们互不干扰。
这就是虚拟地址空间的核心思想:让每个进程都以为自己独占了整个内存,而实际上操作系统在背后管理着物理内存的分配和映射。
4.2 4GB 的由来与真相
很多人会问:“32 位系统每个进程都有 4GB 虚拟地址空间,那如果有 100 个进程,岂不是需要 400GB 物理内存?”
这是对虚拟地址空间最大的误解。虚拟地址空间只是一个逻辑上的地址范围,和物理内存大小没有直接关系。
- 32 位系统的虚拟地址是 32 位的,所以可以表示 2^32 = 4294967296 个地址,也就是 4GB
- 64 位系统通常使用 48 位虚拟地址,所以可以表示 2^48 = 281474976710656 个地址,也就是 256TB
操作系统不会也不可能为每个进程一次性分配 4GB 物理内存。实际上,当进程启动时,操作系统只分配了最基本的内核数据结构(task_struct、mm_struct、页表),只有当进程真正访问某个虚拟地址时,才会通过缺页中断机制分配物理内存。
💡 这就是"延迟分配"策略——用多少,给多少,绝不浪费。
五、🔧 内核如何管理虚拟地址空间:mm_struct 与 VMA
5.1 内存描述符:mm_struct
在 Linux 内核中,每个进程的虚拟地址空间由一个叫做 mm_struct 的结构体描述,它定义在 <linux/mm_types.h> 中。每个进程的 task_struct(进程控制块)里都有一个指向自己 mm_struct 的指针。
mm_struct 的核心成员如下:
struct mm_struct {
struct vm_area_struct *mmap; // 指向虚拟内存区域(VMA)链表
struct rb_root mm_rb; // VMA的红黑树,用于快速查找
unsigned long task_size; // 虚拟地址空间大小
// 各个段的起始和结束地址
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 数据段
unsigned long start_brk, brk; // 堆的起始和当前结束
unsigned long start_stack; // 栈的起始地址
unsigned long arg_start, arg_end; // 命令行参数
unsigned long env_start, env_end; // 环境变量
pgd_t *pgd; // 页全局目录(Page Global Directory)指针
// ... 其他成员
};
你看,我们之前看到的所有段的地址,都在 mm_struct 里有对应的记录。当堆需要增长时,操作系统只需要修改 brk 的值;当栈需要增长时,只需要向下扩展 start_stack。这就是为什么区域划分如此灵活的原因。
5.2 虚拟内存区域:vm_area_struct
你可能会问:“mm_struct 里只有一个堆的起始和结束地址,但我 malloc 了很多次,这些内存块是怎么管理的?”
这就引出了另一个重要的结构体:vm_area_struct(虚拟内存区域,简称 VMA)。
mm_struct 并没有把整个用户空间当作一个整体来管理,而是把它划分成了多个独立的 VMA。每个 VMA 代表一个连续的、具有相同属性(权限、映射类型等)的虚拟地址范围。
例如:
- 代码段是一个 VMA,属性是
VM_READ | VM_EXEC(只读、可执行) - 数据段是一个 VMA,属性是
VM_READ | VM_WRITE(可读、可写) - 堆是一个 VMA,属性是
VM_READ | VM_WRITE - 每个动态链接库的代码段和数据段,也都是各自独立的 VMA
vm_area_struct 的核心成员:
struct vm_area_struct {
unsigned long vm_start; // 虚拟区域起始地址
unsigned long vm_end; // 虚拟区域结束地址
struct vm_area_struct *vm_next, *vm_prev; // 链表指针
struct rb_node vm_rb; // 红黑树节点
struct mm_struct *vm_mm; // 所属的 mm_struct
pgprot_t vm_page_prot; // 该区域的页权限
unsigned long vm_flags; // 标志位
// ... 其他成员
};
所有的 VMA 通过链表和红黑树两种方式组织起来:
- 链表用于顺序遍历所有 VMA
- 红黑树用于快速查找某个地址属于哪个 VMA,时间复杂度是 O(log n)
这就回答了"堆的复数性"问题:虽然我们看到的堆是一个连续的地址范围,但它实际上是一个 VMA。而 malloc 分配的内存块,是在这个 VMA 内部进行的更细粒度的管理,由 C 标准库的 malloc 实现(比如 ptmalloc)负责。
六、🌉 虚拟地址到物理地址的桥梁:页表与缺页中断
6.1 页表:映射的核心
虚拟地址和物理地址之间的映射关系,是通过 页表(Page Table) 来维护的。每个进程都有自己独立的页表,存储在内核空间中。
Linux 采用分页机制来管理内存,它把虚拟地址空间和物理内存都划分成固定大小的页(Page)。在 x86 系统上,默认页大小是 4KB。
分页机制的核心思想是:以页为单位进行映射和内存分配。
一个 32 位的虚拟地址会被分成两部分:
- 高 20 位:页号(Page Number)
- 低 12 位:页内偏移(Offset)
地址转换过程:
- CPU 根据虚拟地址的页号,在页表中查找对应的物理页号(Physical Page Number)
- 将物理页号与页内偏移组合,得到最终的物理地址
- 用物理地址访问实际的内存单元
6.2 缺页中断:按需分配的精髓
当进程访问一个虚拟地址时,如果页表中没有对应的映射(即该虚拟地址还没有分配物理内存),CPU 会触发一个 缺页中断(Page Fault)。
缺页中断的处理流程:
- CPU 触发中断:访问未映射的虚拟地址
- 操作系统介入:保存当前进程状态,转入内核态
- 检查合法性:确认该虚拟地址是否在进程的合法地址范围内(通过遍历 VMA)
- 分配物理页:如果合法,从空闲物理页中分配一页
- 更新页表:建立虚拟地址到物理地址的映射
- 恢复执行:重新执行触发中断的指令
💡 这就是"按需分页"(Demand Paging)机制——进程启动时并不加载全部数据,只有真正访问时才分配物理内存。这大大节省了物理内存的使用。
6.3 写时复制(Copy-On-Write)
还记得前面 fork 的例子吗?父子进程共享同一个物理页,直到其中一个尝试写入时,才会复制一份新的物理页。这就是 写时复制(Copy-On-Write, COW) 机制。
COW 的流程:
- fork 时,父子进程的页表指向相同的物理页,页表项标记为只读
- 任一进程尝试写入时,触发缺页中断
- 操作系统发现是 COW 页,于是复制一份新的物理页
- 修改写入进程的页表,指向新的物理页,并标记为可写
- 重新执行写入指令
这就是父子进程能看到相同虚拟地址、但互不影响的秘密!
七、🤔 几个思考题
学完本文,来试试回答这些问题:
1️⃣ 为什么 32 位系统每个进程的虚拟地址空间是 4GB,而不是其他大小?
答: 因为 32 位系统的虚拟地址是 32 位的,可以表示 2^32 = 4294967296 个不同的地址。每个地址对应 1 字节,所以总空间是 4294967296 字节 = 4GB。这是由地址总线的位数决定的,是硬件层面的限制。
💡 拓展:64 位系统理论上可以寻址 2^64 = 16EB(艾字节),但实际只使用 48 位虚拟地址,可寻址 256TB,这是因为目前的硬件和软件还不需要那么大的地址空间。
2️⃣ 如果两个进程打印出相同的指针地址,它们访问的是同一块物理内存吗?
答: 不是。每个进程拥有独立的虚拟地址空间和独立的页表。相同的虚拟地址在不同进程的页表中会映射到不同的物理页。操作系统通过页表实现了进程间的内存隔离,确保一个进程无法直接访问另一个进程的内存。
💡 拓展:这也是进程间通信(IPC)需要特殊机制(如管道、共享内存、消息队列)的原因——默认情况下进程内存是完全隔离的。
3️⃣ 为什么栈向下增长,而堆向上增长?
答: 这是为了最大化利用地址空间。栈和堆是程序运行时使用最频繁的动态内存区域,它们从两端向中间生长。这种设计让两者可以共享中间的空闲区域,避免了预先为栈或堆分配固定大小空间的浪费。只有当它们相遇时,才会出现内存耗尽的情况。
💡 拓展:在 32 位系统中,栈通常从
0xC0000000(3GB 边界)附近开始向下增长;在 64 位系统中,栈的初始位置更高。
4️⃣ 修改字符串常量为什么会触发段错误?
答: 字符串常量存储在只读数据段(rodata),页表中该区域的页表项被标记为只读(Read-Only)。当 CPU 执行写入操作时,内存管理单元(MMU)会检查页权限,发现写入操作不被允许,于是触发保护异常,操作系统将其转换为**段错误(SIGSEGV)**信号发送给进程。
💡 拓展:这也是代码段(Text Segment)不能被修改的原因——页权限保护是操作系统实现内存保护的核心机制。
5️⃣ 为什么未初始化的全局变量默认值为 0?
答: 未初始化的全局变量存储在 BSS 段。BSS 段在可执行文件中不占用磁盘空间(只记录大小),但在程序加载时,操作系统会为 BSS 段分配物理内存,并自动初始化为 0。这是由操作系统加载器(loader)完成的,目的是确保程序在启动时所有全局变量都有确定的初始值。
💡 拓展:相比之下,已初始化的全局变量存储在 Data 段,其初始值保存在可执行文件中,加载时直接从文件读取到内存。
本节完
✅ 本节完…
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)