Arm 相关#
ch4 我们使能了虚拟内存,这一节的主要任务是完成页表设置。本节首先介绍页表相关的一些硬件规范,然后介绍页表的设置与切换。
VMSAv8-64 页表简介#
双页表与 KPTI#
armv8 的内核态拥有两张页表,页表基址寄存器分别对应 TTBR1_EL1
寄存器与 TTBR0_EL1
寄存器。其中:
虚拟地址以 0xFFFF 开头,也就是
0xFFFF_0000_0000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF
的地址使用TTBR1
作为基址寄存器。这部分对应内核虚拟地址。虚拟地址以 0x0000 开头,也就是
0x0000_0000_0000_0000 ~ 0x0000_FFFF_FFFF_FFFF
的地址使用TTBR0
作为基址寄存器。这部分对应用户虚拟地址。其余地址为非法地址。
doublept#
在 RISC-V 框架中,为了防止 Meltdown 漏洞,我们采取了 KPTI 策略。KPTI 的核心思路是在内核用户切换的跳板代码中切换页表,但是 arm 自然分为两个页表,通过访问不同的虚拟地址即可切换使用的页表(在用户态访问 0xFFFF 开头的地址会触发访问异常)。因此我们不需要在内核用户切换的时候切换页表,也不需要清空 tlb,对应代码与 ch2 相同。
页面大小选择#
armv8 页表支持 3 种页面大小,分别为 4KB, 16KB, 64KB,可在 TCR_EL1 (Translation Control Register) 中进行配置,该实验使用 4KB 页面大小。
虚拟地址#
虽然有 64 为虚拟地址,但我们已经知道高 16 位必须为全0或全1,否则地址非法。对于 4KB 大小的页面来说,其余位按照下图分配。4KB 页面使用一个 4 级页表进行翻译。
虚拟地址#
页表属性介绍#
页基址寄存器(以 TTBR0_EL1 为例)#
bits[63:48]: ASID,进程标识符,本实验不使用。
bits[47:12]: 顶层页表基址。
bits[11:0]: 通常为0 (本实验不考虑这些位)。
各级页表项属性#
如下图,Invalid 代表无效项,Block 代表底层页表(指向一个页或者大页),Table 表示指向了下一级页表。
最低两位属性:
bit[0]: valid,该级页表是否有效。
bit[1]: block,是否指向下一级页表,或者指向一个大页。有 2M, 1G 两种大小的大页。
为0,标志一个页面,地址位直接指向页面基址。
为1,标志下一级页表,地址位指向下一级页表。
顶层页表必须为1,底层必须为0,否则无效。
地址位:
bits[12:47]: 下一级页表基址或者页面基址。
其余位属性:

