我们先来看一段代码以及运行结果:
#include <stdio.h>
#include <unistd.h>
int grobal_val = 10; // 全局变量
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt=0;
while(1)
{
printf("子进程:pid = %d, ppid = %d | grobal_val = %d, &grobal_val = %p\n",getpid(), getppid(), grobal_val, &grobal_val);
sleep(1);
++cnt;
if(cnt == 5)
{
grobal_val=200;
printf("子进程已更改全局变量grobal_val\n");
}
}
}
else if(id > 0)
{
while(1)
{
printf("父进程:pid = %d, ppid = %d | grobal_val = %d, &grobal_val = %p\n",getpid(), getppid(), grobal_val, &grobal_val);
sleep(1);
}
}
else
{
printf("fork error\n");
return 1;
}
return 0;
}
是不是很奇怪,之前我们讲过 fork() 会创建一个子进程,且这个子进程会和父进程一起共享下面的代码,我们也可以看到它们指向 grobal_val 的地址是一样的,但是当子进程修改 grobal_val 为 300 后,我们看到父进程中的 grobal_val 依然是 10,并且它们的地址还是一样的,这是什么现象❓❓❓
难道说这个地址是假的❓❓❓这里需要想一下,这个地址是什么❓如果是物理地址那父子进程的必须是一样,不然硬件是无法执行。那么这里很显然不是物理地址,而是 虚拟地址 !
所以从中我们能得到一些结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量,但地址却是一样的,说明该地址绝对不是物理地址
- 在
Linux中,这种地址叫做虚拟地址 - 我们在用
C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户和CPU一概看不到,由OS统一管理 OS必须负责将 虚拟地址 转化成 物理地址(实际上是通过页表 +MMU来实现的,后面会讲)
Ⅱ. 虚拟地址
从同一块 物理地址 中取出的值是相同的,所以这个程序取出的地址(指针)并不是物理地址,而是虚拟地址,在 Linux 中也可称为线性地址、逻辑地址。(注意其他平台可能不等同)
♻️ 注意:逻辑地址指可执行程序编译完成后内部函数、变量的地址。逻辑地址有两种表示方法,一种是各个区域地址递增,另一种是每个区域的地址都从零偏移量开始(这种是比较老的表示方式)。
而在 Linux 中的逻辑地址是第一种表示方式,所以 Linux 中逻辑地址就是 虚拟地址。
简单的说,虚拟地址就是假的,类似于模拟器这样子的东西,是为了搭建与物理地址的更加安全独立的一个桥梁!
Ⅲ. 进程地址空间
一、感性了解
我们先来讲一个小故事,帮助大家构建进程地址空间的概念!
在国外有一个身家10亿的大富翁,他有三个私生子,并且这三个私生子是互相不知道对方的存在的!大富翁为了让他们保持这种形式,就对每个私生子说,好好干自己的工作,以后家产都是他的。这个时候三个私生子就会觉得自己是独占了这份资产了,也不会经常打扰大富翁,大富翁也说缺钱的时候就找他要钱,但是这个资产还不能一下子给到他,这样子一来大富翁就相当于对三个私生子都规划了一份蓝图,但是这份蓝图终究都是蓝图,不能实现的话都是虚的。
上面所讲的小故事中,大富翁就相当于是父进程,而三个私生子代表着三个子进程,而大富翁画的大饼就是进程地址空间!
相当于说进程地址空间就是构造出来的,本质上是不存在的,进程地址空间只是父进程为了管理子进程而设出来的一个蓝图!而每个子进程都自认为自己是独立占有这个总资源的,但其实并不是,因为大富翁只会在他们缺钱的时候给到他们一些钱,而不会全部给他们,对比与子进程和进程地址空间就是这样子的!
而我们之前讲到的先描述,再组织。描述就是这个过程,而组织就是用数据结构和结构体把它们包装起来!那么操作系统是如何定义这个进程地址空间的呢?
二、进程地址空间概念以及划分
学过 C/C++ 的我们都有涉及到一点内存管理的知识,但是我们之前可能搞错的点就是没有虚拟地址的概念,我们现在要明白进程地址空间是虚拟的存在的,它只是与物理内存的一个桥梁!如下就是进程地址空间的分布图:
大家想一下,如果是在 32 位机器下,那么我们要存储的数据范围就是 那么多,如果说进程地址空间里面还装的是真的内存区,那么我们两三个进程不就把内存跑完了吗?所以我们的进程地址空间里面放的其实是一些指针,假设堆区是 0x0FFF1111-0x2FFFFFFF 这个范围的地址空间,而整个地址空间范围:0x00000000-0xFFFFFFFF,这样子说大家可能不理解,那么我来讲一个小例子:
大家是不是以前都是和同学用同一张桌子,也就是同桌;那么难免发生矛盾或者不是很熟悉,那么可能两个同学就会划分所谓的 “三八线”,那么我们调整这个线的时候其实不是把桌子切开,而是只是划分这个线!对不对,这个很关键哦!假设这张桌子是 100cm,若 A 同学占有的可能是 [1, 50],那么 B 同学占有的是 [51, 100] ,此时如果 A 同学因为体格大的问题,可能要向 B 同学那边延伸个 5cm,那么 A 同学就变成了 [1, 55],而 B 同学就是 [56, 100],这就是区域划分,本质就是对这个区域分界这条线进行管理!
有了上面的例子,我们就可以引出一些概念了,就是进程地址空间其实是一个结构体,在 linux 内核中称为 mm_struct 结构体!这个结构体没有告诉我们空间大小,但是它告诉了我们空间的排布情况!比如说下面这样子:
struct mm_struct
{
unsigned long code_start; // 代码区
unsigned long code_end;
unsigned long init_start; // 初始化区
unsigned long init_end;
unsigned long uninit_start; // 未初始化区
unsigned long uninit_end;
unsigned long heap_start; // 堆区
unsigned long heap_end;
unsigned long stack_start; // 栈区
unsigned long stack_end;
//...等等
} 如果栈区想扩大范围,我们只需要控制该结构体的 stack_start 和 stack_end 就可以了,stack_star 代表区域的起始地址,stack_end 代表区域的结束地址。
Linux 中 mm_struct 中关于区域划分的部分源码如下:
♻️ 还需要知道的是,mm_struct 其实是被我们的进程管理块 task_struct 所管理的,下面是 task_struct 的部分源码:
三、进程地址空间如何映射至物理内存 (页表的引出)
假设存在三个进程 A B C,操作系统就会给每一个进程画一张大饼,叫做当前进程的虚拟地址空间,其中会通过指针将进程和虚拟地址空间关联起来。运行 进程A,就要把 进程A 加载到物理内存中,其中操作系统会给每一个进程创建一张独立的 页表 结构,我们称之为 用户级页表,当然后面还有 内核级页表 ,而页表构建的就是从地址空间中出来的虚拟地址到物理地址中的映射,每个进程都通过页表来维护进程地址空间和物理内存之间的关系,这是页表的核心工作,所以就进程就可以根据页表的映射访问物理内存。
🔴 要注意的是,页表比我们这里说的要复杂的多,这里我们只是按照它的功能来描述它而已!
如下图所示,task_struct 中有独立的 mm_struct ,通过各自的页表,将原来的虚拟地址转化为物理地址,最后找到物理内存!

