予焦啦!RISC-V 虚拟记忆体机制简说

本节是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 为基准做的实验

予焦啦!昨日最後观察到的错误来自於连结器在连结时期就已经将虚拟记忆体位址存放在最後产出的可执行档的资料区内。正常情况下来说,Golang 的可执行档并没有预期自己会面临到记忆体位址切换的情况,这通常只有系统软件才会如此操作。

延续昨日,我们没有办法再得过且过、修修改改就期望能够通过 Golang 的执行期初始阶段了。我们得支援虚拟记忆体才行,而且就是现在、马上。

本节重点概念

  • 基础
    • 虚拟记忆体机制
  • RISC-V
    • Sv39 分页(paging)机制
    • Sv39 的虚拟记忆体转换过程

虚拟记忆体

现代的作业系统为了更有弹性地处理记忆体管理的诸般问题,收敛至今的一个通用解决方案就是虚拟记忆体系统。也就是说,CPU 本身执行一行行指令的时候,无论是它抓取指令时需要读取的 pc 所代表的位置,或是指定某个区域的读取或写入,它所经手的那些位址,实际上都不是硬体的记忆体模组真正接收到的东西。

一个简单的类比是地址。物理上来讲,针对每一间房子,我们都可以计算出它的经纬度,但这其实对於户政事务所来说不是很好管理。比较简单的方法,还是帮每一条路制定路名,然後 1 号、2 号、3 号加以排列。所以其实门牌号码正有点像是虚拟位址,而背後有真正独一无二的经纬度位址。经过行政区重划,甚至天翻地覆的改朝换代,地址会随着时间迁移,但那个地点本身的经纬度,以地球的尺度来讲是不太会改变的。

回到 RISC-V 系统开机的过程来理解。一个系统启动时,平台本身的重设(reset)机制能确保系统在初始化状态。之後的第一行、第二行、第三行等等的指令在执行的时候,当然是还没有虚拟记忆体这样的机制的。这时候系统使用的是在机器模式(M-mode),主要处理低阶的初始化,并且准备将系统的主控权转给作业系统。这阶段中使用的记忆体位址都还是实体记忆体位址,就如我们目前为止的实验一样。我们使用的位址大多在 0x802xxxxx 的部份,这是 QEMU 的 RISC-V 通用平台的物理记忆体位址。

转移到作业系统模式(S-mode)之後的状况又是如何?以 Linux 为例,在它非常早期的阶段,它就已经建立核心部分的页表(page table),从而在剩下的绝大部分的核心生命周期中,真正使用的都是虚拟位址。这对於核心本身的意义,就是要能够方便调度管理。作业系统希望对整个系统有更彻底的控制,当然也包含记忆体的权限管理与分配,因此 CPU 会提供相关的功能给作业系统操作。又,日後进到使用者空间之後,所有的行程也因此可以受惠,因为那些行程大多在被编译的时候是使用预设的连结器脚本(linker script),因而在 ELF 档中使用的都是同一个区段的虚拟位址。一旦虚拟位址转换的机制启用,作业系统就能够分别对应这些行程的虚拟位址到不同的物理位址去,避免混淆。

以下笔者常会交互使用物理位址实体位址

虚拟记忆体名词解释

特权指令规格书中的4.34.5章分别介绍了当前 RISC-V 支援的三种虚拟记忆体转换模式:

  • Sv32:仅支援 32-bit 系统
  • Sv39:是当前 64-bit 系统通常支援的模式,理论上可以花费 1G 的空间存放页表而管理 512GB 的记忆体
  • Sv48:理论上可以支援到256TB

我们将以 Sv39 作为主要的支援模式。接下来,这里解释一些马上就会用到的概念:

  1. satp 控制暂存器:Supervisor Address Translation and Protection,代表虚拟记忆体位址转换与相关的保护设定。历史上,这个暂存器曾经在 1.9 版以前的权限指令规格书中被称为 sptbr:Supervisor Page Table Base Register,也许是虽不能尽表其义却更一目了然的描述方式。这个控制暂存器是位址转换的起点,其格式为(请参见权限指令规格书 4.1.0)
 |63  60|59  44|43                   0|
 +------+------+----------------------+
 | mode | ASID | physical page number |  
 +------+------+----------------------+ 
  1. Sv39 虚拟记忆体:虚拟记忆体有效位址(effective address)虽然是 64 位元,但是这个模式之下,从 39 到 63 位元都必须与第 38 位元相等才行。格式如下:
