从零开始的ARMv8操作系统内核实践 3 为内核启动MMU

有关本篇文章的代码,我已经上传至代码库的"enableMMU"分支

代码库地址

RiversJin/ToyOS: A simple SMP OS on ARMv8

在Aarch64架构中,MMU不但控制着地址翻译,还管理着内存的缓存,分享策略.在没有启动MMU时,数据缓存失效,指令缓存有效.

参考这个链接Can I enable and use D-Cache with disabled MMU? - Architectures and Processors forum - Support forums - Arm Community

在之前的hello the world章节,我们已经做到了通过串口打印出"Hello the world". 在这一章,为了提高运行速度,我们来启动CPU的MMU.为OS启动一个恒等映射的页表

ARMv8-A架构支持3种页表映射粒度4kib,16Kib和64Kib,支持2至4级的多级页表.树莓派的内存不大,那我们就精细一点,使用4k的粒度管理内存.

树莓派3B+的物理地址排布

在前面的章节,我们谈过,对于BCM2837,0x3f00_0000之后是与周边设备通信的MMIO区域,所以,我们可用的物理内存的范围,就是0x0至0x3eff_ffff,共计1008Mib.

ARMv8-A的虚拟地址

虽然说,在64位系统,指针都是64位的,但是通常来说,64位CPU,内部不会真的用64来识别虚拟地址,AMD64架构的地址空间是48位,Risc-V64是39位(我指的是SV39的实现,当然也可能有其他的实现,比如SV48). 给定一个64位虚拟地址,CPU会只关注低48位的值.(高位其实也有一些讲究,不同CPU架构的限制不一样,我们接下来会讲一讲ARMv8-A的)

ARMv8-A架构下,它的低位有效位数(也就是虚拟地址空间大小),是可以配置的,最大48位,最小12位. 不过,为了方便页表的对齐,我们通常会设置一些特定的位数,比如48,39,30. 在代码中,你可以看到有关T0SZ,T1SZ的设置,就是用来配置这个项目的. 有效位数为64-TxSZ (或者,也可以说TxSZ是用来设置高位无效位数的)

比如,我们如果希望使用48位的虚拟地址,就会将TxSZ设置为64-48=16.下面如果我们明确提出,就默认是48位的虚拟地址

另外,说到虚拟地址的高位,那就有意思了.在EL0,EL1的异常级别下,ARMv8-A将虚拟地址分为两半,分别通过两个页表进行控制.而选择哪一个页表,就是通过地址高位判断的.

在低48位以外,也就是[63:48]这16位,如果全为0,选择TTBR0_ELx指向的页表进行翻译;如果全为1,选择TTBR1_ELx指向的页表地址.如果是其他值,就会产生翻译错误.

(这个说法有点片面,其实也可以是其他值,用于内存tagging,但是对于我们有些超纲,咱们暂且装作不知道这个好了)

image

我们要将OS放在高地址区

现有的OS内核代码一般都会在虚拟地址的高地址区. 这其实可以说是一种历史传承了. 在DOS时代,CPU还不支持虚拟地址. 那时的应用程序会独享内存. 而且,那时的应用程序也都是将自己放在低地址区运行(因为当时内存大小有限,可用的内存范围在现在看来也都算低地址区).
而当CPU支持虚拟地址后,OS为了兼容之前的程序,就会将应用程序同样放在虚拟地址的低地址区域,对应地,将自身放置在高地址区域,确保自身数据不会和应用程序的数据重合.

在不考虑虚拟地址高位的情况下,我们如果通过页表实现一个低48位的恒等映射,并将这个页表安装在TTBR1寄存器上,那么就自然形成了0xFFFF_0000_0000_0000 ➡️ 0x0000_0000_0000_0000的映射.这个要怎么做呢?这就要进入下个小节了

ARMv8-A的地址翻译

同上文一样,我们讲的都是48位有效虚拟地址,4KiB的翻译粒度

ARMv8-A的地址翻译是非常灵活的,它最多支持4级页表,最少需要2级页表. 并且翻译时,可以支持不同级别的页表.

在ARM官方手册上,有一个这个图片,讲述了完全翻译到4KiB粒度的过程

image

在这个图中,我们可以看到,每一级的翻译,都会取虚拟地址中的9位数字,作为选择页表项中的偏移量

2^9=512,而每个表描述符的大小都是64位即8字节,合计4K. 正好放在一个4K页框里面,是不是很巧? 才不是,这是故意设计的 😛
另外这么说也不绝对,假如我们通过设置TxSZ使虚拟地址有效位是47位 那么这里面的Zero-Level Table里面就只需要256个项目. 但是这么搞.... 我强迫症捉急

详细的表描述符格式我下面会讲,不过这里先提一嘴.

对于ARMv8-A表描述符来说

  • 第0位 决定它是否有效,如果在MMU遍历到一个[0]处为0的描述符,会产生翻译错误 若为1 视为此描述符是有效的
  • 第1位 标记此描述符描述的是一块内存(block 值为0),还是下一级页表的地址(table 值为1)

