Linux进程万字详解(四) | 进程地址空间深度剖析:虚拟内存、页表映射与缺页中断

目录

引言:

一、程序内存布局的直观观察

[1.1 一段揭示内存分布的代码](#1.1 一段揭示内存分布的代码)

[1.2 运行结果与分析](#1.2 运行结果与分析)

[1.3 关键观察与结论](#1.3 关键观察与结论)

二、从"程序地址空间"到"进程地址空间"

[2.1 一个关键认知转变](#2.1 一个关键认知转变)

[2.2 证明虚拟地址的存在](#2.2 证明虚拟地址的存在)

三、虚拟地址空间的核心机制

[3.1 虚拟地址空间的本质](#3.1 虚拟地址空间的本质)

[3.2 页表:虚拟地址到物理地址的桥梁](#3.2 页表:虚拟地址到物理地址的桥梁)

[3.3 父子进程的页表机制](#3.3 父子进程的页表机制)

[3.4 回顾fork的返回值之谜](#3.4 回顾fork的返回值之谜)

四、虚拟地址空间的内核实现

[4.1 "画饼"的比喻](#4.1 "画饼"的比喻)

[4.2 mm_struct:虚拟地址空间的内核表示](#4.2 mm_struct:虚拟地址空间的内核表示)

[4.3 vm_area_struct:细粒度的区域管理](#4.3 vm_area_struct:细粒度的区域管理)

五、为什么需要虚拟地址空间

[5.1 将无序变为有序](#5.1 将无序变为有序)

[5.2 保护物理内存](#5.2 保护物理内存)

[5.3 进程管理与内存管理的解耦](#5.3 进程管理与内存管理的解耦)

六、进程创建的完整图景

[6.1 创建顺序](#6.1 创建顺序)

[6.2 延迟分配与缺页中断](#6.2 延迟分配与缺页中断)

七、进程的独立性再理解

结语


引言:

在Linux系统中,每个进程都生活在一个"幻觉"之中------它以为自己独占了整个计算机的内存资源。这种幻觉并非系统的欺骗,而是一种精心设计的工程智慧,它就是我们今天要深入探讨的进程地址空间(Process Address Space) ,也称为虚拟地址空间(Virtual Address Space)。

进程地址空间是连接进程管理与内存管理的核心枢纽。它既与硬件(CPU的MMU内存管理单元)密切相关,又深度嵌入操作系统内核;既影响着编译器生成可执行程序的方式,又决定了动态链接库加载的底层逻辑;甚至在多线程编程中,线程共享与隔离的边界也由它划定。正因为其牵涉面如此之广,理解进程地址空间成为掌握Linux系统编程的关键一步。

本文将从一段简单的C程序出发,通过观察内存地址的分布规律,逐步揭示虚拟地址空间的本质;通过父子进程的奇妙现象,证明虚拟地址与物理地址的分离;最终深入内核源码,剖析mm_struct与vm_area_struct的数据结构设计,理解页表映射、写时拷贝、缺页中断与进程挂起的底层机制。让我们开始这段从用户代码到内核结构的深度之旅。


一、程序内存布局的直观观察

1.1 一段揭示内存分布的代码

让我们从一个实验开始,用C语言打印程序中各类数据的内存地址:

cpp 复制代码
#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]);
    }
    
    return 0;
}

1.2 运行结果与分析

bash 复制代码
code addr: 0x40059d
init global addr: 0x60103c
uninit global addr: 0x601048
heap addr: 0x1211c20
heap addr: 0x1211c40
heap addr: 0x1211c60
heap addr: 0x1211c80
test static addr: 0x601040
stack addr: 0x7ffc574ca958
stack addr: 0x7ffc574ca950
stack addr: 0x7ffc574ca948
stack addr: 0x7ffc574ca940
read only string addr: 0x400840
argv[0]: 0x7ffc574cb770
env[0]: 0x7ffc574cb778
...

地址分布规律:

区域 地址范围 特征
代码区(Text) 0x400000附近 最低地址,包含可执行指令
只读数据区 0x400840 与代码区地址接近,字符串常量存放于此
已初始化数据区(Data) 0x60103c 全局变量、静态变量
未初始化数据区(BSS) 0x601048 未初始化的全局变量
堆区(Heap) 0x1211c20起 向上增长,malloc分配
栈区(Stack) 0x7ffc... 向下增长,局部变量、函数参数
命令行参数与环境变量 0x7ffc574cb... 位于栈区上方

1.3 关键观察与结论

(1)常量字符串的存放位置

常量字符串"helloworld"的地址(0x400840)与main函数地址(0x40059d)十分接近。这说明字符串常量实际上被硬编码在代码段中。由于代码段是只读的,因此字符串常量自然也是只读的。试图修改字符串常量会触发段错误(Segmentation Fault),这正是操作系统的内存保护机制在起作用。

(2)static变量的本质

test是局部变量,理论上应在栈上,但用了static修饰后,其地址(0x601040)与全局变量处于同一区域。这说明:static修饰的局部变量本质上就是全局变量,只是其作用域被限制在定义它的函数内部,生命周期则延长至整个程序运行期间。

(3)全局变量的地址顺序

已初始化全局变量(0x60103c)的地址低于未初始化全局变量(0x601048)。这符合标准的内存布局规范:Data段在前,BSS段在后。

(4)堆与栈的增长方向

堆地址从低向高增长(0x1211c20 → 0x1211c40 → 0x1211c60 → 0x1211c80),栈地址从高向低增长(0x7ffc574ca958 → 0x7ffc574ca950 → ...)。两者之间存在大片未使用的地址空间。


二、从"程序地址空间"到"进程地址空间"

2.1 一个关键认知转变

曾经我们学的"程序地址空间",它不是物理内存。

如果每个进程都按照上述固定布局直接占用物理内存,那么:

  • 新进程如何加入?老进程如何退出?
  • 多个进程同时运行时,地址如何分配?
  • 如何保证每个进程都有独立的地址范围?

物理内存不可能保证每个进程都是这种规则的布局。因此,我们需要一个更本质的概念------进程地址空间(Process Address Space) ,也称为虚拟地址空间(Virtual Address Space)。

这是一个操作系统层面的概念 ,而非语言层面的概念。C/C++指针使用的地址、进程看到的地址,全都是虚拟地址。物理地址对用户完全不可见,由操作系统统一管理。


2.2 证明虚拟地址的存在

让我们通过一个实验来证明这一点:

复制代码
#include <iostream>
#include <unistd.h>
using namespace std;

int gval = 100;

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        // 子进程
        while(1)
        {
            cout << "子:gval:" << gval 
                 << " &gval:" << &gval 
                 << " pid:" << getpid() 
                 << " ppid:" << getppid() << endl;
            sleep(1);
            gval++;
        }
    }
    else
    {
        // 父进程
        while(1)
        {
            cout << "父:gval:" << gval 
                 << " &gval:" << &gval 
                 << " pid:" << getpid() 
                 << " ppid:" << getppid() << endl;
            sleep(1);
        }
    }
    return 0;
}

运行结果:

复制代码
父:gval:100 &gval:0x60108c pid:28800 ppid:13036
子:gval:100 &gval:0x60108c pid:28801 ppid:28800
父:gval:100 &gval:0x60108c pid:28800 ppid:13036
子:gval:101 &gval:0x60108c pid:28801 ppid:28800

奇妙的现象:

  • 子进程的gval在修改(100 → 101 → 102...)
  • 父进程的gval保持不变(始终100)
  • 但两个进程的&gval地址完全相同 (0x60108c)

结论: 这绝不可能是物理内存地址。同一个物理地址不可能同时存储两个不同的值(100和101)。因此,我们看到的地址是虚拟地址,C/C++指针使用的全都是虚拟地址。


三、虚拟地址空间的核心机制

3.1 虚拟地址空间的本质

每个进程拥有独立的虚拟地址空间。 在32位系统中,虚拟地址空间的范围是0到2^32-1,即4GB。其中:

  • 0 ~ 3GB:用户空间(User Space)
  • 3GB ~ 4GB:内核空间(Kernel Space)

用户可以直接访问用户空间的数据。代码编译后,变量名消失,替换为内存地址,通过这些地址即可访问代码、数据、环境变量等。


3.2 页表:虚拟地址到物理地址的桥梁

当定义一个全局变量g_val时:

  1. 物理内存中存储g_val,其地址是物理地址
  2. 虚拟地址空间中,也有一个对应的g_val位于初始化数据区,其地址是虚拟地址
  3. 操作系统为每个进程维护一张页表(Page Table)
  4. 页表左侧记录虚拟地址,右侧记录对应的物理地址

访问流程:

复制代码
进程访问虚拟地址 → OS查页表 → 转换为物理地址 → 访问实际数据

这个转换过程由硬件(MMU,内存管理单元)自动完成,对程序完全透明。

程序加载的完整流程:

将程序加载到内存,本质上需要完成两个核心步骤:

  1. 在虚拟地址空间中申请指定大小的空间 :操作系统根据可执行文件的信息,调整mm_struct中的区域划分(设置各段的start和end)
  2. 在物理内存中申请空间并加载内容:将代码和数据写入物理内存

这两个步骤通过页表 建立映射关系。从操作系统的视角看,是先确定了物理地址,再将其"翻译"为虚拟地址提供给上层用户。因此也可以理解为:OS将物理地址转化为虚拟地址。

以代码段为例:编译后代码占多少字节,OS在加载时是清楚的。先将代码写入物理内存,然后在虚拟地址空间中开辟同等大小的区域,最后在页表中建立两者的映射。这组映射关系在加载时就已准备好,因此后续访问虚拟地址时,一定能通过页表找到对应的物理地址。

mm_struct作为内核对象需要初始化,其初始值的主要来源正是程序加载到内存的过程 。编译器在生成可执行文件时已确定各段的大小和属性,加载器读取这些信息后填充到mm_struct中,从而完成虚拟地址空间的初始化。


3.3 父子进程的页表机制

子进程的创建:

  • 子进程的task_struct拷贝自父进程
  • 子进程的虚拟地址空间拷贝自父进程
  • 子进程的页表也拷贝自父进程(浅拷贝)

因此,在子进程未修改数据前:

  • 父子进程的虚拟地址相同
  • 页表映射的物理地址也相同
  • 所以看到的全局变量值相同

写时拷贝(Copy-On-Write):

当子进程试图修改全局变量时:

  1. 操作系统介入(因为进程需要保持独立性)
  2. 为子进程在物理内存中开辟新空间
  3. 将原变量的内容拷贝到新空间
  4. 修改子进程页表的映射关系(虚拟地址不变,物理地址改变)
  5. 子进程在新物理地址上进行修改

这就是写时拷贝 机制。它解释了为什么父子进程的全局变量值不同,但虚拟地址相同------虚拟地址一样,物理地址已经分道扬镳。

代码段也是类似的共享机制。默认情况下,程序的代码和数据都被父子进程共享(通过页表映射到同一物理内存),直到发生写操作时才分离。


3.4 回顾fork的返回值之谜

在《Linux进程万字详解(一)》中,我们提出了fork的第三个问题:为什么同一个变量既==0又>0?

现在可以进一步解释:

  • fork()返回前,父子进程共享同一段代码和数据
  • 返回时,对返回值变量的写入触发了写时拷贝
  • 父进程中的返回值被写入父进程的物理内存
  • 子进程中的返回值被写入子进程的物理内存
  • 两者虚拟地址相同(都是接收返回值的变量地址),但物理地址不同
  • 因此父进程看到子进程PID(>0),子进程看到0

所有这些操作都由操作系统自动完成,无需用户干预。


四、虚拟地址空间的内核实现

4.1 "画饼"的比喻

理解虚拟地址空间,可以用一个生动的比喻:

一位大富翁有10亿美元资产,他有多个互不相识的私生子。大富翁对每个私生子都说:"只要你好好干,将来我的资产全是你的。"私生子们零散地向大富翁要小钱,大富翁都会给;但要大钱,就会被拒绝。

这里,大富翁就是操作系统 ,10亿美元就是物理内存 ,私生子们就是进程 ,那张"将来资产全给你"的大饼就是虚拟地址空间。

操作系统给每个进程"画饼",让每个进程都以为自己独占4GB内存(32位系统)。这种设计需要被管理------先描述,再组织。


4.2 mm_struct:虚拟地址空间的内核表示

虚拟地址空间在内核中的实现是一个数据结构------struct mm_struct。

区域划分的原理:

想象课桌上的"三八线":

  • 桌子宽度100cm,刻度0~99
  • 不需要保存每个刻度,只需记录区域的起始和结束
  • 调整区域只需修改起始/结束值

虚拟地址空间同理:

  • 地址范围0到0xFFFFFFFF(32位)
  • 用整数记录每个区域的start和end

mm_struct的核心属性:

复制代码
struct mm_struct {
    // 代码段
    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;
    // ...
};

在当前进程的task_struct内部,有mm_struct*类型的成员,这个指针指向当前进程自己的地址空间。当程序变成进程时,代码和数据加载到物理内存,占用多少物理字节,就在虚拟地址空间中同批次开辟同等大小,填充页表时建立一一映射关系。


4.3 vm_area_struct:细粒度的区域管理

堆区并非连续的一块区域。动态内存分配(malloc)可能产生多个离散的内存块,每个块有自己的起始和结束地址。mm_struct中的start_brk和brk只能描述整个堆的范围,如何管理内部的多个子区域?

答案是 vm_area_struct(虚拟内存区域):

cpp 复制代码
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
    unsigned long vm_flags;      // 标志位(读写执行权限)
    // ...
};

组织方式:

  • 虚拟区域较少时:使用单链表 ,mmap指针指向链表头
  • 虚拟区域较多时:使用红黑树 ,mm_rb指向树根,实现O(log n)查找

mm_struct描述整个地址空间的概貌,而vm_area_struct精确管理每个子区域。进程访问代码时,可以直接定位到对应的vm_area_struct,无需关心mm_struct中的整体边界。


五、为什么需要虚拟地址空间

虚拟地址空间的存在解决了三个核心问题:

5.1 将无序变为有序

物理内存中,多个进程的代码和数据交叉存放,位置随机。但每个进程的虚拟地址空间都遵循统一的布局规则(代码段→数据段→堆→栈)。

通过页表的映射,操作系统可以将物理内存中的任意位置,映射到虚拟地址空间的有序位置。上层应用程序看到的始终是规则、连续的地址分布,无需关心物理内存的实际混乱状态。


5.2 保护物理内存

地址转换不仅是映射,更是合法性检查。

页表除了存储虚拟地址和物理地址的映射,还记录访问权限(读/写/执行)。当进程试图:

  • 写入只读的代码段 → 权限检查失败 → 触发段错误
  • 访问未分配的地址 → 页表无映射 → 触发段错误
  • 执行数据段的代码 → 权限检查失败 → 触发段错误

野指针的本质: 访问一个虚拟地址,但页表中无此映射(未分配或已释放),操作系统直接终止进程。这就是为什么char* str = "hello"; *str = 'H';能编译通过,但运行崩溃------字符常量区在页表中被标记为只读。

所以这种语法特性和你是C,C++没有关系,这是操作系统层面的原因


5.3 进程管理与内存管理的解耦

假设进程代码有2GB,但物理内存紧张,只能加载500MB。虚拟地址空间可以:

  1. 映射完整的2GB虚拟地址
  2. 只建立前500MB的物理映射
  3. 访问剩余1.5GB时,发现虚拟地址合法但物理地址缺失
  4. 操作系统暂停进程,从磁盘加载更多数据到物理内存
  5. 更新页表映射,恢复进程运行

这就是缺页中断(Page Fault)。它使得:

  • 进程只需关心自己的虚拟地址空间
  • 内存管理(加载、换出、分配)由操作系统透明处理
  • 两者通过页表解耦,互不干扰

进程挂起的实现: 当内存紧张时,操作系统:

  1. 找到等待队列中的进程
  2. 查页表,清空物理地址映射
  3. 将对应物理内存的数据写入磁盘swap分区
  4. 进程依然存在,但代码和数据已换出

下次访问时触发缺页中断,从swap重新加载。这正是解耦设计的优雅体现。

反向思考:没有虚拟地址空间会怎样?

假设去掉虚拟地址空间和页表,让进程直接指向物理内存。此时进程要加载代码和数据,就必须直接与物理内存打交道------每申请一块内存,都要修改task_struct中的指针来记录位置;内存紧张时需要换出数据,又要再次更新PCB。进程管理与内存管理将强耦合在一起,任何内存变动都会波及进程结构。

而有了虚拟地址空间和页表,进程管理只需关心虚拟地址的分配(调整mm_struct),内存管理只需关心物理内存的调配(加载、换出、回收),两者通过页表交互,互不干扰。代码加载内存时,只需修改页表映射,PCB保持不变,实现了优雅的解耦。


六、进程创建的完整图景

6.1 创建顺序

先有内核数据结构,后有代码和数据。

创建进程时:

  1. 分配task_struct
  2. 分配mm_struct(虚拟地址空间)
  3. 建立页表(初始为空或复制父进程)
  4. 后续逐步加载代码和数据(按需分配,延迟加载)

6.2 延迟分配与缺页中断

malloc申请内存时:

  • 只在虚拟地址空间上分配(调整mm_struct中的区域边界)
  • 物理内存可能一个字节都不分配
  • 真正访问时,触发缺页中断
  • 操作系统执行内存分配算法,申请物理内存,建立页表映射

这种延迟分配策略提高了内存使用效率,由操作系统自动完成,用户和进程完全无感知。


七、进程的独立性再理解

我们每一个进程都有自己独立的PCB、独立的虚拟地址空间、独立的页表,而页表映射时映射到不同的物理内存,这样进程就具有独立性了。

进程 = PCB + 自己的代码和数据

今天,我们补充了PCB里的两个直接和间接成员类型:

  • mm_struct:直接成员,指向进程的虚拟地址空间
  • vm_area_struct :间接成员,通过mm_struct访问,管理虚拟地址空间的各个子区域

描述Linux下进程地址空间的所有信息的结构体是mm_struct(内存描述符)。每个进程只有一个mm_struct结构,在每个进程的task_struct结构中,有一个指向该进程mm_struct结构体的指针:

cpp 复制代码
struct task_struct {
    // ...
    struct mm_struct *mm;        // 对于普通用户进程,指向其虚拟地址空间的用户空间部分
    struct mm_struct *active_mm; // 内核线程使用
    // ...
};

对于内核线程,mm字段为NULL,因为内核线程没有用户空间内存地址空间。但所有进程关于内核的映射都是一样的,所以内核线程可以使用任意进程的地址空间。


结语

通过本文的深入探讨,我们完整揭开了Linux进程地址空间的神秘面纱。从一段简单的C程序出发,我们观察到了内存布局的规律;通过父子进程的奇妙实验,我们证明了虚拟地址与物理地址的分离;最终深入内核源码,理解了mm_struct与vm_area_struct的设计哲学,以及页表映射、写时拷贝、缺页中断等核心机制。

虚拟地址空间的设计堪称操作系统中最精妙的抽象之一。它通过"画饼"的方式,让每个进程都以为自己独占内存;通过页表的映射,将物理内存的无序变为虚拟地址的有序;通过权限检查,守护了系统的安全稳定;通过解耦设计,让进程管理与内存管理各司其职。这些机制相互配合,共同构成了现代操作系统高效、安全、灵活的内存管理基石。

回顾我们走过的路:从冯诺依曼体系结构到进程控制块,从进程状态到调度算法,再到今天的虚拟地址空间------Linux进程管理的全景图正在逐步展开。进程地址空间不仅是理解内存管理的钥匙,更是后续学习动态链接、线程机制、进程间通信的必备基础。希望本文能为读者建立坚实的认知框架,在系统编程的道路上走得更远、更稳。

操作系统的设计之美,正在于这些看似抽象的机制背后,蕴含着对硬件特性的深刻洞察、对软件需求的精准把握,以及对工程复杂度的优雅掌控。进程地址空间,正是这种设计之美的绝佳体现。

相关推荐
叼烟扛炮1 小时前
Linux 学习笔记(二十九):进程信号(上)—— 信号捕捉与异步魔法
linux·运维·笔记·学习·1024程序员节
wuminyu1 小时前
JVM规范学习总结-Class文件结构
java·linux·c语言·jvm·c++
m0_547486661 小时前
《Linux操作系统应用项目教程》全套PPT课件2026
linux·运维·操作系统
西索斯coding1 小时前
qwen3.8-max 和 minimax-m3 改后端仓库实测复核:补丁通过率与调用成本别急着排冠军
linux·运维·ubuntu·ai
紧跟先前的步伐1 小时前
Go AI Agent 实战 18|Tool Calling 的基本原理与完整执行流程
前端·数据库·人工智能·ai·golang·1024程序员节
能源探索家1 小时前
本周能源政策密集落子:电力现货规则修订、碳市场扩围至石化化工、储能升格新兴支柱产业
能源·1024程序员节·新型电力系统·十五五规划·能源信息化·能源资讯
女神下凡1 小时前
芯参谋(71):全桥电机驱动电路设计指南
arm开发·stm32·单片机·嵌入式硬件·硬件工程·设计规范·1024程序员节
Hello World . .2 小时前
【嵌入式学习笔记 Day 1】
linux·数据结构·笔记
叼烟扛炮2 小时前
Linux 学习笔记(三十一):线程概念、底层分页机制与初体验
linux·运维·服务器·笔记·学习·1024程序员节