一.地址分区
根据之前的学习我们可以知道,储存代码的地址空间是分区的,如堆、栈,具体分区如下:

我们可以用以下代码来感受不同区间的相对位置:
c
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <stdlib.h>
4 int g_unval;
5 int g_val = 100;
6 int main(int argc, char *argv[], char *env[])
7 {
8 const char *str = "helloworld";
9 printf("code addr: %p\n", main);
10 printf("init global addr: %p\n", &g_val);
11 printf("uninit global addr: %p\n", &g_unval);
12 static int test = 10;
13 char *heap_mem = (char*)malloc(10);
14 char *heap_mem1 = (char*)malloc(10);
15 char *heap_mem2 = (char*)malloc(10);
16 char *heap_mem3 = (char*)malloc(10);
17 printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
18 printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
19 printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
20 printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
21 printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
22 printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
23 printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
24 printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
25 printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
26 printf("read only string addr: %p\n", str);
27 for(int i = 0 ;i < argc; i++)
28 {
29 printf("argv[%d]: %p\n", i, argv[i]);
30 }
31 for(int i = 0; env[i]; i++)
32 {
33 printf("env[%d]: %p\n", i, env[i]);
34 }
35 return 0;
36 }
其中,栈储存数据时是将数据从高地址向低地址储存,堆是从低地址向高地址储存。
二.虚拟地址
前面我们讲过,如果一个进程创建其子进程,那么它们将共享代码和数据,但如果在某个进程中修改了变量,那么该变量就会在那个进程里单独拷贝一份,也就是写实拷贝。我们可以通过代码观察这个现象:

奇怪的是我们可以发现,即使子进程修改了代码中的变量,我们查到的该变量的地址却和父进程中的一样。这不太对吧,明明是同一个地址却有可能查出不同值。
没错,如果查到的是这些变量实际在内存中储存的位置的话这毫无疑问是由问题的,但实际上,我们查到的变量地址都是虚拟地址,真实储存在内存中的地址是查不到的。
三.虚拟地址空间
虚拟地址空间的概念
每个进程都有自己对应的虚拟地址空间,即:

既让这样那系统肯定得将每个进程的虚拟地址空间管理起来,依旧是先组织,在管理 。在内核中一个结构体叫struct mm_struct ,PCB中会有一个指针指向该结构体变量(以下称为mm),这个变量就管理着进程对应的虚拟地址空间。mm_struct结构体有以下成员:
c
struct mm_struct
{
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;
......
}
它记录了该进程对应的虚拟空间中每个区间的起始与结束,如start_code,end_code分别记录了代码区的开始与结束。既然需要记录那就说明不同进程的虚拟地址空间中各部分的始末地址不同。没错,OS会根据进程所需的空间大小来划分其虚拟空间。
除了以上以上这种记录方法,该结构体内还会有类型为vm_area_struct 类型的变量,该变量储存了某个虚拟地址区间的的始末地址,每个虚拟地址区间的起始都会有一个vm_area_struct类型的变量记录,这些变量会通过数据结构管理起来(linux中是链表)。
cpp
struct vm_area_struct
{
unsigned long vm_start;
unsigned long vm_end;
......
}
PCB、struct mm_struc、vm_area_struct三个结构体的关系如下:

页表
在操作系统中定义了一个结构体类型struct mm_struct ,
正如上文所说,我们在代码中查到的所有地址都是虚拟地址,在内存中的物理地址是查不到的。那么这两者之间的关系是什么呢?
在操作系统中有一个变量叫页表 :

每个进程都有自己对应的页表,可以通过PCB找到,在页表中储存有虚拟地址和对应的物理地址,关系类似于哈希表中的数据。进程中需要被记录的每一个虚拟地址都有一个对应的物理地址,当进程被调度时,OS就可以通过页表找到数据在内存中储存的位置。
那我们不难猜到,当父子进程发生写实拷贝时,被修改的变量对应的虚拟地址没变,所以在父子进程中查到的是一样的,变的是该变量虚拟地址在页表中对应的物理地址。
写实拷贝如何触发
上面展示的其实只是页表的一部分,页表还有权限这个属性:

当父进程创建子进程后,每块地址对应的权限都会变成只读r--,当某个进程要修改变量时就会发生冲突,然后操作系统就可以发现并对这块地址进程操作。
为什么要写实拷贝?
1.节省空间:父进程中的大量变量在子进程中可能并不需要修改;
2.节省时间:拷贝数据消耗大。
缺页中断
调度进程时,是现有虚拟地址还是先将加载到内存分配物理地址呢?
是先有虚拟地址,然后为其分配物理地址最后加载到内存中。
那内存不足无法为进程分配其所需的物理地址该怎么办?
内存不足时,先为其分配虚拟地址不为该进程分配物理地址,当内存空出来时再为其分配,这就是缺页中断。
因此我们可以了解进程挂起状态的本质:
当内存不足时就先不为该进程分配物理地址,这就是进程的挂起状态。
为什么要有虚拟地址空间
1.给进程一个有序的虚拟视图,让物理内存可以无序、分散;
如开数组时不用真的在物理内存中找一块连续的区域,可以让其在物理内存中相对分散,加强对空间的利用。
2.地址转换过程中可以对操作的合法性进行判断,进而保护内存;
a.如野指针问题:不会直接物理地址进程操作,而是通过虚拟地址观察是否可以操作;
b.char* s = "dskj";s[0] = 'h';这个操作是不允许的,当s指向该常量字符串时,在页表中其物理地址对应的权限会拒绝该操作。
3.让进程管理和内存管理解耦合。
让两者管理关系不那么大,一方出了问题不会导致两者都挂。
四.总结
每个进程都有自己对应的虚拟地址空间,创建进程后,操作系统会根据进程所需的空间为其虚拟地址划分区域,再为其分配虚拟地址,如果内存够用就会为其每个虚拟地址分配对应的物理地址并设定权限,如果不够就先不分配物理地址使其处于挂起状态。当进程被调度时就可以通过其虚拟地址找到对应的物理地址并对其操作。