x86_64内存寻址 - 一个完整的例子
之前的章节中了解了逻辑地址通过分段到线性地址的转换,线性地址通过分页到物理地址的转换,在这个章节讲以linux的一个内存寻址将之前的知识写成一个完整的例子。在学习的过程中,学习的知识不是很重要,我了解一个知识,我喜欢先学习使用方式,然后学习理论知识,最后我会完整的将学习的理论知识将知识应用到实际中,验证理论知识的正确性。
这个章节很长,首先提供一个逻辑地址(虚拟地址)如何从分段机制中得到线性地址,然后线性地址如何映射到真正的物理地址,最后处理器通过这个物理地址进行寻址。而且我们会区分内核空间和用户空间的寻址。我们提供一个虚拟地址 0xffffffff81bd6b60。那么,处理器是如何找到对应在物理内存中的地址呢?下面就让我们慢慢揭开这个面纱。
这个章节基于x86_64架构的处理器运行。
逻辑地址转换为线性地址
其实,在之前的知识中我们知道在x86_64架构,处理器已经弱化了段寄存器的地位,通常我们访问的虚拟地址都被处理器转换为相同的物理地址,但在操作系统的实现中,仍然需要提供段描述符。如果需要寻址一个代码数据,那么在处理器默认使用的是 CS 段寄存器,我们看看 `CS`的内容
(gdb) info registers cscs 0x10 16
我们将这个数据解析成处理器理解的数据结构:
(gdb) gdt-tool print-selector 0x10Index: 0x2Table indicator(TI): 0x0Requested Privilege Level(RPL): 0x0
`Table indicator(TI)`说明代码段寄存器使用的是GDT描述符表,RPL为0,查找在GDT描述表的索引位置为2,实际上,处理器将这个索引乘以8,得到的是16,处理器将从这个位置获取8个字节的代码段描述符。
下面我们详细查看一下Linux系统中段描述符表的数据内容。这里我们使用的qemu模拟处理器的运行,得到gdt地址,将这个地址的128字符输出成二进制
这一串慕名奇妙的二进制数据表示什么意思呐(在计算机中本质上就是二进制,但上下文赋予了这些数据不同的含义)。按照Intel文档中对段描述符表进行解析后
NULL [0]: {limit = 0x00000000, base = 0x00000000, type = 0x00, s = 0x0, dpl = 0x0, p = 0x0, avl = 0x0, l = 0x0, d = 0x0, g = 0x0}GDT_ENTRY_KERNEL32_CS [1]: {limit = 0xffffffff, base = 0x00000000, type = 0x0b, s = 0x1, dpl = 0x0, p = 0x1, avl = 0x0, l = 0x0, d = 0x1, g = 0x1}GDT_ENTRY_KERNEL_CS [2]: {limit = 0xffffffff, base = 0x00000000, type = 0x0b, s = 0x1, dpl = 0x0, p = 0x1, avl = 0x0, l = 0x1, d = 0x0, g = 0x1}GDT_ENTRY_KERNEL_DS [3]: {limit = 0xffffffff, base = 0x00000000, type = 0x03, s = 0x1, dpl = 0x0, p = 0x1, avl = 0x0, l = 0x0, d = 0x1, g = 0x1}GDT_ENTRY_DEFAULT_USER32_CS [4]: {limit = 0xffffffff, base = 0x00000000, type = 0x0b, s = 0x1, dpl = 0x3, p = 0x1, avl = 0x0, l = 0x0, d = 0x1, g = 0x1}GDT_ENTRY_DEFAULT_USER_DS [5]: {limit = 0xffffffff, base = 0x00000000, type = 0x03, s = 0x1, dpl = 0x3, p = 0x1, avl = 0x0, l = 0x0, d = 0x1, g = 0x1}GDT_ENTRY_DEFAULT_USER_CS [6]: {limit = 0xffffffff, base = 0x00000000, type = 0x0b, s = 0x1, dpl = 0x3, p = 0x1, avl = 0x0, l = 0x1, d = 0x0, g = 0x1}Unused [7]: {limit = 0x00000000, base = 0x00000000, type = 0x00, s = 0x0, dpl = 0x0, p = 0x0, avl = 0x0, l = 0x0, d = 0x0, g = 0x0}GDT_ENTRY_TSS [8]: {limit = 0x00004087, base = 0xfffffe0000003000, type = 0x0b, dpl = 0x0, p = 0x1, zero0 = 0x0, g = 0x0, zero1 = 0x0}GDT_ENTRY_LDT [10]: {limit = 0x00000000, base = 0x0000000000000000, type = 0x00, dpl = 0x0, p = 0x0, zero0 = 0x0, g = 0x0, zero1 = 0x0}GDT_ENTRY_TLS_MIN [12]: {limit = 0x00000000, base = 0x00000000, type = 0x00, s = 0x0, dpl = 0x0, p = 0x0, avl = 0x0, l = 0x0, d = 0x0, g = 0x0}GDT_ENTRY_TLS [13]: {limit = 0x00000000, base = 0x00000000, type = 0x00, s = 0x0, dpl = 0x0, p = 0x0, avl = 0x0, l = 0x0, d = 0x0, g = 0x0}GDT_ENTRY_TLS_MAX [14]: {limit = 0x00000000, base = 0x00000000, type = 0x00, s = 0x0, dpl = 0x0, p = 0x0, avl = 0x0, l = 0x0, d = 0x0, g = 0x0}GDT_ENTRY_CPUNODE [15]: {limit = 0x00000000, base = 0x00000000, type = 0x05, s = 0x1, dpl = 0x3, p = 0x1, avl = 0x0, l = 0x0, d = 0x1, g = 0x0}
在`x86_64` 架构体系中,Linux使用了16个段描述符,第一个位空描述符,这个是Intel要求的,后面的描述符就是Linux系统本身的设计需要了,我们在上文中知道代码段寄存器(CS)选择子的索引值为2,索引处理器在此应用是是 GDT_ENTRY_KERNEL_CS 段描述符
GDT_ENTRY_KERNEL_CS [2]: {limit = 0xffffffff, base = 0x00000000, type = 0x0b, s = 0x1, dpl = 0x0, p = 0x1, avl = 0x0, l = 0x1, d = 0x0, g = 0x1}
由于这个段描述符中S被置位,所以这个段是代码段或数据段,根据代码段和数据段类型表
在这里Type的二进制为 1011,可以知道 CS 段选择子访问的是一个代码段,可执行可读可访问的段,也就是我们所说的代码。有于段选择子的RPL为0,而段描述符中的DPL也是0,也就是说有权限访问。由于标志L被置位了,说明这个段执行的是64位模式代码。同时P位也被置位了,说明访问的数据在内存中。在这里由于G被置位,说明段大小为4G,基址为0,说明Linux采用的是平坦模式,逻辑地址(虚拟地址)等于线性地址,虽然这里提供了limit,但在64位模式下的CPU通常会忽略段大小校验。
所以经过分段地址转换,我们计算出了访问内存的线性地址,在开始计算分页转换前,我们先将线性地址转换为各个分页的分级索引。假设将虚拟地址 0xffffffff81bd6b60,在开始这个分割之前,我们先了解一下Linux的虚拟内存地址布局
======================================================================================================================== Start addr | Offset | End addr | Size | VM area description ======================================================================================================================== | | | | 0000000000000000 | 0 | 00007fffffffffff | 128 TB | user-space virtual memory, different per mm __________________|____________|__________________|_________|___________________________________________________________ | | | | 0000800000000000 | 128 TB | ffff7fffffffffff | ~16M TB | ... huge, almost 64 bits wide hole of non-canonical | | | | virtual memory addresses up to the -128 TB | | | | starting offset of kernel mappings. __________________|____________|__________________|_________|___________________________________________________________ | | Kernel-space virtual memory, shared between all processes: ____________________________________________________________|___________________________________________________________ | | | | ffff800000000000 | -128 TB | ffff87ffffffffff | 8 TB | ... guard hole, also reserved for hypervisor ffff880000000000 | -120 TB | ffff887fffffffff | 0.5 TB | LDT remap for PTI ffff888000000000 | -119.5 TB | ffffc87fffffffff | 64 TB | direct mapping of all physical memory (page_offset_base) ffffc88000000000 | -55.5 TB | ffffc8ffffffffff | 0.5 TB | ... unused hole ffffc90000000000 | -55 TB | ffffe8ffffffffff | 32 TB | vmalloc/ioremap space (vmalloc_base) ffffe90000000000 | -23 TB | ffffe9ffffffffff | 1 TB | ... unused hole ffffea0000000000 | -22 TB | ffffeaffffffffff | 1 TB | virtual memory map (vmemmap_base) ffffeb0000000000 | -21 TB | ffffebffffffffff | 1 TB | ... unused hole ffffec0000000000 | -20 TB | fffffbffffffffff | 16 TB | KASAN shadow memory __________________|____________|__________________|_________|____________________________________________________________ | | Identical layout to the 56-bit one from here on: ____________________________________________________________|____________________________________________________________ | | | | fffffc0000000000 | -4 TB | fffffdffffffffff | 2 TB | ... unused hole | | | | vaddr_end for KASLR fffffe0000000000 | -2 TB | fffffe7fffffffff | 0.5 TB | cpu_entry_area mapping fffffe8000000000 | -1.5 TB | fffffeffffffffff | 0.5 TB | ... unused hole ffffff0000000000 | -1 TB | ffffff7fffffffff | 0.5 TB | %esp fixup stacks ffffff8000000000 | -512 GB | ffffffeeffffffff | 444 GB | ... unused hole ffffffef00000000 | -68 GB | fffffffeffffffff | 64 GB | EFI region mapping space ffffffff00000000 | -4 GB | ffffffff7fffffff | 2 GB | ... unused hole ffffffff80000000 | -2 GB | ffffffff9fffffff | 512 MB | kernel text mapping, mapped to physical address 0 ffffffff80000000 |-2048 MB | | | ffffffffa0000000 |-1536 MB | fffffffffeffffff | 1520 MB | module mapping space ffffffffff000000 | -16 MB | | | FIXADDR_START | ~-11 MB | ffffffffff5fffff | ~0.5 MB | kernel-internal fixmap range, variable size and offset ffffffffff600000 | -10 MB | ffffffffff600fff | 4 kB | legacy vsyscall ABI ffffffffffe00000 | -2 MB | ffffffffffffffff | 2 MB | ... unused hole __________________|____________|__________________|_________|___________________________________________________________
从这个虚拟地址,从内存布局中知道这个地址是一个存放内核代码的地址,内核将内核代码映射到虚拟内存 0xffffffff80000000 开始的2G空间。另外我们还需注意 0xffff888000000000 开始的虚拟地址空间,这个虚拟地址空间大小为119.5G,用于直连映射到物理地址,这个机制为我们后续的分析提供了一些便利设施。比如我们想访问物理地址为 0x1000000,那么我们直接通过虚拟地址 0xffff888001000000 访问内存即可。
另外还需要注意的是,Intel的处理器已经支持5级分页,支持更大的内存空间,但在这里我们的模拟环境只是一个4级分页的模拟处理器而已。
那么,进入正题,我们如何将虚拟地址 0xffffffff81bd6b60 划分为各级分页的索引呢,64位系统支持4K,2M,1G的内存分页,这个地址是处在分页大小是多少的内存空间呢,我们现在并不知道这个地址是处在分页大小为多少的空间,我们先按照4K的分页换分分页索引。将之前64位4K的索引处理重新看一下,在Linux的划分如下
47-------38 39-------30 29-------21 20-------12 11-------0 PDG PUD PMD PTE offset
这是一个支持4级映射的划分,在Linux页支持5级分页,第五级分页叫做p4d,这里我们忽略。我么按照这个规则处理后如下,
pgd_index = 511, pud_index = 510, pmd_index= 013, pte_index = 470, offset = 001d6b60
通过使用GDB查找cr3寄存器的物理地址,我们知道 CR3 寄存器中存放了第一个PGD的物理地址
(gdb) info registers cr3cr3 0x2610000 [ PDBR=1 PCID=0 ]
我们需要将 CR3`的低12位屏蔽,得到了地址 `0x2610000,这个地址就是PDG表的存放位置,PGD表是存放PDG项的内存空间,是一个每项为8个字节的数组,如果想访问这个地址在保护模式下,我们是无法直接使用物理地址访问内存数据的,但前面说到`0xffff888000000000` 开始的虚拟地址空间映射到物理空间,我们可以根据这个规则访问对应的物理内存,查看物理内存中的内容。这样访问虚拟地址 0xffff888002610000 实际上访问的就是物理内存`0x2610000`的数据。
(gdb) x/8bx ((pgd_t *)0xffff888002610000 511 )0xffff888002610ff8: 0x67 0x50 0x61 0x02 0x00 0x00 0x00 0x00
在这里,我们使用pdg_index加到物理地址上,(pgd_t *) 是一个8字节的指针,加上511实际地址为 0xffff888002610000 511 * 8。由于Intel处理器是小端序,所以这8个字节的实际数据为 0x0000000002615067。这个数据将低12位屏蔽,得到地址为 0x2615000。这个物理地址就是PUD表的基址。同样的我们访问对应的数据,根据索引pud_index获得PUD项。
(gdb) x/8bx ((pgd_t *)0xffff888002615000 510)0xffff888002615ff0: 0x63 0x60 0x61 0x02 0x00 0x00 0x00 0x00
这个表项中 PS 没有被置位,所以这个表项指向PMD表,这个地址的数据获得对应的PMD表的基址 0x2616000,根据pmd_index获得PMD项
(gdb) x/8bx ((pgd_t *)0xffff888002616000 13)0xffff888002616068: 0xe3 0x01 0xa0 0x01 0x00 0x00 0x00 0x00
我们根据这个表项值 0x0000000001a001e3,由于 PS 被置位,说明这个表指向的是页大小的位2M的页,那么剩下的线性地址的21位作为偏移量,处理器将根据页表中的项决定是否有权限访问等额外的工作。
0xffffffff81bd6b60 & 0x1fffff = 0x1d6b60
同时这个表项的基址为 0x1a00000,将基址加上偏移量就是最终的物理地址
0x1a00000 0x1d6b60 = 0x1bd6b60
最终的结果 0x1bd6b60 就是虚拟地址 0xffffffff81bd6b60 访问的物理地址为 0x1bd6b60。
写个一个小工具,解释这个过程,这个工具根据内核地址翻译成中间的页表转换过程
(gdb) hack-tool print-x86_64-gpt 0xffffffff81bd6b60pdg: 0x2615000cr3(pgt) => pud: [cr3: 0x2610000, index: 511] => [pud : 0x2615000]pud => pmd: [pud: 0x2615000, index: 510] => [pmd : 0x2616000],? page_size=2G : Falsepud => pmd: [pmd: 0x2616000, index: 13] => [pa : 0x1bd6b60] | ? page_size=2G : Trueva -> pa: ffffffff81bd6b60 => 1bd6b60
(gdb) hack-tool print-x86_64-gpt 0xffffffff81bd6b60pdg: 0x2615000cr3(pgt) => pud: [cr3: 0x4a9e000, index: 511] => [pud : 0x2615000]pud => pmd: [pud: 0x2615000, index: 510] => [pmd : 0x2616000],? page_size=2G : Falsepud => pmd: [pmd: 0x2616000, index: 13] => [pa : 0x1bd6b60] | ? page_size=2G : Trueva -> pa: ffffffff81bd6b60 => 1bd6b60
这两个例子,第一个是系统启动过程中进行断点调试的打印结果,第二个是系统已经启动进行断点调试的打印结果,可以看到虽然CR3寄存器不一样,但相同的内核地址映射到相同的物理地址。CR3寄存器之所以不一样是因为Linux系统的复制机制,CR3寄存器通常和进程绑定,一个进程会有自己的页表,但系统统一为进程提供了内核相同的布局,我们可以理解为进程虽然有自己的用户内存空间,但使用相同视图的内核空间。
总结
上文就是一个虚拟地址转换为物理地址的过程,在这个过程我们应该意识到分段转换为线性地址,在64位中,虽然处理器弱化了段的作用,但仍提供了使用的方式。处理器也强化了分页的机制,分页为现代操作系统支持虚拟内存提供了坚实的基础。