转译表的二进制格式

在后续讨论中,我们只考虑传统ARM中的MMU。

从物理地址 0x10004000 到 0x10007FFF,共有 0x1000 (4096)个节描述符,每个描述符为32位(4字节)。它们该如何使用呢?

在打开MMU之后,程序计数器和所有的CPU访问都在虚拟地址上进行,所以转译会如下工作:在访问总线之前,将一个虚拟地址转译成物理地址。

由虚拟地址决定物理地址的方法如下:

利用虚拟地址的31-20比特作为索引,查找转译表中的某个32位节描述符。

  • 负责转译虚拟内存中的地址 0x00000000-0x000FFFFF (第一个1MB)的索引位于 0x10004000-0x10004003处,是转译表的第一个四字节,称为索引 0 。

  • 负责转译虚拟地址 0x00100000-0x0001FFFF 的是位于 0x10004004-0x10004007 处的索引1,所以索引编号乘以4就是描述符的字节地址。

  • ……

  • 虚拟地址 0xFFF00000-0xFFFFFFFF 由位于 0x10003FFC-0x10003FFF 处的索引 0xFFF 转译。

真聪明。0x4000 (16KB)的内存正好能够跨越32位,即4GB的内存空间。所以使用这个MMU表,我们可以将任意1MB的虚拟内存块映射到1MB的物理内存块上。这并不是巧合。

这就是说,如果内核的虚拟基址为 0xC0000000,那么表的索引将是 0xC0000000 >> 20 = 0xC00,由于索引需要乘以4,所以实际的字节索引为 0xC00 * 4 = 0x3000,所以在物理地址 0x10004000 + 0x3000 = 0x10007000 的地方就能找到内核空间内存的第一个 1MB 的节描述符。

我们使用的32位 1MB节描述的格式大致如下:

07_映射 - 图1

MMU 会查看比特0和比特1,其值“10”表示这是一个节映射。然后我们给其他比特设置一些默认值。除此之外,我们真正关心的只有将比特31-20设置为正确的物理地址,而且我们还有一个可用的节描述符。这就是代码的内容。

对于LPAE而言,情况有点不一样:我们使用64位的节描述符(8字节),但同时,节的大小是2MB而不是1MB,所以最终转译表的大小正好是0x4000字节。

MMU启用代码中的全等映射

首先我们要围绕符号 _turnmmu_on 创建一个全等映射,表示这段代码将在一段物理地址和虚拟地址1:1映射过的内存上执行。如果代码位于 0x10009012,那么代码的虚拟地址也是 0x10009012。如果查看这段代码就会发现,它被放在一个单独的名为 .idmap.text 的节中。创建单独的节的意思是,这个节会连接到一个单独的物理页上,这个页中没有任何其他东西,所以映射的 1MB 完全供这段代码使用(甚至可能有两个 1MB,如果正好跨越节的边界的话),所以全等映射是专门为这段代码准备的。

仔细考虑一下就会发现这样做很巧妙,即使跨越 2MB 也是如此:如果像本例一样,将内核加载到 0x10000000,那么代码就会位于比如 0x10000120 处,还有一个位于同样地址的全等映射。这不会干扰到位于 0xC0000000 处的内核,或者在极端的内核内存分割的情况下,也不会干扰到位于 0x40000000 处的内核。如果有人想把物理内存的起始点放在比如 0xE0000000 处,就会造成大问题。我们希望不会发生这种情况。

映射其余部分

接下来我们创建主要部分的物理到虚拟内存的映射,从物理内存的 PHYS_OFFSET 处(在前面“在哪里执行”一节中介绍过的 r8 中保存的值)和虚拟内存中的 PAGE_OFFSET (编译时的常量)开始,接下来每次移动一页,直到到达虚拟内存中的 _end 符号。该符号位于内核目标代码中的 .bss 节的末尾。

07_映射 - 图2

在引导的前期,初始页表 swapper_pg_dir 和1:1映射过的仅包含一页的节 _turnmmu_on,以及物理到虚拟内存的映射。在本例中,我们没有使用LPAE,所以初始页表为 PHYS_OFFSET 中的 -0x4000,内存的末尾为 0xFFFFFFFF。

