页表是操作系统为每个进程提供其私有地址空间和内存的机制。页表决定了内存地址的含义以及可以访问物理内存的哪些部分。它们允许 Xv6 隔离不同进程的地址空间,并将它们多路复用到单一的物理内存上。页表还提供了一层间接映射,使得 Xv6 可以执行一些技巧:在多个地址空间中映射相同的内存(例如,trampoline 页面),以及用未映射的页面保护内核和用户栈。本章的其余部分将解释 RISC-V 硬件提供的页表以及 Xv6 如何使用它们。
3.1 分页硬件
提醒一下,RISC-V 指令(无论是用户态还是内核态)操作的是虚拟地址。机器的 RAM,即物理内存,是通过物理地址索引的。RISC-V 页表硬件通过将每个虚拟地址映射到物理地址,将这两种类型的地址连接起来。
Xv6 在 Sv39 RISC-V 上运行,这意味着只有 64 位虚拟地址的低 39 位被使用;高 25 位未被使用。在这种 Sv39 配置中,RISC-V 页表逻辑上是一个包含 227(134,217,728)个页表项(PTEs)的数组。每个 PTE 包含一个 44 位的物理页号(PPN)和一些标志位。分页硬件通过使用 39 位中的高 27 位来索引页表以找到一个 PTE,并构造一个 56 位的物理地址,其高 44 位来自 PTE 中的 PPN,低 12 位从原始虚拟地址复制而来。图 3.1 以逻辑视图展示了这一过程,将页表视为一个简单的 PTE 数组(见图 3.2 了解完整故事)。页表使操作系统能够以 4096 字节(212)对齐的块为粒度控制虚拟到物理地址的转换。这样的一个块称为一个页面。
在 Sv39 RISC-V 中,虚拟地址的高 25 位未用于转换;未来,RISC-V 可能会使用这些位来定义更多级别的转换。物理地址也有扩展的空间:PTE 格式中有空间让物理页号再增加 10 位。
虚拟地址和物理地址的转换
图 3.2 展示了实际的转换过程分为三个步骤。页表存储在物理内存中,以一个三级树的形式存在。树的根是一个 4096 字节的页表页,包含 512 个 PTEs,这些 PTEs 包含下一级树中页表页的物理地址。这些页中的每一个又包含 512 个 PTEs,用于树的最底层。分页硬件使用 27 位中的高 9 位来选择根页表页中的一个 PTE,中间 9 位来选择下一级树中页表页中的一个 PTE,低 9 位来选择最终的 PTE。
如果转换一个地址所需的三个 PTEs 中的任何一个不存在,分页硬件会引发一个页错误异常,由内核来处理这个异常(见第 4 章)。这种三级结构允许页表在常见情况下省略整个页表页,即大范围的虚拟地址没有映射。
每个 PTE 包含标志位,告诉分页硬件允许如何使用相关的虚拟地址。PTE_V 表示 PTE 是否存在:如果未设置,对页面的引用将导致异常(即不允许)。PTE_R 控制是否允许指令读取页面。PTE_W 控制是否允许指令写入页面。PTE_X 控制 CPU 是否可以将页面的内容解释为指令并执行它们。PTE_U 控制用户模式中的指令是否允许访问页面;如果未设置 PTE_U,PTE 只能在管理模式中使用。图 3.2 展示了这一切是如何工作的。标志位和所有其他与页硬件相关的结构在 kernel/riscv.h
中定义。
使用页表
为了告诉硬件使用一个页表,内核必须将根页表页的物理地址写入 satp
寄存器。每个 CPU 都有自己的 satp
。一个 CPU 将使用其自己的 satp
所指向的页表来转换后续指令生成的所有地址。每个 CPU 都有自己的 satp
,因此不同的 CPU 可以运行不同的进程,每个进程都有自己的页表描述的私有地址空间。
术语说明
-
物理内存:指的是 DRAM 中的存储单元。物理内存中的一个字节有一个地址,称为物理地址。
-
虚拟地址:指令仅使用虚拟地址,分页硬件将虚拟地址转换为物理地址,然后发送到 DRAM 硬件进行读写操作。
-
虚拟内存:不是一个物理对象,而是指内核提供的一组抽象和机制,用于管理物理内存和虚拟地址。
图 3.1 和图 3.2 的解释
图 3.1:RISC-V 虚拟和物理地址,以及简化的逻辑页表
-
虚拟地址的高 25 位未使用,中间 27 位用于索引页表,低 12 位是页面内的偏移量。
-
页表是一个包含 227 个 PTEs 的数组。
-
每个 PTE 包含一个 44 位的物理页号(PPN)和一些标志位。
-
物理地址由 PTE 中的 PPN 和虚拟地址的低 12 位组成。
图 3.2:RISC-V 地址转换细节
-
页表是一个三级树结构。
-
根页表页包含 512 个 PTEs,每个 PTE 指向下一级的页表页。
-
每个下一级的页表页包含 512 个 PTEs,最终指向物理页面。
-
虚拟地址的高 9 位用于索引根页表页,中间 9 位用于索引下一级页表页,低 9 位用于索引最终的 PTE。
-
如果任何一个 PTE 不存在,将引发页错误异常。
-
PTE 包含标志位,控制页面的访问权限。
总结
尽管内核通过高内存映射使用其栈,但它们也可以通过直接映射的地址被内核访问。另一种设计可能只是使用直接映射,并在直接映射的地址处使用栈。然而,在这种安排中,提供保护页面将涉及取消映射其他虚拟地址,这些地址将指向物理内存,这将很难使用。
内核使用权限 PTE_R 和 PTE_X 映射 trampoline 和内核文本的页面。内核从这些页面读取并执行指令。内核使用权限 PTE_R 和 PTE_W 映射其他页面,以便它可以读写这些页面中的内存。保护页面的映射是无效的。
图 3.3 解释
左侧:Xv6 的内核地址空间
右侧:RISC-V 物理地址空间
总结
3. 内核页表初始化
4. 内核堆栈分配
5. TLB 刷新
总结
Xv6 使用 pagetable_t
和一系列核心函数(如 walk
和 mappages
)来管理地址空间和页表。内核页表在启动时初始化,并为每个进程分配内核堆栈。当更改页表时,Xv6 会刷新 TLB 以确保硬件使用最新的映射。这些机制确保了内核和用户进程的地址空间隔离和正确访问。
-
页表:为每个进程提供私有地址空间和内存。
-
分页硬件:将虚拟地址映射到物理地址。
-
Sv39 RISC-V:使用 39 位虚拟地址,分页硬件通过三级树结构进行地址转换。
-
PTE 标志位:控制页面的访问权限,包括存在性、读写权限、可执行性和用户模式访问权限。
-
satp
寄存器:内核将根页表页的物理地址写入satp
,CPU 使用该页表进行地址转换。 -
3.2 内核地址空间
Xv6 为每个进程维护一个页表,描述每个进程的用户地址空间,以及一个单独的页表来描述内核的地址空间。内核配置其地址空间的布局,以便能够通过可预测的虚拟地址访问物理内存和各种硬件资源。图 3.3 展示了这种布局如何将内核虚拟地址映射到物理地址。文件
kernel/memlayout.h
声明了 Xv6 内核内存布局的常量。QEMU 模拟了一台计算机,其中包括从物理地址
0x80000000
开始并至少延续到0x86400000
的 RAM(物理内存),Xv6 将其称为PHYSTOP
。QEMU 模拟还包括 I/O 设备,例如磁盘接口。QEMU 将设备接口暴露给软件作为内存映射的控制寄存器,这些寄存器位于物理地址空间中0x80000000
以下。内核可以通过读写这些特殊的物理地址与设备交互;这些读写操作与设备硬件通信,而不是与 RAM 通信。第 4 章将解释 Xv6 如何与设备交互。内核通过“直接映射”访问 RAM 和内存映射的设备寄存器;也就是说,将资源映射到虚拟地址,这些虚拟地址等于物理地址。例如,内核本身位于虚拟地址空间和物理内存中的
KERNBASE=0x80000000
。直接映射简化了读写物理内存的内核代码。例如,当fork
为子进程分配用户内存时,分配器返回该内存的物理地址;fork
在将父进程的用户内存复制到子进程时,直接使用该地址作为虚拟地址。有几个内核虚拟地址不是直接映射的:
-
trampoline 页面:它映射在虚拟地址空间的顶部;用户页表也有相同的映射。第 4 章将讨论 trampoline 页面的作用,但这里我们看到了页表的一个有趣用例;一个物理页面(包含 trampoline 代码)在内核的虚拟地址空间中被映射了两次:一次在虚拟地址空间的顶部,一次是直接映射。
-
内核栈页面:每个进程都有自己的内核栈,它被映射在较高的位置,以便在其下方 Xv6 可以保留一个未映射的保护页面。保护页面的 PTE 是无效的(即,未设置 PTE_V),因此如果内核溢出内核栈,很可能会引发异常并导致内核崩溃。如果没有保护页面,溢出的栈可能会覆盖其他内核内存,导致错误的操作。崩溃是更可取的。
-
虚拟地址:
-
MAXVA
:内核地址空间的最大虚拟地址。 -
KERNBASE
:内核基地址,从0x80000000
开始。 -
PHYSTOP
:物理内存的结束地址,0x86400000
。 -
Kstack
:每个进程的内核栈。 -
Trampoline
:trampoline 页面,映射在虚拟地址空间的顶部。 -
Guard page
:保护页面,用于防止内核栈溢出。 -
Kernel data
:内核数据。 -
Kernel text
:内核代码。 -
Free memory
:未使用的内存。 -
VIRTIO disk
:VIRTIO 磁盘设备。 -
UART0
:串口设备。 -
PLIC
:平台级中断控制器。 -
CLINT
:核心本地中断器。 -
boot ROM
:启动 ROM。
-
-
物理地址:
-
0x80000000
:物理内存的起始地址。 -
0x86400000
:物理内存的结束地址。 -
0x10000000
:VIRTIO 磁盘设备。 -
0x0C000000
:串口设备。 -
0x02000000
:PLIC。 -
0x001000
:启动 ROM。 -
0x0
:未使用的内存。
-
-
内核地址空间:内核使用一个单独的页表来描述其地址空间,以便访问物理内存和硬件资源。
-
直接映射:内核通过直接映射访问物理内存和内存映射的设备寄存器。
-
trampoline 页面:映射在虚拟地址空间的顶部,用于用户和内核之间的切换。
-
内核栈页面:每个进程都有自己的内核栈,映射在较高的位置,下方有一个保护页面。
-
保护页面:防止内核栈溢出,保护页面的 PTE 是无效的。
-
权限:内核映射 trampoline 和内核文本页面时使用 PTE_R 和 PTE_X;映射其他页面时使用 PTE_R 和 PTE_W。
-
3.3 代码:创建地址空间
Xv6 中用于操作地址空间和页表的大部分代码位于
vm.c
(kernel/vm.c:1
)中。核心数据结构是pagetable_t
,它实际上是指向 RISC-V 根页表页的指针;pagetable_t
可以是内核页表,也可以是每个进程的页表。核心函数是walk
,它为虚拟地址找到 PTE(页表项),以及mappages
,它为新映射安装 PTE。以kvm
开头的函数操作内核页表;以uvm
开头的函数操作用户页表;其他函数用于两者。copyout
和copyin
将数据复制到用户虚拟地址或从用户虚拟地址复制数据,这些虚拟地址作为系统调用参数提供;它们位于vm.c
中,因为它们需要显式地翻译这些地址,以便找到相应的物理内存。在启动序列的早期,
main
调用kvminit
(kernel/vm.c:22
)来创建内核的页表。这个调用发生在 Xv6 启用 RISC-V 上的分页之前,因此地址直接引用物理内存。kvminit
首先分配一个物理内存页来保存根页表页。然后它调用kvmmap
来安装内核需要的转换。转换包括内核的指令和数据、物理内存的上限到PHYSTOP
,以及实际上是设备的内存范围。kvmmap
(kernel/vm.c:118
)调用mappages
(kernel/vm.c:149
),mappages
将范围虚拟地址到同等范围物理地址的映射安装到一个页表中。它以页面大小为间隔,为范围内的每个虚拟地址单独执行此操作。对于要映射的每个虚拟地址,mappages
调用walk
查找该地址的 PTE 地址。然后,它初始化 PTE,保存相关的物理页号、所需的权限(PTE_W
、PTE_X
和/或PTE_R
)以及用于标记 PTE 有效的PTE_V
(kernel/vm.c:161
)。walk
(kernel/vm.c:72
)模仿 RISC-V 分页硬件查找虚拟地址的 PTE(参见图 3.2)。walk
一次从 3 级页表中获取 9 个比特位。它使用每级的 9 位虚拟地址来查找下一级页表或最终页面的 PTE(kernel/vm.c:78
)。如果 PTE 无效,则所需的页面尚未分配;如果设置了alloc
参数,walk
会分配一个新的页表页面,并将其物理地址放在 PTE 中。它返回树中最低一级的 PTE 地址(kernel/vm.c:88
)。上述代码依赖于物理内存直接映射到内核虚拟地址空间。例如,当
walk
降低页表的级别时,它从 PTE(kernel/vm.c:80
)中提取下一级页表的(物理)地址,然后使用该地址作为虚拟地址来获取下一级的 PTE(kernel/vm.c:78
)。main
调用kvminithart
(kernel/vm.c:53
)来安装内核页表。它将根页表页的物理地址写入寄存器satp
。之后,CPU 将使用内核页表转换地址。由于内核使用标识映射,下一条指令的当前虚拟地址将映射到正确的物理内存地址。procinit
(kernel/proc.c:26
)由main
调用,为每个进程分配一个内核堆栈。它将每个堆栈映射到由KSTACK
生成的虚拟地址,这为无效的堆栈保护页面留出空间。kvmmap
将映射的 PTE 添加到内核页表中,对kvminithart
的调用将内核页表重新加载到satp
中,以便硬件知道新的 PTE。每个 RISC-V CPU 都将页表条目缓存在转译后备缓冲器(TLB)中,当 Xv6 更改页表时,它必须告诉 CPU 使相应的缓存 TLB 条目无效。如果没有这么做,那么在某个时候 TLB 可能会使用旧的缓存映射,指向一个在此期间已分配给另一个进程的物理页面,这样会导致一个进程可能能够在其他进程的内存上涂鸦。RISC-V 有一个指令
sfence.vma
,用于刷新当前 CPU 的 TLB。Xv6 在重新加载satp
寄存器后,在kvminithart
中执行sfence.vma
,并在返回用户空间之前在用于切换至一个用户页表的trampoline
代码中执行sfence.vma
(kernel/trampoline.S:79
)。详细解释
1. 核心数据结构
pagetable_t
pagetable_t
是一个指针,指向 RISC-V 根页表页。它可以是内核页表,也可以是每个进程的页表。2. 核心函数
-
walk
:-
功能:为虚拟地址找到 PTE。
-
实现:
walk
模仿 RISC-V 分页硬件,逐级查找页表。它使用 9 位虚拟地址来索引下一级页表或最终页面的 PTE。如果 PTE 无效且alloc
参数为真,walk
会分配一个新的页表页面,并将其物理地址写入 PTE。它返回树中最低一级的 PTE 地址。
-
-
mappages
:-
功能:为新映射安装 PTE。
-
实现:
mappages
将一个范围的虚拟地址映射到同等范围的物理地址。它以页面大小为间隔,为范围内的每个虚拟地址单独执行映射。对于每个虚拟地址,调用walk
查找 PTE 地址,然后初始化 PTE,保存物理页号、权限标志和有效标志。
-
-
kvminit
:-
功能:初始化内核页表。
-
实现:
-
分配一个物理内存页来保存根页表页。
-
调用
kvmmap
安装内核需要的转换,包括内核的指令和数据、物理内存的上限到PHYSTOP
,以及设备内存。
-
-
-
kvmmap
:-
功能:安装内核需要的转换。
-
实现:
-
调用
mappages
为内核的指令和数据、物理内存和设备内存安装映射。
-
-
-
procinit
:-
功能:为每个进程分配一个内核堆栈。
-
实现:
-
调用
kvmmap
将每个堆栈映射到由KSTACK
生成的虚拟地址,为无效的堆栈保护页面留出空间。
-
-
-
sfence.vma
:-
功能:刷新当前 CPU 的 TLB。
-
实现:
-
在重新加载
satp
寄存器后,在kvminithart
中执行sfence.vma
。 -
在返回用户空间之前,在
trampoline
代码中执行sfence.vma
。
-
-
3.7 代码:sbrk
Sbrk
是一个系统调用,用于进程扩展或收缩其内存空间。该系统调用由 growproc
函数实现(kernel/proc.c:239
)。growproc
根据参数 n
的正负值调用 uvmalloc
或 uvmdealloc
。uvmalloc
(kernel/vm.c:229
)使用 kalloc
分配物理内存,并通过 mappages
将 PTE 添加到用户页表中。uvmdealloc
调用 uvmunmap
(kernel/vm.c:174
),后者使用 walk
查找 PTE 并通过 kfree
释放它们所引用的物理内存。
Xv6 使用进程的页表不仅是为了告诉硬件如何映射用户虚拟地址,而且作为记录分配给该进程的物理内存页面的唯一记录。这就是为什么释放用户内存(在 uvmunmap
中)需要检查用户页表的原因。
3.8 代码:exec
Exec
是创建用户地址空间的系统调用。它从文件系统中的文件初始化用户地址空间的用户部分。Exec
(kernel/exec.c:13
)使用 namei
(kernel/exec.c:26
)打开命名的二进制路径,namei
的实现将在第 8 章中解释。然后,它读取 ELF 头。Xv6 应用程序使用广泛使用的 ELF 格式描述,该格式在(kernel/elf.h
)中定义。ELF 二进制文件由 ELF 头(struct elfhdr
,kernel/elf.h:6
)开始,后面跟着一系列程序段头(struct proghdr
,kernel/elf.h:25
)。每个 proghdr
描述了必须加载到内存中的应用程序的一个段;Xv6 程序只有一个程序段头,但其他系统可能有单独的指令和数据段。
第一步是对文件进行快速检查,以确定它可能包含 ELF 二进制文件。ELF 二进制文件以 4 字节的“魔数”0x7F、'E'、'L'、'F' 开始,或者说是 ELF_MAGIC
(kernel/elf.h:3
)。如果 ELF 头具有正确的魔数,exec
假设二进制文件格式良好。
Exec
使用 proc_pagetable
(kernel/exec.c:38
)分配一个没有用户映射的新页表,使用 uvmalloc
(kernel/exec.c:52
)为每个 ELF 段分配内存,并使用 loadseg
(kernel/exec.c:10
)将每个段加载到内存中。loadseg
使用 walkaddr
查找分配的内存的物理地址,用于写入 ELF 段的每一页,并使用 readi
从文件中读取。
/init
的程序段头(第一个使用 exec
创建的用户程序)看起来像这样:
plaintext复制
# objdump -p _init
user/_init:
file format elf64-littleriscv
Program Header:
LOAD off 0x00000000000000b0 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**3
filesz 0x0000000000000840 memsz 0x0000000000000858 flags rwx
STACK off 0x0000000000000000 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**4
filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw
程序段头的 filesz
可能小于 memsz
,表明它们之间的差距应该用零填充(用于 C 的全局变量),而不是从文件中读取。对于 /init
,filesz
是 2112 字节,memsz
是 2136 字节,因此 uvmalloc
分配足够的物理内存以容纳 2136 字节,但只从文件 /init
中读取 2112 字节。
接下来,exec
分配并初始化用户栈。它只分配一个栈页面。Exec
将参数字符串逐个复制到栈的顶部,并在 ustack
中记录它们的指针。它在将成为传递给 main
的 argv
列表的末尾放置一个空指针。ustack
的前三个条目是伪造的返回程序计数器、argc
和 argv
指针。
Exec
在栈页面下方放置一个不可访问的页面,以便尝试使用超过一个页面的程序会触发故障。这个不可访问的页面还允许 exec
处理过大的参数;在这种情况下,exec
使用的 copyout
函数(kernel/vm.c:355
)将参数复制到栈中,它会注意到目标页面不可访问,并返回 -1。
在准备新的内存映像期间,如果 exec
检测到错误(如无效的程序段),它会跳转到 bad
标签,释放新的映像,并返回 -1。Exec
必须等到确定系统调用将成功后才能释放旧的映像:如果旧的映像不存在,系统调用无法向其返回 -1。Exec
中的错误情况仅发生在创建映像期间。一旦映像完成,exec
可以提交到新的页表(kernel/exec.c:113
)并释放旧的页表(kernel/exec.c:117
)。
Exec
从 ELF 文件中加载字节到内存中的地址由 ELF 文件指定。用户或进程可以在 ELF 文件中放置任何地址。因此,exec
是有风险的,因为 ELF 文件中的地址可能指向内核,无论是偶然还是故意。对粗心的内核来说,后果可能从崩溃到恶意破坏内核的隔离机制(即安全漏洞)。Xv6 进行了一系列检查以避免这些风险。例如,if(ph.vaddr + ph.memsz < ph.vaddr)
检查总和是否溢出 64 位整数。危险在于用户可以构造一个 ELF 二进制文件,其 ph.vaddr
指向用户选择的地址,并且 ph.memsz
足够大,以至于总和溢出到 0x1000,这看起来像是一个有效的值。在一个旧版本的 xv6 中,用户地址空间也包含内核(但在用户模式下不可读/写),用户可以选择一个对应于内核内存的地址,从而将 ELF 二进制文件中的数据复制到内核。在 RISC-V 版本的 xv6 中,这种情况不会发生,因为内核有自己的单独页表;loadseg
加载到进程的页表中,而不是内核的页表中。
内核开发人员很容易遗漏一个关键的检查,而现实世界的内核有着漫长的遗漏检查的历史,这些检查的缺失可以被用户程序利用以获取内核权限。Xv6 很可能没有完全验证提供给内核的用户级数据,恶意用户程序可能会利用这一点来绕过 xv6 的隔离机制。
3.9 现实世界中的操作系统
1. 分页硬件的使用
像大多数操作系统一样,Xv6 使用分页硬件进行内存保护和映射。大多数操作系统通过结合分页和页错误异常(将在第 4 章讨论)来更复杂地使用分页。
2. 直接映射的简化
Xv6 通过内核的虚拟地址和物理地址之间的直接映射来简化设计,并假设地址 0x80000000
处有物理 RAM,内核将在此加载。这在 QEMU 中可以工作,但在真实硬件上,这是一个糟糕的设计。真实硬件将 RAM 和设备放置在不可预测的物理地址上,例如,0x80000000
处可能没有 RAM,而 Xv6 却期望在那里存储内核。更复杂的内核设计利用页表将任意的硬件物理内存布局转换为可预测的内核虚拟地址布局。
3. 物理地址保护
RISC-V 支持在物理地址级别进行保护,但 Xv6 没有使用这一特性。
4. 超级页面(Super Pages)
RISC-V 支持“超级页面”,在拥有大量内存的机器上,使用超级页面可能更有意义。小页面在物理内存较小时很有意义,允许以细粒度进行分配和换页到磁盘。例如,如果一个程序只使用 8KB 的内存,为其分配一个完整的 4MB 超级页面是浪费的。在拥有大量 RAM 的机器上,大页面可能更有意义,并且可以减少页表操作的开销。
5. 内核内存分配器
Xv6 内核缺乏类似 malloc
的分配器,无法为小对象提供内存,这限制了内核使用复杂数据结构的能力。内存分配是一个永恒的热门话题,基本问题是高效利用有限的内存并为未知的未来请求做好准备 [7]。如今,人们更关注速度而不是空间效率。此外,更复杂的内核可能会分配许多不同大小的小块内存,而不仅仅是(如 Xv6 中的)4096 字节块;真正的内核分配器需要处理小分配和大分配。
3.10 练习
1. 解析 RISC-V 的设备树以查找计算机的物理内存数量
设备树(Device Tree)是一种描述硬件设备的机制,用于在启动时向操作系统传递硬件信息。你可以通过解析设备树来查找计算机的物理内存数量。这通常涉及到读取设备树的内存节点,并提取内存的起始地址和大小。
示例代码:
c复制
// 假设设备树存储在某个内存地址
void parse_device_tree() {
// 示例:解析设备树的内存节点
// 这里需要根据具体的设备树格式进行解析
// 通常设备树是一个二进制结构,需要使用特定的工具或库来解析
}
2. 编写一个用户程序,通过调用 sbrk(1)
增加其地址空间 1 字节
运行该程序并调查调用 sbrk
之前和之后的程序页表。内核分配了多少空间?新内存的 PTE 包含什么?
示例代码:
c复制
#include <unistd.h>
#include <stdio.h>
int main() {
// 调用 sbrk(1) 增加 1 字节
sbrk(1);
// 打印消息
printf("Address space increased by 1 byte.\n");
return 0;
}
调查方法:
-
使用 GDB 调试工具观察调用
sbrk
之前和之后的页表。 -
检查新分配的页面的 PTE,确认其权限和物理地址。
3. 修改 Xv6 以使用超级页面
超级页面是一种更大的页面大小,通常用于减少页表的大小和管理开销。修改 Xv6 以支持超级页面需要:
-
修改页表管理代码以支持更大的页面大小。
-
修改内存分配器以分配超级页面。
修改建议:
-
在
vm.c
中修改mappages
和uvmalloc
函数以支持超级页面。 -
确保内核能够正确处理超级页面的映射和访问。
4. 修改 Xv6 以使用户程序解引用空指针时引发异常
默认情况下,Xv6 允许用户程序访问虚拟地址 0。修改 Xv6 以使虚拟地址 0 对用户程序不可用,从而在解引用空指针时引发异常。
修改建议:
-
在内核的页表中将虚拟地址 0 的 PTE 设置为无效(
PTE_V
未设置)。 -
确保内核在处理页错误异常时正确处理这种情况。
示例代码:
c复制
// 在内核初始化时设置虚拟地址 0 的 PTE 为无效
void setup_null_pointer_protection() {
pagetable_t pagetable = ...; // 获取当前页表
pte_t *pte = walk(pagetable, 0, 0); // 查找虚拟地址 0 的 PTE
if (pte) {
*pte = 0; // 设置 PTE 为无效
}
}
5. 为 Xv6 实现对 Shell 脚本的支持
传统的 Unix exec
实现对 Shell 脚本有特殊处理。如果要执行的文件以 #!
开头,则第一行被视为运行文件的解释器。例如,如果 exec
被调用来运行 myprog arg1
,而 myprog
的第一行是 #!/interp
,则 exec
会运行 /interp
,命令行为 /interp myprog arg1
。
实现建议:
-
修改
exec
函数以检查文件的前两个字节是否为#!
。 -
如果是,解析第一行以获取解释器路径,并调用解释器。
示例代码:
c复制
int exec(char *path, char **argv) {
// 检查文件是否为 Shell 脚本
if (is_shell_script(path)) {
char interp_path[256];
get_interpreter(path, interp_path);
return exec(interp_path, argv);
}
// 正常执行
return load_and_run(path, argv);
}
6. 为内核实现地址空间随机化
地址空间随机化(ASLR)是一种安全机制,通过随机化进程的地址空间布局来防止某些类型的攻击。实现 ASLR 需要:
-
在进程启动时随机化其地址空间的基地址。
-
修改内核以支持随机化的内存布局。
实现建议:
-
在
proc.c
中修改进程初始化代码以随机化地址空间。 -
确保内核的页表管理代码能够处理随机化的地址。
示例代码:
c复制
void randomize_address_space() {
// 生成随机偏移量
uint64_t random_offset = ...; // 使用某种随机数生成器
// 设置进程地址空间的基地址
proc->base_address = KERNBASE + random_offset;
}
总结
这些练习涵盖了从设备树解析、内存分配、超级页面支持到 Shell 脚本处理和地址空间随机化的多个方面。通过这些练习,你可以更深入地理解 Xv6 的内存管理机制,并探索如何扩展和改进它以适应更复杂的场景。