本篇文章,继续和大家分享与Linux相关的知识。本篇文章的内容主要会涉及进程地址空间。
vim批量替换
如果我们想把文本中的某个内容,替换成另一个内容,可以使用vim底行模式,我们这里将proc改成myproc。
输入完指令,回车即可替换成功。
遗留问题
前面的文章我们,遗留下了一个核心问题。为什么变量id会存在两个不同的值?这和进程地址空间的息息相关。
pid_t id = fork();
if(id==0)
else if(id>0)
我们之前学习C语言的时候,可能都会见过程序地址空间图,最下方的是代码区,依次往上是字符常量区,全局数据区,堆区,栈区。堆区向上增长,栈区向下增长。我们认为最下方是低地址,也就是全零,最上方为高地址,也就是全f,地址大小由下向上增长。在32位的计算机中,地址空间的大小为4GB。
我们可以简单的写一个程序来验证地址的分布,是否和图中的分布一样。
#include<stdio.h>
#include<stdlib.h>
int g_val_1;
int g_val_2 = 100;
int main(){
//函数名相当与函数的地址
printf("code addr: %p\n",main);
//等号右边会将字符串常量hello Linux的首元素地址返回
const char* str = "hello Linux";
printf("read only string addr: %p\n", str);
printf("init global value addr: %p\n", &g_val_2);
printf("uninit global value addr: %p\n", &g_val_1);
//malloc会向堆申请空间
char *mem = (char*)malloc(100);
printf("heap addr: %p\n", mem);
//main是一个函数,函数内部创建的变量开辟在栈上
printf("stack addr: %p\n", &str);
return 0;
}
从程序的运行结果看,地址空间的分布和我们的画基本是一致的
除了验证,地址空间的分布情况外,我们还可以验证一下,栈和堆的增长方向
从程序运行结果看,堆地址是越使用越大的,这反映出它是向上增长的。而栈地址是越使用越小的,这反映出栈是向下增长的。
大家还记不记得一个语法,static修饰的变量,不会随着函数的销毁而销毁。我们试着把变量b用static来修饰,然后,打印它的地址
我们会发现它已经被放置到了全局数据区域。此时,变量b相当于全局数据,自然不会随着函数的销毁而销毁。
同样的,我们使用打印地址方式,来打印变量id的地址
程序在运行三秒后,子进程修改了g_val的值,子进程获取的结果也确实修改了,可为啥它们打印的地址是相同的。
我们从运行结果中,可以得到一个结论,变量使用的地址绝对不是物理地址,如果是物理地址,不可能存在上面的现象。在我们平时,用C/C++写的指针,指针里面使用的地址,都是虚拟地址,也可以称之为线性地址。
一个运行在CPU上的进程,除了有相应的PCB之外,操作系统还会为它创建一个地址空间,这个地址空间,我们称之为进程地址空间,之前程序地址空间哪种叫法是不对的。别看它叫进程地址空间,其实也就是一个结构体,在Linux中叫mm_struct。
除此之外,操作系统还会为程序创建一个页表,这个页表中会有两列内容,进程地址空间里使用的是虚拟地址(线性地址),这些地址会填入左边,右边会填入物理地址,以此建立一个映射关系。
调用fork创建子进程,操作系统除了为子进程创建相应的PCB,进程地址空空间和页表外,还会把相应的数据信息从父进程的结构里拷贝到子进程相应的结构里。这样一来父子进程拥有了相同的页表和进程地址空间。页表相同,就会映射到同一块物理空间,这也就做到了父子进程能够共享一段代码的能力。当子进程,对某一个数据做修改时,操作系统会自动发生写实拷贝,然后,用新的物理地址覆盖掉子进程的页表中旧的物理地址。在这个过程中,虚拟地址是0感知的。这就解释了为什么,我们刚刚的程序运行结果,为什么,同父子进程访问同一个变量,打印的值不同,地址却相同。
说到这,我们理解了,为什么fork之后,id存在两个值,以及它是怎么做到的。下面,我们来谈谈细节,什么是地址空间。
地址空间
在32位计算机中,有32位的地址和总线,这些总线可以分为地址总线,数据总线和控制总线三类。计算机的内存中会有一个地址寄存器,用来识别存放地址。它是如何识别地址的呢?与内存相连的线,有32根,每根总线,没电表示0,有点表示1。一位二进制数在计算机中占1byte,2的32次方乘以1个比特位,也就是4GB。哪什么是地址空间?地址空间可以理解为,你的总线排列组合形成的地址范围[0,2^32)。
如何理解地址空间上得区域划分?
区域划分
给大家讲个小故事,小胖和小花是同桌。小胖,平时不爱整理桌面,它的位置很乱,还摆放了各种垃圾。他的同桌小花,喜欢干净整洁桌面。于是,小花就和小胖说:小胖呀!为了公平起见,我们这个100cm的桌子,一人50cm。小胖,还没来得及支声,一条分界线已经画好了。[1,50]这个区域放小胖东西,[51,100]这个区域放小花的东西,这不就是区域划分吗?
今天,小胖整理了一下桌面,把铅笔放在10cm处,橡皮放在12厘米处,书本放在30cm处。自此,每一个物品,都有了自己的位置,在第几厘米,这不就是地址吗?小胖可以根据相应的地址,找到相应的物品。对于小胖的这50cm,我们不仅仅要看到这是给小胖划分的范围,也应该看到在范围内,每个最小单位都可以有地址,这个地址可以被小胖使用。
哪在Linux中是实现区域划分呢?其实很简单,就是一个个的包含start和end的结构体,比如
//第一种方式
//确定了地址的起始和结束位置,就确定了可使用的地址范围
struct area{
int start;
int end;
};
//我们以小胖小花的桌子为例
struct destop_area //约定范围为100cm
{
struct area xiaopang;
struct area xiaohua;
};
//定义初始化,就完成了对这张桌子的区域划分
struct destop_area line_area = {{1,50}{51,100}};
//我们也可以直接表示成下面这种方式
struct destop_area{
int start_xiaopang;
int end_xiaopang;
int start_xiaohua;
int end_xiaohua;
};
哪什么是区域调整呢?小胖老是越过分界线,占用小花的区域,于是,小花就把之前的分界线一擦,往左移了10cm。自此,小胖的区域变成了[1,40]
//在代码上的操作就是修改start和end
line_area.xiaopang.end-=10;
line_area.xiaohua.start-=10;
我们一直,在谈地址空间,哪什么是进程地址空间呢?
进程地址空间
什么是进程地址空间
所谓进程地址空间,本质是一个描述进程可视范围大小的数据结构体,它的里面一定会存在区域划分,有类似start和end这样的变量确定不同区域的不同范围。在Linux中,描述进程地址空间的数据结构对象,叫mm_struct。它类似于PCB,也要被操作系统进行管理。哪如何进行管理呢?当然是先组织,再描述。
PCB和mm_struct之间是怎么联系的呢?在进程的PCB中,会有一个mm_stuct指针,指向他自己所对应的进程地址空间mm_struct,这个mm_struct里面有什么内容呢?我们可以结合小胖小花的例子,简单的猜想一下:
struct mm_struct{
//有代码区
long long code_start;
long long code_end;
//有只读字符常量区
long readonly_start;
long readonly_end;
//有初始化和未初始化全局数据区
long init_start,
long init_end;
long uninit_start;
long uninit_end;
//栈区和堆区
long stack_start,stack_end;
long heap_start,heap_end;
};
我们的猜想到底是不是正确的呢?我们到Linux的源码中看看。在源码中,我们可以看到start_code,end_code等类型,这验证了我们的猜想
之前,我说,进程=内核数据结构PCB+程序的代码和数据。现在,我们需要纠正一下,进程的内核数据结构不只是PCB,还有mm_struct,页表等等。
为什么要有进程地址空间
为什么要有进程地址空间?在回答这个文体之前,我们得再谈谈页表。来看看下面这段代码。我们对字符常量区进行修改。
编译运行,程序报错,这是意料之中的。可一开始物理内存里什么也没有,你把内容写进去。这不是写操作吗?没什么到我这就变成了只能读不能写了?这是怎么做到的。
这和页表有关,在页表中,有一列关于读写权限的内容,每当你要对物理内存进行读写,操作系统会先判断你有无读写权限,再进行操作。
之前,我们在讲进程状态的是后,说有什么创建态,就绪态,阻塞态,挂起态等等。大部分状态我们,都可以在Linux中找到,比如说创建态和就绪态,说白了就是运行态的一部分,用r表示。阻塞态,相当于浅度睡眠,用S表示。可挂起态,好像没有对应的状态字符表示,哪操作系统怎么知道进程挂起了?这也和页表有关,在页表中,有一行标识符,表示该部分内容是否在内存当中。如果这个进程挂起了,哪这个位置就标志为没有。当CPU访问这部分内容时,发现这部分内容不在内容中,就会引发缺页中断,把这部分内容加载到内存中。此时,我们也就理解了,进程的独立性,是怎么做到的。父子进程具有同一份页表,实现了代码共享,数据共享。当子进程修改某一个数据时,操作系统根据页表信息,发现父子进程共享这份数据,于是,引发缺页中断,进行写实拷贝,实现了父子进程的数据独立。父子进程的数据相互独立,在运行的过程中,就不会相互影响,也就实现了进程之间的相互独立,也就就是进程的独立性。
我们平时玩的游戏有的大到几十个GB,而内存只有小小的8个GB。是如何做到,把这么大的游戏放到手机上运行的?这么大的游戏,内存肯定装不下,况且,还要同时加载多个程序的数据。当代操作系统,几乎不会做任何浪费资源的事,它们都采用惰性加载的方式,加载代码和数据。当我们在运行一个程序的时候,只会使用程序中的一部分代码和数据,这部分代码和数据是需要加载到内存中的,其他部分,我们只需要在页表中标记没有即可,待需要的时候,再引发缺页中断,加载到内存中即可。如此一来,就可以实现,运行几十个G的运行了。
有一点需要注意:页表使用的是物理地址。在CPU中,有一个寄存器叫cr3,它用来存储当前运行进程的页表地址。
从上面我们的几个例子中,我们不难发现,每当进程访问内存,就需要进行地址的转换过程,在这个转换的过程,可以对我们进行寻址请求进行审查。所以,一旦发现异常访问,直接拦截,该请求不会到达物理内存,保护了内存。这是要有进程地址空间的原因之一。除此之外,还有两个原因。物理空间,我们可能是分散的开辟的,这里一块哪里一块,也就是物理地址不连续。可这和我虚拟地址有什么关系,我可以是连续的,至于不同的虚拟地址对应那个物理地址,那是页表的事。此时,进程看到的是虚拟地址,它们都会以为内存是有序的,就会以统一的视角看待内存。这是其一,其二。因为有了进程地址空间和页表的存在,进程和内存之间变得非常陌生,进程只知道它访问了内存,根本不知道内存那边还搞了个什么缺页中断,不知道也不关心。这就实现了进程管理模块和内存管理模块的解耦。图中页表左边的管理,我们称之为进程管理,右边的管理称之为内存管理。
最后,再给大家讲个小故事,让大家加深对进程地址空间的理解。有一个老美,他是大富翁,他分别告诉他的四个私生子,说他有十亿资产,未来将会由他来继承他的财产。一天,私生子1,打电话说,老爸给我要买一辆跑车,你给我打点钱过来。没一会,1000万就到账了。私生子2,没钱吃饭了。打电话给大富翁,说:老爸我现在连饭都吃不起,赶紧让我继承你的家产吧!富翁一听,要10个亿,直接电话一挂。私生子3,没钱花了,打电话给富翁,说:老爸,我没钱花了,给打10万。富翁说:行,直接把10万打了过去。私生子4,想去旅游。于是,打电话给富翁,也如愿得到了钱。这四个私生子都不知道彼此的存在,有的要求得到了满足,有的没有得到满足。但他们都坚信自己未来会继承10亿的资产。
这里大富翁,其实就是操作系统,私生子就是一个个的进程。大富翁给每个人画了一个大饼,而这个大饼就是进程地址空间。
此时,我们再来看下面这副图就不在陌生了。在栈之上,其实还存放着别的东西,比如说我们熟知的环境变量。环境变量上面的1GB,我们称之为内核空间。往下,包含了环境变量的3GB,我们称之为用户空间。栈和堆之间漏空的部分,是共享区,这个我们后面会分享。
好了,到这里,我们本次的分享就到此结束了,不知道我有没有说明白,给予你一点点收获。关于更多和Linux相关的知识,会在后面的文章更新。如果你有所收获,别忘了给我点个赞,这是对我最好的回馈,当然你也可以在评论发表一下你的收获和心得,亦或者指出我的不足之处。如果喜欢我的分享,别忘了给我点关注噢。