0x01 虚拟地址
作为一个技术人员,不管你日常用的是什么语言,你都应该或多或少的听过c语言。而如果你了解c,那你一定知道它有个,有时可以让你天马行空,有时又可以让你郁郁寡欢的数据类型,是的,它就是指针。
指针本质上和其他的数据类型一样,存放的都是一个数值,只不过指针的这个数值表示的是内存地址,而非具体数据。
但你知道吗,这个地址可不是真实的物理内存地址,而是一个假的地址,我们称之为虚拟地址。
不信?那你看看这段代码的输出,你觉得你的机器会有这么大的内存吗?
代码语言:javascript复制$ cat main.c
#include <stdio.h>
int main(int argc, char **argv) {
printf("%pn", &argc);
printf("%pn", &argv);
return 0;
}
$ gcc main.c && ./a.out
0x7ffefd057a8c
0x7ffefd057a80
0x02 虚拟地址到物理地址的转换
既然我们是用内存来存取数据,最终肯定是要用到它的物理地址的,那虚拟地址是如何转换成物理地址的呢?
这个其实是由硬件和操作系统一起来完成的。
当我们在存取某个内存变量时,其对应到的汇编代码其实就是mov指令,当cpu在执行类似指令时,如果遇到内存地址,则会根据一定的规则,自动将该虚拟的内存地址,转换成真实的物理地址,这在硬件层面是自动完成的。
这个过程我们称之为paging(其实转换过程更复杂,还包含segmentation等阶段)。
在软件层面,或者说是操作系统层面,我们要为这种paging机制提供一种数据结构,叫做 hierarchical paging structures,用来定义虚拟地址到物理地址的映射规则,cpu在paging的过程中,会使用到我们提供的这个数据结构,进而转换出我们期望的物理地址。
hierarchical paging structures的格式及使用方式,是由硬件定义的,具体描述可以参考Intel或AMD的官方文档。
操作系统按照这种格式,为每个进程定义一个自己的hierarchical paging structures,所以每个进程都有自己独享的虚拟地址空间,在进程运行的过程中,操作系统会把各个进程的物理内存使用情况,记录在hierarchical paging structures里。
总之,虚拟地址到物理地址的转换,就是根据操作系统提供的映射规则,由cpu在执行指令时自动完成的。
0x03 四种paging模式
计算机发展到现在(x86体系),paging模式已经有了四种,分别是32-bit paging、PAE paging、4-level paging、5-level paging。我们可以将其做粗略划分,前两种模式是应用于32位平台,后两种模式是应用于64位平台。
因为现在32位的机器已经很少了,所以前两种模式我们就不再介绍,而后两种模式的实现机制基本上也是相同的,只是各自支持的虚拟地址空间范围和物理地址空间范围不同。
对于64位的平台来说,4-level paging支持256TiB的虚拟地址空间,以及64TiB的物理地址空间,5-level paging支持128PiB的虚拟地址空间,以及4PiB的物理地址空间。
由上可见,4-level paging支持的内存空间对我们来说已经足够用了,现实生活中很少会用到5-level paging,5-level paging只是对未来的一个预期及扩展。
所以下面我们主要讨论4-level paging。
0x04 4-level paging
为了使以下分析便于理解,我花了很长长长的时间画了下图:
我先来简单介绍下,最上面的64位地址表示的是要被转换的虚拟地址,中间最左边的是cr3寄存器,用于存放PML4 table的物理地址,接着的四个矩形就是组成hierarchical paging structures的四层结构,最右面的绿色区域描述的就是最终物理地址的计算规则,该图的最下面是hierarchical paging structures的四层结构里,每个table的各个entry编码规则。
当操作系统创建一个进程时,也会为该进程创建属于它的hierarchical paging structures,所谓的hierarchical paging structures,就是由一系列的4KiB大小的内存page组成的一个树形结构,对于4-level paging来说,这个树形结构有4层,分别对应到上图中的 page map level 4 table、page directory pointer table、page directory、page table。
每层的每个table中都有512个8字节大小的entry,每个entry又指向下一层中的一个table,这样层层关联,就形成了一个4层树形结构。
hierarchical paging structures的第一层只会有一个table,其他层可有多个。
当操作系统的调度程序将该进程设置为运行状态时,会把它的hierarchical paging structures的第一层的table,对于4-level paging来说就是PML4 table,的物理地址放到cr3寄存器中,这样当cpu在执行程序代码并遇到虚拟地址时,就会自动执行以下步骤,将虚拟地址转换成物理地址。
1. 根据cr3寄存器中的PML4 table的物理地址,找到PML4 table,该table是由512个8字节大小的entry组成的4KiB大小的page,我们也可以将其理解成是一个长度为512的64位整形的数组。
2. 将虚拟地址中的47:39位(从0开始,以下如果没有特殊说明,用于表示bit位置的数字都是从0开始)组成的数字作为PML4 table的索引,并找到其对应的entry(简写为PML4E),该entry中存放着下一层的page directory pointer table的物理地址。
3. 根据PML4E中的物理地址,我们找到第二层中的一个page directory pointer table,该table也是一个由512个8字节大小的entry组成的4KiB page,或者说是长度为512的64位整形的数组。
4. 接着用虚拟地址中的38:30位作为索引,定位到page directory pointer table中的一个entry。
5. 依次往复,我们最终通过虚拟地址中的20:12位,定位到了第四层的一个page table的entry,简写为 PTE。
6. 在PTE中,我们拿出其中51:12位,作为最终物理地址的51:12位,然后从虚拟地址中拿出剩下11:0位,作为最终物理地址的11:0位,这样我们就得到了一个总长度为52位的物理地址,cpu会拿着这个物理地址去到对应的内存中存取数据。
这就是4-level paging的虚拟地址转换过程,5-level paging的转换过程和4-level paging的基本类似,区别就是在cr3寄存器和PML4 table之间多加了一层PML5 table,然后虚拟地址中,用56:48这9个bits做为该table的索引。
0x05 4-level paging中的物理地址
由上图可见,4-level paging在做虚拟地址到物理地址的转换过程中,不管是cr3,还是各个entry,存放的都是下一层级中的一个table的真实的物理地址,且包括最终的物理地址,长度都是52位,而并不是虚拟地址中使用的48位。
那为什么是52位呢?
其实确切来说应该是最大52位,不同硬件平台可以自己选择支持多少位,这个规范可以从AMD官方文档
以及Intel的官方文档
看到。
再参考linux内核文档的 5level-paging (在文章最后的参考资料中有具体网址),我们可以确切得知,4-level paging的有效虚拟地址是48位,有效物理地址是46位,5-level paging的有效虚拟地址是57位,有效物理地址是52位,这个在上面的4-level paging和5-level paging的虚拟和物理地址空间范围的讨论中也有提到过。
0x06 4-level paging中的虚拟地址
再来看4-level paging中的虚拟地址,由上面分析可知,其有效虚拟地址只有48位(47:0),那63:48的地址位存放的是什么呢?
由AMD文档
及Intel文档
可知,虚拟地址的63:48位存放的是47位的sign extension,也就是说,如果虚拟地址的47位是0,则63:48位必须是0,如果47位是1,则63:48位必须是1,这个格式被称为 canonical address form。
0x07 我们能用虚拟地址干些什么
既然我们知道了虚拟地址的编码格式,那我们可以用它来干些什么呢?
回到文章最开始的地方,我们知道c语言中,地址被存到了指针类型的变量里,我们通过对指针进行操作,间接的对其指向的内存进行了操作。
既然我们知道了,指针中存放的地址的高16位 (63:48) 有canonical address form 这种规则,那我们就可以利用这种规则,在这16位中存放一些我们自己的数据,比如该指针对应的数据类型,当我们需要使用该指针时,我们再根据指针地址中的47位,将整个地址恢复成其原来的canonical address form。
很好,但别急,还有更好的。
其实虚拟地址中的47位我们也可以使用,也就是说,虚拟地址中的63:47都可以用来存储我们自己的数据。
为什么呢?
我们知道,每个进程都有自己单独的虚拟地址空间,对于64位地址来说,就是从0x0000 0000 0000 0000到0xffff ffff ffff ffff的地址范围。
我们还知道,在该地址空间内,不仅有我们的用户程序,还有内核代码(是的,内核代码也映射到了用户进程的虚拟地址空间)。
根据上面定义的虚拟地址的canonical address form,内核将虚拟地址空间进一步划分:
其把虚拟地址的47位为0的地址空间划给了用户代码,而47位为1的地址空间划给了内核代码。
而我们写的程序肯定是在用户空间,所以,我们程序中能用到的虚拟地址的63:47位肯定为0。
这就是为什么我们可以使用虚拟地址中63:47这高17位的原因。
其实该技巧已经在很多 JIT compiler 中被使用到了。
0x08 这种技巧不会和将来的 5-level paging冲突吗
我们上文讲过,5-level paging是会使用虚拟地址的56:48位作为PML5 table的索引,那如果我们像上面描述的那样,在程序中使用虚拟地址的高17位来存储我们的数据,那5-level paging来临时,我们的程序岂不是都不可用了?
这个大可放心,写内核的大神们早已经帮我们想好了兼容方式
简单来说就是默认情况下,内核不会分配47位及其以上的虚拟地址空间给用户,除非用户指定要求,完美。
0x09 虚拟地址的意义
聊了这么多,那虚拟地址存在的意义是什么呢?为什么不直接使用物理地址呢?
好处非常多,我简单说几个吧。
比如进程间的内存隔离,因为你访问的任何虚拟地址都是你自己进程的地址,这样即使有恶意进程,也不会破坏其他进程的数据。
比如物理内存的按需分配,你要操作系统给你分配内存,其实它是只分配了虚拟地址空间,真正的物理内存分配是要等到你使用时才会触发。
比如共享相同的内核代码,以及共享库代码,这样这些共用的代码就只占用一份内存,他们会以映射到进程虚拟地址空间的方式,供用户进程使用。
0x0a 结束语
计算机世界的知识就是这么庞大与繁杂,一个小小的地址就能牵扯出这么多的学问。
很多人在面对这份繁杂时就已望而却步,很多人投入其中但因为频频受挫也中途退出,但还有一部分人,他们依旧努力坚持并苦中做乐,坚信他们肯定能等来黑夜的黎明。
谁说做技术做学问就不是星辰大海了,我们选择了这条路并扬帆启航,虽然孤独,虽然黑暗,但有信念相陪,有星辰作伴,我们一定可以看到黎明,到达彼岸。
0x0b 参考资料
Intel® 64 and IA-32 Architectures Software Developer’s Manual
AMD64 Architecture Programmer’s Manual
https://lwn.net/Articles/106177/
https://lwn.net/Articles/717293/
https://www.kernel.org/doc/html/latest/x86/x86_64/5level-paging.html