UP | HOME

虚拟内存

Table of Contents

现在,RPi OS可以运行和调度用户进程,但它们之间的隔离并不完整:所有进程和内核本身共享同一块内存。这使得任何进程都可以轻易地访问其他进程甚至内核数据

即使假设所有的进程都不是恶意的,仍然存在另一个缺点:在分配内存之前,每个进程都需要知道哪些内存区域已被占用

这使得进程的内存分配变得更加复杂

地址转换

接下来将通过引入 虚拟内存 来解决上述所有问题。虚拟内存为每个进程提供了一个抽象,使其认为它占用了所有可用的内存。每当一个进程需要访问某个内存位置时,它使用虚拟地址,该地址会被转换为物理地址。转换的过程完全对进程透明,由一个特殊设备进行:*MMU*( 内存映射单元 )。MMU使用 转换表 来将虚拟地址转换为物理地址。转换的过程如下图所示:

			   Virtual address                                                                 Physical Memory
+-----------------------------------------------------------------------+                                +------------------+
|         | PGD Index | PUD Index | PMD Index | PTE Index | Page offset |                                |                  |
+-----------------------------------------------------------------------+                                |                  |
63        47     |    38      |   29     |    20    |     11      |     0                                |     Page N       |
		 |            |          |          |             +--------------------+           +---->+------------------+
		 |            |          |          +---------------------+            |           |     |                  |
	  +------+            |          |                                |            |           |     |                  |
	  |                   |          +----------+                     |            |           |     |------------------|
+------+  |        PGD        |                     |                     |            +---------------->| Physical address |
| ttbr |---->+-------------+  |           PUD       |                     |                        |     |------------------|
+------+  |  |             |  | +->+-------------+  |          PMD        |                        |     |                  |
	  |  +-------------+  | |  |             |  | +->+-------------+  |          PTE           |     +------------------+
	  +->| PUD address |----+  +-------------+  | |  |             |  | +->+--------------+    |     |                  |
	     +-------------+  +--->| PMD address |----+  +-------------+  | |  |              |    |     |                  |
	     |             |       +-------------+  +--->| PTE address |----+  +-------------_+    |     |                  |
	     +-------------+       |             |       +-------------+  +--->| Page address |----+     |                  |
				   +-------------+       |             |       +--------------+          |                  |
							 +-------------+       |              |          |                  |
									       +--------------+          +------------------+

以下事实对于理解这个图表和内存转换过程非常重要:

  • 进程的内存总是以 页面 为单位分配的。页面是一个连续的内存区域,大小为 4KB

    ARM处理器支持更大的页面,但4KB是最常见的情况,这里将限制讨论在这个页面大小上
    
  • 页表 具有 分层 结构。在任何一个表中的项目包含了层次结构中下一个表的地址
  • 表层次结构中有4个级别:
    • PGD : 页全局目录 (Page Global Directory)
    • PUD : 页上级目录 (Page Upper Directory)
    • PMD : 页中间目录 (Page Middle Directory)
    • PTE : 页表项 (Page Table Entry) PTE是层次结构中的最后一个表,它指向 物理内存 中的 实际页面
  • 内存转换过程从 定位 PGD (页全局目录)表的地址开始。该表的地址存储在 ttbr0_el1寄存器
  • 每个进程都有自己的所有页表的副本 ,包括 PGD ,因此每个进程都必须保持其PGD地址。在 上下文切换 期间,将 下一个进程PGD地址 加载ttbr0_el1寄存器
  • 然后, MMU 使用PGD指针和虚拟地址计算相应的物理地址。所有虚拟地址仅使用64位中的 48位 。在进行转换时,MMU将地址分为4个部分:

    • 位[39-47] : 包含 PGD表中的索引

      MMU使用此索引查找PUD的位置
      
    • 位[30-38] : 包含 PUD表中的索引

      MMU使用此索引查找PMD的位置
      
    • 位[21-29] : 包含 PMD表中的索引

      MMU使用此索引查找PTE的位置
      
    • 位[12-20] : 包含 PTE表中的索引

      MMU使用此索引在物理内存中找到一个页面
      
    • 位[0-11] : 包含 物理页面中的偏移量

      MMU使用此偏移量确定在之前找到的页面中与原始虚拟地址对应的确切位置 
      
    现在,让我们进行一个小练习,计算页表的大小
    
    从上面的图表中,知道页表中的索引占据9位(对于所有的页表级别都是如此): 这意味着每个页表包含2^9 = 512个条目
    
    每个页表中的条目是层次结构中下一个页表或者PTE情况下的物理页面的地址。由于使用的是64位处理器,每个地址必须是64位或8字节大小
    
    将所有这些放在一起,我们可以计算出一个页表的大小必须是512 * 8 = 4096字节或4 KB。这正是一个页面的大小!
    
    这可能会让你对为什么MMU设计者选择这样的数字产生直觉
    

区段映射

有时候需要映射连续的大内存区域。在这种情况下,可以直接映射2 MB大小的块,称为 区段 。这样可以省去一级的地址转换。在这种情况下,转换图如下所示:

			   Virtual address                                               Physical Memory
