文章目录
- 前言🦕
- 🦖 防止进程对物理内存的非法(危险)访问
- 🦖 进程管理模块与内存管理模块的解耦合
- 🦖 实现进程间的独立性
前言🦕
在文章『 Linux 』进程地址空间概念中提到了进程地址空间的部分概念;
这部分概念主要围绕进程地址空间到底是什么;
在实际中,进程地址空间是一个进程的数据结构,这个数据结构的作用是模拟出虚拟地址;
当一个进程需要访问物理内存时必须经过进程地址空间获取其虚拟地址,通过页表找到页表中所映射的物理地址,才能对需要的物理地址中的数据进行操作;
这样的操作流程一定程度上保证了进程间与物理内存的安全性;
🦖 防止进程对物理内存的非法(危险)访问
在进程当中,每个进程都会有对应的PCB结构体(进程控制块),进程控制块与进程的进程地址空间产生对应关系;
当一个进程需要去访问对应的物理地址时将要从进程地址空间获取对应的虚拟地址,通过该虚拟地址以一种映射关系映射到对应的物理地址当中;
这个映射关系是通过一种名为页表的数据结构进行的;
页表以key/value
的模型使得cpu获取到虚拟地址时能通过映射关系找到对应的物理地址;
而实际当中,页表不仅仅可以做到映射关系,页表还能做到权限查询;
当进程创建之后将会初始化进程间对应的一些数据结构,这些数据结构包括进程控制块,进程地址空间,页表等;
而在初始化页表的阶段,不仅会给页表初始化对应的虚拟地址(不一定会直接申请内存并产生映射关系),还会根据虚拟地址对应代码初始化对应的权限(页表中的页表项,不作过多说明);
使得一个进程在对物理内存进行非法访问的时候能使该进程因内权限不足不予访问;
如果进程在通过页表映射关系对物理内存的访问非法访问时则会触发页表的权限查询;
以一个例子为例,存在这样一段代码:
int main()
{
const char *str="hello world";
*str = 'H';
return 0;
}
在实际中这段代码将在编译过程中的语义分析报错而导致编译失败;
假设这段代码编译未报错且生成了对应的可执行程序;
这段代码中使用*str
对字符常量区进行修改;
但字符常量区的代码的权限为只读权限,即该区域内的代码不能被进行写入操作;
当进程需要对该物理内存以写入的方式进行访问时将会触发页表的权限查询行为;
内存管理单元(MMU)会根据页表中的映射关系讲虚拟地址转化为物理地址,并在转换的过程中进行权限检查;
如果权限不符合访问要求,MMU将会触发异常,这个异常会被传递给OS(操作系统),OS在接收到异常过后会根据异常的类型进行处理;
在OS的层面中,本质上的物理内存是不具备物理访问权限限制的,意思是物理内存本身是可以被进行任意的读写操作的;
而若是直接对物理内存进行读和写的操作时可能会出现进程间的误操作导致不能保证物理内存的安全性;
而在拥有进程地址空间(包括页表)时,在这套机制下,内存管理单元(MMU)将对页表的权限进行查询,使得若是某个进程非法对物理内存进行读写操作时能对该操作进行有效拦截;
可以使用mprotect()
函数对该场景进行模拟:
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <string.h>
int main() {
const char* str = "hello world";
// 将str所在的内存页标记为只读
size_t page_size = sysconf(_SC_PAGESIZE);
void* page_start = (void*)((uintptr_t)str & -page_size);
if (mprotect(page_start, page_size, PROT_READ) != 0) {
perror("mprotect");
return 1;
}
// 尝试对只读内存区域进行写入操作
char* writable_str = (char*)str; // 将const char*转换为char*,这是非法操作
*writable_str = 'H'; // 这里会触发内存保护异常
return 0;
}
在这个演示中,使用mprotect()
函数将str
所在的内存页标记为只读,然后尝试对只读内存区域进行写入操作;
当进程尝试对只读内存区域进行非法写入时,会触发内存保护异常,从而导致程序的终止或异常处理;
总而言之,进程地址空间的存在以及内存管理单元(MMU)对页表的权限查询机制保护了物理内存中的所有的合法数据(包括进程及与内核相关的有效数据);
🦖 进程管理模块与内存管理模块的解耦合
耦合顾名思义就是关联性:
在开发的过程中一般都要求程序的模块间尽量的低耦合高类聚;
一个程序模块间的耦合度越低,其维护成本也低;
在历史中的进程中并不存在进程地址空间,使得一个进程在访问物理地址的时候需要采用直接访问的方式(地址+偏移量);
若是采用直接对物理内存进行访问的方式对地址进行读写操作,则可能出现某些进程恶意修改其他进程的上下文内容或是其他有效代码与合法数据,将危及其他进程;
且若是采用这种方式对物理内存进行访问的话其进程管理与内存管理将是一种强耦合的关系;
对于这种强耦合的关系其维护成本必定高于弱耦合;
而进程地址空间的出现可以有效的将进程对内存的访问分为两个模块:
- 内存管理模块
- 进程管理模块
对于进程管理模块而言,操作系统将初始化对应的进程控制块(PCB结构体)与其内部的数据结构,这些数据结构包括进程中的进程地址空间(mm_struct),在对进程地址空间进行初始化时将对页表内部对应的虚拟地址进行初始化;
当一个进程初始化结束时(并未进入调度队列运行)时,其虚拟地址是已经通过磁盘内的虚拟地址(逻辑地址)进行同步的初始化;
但实际上其物理内存并未真正给予该进程对应的物理内存空间,只不过当该进程使用CPU资源时OS将根据进程的代码为进程合理分配物理内存;
OS为了使进程的物理地址更加具有安全性,将会采用一种ASLR
的内存分布随机化的技术使得进程页表中虚拟地址所映射的物理地址进行随机分布;
即一个进程的虚拟地址在页表中所映射的物理地址在物理内存中是可以随机分布的,并不会以在语言层面的内存概念那样以栈区,堆区,正文代码区等等进行内存分布;
这也更加的能够使得进程管理模块与内存管理模块进行解耦合;
当然在对一个进程进行初始化(包括进程地址空间)时并不一定在会将物理内存中开辟物理空间;
在语言层面当中(以c/C++为例),当使用new
或者是malloc
对内存申请空间时,对于上层的这个内存申请并不是实质的物理内存;
为了避免物理内存被申请时并不马上被使用所造成的空间浪费,上层在申请内存空间时本质上是在进程地址空间中申请的,当上层对进程地址空间进行内存申请时,OS并不会马上在页表中进行映射(开辟物理内存空间);
只有当真正需要对物理内存进行访问时OS才会执行内存的相关管理算法(包括内存申请与构建页表的映射关系)后再对该物理空间进行访问;
OS作为进程与各项资源的管理者,是可以随时对物理内存进行访问的,而在用户和进程的视角当中,并不会感知OS对应的执行内存相关管理算法等操作;
OS将采用一种缺页中断
的技术判断页表中的虚拟地址是否有映射对应的物理地址(开辟物理空间);
总而言之,因为进程地址空间以及页表的存在,可以使得整体以内存管理模块
与进程管理模块
两个模块进行解耦合;
在进程管理模块当中OS只需要根据磁盘中的虚拟地址(逻辑地址)对进程地址空间与页表进行初始化;
而在内存管理模块当中,OS更可以不对该进程立马分配物理地址,而是根据延迟分配
的方式提高整机的效率;
🦖 实现进程间的独立性
根据上文可知,OS在通过页表中的虚拟地址映射给物理地址时(开辟空间)所采用的方式为ASLR
的随内存分布随机化的方式,导致了真正在物理内存当中其物理地址的分化是无序的;
而进程地址空间和页表的存在尤其是页表的映射关系使得在进程的视角中可以使得内存有序化;
同时从上文得知,在对一个进程申请内存时其物理空间并不会马上被申请(延迟分配的策略);
由于内存管理模块与进程管理模块的解耦合,在多个进程对物理内存进行访问时OS将使用一定的内存管理使得在进程的视角当中每个进程都能够独立拥有一整块内存空间,以此实现进程间的独立性;
由此可知进程间的独立性可以依靠进程地址空间与页表共同完成;
总而言之由于进程地址空间的存在,在进程的视角当中每个进程都可以拥有有序的4GB内存(32位机器下);
操作系统将通过页表映射到不同的物理地址从而实现进程的独立性;