在这里插入图片描述

.

个人主页:晓风飞
专栏:数据结构|Linux|C语言
路漫漫其修远兮,吾将上下而求索



在这里插入图片描述

你看到的地址,从来都不是真的

引子:上节课留下的东西

先花三分钟把上节课的结论钉牢——它们跟今天的话题是连着的。

操作系统在 Bash 里维护了一组系统级的全局变量,叫环境变量PATH 记录二进制程序的搜索路径,HOME 记录家目录,PWDOLDPWD 分别记录当前和上一次的工作路径,HISTSIZE 记录历史命令条数,HOSTNAME 记录主机名,USER 记录用户名。它们各有各的功能,彼此独立,没有明显的逻辑关系。系统开机时,每个用户的 Bash 从系统配置文件里加载这些变量。

在进程——尤其是父进程 Bash——内部,存在两张表:命令行参数表环境变量表。命令行参数用来给可执行程序设置选项,这就是为什么 ls -lpwd 这些命令能带选项。获取环境变量有三种方式:通过 main 的第三个参数 envp、通过全局变量 environ、通过系统调用 getenv()。配套的命令有 echo $VAR(查看)、export VAR=value(导出)、env(查看全部)、set(查看含本地变量)、unset VAR(取消)。

环境变量为什么具有全局属性?因为环境变量表可以被子进程继承。父进程 Bash 从配置文件获取环境变量后,fork 创建子进程,子进程拿到父进程的环境变量表;子进程的子进程同样能拿到。从登录系统开始,所有任务形成一棵进程树——从 Bash 往后,所有直接或间接创建的子进程都能拿到环境变量,这就叫全局属性。而本地变量只在本 Bash 内部有效——比如你在命令行里用 while 循环定义的变量。

上节课末尾我们刚开了个头:程序地址空间。在 32 位平台下,从低地址到高地址,依次是代码段(正文段)、初始化数据区、未初始化数据区(BSS)、堆区、栈区——堆和栈相对而生。命令行参数和环境变量也在这张图里。我们当时用代码验证了这些区域的排布确实如此,并且解决了两个问题:为什么字符串常量不能修改?(因为它被编到了只读的代码区)为什么 static 变量生命周期变全局?(因为编译后局部变量被提升进了全局数据区。)

有了这些垫底,今天进入正题。今天的东西比较干——它是你第一次真正意义上的认知升级,往后学进程、学内存管理,全跟它有关。


一个实验,三个版本

版本一:都不改

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_val = 0;