BSS指的是二进制内核在内存中的最后一节,C编译器会在此处设置所有运行时变量的位置。该节的地址已经定义好,但没有二进制数据:其内存包含未定义的内容(即在映射时其中包含的任何内容)。

这段汇编循环值得好好学习一下,才能理解其中的映射代码如何工作:

  ldr    r7, [r10, #PROCINFO_MM_MMUFLAGS] @ mm_mmuflags


    (...)


    add    r0, r4, #PAGE_OFFSET >> (SECTION_SHIFT - PMD_ORDER)


    ldr    r6, =(_end - 1)


    orr    r3, r8, r7


    add    r6, r4, r6, lsr #(SECTION_SHIFT - PMD_ORDER)


1:  str    r3, [r0], #1 << PMD_ORDER


    add    r3, r3, #1 << SECTION_SHIFT


    cmp    r0, r6


    bls    1b

我们来逐步看看。我们假设本例使用的是非LPAE的传统ARM的MMU(你可以认为同样的分析对于LPAE也成立):

add    r0, r4, #PAGE_OFFSET >> (SECTION_SHIFT - PMD_ORDER

r4 包含页表(PGD或PMD)的物理地址,我们将在那里设置节。(SECTION_SHIFT - PMD_ORDER) 会解析成 (20 - 2) = 18,所以执行 PAGE_OFFSET 0xC0000000 >> 18 = 0x3000,正好是转译表中 0xC0000000 的绝对索引,跟我们前面看到的一样。这也正常,因为索引是4字节的。所以每当我们看到 (SECTION_SHIFT - PMD_ORDER) 就知道它的意思是“转化成该虚拟地址在转译表中的绝对索引”,在本例中其值为 0x10003000。

所以第一条语句会在 r0 中生成内核空间内存的第一个32位节描述符的物理地址。

ldr    r6, =(_end - 1)

r6 显然被设置成内核空间内存的最末尾。

  ldr    r7, [r10, #PROCINFO_MM_MMUFLAGS] @ mm_mmuflags


    (...)


    orr    r3, r8, r7

r8 包含 PHYS_OFFSET,本例中为 0x10000000 (我们依赖于比特19-0均为零),然后将其与 r7 进行 OR 操作,后者表示MMU的标志,每个CPU有不同的定义,位于 arch/arm/mm/proc-*.S 中。每个文件都包含一个特殊的节,名为 .proc.info.init,位于索引 PROCINFO_MM_MMUFLAGS (其值大致是 0x08 这样)处是 OR 的右值,这样就可以得到我们所用的CPU对应的节描述符。这个结构体本身的名称为 struct proc_info_list,可以在 arch/arm/include/asm/procinfo.h 中找到。由于汇编无法真正处理C结构体,所以需要使用一些索引技巧才能得到这个魔术数字。

所以,节描述符的物理地址位于比特31-20,r7 中的值会设置更多的比特(如最低两比特),所以MMU就能正确处理节描述符。

add    r6, r4, r6, lsr #(SECTION_SHIFT - PMD_ORDER)

这一行会为我们映射的内存的最后一MB构建节描述符的绝对物理索引地址。我们不会在 r7 上执行操作,只是用它作为循环的比较,而不会真正写入转译表,所以不需要。

现在 r0 是我们设置好的第一个节描述符的物理地址,r6是我们将要设置的最后一个节描述符的物理地址。接下来进入循环:

1:  str    r3, [r0], #1 << PMD_ORDER

    add    r3, r3, #1 << SECTION_SHIFT


    cmp    r0, r6


    bls    1b

这段代码将第一个节描述符写入MMU表中的 r0 指向的地址,在本例中从 0x10003000处开始。然后在循环末尾给 r0 中的地址增加 ( 1<< PMD_ORDER),本例中为4。然后给描述符的物理地址部分(比特20及以上)增加1MB,即 (1 << SECTION_SHIFT),并检查是否到达最后一个描述符,否则返回 1: 处继续循环。

这样就能建立整个内核(包括所有段和 .bss 的1MB块)的虚拟内存到物理内存的映射。