|38    30|29    21|20    12|11          0|
+--------+--------+--------+-------------+ 
| VPN[2] | VPN[1] | VPN[0] | page offset | 
+--------+--------+--------+-------------+

其中,VPN 代表虚拟页面编号(virtual page number),而中括号内的数字代表将虚拟页面编号分成三组,各占 9 个位元。最後留有页面偏移量的 12 位元。

与各位读者说声抱歉:先前自系列文开始,笔者就将 ethanol 的程序码区段设置在 0xffffff8000000000 位址,但其实这是不合规格的 Sv39 位址!因为第 38 位元为 0,但第 39 位元之後又都是 1。自今日起,笔者已将之更正为 0xffffffc000000000 了。但先前就开始存取 github 的读者不必担心,因为目前的版本已经是修正过的版本。

  1. Sv39 物理记忆体格式:
|55    30|29    21|20    12|11          0|
+--------+--------+--------+-------------+
| PPN[2] | PPN[1] | PPN[0] | page offset |
+--------+--------+--------+-------------+

与虚拟记忆体格式唯一不同之处在於,PPN[0] 占据 26 个位元而非 9 个位元。这是因为,在 Sv39 模式当中,合於规格的物理页面编号应该是 44 个位元,如同 satp 控制暂存器中的宽度。
4. 页面(page):Sv39 模式底下,一个页面的大小是 4096 (也常写作 4K 或是 16 进位下的 0x1000)个位元组。各位读者可以留意到,虽然虚拟记忆体与物理记忆体位址格式略有不同,但最後都有 12 个位元个空间用来表示页面偏移量(page offset),因为 12 个位元的空间恰好可以用来定位一个页面内的每一个位元组的位置。
5. 页表(page table):整个位址转移的过程,就是相关的硬体(MMU 或 TLB)以虚拟位址为输入,物理位址为输出。其中的秘诀,是因为有一个概念上呈现树状的页表让硬体能够查询并对照。
6. 页表项(page table entry,PTE):页表的最小单位,每一笔 页表项都会对应到一个页面,内容记载着该页面的属性(低位的 8 个位元),以及与物理记忆体位址格式一样的、分成三段的物理页面编号:

|63     54|53    28|27    19|18    10|9   8|7|6|5|4|3|2|1|0|
+---------+--------+--------+--------+-----+-+-+-+-+-+-+-+-+
| reserve | PPN[2] | PPN[1] | PPN[0] | RSW |D|A|G|U|X|W|R|V|
+---------+--------+--------+--------+-----+-+-+-+-+-+-+-+-+

RISC-V 的位址转换

上一小节的名词解释相当於是演员介绍,位址转换的过程则相当於是正式演出了。假设一个虚拟位址 0xffffffc013579bdf 要转换成物理位址 0x93779bdf 的话,在 satp 控制暂存器与记忆体中的页表内容都必须有相对应的设置与硬体的转换流程。接下来我们拆解整个流程,并且边走访边设计路过的页表项,使得这个转换能够成功。

起点:satp

整个建构完成的页表会呈现树状结构,最深可以达到三层之多,而satp 的物理页面编号(PPN)就是页表的根。现在为了作为范例,我们就随意假设一个物理位址作为页表的根:0x80100000。需注意的是,物理位址要转换为物理页面编号的话,需要除以一个页面大小,也就是除以 4096,或是逻辑运算的右移 12 个位元:0x80100 再扩充为 44 位元的 PPN 区段。

设置完成後的 satp 会像是:

  1. MODE 的内容是代表 Sv39的 8 (这是从规格书上对应的编码)
  2. ASID 我们不需用到,设为 0
  3. PPN 设置为 0x80100,未展示的第 20 位元到第 43 位元为 0

第一层页表

一个页面的大小是 4096 个位元组,而一笔页表项的大小是 8 个位元组,也就是说,每一个页面可以存放 512 个页表项。

