在正式介绍进程地址空间之前,我们需要做一些铺垫,在父子进程同时运行时,从代码层面上的变量的地址,引入进程地址空间的概念,进一步理解之前关于进程的一些现象。
1. 语言层面上的地址
在学习 C/C++ 的时候,伙伴们肯定见过这张图,并且我们熟知,所谓的内存划分为栈区、堆区、全局变量区、常量区、代码区等区域。但是从这篇文章开始,往后讲进程地址空间,将会颠覆之前对 内存空间 或者 地址 的概念的认知!
接着,我们先来写一份代码。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
printf("the value of the child and parent process are the same!\n");
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
// 子进程
while(1)
{
printf("i am child, 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("the child process begin changing g_val : 100->200\n");
sleep(1);
cnt--;
}
}
}
else
{
// 父进程
while(1)
{
printf("i am parent, pid : %d, ppid : %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
}
站在语言的层面上,出现了一个无法理解的认知,fork() 之后,父子进程同时运行,这没问题,曾经我们也说过,子进程在被创建出来的时候,大部分会以父进程为模板来初始化自己,并且父子进程共享同一份代码,数据上,采用 写时拷贝 的策略。而今天,在代码层面上,我们在子进程的范围内修改 g_val 这个变量,等同于子进程要修改父进程的数据了,所以系统会为子进程重新开辟一块空间,然后作为子进程独立的数据。
所以诡异的事情就出来了。豪嘛,在子进程需要修改父进程的数据时,你不是说系统会重新拿一块空间给子进程使用,然后让子进程在自己的空间上修改。但是我今天看到的好像并不是你说的这样!我看到的,父子进程对这个数据的地址空间是一模一样的!虽然我现在不知道为什么,同样的地址,它的值可以不一样。
没错!怎么可能同一个变量,同时读取的时候,读出了不一样的内容!?这是不可能的事的!
因为,我先抛出结论:如果变量的地址是物理地址,就不可能存在上面的现象,不然这绝对是一件无法理解的事情,所以我们代码层面上,打印出来的地址,绝对不是物理地址!!语言中展示出来的各种地址,我们称为 线性地址 或者 虚拟地址!
2. 引入新概念 ---- 地址空间的概念
初步认知:虚拟地址 与 物理地址 是通过页表这样的数据结构来映射的。
fork() 创建子进程时,子进程也会有独立的 task_struct ,然后基本以父进程为模板(除了一些 pid, ppid,优先级等信息)初始自己的 pcb 内核数据结构。再者,子进程也会从父进程拷贝一份进程地址空间 和 父进程的页表(每一个进程都会有自己的进程地址空间)。所以当子进程刚开始被创建出来,它的虚拟地址和页表内的数据一定是与父进程一样的。
所以在代码层面上打印出来的地址,明明是两个不一样的内容,但是指向的确实同一个地址空间,就是因为子进程的进程地址空间是拷贝父进程的,而能够出现两个不同的内容,是因为当子进程需要修改父进程的数据,操作系统就会先完成子进程的写时拷贝工作,然后修改页面表对应的映射关系(即修改映射关系中的物理地址,页面内的数据都是 键值对 的存在,所以也可以理解为,根据 拷贝自父进程的进程地址空间中的 key 值,修改其 value 值,让其映射到属于子进程独立的数据空间),这样之后,虽然父子进程的虚拟地址都是一个地址,但是经过页面映射到物理内存中,却是两个不一样的地址,因此才会出现两个不一样的内容!
进程概念(三)----- fork 初识 在这篇文章中,虽然我们理解了一个函数为什么会有两个返回值( fork() 之后父子进程共享代码,return 语句执行两次),也理解了为什么一个变量怎么做到两个不一样的内容(因为写时拷贝的问题,所以父子进程中的数据可能是不一样的)。所以现在,我们就能够理解,一个变量拥有两个不一样的内容,虽然变量名字是一样的,但底层是通过页面结构,虚拟地址映射到不同的物理地址,所指向的数据不同来实现的,而这是操作系统为进程做的工作!
3. 进一步理解地址空间
上面我们只是见过了地址空间,但现在我们依旧不理解什么是地址空间,地址空间上的区域划分又该如何理解。
我们知道的是,计算机只认识二进制,并不认识什么代码。但在理解地址空间之前,我们不仅要知道,计算机只认识 0 和 1,这件事是站在软件的层面上讲的;站在硬件层面,只有所谓的 高电平(充电) 和 低电平(放电),最终在软件层面上都会被计算机解读成 1 和 0。
在32位计算机上,有32位的地址和数据总线(cpu 和 内存数据交互的是系统总线,内存和外设的是 IO 总线),而 cpu 在内存中寻址的时候,靠的就是这32位地址线,通过对触发器的充放电,就可以实现对某一处地址空间的定位,然后做数据交互。换言之,我们常说的数据拷贝,无非就是一个设备在对另一个设备充放电的过程。
而地址总线有32位,每一位可以是0 或 1,所以一共可以有 2^32 种组合,内存的大小 = 2 ^32 * 1byte = 4GB!
所以什么是地址空间? ---- 地址总线排列组合形成的地址范围 [0, 2^32-1],就叫做地址空间!
那么现在大家就应该清楚,为什么一台32位的机器,它的内存最多就是4GB,因为它的寻址范围就不允许它有更大的内存空间,它的地址总线最多就只能 [0, 2^32-1] 在这么大的范围内寻址。
而地址空间上的区域划分可以怎么理解?
讲个故事,现有小帅、小美两个小学生,教室的课桌又不是特别的大,大家的空间都非常有限,因此为了公平保证,小帅和小美划分了三八线,一人一半,不得越界!而这种行为的本质就是 区域划分!。一人一半之后,小帅和小美总得对自己的区域进行管理使用,那他们是怎么管理自己的区域的呢?
还是一样,只要是管理,就是 “先描述,再组织” !
struct area struct desktop_area // 约定最大范围100cm
{ {
int start; struct area xiaoshuai;
int end struct area xiaomei;
} }
所以今天小帅和小美想要对自己的区域范围做管理,只需要 struct desktop_area line_area = {{1,50}, {51,100}}
但是有一天,小帅不小心就越界了,还把小美桌上摆放整齐的各种文具弄乱了。于是小美很生气,不仅把小帅揍了一顿,还多占了小帅 10cm 的区域!
而上述这个行为的本质,就是空间区域的调整(变大或者变小),而用计算机语音进行描述,那就是 line_area.xiaoshuai.end -= 10; line_area.xiaomei.start -= 10;
这样,小帅的区域范围就变为 [1, 40] ,小美变成 [41, 100]。
小帅在自己的区域空间内,1 ~ 40cm 处的空间中的任何一厘米,小帅都可以自由使用!小帅可以决定 2-3 cm处用来放铅笔,4-5cm处用来放橡皮擦。所以我们不仅要看到小帅划分的地址空间的范围是多大,还要能看到范围内具体的某个地址!换言之,在连续的空间范围内,每一个最小单位都可以有地址,而这个地址可以被小帅所使用!
因此,所谓的进程地址空间,本质就是一个描述进程可视范围的大小。地址空间内一定要存在各种区域划分,对线性地址(虚拟地址)规定 start 和 end !
对于一个进程,操作系统不仅要为其创建一个 pcb 内核数据结构,同样也要为其创建一个进程地址空间的结构体,所以地址空间本质也是内核的一个数据结构对象,类似 PCB 一样,地址空间也是要被操作系统管理的:先描述,再组织!
struct mm_struct // 32位机器下,默认划分的区域为 4GB
{
unsinged long code_start;
unsinged long code_end;
unsinged long readonly_start;
unsinged long readonly_end;
unsinged long init_start;
unsinged long init_end;
unsinged long uninit_start;
unsinged long uninit_end;
unsinged long heap_start;
unsinged long heap_end;
unsinged long stack_start;
unsinged long stack_end;
......
......
}
这就是我们讲的 进程地址空间 的结构体。对每一个进程来说,都要有一个 PCB,也要有一个进程地址空间,并且在 pcb 内部要有指向进程地址空间的指针。而面对进程地址空间划分的4GB,我们不仅仅要看到这个范围的大小,其内部的各个区域(100 - 2000),我们同样的也要能够细化到具体某一个地址处,换言之,在一段连续空间范围内,每一个最小单位都可以有地址,并且这个地址是可以被使用的!
到现在,我们应该要知道为什么操作系统总能够准确的发现我们代码中的越界。当我们在向某一处地址写入数据时,操作系统都会对该地址进行校验,如果发现这个地址落在常量代码区(这种不可写入的区域),就会对你写入的操作进行拦截!
4. 为什么要有地址空间
我们现在已经对地址空间有了初步认识,但为什么要有地址空间呢!?
由于篇幅过长等问题,关于进程地址空间更多的介绍,请跳转至 进程地址空间(二)。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!