Linux进程的地址空间和页表
1. 地址空间
每个进程都有自己独立的地址空间和独立的页表,页表储存着虚拟地址和物理地址的映射。子进程会拷贝父进程的很多内核数据结构(包括地址空间和页表)。当子进程的数据发生改变时,OS会将被改变的数据进行写时拷贝,存入新的物理地址,同时子进程的页表中对应的物理地址映射发生改变。
在程序中打印的是虚拟地址,但真实的物理地址不一定相同。这是父进程和子进程的变量有相同的 “地址” ,但变量数据可以不同的原因。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
int value = 666;
pid_t id = fork();
if (id == 0)
{
// child
int n = 0;
while (1)
{
printf("我是子进程 pid: %d |ppid: %d value: %d |&value: %p\n",getpid(),getppid(),value,&value);
sleep(2);
n++;
if (n == 5)
{
value = 999;
printf("子进程,改变value %d -> %d\n",666,999);
}
}
}
else
{
// father
while (1)
{
printf("我是父进程 pid: %d |value: %d |&value: %p\n",getpid(),value,&value);
sleep(2);
}
}
}
1.2 地址空间的功能
-
地址空间通过页表,将无序的物理地址当成有序的来管理,让进程以统一的视角看待物理内存以及自己运行的各个区域。
实际的物理内存并不划分什么区域,可以看作是无序的。我们现在的电脑,内存都在16GB到32GB,如果没有地址空间,只使用物理地址进行管理,那么效率是很低的(需要从头遍历到尾找数据)。但是地址空间中的区域都是一块一块划分的,堆区在多少多少范围,栈区在多少多少范围,都是明确的,只要在地址空间中找到了资源,就能通过页表找到它对应的物理地址。
-
允许进程管理模块和内存模块进行解耦。
在实际的进程运行中,代码申请的空间短时间内不一定使用。OS可以把代码申请的空间的虚拟地址映射的物理地址先让给别的进程使用,等该进程需要使用的时候再把这块物理地址留给该进程。这种类似于写时拷贝的策略就是解耦,能够提升物理地址的使用效率。
-
拦截非法请求。
进程在运行时,如果遇到非法请求,如代码非法访问了已经释放的空间,在物理地址的层面没有办法阻止这种访问。但在地址空间中,OS会查询页表找对应的地址映射,如果没找到映射,则说明这个访问是非法访问,OS会把这个请求拦截下来(即报错)。
1.3 变量在地址空间中的分配
1.3.1 局部变量
局部变量储存在栈中,栈的分配和回收是由编译器和操作系统自动管理的。栈的地址空间是预先分配好的,但物理内存并不是一次性全部分配。在实际使用时,栈内存的物理页是按需分配的。即当函数执行时,系统会动态地映射物理内存页到对应的虚拟地址空间。
当每一个函数被调用时(包括main()
),系统会为该函数创建一个栈帧(Stack Frame),栈帧包括局部变量、函数参数、返回值地址、保存的寄存器等信息。当局部变量被创建时,**栈指针 (SP
,Stack Pointer)**向下移动,为这些变量分配空间(在内存中,栈是从高地址向低地址增长的)。
当一个函数执行完毕,栈帧被销毁,栈指针会回到函数调用前的状态,局部变量的空间被释放,这个过程由操作系统自动完成。
1.3.2 全局变量、静态变量
全局变量和静态变量的生命周期是整个进程运行的过程,它们的内存分配发生在编译时,并且存储在程序的数据段(Data Segment)或 BSS 段(Block Started by Symbol,未初始化数据段)中。
全局变量和静态变量的内存是在编译阶段确定的。编译器在生成可执行文件时,全局变量和静态变量的大小和位置已经确定了。全局变量和初始化的静态变量被放入数据段中,而未初始化的静态变量(没有明确赋值的静态变量)被放入 BBS 段中。这些信息(大小、起始地址)会记录在可执行程序的程序头(Program Header)中。
当程序被加载到内存中时,操作系统会将数据段和 BBS 段加载到进程的地址空间。但要注意的是,现代操作系统并不会立即为这些虚拟地址映射物理地址,而是在执行流访问这些段的变量时(代码段也是如此),才会将相应的虚拟页映射到物理内存中,这种技术称为懒加载(Lazy Loading)机制。
该机制会触发页表的页错误,详细看页表。
1.3.3 动态分配的变量
当程序调用 malloc()
或 new
时,程序是从堆(Heap)上申请的空间,堆是进程虚拟空间中的一个区域,可以在程序运行期间动态增长或缩小。
堆上的内存与栈一样,物理内存的分配是按需的。当程序访问堆上的变量时,如果相应的虚拟地址没有映射到物理内存,会触发页错误,操作系统会动态地为堆分配物理内存。
2. 内核空间
在地址空间的大小一共有4GB,其中[0,3]GB 是用户空间,[3,4]GB是内核空间。我们所写的变量、函数只能够占用用户空间,而系统调用等由操作系统执行的操作都在内核空间中。内核空间的数据有内核级页表来映射到物理地址,与用户级页表不同的是,每一个进程的地址空间有一个用户级页表,但内核级页表是所有进程的内核空间共用一张内核级页表。
3. 页表
3.1 页表介绍
页表(Page Table)是操作系统为每个进程维护的一个数据结构,用于管理虚拟地址到物理地址的映射关系。
页表中不仅存储着虚拟地址和物理地址的映射,它内部还有各个地址的rwx权限的信息。CPU的 cr 寄存器中的**cr3( 页目录基地址寄存器)**存储着页目录的起始地址,所以 CPU 通过 MMU、cr等寄存器,就可以直接访问物理地址。
3.2 物理地址的按需分配
现代操作系统采用了按需分配(Demand Paging)的机制,以提高内存利用率。即虚拟地址并不会立即全部映射到物理地址,而是根据进程的实际情况按需分配物理地址。
进程启动时,操作系统为进程分配地址空间,但不会立即为该空间分配对应的物理内存。页表会记录这些虚拟地址的初始状态,但不会为它们建立有效的映射。当进程执行流第一次访问某个虚拟地址时,系统会检查页表,如果发现该虚拟地址没有对应的物理地址(即该页表项无效),这时就会发生缺页中断,操作系统会动态分配物理地址并更新页表。
缺页中断是页错误的一种。
按需分配可以节约物理内存资源,因为虚拟地址空间可以远大于物理内存,系统只在实际需要时才为虚拟内存分配物理内存。
3.3 页错误
页错误(Page Fault)是指当进程尝试访问某个虚拟地址时,发现该虚拟地址在当前的页表中没有对应的物理地址映射,这时 CPU 会触发一个异常,由操作系统捕捉这个异常后,根据实际情况处理页错误。
页错误又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等。但缺页中断通常特指的是没有分配物理地址的页错误。
触发页错误的常见原因:
-
没有分配物理地址
进程尝试访问的虚拟页还没有分配物理内存。操作系统可以根据请求分配新的物理内存,然后更新页表,这是一种合法的页错误。
-
页被交换到硬盘
硬盘存在一个 swap 区,会将处于内存中,但可能长时间不会使用的数据先放在硬盘中,来为内存腾出空间。如果虚拟页被放在 swap 区中,访问这个页就会触发页错误,将该页从硬盘中重新加载到内存中。
-
非法访问
进程访问了它无权访问的内存区域,导致非法页错误。操作系统可能会终止该进程,并抛出分段错误(segmentation fault)。
-
地址空间需要写时拷贝
看下面的地址空间的写时拷贝由页表管理
操作系统处理完页错误后,操作系统将控制权交回给进程,CPU 再次尝试访问该虚拟地址,这次由于页表已经更新,访问可以正常完成。
3.4 地址空间的写时拷贝由页表管理
父子进程的地址空间的写时拷贝(Copy-On-Write, COW) 机制是通过页表来管理的。写时拷贝是一种优化技术,用于在创建子进程时避免不必要的内存拷贝,从而提高系统效率。
当一个进程调用 fork()
创建子进程时,操作系统为子进程创建了父进程的地址空间副本,但父子进程并不会立即复制所有的物理内存页,而是最初共享同一组物理内存页,操作系统会将这些共享页面的页表项的写权限标记为只读。这样,即使进程尝试修改某个共享页,也会先触发一个页错误。
当父进程或子进程某一方尝试写入共享页的内容时,才进行真实的内存拷贝,具体过程:
- 操作系统检查该页是否为写时拷贝页面(是否权限仅可读)
- 操作系统为进程分配一个新的物理内存页。
- 操作系统将原来的只读页面的内容拷贝到新分配的物理页中。
- 操作系统更新进程的页表,将原虚拟地址指向新的物理页,并设置写权限。
- 重新执行写操作。
在写时拷贝机制中,只有在父进程或子进程试图写入某个页面时才会触发拷贝。对于那些从未被修改的页面,父子进程可以一直共享同一块物理内存。这对于大量数据不需要修改的应用场景(例如程序的代码段)可以节省大量内存。
注意,地址空间的写时拷贝不针对父进程或子进程,而是对双方都适用。
如果子进程先写,子进程的页表更新,指向新的物理页,父进程仍然指向原来的物理页。
如果父进程先写,父进程的页表更新,指向新的物理页,子进程仍然指向原来的物理页。
4. 内存结构
OS进行内存管理,不是以字节为单位的,而是以内存块为单位的,默认大小4KB。一个内存块叫做页框(页帧)。
CPU访问内存时,不是按照实际需要的大小访问,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中(4KB),这样的访问方式称为局部性原理。局部性原理能减少写时拷贝的次数,以空间换时间,提高CPU的访问效率。
所以,OS在管理内存时,也是通过一个结构体类型来管理的:
struct page
{
int flag;//是否被占用,是否是脏页,是否被锁定
int mode;
}
struct page memory[1048576];//用数组管理。对内存的管理工作转换成了对这个数组的增删查改
5. 页表的实际结构
页表实际并不是一张表,因为假设在32位系统下,一个虚拟地址占4个字节,地址空间有4GB,如果页表是一张表,页表包含映射关系和状态标记符,总大小至少超过300GB,显然与事实不符。
在32位中,一个虚拟地址有32个比特位,其中高10个比特拿来作为页目录的索引。页目录每个下标的内容存着页表的地址,指向一个页表,页目录一共有1024个索引;中间的10个比特位拿来作为页表的索引,页表里的每个内容存着指向页框的起始地址;低12位比特位从[0,4095]共4096个,拿来作为偏移量在页框内偏移,以此便能找到页框内的每一个内容。
经过这样的变换,页表的最终大小为
1024
∗
4
b
i
t
e
+
1024
∗
2
K
B
=
2
M
B
+
4
K
B
1024*4bite+1024*2KB=2MB+4KB
1024∗4bite+1024∗2KB=2MB+4KB
在64位的系统中,页表经过更多次的变换,但原理相同。
虚拟地址 & (0xFFF) = 页框号