1 #include
2 #include
3 int g_val = 200;
4 int main()
5 {
6 printf("begin");
7 int id = fork();
8 if(id == 0)
9 {
10 g_val = 100;
11 printf("我是子进程,pid: %d, 父进程:%d, g_val:%d, g_val地址:%p\n", getpid(),
getppid(), g_val, &g_val);
12 }
13 else
14 {
15 printf("我是父进程, pid:%d, g_val:%d, g_val地
址:%p\n",getpid(),g_val,&g_val);
16 }
17 return 0;
18 }
19
核心问题,int id = fork(), id如何即等于0,又大于零,你说发生了写实拷贝,那为什么printf的值不同,地址却相同。
写时拷贝后地址一样,说明肯定不是物理地址,而是虚拟地址,好吧,虚拟就虚拟喽,那怎么对应真正的物理内存呢?用页表,就像一个表格,左边写着你打印出来的变量的地址,右边写着真正的物理内存地址,cpu只要通过页表就能找到物理内存,所以打印id的地址没变,是因为这是虚拟地址,没必要改,没有意义,操作系统不会做无意义的事,影响效率,只要改父子进程对应的物理地址就行了,所以打印的值不一样,这就是上述代码大致原理。后面了解了页表的结构,理解会更深刻。
但是还有些问题,进程地址空间是什么来的,为什么要弄虚拟地址,后面会一步步解析。
c语言的程序地址分布,我曾以为下面这个图就是真正的内存,所有的程序都很聪明而且守规矩的往指定地方放数据,例如在代码区放代码,在全局数据区放全局变量。
当我们接触了进程地址空间,我才意识到原来这个不是内存,而是叫进程地址空间,那它是什么呢?什么是地址空间呢?
由于进程要使用内存,就必然在cpu的运行队列上,那cpu就要去根据地址总线告诉内存要访问的地址,32位机器下cpu和内存相连的线是32根,每根线发射高电频和低电频表示1和0,所有的寻址组合范围都在[0,2^32]内,这个范围的集合就叫地址空间,类似样本空间的概念,进程地址空间就是进程能看到的所有内存地址的范围就叫进程地址空间,那是如何实现的呢?先看看其功能,1 管理了所有的内存的地址,这个地址的范围是[0,2^32],2 对内存进行分区域管理,我们从这两个功能来猜猜进程地址空间的实现。
例如一个数组,划分为两个区域,我们希望存数据的时候AB两人最多用一半,那如何保证呢,用下标,让A只能在0到49处存数据,B只能在50,99处存数据,代码上用下标就保证了AB两人各自使用各自的,不互相干扰。而一段空间要分区域管理,实际上用的就是指针,例如stack_begin和stack_end这不就能维护栈的大小了吗,heap_begin和heap_end不就能维护堆了吗(维护区域范围的意义后提)。
所以操作系统要分区域管理内存,那就要先描述,再组织,也就是用区域指针分开描述各个区域,系统还增加了大小这类参数,防止不同的区域生长的时候碰到一起了,这个不用我们操心。
这个时候我们就可以还原进程地址空间的真面目。
这些begin和end的范围是编译时决定的,因为每个进程的代码大小不同,所以初始的时候,代码段范围不同,而且每一个进程都要有一个进程地址空间,因为独立性,总不能一个进程的栈的范围变了,大家一起变吧,那很容易出现越界误判和数据丢失,所以不能共用一个进程地址空间这个数据结构。不同进程的页表也是独立的,因为进程是根据进程地址空间的begin和end来确定页表的查找范围,所以页表必须是只能用于自己的进程,父子进程的页表是独立的,但我还未看过页表结构,现在将页表理解为表格够用了。
好吧,你先前说的原理实现我都勉强理解,那为什么通过进程地址空间弄出虚拟地址,然后页表转为物理内存地址,有点像中介一样,我不能直接和内存交互吗?当然不能,进程是不可以直接访问硬件的。内存保存数据实际上是通过电,所以对内存充放电就可以保存和删除数据,内存怎么拦得住你对它充电放电。所以必须我们在软件层面上做拦截。
所以为了做拦截,增加虚拟地址到物理地址转换,举个例子,当cpu找到页表,通过虚拟地址找物理内存时,操作系统发现你要往代码段写数据,但是这个物理内存是只读的(通过页表的标记判断是只读的),操作系统就可以禁止你的非法行为,那为什么一开始操作系统又可以往代码段写数据,我通过虚拟地址找物理内存写数据又不行呢,我觉得就是因为此时页表标记是只读的,通过这虚拟地址寻址就被拦截了,但是操作系统就可以,我就简单理解为操作系统加载代码不是用的虚拟地址这一套。
老实说,意义2是我不太理解的。毕竟写的代码不多,我联想到平时写代码的时候,有时候找错误很麻烦,可是有些时候找错误却很轻松,例如我实现了两个函数,fun1和fun2,我需要这两个函数的返回值做下一步运算,现在结果不对,我只要看fun1和fun2的返回值符不符合,我就知道哪里出错了,这一下子就缩小了范围,现在进程管理只需要和页表说我要存数据了,快开物理内存,内存管理就去找了,如果出错了,我们就可以判断大致是哪个模块的问题,修改也只要改这个模块的代码,进程管理则不需要动,我想这就是虚拟地址的意义,让两处代码互不影响,又相互配合。
先前说每个进程都有一个进程地址空间,都管理着4个g的内存,也就是说每个进程都认为自己有4个g的内存,你要多少操作系统就申请给你,不够就不给,操作系统不用管这个这个进程的虚拟地址,是不是和其它的进程重复,即便大家的虚拟地址相同,但是有页表,就可以保证映射的物理内存不相同,所有进程都以一个统一视角看待内存,认为这个内存是分区域的,查找数据就可以去特定区域找,而如果没有虚拟地址,直接上物理地址,那操作系统就得保证各个进程的地址不能重复了,维护起来非常麻烦。
噢,页表拦截非法访问,还让操作系统不用担心虚拟地址是否重复,那进程地址空间好像没啥用,其实进程进程地址空间的作用就是给页表打辅助。你以为虚拟地址是随便写在页表上的吗,是要有根据,有顺序的。
如果在页表左侧的地址也是乱序的,那对于进程来说要找一个虚拟地址就要遍历整个页表,而且当堆增大减小的时候,还要删除对应的虚拟地址,这多难找啊,所以页表左侧是有序的非常必要,而不仅要有序还得要知道各个区域的范围,这就是维护进程地址空间的意义,例如有数据要存到堆上,那就在进程地址空间的heap_begin和heap_end范围内选一个虚拟地址写到页表上,然后随便找个申请一个内存出来把地址填到页表上形成映射关系,heap_begin和heap_end随着我们申请资源和释放资源调整范围,左边有序有利于高效完成虚拟地址的查找和删除,快速判断是否是非法访问,右边无序,方便操作系统快速申请,这里我觉得这里设计太巧妙了,我先记录下来,以后再回味回味。
前面说的我大致了解了,但是还有些问题,前面提过挂起状态,linux没有挂起状态,说明无法通过进程状态判断代码和数据在不在内存,那进程是如何知道代码和数据不在内存的呢?还是页表的标记位,例如0表示不在,1表示在,所以当进程要读数据的时候发现数据不在内存,触发缺页中断,操作系统就会让进程等一等,马上加载。
实际上操作系统对大文件,软件进行惰性加载的方式,例如500mb,只会先加载5mb,因为短期内cpu也跑不完500mb的代码,没必要一次性全加载进来,也是为了不让大软件过多占用内存,这个场景下缺页中断可以大大提高内存的使用率。
还有就是前面父子进程数据共享,当父进程要修改的时候,也会触发缺页中断,因为操作系统发现这个是可修改的变量,却设为进程只读,就知道有其它进程共享,所以就在内存又开了空间保存新数据,并且替换了页表右侧的物理地址。
先前说进程是pcb+代码和数据,pcb对象最重要,现在对于什么是进程,更完善的回答是:pcb+进程地址空间+页表+代码和数据,代码和数据仍旧是最不重要的。
还有个小细节,cpu上存的是页表的什么地址,如果cpu存的也是页表的虚拟地址,那就要再存一个页表地址,能将页表虚拟地址转为物理地址,那此时这个页表地址又是虚拟地址呢?这就套娃了,所以cpu上寄存器存的的必须是页表的物理地址,从这也可以看出cpu拿到了物理地址,就可以直接访问内存,说明内存硬件根本无法拦截,必须通过软件层,既然是存在寄存器上的,而cpu的寄存器存的数据都属于进程的上下文数据,所以进程切换的时候pcb带走了上下文数据,切换了pcb数据结构,上下文数据没了,页表也切换了,和pcb对应的进程地址空间也自动切换了,这就是目前我对进程切换做的事情的理解。
首先PCB是独立的,然后进程地址空间是独立的,页表也是独立的,内存管理保证申请的内存不重复,这样进程代码和数据存在不同的地方,申请和释放也就不会彼此影响了。
往后随着学习的深入,一个进程会有越来越多的数据结构,但是主要框架基本就这些了。