目录
1. 虚拟地址
2. 进程地址空间分布
3. 描述进程地址空间
4. 内存管理——页表
5. 父子进程的虚拟地址关系
6. 页表标记位
6.1 读写权限
6.2 命中权限
7.为什么存在进程地址空间
1. 虚拟地址
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int gval = 100;
int main()
{
printf("我是一个进程, pid: %d, ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if (id == 0)
{
//child
while(1)
{
printf("我是一个子进程, pid: %d, ppid: %d, gval: %d, &gval: %p\n",
getpid(), getppid(), gval, &gval);
gval++;
sleep(1);
}
}
else
{
while(1)
{
printf("我是一个父进程, pid: %d, ppid: %d, gval: %d, &gval: %p\n",
getpid(), getppid(), gval, &gval);
sleep(1);
}
}
}
我们写一份代码,先定义一个全局整型变量gval,赋值为100。再使用fork函数创建子进程,都使用while循环,不断打印pid,ppid,gval值和gval的地址,不一样的是子进程对gval不断加加。
运行结果如上,不管是子进程还是父进程对gval变量取地址,得到的地址都是相同的,但是子进程的gval变量在不断变大,而父进程的gval变量没有变。这在我们之前的学习过程中是不可能出现的情况。
因为子进程和父进程数据不是共享的,那么子进程和父进程打印出来的gval值应该是不同的,所以内存上的地址也应该不相同。如果内存上的地址相同,gval值就应该一样。这显然是互相矛盾。
这种情况表明什么呢?说明在语言层面上获得的地址不是物理内存地址,而是一个虚拟地址,也叫做线性地址。
2. 进程地址空间分布
上面32为系统下虚拟地址分布情况中,先是由低地址往高地址增长,总共是4GB的内存。此外还有我们熟悉的代码段,栈区,堆区。我们可以写一段代码来验证上面区域的分布情况。
#include <stdio.h>
#include <stdlib.h>
int unval;
int gval = 100;
int main()
{
int a,b,c;
printf("stack addr: %p\n", &a);
printf("stack addr: %p\n", &b);
printf("stack addr: %p\n", &c);
int *heap = (int*)malloc(10*sizeof(int));
printf("heap addr: %p\n", heap);
printf("unval addr: %p\n", &unval);
printf("gval addr: %p\n", &gval);
printf("code addr: %p\n", main);
return 0;
}
运行结果如下,栈区地址最高,其次是堆区,并且栈区和堆区地址相差较大,再到数据区,最后是代码段。
3. 描述进程地址空间
那么我们该如何理解进程地址空间呢?我们通过一个的例子来说明这一概念。
假设一位公司老板希望评估其手下四位员工的能力,于是决定启动一个项目,并指示这四人分别组建团队来实施。尽管项目的实际启动资金仅为十万元,但老板对每位员工都声称项目拥有十万元的启动资金,任由他们自由支配。这样一来,老板实际上在概念上“创造”了四十万元的虚拟资金池,这种做法俗称为“画饼”。
在这个比喻中,老板的角色相当于操作系统,而他手下的四位员工则代表进程。那十万元的实际项目资金,可以看作是物理内存;而老板向每位员工承诺的十万元“虚拟”资金,则相当于每个进程的地址空间。
既然每个进程都拥有自己独立的地址空间,那么就需要对其进行有效的管理。这里,我们遵循“先描述,再组织”的原则。首先,将进程地址空间封装成一个结构体,该结构体内部还包含一些用于连接和管理的字段。这样一来,对进程地址空间的管理,就转化为了对这个特定数据结构的增删查改的操作。
在进程地址空间的管理中,每个区域(如代码区、数据区、栈区、堆区等)都需要被精确地划分和界定。这可以通过在结构体中使用变量来记录每个区域的起始地址和结束地址来实现。这些变量定义了每个区域在进程地址空间中的位置和范围,从而确保了进程在运行过程中能够正确地访问和管理其内存资源。
接下来,让我们通过小明和小李的例子来进一步说明这一概念:
小明和小李作为同桌,他们共同使用了两张拼在一起的桌子,但为了避免相互干扰,他们设定了一条“三八线”。小明将桌子从1到100进行刻度划分,并规定了自己的活动范围为[1,50],而小李的活动范围为[51,100]。这样,一旦小李的活动超出了他规定的范围(即超过了51这条线),就被视为越界。
在32位操作系统中,进程地址空间也是从某个起始地址(通常是全0地址)到某个结束地址(如0xffffffff)进行排列的。与小明和小李的桌子划分类似,进程地址空间中的每个区域也可以通过两个变量(如起始地址和结束地址)来界定。例如,对于栈区,我们可以设定一个变量start来表示其起始地址,另一个变量end来表示其结束地址,那么栈区的空间范围就可以表示为[start, end]。
根据上面的讲解,我们大概了解进程地址空间。其中进程地址空间中有个地址,每个地址占一个字节的空间,计算它们的乘积就相当于4GB的空间。并且每个地址就是一个无符号的整数,从最低位开始编址,刚好是全0地址到全f地址。
其中Linux的进程pcb是task_struct结构体,里面包含了一个指针变量,类型是名为mm_struct的结构体。该结构体就是描述进程地址空间的类,它的属性里就有对所有区域划分的start和end变量。因为地址是个无符号整数,所以使用的是unsigned long类型。
4. 内存管理——页表
前文讲解的都是虚拟地址,如果myproc可执行程序加载到物理内存中,它的代码和数据都存储在物理内存中,怎么做到从虚拟地址到物理地址的转换呢?
假设myproc进程中有个初始化的全局整型变量gval,物理地址为0x1234。它对应在进程地址空间的初始化数据区,也会有虚拟地址0x1010。操作系统会维护一个页表,对虚拟地址和物理地址建立映射关系。
因此,如果要修改gval变量,操作系统会通过页表,查找虚拟地址对应的物理地址,再进行写入操作。虚拟地址空间加上页表就是虚拟内存的管理方案。
5. 父子进程的虚拟地址关系
当myproc
进程通过fork
函数创建一个子进程时,操作系统会为该子进程分配一个新的task_struct
结构体对象。此时,子进程的虚拟地址空间属性会复制自父进程,包括页表。由于父子进程的代码区都映射到物理内存的同一块空间,因此可以认为它们的代码是共享的。
在父子进程都没有修改全局变量gval的值时,它们的虚拟地址空间中的数据区也会指向物理内存的同一块空间。然而,一旦子进程尝试修改gval的值,操作系统就会采用写时拷贝(Copy on Write)技术。这一技术会为子进程分配一块新的物理内存空间,并修改子进程的页表,以重新建立虚拟地址与物理地址之间的映射关系。这样,子进程就可以在其独立的内存空间中修改gval
的值,而不会影响到父进程。
这也就是为什么fork函数看起来能返回两个返回值的原因,因为父子进程看似有相同的虚拟地址空间,实际上映射到物理内存上的空间是不同的。
通过了解进程地址空间,我们需要重新理解进程和进程的独立性。
- 进程 = 内核数据结构(task_struct/mm_struct/页表) + 代码和数据
父进程创建子进程时,虽然子进程的内核数据结构一些属性会拷贝自父进程,但是内核数据结构各自一份。代码虽然在物理内存上共享,但是代码时只读的,也相当于各自一份。数据在父子任意进程中发生修改,会发生写时拷贝,也会各自私有一份。至于其他没有联系的进程,更不用说,所有的东西都是各自私有一份。因此,进程与进程之间是具有独立性的
6. 页表标记位
6.1 读写权限
页表除了会建立虚拟地址和物理地址的映射关系,还会存储一些标记位。因为一个字节有八个比特位,每个比特位可以写入0和1,表示两种不同的状态。这样一个字节就可以表示八种不同权限的有无。
其中就有几个比特位确定能否进行读写操作。我们以前谈程序崩溃,就是进程崩溃,进程又跟操作系统相关。假如我们写char *buff = “helloworld”,字符“helloworld”保存在代码区里面,而代码区是只读的,当我们写下*buff = “byte”代码时,编译器是检查不出来的,只有操作系统查看页表,发现你准备对只读代码区进行写操作,操作系统才会阻拦你,杀掉进程。
这就是为什么C语言会有const关键字的原因。因为编译器和操作系统耦合度不能过高,所以编译器无法获取进程页表信息,只能通过const关键字知道该变量无法修改,进而报错。
6.2 命中权限
页表中还有一个标记位,isexist表示该虚拟地址上的数据是否在物理内存中。因为有些可执行程序十分庞大,可能有几GB,操作系统不会一次性加载进来,会分批记载进来。或者是某些进程正在等待硬件资源的响应,进程占用内存空间但不被CPU调度,操作系统会将该进程占用的物理空间换出去,给其他进程使用。这些操作都会用到该标记位,该标记就可以支撑分批加载和进程挂起等待的操作。
其实代码在编译的过程中,就会按照代码区,初始化数据区和为初始化数据区的格式编码,形成elf格式的可执行程序。当可执行程序导入内存中,操作系统才会动态创建栈区、共享区和堆区。
我们通过malloc或者new函数申请堆区空间,实际上先向虚拟地址空间上申请一段空间,等要使用时,操作系统发现你的isexist标记位为0,会在物理内存上开辟一段空间给你使用。
7.为什么存在进程地址空间
- 虚拟地址空间加上页表管理模式是为了保护内存。
比如我们常说的野指针问题,在语言层拿到的地址都是虚拟地址,通过页表转换指向其他位置的内存空间,而野指针崩溃是因为转换过程中的权限问题,或者根本不存该映射关系。
- 进程管理和内存管理在系统层面上可以解耦合。
因为进程pcb和物理内存通过虚拟地址空间和页表隔开,物理内存根本不用关心进程申请的空间要做什么,而进程通过虚拟地址空间认为自己可以支配整个物理内存,他在虚拟地址开辟的空间,只有进程被调度执行时,才会在物理空间上申请。这就做到了进程管理和内存管理在系统层面上解耦了。
- 让进程以统一的视觉看待物理内存。
如果没有虚拟地址,进程直接在物理内存上开辟空间,可能会有许多不连续的内存空间,产生内存碎片,造成内存空间的浪费。当虚拟地址空间和页表出现,进程认为自己可以支配整个物理内存,可以为许多区域进行扩展,再通过页表都映射到同一块连续的内存空间,这样就不会出现内存空间的浪费。
创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!