在之前,我们学习C/C++的时候看过一个地址空间,它是这样的。
我们带着疑问,这个所谓的内存区域实际是计算机的物理内存吗?
我们在Linux中,通过一个程序验证一下。
首先fork被调用后创建子进程,操作系统为了管理一个进程,在父进程创建子进程时,必须拷贝父进程的数据结构(子进程按照父进程为模板)。
1 #include <stdio.h>
2 #include <unistd.h>
3
4 int g_val = 0;
5
6 int main()
7 {
8 pid_t id = fork();
9 if(id < 0)
10 {
11 perror("fork fail\n");
12 return 0;
13 }
14 else if(id == 0)
15 {
16 //子进程
17 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
18 }
19 else
20 {
21 //父进程
22 printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
23 }
24 sleep(1);
25
26 return 0;
27 }
结果显示在同一个地址空间。
修改一下程序
我们并不能确定父子进程哪个先跑完,所以给父进程代码sleep3秒,让子进程修改g_val后先跑完再执行父进程。
1 #include <stdio.h>
2 #include <unistd.h>
3
4 int g_val = 0;
5
6 int main()
7 {
8 pid_t id = fork();
9 if(id < 0)
10 {
11 perror("fork fail\n");
12 return 0;
13 }
14 else if(id == 0)
15 {
16 //子进程
17 g_val = 100;
18 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
19 }
20 else
21 {
22 //父进程
23 sleep(3);
24 printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
25 }
26 sleep(1);
27
28 return 0;
29 }
从结果上看,由于进程之间是独立的,所以在子进程修改g_val后,父进程的值依然不变,这很正常,但是竟然出现了同一个空间出现两个不同的值。
这是我们后续需要解释的一个现象。
不过在此前,我们需要认识到,物理内存是不可能一个地址存两个不同的值的,所以上述的地址空间不是在内存中,其实我们上述所指的地址空间其实是一个虚拟地址空间。
虚拟地址空间其实就是让程序自己认为独占内存空间。(其实不是)
通过一个故事理解。
将操作系统比喻成一个富老爸,这个老爸假设有10亿资产(对应内存空间),而这个富老爸有3个私生子(3个进程),他们彼此并不知道对方的存在。
每个进程以为自己独占空间,其实没有。
富老爸为了让每个私生子做好自己的事,给每个人都偷偷的画了一个饼:“ 只要你努力变得有出息,在我死后我就把我的10亿资产都继承给你 ”(虚拟地址空间)。
进程向内存申请空间,但如果太多会被操作系统拒绝
有个儿子可能在自己努力的道路上遇到点挫折,需要在老爸那里借点钱(进程向内存申请空间),但是这个钱(内存空间)不会很多,如果很多的话,老爸(操作系统)会拒绝的。
虚拟地址空间是由操作系统画的一个饼,那么操作系统是如何画的呢?
公司给员工画饼,员工需要记忆好,不然画饼没有意义。
画饼本质:在你大脑里构建一副蓝图 – 数据结构struct 蓝图
如果公司给400个员工每个画了一个饼,员工需要被管理,每个员工对应的饼也要管理,不然认错了。
如果操作系统给400个进程每个构建了一个虚拟地址空间,进程需要被管理,每个对应的虚拟空间也要被管理。
那么就需要对每个虚拟空间进行描述,创建对应结构体,再利用数据结构组织起来。
地址空间的本质:是内核的一种数据结构!struct mm_struct。
所以我们知道了,在操作系统中,进程地址空间就是一个虚拟的空间,那么我们如何描述它呢?
在此之前,我们需要对地址空间有一个基础。
下面对32位进行讨论
(1个字节是每个地址对应空间大小,地址4个字节是指自身大小)
操作系统会为每个进程创建一个虚拟的进程地址空间,对应的进程PCB内会有一个指针(struct mm_struct *mm;)指向这个空间,而这个空间的描述在内核代码中就是一个结构体(struct mm_struct { … })。
进程地址空间通过对应的结构体进行描述,再通过相应的数据结构进行管理。
每个进程对应的进程地址空间,不是一开始就给4GB空间的,而是通过数据结构修改结构体变量进行调整空间大小。
Linux内核部分代码:
对应PCB,在进程各种属性中,有一个mm指针管理着内存信息。
在mm_struct结构体中,有着维护各种区域字段的属性信息。
struct task_struct {
...
struct mm_struct *mm; //进程内存管理信息
...
}
struct mm_struct {
...
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
unsigned long start_code, end_code, start_data, end_data; /*维护代码区和数据区的字段*/
unsigned long start_brk, brk, start_stack; /*维护堆区和栈区的字段*/
unsigned long arg_start, arg_end, env_start, env_end; /*命令行参数的起始地址和尾地址,环境变量的起始地址和尾地址*/
...
}
所以我们知道了,所谓进程地址空间,其实就是一个进程PCB属性指向的一个数据结构,通过相应数据结构操作调整这个结构体变量就可以调整对应虚拟空间大小。
但是,程序是要加载到内存的,进程地址空间和内存有什么关系呢?
程序加载到内存,操作系统会为每一个程序创建匹配的进程控制块并管理起来,每一个进程控制块指向加载到内存的程序,而在这个指向中,进程地址空间是虚拟地址,虚拟地址是如何与内存的物理地址打交道的呢?
当一个程序将代码和数据加载到内存,操作系统创建对应的PCB同时为进程创建进程地址空间。
当有一个int a = 10;加载到内存中,对应进程地址空间就有一个虚拟地址,内存中也有一个物理地址。
操作系统中进程自己的页表,将对应的虚拟地址和物理地址保存并匹配起来,这样就能使得虚拟地址通过映射访问到物理空间。
当我们需要修改一个数据(比如将a = 10 改为 a = 100时),我们看到的&a是虚拟地址,就能直接通过页表访问物理空间将a修改为100。
这里粗略的了解一下页表的功能,以上所有工作都是由操作系统做的
必须认识到,进程地址空间只是一个“饼”,实际上根本没有4GB,操作系统按需求分配给进程空间,其实也不会分配很多。
拓展知识:
- 内存被使用时基本单位是4KB。
- 一个4KB空间称为页。
- 进程地址空间由于地址都是连续的,所以也被称作线性地址。
- 虚拟地址空间包括进程地址空间和页表
fork调用之后,父子进程共享后续代码,当子进程对共享空间修改时,操作系统先会对原空间进行拷贝,再修改子进程对应的页表映射,最后再让子进程修改数据。
操作系统为了保证进程的独立性,做了很多工作。通过进程地址空间,通过页表,让不同进程映射到不同物理内存。
进程 = 内核数据结构(PCB) + 加载到内存的代码和数据。
进程地址空间和页表体现了内核数据结构的独立性,写时拷贝也体现了数据的独立性,所以进程具有独立性。
拓展知识:
逻辑地址有两种构建方式:
1、用32位线性编址方式。
2、从0开始,通过区域起始地址+偏移量方式编址,加载到物理内存后将物理空间起始地址+偏移量方式编址。
本章完~