这就是说,给定一个页表,其中的描述符,既可能是下一级的页表,可能直接指向一片内存区域. 通过这个特点,ARMv8-A可以在同一个页表下,实现不同粒度的翻译.

比如,0级页表,每个条目描述的是一个512G的虚拟内存空间;1级页表是1G; 2级是2M 3级是4K. 当然,也没有我说的这么灵活,其实还是有一些限制的. 0级页表中的描述符不能是block类型; 3级页表中的描述符不能是table类型(否则粒度就比4K还小了)

image

而且,ARMv8-A架构下,地址翻译也不一定是从0级页表开始的. 比如我们通过TxSZ设置虚拟地址有效位数为38.那么MMU会将TTBRx指向的页表为1级页表(而不再是0级).此时最多是3级页表.
如果虚拟地址空间再小一点,也可以将根级页表视为2级

构造恒等映射的页表

在代码中,我直接硬编码了一个内核用的页表(kernel/arch/aarch64/board/raspi3/kpg.c).

在Linux代码中,四级页表的名字依次是PGD->PUD->PMD->PTE,我们沿用这些名字(起名很麻烦啊淦)

实现是这样的:

image

在这张图里面,绿色代表指向下一级页表的描述符,蓝色代表指向普通内存的描述符,红色代表指向设备内存的描述符. 至于设备内存和普通内存的区别.限于篇幅,我下一章再将.

另外要注意的一点是,在描述符中地址也好,在TTBR(页表基址寄存器)上设置的地址也好.它们全都是物理地址,道理很浅显: 页表本身是用来翻译虚拟地址和物理地址的.如果它是基于虚拟地址的,就需要再来一个"页表的页表",这就无限递归了. 在代码中,我也会给出指示.

从这个图中,我们可以看出,在PGD,PUD的表中,我们设置的有效的内存范围是0-2G. 其中,0-1007M是普通内存,1008M以上到2G即[1008M,2G)是设备内存.

或者 普通物理内存的地址范围是[0x0-0x3eff_ffff] 设备内存的地址范围是[0x3f00_0000,0x7fff_ffff]

如果我们将TTBR1_EL1指向此页表,那么就会形成虚拟地址 [0xFFFF_0000_0000_0000,0xFFFF_0000_7FFF_FFFF] 到 物理地址[0x0,0x3EFF_FFFF]的映射.

同理,我们将TTBR0_EL1也指向此页表,形成[0x0,0x3EFF_FFFF]虚拟地址和物理地址的恒等映射.

之前提到过,我们要将内核代码放置到0xFFFF_0000_0000_0000的高地址区,而CPU在使能MMU前,PC计数器还指向低地址区,此时如果没有配置低地址区(即TTBR0_EL1)的恒等映射,在启动MMU后的下一条指令,也就是PC指向的低地址区域在虚拟地址空间中属于无效区域,会立即引发MMU翻译错误,不能继续执行.所以低地址区域的恒等映射在这里也是非常必要的.

代码

这一小节来聊聊相对于上一章节的代码改动

linker.ld

在链接脚本中,我们将起始地址改为0xFFFF000000080000.这样在编译出的elf文件中,各个函数,各个段的地址就会位于高地址区域了.虽然bootloader依旧只是将数据放置在0x80000处,但通过上文实现的地址映射.物理地址0x80000对应了虚拟地址0xFFFF_0000_0008_0000.所以在开启MMU前后,不会影响代码的执行

entry.S

先给出这个jump_to_main的反汇编结果(这个内容可以在build/kernel8.asm文件中找到)

jump_to_main:
// 此时CPU已经工作在EL1状态
    /* 配置页表 */
    adr     x2, kpgd // 取得页表地址
ffff000000080084:   10007be2    adr x2, ffff000000081000 <kpgd>
    msr     ttbr0_el1, x2
ffff000000080088:   d5182002    msr ttbr0_el1, x2
    msr     ttbr1_el1, x2
ffff00000008008c:   d5182022    msr ttbr1_el1, x2
    ldr     x3, =(TCR_VALUE)
ffff000000080090:   58000283    ldr x3, ffff0000000800e0 <jump_to_main+0x5c>
    msr     tcr_el1, x3
ffff000000080094:   d5182043    msr tcr_el1, x3
    ldr     x4, =(MAIR_VALUE)
ffff000000080098:   58000284    ldr x4, ffff0000000800e8 <jump_to_main+0x64>
    msr     mair_el1, x4
ffff00000008009c:   d518a204    msr mair_el1, x4
​
    /* 使能MMU */
    mrs     x5, sctlr_el1
ffff0000000800a0:   d5381005    mrs x5, sctlr_el1
    orr     x5, x5, #SCTLR_MMU_ENABLED
ffff0000000800a4:   b24000a5    orr x5, x5, #0x1
    msr     sctlr_el1, x5     
ffff0000000800a8:   d5181005    msr sctlr_el1, x5
​
    ldr     x0, =_entry
ffff0000000800ac:   58000220    ldr x0, ffff0000000800f0 <jump_to_main+0x6c>
    mov     sp, x0 // 先暂时将_entry作为内核栈的顶部
