在操作系统中,进程的地址空间是一个非常关键的概念。理解它不仅有助于我们掌握进程间通信和资源分配,还能深入理解操作系统的核心机制。本文将详细讲解进程地址空间的结构、其与物理内存和进程控制块(PCB)的关系,以及页表和写时拷贝(Copy-on-Write)的具体实现。
进程地址空间概述
首先我们来看以下程序的内存分布
地址空间可以划分为多个区域,每个区域对应不同类型的数据和代码。例如,通常包括以下几个部分:
- 代码段:存储程序的可执行代码。
- 数据段:存储全局变量和静态变量。
- 堆:用于动态分配内存。
- 栈:用于存储函数调用的局部变量和返回地址。
这些区域的起始和结束地址在地址空间的结构体中有所标识,使得操作系统可以准确地管理和分配内存。
但是我们并不理解,接下来我们通过一段代码来进一步理解
int g_val = 10;
void fork_example() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
g_val = 20;
} else {
// 父进程
wait(NULL);
}
printf("g_val: %d\n", g_val);
}
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
OS必须负责将 虚拟地址 转化成 物理地址 。
地址空间的内部结构
那么我们该如何理解分页和虚拟地址空间呢?
每个进程由进程控制块(PCB)和对应的代码、数据构成。PCB中包含指向代码和数据的指针,但这些指针实际上是指向一个新的地址空间。每个进程都有一个独立的地址空间和页表,用于管理其虚拟地址到物理内存的映射。
地址空间本质上是一个结构体(struct),其中包含许多属性,这些属性用来表示内存的各个区域,例如代码段、数据段、堆、栈等的起始和结束地址。通过这种方式,操作系统将无序的物理内存管理变得有序,使进程能够以统一的视角看待内存。
页表与物理内存的连接
进程的地址空间通过页表实现与物理内存的连接。页表是一种数据结构,存储了虚拟地址到物理地址的映射关系。当进程访问某个虚拟地址时,操作系统通过页表找到对应的物理地址,从而访问物理内存中的数据。
具体来说,当一个进程尝试访问某个内存地址时,CPU首先查找该地址的页表项(PTE),从中获取对应的物理内存地址。如果页表项存在且有效,CPU就能通过该物理地址访问实际的内存单元。否则,会发生缺页异常,操作系统会加载所需的页面并更新页表
那么为什么父进程的g_val和子进程的g_val可以不一样呢,这其实利用的是接下来要讲的
“父子写时拷贝技术”
写时拷贝(Copy-on-Write,COW)是操作系统优化内存使用的一种技术。创建子进程时,操作系统并不会立即复制父进程的所有数据,而是让父子进程共享相同的物理内存。当子进程尝试修改共享的内存时,操作系统才会进行实际的拷贝操作,给子进程分配新的内存区域,并更新页表指向新的内存地址。
例如,当fork
创建子进程时,子进程会默认拷贝父进程的大部分数据(浅拷贝)。父子进程的虚拟地址相同,但指向的物理内存是共享的。当子进程对共享内存进行写操作时,操作系统检测到该操作并执行写时拷贝,分配新的物理内存,并将子进程的页表更新为指向新内存,确保父进程的内存不受影响。
所以也就是说每一块进程都会开辟一个地址空间,而地址空间的本质也是结构体!
如何理解地址空间
也就是说 地址空间本质是一个struct结构体,内部的很多属性都是表示start和end的范围
为什么要有地址空间,将无序变成有序,让进程以统一的视角看待物理内存以及自己运行的各个区域 进程管理模块和内存管理模块进行解耦。
进一步理解页表和写时拷贝
在操作系统中,fork
函数会返回两次,一次在父进程中返回子进程的PID,另一次在子进程中返回0。这是因为fork
在创建子进程时,父子进程共享相同的地址空间,但在子进程尝试写入数据时,操作系统通过写时拷贝技术为子进程分配新的物理内存,并更新页表,从而使得父子进程的内存空间真正独立。
这样的映射关系有什么好处呢?
答案是在通过虚拟地址寻找物理内存的过程中,增加一个中间变量,如果OS识别到系统错误,首先判断数据是否在物理内存中,如果不在,就自动报错,发生缺页中断。其次要判断数据是否要发生写时拷贝,如果都不是,那么就会最终进行异常处理。所以作为中间层,可以对非法请求进行合法拦截!保护内存安全
谈完这些,我们接下来要谈谈
Linux中进程是如何被真正调度的
在Linux中,每个CPU都有一个运行队列(task_struct *queue[140])。实时操作系统使用0-99的优先级,而分时操作系统使用100-139的优先级。调度算法称为O(1)调度算法,CPU通过active和expired指针管理进程的调度。active指针指向当前运行的队列,而expired指针指向等待运行的队列,当active队列处理完毕时,两个指针交换即可完成调度。
具体来说,Linux的调度机制如下:
- 优先级队列:进程根据优先级分配到不同的队列中,优先级越高的队列越早被调度。
- O(1)调度算法:这种算法保证了调度过程在恒定时间内完成,不受进程数量影响。
- 活跃队列和过期队列:active指针指向当前正在运行的队列,expired指针指向等待运行的队列。当active队列中的所有进程都被调度完毕时,两个指针交换,开始调度expired队列中的进程。
我们前文说过nice的取值范围是从-20 --- 19,一共40个,正好能够对应我们的分时操作系统。
同时查看bitmap就能查看是否为空,哪一个队列有进程 bitmap[5]来代表对应的运行队列下标,类型为 long bitmap 字节大小为 32*5 160 这种调度算法叫 O(1)调度算法
在Linux操作系统中,调度器使用位图(bitmap)来跟踪和管理进程队列的状态。位图是一种紧凑的数据结构,能够有效地表示多个布尔值(即是否有进程在特定优先级队列中)。位图的每一位表示一个优先级队列的状态,当某个优先级队列中有进程时,位图中的相应位会被置1,否则为0。
在Linux调度器中,位图用于快速确定某个优先级队列是否为空。例如,如果有一个包含140个元素的位图数组,每个元素表示一个优先级队列的状态,那么调度器可以通过检查这些位来快速决定哪个队列包含可运行的进程。
位图的结构和初始化
假设位图的长度为140,对应调度器中的140个优先级队列。每个元素的状态如下:
- 0:表示该优先级队列为空
- 1:表示该优先级队列中有进程
当系统启动或进程状态发生变化时,调度器会更新位图。
在这个过程中,调度器首先检查位图数组的每一个元素,如果某个元素不为0,。然后,通过逐位检查,找到具体的非空队列。
但我们说了,真正的进程切换,CPU会优先寻找活动队列
活动队列
过期队列
但我们说了,真正的进程切换先找active指针,指向哪个就访问哪个队列,这个指向的队列是只出不进 而expired指针指向的是只进不出,当active指向的全部被处理完毕,在swap一下就可以了
通过本文的详细讲解,相信你对进程地址空间、页表、写时拷贝以及Linux的调度机制有了更深入的理解。希望这些内容能帮助你更好地掌握操作系统的核心概念。