??所谓进程地址空间,本质是描述进程可视范围的大小。更简单点来说,地址空间本质是内核的一个数据结构对象,类似PCB一样,地址空间也是要被操作系统管理的。即先描述,再组织。每一个进程都有一个自己的 task_struct 结构体来描述组织,而它的地址空间我们用 mm_struct 来描述组织。要对地址空间进行各种区域划分,我们需要用 start 和 end 来对线性地址进行标记。要让地址空间被管理起来,则每一个进程的 task_struct 结构体必然能指向 mm_struct,这样才能知道自己的代码、数据等相关信息在哪里,才能管理起来。看下面图示,了解Linux 内核中的地址空间
??我们学习C&C++,经常接触到的地址空间并不是内存,准确来说程序&进程&虚拟地址空间。我们可以通过下面代码来验证地址空间的基本排布,同时得到以下结论,堆和栈想向而生,堆向上生长,而栈向下生长(即栈是先使用高地址,而堆是先使用低地址)
# include<stdio.h> # include<stdlib.h>
# include<stdlib.h>
int g_unval;//未初始化
int g_val = 100;//初始化
int main()
{
const char* str = "hello bit";
char* mem = (char*)malloc(20);
char* mem1 = (char*)malloc(20);
char* mem2 = (char*)malloc(20);
int a;int b; int c;
printf("代码区: %p\n",main);
printf("字符常量区 : %p\n",str);
printf("已初始化全局数据区: %p\n",&g_val);
printf("未初始化全局数据区: %p\n",&g_unval);
printf("\n");
printf("栈地址:%p\n",&a);
printf("栈地址:%p\n",&b);
printf("栈地址:%p\n",&c);
printf("\n");
printf("堆地址:%p\n",mem);
printf("堆地址:%p\n",mem1);
printf("堆地址:%p\n",mem2);
return 0;
}
??通过之前fork函数的认识,我们知道,fork创建子进程可以返回两次,并且父子进程读时共享,写时拷贝。那么写实拷贝的时候,同一变量名的两个变量地址是不是应该不同?通过下面的代码验证却会发现,两个变量地址相同!那么这两个变量地址肯定不是所谓的物理地址,即它们是虚拟地址,同时虚拟地址必然和物理地址存在一种映射关系!
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val=100;
int main()
{
pid_t id=fork();
if(id==0)
{
int cnt=5;
while(1)
{
printf("子进程 pid: %d, ppid: %d, g_val: %d,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
if(cnt) cnt--;
else{
g_val=200;
printf("子进程改变:g_val 100->200\n");
cnt--;
}
}
}
else
{
while(1)
{
printf("父进程 pid: %d, ppid: %d,g_val: %d, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
return 0;
}
??由上可知虚拟地址是存在的,对于进程地址空间我们需要先描述再组织,因此就多了一个 mm_struct 结构体。而对虚拟地址和物理地址存在的映射关系,我们也需要先描述再组织,在这其中会有一种表结构,即页表。页表的地址保存在CPU的cr3寄存器中,属于物理地址。当进程在CPU中轮转调度时,进程会把cr3寄存器中的页表相关信息带走,CPU下次调度时再恢复过来,这样就知道上次该进程调度时,代码执行的位置。
??每个进程都有自己的页表,父子进程的页表可以完全相同。页表中必然有 key-value 结构来保存虚拟和物理内存的映射关系。这样当fork父子进程数据没有做修改时,映射的的物理地址便是一样的。若父子进程中修改了数据,则进行写实拷贝,修改页表中物理地址的映射值,在物理内存中单独存储一份。但在用户看来,虚拟地址是一样的,这也就解决了上面的 g_val 值不同,而打印出来的地址却相同的疑惑
??一个程序运行有代码区和和字符常量区,它们具有只读属性。进程访问物理内存的怎么知道这个区域是可读还是可写的呢?物理内存是没有权限管理的,没有区分读写的能力(并不能自己独立判断访问是否合法),因此需要通过页表作为桥梁,在页表中设立标志位,而标志位用位图来标明其读写属性。
??目前,主流的笔记本电脑物理内存大小是8GB,可我们却却能够玩十几个G,甚至几十个G的游戏,这充分证明操作系统可以对大文件进行分批加载!如果一下子把进程所有代码和数据加载到内存,由于进程调度和时间片轮转,后面很大一部分内存并不会被访问,就会造成时间和空间浪费。因此操作系统采取惰性加载的方式(即在进程需要使用某个资源时才将其加载到内存中)。同样在页表中设置一个标志位,用0和1来确定进程的代码和数据是否加载到内存中。
??在这个进程中先把先把页表中的虚拟地址填满,物理地址先填一部分。进程调度时,先找到该虚拟地址对应的标志位,看物理地址是否存在(即代码和数据是否已经加载到内存)。若标志位为1,再看其页表中读写权限访问物理内存。若标志位为0,则说明代码和数据并没有加载到内存里。此时操作系统触发缺页中断,找到该可执行程序在磁盘中的代码和数据,为其在物理内存中开辟一块空间加载进来,并补上在页表中的物理地址。此后,继续执行进程调度访问物理内存。
??现在我们对进程有了更加深入的理解,进程 = 内核数数据结构(task_struct&进程地址空间&页表)+ 程序的代码和数据。进程地址空间的存在可以让进程以统一的视角看待内存,虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,在这个转化的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存。因为有地址空间和页表的存在,将进程管理模块,和内存管理模块进行解耦合!