上个小节,satpPPN 设置了 0x80100,代表页表的根,也就是说,这一组虚拟到物理的记忆体位址转换的第一站,这第一个页表项的 8 个位元组,实际上位在该页面(也就是范围 0x80100000~0x80101000)。然而,又是 512 当中的哪一个页表项呢?

决定这件事情的,就是 VPN[2] 项,也就是虚拟记忆体位址的第 38 位元到第 30 位元的这 9 个位元。9 个位元,恰好可以表示最小为 0,最大为 511 的数值,也就作为定位页表的页面当中的特定页表项的索引。若是 0 的话,就对应到 0x80100000 的页表项;1 的话,就对应到 0x80100008;2 的话,就对应到 0x80100010;511 的话,就是最後一个页表项的 0x80100ff8 。相当於是将 VPN[2] 乘以 8,再加上该物理页面位址。

以这个例子来说,0xffffffc013579bdfVPN[2] 相当於是 b'100000000,最左端的位元 1 是来自 c 的尾端的位元,而其余都是 0,是因为最靠近的位元 1 在第 28 个位元的部分。

所以,第一层的页表项位在 0x80100800

第一层的页表项

我们这个假定的情境,没有预先设定记忆体内容,所以这里可以随便我们设计。这里就令 0x80100800 起算 8 个位元组内的这个页表项的值为 0x00000000_20040401。对应到先前介绍页表项的每一个栏位,拆解如下:

  • V(第 0 个位元):有效与否的指示位元。如果这个位元的值是 0 的话,那麽这 8 个位元组当中的资料都不是有效的页表项。所以这里当然是 1。
  • R(第 1 个位元):对应到的页面是否具有读取权限。这里是 0。
  • W(第 2 个位元):对应到的页面是否具有写入权限。这里是 0。
  • X(第 3 个位元):对应到的页面是否具有执行权限。这里是 0。怪了,综合读取、写入与执行三种权限来看,这个页面都是没有权限的话,那到底对应到的有效页面又能做什麽?这个就是 RISC-V 的一个设计:如果页表项所对应到的页面空有有效性质,却缺乏其他三项权限,那就是代表下一个层级的页表。
  • U、G、A、D(第 4 到第 7 位元):其他我们尚未使用的属性,这里都是 0。
  • RSW(第 8 到第 9 位元):保留。这里都是 0。
  • PPN(第 10 到第 53 位元):这个页表项所对应的物理页面编号。这里是 0x80101
  • Reserve(第 54 位元到第 63 位元):保留,这里都是 0。

所以,第二层的页表,物理位址在 0x80101000

第二层的页表项

