什么是进程地址空间
简单来讲, 进程的地址空间是进程可用于寻址内存的地址集合, 包括进程的物理地址空间和虚拟地址空间.
操作系统有虚拟内存和物理内存的概念. 但是在很久之前, 操作系统只有物理内存的概念, 程序寻址都是用的物理地址, 寻址空间的大小取决于 cpu 地址线条数, 在 32 位的操作系统上, 寻址的范围是固定的(最多 4G). 也就是说, 每次运行一个程序, 都会给进程分配 4G 的物理内存, 这样就带来了很多麻烦
1. 内存浪费: 每个进程固定分配 4G 物理内存, 对内存要求太高;
并且物理内存的分配是连续的且随机的, 不能充分利用物理内存, 进而造成内存浪费
2. 效率低下: 内存得不到充分利用, 就会产生过多的进程等待, 等待其他进程
结束后再被载入内存, 操作频繁, 效率低下
3.安全性低: 进程中的指令都是直接访问物理内存的, 这就可能影响其他进程
甚至破坏内核空间, 安全性低
虚拟内存
什么是虚拟内存
简单来说, 虚拟内存其实就是暂时将一部分硬盘空间来充当内存在使用, 使得进程认为拥有一段连续的内存空间, 而实际上它通常被分割成多个物理内存碎片, 还有一部分暂时存储在外部的磁盘存储器上(在需要时进行数据交换) -------- 像 Windows 的"虚拟内存", Linux 的"交换空间"…
虚拟内存空间的组成
虚拟内存空间包括内核空间和用户空间, 在 32 位的 Linux 操作系统上, 1G(高地址)是内核空间, 3G 是用户空间
我们看下列代码
通过 size main.out 查看程序段组成
代码段(text): 存放代码
数据段(data + bss): 存放初始化和未初始化的全局变量和静态变量
堆(Heap): 存放动态内存分配
栈(Stack): 存放函数参数和局部变量
栈和堆会不会碰头
虽然栈是自上而下, 堆是自下而上; 但是他们之间的间隔很大, 一般来说不会碰头, 我们可以写一个程序:
#include <stdio.h>
#include <stdlib.h>
int bss_var0;
int bss_var1;
int data_var0 = 1;
int main() {
printf("-----------------------\n");
printf("Stack location:\n");
int stack_var0 = 1;
printf("\tstack_var0 = %p\n", &stack_var0);
int stack_var1 = 1;
printf("\tstack_var1 = %p\n", &stack_var1);
printf("-----------------------\n");
printf("Heap location:\n");
int* heap_var0 = (int*)malloc(sizeof(int));
*heap_var0 = 1;
printf("\theap_var0 = %p\n", heap_var0);
int* heap_var1 = (int*)malloc(sizeof(int));
*heap_var1 = 1;
printf("\theap_var1 = %p\n", heap_var1);
printf("-----------------------\n");
printf("BSS location:\n");
printf("\tbss_var0 = %p\n", &bss_var0);
printf("\tbss_var1 = %p\n", &bss_var1);
printf("-----------------------\n");
printf("Data location:\n");
printf("\tdata_var0 = %p\n", &data_var0);
static int data_var1 = 1;
printf("\tdata_var1 = %p\n", &data_var1);
printf("-----------------------\n");
printf("Text location:\n");
printf("\ttest = %p\n", main);
return 0;
}
以上的地址都是虚拟地址, 我们可以查看一下 Linux 下的 ELF 格式(UNIX下的可执行文件, 目标文件, 核心转储文件, 共享库文件格式)的文件:
我们在编译完毕的时候, 程序的入口地址就已经确定了.
代码共享, 数据独有
创建一个子进程, 来描述代码共享:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
int var = 1;
pid_t pid = fork();
if (pid < 0) {
perror("fork error");
exit(-1);
} else if (pid == 0) {
printf("child &var = %p\n", &var);
printf("child var = %d\n", var);
} else {
printf("parent &var = %p\n", &var);
printf("parent var = %d\n", var);
}
return 0;
}
这就是代码共享, 子进程复制父进程的pcb, 子进程和父进程的虚拟地址内存相同, 数据的分配地址相同(但是pid不同), 子进程和父进程共用同一段代码, 这样的话子进程可以帮助父进程分摊压力, 但是这就是子进程存在的意义吗? 或者说, 两个进程的物理内存相同吗?
用修改以上代码来描述数据独有:
这就是数据独有, 在子进程中修改变量的值, 但是不影响父进程中变量的值, 并且数据的虚拟地址空间依然相同, 这其实不难想象, 虚拟地址相同, 但是物理地址不相同; 而且这里还涉及到了一个关于写时拷贝技术的知识:
写时拷贝技术
简单的来讲, 写时拷贝技术就是内容改变时的一份拷贝; 进程的各段内容发生改变的时候, 才会将父进程的内容拷贝一份给子进程, 这就意味着在进程各段内容发生改变之前, 父进程和子进程共享同一块物理内存, 只有当某段内容发生改变时, 才会将这段的内容拷贝一份给子进程, 然后映射到物理内存的新位置.
虚拟内存的工作
进程控制块(task_struct)
一个进程是由进程控制块来描述的:
内存描述符(mm_struct)
虚拟内存是由内存描述符来描述的, 在 task_struct 中找到 mm_struct:
mm_struct 是用来描述进程地址空间的, 每一个进程都有唯一的进程地址空间, 即每个进程拥有唯一的 mm_struct 结构体
mmap 是把磁盘文件和逻辑地址映射到虚拟地址, 以及把虚拟地址映射到物理地址, mmap 指向的 vm_area_struct 结构体描述了虚拟内存.
mmap和mm_rb描述的对象是相同的, 之所以用两种方式来组织对象, 是因为用mmap作为链表可以方便遍历所有元素;而mm_rb作为红黑树,适合于搜索指定的元素。
mm_users 是指有多少个线程(进程执行的基本单元)在共享该进程地址空间;
mm_count 是指是否有线程使用该进程地址空间, 如果有则为 1, 否则为 0 并且此结构体被撤销.
mmlist 是连接所有 mm_struct 结构体的双向链表, 表头是 init 进程的进程地址空间.
(虚存管理)vm_area_struct:
它是虚存管理最基本的管理单元, 它以双向链表的形式描述了一块连续的, 具有相同属性的虚拟内存, 该虚拟内存的大小是对应物理内存的整数倍.
vm_mm 指向了该虚拟内存区域的进程地址空间
vm_start 是进程地址空间的起始地址;
vm_end 是进程地址空间的结束地址;
虚拟地址的范围: [vm_start,vm_end)
vm_flags 保存了对虚拟空间的访问权限
vm_file: 如果 vm_area_struct 描述的是一个文件映射的虚拟空间, 那么 vm_file 便指向被映射文件的 file 结构, vm_pgoff 是该虚拟内存起始地址在 vm_file 文件里面的偏移量(单位为物理页面).
vm_ops: 当进程创建 vm_area_struct 之后, 只能说明进程可以访问这个虚拟空间, 但有可能还没有分配相应的物理页表来建立好页面映射. 在这种情况下, 访问内存会产生缺页异常, 这时需要通过 vm_ops -> nopage 所指向的函数将产生缺页异常的地址对应的文件数据读取出来.
虚拟内存的工作流程
1.进程创建
2.建立虚拟内存和磁盘文件的映射:
通过 mm_struct 将可执行程序映射到虚拟内存空间时, 一组 vm_area_struct 数据段将被产生
1、创建一组vm_area_struct;
2、圈定一个虚拟用户空间,将其起始结束地址(elf段中已设置好)保存到vm_start和vm_end中;
3、将磁盘file句柄保存在vm_file中;
4、将对应段在磁盘file中的偏移值(elf段中已设置好)保存在vm_pgoff中;
5、将操作该磁盘file的磁盘操作函数保存在vm_ops中;
3.建立虚拟内存和物理内存的映射(MMU):
当运行到对应的数据段时, 进程去寻找页表, 如果页表中没有映射到物理内存的信息, 则产生缺页异常(有效位为 0), 然后分配物理内存页并将磁盘中的指定文件加载到物理内存之后(有效位为 1), 重新访问
1.cpu依据CR3(current->pgd)找到0x08000011地址对应的pgd[i],由于该pgd[i]内容保持为初始化状态即为0,导致cpu异常。
2.do_page_fault被调用,在该函数中,为pgd[i]在内存中分配一个页表,并让该表项指向它
3.为pte[j]分配一个真正的物理内存页面,依据vm_area_struct中的vm_file、vm_pgoff和vm_ops,调用filemap_nopage将磁盘file中vm_pgoff偏移处的内容读入到该物理页面中
物理内存满了怎么办
淘汰物理内存中的其他页, 来为新页腾出空间
struct mem_map
{ // 物理页描述
1、本页使用计数,当该页被许多进程共享时计数将大于1
2、age描叙本页的年龄,用来判断该页是否为淘汰或交换的好候选
3、map_nr描叙物理页的页帧号
}
linux使用"最近最少使用(Least Recently Used ,LRU)“页面调度技巧来公平地选择哪个页可以从系统中删除。这种设计系统中每个页都有一个"年龄”,年龄随页面被访问而改变。页面被访问越多它越年轻;被访问越少越老。年老的页是用于交换的最佳候选页。
总结:
我们理解进程地址空间, 其实就是理解进程和线程的一个工作流程.
当一个程序从静态到动态的这么一个过程中, 系统先通过在内存中产
生进程核心 PCB(task_struct), 然后通过 task_struct 中的
mm_struct 来在建立磁盘到虚拟地址空间的映射, 然后再通过 mm_struct
中的 vm_area_struct 来建立虚拟地址空间到物理地址空间的映射.
这样其实就通过进程地址空间来理解了以下线程的工作流程.