原文《🏠/OS Components/On-Demand Paging》
按需分页
内核构建实现
按需分页和惰性加载是用于管理物理内存的技术。基本思想是允许程序在不完全驻留在内存中的情况下执行。程序按需加载到内存中。这是一种在许多操作系统中常用的技术,允许大型程序在内存较小的系统上执行。通常,内存管理单元(MMU)用于将虚拟内存映射到物理内存。应用程序然后被加载到虚拟内存地址空间中,对物理内存的访问由MMU管理。如果虚拟内存不在物理内存中,则会发生页故障。操作系统然后将缺失的页面加载到内存中,并恢复执行。
需求和假设
按需分页需要内核构建模式(CONFIG_BUILD_KERNEL=y)。在这种模式下,NuttX内核中不构建任何应用程序。相反,应用程序作为单独的程序被加载到内存中(CONFIG_ELF=y 和 CONFIG_BINFMT_LOADABLE=y)。在这种模式下,每个进程都有自己的地址环境(CONFIG_ARCH_ADDRENV=y)。
逻辑设计描述
当应用程序正在加载时,会调用 up_addrenv_create
来创建进程的地址环境。这包括在虚拟内存空间中映射常用的文本、数据和堆段。在没有按需分页的情况下,物理内存随后被分配并相应地映射,然后进程开始执行。当启用按需分页时,通常只为每个段分配和映射一个页面。
进程在其地址环境中开始执行,访问虚拟内存。每当它尝试访问未在MMU中映射的虚拟内存地址时,会发生页故障。MMU然后触发一个异常,由内核处理。内核然后检查是否有足够的空闲物理页面可用,并将虚拟内存地址映射到该页面。最后,从第一次发生页故障的同一个点恢复执行。
示例:RISC-V
RISC-V 的 up_addrenv_create
调用 create_region
(两者定义在 arch/risc-v/src/common/riscv_addrenv.c
中)。create_region
通过为页表分配物理内存将单个区域映射到MMU。当 CONFIG_PAGING=y
未选择时,所有物理页表都从物理内存空间中分配,然后映射到虚拟内存空间。当 CONFIG_PAGING=y
被选择时,只有每个段的第一页被映射到虚拟内存空间。其余页面仅在发生页故障时映射到虚拟内存空间。
页故障由异常处理程序中的 riscv_fillpage
函数处理(定义在 arch/risc-v/src/common/riscv_exception.c
中)。每当发生页故障时,调用 riscv_fillpage
函数。该函数分配一个物理页面并将其映射到触发页故障异常的虚拟内存空间,然后从第一次发生页故障的同一个点恢复执行。
nuttx/boards/risc-v/qemu-rv/rv-virt/configs/knsh_paging 模拟了一个具有 4MiB 物理内存和每个进程分配 8MiB 虚拟堆内存的设备。通过启用按需分页,这是可能的。
旧实现
此旧实现运行在扁平构建模式下(内核构建模式当时甚至不存在)。
哪些平台可以支持 NuttX 旧的按需分页?
MCU 应该有一些大容量、可能是低成本的非易失性存储器,例如串行 FLASH 或 SD 卡。这种存储器可能不支持非随机访问(否则,为什么不直接在存储介质上执行程序)。SD 和串行 FLASH 价格低廉,不需要很多引脚,SPI 支持在几乎所有 MCU 中普遍存在。这种大容量串行 FLASH 将包含一个大程序。可能是几兆字节的程序。 MCU 必须有一块相对较小的快速 SRAM,可以在其中执行代码。例如 256K(或 NXP LPC3131 中的 192K)对于许多应用程序来说已经足够。 MCU 必须有一个 MMU(例如 NXP LPC3131)。
如果平台满足这些要求,那么 NuttX 可以提供按需分页:它可以将大程序中的 .text 从非易失性介质复制到 RAM 中,按需执行大程序。
术语
g_waitingforfill
: 一个 OS 列表,用于保存等待页面填充的任务的 TCB。g_pftcb
: 一个变量,保存当前正在重新填充的线程的 TCB。g_pgworker
: 将执行页面填充的线程的进程 ID。pg_callback()
: 从驱动程序调用的回调函数,当填充完成时调用。pg_miss()
: 从架构特定代码调用的函数,用于处理页故障。TCB
: 任务控制块
NuttX 通用逻辑设计描述
初始化
初始化时会添加一下变量
g_waitingforfill
:一个双向链表,用于实现一个优先级列表,保存等待页面填充的任务的 TCB。g_pgworker
:‘页面填充工作线程’的ID。
在 sched/init/nx_start.c
中的 OS 初始化期间,将执行以下步骤:
- 初始化
g_waitingforfill
队列。 - 启动一个特殊的页面填充工作线程。页面填充工作线程的 PID 将保存在
g_pgworker
中。需要注意的是,我们需要一个特殊的工作线程来执行填充;我们不能使用“通用”工作线程设施,因为我们不能保证“通用”线程调用的所有操作总是驻留在内存中。
g_waitingforfill
、g_pgworker
和其他内部、私有定义的声明将提供在 sched/paging/paging.h
中。所有应由架构特定代码使用的公共定义将提供在 include/nuttx/page.h
中。大多数架构特定函数在 include/nuttx/arch.h
中声明,但对于这种情况下的分页逻辑,这些架构特定函数在 include/nuttx/page.h
中声明。
页故障(Page Faults)
Page fault exception handling
。页故障处理由 pg_miss()
函数执行。该函数从架构特定的内存分段故障处理逻辑中调用。该函数将执行以下操作:
- 合理性检查。如果当前正在执行的任务是页面填充工作线程,该函数将触发 ASSERT。页面填充工作线程是解决页故障的方式,所有与页面填充工作线程相关的逻辑必须“锁定”并始终驻留在内存中。
- 阻塞当前正在执行的任务。该函数将调用
up_switch_context()
以阻塞就绪运行列表中的任务。这将导致中断级别的上下文切换到下一个最高优先级任务。被阻塞的任务将被标记为状态TSTATE_WAIT_PAGEFILL
,并保留在g_waitingforfill
优先级任务列表中。 - 提升页面填充工作线程的优先级。检查
g_waitingforfill
列表头部任务的优先级。如果该任务的优先级高于当前页面填充工作线程的优先级,则提升页面填充工作线程的优先级到该优先级。因此,页面填充工作线程将始终以最高优先级任务等待填充的优先级运行。 - 通知页面填充工作线程。是否有页面正在被填充?如果没有,则通知(signal)页面填充工作线程开始处理排队的页面填充请求。c
nxsig_kill(g_pgworker, SIGPAGING)
收到 pg_miss()
发送的信号后,页面填充工作线程将被唤醒并开始填充操作。
pg_miss()
路径nuttx/sched/paging/pg_miss.c:111
输入参数
void
假设就绪运行列表的头部任务是导致异常的任务。当前任务上下文应该已经保存在该任务的 TCB 中。不需要其他输入。
假设
- 假设此函数从异常处理级别调用,并且所有中断已禁用。
pg_miss()
必须“锁定”在内存中。调用pg_miss()
不能引起嵌套的页故障。- 假设当前正在执行的任务(就绪运行列表的头部任务)是导致故障的任务。除非页故障发生在中断处理程序中,否则这将始终为真。中断处理逻辑必须始终可用并“锁定”在内存中,因此页故障永远不会来自中断处理。
- 架构特定的页故障异常处理已经验证异常没有发生在中断/异常处理逻辑中。
- 页故障导致的任务不能是页面填充工作线程,因为这是完成页面填充的唯一方式。
填充初始化
页面填充工作线程将在以下三种条件之一下唤醒:
- 当由
pg_miss()
信号时,页面填充工作线程将被唤醒(见上文), - 从
pg_callback()
完成上一次填充后(当CONFIG_PAGING_BLOCKINGFILL
被定义时…见下文),或 - 可配置的超时到期且没有活动。此超时可以用于检测填充从未完成等失败条件。
页面填充工作线程将维护一个静态变量 struct tcb_s *g_pftcb
。如果没有填充在进行中,g_pftcb
将为 NULL。否则,它将指向正在接收填充的任务的 TCB。
当从 pg_miss()
唤醒时,没有填充在进行中,g_pftcb
将为 NULL。在这种情况下,页面填充工作线程将调用 pg_startfill()
。该函数将执行以下操作:
- 调用架构特定的函数
up_checkmapping()
以查看是否仍需要执行页面填充。在某些情况下,页故障可能在多个线程上发生并被多次排队。在这种情况下,被阻塞的任务将简单地重新启动(见下文关于正常完成填充操作的逻辑)。 - 调用
up_allocpage(tcb, &vpage)
。此架构特定的函数将设置内存中的页面并映射到虚拟地址(vpage
)。如果所有可用页面都在使用中(常见情况),此函数将选择一个正在使用的页面,取消映射并使其可用。 - 调用架构特定的函数
up_fillpage()
。支持两种版本的up_fillpage
函数——基于配置设置CONFIG_PAGING_BLOCKINGFILL
的阻塞和非阻塞版本。- 如果定义了
CONFIG_PAGING_BLOCKINGFILL
,则up_fillpage
是阻塞调用。在这种情况下,up_fillpage()
只接受(1)需要填充的 TCB 的引用。TCB 中的架构特定上下文信息将足以执行填充。和(2)要填充的分配页面的(虚拟)地址。填充的结果将通过up_fillpage()
的返回值提供。 - 如果定义了
CONFIG_PAGING_BLOCKINGFILL
,则up_fillpage
是非阻塞调用。在这种情况下,up_fillpage()
将接受一个额外的参数:页面填充工作线程将提供一个回调函数pg_callback
。此函数是非阻塞的,它将启动异步页面填充。调用非阻塞的up_fillpage()
后,页面填充工作线程将等待下一个事件——填充完成事件。当页面填充完成(或发生错误)时,回调函数将被调用。填充的结果将作为回调函数的参数提供。此回调可能在中断级别发生。
- 如果定义了
在任何情况下,当填充进行时,其他任务可能执行。如果在此期间发生另一个页故障,故障任务将被阻塞,其 TCB 将按优先级顺序添加到 g_waitingforfill
,并且页面工作线程的优先级可能被提升。但在当前页面填充完成之前不会采取任何行动。注意:IDLE 任务也必须完全锁定在内存中。IDLE 任务不能被阻塞。在所有任务都因等待页面填充而阻塞的情况下,IDLE 任务仍必须可用。
架构特定的函数 up_checkmapping()
、up_allocpage(tcb, &vpage)
和 up_fillpage(page, pg_callback)
将在 include/nuttx/arch.h
中声明原型。
填充完成
对于阻塞的 up_fillpage()
,填充的结果将直接从 up_fillpage
调用返回。
对于非阻塞的 up_fillpage()
,架构特定的驱动程序将在填充完成时调用 pg_miss()
中提供的 pg_callback()
。在这种情况下,pg_callback()
可能从驱动程序中断级别逻辑中调用。驱动程序将提供填充的结果作为回调函数的参数。注意:pg_callback()
也必须锁定在内存中。
在这种非阻塞情况下,当回调 pg_callback()
被通知填充已完成时,将执行以下操作:
- 验证
g_pftcb
不为 NULL。 - 找到等待填充完成的任务
g_pftcb
和g_waitingforfill
列表头部等待的任务之间的较高优先级。这将是最高优先级任务等待填充的优先级。 - 如果此较高优先级高于当前页面填充工作线程的优先级,则提升工作线程的优先级到该优先级。因此,页面填充工作线程将始终以最高优先级任务等待填充的优先级运行。
- 保存填充操作的结果。
- 信号页面填充工作线程。
任务恢复
对于非阻塞的 up_fillpage()
,页面填充工作线程将在被唤醒时检测到页面填充已完成,此时 g_pftcb
不为 NULL 且填充完成状态来自 pg_callback
。在非阻塞情况下,页面填充工作线程将在 up_fillpage()
返回时知道页面填充已完成。
在这种情况下,页面填充工作线程将:
- 验证状态信息和
g_pftcb
的一致性。 - 验证页面填充是否成功完成,如果成功,则
- 调用
up_unblocktask(g_pftcb)
使刚刚接收填充的任务准备好运行。 - 检查
g_waitingforfill
列表是否为空。如果不为空:- 从
g_waitingforfill
中移除最高优先级任务,等待页面填充。 - 将任务的 TCB 保存在
g_pftcb
中。 - 如果
g_pftcb
中线程的优先级高于页面填充工作线程的默认优先级,则将页面填充工作线程的优先级设置为该优先级。 - 调用
pg_startfill()
开始下一次填充(如上所述)。
- 从
- 否则,
- 将
g_pftcb
设置为 NULL。 - 恢复页面填充工作线程的默认优先级。
- 等待下一个与填充相关的事件(新的页故障)。
- 将
架构特定支持需求
内存组织
内存区域。芯片特定逻辑将虚拟和物理地址空间映射到三个通用区域:
- 一个包含“锁定在内存中”的代码的 .text 区域,这些代码始终可用,永远不会引起页故障。此锁定内存是在启动时加载的,并始终驻留在内存中。此内存区域必须包括:
- 所有中断路径的逻辑。所有中断逻辑必须锁定在内存中,因为这里的设计不支持从中断处理程序中发生页故障。这包括页故障处理逻辑和
pg_miss()
(从页故障处理程序调用)。还包括唤醒页面填充工作线程的pg_callback()
函数和调用pg_callback()
的架构特定逻辑。 - IDLE 线程的所有逻辑。IDLE 线程必须始终准备好运行,不能因任何原因被阻塞。
- 所有的页面填充工作线程必须锁定在内存中。此线程必须执行以解除任何等待填充的线程的阻塞。如果此线程被阻塞,则无法完成填充!
- 一个包含可以从某些大容量存储介质分配、映射到各种虚拟地址并填充的页面的 .text 区域。
- 一个固定 RAM 空间,用于 .bss、.text 和 .heap。
此内存组织在下表中说明。请注意:
- 虚拟地址空间中的页面与非易失性大容量存储设备中的 .text 页面之间存在一对一关系。
- 然而,可用的物理页面数量远少于虚拟页面。在任何给定时间,只有少量物理页面将映射到虚拟页面。此映射将按需进行,以满足程序执行的需要。
SRAM | 虚拟地址空间 | 非易失性存储 |
---|---|---|
. | DATA | . |
. | 虚拟页面 n (n > m) | 存储页面 n |
. | 虚拟页面 n-1 | 存储页面 n-1 |
DATA | … | … |
物理页面 m (m < n) | … | … |
物理页面 m-1 | … | … |
… | … | … |
物理页面 1 | 虚拟页面 1 | 存储页面 1 |
锁定内存 | 锁定内存 | 内存驻留 |
示例 假设 SRAM 的大小为 192K(如 NXP LPC3131),并且:
- 锁定的内存驻留 .text 区域的大小为 32K,
- DATA 区域的大小为 64K。
- 一个管理页面的大小为 1K。
- 非易失性大容量存储设备上的整个 .text 图像的大小为 1024K。
那么,锁定的内存驻留代码的大小为 32K(m=32 页)。物理页面区域的大小为 96K(96 页),数据区域的大小为 64 页。虚拟分页区域的大小必须大于或等于(1024-32)或 992 页(n)。
构建锁定的内存中镜像
实现这一点的一种方法是两阶段链接:
- 第一阶段,创建一个部分链接的对象,包含所有中断/异常处理逻辑、页面填充工作线程以及空闲线程的所有部分(必须始终可执行)。
- 此部分链接的所有
.text
和.rodata
段应收集到单个段中。 - 第二阶段链接将部分链接的对象与其余对象链接,以生成最终的二进制文件。链接器脚本应将“特殊”段定位在保留的“不可交换”区域中。
特定于架构的函数
大多数标准的架构特定函数在 include/nuttx/arch.h
中声明。然而,对于这种情况下的分页逻辑,架构特定函数在 include/nuttx/page.h
中声明。标准的架构特定函数已经在架构端口提供,例如 up_switch_context
。为支持按需分页而必须实现的新函数是:
int up_checkmapping(FAR struct tcb_s *tcb)
函数 up_checkmapping()
返回一个指示页面填充是否仍需要执行的值。在某些情况下,页故障可能在多个线程上发生并被多次排队。此函数将防止同一页面被多次填充。
int up_allocpage(FAR struct tcb_s *tcb, FAR void *vpage)
此架构特定的函数将设置内存中的页面并映射到正确的虚拟地址。TCB 中保存的架构特定上下文信息将提供函数所需的信息,以识别虚拟地址缺失。此函数将在 vpage
中返回分配的物理页面地址。底层物理页面的大小由配置设置 CONFIG_PAGING_PAGESIZE
确定。注意:此函数必须 始终 返回页面分配。如果所有可用页面都在使用中(常见情况),则此函数将选择一个正在使用的页面,取消映射并使其可用。
int up_fillpage(FAR struct tcb_s *tcb, FAR const void *vpage, void (*pg_callback)(FAR struct tcb_s *tcb, int result))
从非易失性存储中填充页面的实际数据必须通过调用架构特定的函数 up_fillpage()
来完成。这将启动异步页面填充。通用分页逻辑将提供一个回调函数 pg_callback
,当页面填充完成(或发生错误)时将调用该函数。此回调假定在设备驱动程序完成填充操作时从中断级别发生。