和第一层取得页表项时的方法相同,只是当时的索引由 VPN[2] 取得,现在则要退一阶,使用 VPN[1] 的 9 个位元(第 29 到第 21 位元,也就是 b'010011010,也就是 0xffffffc013579bdf135 当中的 1 取 後 2 位元,3 取全部 4 位元,5 则取前 3 位元)。

读者可以自己验算,第二层的页表项,在 0x801014d0

我们令 0x00000000_20040801 为这个页表项的内容。由於权限位元与第一层完全相同,所以还有第三层的存在。计算 PPN 之後,不难推得第三层的页表在物理页面 0x80102000 之处。

第三层的页表项

VPN[0] 取得索引为 b'101111001,也就是说第三层的页表项为在 0x80102bc8 之处。

我们令 0x00000000_24dde40f 为这个页表项的内容。与第一层时不同的部分在於:

  • R、W、X 权限位元全部开启,可知对应到的物理页面权限全开,可读写也可执行。
  • 物理页面编号,可以从 0x24dde4 向右移 2 个位元计算而得,正是为了搭配我们原本的目标位址 0x93779bdf 所属页面的页面编号 0x93779。至此就大功告成了。

结论:所需配置的页表内容

// 状态暂存器内容,指定第一层页表为 0x80100000
// 最高位的位元代表 Sv39 的启用
satp = 0x80000000_00080100

// 第一层页表项设置,0x800 来自 VPN[2] 的 b'100000000
// PPN 为 0x80101,表示第二层页表为 0x80101000
*0x80100800 = 0x00000000_20040401

// 第二层页表项设置,0x4d0 来自 VPN[1] 的 b'010011010
// PPN 为 0x80102,表示第三层页表为 0x80102000
*0x801014d0 = 0x00000000_20040801

// 第三层页表项设置,0xbc8 来自 VPN[0] 的 b'101111001
// PPN 为 0x93779,表示对应到的物理页面为 0x93779000
// 权限位元已经全数设置,所以转换到此结束
*0x80102bc8 = 0x00000000_24dde40f

只要有这 satp 状态暂存器的设置搭配三个作为页表项的记忆体内容,那软件使用虚拟位址 0xffffffc013579bdf 的时候,就应当可以对应到物理位址 0x93779bdf 了。

这个范例里面,Sv39 最大的三层页表都已经走访完毕,而最後余下 12 个低位位元,是用来存取一个 4KB 页面内的偏移量。

RISC-V 可以支援页表项在第一层甚至第二层就取得足够的权限,而立刻完成转换。第一层就完成的情况,相当於是一个巨型页面(gigapage)的转换,偏移量就使用虚拟位址中的 VPN[1]VPN[0] 与最低位 12 个位元共 30 个位元;偏移量使用 30 个位元就代表可以定址 1GB 的内容。第二层就完成的状况,相当於是一个中型页面(megapage)的转换,偏移量就使用虚拟位址中的 VPN[0] 与最低位 12 个位元共 21 个位元;偏移量使用 21 个位元就代表可以定址 2MB 的内容。

启用虚拟记忆体

所以我们来做个实验吧!先使用物理位址 0x93779bdf,在这里设置一个位元组的值之後,按照前一小节的做法,先特别为这个页面设置转换用的页表,然後写入 satp 状态暂存器以启用虚拟记忆体。然後我们观察,先前设置的值是否能够透过虚拟位址 0xffffffc013579bdf 存取。

对应页表设置,在 src/runtime/rt0_opensbi_riscv64.s 当中:

diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index 5676184343..c062c0adea 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -79,5 +79,32 @@ zeroize:
        ADD     $8, T0, T0
        BLT     T0, T1, zeroize
 
+       // TEST: 'A' at 0x93779bdf
+       MOV     $0x93779bdf, T0
+       MOV     ZERO, T1
+       ADD     $0x41, T1, T1
+       SB      T1, 0(T0)
+       // Level 3
+       MOV     $0x80102bc8, T0
+       MOV     ZERO, T1
+       ADD     $0x24dde40f, T1, T1
+       SD      T1, 0(T0)
+       // Level 2
+       MOV     $0x801014d0, T0
+       MOV     ZERO, T1
+       ADD     $0x20040801, T1, T1
+       SD      T1, 0(T0)
+       // Level 1
+       MOV     $0x80100800, T0
+       MOV     ZERO, T1
+       ADD     $0x20040401, T1, T1
+       SD      T1, 0(T0)
+       // SATP
+       MOV     $0x80000000, T0
+       SLL     $32, T0, T0
+       ADD     $0x80100, T0, T0
+       CSRRW   CSR_SATP, T0, X0
+
        MOV     $runtime·rt0_go(SB), T0

这里的程序码并没有包含虚拟位址的检验,原因等一下我们就会看到了。但我们可以使用除错器,并在 satp 启用虚拟记忆体之後,观察记忆体的内容。

当然,我们也必须增加 satp 暂存器,(而且,由於这个改动会动到工具链,所以必须要重编)

diff --git a/src/runtime/opensbi/csr.h b/src/runtime/opensbi/csr.h
index 2ee6d54498..bfb7f7a880 100644
--- a/src/runtime/opensbi/csr.h
+++ b/src/runtime/opensbi/csr.h
@@ -7,3 +7,4 @@
 #define CSR_SEPC       $0x141
 #define CSR_SCAUSE     $0x142
 #define CSR_STVAL      $0x143
+#define CSR_SATP       $0x180

使用除错器检验虚拟记忆体

如前几日展示的那样,先启动 QEMU 模拟器

$ make run EXTRA_FLAGS='-S -s'             
make -C ethanol/                                
make[1]: 进入目录「/home/noner/FOSS/hoddarla/ithome/ethanol」
make[1]: 对「all」无需做任何事。                
make[1]: 离开目录「/home/noner/FOSS/hoddarla/ithome/ethanol」
qemu-system-riscv64 \                           
        -smp 4 \                                                                                
        -M virt \                               
        -m 512M \                               
        -nographic \                            
        -bios misc/opensbi/build/platform/generic/firmware/fw_jump.bin \
        -device loader,file=ethanol/goto/goto.bin,addr=0x80200000 \
        -device loader,file=ethanol/ethanol,addr=0x80201000,force-raw=on -S -s

注意,我们这里已经将记忆体大小(-m 参数)调整为 512MB,因为 0x93779xxx 位址已经超过原先的 256MB 大小了。

然後在另外一个终端机开启除错器:

$ riscv64-elf-gdb -ex "target remote :1234"                                                     
GNU gdb (GDB) 10.2 
Copyright (C) 2021 Free Software Foundation, Inc.
...
determining executable automatically.  Try using the "file" command.
0x0000000000001000 in ?? ()
(gdb) b *0x8025557c
Breakpoint 1 at 0x8025557c
(gdb) c
Continuing.
[Switching to Thread 1.4]

Thread 4 hit Breakpoint 1, 0x000000008025557c in ?? ()
(gdb) x/i $pc
=> 0x8025557c:  csrw    satp,t0

这个断点是预先使用 objdump 工具找到的。所以在这里为止,我们都还是在物理位址模式下运作。

(gdb) x/b 0x93779bdf                             
0x93779bdf:     0x41
(gdb) si
0x0000000080255580 in ?? ()
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     0x41

完全符合预期。我们也可以做一些周边的检验:

(gdb) x/b 0x93779bdf
0x93779bdf:     Cannot access memory at address 0x93779bdf
(gdb) set $satp=0
(gdb) x/b 0x93779bdf
0x93779bdf:     0x41
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     Cannot access memory at address 0xffffffc013579bdf

这时候,原先的物理位址已经无法使用。而若重新将 satp 写回 0 以关闭虚拟记忆体,物理位址就会又重新可以使用,反而是虚拟位址的存取会由除错器回报无法存取。

(gdb) set $satp=0x8000000000080100
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     0x41
(gdb) set *0xffffffc013579bdf=0x42
(gdb) x/b 0xffffffc013579bdf
0xffffffc013579bdf:     0x42
(gdb) set $satp=0
(gdb) x/b 0x93779bdf
0x93779bdf:     0x42

我们也可以反过来重新启用之後,修改记忆体内容,再关闭虚拟记忆体,且於物理位址验证确实内容已经遭到修改了。

所以,这就是成功了!

试跑

可以存取 github 以进行以下实验。

但如果直接执行现在的 ethanol,我们会得到的结果是:

$ qemu-system-riscv64 \                           
        -smp 4 \                                                                                
        -M virt \                               
        -m 512M \
...
Boot HART MHPM Count      : 0
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000b109
HI000000000000000c
000000008024fb40
000000008024fb40

卡在这里动弹不得!也出现了第一次看到的 scause 内容为 0xc 的例外。关於启动虚拟记忆体,我们才刚开始而已。

小结

予焦啦!我们今天彻底走过一轮 RISC-V 将虚拟记忆体位址转换为物理位址的过程,并且针对一个页面,实际操作所需要的修改。虽然针对该页面内容,系统行为符合预期,但是後续执行之後,不确定原因的卡住了。至於实际情况为何,我们马上就会探讨到。

各位读者,我们明日再会!


<<:  pure component

>>:  Day_07 有线网路应用(一)

day17 : kafka服务应用 on K8S (上)

kafka是一套与昨天的NATS类似的分布式MQ系统,会用这两套也不是想要做差异比较,单纯只是有多一...

相关是什麽?来认识回归分析

当我们的自变数和应变数都是定量资料的时候,我们就可以用回归分析的方法来从中找出两者之间的关系。简单的...

[DAY 22]纠团通知功能(2/3)

纠团的功能我把它切成两个部分 使用者输入讯息 背景执行 今天先介绍使用者输入讯息的部分 使用者输入讯...

Day28-Custom Hook

前言 我们学习了效能优化、生命周期、React状态等等,今天我们要来学习React的模组化,也就是c...

[面试][人格特质]一再被问的经典面试题

每个人的性格不同,不要去追求所谓的完美解答;而是去寻找适合自己的环境。 这边笔者依照类型统整了经常...