ffff0000000800b0:   9100001f    mov sp, x0
    
    ldr     x1, =main
ffff0000000800b4:   58000221    ldr x1, ffff0000000800f8 <jump_to_main+0x74>
    br      x1
ffff0000000800b8:   d61f0020    br  x1

在jump_to_main中,我们新增了配置页表,使能MMU的功能.这里有两个有趣的地方值得一提.

首先,我们可以看到kpgd位于地址ffff000000081000,显然这是一个高地址区域的虚拟地址,而对于jump_to_main中加载页表的指令"adr x2, kpgd"反汇编的结果也是

"ffff000000080084: 10007be2 adr x2, ffff000000081000 <kpgd>"

但我之前提到过,页表寄存器和页表描述符中,需要的是物理地址,但这里是虚拟地址. 这会不会有一些问题呢?

当然是不会啦,你应该注意到,我用的是"adr",而不是ldr或者mov之类的赋值指令.

ARM Software Development Toolkit Reference Guide

ARM官网上提到,"The ADR pseudo-instruction loads a program-relative or register-relative address into a register."

也就是说,这个伪指令,会记录当前PC与目标值的差,然后通过这个差来计算真正赋值到寄存器的值.

在elf文件中,adr所在指令位置是ffff000000080084,目标值是ffff000000081000

而在实际运行时,运行adr指令时PC的值应该是对应的0x80084,而kpgd对应的物理地址是0x81000.这两个值的查当然是与上面的两个虚拟地址之差一致(恒等映射嘛)

所以,这条指令,虽然反编译出的结果是 adr x2, ffff000000081000,而在实际运行时,仍然会将真正的物理地址放入x2. (通过gdb单步调试汇编,就能证明这个说法)

其次,是最后跳转到C语言main函数的地方

ldr x1, =main
br x1

为什么我们不直接用b main呢?

这个问题也很有趣. 这个ldr与adr可不一样,它向x1赋值的值,就是ELF文件中main的地址ffff0000000800f8

而在entry.S中,我们还运行在低地址区域0x80000附近.

也就是说,我们要让PC计数器从0x80000跳到0xffff0000000800f8.

这差不多跨越了200多TB的虚拟内存空间.而在Aarch64中,B指令只支持相对于当前PC+128MB范围内的跳转.(更细致来说,这个机制与指令集编码有关)

所以,一定要用br不能用b

浅谈OS如何获知可用内存

在上面,我们提到,树莓派3b+的可用物理内存范围是0x0至0x3eff_ffff,但这是我们通过查阅手册得出的,会硬编码到代码中(对应宏定义PHY_STOP).

如果是一个通用的OS,这么做肯定是不好的,不可能适配一种设备就在代码里面改一遍宏定义,对吧. 那么,对于一个真正的OS,比如Windows,Linux,它们是怎么做的呢? 以下是我的一些见解,如果不对,欢迎指教

在x86-64平台,OS通常会通过UEFI或者BIOS获得可用内存信息.

而在Arm平台,Linux会使用设备树定义,获知内存,外设等信息.

就拿树莓派举例吧,我们可以查看Linux的与之相关的设备树定义代码,在

bcm2837-rpi-3-b-plus.dts

其中有一个"memory"项,说明了在树莓派3B+上的内存信息,并且,在bcm2837.dtsi这个文件中,我们还能获知如何启动多核心(自旋表 Spin Table)等信息.可以说,对于嵌入式设备,Linux的设备树文件,在某种角度上来说仍然靠硬编码实现的.

而Arm平台上的Windows,就更是重量级了.它倒是不用设备树,它需要UEFI,也就是说,在Arm平台上启动的Windows,需要在对于平台上再实现一个UEFI才行.

那如果没有UEFI,BIOS或者设备树的情况下,OS还有什么办法呢?

其实有一个比较笨的方案,这个方案也是很久很久之前BIOS获得可用内存大小的方法(那个上古时代还没有UEFI呢),就是一个一个试:

逐内存单元写入某些特定的值,然后读取. 如果这个地址没有对应的内存单元,那么这个写入肯定是不成功的,在x86上的实现我记得是会丢弃写入,读取永远为0(而某些虚拟机则不然,读取可能永远为0xFF或者其他什么稀奇古怪的值,这也是程序区分当前是否运行在虚拟机上的一个方法)

END

接下来,我们要做内存管理系统.

内核的内存管理算法一般会用Buddy System(Linux)或者我们也可以用FreeList算法.我决定都试一试.别急 我们慢慢来.

另外,在这篇文章中,有关页描述符的格式,什么是设备内存,什么是普通内存,也还没说.

严格来说,这不算我们实现操作系统要知道的主线任务,但如果是在ARMv8平台上实现OS,那这块内容也是非常必要的,我会再出一篇文章讲有关的内容.网上搜的东西比较乱,而ARM手册上的大片大片的英文术语看着也挺搞心态的.希望我能帮助到以后有需要的人吧.

posted @ 2024-01-29 00:08  RiversJin  阅读(279)  评论(0)    收藏  举报