予焦啦!在 ethanol 中启用虚拟记忆体

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

予焦啦!昨日直接瞄准一组虚拟到物理位址的转换,并展示每一个步骤所需要的资料。最後自由运行(free run)下去,却发现整个系统卡住了。其实启用虚拟记忆体,需要更周全的准备才行。

本节重点概念

  • RISC-V
    • SFENCEVMA 指令
    • 虚拟记忆体启动流程

分析:跑不出东西来是很正常的

还记得吗?我们只准备了三个页表项(page table entry),其中两个页表项是仅设置有效位元(V bit)的中继站,最後一个才是第三层的终端节点,给一组特定的 4KB 页面转换。所以若要使用虚拟位址存取该页面之外的记忆体空间,再不扩增更多页表项的话,是不可能的。笔者曾提及,完成更高的的页表会是树状结构(只有有效位元的页表项就是其中的节点。而终端节点的读取、写入或是可执行属性必然不可全为 0)。当然,昨日的一条鞭式设置广义来说是一种简化的树,但显然包含的范围不够广。

应该说,我们可以设想一下 satp 暂存器刚执行完写入之後的情境。在 csrw 指令过後,程序指标(pc)向前推进一道指令,落在的位置,其实也是个物理位址!毕竟,CPU 的撷取指令单元(instruction fetch)并没有厉害到知道我们改动位址或是如何改动,所以一定也是依序往前推进。而且,我们并没有为 0x80200000 这附近的值设置页表项,而是远在 0x93779xxx 这个页面才有

调整先前的设置

反正昨日也只是个范例,不妨就让我们来重新调整,将从 0x80202000 开始的程序码区域造成可以从 0xffffffc000002000 虚拟位址映射到的区域。

昨日的范例走访了完整三层页表项,制造了 4KB 的对应;但其实也如笔者注记中提及的那样,我们也可以制造大一点的对应。比方说,其实现阶段的 ethanol 非常小,只有 1MB 出头而已。所以我们可以改造成,只走两层页表项,对应到 2MB 的区域。

这需要以下的改动:

-       // TEST: 'A' at 0x93779bdf
...
-       // Level 3
...
        // Level 2
-       MOV     $0x801014d0, T0
+       MOV     $0x80101000, T0
        MOV     ZERO, T1
-       ADD     $0x20040801, T1, T1
+       ADD     $0x2008000f, T1, T1

其实只有第二层需要改动。原先测试用的写入 A 字元不再需要了,删去;第三层也不需要了,删去。第一层爲什麽不需要动呢?因为

0xffffff c01 3579bdf
和
0xffffff c00 0002000

这两个虚拟位址的 VPN[2] 一样都是 b'100000000,再加上我们没有调整 satp 控制暂存器,所以第一层的设定完全一样。

第二层,不像昨日的范例虚拟位址的 VPN[1] 有值。这里完全是 0,所以第二层的页表项就直接在 0x80101000 的位址。但这个页表项换算起来对应到 0x80200000 的物理页面编号,而不是 0x80202000,为什麽这样还能用呢?因为,这次在第二层结束了位址的转换,因此不只最後的 12 位元,连 VPN[0] 的 9 个位元也会被纳入,作为 2MB 大小的中型页面(megapage)的页面内偏移量。这里就是 0x002000(因为用的是十六进位表示法,多了最高位的 3 个 0),补上物理页面编号就成为我们的目标 0x80202000,没有问题。

使用除错器验证,也和昨天一样容易:

Thread 1 hit Breakpoint 1, 0x000000008025554c in ?? ()
(gdb) x/2gx 0x80202000
0x80202000:     0xfd010593010db503      0x00050f9700b56863
(gdb) x/2gx 0xffffffc000002000                                    
0xffffffc000002000:     Cannot access memory at address 0xffffffc000002000
(gdb) si
0x0000000080255550 in ?? ()
(gdb) x/2gx 0xffffffc000002000
0xffffffc000002000:     0xfd010593010db503      0x00050f9700b56863

可见,程序码内容都有确实对应出来。可是问题是,现在的情况也如前一小节的分析,这里的程序指标仍然对应到物理位址,没有跳转。就算在写入暂存器指令後立刻放置一道跳跃指令,也没有办法让它跳转,毕竟光程序指标就停留在物理位址了,CPU 根本无法取得该处的指令加以执行。

