本节是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 为基准做的实验
予焦啦!昨日直接瞄准一组虚拟到物理位址的转换,并展示每一个步骤所需要的资料。最後自由运行(free run)下去,却发现整个系统卡住了。其实启用虚拟记忆体,需要更周全的准备才行。
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)的两种事件之间的执行顺序:
之所以需要这个指令,是因为现代 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
函式。看来踩到了相当严重的错误。
予焦啦!今天我们正式将虚拟位址启用,放下去跑之後到达了发生严重错误的部分。至於为何如此,各位读者,就让我们明日再探讨吧!
昨天介绍了Golang的http,今天是这30天的结尾。 这30天,我收获良多因为我觉得这是一个毅力...
NAS - 网路上的档案系统 接下来会分享两个常用的云端基础架构NAS和SAN的原理和不同之处,简...
在 Vue3 中我们可以使用 Options API 或是 Composition API 选择一种...
优先性(Precedence) 决定运算子彼此之间被语法解析的方式,优先序较高的运算子会成为优先序...
三大作业系统 目前无论是桌上型电脑与笔记型电脑抑或是服务器,大致上可分为三个主要作业系统,Windo...