予焦啦!前期分析

本节是以 Golang 上游 ee91bb83198f61aa8f26c3100ca7558d302c0a98 为基准做的实验。

回顾昨日,我们发现过去一直未知的错误竟然就发生在下一行。那是一个以 sp 暂存器为基础的读取,结果造成了读取错误。

我们得想办法绕过这个错误才行。

本节重点概念

  • 基础
    • 自机器模式传入作业系统的参数
    • 堆叠指标
    • 序幕(prologue)与结尾(epilogue)
  • Hoddarla
    • 系统概观

分析当前困境

TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0  
        MOV     $0x48, A0
        MOV     $1, A7 
        MOV     $0, A6
        ECALL
        MOV     $early_halt(SB), A0 
        CSRRW   CSR_STVEC, A0, X0 
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv 
        JMP     main(SB)

读者可能会疑惑,为何昨天反组译时,错误指令的存取基准是 sp,这里却是 X2?因为 RISC-V 可以将 32 个通用暂存器称作 X0~X31,也能够依照它们的用途来称呼。事实上,X2 就是 sp,代表堆叠指标。

这里的注解 argcargv,是作业系统通常已经为使用者应用程序准备好的参数。argc 代表的是命令列参数的个数,而 argv 是每个参数的内容各自为何,通常型别是字串的阵列。

之所以他们的存取都与堆叠指标有关,是因为通常来讲,作业系统帮应用程序做初始化,当然是把这些东西存放在可以取得的地方,堆叠(stack)就是最直接了当的位置。

但既然 opensbi/riscv64 作为一个可以用来写作业系统的系统组合,那麽这里这两个参数就显然不合时宜。这是我们现在可以直接拿掉这两行的理由。但有另外两组问题,是我们将它拿掉之前必须想想的。

  1. 如同应用程序从作业系统核心取得这两个参数一样,ethanol 从 OpenSBI 取得了什麽参数?有记忆体上的吗?有暂存器内的吗?
  2. ethanol 什麽时候会开始需要用到堆叠指标?应该怎麽决定堆叠位置?

以下两个小节分别探讨这两组问题,也以 RISC-V Linux 的启动程序码 作为范例来研究一下(由於 Linux 核心支援多种组态,不可能全部列举,这里只以执行在作业系统模式底下、支援 MMU 的组态为例)。

自 OpenSBI 取得的参数

省略 OpenSBI 的细节不谈,事实上,作业系统(如 Linux)接棒之後,只有拿到两个参数:

  1. A0:当前执行的 CPU 的编号
  2. A1:所运行的硬体的装置描述树(DTB,Device Tree Blob)

Hart ID vs. CPU ID

OpenSBI 运行在机器模式,它可以存取 mhartid 状态暂存器以获得该硬体核心的编号(hart ID)。这个值会置放在 A0 传给作业系统模式。又,如果 Linux 运行在机器模式,由於没有 OpenSBI 或是其他韧体能够给予这个代表硬体核心编号的值,就会自己读取

但是,一般作业系统模式的 Linux,对於 CPU 这个资源,会使用另外的 CPU ID 来作编号。所以严格来说,在多核心(SMP)系统里面,只有开机核心(boot CPU)的 hart ID 才比较有意义。开机核心是能够闪过两种关卡还能够抵达开机核心初始化流程的核心:一种是,OpenSBI 或是其他机器模式韧体可能只会放行一个核心进入作业系统模式;另一种是,在 Linux 最一开始,有个乐透机制,会只允许一个核心继续运行,其他则跳转到 .Lsecondary_start,等待开机核心为它们设置运行条件。开机核心会先在最一开始的这一段组语中设置 boot_cpu_hartid 变数,并在稍後将 CPU ID 会登记为 0。更稍微後期一点,开机核心将会为所有 CPU 设置 CPU ID 与 hart ID 的对应关系。其余核心在後期的开机流程当中,也仅仅是凭藉着自己存放在 A0 的 hart ID 来对照出已经由开机核心设定好的 CPU ID 而已。

由於运行多核心 ethanol 且能够运用 Golang 执行期的做法还是很後面的事,这里我们就先不理会 A0 存放的 hart ID 资讯了。

装置描述树