int main()
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return 0;
    }
    else if (id == 0) {  // child
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    else {  // parent
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    sleep(1);
    return 0;
}

编译运行:

gcc -o myproc myproc.c
./myproc

输出(与环境相关,观察现象即可):

parent[2995]: 0 : 0x80497d8
child[2996]:  0 : 0x80497d8

变量值和地址一模一样。这很好理解——子进程以父进程为模板,父子都没有对变量做任何修改。

版本二:子进程改,父进程等

把代码改一下:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_val = 0;

int main()
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return 0;
    }
    else if (id == 0) {  // child:子进程先跑完,先修改,完成之后父进程再读取
        g_val = 100;
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    else {  // parent
        sleep(3);
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    sleep(1);
    return 0;
}

输出:

child[3046]:  100 : 0x80497e8
parent[3045]: 0   : 0x80497e8

父子进程输出的地址一致,但变量内容不一样。

版本三:两个死循环同时跑

为了看得更清楚——让父子进程各自每秒打印一次,子进程每次对全局变量 ++

#include <stdio.h>
#include <unistd.h>

int g_value = 0;

int main()
{
    pid_t id = fork();
    if (id == 0) {
        // 子进程
        while (1) {
            printf("child: pid=%d, g_value=%d, &g_value=%p\n",
                   getpid(), g_value, &g_value);
            sleep(1);
            g_value++;
        }
    }
    else if (id > 0) {
        // 父进程
        while (1) {
            printf("parent: pid=%d, g_value=%d, &g_value=%p\n",
                   getpid(), g_value, &g_value);
            sleep(1);
        }
    }
    return 0;
}

运行一会儿:

  • 最开始,父子打印的 g_value 都是 0,地址相同(比如都是 0x601058)。
  • 过了几秒,父进程始终打印 0(它不改),子进程打印 1, 2, 3, 4, 5...(它每秒 ++)。
  • 子进程认为变量已经是 2 了,父进程读到的还是 0——而两者的地址一模一样。
  • 两个死循环同时在跑。如果是单进程,ifelse 两个分支不可能同时进入。但因为 fork 创建了双进程,两个分支同时执行了。

停下来看清楚

你看到什么了?

站在实验结果前面,一个一个捋:

  • 变量内容不一样 → 父子进程输出的变量绝对不是同一个变量
  • 但地址值是一样的 → 说明该地址绝对不是物理地址。如果 0x80497e8 是内存芯片上某个晶体管的物理坐标,同一个物理位置不可能同时存着 100 又存着 0
  • 在 Linux 下,这种地址叫做虚拟地址
  • 你在 C/C++ 语言里看到的地址,全部都是虚拟地址。 物理地址用户一概看不到,由操作系统统一管理。
  • 操作系统必须负责将虚拟地址转化成物理地址。

你不是第一次遇到这种事了。之前讲 fork 返回值的时候就出现过:同一个 id 变量,在子进程里等于 0(进入 if (id == 0) 分支),在父进程里大于 0(进入 else 分支)。两个分支都进入了,两个死循环都跑了。这个现象和全局变量地址相同值不同的现象,本质上是一个原理。


物理内存、进程和页表

在往下走之前,先把几个东西摆到桌面上。

第一,物理内存。 不管你怎么玩虚拟地址,代码和数据最终一定在物理内存里。这是冯·诺依曼体系决定的客观事实——软件上怎么设置,数据都要在内存里保存。我们刚才访问的 g_value,它一定在物理内存里的某个位置。

第二,进程。 在 Linux 里,每个进程用一个 task_struct(PCB)来描述。操作系统会为每一个进程创建一个虚拟地址空间,在 32 位平台下就是 0 到 4GB。代码也是数据,初始化数据也是数据,堆也是数据——这上面所有的东西在计算机层面全是二进制数据。虚拟地址空间只是告诉我们:这些数据在 0 到 4GB 的范围内,各占多大区间。从全 0 到全 1,每个字节都有一个地址,这些地址就叫虚拟地址

打个比方。你们班 40 个人,在教室里坐着的时候,你的座位号(第几排第几列)就是物理地址。全班同学去操场按大小个排队,从 1 排到 40——这个排队编号就是虚拟地址。无非都是数字,一个代表教室位置,一个代表队列位置。

第三,页表。 操作系统为了让进程能找到物理内存里的对应位置,会构建一张映射表——左侧是虚拟地址,右侧是物理地址。这张表叫页表。核心工作就是做虚拟地址到物理地址的转化。真实情况比这复杂得多(有各种标志位),但核心就是这个。

第四,访问内存的基本单位是字节。 原则上每个字节都有地址。一个整数占四个字节,取地址时取出来的是四个字节中最小的那个字节地址——这在我们讲进程双链表实现的时候就聊过。地址这个概念和字节是绑定的。

在 32 位平台下,一个地址用 32 个比特位表示。32 个比特位能表示 2³² 个不同的值。每个虚拟地址对应一个字节,所以总共能表达:

2³² × 1 字节 = 4GB

(2¹⁰ = 1KB,2²⁰ = 1MB,2³⁰ = 1GB,乘 2² 就是 4GB。)

物理内存通常小于等于 4GB。

所以,回到我们的 g_value。它在虚拟地址空间里有一个虚拟地址,比如说 0x601058。它在物理内存里也一定存在,假设物理地址是 0x1234。操作系统为当前进程构建映射:0x6010580x1234。进程要访问 g_value,必须先查页表。查页表不是进程自己做的——是操作系统(准确说是 MMU 硬件)做的。

一个完整的进程,至少有三大块结构:task_struct(PCB)、mm_struct(虚拟地址空间)、页表。 后面还有补充,以后再说。


fork 到底干了什么

子进程以父进程为模板来创建。父进程的 PCB、虚拟地址空间、页表——全部拷贝一份。

页表里保存的是虚拟地址到物理地址的映射。拷贝页表的本质就是拷贝地址。子进程的虚拟地址空间跟父进程一模一样,所以 g_value 的虚拟地址也是 0x601058。页表映射关系也被拷过来了——父子进程的虚拟地址 0x601058 现在都指向物理内存里同一个位置

这就好比你以前学字符串拷贝时遇到的浅拷贝:把一个指针赋给另一个指针,两个指针指向同一块内存。如果你要两个字符串彻底独立,你得给目标开辟新空间,再把内容拷过去——这叫深拷贝。但页表拷贝做的是浅拷贝——父子进程的虚拟地址指向了同一个物理地址。

这就是为什么 fork 之后父子进程代码共享、数据也默认共享。因为我们拷贝页表时,发生了地址级别的浅拷贝。父子此时指向同一个物理变量。如果映射的不是全局变量而是代码、初始化数据、堆、栈——父子同样可以访问同一块空间。

但进程是独立的

在 Linux 里,在操作系统学科里,进程具有独立性——一个进程运行不能影响另一个进程。如果父子对 g_value 都只是读取不做修改,双方不会影响彼此。只读不会产生问题。

但一旦子进程尝试对 g_value++ 修改——操作系统发现:“这个变量正被父子共享。你要写?”

操作系统在底层:

  1. 重新开辟一段同等大小的空间(比如物理地址 0x1111
  2. 把老空间的内容拷贝过去(内容为 0
  3. 修改子进程的页表:虚拟地址不变(还是 0x601058),但物理地址改成 0x1111
  4. 子进程后续的 ++ 操作,就在 0x1111 这个新空间上进行

整个过程中,子进程的虚拟地址空间没有变——它上面的虚拟地址和父进程的虚拟地址,数字是一样的。但虚拟地址到物理地址的映射关系变了。所以上层看到虚拟地址一样,打印出来的值却不同。

这个过程叫写时拷贝(Copy-on-Write)。只有修改时才重新开辟空间。不修改?那就共享。一旦修改,操作系统才把父子在数据层面分开。

为什么要这么设计?直接分开不就好了?

如果我定义的不是一个整型,而是一万个整型呢?一万个整型里,父子可能只会修改其中一小部分。如果在 fork 时就全部分开,那就申请了多余的空间——对可以共享的变量强行拆成两份,浪费内存,而且创建子进程的速度也会变慢(因为要完整拷贝数据)。

写时拷贝的本质是惰性申请空间。懒一点——当你要的时候我再给。创建进程时不做数据拷贝,快了;修改时才申请,慢了——用时间来换内存。

这就好比:你身上有 100 块钱,室友说一个礼拜后借 100 块。你现在就给他?他一个礼拜以后才用,这一周你的 100 块还可以借给其他人周转。资源的利用率就高了。等他真要的时候,你给他就行。写时拷贝就是这个思路。

整个过程全部由操作系统自动完成。

回到 fork 返回值

fork 返回时,返回值被写入变量 idid 是父进程在栈空间创建的一个变量。返回值本身就是向 id 进行写入操作——谁先返回,谁写入,此时就发生写时拷贝。

父子进程的 id 虚拟地址一样,但经过页表映射到了不同的物理地址。子进程写入了 0,父进程写入了子进程的 PID(大于 0)。所以同一个变量,既等于 0 又大于 0——底层已经是两份数据了。


什么是虚拟地址空间?

到现在为止,我们搭了虚拟地址、页表、物理地址的概念架子,也在一定程度上理解了那个诡异的现象。但上面说的还是有点抽象。接下来要真正搞清楚:虚拟地址空间到底是个什么东西?

大富翁和他的私生子

从前有个大富翁,身价十个亿美元。他有四个私生子,彼此不知道对方存在。每个私生子都认为自己是老爹唯一的继承人。

大富翁逐一找到他们:

对老大说:"儿子,听说你在搞金融?好好搞。等老爹驾鹤西去了,十个亿家产全是你的。"老大欣喜若狂。

过了几天找到老二:"儿子,你好好读博士,出来直接来老爹公司上班。等老爹蹬腿了,十个亿全是你的。"老二也很高兴。

又找到老三:"闺女,化妆品生意怎么样?你好好弄,老爹给你兜底。等老爹走了,十个亿全是你的。"老三非常开心。

最后找到老四:"儿子,玩具还要不要?怎么不玩啊?"老四说:"我要好好学习,成为世界上最厉害的人!"大富翁说:“你好好上,等大学毕业了,老爹的十个亿全是你的。”

后来——

老大说:"老爹,能不能给我一万刀?我要买西装手表,搞金融排面得拉满。刚起步没钱。"大富翁转手就给了一万。

老二说:"老爹,能不能给我一千块?学校实验室买设备,没钱了。"大富翁转手给了一千。

老三说:"老爹,能不能给我两千美元?我要买个包。"大富翁转手给了两千。

老四说:"老爹,能不能把十个亿给我?我跟同学吹牛说我有十个亿,他们不信。"大富翁转手给了老四一个巴掌:“我还没死呢!”

老四虽然挨了巴掌,但依然坚信自己拥有十个亿。因为老大、老二、老三——他们要钱,老爹都给了。

这故事是对虚拟地址空间最精确的映射:

故事 系统
大富翁 操作系统
私生子 进程
十个亿 内存(物理内存)
老爹给每个人画的饼 虚拟地址空间

虚拟地址空间,本质上是操作系统给进程画的大饼。 每个进程都以为自己独占 4GB。大富翁如果告诉老大"你只有 2.5 个亿",老大立刻就会问:“那剩下 7.5 个亿给谁了?”——其他私生子的存在就暴露了。操作系统就是要给进程一种错觉:你独占系统资源。

有了这个饼,进程就可以提前规划——前面 100MB 我放代码,再往后 50MB 放数据,中间这块给堆让它往上长,最上面那块给栈让它往下长。就像私生子听说自己有十个亿,就开始规划买豪车、买房子、买游艇。

但饼本身也需要被管理。 大富翁有四个私生子,画了四张饼。老板有十个员工,给每人画的饼不一样——“小王你好好干,明年让你当组长”、“小李你好好干,明年给你涨工资”、“小杨你好好干,明年大项目交给你”。饼对错了,员工就觉得你在骗他。

怎么管理这么多饼?跟管理任何东西一样——先描述,再组织。 所以虚拟地址空间在内核里就是一个结构体变量。这个结构体在 Linux 里叫 struct mm_struct

银行存钱的类比

你去银行存 1000 块钱。银行给你的卡上打了一个数字 1000。实际上你那 1000 块并没有静静躺在银行里——银行赚的是息差,你的钱早就被借出去了。但只要你想取的时候银行能兑付,就没事。

银行给我们每个人画了一张饼。你知道自己卡上有 1000 块,从第 1 块到第 1000 块都是你的,你随时可以取。操作系统给每个进程画了 4GB 的饼,从地址 0 到地址 0xFFFFFFFF,全是你的——直到你真正要用的时候,操作系统才在物理内存里给你分配。


区域划分:三八线就够了

还有一个问题:就算我接受了"虚拟地址空间是一个结构体",但结构体里怎么表达区域划分?代码区、数据区、堆区、栈区——它们是明晃晃的不同区域,一个结构体怎么表示?

小胖和小花的桌子

幼儿园桌子宽 100 厘米。小胖和小花是同桌。小胖不爱干净,脸黑鼻涕多,还老把东西扔到小花那边。小花生气了,在桌子上画了一根线。

“老师说桌子宽度 100 厘米。我画一根线。1 到 50 厘米是我的,50 到 100 厘米是你的。从现在开始,不准越过这条线。越过了我就打你。”

这根线就是三八线。三八线的本质是什么?区域划分。

用计算机语言描述:

struct desk {
    int total_size    = 100;
    int xiaohua_start = 1,   xiaohua_end = 50;
    int xiaopang_start = 50, xiaopang_end = 100;
};

因为区域是线性的——1, 2, 3, 4, 5, …, 100——所以划分区域只需要给每个人指定 startend

进程虚拟地址空间也是线性的:32 位系统下,从全 0(最低地址)到全 1(最高地址),一共 2³² 个地址(约 42 亿多),每个字节一个编号。要划分代码区、数据区、堆区、栈区,就是给每个区域一个 start_codeend_codestart_dataend_data……

内核里用的类型是 unsigned long,不是指针。地址本身就是一个数字——全 0 是 0,全 1 约 42 亿,中间是 1, 2, 3, 4, 5…,跟桌面上 1 到 100 厘米的刻度没区别。小花把铅笔放在 10 号刻度上——她就找自己桌面上刻度为 10 的位置。内核里表达地址空间范围,就是用 unsigned long 来表示这些"刻度"。

关键不是数字本身,是"之间"

给小花的区域是 1 到 50。这意味着什么?

意味着第 2 厘米、第 17 厘米、第 49 厘米——1 到 50 之间的每一个刻度,小花都可以随意使用。 不是只有 150 这两个端点属于她。

同样的道理:代码区 start_codeend_code 之间的每一个字节地址,进程都可以访问。虚拟地址不是只有区间端点有意义——区间里的每一个位置都叫虚拟地址,都可以用。划分好虚拟地址空间后,区域之间的任意一个地址,进程随时可以取用。

后来小胖还越过线。小花把三八线擦掉,往小胖那边又挪了 10 厘米。xiaohua_end 从 50 改成 60,xiaopang_start 从 50 改成 60——这就叫区域空间的调整(扩展或缩小)。把各自区域的 start 和 end 往大改或往小改。malloc 干的就是这个:如果堆区不够用,brk() 系统调用把堆区的 end 往后挪。你没"创建"任何东西——你只是重新画了根线。

代码区和数据区的大小怎么来的?

堆和栈是动态变化的——栈顶往下移、堆顶往上移,区间就扩了。哪怕刚创建时它们都是 0,后面也可以动态扩展。

代码区和数据区呢?每个程序无非由代码和数据构成。你写的函数、循环、类——这是代码。你定义的整型、浮点、结构体——这是数据。Hello World 和王者荣耀的规模根本不在一个量级。编译时,代码和数据的大小就确定了。

size 命令可以直接看:

$ size myproc
   text    data     bss     dec     hex filename
 168487     580       0  169067   2946b myproc

text 是代码段,data 是数据段。

程序编译好之后是一个二进制可执行文件(ELF 格式),里面记录了代码有多大、数据有多大。将来创建进程时,操作系统读取 ELF 头,就知道该分配多大的虚拟地址空间给代码区和数据区。然后把代码和数据从磁盘加载到物理内存里。物理地址有了,虚拟地址也在地址空间上开辟了等大的区间——虚拟和物理都有了,就可以填充页表,构建映射关系。此后,进程通过访问虚拟地址就能访问物理内存。


内核源码里长什么样

从 task_struct 到 mm_struct

struct task_struct
{
    /* ... */

    // 普通用户进程:指向它的虚拟地址空间(用户空间部分)
    // 内核线程:这个字段为 NULL
    struct mm_struct *mm;

    // 内核线程使用。当 mm 为 NULL 时,内核线程借别的进程的地址空间用
    // 因为所有进程关于内核的映射都是一样的
    struct mm_struct *active_mm;

    /* ... */
};

mm_struct(mm_types.h

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;       // 环境变量

    /* ... */
};

看到了吗?代码区开始、代码区结束;数据区开始、数据区结束;堆区开始、堆区结束;栈区开始;命令行参数开始结束;环境变量开始结束。这就是虚拟地址空间的区域划分——不是魔法,是 startend

每个进程有自己独立的 mm_struct,每个进程有自己独立的地址空间,互不干扰。

vm_area_struct:处理不连续区域

代码区和数据区是编译时确定的,整齐连续。但堆区呢?申请三次,释放中间那次——堆区就不连续了。一个 start_brk 和一个 brk 怎么能表达被掏了洞的堆区?

mm_struct 中,除了宏观的 start_codeend_code 这些字段,还维护了更细粒度的结构——vm_area_struct(VMA)。每个 VMA 描述一段连续的虚拟地址区域:

struct vm_area_struct {
    unsigned long vm_start;           // 本区域的起始虚拟地址
    unsigned long vm_end;             // 本区域的结束虚拟地址

    struct vm_area_struct *vm_next;   // 链表后指针
    struct vm_area_struct *vm_prev;   // 链表前指针

    struct rb_node vm_rb;             // 红黑树节点(VMA 多时用树管理)

    struct mm_struct *vm_mm;          // 属于哪个地址空间

    pgprot_t vm_page_prot;
    unsigned long vm_flags;           // 权限标志位(可读?可写?可执行?)

    struct {
        struct rb_node rb;
        unsigned long rb_subtree_last;
    } shared;

    struct list_head anon_vma_chain;
    struct anon_vma *anon_vma;

    const struct vm_operations_struct *vm_ops;  // 本区域对应的操作函数表

    unsigned long vm_pgoff;            // 文件映射的偏移量
    struct file *vm_file;              // 映射的文件(如果是文件映射)
    void *vm_private_data;             // 私有数据

    atomic_long_t swap_readahead_info;

#ifndef CONFIG_MMU
    struct vm_region *vm_region;
#endif
#ifdef CONFIG_NUMA
    struct mempolicy *vm_policy;
#endif
    struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

每个 VMA 用自己的 vm_startvm_end 描述一段连续区域。堆区不连续?两个 VMA 节点,一个管洞前的连续段,一个管洞后的连续段。

所有 VMA 节点组织起来,有两种方式:

  • 虚拟区间少时用单链表mmap 指针指向链表头,vm_next / vm_prev 串联
  • 虚拟区间多时切换红黑树mm_rb 指向树根,用 vm_rb 节点插入树中

不管哪种方式,每个节点都记录了一段区域的 vm_startvm_end。代码区、数据区、堆区、栈区、mmap 映射的共享库、命令行参数、环境变量——所有这些区域在进程眼里是有序的,底层就是用这张链表(或红黑树)来管理的。

mm_struct 里的宏观字段(start_codeend_code 等)和 VMA 链表是同时使用的。宏观字段表达整体空间的大框架,VMA 链表提供细粒度的区域管理。它们在某些位置的值是相等的——比如代码区对应的某个 VMA 节点的 vm_start 就等于 start_code

理解虚拟地址空间,建议以 VMA 这张图为主——它是一个一个 vm_startvm_end 串起来的结构,合在一起就是进程看到的全部区域。


回到那个冰箱

现在重新看最早那个实验。把一个进程的完整结构画出来:

  • task_structmm_struct(虚拟地址空间),全局数据区里 g_value 的虚拟地址 0x601058
  • 页表:0x6010580x1234
  • 物理内存 0x1234 处:g_value = 0

fork 创建子进程。PCB、地址空间、页表全部拷贝。因为虚拟地址空间完全一样,子进程的 g_value 虚拟地址也是 0x601058。页表发生浅拷贝,父子的虚拟地址都指向物理地址 0x1234

子进程做 g_value = 100(或者 g_value++)。

操作系统发现:“这个物理页正被两个进程共享。你要写?我给你复制一份。”

于是:物理内存里开辟新空间(0x1111),拷贝旧值(0)过去,修改子进程页表——0x6010580x1111。子进程在新空间上写 100(或做 ++)。

父进程的页表没动——还是 0x6010580x1234,值还是 0

虚拟地址相同,页表映射不同,物理地址不同,值不同。 这就是全局变量地址一样值不一样的根本原因。也是 fork 返回值既等于 0 又大于 0 的根本原因。


为什么非要虚拟地址空间?

这个问题等价于:如果程序直接操作物理内存,会有什么问题?

第一个时代:没有虚拟地址的时候

早期的计算机直接把整个程序装入物理内存运行。程序里指针指的就是物理地址。

比如物理内存 128MB,同时跑程序 A(占 10MB)和程序 B(占 110MB)。操作系统先把前 10MB 分给 A,再从剩余 118MB 中划出 110MB 给 B。A 和 B 都能跑,但问题一堆:

安全风险。 每个进程的内部指针就是物理地址。A 的一个野指针完全可能指向 B 的内存区域——读走密码、改掉余额。如果是木马病毒,随意修改系统内存,设备直接瘫痪。进程独立性无从谈起。

地址不确定。 程序编译好放在硬盘上,运行时搬到内存。如果直接使用物理地址,每次运行的加载位置都不确定——第一次跑 a.out 时内存空空,可能搬到 0x00000000;第二次跑时内存里已经有 10 个进程在跑,加载地址就不知道在哪了。操作系统必须把每个进程的加载地址全记下来,管理复杂。

效率低下。 物理内存不够用时,需要把不常用的进程整个拷贝到磁盘交换分区(swap out),腾出内存后再把需要的进程 swap in。如果直接操作物理地址,就得把整个进程一起拷走——内存和磁盘之间的拷贝时间太长,效率低。

有了虚拟地址空间之后

理由一:对内存进行保护。

虚拟地址到物理地址的转化由 MMU 完成。MMU 是一个集成在 CPU 内部的硬件。CPU 每次发出访存请求,MMU 自动查页表做翻译。

因为多了一层转化层,就在这层转化里做拦截:

  • 你指的那个地址在页表里没映射?→ 转化失败 → 操作系统终止进程。不存在的东西你访问不了。
  • 映射存在,但权限不对?页表条目里有权限标志位:可读(R)、可读写(RW)、可读可执行(RX)。代码区通常是只读的——你拿指针想往里写?权限不匹配 → 转化失败 → 进程崩掉。

这也就解释了为什么字符串常量不可被修改。char *msg = "hello world"——这个字符串编译时被放在了代码区。代码区对应的页表条目里,权限标志是 R(只读)。你想写?MMU 不让你过,操作系统把你进程毙掉。

怎么理解这个拦截的效果?

以前你拿着压岁钱直接跑去商店——没人拦你,买了一堆不需要的东西。后来你妈说:"压岁钱交给我。你要买什么跟我说,我告诉你能不能买。"你要买本子——你妈让你去。你要买游戏机——你妈说:"上学呢,玩什么游戏机?"拒绝了。因为你和商店之间多了层"你妈"这个角色,你的行为就可以在这一层被拦截。

虚拟地址空间 + 页表 + MMU,就是在进程和物理内存之间插了一层"你妈"。进程说"我要访问地址 X"——操作系统(通过 MMU)先查页表:X 在不在?权限对不对?不合法就不让你过去。地址空间和页表是操作系统创建和维护的,所有内存访问都得在操作系统的监管下进行。 这就保护了物理内存里所有合法数据——包括各个进程以及内核的数据。

再拿三八线来理解野指针检测。小花划分的区域是 1 到 60,小胖是 60 到 100。小胖把胳膊伸到 50 这个位置——小花立刻知道:"你的开始是 60,你把胳膊伸到 50 这里,你越界了!"同样的道理,进程的虚拟地址空间里,各个区域的 start 和 end 限定了合法范围。代码区只有这一段是你的,数据区只有那一段是你的,堆区只有这一段是——空白区域不是你的。你拿指针乱指向空白区域——这个地址没落在任何一个区间的 start/end 里。页表里也查不到。操作系统就识别到:刚才这个进程拿了一个乱指。直接把进程毙掉。你的进程崩溃了——不是因为操作系统脾气差,是因为你越界了。

理由二:把物理内存的无序变成进程视角的有序。

因为物理内存的分配是动态的,进程的代码可能在 0x1234,数据在 0xABCD,堆在 0x5678——在物理层面是杂乱无章的。

但因为有虚拟地址空间和页表的映射,每个进程看到的布局永远一样:代码段在最低处,数据段在上面,堆在数据上面往上长,栈在最高处往下长,命令行参数和环境变量在最顶上。无非你的代码比我大一点,我的堆比你大一点——但地图的结构是一致的。

因为页表的映射的存在,程序在物理内存中理论上可以在任意位置加载。它将虚拟地址和物理地址进行映射,在进程视角中所有的内存分布都可以是有序的。

这就是虚拟地址空间第二个核心价值:把无序变有序。

理由三:进程管理与内存管理解耦合。

因为有大富翁那张饼——你在 mallocnew 的时候,其实只是在虚拟地址空间上申请。mm_struct 里改几个 end 值,空间就有了。物理内存可以一个字节都不给你。

等到你真正对这个空间做写入时,操作系统才执行内存管理算法:申请物理页,填充页表映射关系。整个过程叫延迟分配——由操作系统自动完成,用户和进程完全零感知。

这样一来——

  • 左边(进程的创建、调度、切换、空间申请)属于进程管理模块。进程申请空间,在虚拟地址空间上操作就够了。
  • 右边(物理内存的分配、回收、换入换出)属于内存管理模块。独立负责物理层的操作。

物理内存的分配和进程的管理可以做到没有关系。进程管理模块和内存管理模块完成了解耦合。

malloc 了 1GB 但物理内存只有 512MB?没问题——只要你没真的同时用满 1GB,物理内存就不会被掏空。


还没讲到的

页表本身的内部结构(多级页表怎么省空间)、TLB(页表缓存)怎么加速翻译、缺页中断(page fault)怎么触发、ELF 可执行文件格式和虚拟地址空间怎么配合、动态库映射机制、物理内存管理的算法(buddy system、slab)、页框回收(page reclamation)怎么决定把谁的页换出去——这些话题,一节课塞不下。

但这个话题后续会再讲四次。每次各有侧重。今天的目标是搭出宏观框架:虚拟地址空间是什么、区域划分怎么来的、页表干嘛用的、写时拷贝怎么回事、为什么要有这一整套东西。

真正理解虚拟地址空间,需要掌握进程本身、硬件(页表、MMU)、程序翻译(编译器、ELF 格式)、内存管理——四块拼图。今天只是第一块。


你已经看到了这张地图的全貌:

  1. 你在 C 语言里看到的地址是假的。 虚拟地址,地图上的坐标,不是物理芯片上的位置。物理地址用户根本看不到。
  2. 这张地图是一个内核数据结构。 mm_struct,里面装了一堆 startendvm_area_struct 链表(或红黑树)处理不连续区域。
  3. 虚拟地址到物理地址,中间隔了页表。 查页表的工作是 MMU 这个硬件自动完成的。所有访问都经过这层翻译。
  4. 承诺和兑现是分开的。 fork 时不拷数据(写时拷贝,谁写谁触发)。malloc 时不分配物理内存(惰性分配,真用了才给)。你以为的"实时"全是延迟的。
  5. 这不是 bug,这是现代操作系统最精巧的设计。 安全靠它(MMU 在翻译层做拦截)、整齐靠它(无序物理内存映射成有序虚拟空间)、解耦靠它(进程管理和内存管理各干各的)。

你在 C 语言里写的每一行代码——每一个 &、每一个 malloc、每一个 fork——全都是在跟 mm_struct 交互。你以为你在操作内存,其实你在改 startend。你以为你写的是物理地址,其实你写的是操作系统给你画的一张饼上的编号。

That’s the way it is.

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