予焦啦!装置树(DTB)解析

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

予焦啦!昨日打通了 throw,所以之後我们可以获得更清楚的讯息。但根本问题是,记忆体管理抽象层的未实作。我们并不适合直接跳入实作的部分,因为 Hoddarla/ethanol 的状况相当特殊,我们应该全盘考虑之後再决定怎麽做。

本节重点概念

  • RISC-V
    • Linux sv39 记忆体映像(Memory Map)
  • Golang
    • string 格式
  • 杂项
    • DTB 解析

记忆体映像(Memory Map)

我们一开始豪爽地为了 ethanol 设置了基底位址,在 0xffffffc000000000。这个数字来自 sv39 定址模式的限制,那就是 VA[63:39] 必须都与 VA[38] 相同这个条件,再加上我们让其他 VA[37:0] 都为 0。

记忆体映像即是,一个作业系统,在偌大的可用的虚拟位址空间之中,如何规划每个区块的用途。我们现在在一个起始阶段,所以大可以参考一下其他系统组合如何做。老样子,我们观察一下 Linux 怎麽安排的。

相关的程序码可以参考这个。其实就是,从 KERNEL_LINK_ADDRESS 之後,许多不同的区块以之为基准开始加上一个一个区块大小(_SIZE 的那些数值),而形成的不同记忆体部位。总和起来,参考这个图例更简单一些。

  • 0x0 - 0x40_0000_0000:使用者空间的虚拟位址
  • 0x40_0000_0000 - 0xffff_ffc0_0000_0000:根据 sv39 限制,不能使用的区域。
  • 0xffff_ffc0_0000_0000 -:Linux 根据不同的用途,切分成大小不同的几块分别做使用。

并且我们要记得,这只是虚拟位址,实质上要使用的话,我们仍然要经历前两日的建立页表步骤,才能够对应到真正的物理位址。而物理位址该怎麽对应到这些虚拟位址呢?以 Linux 来讲,除了直接映射(direct map)的区块之外,其余的区块很有可能都是一页一页对应,物理上也不连续的。

问题是,我们该怎麽分配这个对应?前两日的 0xffffffc000[0:1]000000x80[2:3]00000 还算是一个直接的中型页面分配,但物理位址远比虚拟位址有限,而且随着不同的硬体还可能会变动范围。所以在我们真正解决 ethanol 的记忆体映像分配问题之前,我们必须先来研究一下怎麽能够让系统在执行期取得实体记忆体大小资讯。

这个机制,当然就是先前也提及过的装置树(DTB)。笔者并不打算在这个阶段认真实作一个完整的装置树剖析器(device tree parser),而是会先采用快速暴力的方式,取得需要的资讯就走。

装置树资讯解析

本节没有详细说明的资讯,可以参考这篇非常详尽、甚至还有规格的说明文。

在 1.1 的前期分析时,我们就曾经印出 QEMU 模拟器传给系统的装置树资讯了。但 OpenSBI 或 ethanol 核心在执行期能够取得的是已经编译过的二进位格式。因此我们必须知道如何解析才行。

笔者现在只对记忆体资讯有需求,也就是起始位址和大小。这些资讯在装置树的 memory 结点当中:

memory@80000000 {
      device_type = "memory";
      reg = <0x00 0x80000000 0x00 0x20000000>;
};

这代表起始位址在 0x80000000,大小为 512MB。至於两个数字前置的 0x00 为何需要,是因为这里想要表达两个(起始位置与大小)64 个位元的数字。装置树规格定义了一些方法让系统可以描述他们想要呈现数字的方式,而 QEMU 模拟器合成的装置树当中,是以 32 位元为一个数字的单位,所以要表达这两个数字的话,尽管它们高位元的前半部都没有值,也还是得各放两组 32 位元数字。

若要试图直接取得这两个资讯,相关的内容位址如下:

  • 0x82200000:这是 OpenSBI 预设从 a1 暂存器给予下一级系统软件的装置树所在位址。当然,是实体位址。以下使用 DTB_BASE 代称。
  • DTB_BASE[0:4](第 0 个位元组到第 4 个,以下同):格式识别用的模式,这个内容必须是 0xd00dfeed,才会被当作装置树二进位档来解析。看起来很奇特,但其实这是档案格式里面惯用的 MAGIC 栏位。我们这里就直接跳过这个检查吧。
  • DTB_BASE[4:8]:这个装置树二进档的大小,以位元组记。需注意的是,横跨多个位元组的数字,在装置树的惯例里面都是采取高位数在前的格式(big endian)。我们实际上也不见得需要这个数字。
  • DTB_BASE[8:12]:装置区的偏移量。我们需要这个,因为我们要能够找到 memory 节点。
  • DTB_BASE[12:16]:特徵名称(attribute name)的偏移量。我们需要这个,因为我们要能够找到 memory 节点中的 reg 特徵。
  • 装置区:遵守一套树状格式,里面有所有的装置节点以及它们的特徵。
  • 特徵名称区:只是用 \0 字元分隔开的一连串字串。这些字串本身在特徵名称区内的偏移量,会被用来当作装置区里面的参照。後续小节会有实例。