可参考这份很详尽的文章

装置描述树是为了让 Linux 这样的作业系统,能够从一份文件的资讯当中得知当前运行的硬体平台底下存在哪些装置,那些装置的各式各样资讯等等。即使是我们现在使用 QEMU 当作模拟器,也有这个资讯可供存取,

$ qemu-system-riscv64 -M virt --machine dumpdtb=/tmp/dtb
qemu-system-riscv64: info: dtb dumped to /tmp/dtb. Exiting.

这个 dtb 格式档案是一种二进位档,已经是从装置描述树的原始码编译而成的产物。要解析这个档案,我们需要 dtc 工具,

$ dtc -I dtb -O dts /tmp/dtb -o /tmp/dts
/tmp/dts: Warning (simple_bus_reg): /soc/poweroff: missing or empty reg/ranges property
/tmp/dts: Warning (simple_bus_reg): /soc/reboot: missing or empty reg/ranges property

其内容节录如下:

/dts-v1/;

/ {
        #address-cells = <0x02>;
        #size-cells = <0x02>;
        compatible = "riscv-virtio";
        model = "riscv-virtio,qemu";

        fw-cfg@10100000 {
                dma-coherent;
                reg = <0x00 0x10100000 0x00 0x18>;
                compatible = "qemu,fw-cfg-mmio";
        };
...
        memory@80000000 {
                device_type = "memory";
                reg = <0x00 0x80000000 0x00 0x8000000>;
        };

        cpus {
                #address-cells = <0x01>;
                #size-cells = <0x00>;
                timebase-frequency = <0x989680>;

                cpu@0 {
                        phandle = <0x01>;
                        device_type = "cpu";
                        reg = <0x00>;
...
                rtc@101000 {
                        interrupts = <0x0b>;
                        interrupt-parent = <0x03>;
                        reg = <0x00 0x101000 0x00 0x1000>;
                        compatible = "google,goldfish-rtc";
                };

                uart@10000000 {
                        interrupts = <0x0a>;
                        interrupt-parent = <0x03>;
                        clock-frequency = <0x384000>;
                        reg = <0x00 0x10000000 0x00 0x100>;
                        compatible = "ns16550a";
                };
...

其中除了 CPU 和某些特殊的记忆体区段之外,大致上的基本单位就是像这里的 rtcuart 一样,它们是周边装置,@ 後面的数字代表它们的基底位址(base address),通常它们会有一些界面与功能是相对於这个基底,让系统软件能够控制。

像 Linux 之类的作业系统,在开机过程中的某个阶段,会拆解传入的 DTB,并根据相容字串(compatible)的内容来启用对应的驱动程序。

Linux 在早期阶段将这个位址从 A1 保存下来的方法,是在准备设定虚拟记忆体的转换之前,将传入的 DTB 位址(此时还是物理位址,当然,因为 OpenSBI 不使用虚拟位址)换算成未来的虚拟位址。

至於 ethanol 现在的状态,我们距离使用周边装置也还很遥远,所以这个值也可以之後再处理即可。

堆叠指标的需求

相较於此时被笔者延宕处理的硬体核心编号(A0)与装置描述树(A1)的资讯,堆叠指标是我们无法回避的项目。最根本的原因是因为暂存器速度快、距离 CPU 的运算核心近,而且数量非常稀少,无论如何不可能只靠暂存器完成所有的功能,因此势必需要记忆体。

存取记忆体的方法,当然也不能随便乱决定,而是应该有规则可循,如此一来,高阶语言的编译器在编译成一个一个函式的时候,进入与离开的条件才能在呼叫程序(caller)与被呼叫程序(callee)之间建立一致的协定。所谓函数的进入条件,正式的名称是序幕(prologue);离开,则名为结尾(epilogue)。这样的规则,算作是呼叫惯例(calling convention)的一部分。

比方说昨日介绍的 dump 函式。事实上它没有什麽呼叫惯例可言,它在里面使用了许多暂存器,但都没有进行保存。对於呼叫端来讲,很有可能日後会造成不必要的困扰。

这里的描述可能会让读者误解为函数的参数势必要使用堆叠来传递,虽然目前 Golang 确实如此,但实际并非普世皆然。以一般 C 语言编译器处理函数的方法来讲,大部分的函数参数都会使用 a0~a7 作为参数暂存器,依序传入。这里强调的是序幕与结尾对於堆叠的需求,因为暂存器不足的时候总得有个地方能够存放原先的资料。

Linux 的初始化时机

开机核心

Linux 会在启动虚拟记忆体之後,从预先定义的结构体去设置堆叠指标。

其他核心

其他核心会一直等待开机核心为它们准备可用的堆叠指标。

Golang 内的使用状况

序幕

我们可以观察几个例子,比方说 fmt.init 函式:

ffffff800008ded0 <fmt.init>:
ffffff800008ded0:       010db503                ld      a0,16(s11)
ffffff800008ded4:       00256863                bltu    a0,sp,ffffff800008dee4 <fmt.init+0x14>
ffffff800008ded8:       fffd8f97                auipc   t6,0xfffd8
ffffff800008dedc:       cd8f82e7                jalr    t0,-808(t6) # ffffff8000065bb0 <runtime.morestack_noctxt>
ffffff800008dee0:       ff1ff06f                j       ffffff800008ded0 <fmt.init>
ffffff800008dee4:       fe113023                sd      ra,-32(sp)
ffffff800008dee8:       fe010113                addi    sp,sp,-32

这个函式也是理所当然的使用堆叠指标。最一开始,首先是从一个结构(s11)里面存取(ld)一个边界值(a0),然後比较(bltu)堆叠指标与这个边界。

如果没有超出,就能够跳到正常的函式执行部分(...dee4),而这里也是直接使用堆叠,将回传位址(ra)存入(sd)堆叠(sp)上的某个更低的位址,然後将堆叠指标调整(addi ... -32)到该处。

如果超出的话,则会执行一个函式呼叫,名为 runtime.morestack_noctxt,我们可以粗略地理解为,它会设法取得更多可用的空间以作为堆叠使用。

结尾

回传到前一个函式的时候,总得让前一个函式可以继续执行下去。目前 Golang 在 RISC-V 的支援应该还只能算很初步,所以通常结尾也是将堆叠指标归位,并提取回传指标而已。一样取 fmt.init 的例子的话:

ffffff800008dfb4:       00013083                ld      ra,0(sp)
ffffff800008dfb8:       02010113                addi    sp,sp,32
ffffff800008dfbc:       00008067                ret
初始化

我们当初是从 linux/riscv64 系统组合复制过来的,在 _rt0_riscv64_linux 早早数行就直接使用了 X2 暂存器来取得命令列参数。由此我们可知,这完全是仰赖底下的作业系统的帮助才能够这麽做。opensbi/riscv64 现在会遇到问题,就是因爲 OpenSBI 初始化了之後,跳到作业系统模式之前,也不会特地为了它去清除自己暂存器的状态,因此是保留了最後跳跃之前的堆叠指标状态。

所以,正常来讲,给使用者应用程序使用的 Golang 执行期,是不需要烦恼如何初始化堆叠指标这件事情的。但显然 ethanol 应该更考虑 Linux 的使用方法才是。

但是,笔者也打算等到虚拟记忆体启动之後,再来考虑如何设置。

补充:Hoddarla 概观

笔者感谢友人阿 Jay 详细的审稿与提问,这里就用以回应释疑。

他的问题是:「hw.go 在 OpenSBI 所扮演的角色具体来说是什麽?是系统 boot 起来的 image 吗?应该不只是可以在 riscv64 的 OpenSBI OS 上执行的程序吧?」

正式回答之前,笔者得先澄清先前的一个主要编辑错误:hw.go 档案其实不该正式存在。在 Hoddarla 的 github 上面,都已经正名成为 ethanol/ethanol.go 档案。虽然其内容仍然是最基本的 Hello World 程序。无论如何,要回答这个档案本身到底有何意义,笔者必须先补充先前潦草的语义才行;我想,辅佐以一些图像,或许有助於理解其中关键。

首先还是得回到一般的构图:作业系统提供诸般系统呼叫与服务,而使用者空间程序使用那些服务。

 +----------+
 | program  |
 +----------+
-------------- system call interface
 +----------+
 |    OS    |
 +----------+

但由於过去数十年之前就已经有无数系统软件巨人为今日的软件世界打好基础,因此我们就算自称软件工程师,也鲜少从头开始写,而是会利用许多既有的框架与工具。C 语言工程师从 main 函数开始写,实际上,在作业系统交付执行权给使用者程序,到 main 函数执行之前统称为 C 语言执行期(C runtime)的部分也帮忙做了不少事情。Golang 的场合也是相同。也就是说我们大致上可以有这个稍微完整一点的构图:

 +---------------+
 |   Go program  |
 +------------+  |
 | Go runtime |  |
 +------------+--+
------------------- system call interface
 +---------------+
 |       OS      |
 +---------------+

所谓 Go program 是指一般 Golang 程序设计师所撰写的部分,从 package main 的所有东西到所有它所使用的相依函式库。这些东西,都还是需要 Golang 执行期设置垃圾回收(garbage collection)以及共常式排程(Goroutine scheduling)等机制。再扩充一步,符合一般 Unix-like RISC-V 系统的构图:

 +---------------+
 |    Program    |
 +---------------+
------------------- system call interface
 +---------------+
 |     Linux     |
 +---------------+
=================== supervisor binary interface
 +---------------+
 |    OpenSBI    |
 +---------------+

但 OpenSBI 是贴近硬体的韧体层,确实它负责提供一些服务给 Linux,但这之间的依赖性与服务的方便性远远不及 Linux 透过系统呼叫层提供给使用者程序的各种服务。所以当我们观察 Hoddarla 想要达成的目标时,就会变成这样的构图(Linux 放在旁边供参照,且略去使用者空间层):

            |  +---------------+   
 +-------+  |  |    Go program |
 | Linux |  |  +------------+  |
 +-------+  |  | Go runtime |  |
            |  +------------+--+
============================ supervisor binary interface
    +---------------+
    |    OpenSBI    |
    +---------------+

所以可以开始来回答笔者友人的问题了:

  • ethanol.go 在 OpenSBI 所扮演的角色具体来说是什麽?
    就相当於上图 Go program 的部分。

  • 是系统 boot 起来的 image 吗?
    整个第零章相当於是让上图右上的整个方格能够建立起来成为一个可执行档。但是要调整成能够和 OpenSBI 协同合作、利用 RISC-V 核心功能、甚至日後提供使用者空间各种服务,Golang 执行期也是不可忽略的一大项目。事实上,本系列文将会只有极小的篇幅与执行期完全无关。

  • 应该不只是可以在 riscv64 的 OpenSBI OS 上执行的程序吧?
    OpenSBI 只能算是运行在机器模式(machine mode)的韧体。承上,ethanol.go 基本上现在的意义只是让右上方格能够成型。就像是没有 main 函数的 C 程序将会无法编译完成一个可执行档一样,ethanol.go 里面哪怕是只有一个基本的 Hello World,也正扮演这个角色。

小结

今天主要是浏览些 Linux 程序码,作为接下来的参考。但目前为止几个资讯我们都还没有打算利用或是处理:

  • 当前核心 ID
  • 装置描述树位址
  • Golang 执行期初始化下去需要用到的堆叠指标

明天开始我们试着利用 GDB 除错器,看看能够走到哪里吧。各位读者,我们明日再会!


<<:  Day00 前言与目录

>>:  【Day 01】Zeze 的野望 - 开赛前言

CSS display:grid

Gird是一种二维的布局方式,相较flex来说grid还多控制了列~ example : <d...

Day 3 彩色照片转黑白

彩色照片转黑白 教学原文参考:彩色照片转黑白 这篇文章会介绍使用 GIMP 的颜色调整功能,将一张彩...

SQL Server 每日定期备份与定期删除旧有备份档

SQL Server 资料库备份是将存放在资料库里面的资料,转成单一档案保存,通常是副档名为 bak...

Day01:时间复杂度

刚开始,我想说点什麽 看过市面上许多解释演算法的资料,有些书搭配图片,有些影片浅显易懂,为了挑战自己...

30天打造品牌特色电商网站 Day.11 CSS框架-Bootstrap5

昨天已经初步介绍几个简单常用的bootstrap语法, 今天来看看几个也是好用、比较详细或特殊的情况...