通过上面这张图,我们可以提出以下这些问题:
- 能否把
进程A中的代码和数据加载到物理内存中的任意位置?
在不考虑特殊情况下,是可以将进程对应的代码和数据加载到物理内存的任意位置的,因为最终我们能通过进程中虚拟地址与页表之间的映射,再通过页表与物理内存中代码和数据的映射进行访问。所以进程中的代码和数据是能够加载到物理内存中的任意位置的,其中本质是通过页表去完成的。
- 多个进程之间会互相干扰吗?
每个进程都是相互独立的,不会互相干扰,只是效率会变低!
- 不同的进程的虚拟地址可以一样吗?
同样 进程B 也可以通过页表把代码和数据加载到物理内存的任意位置,这里就算不同的进程的虚拟地址完全一样也没问题,因为不同进程通过一样的虚拟地址查的是不同的页表,其中的工作细节是由页表去完成的。
- 不同的进程的物理地址可以一样吗?
这是操作系统的代码,一般不可能重址。当然也存在这样的特殊情况,如果 进程B 和 进程C 是父子关系,我们在创建子进程 C 的 PCB、地址空间、页表、建立各种映射关系,把代码区、数据区等区域映射时,只需要将子进程 C 映射到物理内存中父进程代码和数据处,但当子进程 C 修改数据时,操作系统就会重新申请内存,修改当前进程的指向关系,让子进程 C 指向新的空间,把旧数据拷贝至新数据,此时这就是写时拷贝。所以不同的页表,物理地址可以重址,只不过这种重址是刻意的。
四、解释一开始的问题
嘶~~那我们通过上面的问题好像就能明白了一开始那个问题的原因,父进程在 fork 时,操作系统一定是多了一个进程,而子进程需要创建自己的 mm_struct、页表,其中子进程中的大多数属性是以父进程为模板。
其实就是因为不同进程的物理地址是一样的,而子进程修改了与父进程共享的值,那么操作系统就会为子进程在物理内存上开辟一段同样大小的空间,并拷贝原数据,提出修改的进程的 页表映射关系将会被改变,然后再让进程对 数据进行修改,这样子的话子进程修改了 grobal_val 的话,实质上对父进程是没有如何影响的,这也称为写时拷贝技术!
除此之外我们还要明白的是,改变指向的只是物理地址,子进程的虚拟地址还是不变的,所以就会出现我们上面修改值后地址还是一样的错觉,其实本质上只是虚拟地址一致,但是物理地址并不是一样的!
具体步骤可以参考下图:
除此之外,不仅仅操作系统会遵守进程地址空间,编译器等也会遵守进程地址空间,所以我们在 vs2022 等编译器中看到的变量地址什么的,其实都是虚拟的!
Ⅳ. 为什么要存在进程地址空间
① 进行权限管理和保护物理内存不受到任何进程内地址的直接访问,保证了数据的安全性
其实早期操作系统是没有所谓的虚拟地址空间的。如果进程直接访问物理内存,那么我们看到的地址就是物理地址,当我们认识过在 C语言中有一个概念叫做指针,那么就能理解有可能会出现:如果进程 A 出现了越界,那么就有可能直接访问到了另一个进程的代码和数据,所以进程的独立性便无法保证。因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改。比如说某进程里有帐号和密码的数据,那就有可能会被更改帐号密码,如果操作系统不让改,那也可以进行读取,如果操作系统不想让你读取,操作系统就要实现一些较为困难的权限管理,成本较高。后来大佬们对进程和物理内存之间就引出了虚拟地址空间,其中每一个进程都有自己的地址空间、页表。虚拟地址最终通过页表转换为物理地址,那么页表需要根据实际情况转或不转。
举个例子,小时候过年,收到大人的压岁钱后,你妈妈怕你乱花钱,所以就帮你存起来,当你要买资料时,你妈就帮你支出,但你要买游戏机时,你妈就可以拒绝你。换言之,虚拟地址到物理地址的转换,是由页表和操作系统完成的,同时也需要进行合法性检测。
② 进程地址空间可以更方便的进行不同进程间代码和数据的解耦,保证了进程的独立性
对于互不相关的两个进程来说,它们都拥有自己独立的地址空间以及页表,页表会映射到不同的物理内存上,磁盘代码和数据加载到内存中的位置也不同,一个进程数据的改变不会影响另一个进程;
对于父子进程来说,由于子进程的 mm_struct 和页表是通过拷贝父进程得到的,所以二者指向同一块物理内存,共用内存中的同一份代码和数据,但即使是这样,父进程/子进程在修改数据是也会发生写时拷贝,不会影响另一个进程,保证了进程的独立性。
这里就回到最开始,就解决了子进程和父进程使用了同一个进程地址空间而数据不一样的问题:
③ 进程和编译器均遵守进程地址空间这一套规则,方便编完即可使用
对于进程来说,各个进程都认为自己的数据被放置在对应的区域,比如代码区、全局数据区,但是物理内存实际上是可以非规律存储的;
对于磁盘中的程序以及编译器来说,**编译器也是以进程地址空间的规则来进行编译的,所以磁盘中的可执行程序内部也是有地址的,且此地址也是虚拟地址;甚至你自己写的代码中还没有编译之前就已经是存在地址的了,并且也是虚拟地址!所以,当我们的程序被加载到内存变成进程后,不仅程序中的各个数据会被分配物理地址,程序的内部同时也存在虚拟地址,使得CPU在取指令进行运算时,拿到的下一条指令的地址也是虚拟地址,这样CPU**也可以以 虚拟地址 -> 页表 -> 物理地址 的方式来统一执行工作。

