* + [💦 虚拟地址](#__64)
+ [💦 什么是进程地址空间](#__117)
+ [💦 页表](#__135)
+ [💦 为什么要存在进程地址空间](#__155)
+ [💦 解释虚拟地址中的现象](#__213)
* [四、Linux2.6内核进程调度队列 —— 了解](#Linux26___234)
* + [💦 Linux2.6内核中进程队列的数据结构](#_Linux26_237)
+ [💦 一个CPU拥有一个runqueue](#_CPUrunqueue_239)
+ [💦 优先级](#__243)
+ [💦 活动队列](#__248)
+ [💦 过期队列](#__261)
+ [💦 active指针 and expired指针](#_active_and_expired_267)
+ [💦 总结](#__274)
【写在前面】
本文中会介绍很多结构化的知识,我们会通过很多例子里的角色和场景来对一个概念进行定位和阐述,让大家有一个宏观的认识,之后再循序渐进的填充细节,如果你一上来就玩页表怎么映射,那么你可能连页表存在的价值是什么都不知道,最后也只是竹篮打水。
一、回顾与纠正
C/C++ 内存布局这个概念比较重要,之前我们也涉及过 —— 我们在语言上定义的各种变量等在内存中的分布情况,如果没有听说过,那么你的 C/C++ 是不可能学好的。
上图表示的是内存吗 ❓
其实我们曾经在语言中说过的 C/C++ 内存布局,严格来说是错误的,从今天开始我们应该称之为C/C++ 进程地址空间。为啥要故意说错呢,其实是因为方便理解,如果当时说 C/C++ 进程地址空间,那么不谈进程、地址、空间,就很容易误导大家。也就是说实际上要真正理解 C/C++ 的空间布局,光学习语言是远远不够的,还需要学习系统以及进程和内存之间的关系。
进程地址空间既然不是内存,那么栈、堆等这些空间的数据存储在哪 ???
进程地址空间,会在进程的整个生命周期一直存在,直到进程退出,这也就解释了为什么全局变量具有全局属性。其实这些数据最后一定会存储于内存,只不过进程地址空间是需要经过某种转换才到物理内存的。
上图的共享区/内存映射段会在进程间通信以及动静态库的时候再去细谈,现在可以简单理解计算机中是有很多动静态库的,而共享区主要用来加载它们。
二、 验证进程地址空间的基本排布
1 #include<stdio.h>
2 #include<stdlib.h>
3
4 int g_unval;//全局未初始化故意与全局初始化写反
5 int g_val = 100;
6
7 int main(int argc, char\* argv[], char\* env[])
8 {
9 //以下由低地址到高地址分别验证,除了栈
10 printf("code addr: %p\n", main);//对于一个函数的地址,main同&main
11 const char\* p = "hello bit!";
12 printf("read only: %p\n", p);//p就是字符串的首地址
13 static int a = 5;
14 printf("static global val: %p\n", &a);//static后局部变量的存储地方就由栈变为数据段
15 printf("global val: %p\n", &g_val);
16 printf("global uninit val: %p\n", &g_unval);
17 char\* q1 = (char\*)malloc(10);
18 char\* q2 = (char\*)malloc(10);
19 printf("heap addr: %p\n", q1);//栈区的地址&q1会指向堆区的q1
20 printf("heap addr: %p\n", q2);
21 printf("p stack addr: %p\n", &p);
22 printf("q1 stack addr: %p\n", &q1);
23 printf("args addr: %p\n", argv[0]);//数组的第一个元素
24 printf("args addr: %p\n", argv[argc - 1]);//数组的最后一个元素
25 printf("env addr: %p\n", env[0]);
26
27 return 0;
28 }
args 的地址是一样的,根本原因是 ./checkarea 时只有一个命令行参数,如果加上选项那么就不一样了:
三、 进程地址空间
💦 虚拟地址
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int g_val = 0;
int main()
{
printf("begin......%d\n", g_val);
pid_t id = fork();
if(id == 0)
{
int count = 0;
while(1)
{
printf("child pid: %d, ppid: %d, [g\_val: %d][&g\_val: %p]\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
count++;
if(count == 5)
{
g_val = 100;
}
}
}
else if(id > 0)
{
while(1)
{
printf("father pid: %d, ppid: %d, [g\_val: %d][&g\_val: %p]\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
else
{
//TODO
}
return 0;
}
根据我们现有的知识,无可厚非的是前 5 次父子进程的 g_val 的值是一样的,且地址也一样,因为我们没有修改 g_val, 5 次后,子进程把 g_val 的值改了之后,父进程依旧是旧值,这个我们一点都不意外,因为父子共享代码,数据各自私有,后面会站在系统角度讲数据各自私有是写时拷贝来完成的,以前我们是在语言层面上了解;但匪夷所思的是 5 次后,父子进程的 g_val 的地址竟然也是一样的。
推导和猜测???
从上图我们可以知道 &g_val 一定不是物理地址 (真正在内存中的地址),因为同一个物理地址处怎么可能读取到的是不同的值。所以我们断言曾经所看到的任何地址都不是物理地址,而这种地址本质是虚拟地址,它是由操作系统提供的,那么操作系统一定要有一种方式帮我们把虚拟地址转换为物理地址,因为数据和代码一定在物理内存上存储,这是由冯 • 诺依曼体系结构规定的。上面说到虚拟地址是由操作系统提供的,我们也说过程序运行起来之后,该程序立即变成进程,那么虚拟地址和进程大概率存在某种关系。
💦 什么是进程地址空间
地址空间在 Linux 内核中是一个mm_struct结构体,这个结构体没有告诉我们空间大小,但是它告诉我们空间排布情况,比如[code_start(0x1000), code_end(0x2000)],其中就会有若干虚拟地址,这是因为操作系统为了把物理内存包裹起来,给每个进程画的一把尺子,这把尺子我们叫进程地址空间。进程地址空间是在进程和物理内存之间的一个软件层,它通过mm_struct 这样的结构体来模拟,让操作系统给进程画大饼,每一个进程可以根据地址空间来划分自己的代码。
所以我们再回顾:进程地址空间当然不是物理内存,它本质只是操作系统让进程看待物理内存的方式,其中 Linux 内核中是用 mm_struct 数据结构来表示的,这样的话每个进程都认为自己独占系统内存资源 (好比每个老婆都认为自己独占10亿);区域划分的本质是将线性地址空间划分成为一个一个的区域[start, end];而所谓的虚拟地址本质是在[start, end]之间的各个地址。
看看源码中怎么写 ❓
💦 页表
进程地址空间如何映射至物理内存这就引出了页表,页表的结构是 b 树,目前不打算深入它。假设存在三个进程 A B C,操作系统就会给每一个进程画一张大饼,叫做当前进程的虚拟地址空间,其中会通过指针将进程和虚拟地址空间关联起来。运行进程 A,就要把进程 A 加载到物理内存中,其中操作系统会给每一个进程创建一张独立的页表结构,我们称之为用户级页表,当然后面还有内核级页表,而页表构建的就是从地址空间中出来的虚拟地址到物理地址中的映射,每个进程都通过页表来维护进程地址空间和物理内存之间的关系,这是页表的核心工作,所以进程就可以根据页表的映射访问物理内存。当然单纯一张页表是不可能完成映射的,还要配合某些硬件,以后会谈。
能否把进程 A 中的代码和数据加载到物理内存中的任意位置 ❓
在不考虑特殊情况下,是可以将进程对应的代码和数据在物理内存的任意位置加载的,因为最终只需要将物理内存上的代码和数据与页表建立映射关系,就可以通过虚拟地址找到物理地址。所以进程中的代码和数据是能够加载到物理内存中的任意位置的,其中本质是通过页表去完成的。
多个进程之间会互相干扰吗,不同的进程它们的虚拟地址可以一样吗 ❓
同样进程 B 也可以通过页表把代码和数据加载到物理内存的任意位置,就算不同的进程的虚拟地址完全一样也没问题,因为不同进程通过一样的虚拟地址查的是不同的页表,其中的工作细节是由页表去完成的,这也解释了上面为啥两个进程虚拟地址一样却不会互相影响。
如果物理地址重址呢 ❓
这是操作系统的代码,一般不可能重址。当然也存在这样的特殊情况,如果进程 B 和进程 C 是父子关系,我们在创建子进程 C 的 PCB、地址空间、页表、建立各种映射关系,把代码区、数据区等区域映射时,只需要将子进程 C 映射到物理内存中父进程的代码和数据处,但当子进程 C 修改数据时,操作系统就会重新申请内存,修改当前进程的指向关系,此时子进程就不再指向父进程的数据了,而让子进程指向新的空间,把旧数据拷贝至新数据,最后再修改数据,此时这就是写时拷贝。所以不同的页表,物理地址可以重址,只不过这种重址是刻意的,因为父子代码共享。
💦 为什么要存在进程地址空间
- 其实早期操作系统是没有所谓的虚拟地址空间的。如果进程直接访问物理内存,那么我们看到的地址就是物理地址,当我们认识过在 C 语言中有一个概念叫做
指针,那么就能理解有可能会出现:如果进程 A 出现了越界,那么就有可能直接访问到了另一个进程的代码和数据,所以进程的独立性便无法保证。甚至因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改。比如说某进程里有帐号和密码的数据,那就有可能会被更改密码,如果操作系统不让改,那也可以进行读取,如果操作系统不想让你读取,操作系统就要实现一些较为困难的权限管理,成本较高。后来大佬对进程和物理内存之间就引出了进程地址空间,其中每一个进程都有自己的地址空间、页表。虚拟地址最终通过页表转换为物理地址,那么页表需要根据实际情况转或不转。好比小时候过年,收到亲戚的压岁钱后,妈妈怕你乱花钱,所以就帮你存起来,当你要买资料时,你妈就帮你支出,但你要买游戏机时,你妈就可以拒绝你。换言之,虚拟地址到物理地址的转换,是由页表完成的,同时也需要进行合法性检测。所以进程地址空间的意义就是保存物理内存,不受任何进程内的地址的直接访问,也方便进行合法性校验;另一方面可以不用在物理内存上找一块连续的区域存储了,减少内存碎片。实际有了地址空间后,以进程的视角看它的代码区,数据区等区域时都是连续的,而真正在物理内存上不一定连续。
- 至此我们认识到地址空间的引入可以保护物理内存。其它情况,越界时不一定报错,比如在栈区越界后还是在栈区,在一个合法区域内,操作系统是有其它机制去检测的,那么既定的 C/C++ 事实是我们在越界时是不一定报错的,因为编译器是以抽查的形式来检测,这里可以去了解一下
金丝雀技术。对于有区域划分的地址空间,你访问数据区,但是因为越界访问了代码区,操作系统就可以根据你曾经区域划分时的[start, end]来确认是否越界。对于页表,它将每个区域映射至物理内存中,页表要进行某种映射级别的权限管理,比如在映射数据区时,物理内存的任意位置都是可以被修改的,否则曾经的数据是怎么被加载的;但在映射代码区后,你有任何的写入操作时,操作系统发现对应页表你只有r权限,一旦写了,操作系统就终止你的进程。我们都知道如下这种字符串是在代码区存储,代码区是只读的,所以你要修改它,在 Linux 下报的是段错误,在 VS 下报的是表达式必需是可修改的左值。从 Linux 报的错误来看,这段代码是能编译通过的,但是运行后,操作系统发现页表在映射时,你要映射的区域是不可写的,那么经过这样的进程地址空间 + 页表,操作系统就可以直接终止进程,换言之,进程地址空间是为了更好的进行权限管理。
只读的代码区不能被修改,那么第一次是怎么形成的 ❓
形成代码区时不就是把数据往代码区里写吗,其实代码区在操作系统的角度,它一定是物理内存的任何位置都可以改的,只不过*str = 'H'是在你进行写入后修改字符串起始的第一个字符,所以经过对应的页表映射时,发现你对这个区域的权限是只读的,而你竟然想写入,所以操作系统就不会映射,而直接终止进程。
我们都知道操作系统具有 4 种核心功能:进程管理、内存管理、驱动管理、文件管理。而下图很明显是与进程管理和内存管理有关,比如说一个进程要执行,首先要申请内存资源,并加载到内存,然后创建 PCB 等进程管理工作;而进程死亡后,就需要内存管理模块来进行尽快回收,内存管理必须得做到知道某个进程的状态。所以内存管理模块和进程管理模块是强耦合的。如果有了虚拟地址空间的概念,那么更多的是进程管理只关注左半部分 —— 为进程创建 task_struct,mm_struct 等等,而内存管理关注右半部分 —— 它只需知道哪些内存区域 (page) 是无效的或是有效的。如下图,这样的好处是将内存管理和进程管理进行解耦,比如创建进程只需要在页表中向系统申请内存,而进程释放则通过页表释放即可。在 C++ 中有一个技术叫做智能指针,比如说给物理内存的一块区域设置一个计数器 count,其中当页表映射一个进程后,count++,当进程释放后,映射关系消失,count--。所以内存管理只需要检测当前物理内存中的 count 是否为 0。
一个 16G 的游戏能否在 4G 的物理内存上运行 ❓
能,比如你的内存是 32G,即便直接来 16G,对计算机而言,它是从头开始访问的,也就是说 16G,你已经有 15G 已经加载到物理内存了,但你尚没有正常使用,还需要等待后面的数据加载进来,所以这是一种很低效的方案。所以操作系统要执行这个进程,但内存管理模块认为给你搞这么多你又不使用,所以就先加载 200M 给你,当你从上至下访问到最后时,如果你还需要,就再给你覆盖式的加载 200M,此时进程是不知道内存管理模块给他做的操作,内存管理就可以通过不断延迟加载的技术方案,来保证进程照样可以正常运行,这就是进程管理模块和内存管理模块解耦。所以对于用户来说,唯一感受到的是我的电脑变慢了,当然就这配置也是应该的。
- 在磁盘上形成的 .exe 文件在编译时其实并不是无脑的一分为二成代码和数据就完了,而是在磁盘中按照文件的方式组织成一个一个区域,也就是说可执行程序本身在磁盘上就已经被划分成了一个个区域,为什么要在编译的时候要这样划分区域,因为这样便于生成可执行程序,如果划分好了区域,那么就会减少程序链接过程的成本。因为磁盘上的可执行程序本身就是按模块划分的,所以进程地址空间才有了区域划分的概念,但要注意物理内存的情况有可能大部分的空间已经被使用了,那么进程的代码和数据可能就零散的分布于物理内存的不同位置,甚至你的代码的数据都不一定在一起,可能会被零散的加载到物理内存的任意地方,具体加载到哪里是由内存所处的状态以及 linux 内核的内存管理算法决定的,所以对于进程而言就不好找到代码的位置了,所以就在虚拟地址空间中将区域进行划分,划分成在磁盘中可能看到的区域,当然内存中的栈区,堆区磁盘中没有,然后再经页表将所有区域的数据整合,所以通过地址空间看到的就和在磁盘中看到的就是同一种物理排序了,所以每个进程就可以以同样的方式来看待代码和数据,比如进程怎么找到第一行代码呢,如果有地址空间就可以默认的在地址空间的一个确定的区域执行代码的入口,继而执行整个代码。物理内存也有区域,只不过它的内存分配是按页为单位,一页是 4kb,也就是说内存和磁盘进行交互时是按 4kb 为基本单位,所以可执行程序的生成基本都是 4kb 的整数倍,比如如下的代码区就被划分了若干个 4kb 的段。在内存中的若干个小框称为
页框,而在磁盘中可执行程序被划分为若干个 4kb 大小的数据称为页帧。
- 顺序语句
顺序语句就是从上至下执行,那么这里所有的代码它们的地址是连续的,如果它们不连续,那么 pc(eip) 指针如何进行加减呢。所以本质是将虚拟地址线性连续后,顺序语句就能实现了。所以顺序语句就是当前语句的起始地址 + 当前代码的长度。
show 函数调用完后,字符串还在吗 ❓
当 show 函数调用完后,函数栈帧销毁,所以局部变量 str 一定是不在了;但是对于字符串,它存储于常量区,只要进程还在,那么字符串就还在。show 栈帧结束,理论上是找不到字符串了,所以我们就能理解所有的地址信息都必须要用变量保存,当你在物理内存中 malloc 好一块内存,页表构建映射关系,把地址映射到堆区,最后这个区域的起始地址就返回给用户,如果用户不使用变量保存,那么就会存在内存泄漏。
-
所以虚拟地址空间存在的意义有:
- 更好的进行权限管理和保护物理内存不受到任何进程内地址的直接访问,方便进行合法性校验。
- 进程管理和物理管理进行解耦。
- 让每个进程以同样的方式,来看待代码和数据。
-
为什么要区域划分
区域划分的本质是将线性地址空间划分成一个个区域 [start, end];而所谓的虚拟地址本质是在 [start, end] 之间的各个地址。
其一是可以初步判定当前进行区域寻址时是否越界,其二是可执行程序本身是按照若干个 4kb 模块划分好的,这就影响了操作系统在进行进程设计时也必须进行模块划分,所以就有了区域划分的概念。