+-----------------------------------------------------------------------+              +------------------+
|         | PGD Index | PUD Index | PMD Index |      Section offset     |              |                  |
+-----------------------------------------------------------------------+              |                  |
63        47     |    38      |   29     |    20            |           0              |    Section N     |
		 |            |          |                  |                    +---->+------------------+
		 |            |          |                  |                    |     |                  |
	  +------+            |          |                  |                    |     |                  |
	  |                   |          +----------+       |                    |     |------------------|
+------+  |        PGD        |                     |       +------------------------->| Physical address |
| ttbr |---->+-------------+  |           PUD       |                            |     |------------------|
+------+  |  |             |  | +->+-------------+  |            PMD             |     |                  |
	  |  +-------------+  | |  |             |  | +->+-----------------+     |     +------------------+
	  +->| PUD address |----+  +-------------+  | |  |                 |     |     |                  |
	     +-------------+  +--->| PMD address |----+  +-----------------+     |     |                  |
	     |             |       +-------------+  +--->| Section address |-----+     |                  |
	     +-------------+       |             |       +-----------------+           |                  |
				   +-------------+       |                 |           |                  |
							 +-----------------+           |                  |
										       +------------------+

这里的区别在于 PMD 现在包含指向 物理区段的指针 。另外,偏移量占据了 21位 而不是12位

因为需要21位来编码2MB的范围

页表描述符

可能会问MMU如何知道PMD项目是指向PTE还是物理2MB区段的?

为了回答这个问题,需要更仔细地看一下页表项的结构

页表中的项目称为 描述符 。描述符具有特殊的格式:

			   Descriptor format