在磁盘上形成的 .exe 文件在编译时其实并不是无脑的把代码和数据一分为二就完了,而是在磁盘中按照文件的方式组织成一个一个区域,这样做的原因是便于生成可执行程序,如果划分好了区域,那么就会减少程序链接过程的成本。因为磁盘上的可执行程序本身就是按模块划分的,所以进程地址空间才有了区域划分的概念,但要注意物理内存的情况有可能大部分的空间已经被使用了,那么进程的代码和数据就零散的分布于物理内存的不同位置。物理内存也有区域,只不过它的内存分配是按页为单位,一页是 4kb,换言之,磁盘数据加载到物理内存时,是按 4kb 为单位,其中每页叫做页框,4kb 是页帧。
④ 进程管理和内存管理进行解耦

我们都知道操作系统具有 4 种核心功能:进程管理、内存管理、驱动管理、文件管理。而上图很明显是与进程管理和内存管理有关,比如说一个进程要执行,首先要申请内存资源,并加载到内存,然后创建 PCB 等进程管理工作;而进程死亡后,就需要内存管理模块来进行尽快回收,内存管理必须得做到知道某个进程的状态。所以内存管理模块和进程管理模块是强耦合的。
如果有了虚拟地址空间的概念,那么进程管理关注左半部分,而内存管理关注右半部分!
💦 既然页表可以拒绝非法地址的访问,那么打印数组的越界访问部分为什么也可以打印出数据呢???
我们一定要想清楚一个问题,越界访问是有区域性的,而我们栈区在给我们开辟的时候,不是我们想的那样子我们需要几个字节就占有几个字节,而是会多预留一些位置给我们,这个取决于平台是怎么实现的,甚至我们还可能访问到其他的函数中去,这都是正常的!
💦 为什么只读代码区可以生成数据???