ARM#
完整含义可参考完整手册。其中我们需要关注的有:
UXN: User Execute-never, 页面在 EL0 是否不可执行。
PXN: Privileged execute-never,在 EL1 是否不可执行。
AP[2:1]: Access Permissions,页面在各特权级读写权限。
AP[2] = 0,表示可读可写。AP[2] = 1,表示只读。注意一个不可读的页面没有意义。
AP[1] = 0,表示仅 EL1 可访问,EL0 不可访问。AP[1] = 1,EL0 也可访问。
AF: Access Flag,是否允许加载该项进 TLB,初始通常设为 1。
AttrIndex: Attributes Index,选择 MAIR_EL1 中定义的 8 中内存区域类型。这里我们仅需要区分 device memory (MMIO 内存)与 normal memory (普通内存)即可。
初始页表配置#
在使能页表后,我们需要配置一个初始页表。配置函数如下:
// 0级页表
static mut BOOT_PT_L0: [PageTableEntry; 512] = [PageTableEntry::empty(); 512];
// 1级页表
static mut BOOT_PT_L1: [PageTableEntry; 512] = [PageTableEntry::empty(); 512];
unsafe fn init_boot_page_table() {
// 0级页表的第一项指向以下下一级页表,也就是前 512G 的映射取决于 `BOOT_PT_L1`。
// 0x0000_0000_0000 ~ 0x0080_0000_0000, table
BOOT_PT_L0[0] = PageTableEntry::new_table(PhysAddr::new(BOOT_PT_L1.as_ptr() as usize));
// 1级页表的第一项直接指向了一个 1G 的大页,且 0x0 指向 0x0,也就是第一个G为一个原地映射。属性为可读可写的 Device 内存。
// 0x0000_0000_0000..0x0000_4000_0000, block, device memory
BOOT_PT_L1[0] = PageTableEntry::new_page(
PhysAddr::new(0),
MemFlags::READ | MemFlags::WRITE | MemFlags::DEVICE,
true,
);
// 1级页表的第二项直接指向了一个 1G 的大页,且 0x4000_0000 指向 0x4000_0000,则第二个G的内存也为原地映射。属性为可读可写可执行的普通内存。
// 0x0000_4000_0000..0x0000_8000_0000, block, normal memory
BOOT_PT_L1[1] = PageTableEntry::new_page(
PhysAddr::new(0x4000_0000),
MemFlags::READ | MemFlags::WRITE | MemFlags::READ | MemFlags::EXECUTE,
true,
);
}
初始页表配置了 0x0 ~ 0x8000_0000
为原地映射,其中
0x0~0x4000_0000
为 Device 内存,0x4000_0000~0x8000_0000
为普通内存。由于我们的内核放在 0x4008_0000
起始的一段内存上,而我们需要的外设也都位于 Device
内存内,这样的配置可以正常执行。
此阶段不需要考虑用户地址,故可以如下这样设置,但起作用的是
TTBR0_EL1
。
TTBR0_EL1.set(BOOT_PT_L0.into());
TTBR1_EL1.set(root_paddr.into());
页表重映射#
在完成 boot 之后,我们会重新为内核建立映射,将内核地址放在 0xFFFF
开头的高位地址。也就是建立一个 offset = 0xffff_0000_0000_0000
的线性映射,这部分映射的代码可见 mm::memoryset::init_paging()
:
pub fn init_paging() {
let mut ms = MemorySet::new();
let mut map_range = |start: usize, end: usize, flags: MemFlags, name: &str| {
println!("mapping {}: [{:#x}, {:#x})", name, start, end);
assert!(start < end);
assert!(VirtAddr::new(start).is_aligned());
assert!(VirtAddr::new(end).is_aligned());
ms.insert(MapArea::new_offset(
VirtAddr::new(start),
PhysAddr::new(virt_to_phys(start)),
end - start,
flags,
));
};
// map stext section
map_range(
stext as usize,
etext as usize,
MemFlags::READ | MemFlags::EXECUTE,
".text",
);
// ..., map: rodata, data, bss, bootstack 段
// map other memory
map_range(
ekernel as usize,
phys_to_virt(MEMORY_END),
MemFlags::READ | MemFlags::WRITE,
"physical memory",
);
// map mmio memory
for (base, size) in MMIO_REGIONS {
map_range(
phys_to_virt(*base),
phys_to_virt(*base + *size),
MemFlags::READ | MemFlags::WRITE | MemFlags::DEVICE,
"MMIO",
);
}
// activate kernel page table
let page_table_root = ms.page_table_root();
KERNEL_SPACE.init_by(ms);
}
在完成该页表后,我们设置
TTBR1_EL1.set(KERNEL_SPACE.into()); // 也就是刚刚重新建立的现行映射
TTBR0_EL1.set(0); // 非法地址 0,这保证一旦访问低地址,会立即触发错误。
用户页表与页表切换#
ch4 开始使用 elf 程序作为用户程序,因此用户页表的建立与 elf
文件的解析相关联,具体代码参见loader::load_app()
。
此时,任务的任务控制块中多了 memory_set
属性:
pub struct Task {
id: usize,
status: TaskStatus,
+ memory_set: Option<MemorySet>,
kstack: Option<Box<Stack<KERNEL_STACK_SIZE>>>,
ctx: TaskContext,
entry: TaskEntryStatus,
}
该 memory_set 关联 TTBR0_EL1
,也就是用户部分的页表基址。
当发生任务切换时:TTBR0_EL1
设置为目标任务的 memory_set
对应基址,TTBR1_EL1
不变。若为内核任务,则设置 TTBR0_EL1
基址为0,也就是不能访问低地址:
fn switch_to(&self, curr_task: &Arc<Task>, next_task: Arc<Task>) {
// 设置即将运行的进程状态
next_task.set_state(TaskState::Running);
// 若 next_task = curr_task,直接退出
if Arc::ptr_eq(curr_task, &next_task) {
return;
}
// 获取上下文结构体指针
let curr_ctx_ptr = curr_task.context().as_ptr();
let next_ctx_ptr = next_task.context().as_ptr();
// 更新`当前进程`信息
PerCpu::current().set_current_task(next_task);
+ // 更换用户页表 TTBR0
+ unsafe { crate::arch::activate_paging(next_ctx_ptr.ttbr0_el1 as usize, false) };
// 调用 context_switch 函数完成切换
unsafe { context_switch(&mut *curr_ctx_ptr, &*next_ctx_ptr) };
}
由于改变用户页表(TTBR0
)不会影响内核空间映射,所以我们无需在汇编中完成这一操作。