历史核心问题
pid_t id = fork();
if (id == 0)
else if (id > 0)
id 变量中为什么能存在两个值.
地址空间
验证图是否正确,变量是否在图上分布?
大地址在上部
验证:栈向低地址增长,堆向高地址增长
heap 地址增大,stack 地址减小(先入的先压栈)
保留问题:堆栈地址相差极大,中间的镂空是什么?动静态库讲
堆栈是相对而生的
验证语法问题:
无论全局变量是否被初始化,只要全局数据区一直在,这些变量也存在,并不会随着函数的调用和返回而释放。
当一个变量用 static 修饰时,这个变量不会随着函数的调用完毕而释放,只在首次调用函数时初始化,之后就使用之前的变量 。
打印地址:
在全局数据区中
static 修饰的局部变量,编译的时候已经被编译到全局数据区了。
所以不会随着结束调用释放,因为已经是一种全局变量。
作用域只在代码块,生命周期是全局
结合历史问题和现象(验证结果)谈一谈:
code:
数据具有独立性,g_val 是两份,所以子进程修改了 g_val 的值后父进程并不影响,但是 g_val 的地址是同一个。
怎么可能同一个变量,同一个地址,同时读取,读到了不同的内容。
结论:如果变量的地址是物理地址,不可能存在上面的现象!绝对不是物理地址。是 线性地址(虚拟地址) 。
所以我们平时写的 c/C++ 用的指针,指针里面的地址都不是物理地址!
宏观过程理解
变成一个进程之后,可执行程序被加载到内存中并且内核会创建 task_struct ,同时OS会为该进程创建 进程地址空间 。
平时用的地址是地址空间中的地址。
pcb 中有指针指向地址空间
每一个进程都要有进程地址空间。
变量不仅具有虚拟地址,也有物理地址(真实存在的地方)。
当上层对某个变量进行使用时,会查页表,根据变量虚拟地址找出对应物理地址,从而访问空间对应的值。(页表:kv结构,左边虚拟地址,右边物理地址)
产生子进程时,子进程具有独立性,具有自己的 pcb 和 地址空间,数据大多从父进程继承,并且会拷贝页表,所以父子进程可以共享代码。
当某进程要对共享数据进行写入时,OS会查页表,找到物理地址,发现数据共享,所以 OS 会在写入之前重新开辟空间,值拷贝过来。对页表中,该虚拟地址对应的物理地址进行修改。对这块空间进行写入(写时拷贝)
这些工作做完后,虚拟地址不变,父子进程所看到的的虚拟地址都是一样的(子继承父)。
实际访问是,本质是因为父子进程通过页表寻址时,找到的物理地址不同,对应的值也不同。
所以值不同,虚拟地址相同
历史问题
pid_t id = fork();
if (id == 0)
else if (id > 0)
fork 返回时,也是写入,这时 id 变量是虚拟地址对应的,同一个 id 被读取时,不同进程会根据页表查到不同的值。
谈细节
实现角度谈地址空间(是什么)
地址空间是什么?
在 32 位计算机中,有 32 位的地址和数据总线;cpu和内存连的线是系统总线;内存和外设为io总线
cpu向内存充电,设备中识别高低电平,形成01串,组合形成物理地址。
计算机认识01串,实际上是认识高低电平,其中数据所有的拷贝就是设备之间充放电的过程。
地址空间是地址总线排列组合形成的地址范围[0 ~ 2^32)。
如何理解地址空间上的区域划分?
区域划分本质上就是利用结构 area 规定始末位置(区域的概念),在 destop_area 中规定两分段的区域,并初始化。
但在真正的区域划分中,就是约定最大区域宽度为 xxx 大小,直接将结构体写为:
struct destop_area
{
int start_xiaopang;
int end_xiaopang;
int start_xiaohua;
int end_xiaohua;
};
大小不体现在代码上,区域划分本质上就是在计算机语言中,定义区域的起始和结束。
所谓空间区域的变大和变小,如何理解?
line_area.xiaopang.end -= 10;
line_area.xiaohua.start -= 10;
该两区域 start 和 end 的变量值,就可以调整区域大小。
在小胖划分的地址空间范围中,连续的空间内,每一个最小单位都可以有地址,这个地址可以被小胖使用。 同理,这个地址处存放的东西(数据)也可以被我们访问。
结论:所谓的进程地址空间,本质是一个描述进程可视范围的大小。地址空间内要一定要存在各种区域划分,对线性地址进行 start,和 end 即可。
地址空间本质是内核的一个数据结构对象,类似 pcb 一样,地址空间也是要被OS管理:先描述,再组织
struct mm_struct // 默认划分区域4GB
{
long code_start;
long code_end;
long read_only_start;
long read_only_end;
long init_start;
long init_end;
long uninit_start;
long uninit_end;
long heap_start;
long heap_end;
long stack_start;
long stack_end;
}
创建一个进程,在创建pcb的同时,也创建 mm_struct 结构体;pcb中也有 mm_struct 的指针,方便找到地址空间。
相当于一张4gb的纵向桌子,被各个同学区域划分了。
地址空间中能被当前进程使用,这些地址被称为虚拟地址(线性地址)
因此也可以知道:当检查越界时,只要检查写入的地址的 start 和 end 边界是否超出其边界范围就好了,不合适直接拦截。
进程是什么?进程地址空间是什么?为什么要有地址空间? (为什么)
进程启动时,OS会为每个进程构建地址空间的数据结构,表征进程所能看到内存的空间范围(画大饼)。
一个进程申请内存时,只要不过分,就给,过分就不给。每个进程看到的空间都很大,但实际上在物理内存中,要多少空间给多少,多了也没有,这是虚拟地址空间。
进程 只是想当然的认为自己拥有全部的进程地址空间。
为什么要有地址空间?
- 让所有进程以统一的视角看到内存结构(不同的进程可以 以相同的线性地址为起始或结束,例如父子进程,不必用繁杂的方式在pcb以及物理内存中标识,因为当数据换出时,如果不这么做,在pcb结构中,对于代码和数据的管理,很麻烦)
- 增加进程虚拟地址空间可以让我们访问内存的时候增加一个转换的过程,在这个转换的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存。(有了进程地址空间,不直接和物理内存打交道,避免了直接越界进程挂掉等情况;如果访问的地址,并没有在线性地址中申请,页表中没有对应映射的条目(或条目是只读的),转换的过程可以进行系统级别的检查有效拦截进程的非法操作。)
- 因为有地址空间和页表的存在,将进程管理模块,和内存管理模块进行解耦合。(参考页表第三点)
页表
- cpu 中有 cr3 寄存器保存页表的起始地址。当进程切换走了,进程担不担心找不到页表?
不会,因为cr3中该进程运行的连续数据(页表地址)为进程的硬件上下文,进程不被调度时,会把上下文保存到pcb中,以便下一次被调度时,恢复数据。(页表的地址是物理地址)
地址空间中,有些空间是只读的,有些可以被执行,为了标识,在页表中有标志位标识,当前物理内存是读/写/执行(执行先不考虑,因为 cpu 内eip指向就标识该位置是指令,可被执行),重点是 rw(读/写)
执行命令时,cpu读取到虚拟地址,根据 cr3 寄存器找到页表,查页表,找到物理地址,权限,发现数据只读,如果对该位置进行写入,页表发现权限只读,直接拦截,进程进行非法操作,OS干掉进程。
页表可以提供很好的权限管理
- 解释问题:代码区是只读的,字符常量区是只读的,为什么?
物理内存并没有读/写/执行,权限控制的概念,cpu能访问 cr3 寄存器,访问页表就说明直接能访问;所以可以把代码和数据放到内存的任何地方。而如果说代码只读的,那么它如何把数据加载到物理内存上?因为加载也是写入。
所以只读只是代码区和字符常量区匹配的页表对应映射关系标志位全是 r (只读),只有这样进行非法写入时 OS 才会拦截,进程挂掉。
进程可以被挂起,代码和数据会被换到磁盘中,进程的状态只有 r, t, d 没有挂起,如何知道被挂起,如何知道代码和数据在不在内存?
- 利用标识位实现惰性加载
共识:现代操作系统,几乎不做任何浪费空间的事情
玩大型游戏时,物理内存才4/8个 g ,玩的游戏几十个 g ,游戏还是能跑;说明OS对大文件可以进行分批加载。
而对于大文件,例如 500 兆,短期之内,可能跑不完,只能跑一小部分,多加载的代码和数据,在短期之内是不会被使用的 —— 给了空间,但是没有正常使用 —— 浪费时间和空间 —— 所以 OS 对可执行程序的加载使用 惰性加载 —— 承诺给了很大的空间,但是真实给使用的空间为用多少给多少
例如 2 g 的游戏,可能就给加载几十kb的数据,不会一上来就加载很多。因为短时间内剩下空间用不着,内存空间的使用率就降低了。
所以在页表中,左侧的虚拟地址都会填上,右侧的不填,并且在页表中存在 **标志位:对应的代码和数据是否已经被加载到内存(平常有一种画法为指向磁盘中的地址或指向物理地址)—— 理解:被加载 0 ,没加载 1 **
访问虚拟地址时,查页表,看标志位,1 读取物理地址,找到区域访问;0 则触发概念 —— 缺页中断,之后会找到可执行程序,在物理内存上申请一块空间,把剩下的代码和数据加载到内存中,把这块空间的地址填到页表中(自动完成),完成之后恢复到最初访问的过程,此时就可以访问代码和数据了。
注:写时拷贝也是缺页中断。
极端下,可以把 pcb 等申请好,一行代码都不加载;当要执行时,再惰性加载,由此实现边使用边加载(实际上是会至少加载一部分代码,预读可执行程序的格式)
进程在被创建的时候,是先创建内核数据结构,再慢慢加载可执行程序。
而当通过虚拟地址访问物理地址,发现物理内存不再系统中,触发缺页中断,那缺页中断的整个过程由谁做?linux的内存管理模块
因为有页表和地址空间的存在,让进程管理不用关心内存管理;进程管理只要使用虚拟地址,没有物理地址自动会缺页中断,调用内存管理的功能,虚拟地址空间的存在把内存管理和进程管理实现了软件层面上的解耦。
什么是进程
进程 = 内核数据结构(task_struct && 地址空间 && 页表) + 程序的代码和数据
进程切换只要把进程上下文切换,pcb,地址空间,页表全部切换。
进程具有独立性,怎么做到的?
- 有自己的内核数据结构
- 物理内存中加载的代码和数据,只要在页表层面上,不同的进程虚拟地址可以一样,物理地址可以完全不一样,让页表映射到物理内存的不同区域,每个进程的代码和数据就解耦了;只要让代码区指向一样,数据区指向不一样,数据也完成了解耦。
页表左侧可以线性有序,右侧乱序,进程以一个统一的视角看待内存。页表也让无序变有序,方便进程管理。
补充
代码在经过编译后,编程汇编or二进制代码之后,还有没有变量名这个概念?
没有,变量名在二进制中,都会被编译器转换为地址。转换为二进制后变量名和&变量名是一样的概念;而应用层则是用变量和&变量对打印地址还是内容今进行区分。这也是变量名可以随便写的原因,因为编译完之后和变量名关系不大(编译器看来)。
物理内存怎么都看不到。
命令行参数和环境变量在栈上方,环境变量在命令行参数的上方。
子进程为什么能继承父进程的环境变量?
因为环境变量在父进程已经加载过,父进程的环境变量是父进程地址空间上的数据,所以创建子进程时,子进程创建页表,会将父进程对应虚拟地址空间环境变量的相关参数建立映射关系。所以不传也能获得环境变量信息。这也是环境变量具有全局属性,会被子进程继承下去的原因,因为数据是可以直接被页表让子进程直接找到的。
内核数据结构也要在物理内存中放置,会被映射到内核空间中。