启用虚拟记忆体位址的技巧

但这当然是做得到的。老样子,我们可以参考作业系统老大哥 Linux 怎麽处理这个部分:

	la a1, kernel_virt_addr
...
    /* Point stvec to virtual address of intruction after satp write */
	la a2, 1f
	add a2, a2, a1
	csrw CSR_TVEC, a2

让我们直译这段注解:将 stvec 状态暂存器设定为,指向,写入 satp 的指令,之後的指令,的虚拟位址?为什麽要这麽做呢?我们先前已将 stvec 指向 early_halt ,当时生效的当然都是物理位址。

...

	/*
	 * Load trampoline page directory, which will cause us to trap to
	 * stvec if VA != PA, or simply fall through if VA == PA.  We need a
	 * full fence here because setup_vm() just wrote these PTEs and we need
	 * to ensure the new translations are in use.
	 */

直译的话是:载入跳跃用(trampoline)页表。若虚拟位址与物理位址不同,我们可以藉此跳到 stvec;反之,则可以直接继续执行下去。这里需要完整的篱(fence),因为 setup_vm() 函式才刚写入这些页表项,而我们必须确保新的转换正常运作。

...
	sfence.vma
	csrw CSR_SATP, a0
.align 2
1:
	/* Set trap vector to spin forever to help debug */
	la a0, .Lsecondary_park
	csrw CSR_TVEC, a0

白话解析

在第一段中的 la a2, 1f 效果是载入第三段的 1: 标签的位址到 a2 暂存器。但由於这时候还是物理位址,所以需要再加上 Linux 自己维护的虚拟位址偏移值,获得一个以虚拟位址表示的 1:,并将之作为错误发生时的进入点而写入 stvec

因此整个流程是像这样:当第三段中的 satp 控制暂存器的写入生效之後,虚拟位址启用(根据规格以及 CPU 提供的功能),之後 CPU 仍然会试着去下一行的物理位址取指令,而这个位址不可能是个合法的虚拟位址,所以对於 RISC-V 来说会触发指令缺页错失(instruction page fault)。这是一个例外(exception),所以当然就会进入到陷阱向量 stvec

第一段已经在 stvec 内准备了虚拟位址表示的 1:,所以实际上,程序就会实际上原地着执行下去了,虽然概念上,这已经经历了一个位址转换,其变动可以说是相当剧烈的。

这段的启动过程中,还包含最後一个谜团。

sfence.vma 指令

也许再过一阵子,官方会通过权限指令更新,提出新的一些指令来更加细分这个指令相关的行为。

这个作业系统模式的权限指令全名为记忆体管理藩篱指令(Supervisor Memory Management Fence Instruction),可以用来保障在同一个硬体核心(hart)的两种事件之间的执行顺序:

  1. 隐式(implicit)的读写记忆体,而且是针对记忆体管理的内容(大致上就是在说各种页表项的相关存取)
  2. 显式(explicit)的读写记忆体

之所以需要这个指令,是因为现代 CPU 为了效能很可能最佳化指令的顺序,进而打乱原本软件真正想要呈现出来的逻辑。所以,所谓藩篱(fence)型的指令,就是为了这种场合而存在。

QEMU 毕竟是个模拟器,似乎未必有模拟到那麽真实的硬体行为。笔者我们目前为止使用过数个版本的 QEMU 验证本系列文的实验,有时尽管没有正确地使用 sfence.vma 指令,也还是跑出了预期的结果。当然,逻辑上对真实硬体来说,这些偶然并不是正确的做法就是了。今天的进度也会包含到相关的修正。

以启动流程为例,写入 satp 的稍早已经写过一些页表项到记忆体中,所以必须使用 sfence.vma 指令使之生效。

为了让 Golang 工具链支援这个指令,这里加入以下修正并重编:

diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index fcb953d03d..e8614471b1 100644
--- a/src/cmd/internal/obj/riscv/obj.go
+++ b/src/cmd/internal/obj/riscv/obj.go
@@ -1682,11 +1682,12 @@ var encodings = [ALAST & obj.AMask]encoding{
        // Privileged ISA
 
        // 3.2.1: Environment Call and Breakpoint
-       AECALL & obj.AMask:  iIEncoding,
-       AEBREAK & obj.AMask: iIEncoding,
-       AWFI & obj.AMask:    iIEncoding,
-       ACSRRW & obj.AMask:  iIEncoding,
-       ACSRRS & obj.AMask:  iIEncoding,
+       AECALL & obj.AMask:     iIEncoding,
+       AEBREAK & obj.AMask:    iIEncoding,
+       AWFI & obj.AMask:       iIEncoding,
+       ACSRRW & obj.AMask:     iIEncoding,
+       ACSRRS & obj.AMask:     iIEncoding,
+       ASFENCEVMA & obj.AMask: iIEncoding,
        // Escape hatch
        AWORD & obj.AMask: rawEncoding,
@@ -1860,7 +1861,7 @@ func instructionsForProg(p *obj.Prog) []*instruction {
                ins.funct7 = 2
                ins.rd, ins.rs1, ins.rs2 = uint32(p.RegTo2), uint32(p.To.Reg), uint32(p.From.Reg
)
 
-       case AWFI, AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
+       case AWFI, ASFENCEVMA, AECALL, AEBREAK, ARDCYCLE, ARDTIME, ARDINSTRET:
                insEnc := encode(p.As)
                if p.To.Type == obj.TYPE_NONE {
                        ins.rd = REG_ZERO

实作

我们现在只缺少最後一块拼图:stvec 的设置。然而,为了设置这个控制暂存器,我们必须要能够取得物理位址到虚拟位址的偏移量,加上执行时期的物理位址,才能够用以设置。

取得物理位址到虚拟位址的偏移量

本小节大幅参考自 OpenSBI 的实作,搭配一些 Golang 环境的处理。

物理位址必须在执行期才能取得,而理论上虚拟位址是连结时就已经可以决定的值,所以只要设法在执行期让两者相减之後,就是一个可以通用的偏移量了。为此,先建一个 Golang 档案:

$ cat src/runtime/opensbi/pivot.go
package opensbi

import "unsafe"

var Pivot uintptr
var LoadAddr uintptr
var LinkAddr = uintptr(unsafe.Pointer(&Pivot))

取这个档案名称为轴(pivot),是因为我们将以它为基准来比较这两种位址。LinkAddr 在编译时就能够指定其内容为 Pivot 变数的指标。由於这在编译完成之後就已经赋值,因此我们可以预期 LinkAddr 变数存在资料区段,且可以直接观察到这个值的内容:

  [ 9] .noptrdata        PROGBITS         ffffffc0000aa020  000a9020
       00000000000012a0  0000000000000000  WA       0     0     32
  [10] .data             PROGBITS         ffffffc0000ab2c0  000aa2c0
       0000000000001990  0000000000000000  WA       0     0     32
...
  1086: ffffffc0000d7838     8 OBJECT  GLOBAL DEFAULT   12 runtime/opensbi.Pivot
  1087: ffffffc0000d7830     8 OBJECT  GLOBAL DEFAULT   12 runtime/opensbi.LoadAddr
  1088: ffffffc0000aa070     8 OBJECT  GLOBAL DEFAULT    9 runtime/opensbi.LinkAddr

使用 readelf 工具可以判断出这个变数在 noptrdata 区段,而且换算起来是对应在档案内的 0xa9070,可以用 hexdump 工具观察:

$ hexdump -C hw | less
...
000a9060  00 00 10 00 00 00 00 00  00 00 10 00 00 00 00 00  |................|
000a9070  38 78 0d 00 c0 ff ff ff  00 00 00 00 00 00 00 00  |8x..............|
000a9080  0e 00 00 00 00 00 00 00  03 00 00 00 00 00 00 00  |................|

是的,0xffffffc0000d7838 正是 Pivot 所在的位址。

其实没有特别的理由另外将这些变数藏在另外一个组件里面,但是笔者认为这样可以把概念上更加特属於 opensbi 的东西独立於 runtime 组件之外。

至於执行期才能够计算的物理位址,我们也在以下函式中计算

$ cat src/runtime/indirect_opensbi.go
package runtime
                        
import (            
        "runtime/opensbi"                     
        "unsafe"
)                                               
                                                                                                
var AddrOffset uintptr
                                                
func setAddrOffset() {
        opensbi.LoadAddr = uintptr(unsafe.Pointer(&opensbi.Pivot))
        AddrOffset = opensbi.LinkAddr - opensbi.LoadAddr
}

虽然看起来一样是从 Pivot 变数的指标赋值,但连结期决定与执行期决定的意义截然不同,前者对应到我们预期调整的虚拟位址,後者则对应到硬体相关的物理位址。然後,我们可以在组语当中呼叫这个函式,以设定 AddrOffset 变数。

+       // setup stvec for enabling VA
+       MOV     $_rt1_riscv64_opensbi(SB), A0
+       CALL    runtime·setAddrOffset(SB)
+       MOV     $runtime·AddrOffset(SB), A1
+       LD      0(A1), A1
+       ADD     A0, A1, A0
+       CSRRW   CSR_STVEC, A0, X0

这里我们有个新的函式 _rt1_riscv64_opensbi 是打算在虚拟位址启用之後跳转的目的地。这里我们写入的是,已经补上偏移量而成为虚拟位址的结果。

正式启用

结合之前所提及的所有要点,启用虚拟位址且继续运行也不是那麽困难。

+       SFENCEVMA
        CSRRW   CSR_SATP, T0, X0
 
+       // never reach here
+       NOP
+       EBREAK
+
+TEXT _rt1_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
+       MOV     $early_halt(SB), A0
+       CSRRW   CSR_STVEC, A0, X0
        MOV     $runtime·rt0_go(SB), T0
        JALR    ZERO, T0

试跑

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

...
Boot HART MIDELEG         : 0x0000000000000222 
Boot HART MEDELEG         : 0x000000000000a109
HI000000000000000f 
ffffffc00fdffff8 
ffffffc000052450

试跑之後发现,会卡在 early_halt 回报的存取错误,而位置在 rt0_go 的第一行,将 ra 暂存器写入 -8(sp) 的位址(ffffffc000052450)。我们先前都还在使用物理位址的阶段,使用的是 0x90000000,即使是补上虚拟位址的偏移量,这个位址区域也没有被我们稍早的调整涵盖。我们稍早,只有对应一个 2MB 的页面而已。

所以如果这里我们再做一个调整:

+       // setup SP in VA
+       MOV     $0x80202000, X2
+       ADD     X2, A1, X2

这个位址算起来刚好在 ELF 档里面的程序区段的起头处,而且又有被我们目前准备的 2MB 中型页面涵盖。又,A1 来自稍早计算的虚拟位址偏移量,这里当然也需要提早补上才行,否则之後就没有虚拟位址的堆叠指标可以使用了。这麽调整之後,再度试跑:

...
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000b109
HI000000000000000f
0000000000000000
ffffffc00002e138

对应一下这个错误指标,可以发现我们走到一个比较远的地方:runtime.fatalthrow 函式。看来踩到了相当严重的错误。

小结

予焦啦!今天我们正式将虚拟位址启用,放下去跑之後到达了发生严重错误的部分。至於为何如此,各位读者,就让我们明日再探讨吧!


<<:  C#入门之文本处理(下)

>>:  Day12-指标Pointer

全端入门Day30_结尾

昨天介绍了Golang的http,今天是这30天的结尾。 这30天,我收获良多因为我觉得这是一个毅力...

Day8 云端储存 - NAS

NAS - 网路上的档案系统 接下来会分享两个常用的云端基础架构NAS和SAN的原理和不同之处,简...

[前端暴龙机,Vue2.x 进化 Vue3 ] Day24.Vue3 Options API & Composition API 介绍

在 Vue3 中我们可以使用 Options API 或是 Composition API 选择一种...

【Day14】优先性及相依性

优先性(Precedence) 决定运算子彼此之间被语法解析的方式,优先序较高的运算子会成为优先序...

day5_Windows,Linux, MacOs 与 arm 的支援度和 x86 的差异

三大作业系统 目前无论是桌上型电脑与笔记型电脑抑或是服务器,大致上可分为三个主要作业系统,Windo...