问题引入:fork函数使用时,给子进程返回0,给父进程返回子进程的PID,那么一个变量为什么可以同时等于0又同时>0
#include "stdio.h"
#include "unistd.h"
#include <sys/types.h>
int main()
{
printf("begin:我是一个进程,pid:%d,ppid:%d\n",getpid(),getppid());
pid_t id = fork();
if(id == 0)
{
// 子进程
while(1)
{
printf("我是子进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else if(id > 0)
{
// 父进程
while(1)
{
printf("我是父进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
// error
}
return 0;
}
如果对两个id取地址,我们会发现两个id的地址居然是相同的,也就是说,同一个地址竟然读到了不同的内容!! 如果变量的地址是一个物理地址,是绝对不可能出现这种情况的,因此我们的变量地址必然不是物理地址!
所以,我们平时C/C++里面使用的地址全都不是物理地址,而是虚拟地址! 用户是看不到物理地址的,而OS必须要负责将我们所看到的虚拟地址转化成物理地址
其实我们的之前所学的线性地址,并不是真正的物理内存,而是在PCB内部有一个指针,指向了一块进程地址空间,然后虚拟地址会通过页表来映射到具体的物理地址。 所以当我们创建出一个子进程后,他会拷贝一份和父进程一样的地址空间,然后当子进程想要修改对应的数据时,此时就会发生写时拷贝(由操作系统自动完成),也就是重新开辟空间,在这个过程当中只有页表对应的物理地址发生了变化,左边的地址空间不会有任何的感知。
1 什么叫做地址空间
在32位的机器中,有32位的地址和数据总线,所以每一根地址总线有0或1,其实从本质上来说计算机能够识别是高低电频而并非二进制,其中1代表的是高电频,0代表的是低电频。这个过程就是CPU通过,像内存充电的形式告诉内存我需要哪个地址,然后内存就能够通过识别高低电频,形成一个物理数据,将地址对应的数据以同样的方式交给CPU。
所以地址空间就是地址总线排列组合形成的地址的范围【0,2^32】
1.1 如何理解地址空间的区域划分?
地址空间的区域划分是操作系统将进程可用的内存(虚拟或物理)划分为多个功能独立的区块(如代码段、数据段、堆、栈等),每个区域具有特定的访问权限(如可读、可写、可执行)和用途(如存放指令、全局变量、动态分配内存等),以实现内存隔离、安全保护和高效管理。
区域划分就是通过结构体内部的start和end去做划分,如何让区域变大或变小了? —> 修改对应结构体内部的start和end即可。
1.2 什么是进程地址空间
进程地址空间是操作系统为每个进程分配的独立虚拟内存范围,它让进程认为自己独占整个内存,实际通过MMU和页表映射到物理内存或磁盘。地址空间被划分为代码段(只读)、数据段(全局变量)、堆(动态内存)、栈(函数调用)等区域,各区域有不同权限,以实现内存隔离、安全保护和高效利用,确保进程间互不干扰且能共享物理资源。
所谓进程地址空间,本质上就是一个描述进程可视化范围的地址空间内存在各种区域划分,对线性地址进行start、end即可 。本质上其实就是一个内核数据结构,和PCB一样,地址空间也是需要被操作系统管理的:先描述再组织。 而每一个进程都有自己的进程地址空间,PCB内部有一个指针指向这块空间。
2 页表
共识:现代操作系统中,几乎不做浪费空间和时间的事情。
2.1 写时拷贝、缺页中断、惰性加载
页表具体有哪些内容呢?
- 虚拟地址、物理地址、读写权限、标志位(对应的代码和数据是否被加载到内存中)
读写权限就可以帮助我们做检查,比方说当前是常量字符区但是你却想修改,就会被操作系统拦截,该非法请求就不会被发送到物理内存。
标志位就是帮助们判断进程的代码和数据是否被加载到内存中,因为我们知道我们的进程对应的代码和数据是有可能处于挂起状态的(还没加载到内存)
惰性加载:其实就是需要多少就加载多少。操作系统对大文件是可以实现分批加载,也就是说当前的进程可能只有PCB在内存中,但是代码和数据可能还没马上加载进来。
缺页中断:在执行进程的时候如果发现标记位显示当前代码和数据没有加载起来,就会发生缺页中断,也就是暂时中断这个进程,然后等代码和数据加载进来之后,再恢复原来的状态继续运行。
问题:一次加载进去不是更快吗,为什么需要检测了之后才通过缺页中断加载进去?
- 一方面是因为可能这个文件特别大,所以没办法一次加载进去,就算是可以一次加载进去,可是我们在使用的时候,不也是一点点去用嘛, 所以缺页中断解决的是,初步局部性加载的问题,能够更合理的去使用内存!
写时拷贝:数据区的数据是按道理是可写的,但是一开始权限会被设置成只读(意思就是当前父子进程共享),一旦父子进程任意一方尝试做修改的时候,发现当前的数据是只读的(但是这里不做异常处理,而是转而发生写时拷贝),就会开辟一块新的物理内存,修改页表的映射。
2.2 进程地址空间是如何切换的
进程PCB结构体里有对应的进程地址空间指针,所以进程切换就是进程空间的地址被切换,而页表会被存储在CPU的cr3寄存器中,这属于进程的上下文信息,在进程切换的时候会被进程带走,后面再恢复过来。
2.3 进程创建的具体过程分析
进程被创建的时候,优先加载的是PCB结构体以及里面对应的进程地址空间结构体,它的代码和数据可能不会马上被加载进来。
2.4 再次理解进程具有独立性
- 在内核数据结构上是独立的
- 物理内存中加载的代码和数据,只需要再页表上去体现。虚拟地址可以一样,但是通过页表映射不同的物理地址,就可以让父子进程解耦
- 通过页表的虚拟地址映射物理地址,物理地址可以随便取地址,甚至是乱序。但是虚拟地址可以将一个线性的地址呈现给进程
3 为什么要有进程地址空间
首先对进程进行一个总结:
- 进程 = 内核数据结构(task_struct && mm_struct && 页表) + 程序的代码和数据
抄的一个故事1:
一个大富翁(操作系统)有10亿美金,而他有四个私生子,但是四个私生子(进程)都并不知道对方的存在,所以他们都认为大富翁只有他唯一一个儿子,而大富翁告诉他们一旦自己去世了,就把所有的家产留给他,所以每个儿子也都信了,所以大富翁其实给每个私生子都画了一个大饼(进程地址空间)。每个人都认为自己有十亿家产。 但实际上是这些私生子要多少才会给多少(进程需要多少空间操作系统就给多少空间)
结论1:让进程以统一的视角看待内存,这样进程就不需要关心,我具体应该放在物理内存的什么位置,也不需要关心当前这个物理内存是否会影响别人的数据,这些工作都由操作系统去完成。
抄的一个故事2:
你过年的时候经常有压岁钱,但是你还小所以你经常会买到一些没有用的东西,于是你的妈妈就让你把钱交给他保管,等你需要买什么的时候,他再把钱给你,比如说当你想要买个一块钱的橡皮时,你妈妈就给了你一块钱,但如果你想花100块钱买个游戏机的时候,你的妈妈就不给你买,所以这个过程其实妈妈的作用就是会阻止你做一些不太适合的事情。
结论2:增加虚拟地址空间,可以让我们访问的时候增加一个转换的过程,在这个转化的过程中,可以对我们的寻址进行审查,一旦异常访问,直接拦截,该请求就不会到达物理内存,从而保护物理内存。
结论3:因为有地址空间和页表的存在,将进程管理模块和内存模块进行解耦合
结论4:其实变量名在定义的时候就已经被转化成一个个虚拟地址了,而我们之所以有a和&a,本质上是为了区分想获取的是变量的值还是地址。
结论5:以前我们所学习的C内存管理,其实本质上是进程地址空间,而内存管理是由Linux替我们完成的,我们上层语言并不需要关心具体的细节,只需要正常去通过对应的线性地址去使用就行了。
4命令行参数和环境变量在栈的上面
环境变量和命令行参数是在栈之上的一个独立空间。 所以为什么子进程可以继承父进程的环境变量,因为子进程启动时,父进程已经把对应的环境变量信息加载进去了, 他也是地址空间的一部分,所以他必然有页表去帮助我们建立虚拟地址和物理地址的映射,而当子进程创建的时候,子进程也会将父进程虚拟地址空间当中的环境变量的相关参数也给我们建立了一份映射,所以即使你传参数,子进程也照样能够获得父进程的环境变量信息。
7072

被折叠的 条评论
为什么被折叠?



