一、前言
本篇及后续几篇文章介绍的都是移植工作的软件部分。这些文章的重点是讲解代码原理,并简要介绍我们的做法。目前,移植只满足基本功能。具体来说,只支持单核,外设包括DDR、串口和SPI接口的SD卡。我们的主要目的是展示跑通流程的方法,给想要在CPU上跑Linux的同学提供参考。
二、启动原理
2.1 各级简介
BootROM:上电后固定首先执行的代码,由芯片厂家烧录,不可更改,可看作硬件初始化状态机的一种实现。它一般进行安全相关的工作,然后从外部存储中加载并启动后级代码。
U-Boot SPL:SPL(Second Program Loader)的存在是由于U-Boot太大了,无法装在片上SRAM中,只能放在DDR,但DDR又还没有初始化,所以先加载一段简易程序,负责初始化DDR,并加载U-Boot到DDR执行。
U-Boot:主要负责初始化板上硬件,然后加载并启动操作系统。
OpenSBI:SBI(Supervisor Binary Interface)是伴随着RISC-V的M态概念而诞生的:利用M态,构建一个特权级在操作系统之上的管理程序,负责处理OS启动引导、M态中断&异常服务、来自S态的系统调用服务等。OpenSBI则是SBI的一个开源实现。
Linux:一般而言,Linux镜像需要一个dtb镜像和一个可选的initramfs(或initrd)。Linux在启动过程中,会根据设备树提供的信息了解自己所拥有的硬件资源,从而使用正确的驱动操作它们。最终,Linux会运行根文件系统中的某个指定的init程序。作为第一个用户态进程,该进程一般不会退出,是一个死循环。至此启动完成。
2.2 方案选择
我们的软件主要基于平头哥的Buildroot工程[1],另外在SD卡驱动和分区等地方参考了Chipyard的做法[2]。所以先介绍一下这两个方案。
2.2.1 平头哥方案
其大致流程是BootROM—>U-Boot SPL—>U-Boot—>OpenSBI—>Linux。我们没有相应的开发板,所以只能从其代码中大致推断。我们从该方案的外部存储和运行流程来讲解:
外部存储:该方案的外部存储使用eMMC,有三个分区,第一个分区没有安装文件系统,用来存放U-Boot和U-Boot SPL,第二、三个分区分别叫boot和root,都安装了ext4文件系统,其中boot存放Linux镜像、opensbi镜像、Linux设备树镜像、以及(如果需要的话)initramfs;root中是Linux的根文件系统。
运行流程:U-Boot SPL运行在片上SRAM中,负责初始化DDR,然后从第一个(裸)分区中加载U-Boot镜像到DDR。U-Boot会从boot分区中读取opensbi镜像、Linux镜像、Linux设备树镜像、(如果需要的话)initramfs,分别加载到DDR的特定位置,然后从openSBI开始运行。(强调“Linux”设备树是因为该设备树只是给openSBI和Linux使用,U-Boot及其SPL则使用另一份在编译时分别被链接进它们各自的二进制文件中的设备树。)
2.2.2 chipyard方案
其流程是BootROM—>OpenSBI—>Linux,主要区别在于没有U-Boot及其SPL。同样从外部存储和运行流程来讲解:
外部存储:该方案使用SD卡作为外部存储。有两个分区,分区1没有安装文件系统,存放OpenSBI+Linux镜像。之所以这么称呼是因为其OpenSBI使用了payload模式,所以在编译时会将Linux镜像和OpenSBI链接在一起;分区2中是Linux的根文件系统。
运行流程:BootROM将OpenSBI+Linux镜像和设备树镜像加载到DDR指定位置,然后开始运行OpenSBI。在这个方案中,设备树镜像存放在BootROM中。
2.2.3 最终选择
若只为运行Linux,其实可以省掉U-Boot及其SPL。加上是因为我们一开始不了解各级作用,所以最先跑通了U-Boot。另外,U-Boot也可以放在OpenSBI之后,以S态运行,但简单起见,我们没有这样做。最终,我们方案的外部存储(的内容)和运行流程基本都参照平头哥版本。主要不同只有两点,第一,外部存储(的介质)选择了SD卡,使用SPI控制器控制(也因此参考了chipyard工程);第二,因为MIG IP会自动初始化DDR,所以SPL级不用初始化DDR,从U-Boot SPL开始,就直接运行在DDR中。
启动的5级中,BootROM只是我们自己写的小段代码,不再介绍。接下来的介绍主要会分成U-Boot及其SPL、OpenSBI和Linux三大块,每块中会介绍所有我们认为与移植有关的主题。每大块的最后会集中介绍所作修改。
三、U-Boot及其SPL
虽然我们会顺着“SPL级—>U-Boot—>后级”的流程讲解,但由于U-Boot及其SPL级是使用相同的源码树,通过不同的编译选项编译出来的,SPL级只是一个在启动流程和驱动种类上精简了的U-Boot而已,所以凡是在SPL级中提到的驱动,也都适用于U-Boot级。
3.1 启动汇编
SPL级启动流程是这样的:初始化(start.S)—>board_init_f()—>清空bss,重定位数据段(start.S)—>board_init_r(),然后board_init_r()函数不返回,直接跳转到U-Boot。可见它会在初始化和为board_init_r()做准备时两次进入start.S。我们这里先讲述start.S用于初始化的部分,我把它分为设置中断,决定启动核,开辟空间,设置gd变量4件事,其中,决定启动核无非是事先约定或通过原子操作抽奖,开辟空间则是由启动核在栈顶依次开辟f_malloc(即board_init_f()阶段使用的malloc)空间,gd空间和每个hart的栈空间。这两件事比较简单,但另两件事需要一些解释:
3.1.1 设置中断
在整个U-Boot及其SPL中,mstatus的MIE都是关闭的,只是在mie中打开了MSIE。这种情况下,中断是无法trap的(因此赋值给mtvec的函数只是用于异常处理,我们不关心它),然而wfi状态的核心却可以被唤醒,且唤醒之后会执行pc+4的指令。这是wfi指令有趣设定的一部分,详见risc-v手册的wfi指令部分。利用wfi机制,非启动核等待ipi的函数被写成这样(删去了多个条件编译宏):
secondary_hart_loop:
wfi // 等待唤醒
csrr t0, MODE_PREFIX(ip) // 读mip
andi t0, t0, MIE_MSIE // 判断被唤醒原因
beqz t0, secondary_hart_loop // 若唤醒原因不是MSIE,继续wfi
mv a0, tp
jal handle_ipi //处理ipi
这些做法实际上也都是risc-v手册中要求的。
3.1.2 设置gd变量
即初始化gd指向的gd_t类型结构体的一些成员。gd变量是gd_t类型结构体的指针,该变量在C语言中声明为“以gp寄存器分派”:
register gd_t *gd asm (gp)
汇编中会将从栈中开辟的gd的地址赋值给gp寄存器:
jal board_init_f_alloc_reserve // 该函数返回值是指向gd结构体起始的指针
mv gp, a0 // 赋值给gp寄存器
需要注意的是,多个核更改gd变量时需要维护一个锁:
/* 摘自每个核分别在gd中注册自己的代码 */
...
la t0, available_harts_lock // 锁
li t1, 1
1: amoswap.w t1, t1, 0(t0) // 获取锁
fence r, rw
bnez t1, 1b
...
/* 临界区 */
...
fence rw, w
amoswap.w zero, zero, 0(t0) // 释放锁
完成初始化后,启动核继续执行board_init_f(),其他核进入刚刚分析过的secondary_hart_loop等待ipi。
3.2 设备树与驱动模型
board_init_f()在源码中有多处定义。因为平头哥版本专门为ice-c910板写了一个board_init_f(),在board/thead/ice-c910/spl.c中,所以我们基于这个函数来讲解。它主要做了两件事:调用spl_early_init()生成udevice节点,以及调用preloader_console_init()首次初始化串口。本节就介绍第一个函数。
但在介绍函数的具体操作之前,我们要先了解U-Boot驱动模型。首先,U-Boot驱动模型最终试图对驱动开发者、平台开发者、驱动调用者三方展现的大致接口是这样的:
(a)驱动的编写者