形成代码区时不就是把数据往代码区里写吗,其实代码区在操作系统的角度,它一定是物理内存的任何位置都可以改的,只不过要让 *str = 'H' 的话是在你进行写入后修改字符串起始的第一个字符,所以经过对应的页表映射时,发现你对这个区域的权限是只读的,而你如果想写入,所以操作系统就不会映射,直接终止进程!换言之,进程地址空间是为了更好的进行权限管理!
💦 函数调用完后,常量字符串还在吗???
当这里 show() 函数调用完后,函数栈帧销毁,所以局部变量 str 一定是不在了;但是对于字符串,它存储于常量区,只要进程还在,那么字符串就还在。show() 栈帧结束,理论上是找不到字符串了,所以我们就能理解所有的地址信息都必须要用变量保存,当你在物理内存中 malloc 好一块内存,页表构建映射关系,把地址映射到堆区,最后这个区域的起始地址就返回给用户,如果用户不使用变量保存,那么就会存在 内存泄漏。
💦 一个 16G 的游戏能否在 4G 的物理内存上运行吗???
能,比如你的内存是 32G,即便你加载了 16G,对计算机而言,它是从头开始访问的,也就是说 16G,你已经有 15G 已经加载到内存了,但你尚没有正常使用,还需要等待后面的数据加载进来,所以这是一种很低效的方案。所以操作系统要执行这个进程,但内存管理模块认为给你搞这么多你又不使用,所以就先加载 200M 给你,当你从上至下访问到最后时,如果你还需要,就再给你覆盖式的加载 200M,此时进程是不知道内存管理模块给他做的操作,内存管理就可以通过不断延迟加载的技术方案,来保证进程照样可以正常运行,这就是进程管理模块和内存管理模块解耦。所以对于用户来说,唯一感受到的是我的电脑能开很多软件,但是速度变慢了,当然这也是应该的。
Ⅴ. Linux2.6内核进程调度队列 —— 了解
不是本文的重点,所以了解一下即可。
上图是Linux2.6内核中进程队列的数据结构。
💦 一个CPU拥有一个runqueue
如果有多个 CPU 就要考虑进程个数的负载均衡问题。
💦 优先级
- 普通优先级:100~139 (我们都是普通的优先级,想想 nice 值的取值范围,可与之对应)。
- 实时优先级:0~99 (不关心)
💦 活动队列
- 时间片还没有结束的所有进程都按照优先级放在该队列。
- nr_active:总共有多少个运行状态的进程。
- queue[140]:一个元素就是一个进程队列,相同优先级的进程按照 FIFO 规则进行排队调度,所以,数组下标就是优先级。
- 从该结构中,选择一个最合适的进程,过程是怎么的呢?
1、从 0 下标注开始遍历 queue[140]。 2、找到第一个非空队列,该队列必定为优先级最高的队列。 3、拿到选中队列的第一个进程,开始运行,调度完成。 4、遍历 queue[140] 时间复杂度是常数,但还是太低效了。
- bitmap[5]:一共 140 个优先级,一共 140 个进程队列,为了提高查找非空队列的效率,就可以用 5*32 个比特位表示队列是否为空,这样,便可以大大提高查找效率。
💦 过期队列
- 过期队列和活动队列结构一模一样。
- 过期队列上放置的进程,都是时间片耗尽的进程。
- 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算。
💦 active指针 and expired指针
- active 指针永远指向活动队列。
- expired 指针永远指向过期队列。
- 可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都会存在的。
- 没关系,在合适的时候,只要能够交换 active 指针和 expired 指针的内容,就相当于有具有了一批新的活动进程。
💦 总结
- 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度 O(1) 算法。
参考文案:
【Linux进程概念——下】验证进程地址空间的基本排布 | 理解进程地址空间 | 进程地址空间如何映射至物理内存(页表的引出) | 为什么要存在进程地址空间 | Linux2.6内核进程调度队列