`+------------------------------------------------------------------------------------------+
 | Upper attributes | Address (bits 47:12) | Lower attributes | Block/table bit | Valid bit |
 +------------------------------------------------------------------------------------------+
 63                 47                     11                 2                 1           0

关键是要理解每个描述符总是指向对齐的内容(可以是物理页、区段或层次结构中的下一个页表)。这意味着 描述符中存储的地址最后12位 始终为0 。这也意味着MMU可以使用这些位来存储更有用的信息,这正是它所做的

描述符中各位的含义:

  • 位 0 :对于所有 有效 的描述符,此位必须设置为 1

    如果在转换过程中MMU遇到非有效的描述符,将生成同步异常
    
    然后内核应处理此异常,分配一个新页并准备正确的描述符
    
    稍后将详细了解其工作原理
    
  • 位 1 : 指示当前描述符是指向层次结构中的 下一个页表 的描述符(称此类描述符为 表描述符 ),还是指向 物理页或区段 的描述符(称此类描述符为 块描述符
  • 位 [11:2]
    • 对于表描述符,这些位被忽略
    • 对于块描述符:它们包含一些属性,比如控制映射的页 是否可缓存可执行
  • 位 [47:12] :存储描述符指向的 地址

    如前所述,只需存储地址的位 [47:12],因为其他位始终为0
    
  • 位 [63:48] :另一组属性位

页面属性配置

正如在前面的部分中提到的,每个块描述符包含一组属性,用于控制各种虚拟页面的参数

然而,对于我们的讨论来说,最重要的属性并不直接在描述符中配置

相反,ARM处理器实现了一种技巧,可以在描述符属性部分节省一些空间

ARM.v8架构引入了 mair_el1寄存器 。该寄存器由8个部分组成,每个部分都有8个比特位。每个部分配置了一组常用的属性。然后, 描述符 仅指定 mair部分索引 ,而不是直接指定所有属性。这样可以在描述符中仅使用 3个比特位 来引用mair部分

mair部分中每个比特位的含义在AArch64参考手册的第2609页上有描述

在RPi OS中,我们仅使用了一些可用的属性选项

以下是准备mair寄存器值的代码:

/*
 * Memory region attributes:
 *
 *   n = AttrIndx[2:0]
 *            n    MAIR
 *   DEVICE_nGnRnE    000    00000000
 *   NORMAL_NC        001    01000100
 */
#define MT_DEVICE_nGnRnE         0x0
#define MT_NORMAL_NC            0x1
#define MT_DEVICE_nGnRnE_FLAGS        0x00
#define MT_NORMAL_NC_FLAGS          0x44
#define MAIR_VALUE            (MT_DEVICE_nGnRnE_FLAGS << (8 * MT_DEVICE_nGnRnE)) | (MT_NORMAL_NC_FLAGS << (8 * MT_NORMAL_NC))

在这里,只使用了mair寄存器中可用的8个槽位中的2个。第一个对应于 设备内存 ,第二个对应于 普通非缓存内存MT_DEVICE_nGnRnEMT_NORMAL_NC 是将在 块描述符 中使用的 索引 ,而 MT_DEVICE_nGnRnE_FLAGSMT_NORMAL_NC_FLAGS存储mair_el1寄存器 的前两个槽位中的值

内核 VS 用户 虚拟内存

在打开MMU之后,每次内存访问都必须使用虚拟内存而不是物理内存。这个事实的一个结果是 内核本身必须准备好使用虚拟内存并维护自己的页表集合

一种可能的解决方案是每次从用户模式切换到内核模式时重新加载pgd寄存器

但问题是切换pgd是非常昂贵的操作,因为它需要使所有缓存失效

考虑到需要多频繁地从用户模式切换到内核模式,这种解决方案将使缓存完全无效,因此在操作系统开发中从不使用这种解决方案

相反,操作系统的做法是将地址空间分为两个部分: 用户空间内核空间

32位体系结构通常将地址空间的前3GB分配给用户程序,将最后1GB保留给内核

64位体系结构在这方面更加有利,因为它们具有巨大的地址空间

更重要的是:ARM.v8体系结构带有一种原生功能,可以用来轻松实现用户/内核地址分割

有两个寄存器可以保存PGD的地址: ttbr0_el1ttbr1_el1 。前面提到我们只使用了64位地址中的48位,因此上面的16位可以在转换过程中用来区分ttbr0和ttbr1:

  • 如果上面的16位全为0,则使用存储在ttbr0_el1中的PGD地址
  • 如果地址以0xffff开头(前16位全为1),则选择存储在ttbr1_el1中的PGD地址

体系结构还确保在 EL0下运行的进程 永远无法访问以 0xffff开头的虚拟地址 ,否则会 生成 同步异常

从这个描述中,可以轻松推断:

内核PGD的指针存储在ttbr1_el1中,并在内核的整个生命周期中保持不变

而ttbr0_el1用于存储当前用户进程的PGD

这种方法的一个隐藏的结论是所有绝对内核地址必须以 0xffff开头 。在RPi OS源代码中有两个地方处理了这个问题:

  1. 链接脚本 中,将镜像的基地址指定为0xffff000000000000。这会使编译器认为镜像将加载到0xffff000000000000地址,因此无论何时需要生成绝对地址,它都会生成正确的地址

    链接脚本还有一些其他更改,将在后面讨论它们
    
  2. 硬编码了绝对内核基地址:在定义 设备基地址头文件 中。现在,将从0xffff00003F000000开始访问所有设备内存

    当然,为了使其正常工作,首先需要映射内核需要访问的所有内存
    
    下面,将详细探讨创建此映射的代码
    

初始化内核页面表

在引导过程的早期阶段,需要处理 创建 内核页表 的任务。这个过程始于 boot.S 文件。在切换到EL1并清空BSS之后,会调用 create_page_tables 函数。接下来逐行分析这个函数:

__create_page_tables:
        mov    x29, x30                        // save return address

首先,该函数保存了 x30寄存器 (链接寄存器)。因为将从 __create_page_tables 调用其他函数,x30寄存器会被覆盖

通常做法是把 x30寄存器保存在栈上,但是由于:
1. 在__create_page_tables执行期间不会使用递归
2. 没有其他人会使用x29寄存器

因此这种简单的保留链接寄存器的方法也能很好地工作
adrp    x0, pg_dir
mov    x1, #PG_DIR_SIZE
bl     memzero

接下来,清除初始页表区域。在这里需要理解的重要事情是 该区域的位置 以及 如何知道它的大小

  • 初始页表区域在 链接脚本 中定义:这意味着在 内核映像 本身中为该区域分配了位置
  • 计算该区域的大小稍微有些棘手,需要了解初始内核页表的结构:

    • 所有的映射都位于 1 GB 的区域内(这是RPi内存的大小),一个PGD描述符可以覆盖 2^39 = 512 GB ,一个PUD描述符可以覆盖 2^30 = 1 GB 的连续虚拟映射区域

      这些值是根据PGD和PUD索引在虚拟地址中的位置计算得出的
      
      这意味着只需要一个PGD和一个PUD来映射整个RPi内存
      
    • 更重要的是,PGD和PUD都只包含一个描述符。即使只有一个PUD条目,那么也必须有一个单独的PMD表,该条目将指向该表

      单个PMD条目覆盖2 MB,一个PMD中有512个条目,所以整个PMD表覆盖了与单个PUD描述符相同的1 GB内存
      
    • 需要映射1 GB的内存区域,而这是2 MB的倍数 :可以使用 区块映射 。这意味着 根本不需要PTE

      因此,总共需要3个页面:一个用于PGD,一个用于PUD,一个用于PMD
      
      这恰好是初始页表区域的大小
      

    现在暂时先离开 __create_page_tables 函数,看一下两个关键的宏: create_table_entrycreate_block_map

create_table_entry宏

负责 分配 新的页表(可以是PGD或PUD):

.macro    create_table_entry, tbl, virt, shift, tmp1, tmp2
lsr    \tmp1, \virt, #\shift
and    \tmp1, \tmp1, #PTRS_PER_TABLE - 1            // table index
add    \tmp2, \tbl, #PAGE_SIZE
orr    \tmp2, \tmp2, #MM_TYPE_PAGE_TABLE
str    \tmp2, [\tbl, \tmp1, lsl #3]
add    \tbl, \tbl, #PAGE_SIZE                    // next level table page
.endm

这个宏接受以下参数:

  • tbl : 指向需要分配新表的内存区域的指针
  • virt : 当前正在映射的虚拟地址
  • shift : 应用于虚拟地址以提取当前表索引的位移量(对于PGD是39,对于PUD是30)
  • tmp1, tmp2 : 临时寄存器

这个宏非常重要,所以将花一些时间来理解它

lsr    \tmp1, \virt, #\shift
and    \tmp1, \tmp1, #PTRS_PER_TABLE - 1            // table index

前两行负责从 虚拟地址提取 表索引 。首先进行 右移 操作,以 去除 索引右侧的所有位 ,然后使用 与位操作去除 索引左侧的所有位

add    \tmp2, \tbl, #PAGE_SIZE

然后计算下一个页表的地址:

在这里,使用的约定是初始页表都位于一个连续的内存区域中

简单地假设下一个页表在层级结构中将与当前页表相邻
orr    \tmp2, \tmp2, #MM_TYPE_PAGE_TABLE

接下来,将层级中的下一个页表的指针转换为一个表描述符(描述符的低两位必须设置为1)

str    \tmp2, [\tbl, \tmp1, lsl #3]

然后,将 描述符 存储当前页表 中。这里使用之前计算的索引找到表中的正确位置

add    \tbl, \tbl, #PAGE_SIZE                    // next level table page

最后,将 tbl参数 更改指向层次结构中的下一个页表

这一步不是必须的,但如果再次调用create_table_entry来为层次结构中的下一个表分配空间,就无需对tbl参数进行任何调整

这正是create_pgd_entry宏所做的,它只是一个分配PGD和PUD的包装器

create_block_map宏

正如猜测的那样,这个宏负责 填充 PMD表的条目 。代码如下所示:

        .macro    create_block_map, tbl, phys, start, end, flags, tmp1
        lsr    \start, \start, #SECTION_SHIFT
        and    \start, \start, #PTRS_PER_TABLE - 1            // table index
        lsr    \end, \end, #SECTION_SHIFT
        and    \end, \end, #PTRS_PER_TABLE - 1                // table end index
        lsr    \phys, \phys, #SECTION_SHIFT
        mov    \tmp1, #\flags
        orr    \phys, \tmp1, \phys, lsl #SECTION_SHIFT            // table entry
9999:    str    \phys, [\tbl, \start, lsl #3]                // store the entry
        add    \start, \start, #1                    // next entry
        add    \phys, \phys, #SECTION_SIZE                // next block
        cmp    \start, \end
        b.ls    9999b
        .endm

这里的参数略有不同:

  • tbl: 指向PMD表的指针
  • phys: 要映射的物理区域的起始地址
  • start: 要映射的第一个section的虚拟地址
  • end: 要映射的最后一个section的虚拟地址
  • flags: 需要复制到块描述符的低属性中的标志位
  • tmp1: 临时寄存器
lsr    \start, \start, #SECTION_SHIFT
and    \start, \start, #PTRS_PER_TABLE - 1            // table index

这两行代码从 起始虚拟地址提取表索引 。这与之前在create_table_entry宏中所做的方式完全相同

lsr    \end, \end, #SECTION_SHIFT
and    \end, \end, #PTRS_PER_TABLE - 1                // table end index

对结束地址进行相同的操作。现在,start和end都包含了PMD表中对应原始地址的索引,而不是虚拟地址

lsr    \phys, \phys, #SECTION_SHIFT
mov    \tmp1, #\flags
orr    \phys, \tmp1, \phys, lsl #SECTION_SHIFT            // table entry

接下来,会准备并将 块描述符 存储tmp1变量 中。为了准备描述符,首先对 phys参数 进行 右移 ,然后再进行 左移 ,并使用 orr指令flags参数 合并

为什么必须将地址来回移动 ? 答案是:

1. 这样清除了物理地址中的前21位
2. 使宏通用化,可以用于任何地址,而不仅仅是每一段的第一个地址 
9999:    str    \phys, [\tbl, \start, lsl #3]                // store the entry
        add    \start, \start, #1                    // next entry
        add    \phys, \phys, #SECTION_SIZE                // next block
        cmp    \start, \end
        b.ls    9999b

函数的最后部分在一个循环中执行:

  1. 将当前描述符存储在PMD表的正确索引位置
  2. 将当前索引增加1,并更新描述符,使其指向下一个节
  3. 重复这个过程,直到当前索引等于最后一个索引

映射内核和内核堆栈

现在,当你理解了create_table_entry和create_block_map宏的工作原理后,理解__create_page_tables函数的其余部分将变得简单明了
adrp    x0, pg_dir
mov    x1, #VA_START
create_pgd_entry x0, x1, x2, x3

在这里,创建了 PGDPUD 。将它们配置为从 VA_START虚拟地址 开始进行映射。由于create_table_entry宏的语义,当create_pgd_entry完成后,x0将包含层次结构中下一个表的地址,即PMD:

/* Mapping kernel and init stack*/
mov     x1, xzr                            // start mapping from physical offset 0
mov     x2, #VA_START                        // first virtual address
ldr    x3, =(VA_START + DEVICE_BASE - SECTION_SIZE)        // last virtual address
create_block_map x0, x1, x2, x3, MMU_FLAGS, x4

接下来,创建了整个内存的虚拟映射,但排除了 设备寄存器区域 。使用 MMU_FLAGS 常量作为 flags参数 ,这将所有的节区标记为正常的非缓存内存

请注意,MMU_FLAGS常量中也指定了MM_ACCESS标志

如果没有这个标志,每次内存访问都会引发同步异常

映射设备内存

/* Mapping device memory*/
mov     x1, #DEVICE_BASE                    // start mapping from device base address
ldr     x2, =(VA_START + DEVICE_BASE)                // first virtual address
ldr    x3, =(VA_START + PHYS_MEMORY_SIZE - SECTION_SIZE)    // last virtual address
create_block_map x0, x1, x2, x3, MMU_DEVICE_FLAGS, x4

设备寄存器区域被映射过程与之前的内核内存区域被映射完全相同,只是现在使用不同的起始地址、结束地址和标志位

mov    x30, x29                        // restore return address
ret

最后,函数恢复了链接寄存器并返回给调用者

配置页表翻译

现在页面表已创建,再次回到el1_entry函数。但在打开MMU之前还有一些工作要做:

mov    x0, #VA_START
add    sp, x0, #LOW_MEMORY

更新init任务的堆栈指针。现在它使用的是虚拟地址,而不是物理地址

因此,只能在MMU打开后使用
adrp    x0, pg_dir
msr    ttbr1_el1, x0

ttbr1_el1 被更新为指向先前填充的 PGD表

ldr    x0, =(TCR_VALUE)
msr    tcr_el1, x0

tcr_el1寄存器 负责配置MMU的一些通用参数

例如,在这里配置内核和用户页表都应该使用4KB的页面大小
ldr    x0, =(MAIR_VALUE)
msr    mair_el1, x0

在前面已经讨论过了 mair_el1 寄存器,这里设置它的值

ldr    x2, =kernel_main

mov    x0, #SCTLR_MMU_ENABLED
msr    sctlr_el1, x0

br     x2

msr sctlr_el1, x0 是实际启用MMU的指令。现在可以跳转到kernel_main函数了

一个有趣的问题是为什么不能直接执行br kernel_main指令呢?

事实上,在MMU启用之前,我们一直在使用物理内存,内核加载在物理偏移0处,这意味着当前程序计数器非常接近0

启用MMU不会更新程序计数器。如果现在执行br kernel_main指令,该指令将使用相对于当前程序计数器的偏移量,并跳转到未开启MMU时kernel_main所在的位置

而ldr x2, =kernel_main则会加载x2寄存器的值为kernel_main函数的绝对地址

由于在链接脚本中将图像的基地址设置为0xffff000000000000,kernel_main函数的绝对地址将从内核镜像开始处的偏移量加上0xffff000000000000来计算,这正是我们所需要的

另一个需要理解的重要事项是为什么ldr x2, =kernel_main指令必须在我们启用MMU之前执行

原因是ldr指令也使用pc相对偏移量,因此如果我们尝试在MMU开启后但在跳转到镜像基地址之前执行此指令,该指令将引发页错误

加载用户级别进程代码

如果使用的是真正的操作系统,可能会期望它能够从文件系统中读取您的程序并执行它

但对于Rpi OS操作系统而言,情况有所不同,它目前还不具备文件系统支持

之前并不关注这个事实,因为用户进程与内核共享相同的地址空间

现在情况发生了变化,每个进程应该有自己独立的地址空间,因此需要找出如何存储用户程序,以便稍后加载到新创建的进程中

最终实现的一个技巧是将用户程序存储在内核映像的一个单独部分中。下面是负责执行此操作的链接脚本的相关部分:

. = ALIGN(0x00001000);
user_begin = .;
.text.user : { build/user* (.text) }
.rodata.user : { build/user* (.rodata) }
.data.user : { build/user* (.data) }
.bss.user : { build/user* (.bss) }
user_end = .;

这里使用了一种约定,即用户级别的源代码应该定义在以 user 前缀命名的文件中。然后,链接脚本可以将所有与用户相关的代码隔离在一个连续的区域中,并定义 user_beginuser_end 变量,用于标记此区域的起始和结束位置。通过这种方式,可以简单地将user_begin和user_end之间的所有内容复制到新分配的进程地址空间中,从而模拟加载用户程序

这种方法足够简单,并且对于当前的目的效果很好

在实现文件系统支持之后,将摆脱这种临时解决方案,能够加载ELF文件

目前有两个文件被编译到用户区域中:

  • user_sys.S :该文件包含系统调用包装函数的定义

    RPi操作系统仍然支持与之前相同的系统调用,只是将使用fork系统调用而不是clone系统调用
    
    fork会复制进程的虚拟内存,而这正是现在想要尝试的
    
  • user.c :用户程序的源代码

    几乎与之前使用的代码相同
    

创建第一个用户级别进程

与以前类似, move_to_user_mode 函数负责创建第一个用户进程。从一个内核进程中调用此函数:

void kernel_process(){
    printf("Kernel process started. EL %d\r\n", get_el());
    unsigned long begin = (unsigned long)&user_begin;
    unsigned long end = (unsigned long)&user_end;
    unsigned long process = (unsigned long)&user_process;
    int err = move_to_user_mode(begin, end - begin, process - begin);
    if (err < 0){
        printf("Error while moving process to user mode\n\r");
    }
}

现在需要三个参数来调用 move_to_user_mode函数:

  1. 用户代码区域的起始指针
  2. 区域的大小
  3. 启动函数的偏移量
这些信息是基于前面讨论过的user_begin和user_end变量进行计算得出的

move_to_user_mode 函数:

int move_to_user_mode(unsigned long start, unsigned long size, unsigned long pc)
{
    struct pt_regs *regs = task_pt_regs(current);
    regs->pstate = PSR_MODE_EL0t;
    regs->pc = pc;
    regs->sp = 2 *  PAGE_SIZE;
    unsigned long code_page = allocate_user_page(current, 0);
    if (code_page == 0)    {
        return -1;
    }
    memcpy(code_page, start, size);
    set_pgd(current->mm.pgd);
    return 0;
}

现在逐行检查代码:

struct pt_regs *regs = task_pt_regs(current);
regs->pstate = PSR_MODE_EL0t;

首先获取一个指向 pt_regs区域的指针 ,并设置 pstate ,这样在kernel_exit之后将进入EL0模式

regs->pc = pc;

现在,pc指向用户区域中启动函数的 偏移量

regs->sp = 2 *  PAGE_SIZE;

这里有一个简单的约定,即用户程序不会超过1页的大小。所以从第二页开始分配给栈

unsigned long code_page = allocate_user_page(current, 0);
if (code_page == 0)    {
    return -1;
}

allocate_user_page函数 预留 1个内存页面,并将其 映射提供的虚拟地址 作为第二个参数。在映射过程中,它 填充 了与 当前进程关联的页表

稍后将详细研究这个函数的工作原理
memcpy(code_page, start, size);

接下来,将把整个用户区域复制到新的地址空间(刚刚映射的页面中)

从偏移量0开始,这样用户区域中的偏移量将成为实际的起始虚拟地址!
set_pgd(current->mm.pgd);

最后,调用 set_pgd函数 ,它会 更新 ttbr0_el1寄存器 ,从而 激活 当前进程的转换表

TLB (地址转换缓存)

如果查看 set_pgd函数 ,会看到在设置 ttbr0_el1 之后,它还清空了TLB(Translation Lookaside Buffer,地址转换缓存)。TLB是一个专门用于存储物理页和虚拟页映射关系的缓存

  • 当某个虚拟地址第一次映射到物理地址时,该映射关系会被存储在TLB中
  • 下次访问相同的页面时,就不再需要进行完整的页表查找
  • 因此,在更新页表之后清空TLB是非常有意义的,否则更改将不会应用于已存储在TLB中的页面。
通常情况下,为了简化操作,尽量避免使用所有的缓存,但是如果没有TLB,任何内存访问都会变得极其低效,而且我认为完全禁用TLB可能是不可能的

此外,除了在切换ttbr0_el1之后需要清空它之外,TLB不会给操作系统增加其他复杂性

映射虚拟地址

前面已经看到了 allocate_user_page 函数怎么使用,现在是时候来看看它是怎么实现的:

unsigned long allocate_user_page(struct task_struct *task, unsigned long va) {
    unsigned long page = get_free_page();
    if (page == 0) {
        return 0;
    }
    map_page(task, va, page);
    return page + VA_START;
}

这个函数分配一个新的页面,将其映射到提供的虚拟地址,并返回页面的指针。现在说 指针 时,需要区分三种不同的指针:

  1. 指向 物理页面 的指针
  2. 指向 内核地址空间 中的指针
  3. 指向 用户地址空间 中的指针
这三种不同的指针都可以指向相同的内存位置

在这里,page变量是一个物理指针,而返回值是内核地址空间中的指针

这个指针可以很容易地计算,因为在boot.S中将 整个物理内存 线性映射 到了 VA_START虚拟地址 开始的位置。也不需要担心分配新的内核页表,因为在boot.S中已经将所有的内存映射完成。但是,仍然需要 创建 用户映射 ,这是在 map_page函数 中完成的,接下来将探讨这个函数:

void map_page(struct task_struct *task, unsigned long va, unsigned long page){
    unsigned long pgd;
    if (!task->mm.pgd) {
        task->mm.pgd = get_free_page();
        task->mm.kernel_pages[++task->mm.kernel_pages_count] = task->mm.pgd;
    }
    pgd = task->mm.pgd;
    int new_table;
    unsigned long pud = map_table((unsigned long *)(pgd + VA_START), PGD_SHIFT, va, &new_table);
    if (new_table) {
        task->mm.kernel_pages[++task->mm.kernel_pages_count] = pud;
    }
    unsigned long pmd = map_table((unsigned long *)(pud + VA_START) , PUD_SHIFT, va, &new_table);
    if (new_table) {
        task->mm.kernel_pages[++task->mm.kernel_pages_count] = pmd;
    }
    unsigned long pte = map_table((unsigned long *)(pmd + VA_START), PMD_SHIFT, va, &new_table);
    if (new_table) {
        task->mm.kernel_pages[++task->mm.kernel_pages_count] = pte;
    }
    map_table_entry((unsigned long *)(pte + VA_START), va, page);
    struct user_page p = {page, va};
    task->mm.user_pages[task->mm.user_pages_count++] = p;
}

map_page函数 在某种程度上重复了在 __create_page_tables函数 中的操作:它 分配填充 了一个 页面表层级结构 。然而,有三个重要的区别:

  1. 现在使用C语言而不是汇编语言
  2. map_page函数映射单个页面而不是整个内存
  3. 使用普通的页面映射而不是段映射

在这个过程中涉及到两个重要的函数: map_tablemap_table_entry 。map_table 代码如下:

unsigned long map_table(unsigned long *table, unsigned long shift, unsigned long va, int* new_table) {
    unsigned long index = va >> shift;
    index = index & (PTRS_PER_TABLE - 1);
    if (!table[index]){
        *new_table = 1;
        unsigned long next_level_table = get_free_page();
        unsigned long entry = next_level_table | MM_TYPE_PAGE_TABLE;
        table[index] = entry;
        return next_level_table;
    } else {
        *new_table = 0;
    }
    return table[index] & PAGE_MASK;
}

该函数具有以下参数:

  • table :指向父级页表的指针。假设该页表已经分配,但可能为空
  • shift :用于从提供的虚拟地址中提取表索引的值
  • va :虚拟地址本身
  • new_table :这是一个输出参数。如果已经分配了新的子表,则设置为 1 ;否则设置为 0

可以将这个函数视为 create_table_entry 宏 的引申。它从虚拟地址中提取表索引,并在父表中准备一个指向子表的描述符

但与 create_table_entry 宏不同,不能假设子表应与父表相邻 相反,依赖 get_free_table 函数返回可用的任意页

还可能出现子表已经分配的情况(如果子页表覆盖了先前已分配另一页的区域)。在这种情况下,将 new_table 设置为0,并从父表中读取子页表的地址

map_page 函数调用 map_table 三次:一次用于 PGD ,一次用于 PUD ,一次用于 PMD 。最后一次调用分配 PTE 并在 PMD设置 一个 描述符 。接下来,会调用 map_table_entry 函数

void map_table_entry(unsigned long *pte, unsigned long va, unsigned long pa) {
    unsigned long index = va >> PAGE_SHIFT;
    index = index & (PTRS_PER_TABLE - 1);
    unsigned long entry = pa | MMU_PTE_FLAGS;
    pte[index] = entry;
}

map_table_entry 函数从 虚拟地址提取 PTE 索引 ,然后准备并 设置 PTE 描述符

类似于在 create_block_map 宏中所做的操作

这就是有关用户页表分配的内容,但是 map_page 还负责其他更重要的任务:

  • 它跟踪在虚拟地址映射过程中已经分配的页面。所有这些页面都存储在 kernel_pages 数组

    需要这个数组来在任务退出后清理已分配的页面
    
  • 还有一个 user_pages 数组 ,也由 map_page 函数填充。这个数组 存储进程虚拟页面物理页面之间 的对应关系

    需要这些信息来在 fork 过程中能够复制进程的虚拟内存
    

fork进程

在继续之前,总结一下目前的进展:已经看到了如何创建第一个用户进程,填充其页表,将源代码复制到正确的位置并初始化堆栈

经过所有这些准备工作,进程已经准备好运行了。下面是在用户进程内执行的代码:

void loop(char* str)
{
    char buf[2] = {""};
    while (1){
        for (int i = 0; i < 5; i++){
            buf[0] = str[i];
            call_sys_write(buf);
            user_delay(1000000);
        }
    }
}

void user_process() 
{
    call_sys_write("User process\n\r");
    int pid = call_sys_fork();
    if (pid < 0) {
        call_sys_write("Error during fork\n\r");
        call_sys_exit();
        return;
    }
    if (pid == 0){
        loop("abcde");
    } else {
        loop("12345");
    }
}

这段代码本身非常简单。唯一棘手的部分是fork系统调用的语义。与clone不同:

  • 在fork时不需要提供在新进程中需要执行的函数

    fork包装函数比clone简单得多,因为fork会对进程的虚拟地址空间进行完全复制
    
  • fork包装函数会返回两次:一次在 原始进程 中,一次在 新进程 中。此时,有了两个 完全相同 的进程,具有相同的堆栈和pc位置。唯一的区别是fork系统调用的返回值:
    • 在父进程中返回子进程的PID
    • 在子进程中返回0

      从这一点开始,两个进程开始完全独立的生活,可以修改它们的堆栈并使用相同的内存地址写入不同的数据,而互不干扰 
      

现在来看一下fork系统调用是如何实现的。 copy_process函数 完成了大部分的工作:

int copy_process(unsigned long clone_flags, unsigned long fn, unsigned long arg)
{
    preempt_disable();
    struct task_struct *p;

    unsigned long page = allocate_kernel_page();
    p = (struct task_struct *) page;
    struct pt_regs *childregs = task_pt_regs(p);

    if (!p)
        return -1;

    if (clone_flags & PF_KTHREAD) {
        p->cpu_context.x19 = fn;
        p->cpu_context.x20 = arg;
    } else {
        struct pt_regs * cur_regs = task_pt_regs(current);
        *childregs = *cur_regs;
        childregs->regs[0] = 0;
        copy_virt_memory(p);
    }
    p->flags = clone_flags;
    p->priority = current->priority;
    p->state = TASK_RUNNING;
    p->counter = p->priority;
    p->preempt_count = 1; //disable preemtion until schedule_tail

    p->cpu_context.pc = (unsigned long)ret_from_fork;
    p->cpu_context.sp = (unsigned long)childregs;
    int pid = nr_tasks++;
    task[pid] = p;

    preempt_enable();
    return pid;
}

这个函数看起来几乎与之前的函数完全相同,只有一个例外:在复制用户进程时,现在不再修改新进程的堆栈指针和程序计数器,而是调用copy_virt_memory函数。 copy_virt_memory 的实现如下所示:

int copy_virt_memory(struct task_struct *dst) {
    struct task_struct* src = current;
    for (int i = 0; i < src->mm.user_pages_count; i++) {
        unsigned long kernel_va = allocate_user_page(dst, src->mm.user_pages[i].virt_addr);
        if( kernel_va == 0) {
            return -1;
        }
        memcpy(kernel_va, src->mm.user_pages[i].virt_addr, PAGE_SIZE);
    }
    return 0;
}

它遍历 user_pages数组 ,该数组包含 当前进程分配的所有页面

请注意,user_pages数组只存储实际可用于进程的页面,其中包含其源代码或数据

不在此处包括存储在kernel_pages数组中的页表页面

接下来,对于每个页面,分配另一个空页面,并将原始页面的内容复制到其中。这里使用与 原始页面相同的虚拟地址 映射 新页面 。这样就获得了 原始进程地址空间的精确副本

其他fork过程的细节与前一课程完全相同

需要时分配新的页面

如果回过头去看一下move_to_user_mode函数,可能会注意到只映射了从偏移量0开始的单个页面,但也假设第二个页面将用作堆栈

为什么不映射第二个页面呢?如果认为这是一个错误,那不是

这是一个特性!堆栈页面以及进程需要访问的任何其他页面将在首次请求时进行映射

接下来将探索此机制的内部工作原理

当进程尝试访问尚未映射的页面的地址时,将 生成 同步异常 。这是将要支持的第二种同步异常(第一种是由svc指令生成的系统调用异常):

el0_sync:
        kernel_entry 0
        mrs    x25, esr_el1                // read the syndrome register
        lsr    x24, x25, #ESR_ELx_EC_SHIFT        // exception class
        cmp    x24, #ESR_ELx_EC_SVC64            // SVC in 64-bit state
        b.eq    el0_svc
        cmp    x24, #ESR_ELx_EC_DABT_LOW        // data abort in EL0
        b.eq    el0_da
        handle_invalid_entry 0, SYNC_ERROR

在这里,使用 esr_el1寄存器 来确定 异常类型 。如果它是 页面故障异常 (或者说是数据访问异常),则调用 el0_da函数

el0_da:
        bl    enable_irq
        mrs    x0, far_el1
        mrs    x1, esr_el1
        bl    do_mem_abort
        cmp x0, 0
        b.eq 1f
        handle_invalid_entry 0, DATA_ABORT_ERROR
1:
        bl disable_irq
        kernel_exit 0

el0_da将主要工作转交到 do_mem_abort函数 。此函数接受两个参数:

  1. 尝试访问的内存地址。此地址从 far_el1寄存器故障地址寄存器 )中获取
  2. esr_el1的内容( 异常综合寄存器
int do_mem_abort(unsigned long addr, unsigned long esr) {
    unsigned long dfs = (esr & 0b111111);
    if ((dfs & 0b111100) == 0b100) {
        unsigned long page = get_free_page();
        if (page == 0) {
            return -1;
        }
        map_page(current, addr & PAGE_MASK, page);
        ind++;
        if (ind > 2){
            return -1;
        }
        return 0;
    }
    return -1;
}

为了理解这个函数,需要了解一些关于esr_el1寄存器的具体细节。该寄存器的 [32:26]位 被称为 异常类

在el0_sync处理程序中检查这些位,以确定它是系统调用,数据访问异常还是其他可能的情况

异常类确定了[24:0]位的含义,这些位通常用于提供有关异常的附加信息

关于数据访问异常的[24:0]位的含义在AArch64参考手册的第2460页上有描述

一般来说,数据访问异常可能发生在许多不同的场景中(可能是权限故障、地址大小故障或其他故障)。这里只关注发生在当前虚拟地址的某些页表未初始化的情况下的转换故障

因此,在do_mem_abort函数的前两行中,检查当前异常是否确实是转换故障。如果是,将 分配 一个 新页面 并将其 映射请求的虚拟地址

所有这些对于用户程序来说都是完全透明的

它不会注意到一些内存访问被中断,并且在此期间分配了新的页表

结论

这是一个漫长而困难的旅程,但希望它是有用的,虚拟内存是任何操作系统中最基本的组成部分之一

通过引入虚拟内存,现在拥有了完整的进程隔离,但是RPi OS离完成还有很长的路要走

它仍然不支持文件系统、驱动程序、信号和中断等待列表、网络以及许多其他有用的概念
Previous:系统调用 Home: 用树莓派学习操作系统开发