调整装置树位址

由於预设的 0x82200000 不在我们已经配置的范围,且由於我们还没有一套很完善的记忆体管理机制(只有第一个中型页面的 2MB 属於有建立页表的范围),我们其实不适合处理这个实体位址。所以我们先把它摆到整个 ethanol 系统映像的尾端,这麽一来,当虚拟位址生效时,我们便也可以从虚拟位址的部分继续存取装置树资讯。

+TEXT setupFDT(SB),NOSPLIT|NOFRAME,$0
+       // Move DTB from T0 to T1, totally T2 bytes
+       MOV     A1, T0
+       MOV     $runtime·end(SB), T1
+       LBU     4(T0), T2
+       SLL     $8, T2, T2
+       LBU     5(T0), T3
+       ADD     T2, T3, T2
+       SLL     $8, T2, T2
+       LBU     6(T0), T3
+       ADD     T2, T3, T2
+       SLL     $8, T2, T2
+       LBU     7(T0), T3
+       ADD     T2, T3, T2
+
+move_dtb:
+       LBU     0(T0), T4
+       SB      T4, 0(T1)
+       ADD     $1, T0, T0
+       ADD     $1, T1, T1
+       ADD     $-1, T2, T2
+       BGT     T2, ZERO, move_dtb
+
+       RET
+
 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
-       MOV     $0x48, A0
-       MOV     $1, A7
-       MOV     $0, A6
-       ECALL
+       CALL    setupFDT(SB)
 
        MOV     $runtime·bss(SB), T0

砍掉一开始的 H 字元,是因为这个其实现在已经不需要了。

存取装置树

若要在一般 Golang 档案里面存取装置树,有很多种作法,其中最直接的是利用内建的 string 型别。为此,我们可以在 setupFDT 中新增:

+TEXT setupFDT(SB),NOSPLIT|NOFRAME,$0
+       // Move DTB from T0 to T1, totally T2 bytes
+       MOV     A1, T0
+       MOV     $runtime·end(SB), T1
...
+       ADD     T2, T3, T2
+       // format FDT as string part 1: address and length
+       MOV     $runtime·fdt(SB), T3
+       MOV     T1, 0(T3)
+       MOV     T2, 8(T3)

Golang 的 string 型别包含位址与长度资讯,这里位址给了装置树的新起点,长度则给算好的长度。但因为这里新起点仍然是物理位址,必须要在启动完虚拟记忆体之後加上偏移量(我们先前将虚拟位址到物理位址的偏移量储存在 AddrOffset 变数之中):

 TEXT _rt1_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
        // Trap vector for debugging
        MOV     $early_halt(SB), A0
        CSRRW   CSR_STVEC, A0, X0
+
+       // format FDT as string part2: relocate address
+       MOV     $runtime·fdt(SB), T3
+       MOV     0(T3), T1
+       MOV     $runtime·AddrOffset(SB), T2
+       LD      0(T2), T2
+       ADD     T1, T2, T2
+       MOV     T2, 0(T3)

而这个 fdt 变数本身,我们可以在 osinit 中宣告,位在 src/runtime/os_opensbi.go 里面:

+ var fdt string
  func osinit() {
          ncpu = 1   
          getg().m.procid = 2    
          physPageSize = 4096 
+         for i := 0; i < len(fdt); i++ { 
+                 print(fdt[i], "\n")   
+         }  
  }

这当然只是范例的使用方式而不是正式的系统行为,但可以展示这麽做能够将整个装置树当作内建的字串来存取。

快又暴力的针对记忆体节点装置树存取

重写 osinit 存取的部分:

func osinit() {
...
        memBase, memSize := ethanol.GetMemoryInfo(fdt)
        print("Memory Base:", memBase, "\n")
        print("Memory Size:", memSize, "\n") 
}

然後,在 runtime/ethanol 组件当中实作这个 GetMemoryInfo 函式:

