目录
前言
在我们之前学习的过程中,大概知道程序的地址空间是这样分布的:
其实这样的理解并不全面,今天就让我们来深入了解程序的地址空间。
一、程序地址空间
我们首先写一个程序看看不同类型的数据在内存中的存储情况:
可以看到运行结果是符合我们之前学习的存储逻辑的。
接下来我们再看段代码
这段代码让子进程在循环五次后改变g_val的值,我们来看看运行结果:
在改变之前,其结果是符合我们的逻辑的,子进程和父进程共享着g_val这一个变量,因此它们的值、地址都是相同的。可是在子进程修改后,怎么可能同一个变量,同一个地址,同时读取时读到了不同的内容呢??
由此我们可以推断出:这个变量的地址,绝对不是物理地址。
其实在平常我们写C/C++时用的指针,其地址都不是物理地址,我们把它称为虚拟地址,或者线性地址。
二、进程地址空间
2.1 大致原理
所以之前程序地址空间这个说法是不准确的,应该是进程地址空间。
我们看一下图就可以理解了:
当我们运行一个程序时,实质上它会变成一个进程,此时在操作系统内核中一定会为该进程创建一个PCB,即struct task_struct。如图,一旦父进程PCB创建,此时它不会直接映射到物理内存中,而是指向一个专属于它的进程地址空间,紧接着还会为当前的父进程构建一个页表,其中有虚拟地址、物理地址,假设父进程中有一个初始化的数据,其虚拟地址就会存到页表中,页表根据虚拟地址转化为物理地址,此时就可以访问到物理地址中的内容。
当我们创建一个子进程时,它会基本以父进程为模版,来初始化它结构体内部对象的值,当然也会把父进程的进程地址空间拷贝一份,其虚拟地址转化后的物理地址的指向和父进程都是一样的:
当我们在子进程修改g_val时,在系统层面上就识别了我们要在0x405c这个地址进行写入,系统顺着物理地址找到了这个变量,发现是与父进程共享的,因此在写入之前会重新开辟一段空间,再把页表中的物理地址改为新空间的:
这个操作我们称为写时拷贝,它是由操作系统自动完成的,在这个过程中,左侧的虚拟地址是0感知的,不会影响到它。
2.2 有关地址空间细节
那么地址空间究竟是什么呢?我们要如何理解地址空间上的区域划分?
在32位计算机中,有32位的地址和数据总线,每一个总线只有0/1两种,因此会有2^32byte(2^10byte=1KB)即4GB大小的空间 。
因此地址空间就是计算机能访问到的物理内存的最大数据范围。
区域划分就是在地址空间内对各种区域划分,对线性地址进程start和end即可。
综上可得,地址空间本质上是内核的一个数据结构对象,类似PCB一样,它是一个mm_struct结构,在32位系统上默认划分的区域是4GB,地址空间也是要被操作系统管理的:先描述,再组织:
那么设计地址空间的意义在哪里呢?
有了地址空间,进程在访问物理内存时就会经过一个中间转换的过程,此时可以做系统级别的检查,从而可以有效拦截进程的非法操作,该请求就不会到达物理内存,保护了物理内存。
2.3 有关页表细节
我们已经知道,在进程创建时,系统还会为他构建一个页表,这个页表是存在CPU的cr3寄存器中的,在这个寄存器中会保存页表的地址,它本质属于进程硬件上下文,即使进程切换走,它也是会把寄存器中的地址带走的,当切回时又恢复上来,因此自始至终进程都可以找到它自己的页表。
那么对于各个数据,我们怎么知道它是只读还是可写的呢?其实页表还有一个项来表示当前访问的物理内存是读、写还是可执行的:
如图,假设一变量是在常量区的,当你想请求进行写操作时,操作系统能直接识别到你要访问这个地址,但是这里是只能读的,操作系统就会直接拦截你,你的进程相当于进行了一次非法操作,操作系统就会直接杀死你这个进程。
因此页表可以给我们提供很好的权限管理。
我们也知道进程是可以被挂起的,这要怎么知道进程的代码数据是否在内存中呢?
页表中其实还存在一个标志位,它用来记录对应的代码和数据是否已经被加载到了内存:
要知道操作系统对大文件是分批加载的,像一些大型游戏高达上百G,操作系统就采用惰性加载,承诺给你多大的空间,但是在真实使用时是在物理内存中几乎是用多少给多少。在页表中就会先把虚拟地址填好,物理地址不填,要转化时先看页表中的标志位,如果标志位为已加载,则可以直接读取物理地址找到它在物理内存中的位置进行访问,否则操作系统会触发缺页中断,此时就会在物理内存中开辟一段空间,把当前可执行的代码数据加载到内存里,然后把这段物理地址填到页表当中。所以说写时拷贝也是一个缺页中断。
所以在进程被创建时,先会创建内核数据结构,而不是先加载对应的可执行程序。
经过上面的学习,可以发现有了页表和进程地址空间的存在,进程管理和内存管理就解耦合了,这样就会防止一旦内存管理出问题,也会影响到进程的问题了。