// We don't have any advanced features yet, so                                                  
// hardcoded to search for "memory" and "reg"                                                   
func GetMemoryInfo(fdt string) (uint, uint) {                                                   
        nodeOffset := getWord(fdt, 8)                                                           
        strOffset := getWord(fdt, 12)                                                                   
        // find "reg" directly                                                                  
        regStrOffset, found := getStrOffset(fdt, "reg", strOffset, getWord(fdt, 32))            
        if !found {
            return memoryNotFound()
        }

装置树格式当中的两个主要部分,分别是节点区域和字串区域。这个函数一开始先分别取得两者的偏移量,分别位在第 8 与 12 个位元组的标头内位址。这里略去 getWord 函数的实作细节,因为只是单纯的字串处理。

初始化在这个早期的阶段,没有太多进阶功能,甚至也包含 string 组件的存取。但所幸 Golang 本身的基本语法就足堪使用。

然後透过 getStrOffset 函数,取得 reg 字串在字串区域中的偏移量。如前所述,这个偏移量会在节点区域中被参照。这样设计的目的是,装置树中的多个节点内部,往往会定义类似的性质。这些性质的字串若是各自存在节点区域内,每个字串很容易就会超过 4 个位元组,而这本身已经可以表示相当大范围的整数了!

因此,将所有的性质字串都藏在另外一个区域中,再使用偏移量去表示它们,就可以大幅降低空间用量。reg 性质是 memory 节点必备,用来表示位址与大小的一个性质,所以这里先搜寻之。

        // TODO: abstract this better later.  Technically it is wrong.
        getReg := false                                                                         
        for i := nodeOffset; i < nodeOffset+getWord(fdt, 36); i += 4 {
                if getWord(fdt, i) == FDT_BEGIN_NODE {
                        i += 4                                                                  
                        if fdtsubstr(fdt, "memory", i) {
                                for fdt[i] != 0 {
                                        i++                                                     
                                }                                                               
                                i = i / 4 * 4
                                getReg = true                                                   
                        }                                                                       
                } else if getWord(fdt, i) == FDT_PROP && getReg {                               
                        if getWord(fdt, i+8) == regStrOffset {                                  
                                return getWord(fdt, i+16), getWord(fdt, i+24)                   
                        }                                                                       
                }                                                                               
        }                                                                                       
        return memoryNotFound()

要实作一个完整的装置树格式剖析器(parser)的话,非得用上包含堆叠(stack)资料结构(最简单的方式是递回)才行,但这里我们追求简单暴力,所以只要看到有名为 memory 的节点,就开始正式剖析该节点内的性质。只有看到 reg 的偏移量出现了才去取得。

如同注解说的,这个技术上来说是错误的剖析。不只是整个装置树结构,还有找到 reg 偏移量之後,回传的两个数字。这两个数字都只来自 4 个位元组,但实际上,整个 reg 性质包含 16 个位元组,记忆体位址和大小分别以 8 个位元组表示。但我们已经知道在 QEMU 这个模式之中,前 4 个位元组都是零,所以就先跳过了。

试跑

以下实验可以在今日更新的 Hoddarla repo 取得。

Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
Memory Base:2147483648
Memory Size:536870912
fatal error: runtime: cannot allocate memory

runtime stack:
runtime.throw({0xffffffc000064339, 0x1f})
...

大成功!

小结

予焦啦!今天运用了两个阶段的手续,确保装置树内容能够在虚拟记忆体位址启用之後使用。并且运用了 Golang string 型别的知识,让剖析(暴力版的)装置树成为不到一百行 Golang 能够解决的事情。如此一来,我们就已经取得所有的实体位址资讯了。再回顾一次,我们之所以不直接实作记忆体管理,是因为如果连物理位址空间都无法掌握的话,以虚拟位址瞎转根本谈不上什麽管理。所谓掌握,当然也至少要头尾都知道才行。所以我们才先来这里解决装置树的部份

明日,让我们继续面对 Golang 记忆体管理抽象层的问题。各位读者,我们明天再会!


<<:  Day 21:「爸爸说,家里要重新装潢了」- 关於样式的属性绑定讲解

>>:  如何衡量万事万物 (1) 衡量的定义

Day6 - 读 Concurrency is not Parallelism - Rob Pike (一)

本篇是看 Concurrency is not Parallelism 的心得 Concurrenc...

Android Studio 菜鸟笔记本-Day 29-介绍Room资料库三种类别

Room 今天会介绍Android里的Room资料库中的类别有 Entity DAO Databas...

[Day 29] LIFF Bluetooth RequestDevice

前言 在查找 liff.bluetooth.requestDevice() 的用途,发现一份Web ...

Day01-CRUD API 实作(一)事前规划、Laravel Sanctum 安装与设定

大家好~ 第一天先来规划我们的主题和预计会有哪些功能吧! 主题的话, 我决定做个留言板, 会有留言的...

Day 30 最终章:结语与初心

各位读者大家好~我是Android工程师兼作家 小笠宏树,今天不演别人演我自己。希望大家这个系列看得...