玄铁处理器的Linux移植(二)—U-Boot SPL

一、前言

本篇及后续几篇文章介绍的都是移植工作的软件部分。这些文章的重点是讲解代码原理,并简要介绍我们的做法。目前,移植只满足基本功能。具体来说,只支持单核,外设包括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)驱动的编写者无须考虑运行代码的硬件平台。他们只需知道两点。第一,他们需要在一个可查找到的地方[3]静态定义一个drvier结构体,其中至少要包含诸如(1)表示的驱动名字的of_match等字段,(2)指向外设初始化函数的probe字段,(3)指向dm_xxx_ops结构体的指针等。(dm_xxx_ops结构体可以由驱动开发者定义(当然需要将定义告知调用者),其中存放驱动编写者实现的各驱动函数的指针。)第二,存在一种udevice结构体,每个udevice代表一个可用的设备,其中含有该设备所有信息。而驱动函数每次被调用时,调用者都会传入要操作的设备的udevice。

(b)硬件平台的开发者无须了解驱动函数的实现,只需用设备树描述设备信息,及用compatible属性指定驱动名字。设备树在启动时传入。

(c)驱动函数的调用者(调用者可以是U-Boot中其他模块,也可以是App)应当能通过驱动系统提供的udevice查找函数,找到任何udevice。且udevice应当包含一个driver字段,指向对应的driver,从而调用者得以进行“搜索udevice—>索引到其driver—>调用probe()—>调用各驱动函数”的流程。

那么接下来,我们将讲解U-Boot是如何实现以上接口的。我们分为bindingdriver(硬件层驱动)uclass(通用层驱动)udevice(设备)、和调用者的api这5个部分讲解:

3.2.1 binding

binding这个词语在源码注释中的用法比较随意,有时指udevice和设备树节点的绑定,有时又指udevice和driver的绑定。不过这两个步骤确实是一起进行的。任何binding行为最终都要调用这个函数完成:

/* 
 * parent:        要生成的udevice的父udevice
 * node:          表示一个设备树节点,只有一个成员offset,表示节点在dtb中的偏移量
 * **devp:        要生成的udevice
 * pre_reloc_only:若传入true,除非该节点有u-boot,dm-pre-reloc属性,
 *                 否则不生成udevive。
 * 函数作用:      根据node处的设备树节点,生成udevice,把设备树节点每个compatible
 *                 属性和每个driver的of_match中所有名字匹配,绑定正确的driver
 */
int lists_bind_fdt(struct udevice *parent, ofnode node, struct udevice **devp,
		   bool pre_reloc_only)
{
    ...
    /* 获取设备树节点的compatible属性 */
    compat_list = ofnode_get_property(node, "compatible", &compat_length);
    ...
    /* 遍历compatible属性中每个值 */
    for (i = 0; i < compat_length; i += strlen(compat) + 1) {
        compat = compat_list + i;
         ...
         /* 遍历driver */
        for (entry = driver; entry != driver + n_ents; entry++) {
           /* 是否匹配?*/
            ret = driver_check_compatible(entry->of_match, &id,
                              compat);
            if (!ret)
                break; // 是。往后执行
        }
        ...
         /* device_bind_common()的一种封装,用于生成udevice并绑定driver */
        ret = device_bind_with_driver_data(parent, entry, name,
                            id->data, node, &dev);
	...
}

其中,device_bind_with_driver_data()负责生成udevice并绑定driver。该函数是static函数device_bind_common()的多个单行封装之一(详见drivers/core/device.c)。device_bind_common()主要做4件事:

static int device_bind_common(struct udevice *parent, const struct driver *drv,
                  const char *name, void *platdata,
                  ulong driver_data, ofnode node,
                  uint of_platdata_size, struct udevice **devp)
{
    /* 获取uclass到uc */
	ret = uclass_get(drv->id, &uc);
 
    /* 创建dev */
	dev = calloc(1, sizeof(struct udevice));
/*------------------------------一、初始化--------------------------------------*/
    
    /* 进行各种初始化,其中就绑定了driver、uclass */
    ...
    dev->driver = drv;
    dev->uclass = uc;
    ...
/*------------------------------二、分配私有数据---------------------------------*/

    /*
     * 根据uclass、driver中指定的大小,分配udevice的各种私有数据,
     * 这里仅展示其中分配platdata的一句
     */
    ...
        	dev->platdata = calloc(1,
                           drv->platdata_auto_alloc_size);
    ...
/*------------------------------三、建立链表-----------------------------------*/

    /* udevice链表1:sibling_node字段链接进父设备的child_head链表 */
	if (parent)
    	list_add_tail(&dev->sibling_node, &parent->child_head);

    /* udevice链表2:在该函数中,uclass_node字段会链接进uclass的dev_head */
    ret = uclass_bind_device(dev);
    ...
 /*------------------------------四、执行回调-----------------------------------*/

    /* 
     * 执行driver、uclass中定义了各种bind、post_bind回调函数,
     * 都要在这个函数中执行,这里仅展示其中执行driver的bind()的一句
     */
    if (drv->bind) {
    	ret = drv->bind(dev);
        ...
    }
    ...
}

阅读该函数可以解释不少driver、uclass和udevice用于设置私有数据、链表和回调的字段。接下来我们再集中介绍一下这些结构体:

3.2.2 driver(硬件层驱动)

每个驱动都用一个driver结构体表示。driver结构体一律使用U_BOOT_DRVIER宏静态定义进数组中。其定义及解释如下:

struct driver {
    char *name;                            // 设备名
    enum uclass_id id;                     // uclass是使用enum唯一编码的
    const struct udevice_id *of_match;     // 用于匹配udevice的结构体数组
    
    /* 各种过程中的回调 */
    int (*bind)(struct udevice *dev);
    int (*probe)(struct udevice *dev);     // probe回调,一般初始化函数放在这里
    int (*remove)(struct udevice *dev);
    int (*unbind)(struct udevice *dev);
    int (*ofdata_to_platdata)(struct udevice *dev);
    int (*child_post_bind)(struct udevice *dev);
    int (*child_pre_probe)(struct udevice *dev);
    int (*child_post_remove)(struct udevice *dev);

    /* 指定udevice的各种私有数据大小 */
    int priv_auto_alloc_size;
    int platdata_auto_alloc_size;
    int per_child_auto_alloc_size;
    int per_child_platdata_auto_alloc_size;

    const void *ops;    // 指向函数指针结构体

    uint32_t flags;
};

3.2.3 uclass(通用层驱动)

如果只有硬件层驱动,当调用者获得了一个设备,想要使用驱动时,需要这么做:

dev->driver->ops->某硬件层方法(dev, 数据);

这有两个问题。第一,每个驱动都可以定义自己的dm_xxx_ops的结构,那么调用者的调用过程理论上也需要考虑所有可能的驱动种类。想调用一个串口,居然需要考虑所有串口硬件驱动的调用过程,这是不现实的。第二,有时候,我们想要在基本操作上封装一些其他操作。以串口为例,我们不仅想要putc()来输出字符,还想要将其封装为puts()来输出字符串。这个puts()可能是这样的:

void puts(struct udevice *dev, const char *str)
{
	while (*str)
		dev->driver->ops->putc(dev, *str++);
}

试想我们在每个串口驱动的dm_xxx_ops中加上一个puts(),这意味着每个串口驱动都要实现一遍puts(),相当浪费。解决这两个问题的方法便是在硬件层之上引入通用层。现在,每个driver所使用的dm_xxx_ops必须在有限的几个里选择,每种dm_xxx_ops结构对应一个uclass。然后在xxx-uclass.c文件中,通用层会在dm_xxx_ops上进一步封装。有了通用层后,我们获得设备想要使用驱动时,只需要这么做:

某通用层方法(dev, 数据);

而且,考虑到在binding等过程中,同类的驱动也有一些重复操作,所以U-Boot还设计了uclass结构体,使用UCLASS_DRIVER宏定义。正如之前代码中看到的,driver有一个uclass_id字段,device_bind_common()的第一行则是调用uclass_get(),传入uclass_id,获取uclass结构体。在binding等过程中,uclass和driver具有类似的效果,都能触发私有数据分配和回调。另外,刚刚在“udevice链表2”看到,uclass的dev_head字段还是该uclass所有udevice的链表头。由于uclass的结构体除了dev_head之外,和driver几乎一样,就不再重复展示了。

3.2.4 udevice(设备)

每个真实存在的设备使用一个udevice结构体表示。在引入设备树之前,这些结构体往往要使用U_BOOT_DEVICE宏静态定义,但现在,它们大多在binding时被动态生成。其经省略的定义及解释如下:

struct udevice {
    const char *name;               // 设备名
    
    /*设备的驱动、父设备、uclass*/
    const struct driver *driver;
    struct udevice *parent;
    struct uclass *uclass;

    /*
     * udevice最多有6个内存类型私有数据和一个ulong类型私有数据,
     * 其分配(或ulong类型的初始化)过程散布在binding阶段的
     * device_bind_common()函数和probe阶段的device_probe()中,
     * 其大小(或ulong类型的内容)则由uclass、driver、父设备的
     * uclass、父设备的driver中多个字段指定,且有优先级关系,另外
     * 还与如OF_PLATDATA等宏定义有关。这些纯细节内容不必在此探讨。
     */
    void *platdata;
    void *parent_platdata;
    void *uclass_platdata;
    void *priv;
    void *uclass_priv;
    void *parent_priv;
    ulong driver_data;
    

    /* 以“同属一uclass”关系构成链表 */
    struct list_head uclass_node;	
    
    /* 以“父子设备”关系构成树 */
    struct list_head child_head;
    struct list_head sibling_node;
    ...
};

3.2.5 调用者的api

可以分为查找设备调用probe()调用驱动函数三步,但是最后一步已经在uclass介绍中说明了:调用驱动函数一般是通过调用uclass层对其的封装来进行。所以我们只介绍前两步。

(a)查找设备:在device_bind_common()中,udevice被加入到其父设备和uclass的链表中。通过之前对udevice结构体的介绍,我们应该已经能画出这副图:

udevice之间的链表

那么必然地,查找特定udevice的函数要么通过遍历uclass(详见drivers/core/uclass.c中的uclass_find_device_by_xxx()系列函数),要么通过遍历子设备(详见drivers/core/device.c中的device_find_child_by_xxx()系列函数)来进行。我们姑且将实现查找的函数称作"find类函数"。

(b)调用probe():虽然drivers/core/device.c中确实提供了外部可调用的device_probe()函数用于初始化设备,但更常见的做法是使用"get类函数",它们的实现一般是先调用对应的find类函数,再device_probe(),把查找和probe一起做掉。基本上每个find类函数都有对应的get类函数,我们看一个即可:

int device_get_child_by_seq(struct udevice *parent, int seq,
			    struct udevice **devp)
{
    ...
    ret = device_find_child_by_seq(parent, seq, false, &dev); //先调用对应的find
    ...
    return device_get_device_tail(dev, ret, devp); // 这个函数定义在下面
}

static int device_get_device_tail(struct udevice *dev, int ret,
				  struct udevice **devp)
{
    ...
    ret = device_probe(dev); // 调用device_probe()初始化设备
    ...
}

我们不再展示device_probe()函数,只是需要了解两点。第一,device_probe()函数和device_bind_common()一样,其中可以触发各种私有数据分配和回调,当然回调中最重要的是driver的probe,初始化过程往往写在其中。第二,device_probe()会递归地对所有父设备进行。这告诉了我们设备树是如何理解父子设备的。如果A用到了B,那么A应当是B的子设备,因为初始化A的前提是初始化B。例如,一个mmc是基于spi控制器实现的,那么我们应该把mmc作为spi的子设备。

3.2.6 spl_early_init()

读者是否还记得本节开始的目的:介绍spl_early_init()。刚刚我们完整地了解了U-Boot中的驱动模型,就是为了能比较快地介绍后续所有与驱动相关的函数。对于spl_early_init(),从下面的调用图中,可以找到该函数如何调用了我们熟悉的lists_bind_fdt(),最终对设备进行生成、匹配和绑定。

spl_early_init()
    spl_common_init()
    ...
    fdtdec_setup()
    dm_init_and_scan()
        dm_init() // 初始化根设备
        ...
        dm_extended_scan_fdt()
        dm_scan_fdt(blob, pre_reloc_only)
            dm_scan_fdt_node(gd->dm_root, blob, 0, pre_reloc_only)
                遍历根节点下的所有描述设备的节点,调用lists_bind_fdt()
        ...

spl_early_init()中其他细节我们就不再讨论了。接下来我们来看preloader_console_init()如何第一次初始化串口。不过在此之前先讲一下时钟驱动,因为U-Boot中不少地方会调用时钟驱动进行计时和延时。

3.3 时钟驱动

从原理上讲,上层时钟驱动最终都会调用riscv_timer驱动提供的riscv_timer_get_count()函数(其中内联了rdtime伪指令以读取time CSR)来获取时间。因此作为移植者,我们理论上只关心其binding注册为系统时钟初始化三部分,又因为time CSR不需要初始化,所以没有初始化过程,所以只介绍前两者。

3.3.1 binding

首先,设备树中必须有一个代表timer的节点。timer比较特殊,它只需在启动核cpu节点(或所有cpu的父节点“cpus”)中指定timebase-frequency属性:

cpus {
    ...
    timebase-frequency = <频率>; // 在这里定义
    ...
    cpu@0 {
        ...
        timebase-frequency = <频率>; // 或在这里定义(前提是它能成为启动核)
        ...
    };
    ...
};

这样,bind cpu节点时,在riscv_cpu driver的bind回调里,会动态生成一个使用riscv_timer驱动的udevice,并将timebase-frequency的值作为udevice的driver_data:

/* riscv_cpu driver的bind回调 */
static int riscv_cpu_bind(struct udevice *dev)
{
	...
	/* 优先使用本节点的timebase-frequency */
	ret = dev_read_u32(dev, "timebase-frequency", &plat->timebase_freq);
	/* 若没有,再去父节点找timebase-frequency */
	if (ret)
		dev_read_u32(dev->parent, "timebase-frequency",
			     &plat->timebase_freq);

	/* 若当前节点为启动核 */
	if (plat->cpu_id == gd->arch.boot_hart && plat->timebase_freq) {
		...
		/*
		 * 生成一个使用使用riscv_timer驱动的udevice,并将timebase-frequency
		 * 的值作为udevice的driver_data
		 */
		device_bind_with_driver_data(dev, drv, "riscv_timer",
					     plat->timebase_freq, ofnode_null(),
					     NULL);
    ...
}

3.3.2 注册为系统时钟

系统时钟需要由gd->timer指定。这一步是调用者试图调用gd->timer的驱动时动态完成的。调用者应判断gd->timer是否存在,若不存在,则会先调用dm_timer_init(),在UCLASS_TIMER类的设备中找到第一个,指定为gd->timer。相关的各种细节详见lib/time.c(最上层封装),drivers/timer/timer-uclass.c和drivers/timer/riscv_timer.c,大家可以自行研究。

3.4 串口驱动

现在我们回到preloader_console_init()。我们先来介绍一下串口驱动的console层:

3.4.1 console层

串口驱动在设备树定义、驱动编写等底层很符合之前讲的驱动模型,但是其上层又封装了console层,我们需要解释一下它和uclass层和driver层的关系。以putc()为例,硬件层driver的putc是形如这样的:

/* 传入udevice,携带了地址信息 */
static int xxxx_serial_putc(struct udevice *dev, const char ch)
{
    /* 操作对应寄存器 */
}

uclass-serial层封装后的putc()是这样的:

/* 不需要传入udevice,使用一个被注册为系统serial的udevice */
void serial_putc(char ch)
{
    if (gd->cur_serial_dev) // 系统serial
        _serial_putc(gd->cur_serial_dev, ch); // 该函数最终会调用硬件层的putc()
}

封装到这里其实已经可以了,但考虑到一些桌面平台并不以串口为console,而是用如键盘作为输入,显示器为输出等,所以又加了一层。具体来说,如果没有定义CONSOLE_MUX允许无限多个stdio设备,那么console层只会维护3个stdio设备,由stdio_devices[]数组指向:

struct stdio_dev *stdio_devices[] = { NULL, NULL, NULL }; 

假设初始化已经结束,那么三个元素应当被填充,console层会分别将三者认作stdin、stdout、stderr。然后就可以用如下的方法使用它们:

static inline void console_putc(int file, const char c) // file属于{0,1,2}
{
    stdio_devices[file]->putc(stdio_devices[file], c);
}

这个函数被封装后就成为了外部可使用的void putc(const char c)和void fputc(int file, const char c)等。

至于如何初始化,需要等到U-Boot级的board_init_r()才会进行。该函数会依次执行init_sequence_r[]函数序列中的函数,其中通过如下三步初始化console:

static init_fnc_t init_sequence_r[] = {
    ...
    stdio_init_tables, // 第一步
    initr_serial, // 第二步
    ...
    console_init_r, // 第三步
    ...
}

第一步是调用stdio_init_tables()初始化一个链表devs.list:

int stdio_init_tables(void)
{
    ...
    INIT_LIST_HEAD(&(devs.list));
    ...
}

第二步是调用initr_serial()重新初始化串口。初始化过程中,会在uclass层的post_probe回调中向devs.list注册stdio_dev。其实SPL级也进行会进行这步,但是刚刚stdio_init_tables()初始化了链表,所以之前注册的都被丢失了。post_probe回调如下:

static int serial_post_probe(struct udevice *dev)
{
    ...
    struct stdio_dev sdev;
    ...
    /* 初始化sdev,使用uclass层的函数 */
    sdev.putc = serial_stub_putc;
    sdev.puts = serial_stub_puts;
    sdev.getc = serial_stub_getc;
    sdev.tstc = serial_stub_tstc;
    ...
    stdio_register_dev(&sdev, &upriv->sdev); // 注册sdev
    ...
}

第三步,在console_init_r()中,挑选链表中的设备,注册进stdio_devices数组:

int console_init_r(void)
int console_init_r(void)
{
    ...
    struct list_head *list = stdio_get_list(); // 获取dev.list
    ...
    /* 遍历dev.list,寻找合适的输入输出设备 */
    list_for_each(pos, list) {
        dev = list_entry(pos, struct stdio_dev, list);

        /* 以找输入设备为例,将第一个带DEV_FLAGS_INPUT的设备设为输入设备 */
        if ((dev->flags & DEV_FLAGS_INPUT) && (inputdev == NULL)) {
            inputdev = dev;
        }
        ...
        if(inputdev && outputdev) // 都找到了就结束
            break;
        }
        /* 把输出设备注册为stdout和stderr */
        if (outputdev != NULL) {
            console_setfile(stdout, outputdev);
            console_setfile(stderr, outputdev);
            ...
        }
        /* 把输入设备注册为stdin */
        if (inputdev != NULL) {
            console_setfile(stdin, inputdev);
            ...
        }
    ...
    gd->flags |= GD_FLG_DEVINIT; // 现在putc等函数可以使用console_putc()了
}

最后的GD_FLG_DEVINIT是指,由于console_init_r()等console相关的初始化是U-Boot级重定位后才进行的,所以需要该flag表示是否初始化了console层,若没有,此时调用putc()等函数会固定用serial-uclass的serial_putc()等函数,在putc()中如此体现:

void putc(const char c)
{
    ...
    /* 判断GD_FLG_DEVINIT,在console_init_r()中置位 */
    if (gd->flags & GD_FLG_DEVINIT) {
        fputc(stdout, c); // 最终会使用console_putc()
    } else {
        ...
        serial_putc(c); // 固定用串口
    }
}

3.4.2 preloader_console_init()

以上介绍了串口驱动各层的关系。那么再来看preloader_console_init()就很简单了:

void preloader_console_init(void)
{
    ...
    /* 初始化serial,并设置为系统串口 */
    serial_init();
    gd->have_console = 1;
    ...
}

serial_init()中必然要调用get类函数寻找并probe一个串口,然后设置为gd->cur_serial_dev ,验证一下:

static void serial_find_console_or_panic(void)
{
    ...
        /* 其中一种找法 */
        if (!uclass_get_device_by_seq(UCLASS_SERIAL, INDEX, &dev) || // get类函数
            !uclass_get_device(UCLASS_SERIAL, INDEX, &dev) ||
            (!uclass_first_device(UCLASS_SERIAL, &dev) && dev)) {
            gd->cur_serial_dev = dev; // 设为系统串口
            return;
        }
    ...
}

根据宏定义和设备树的定义不同,寻找gd->cur_serial_dev的逻辑比较冗长,我们只截取其中一部分来看。发现确实是先get,再设为gd->cur_serial_dev。

至此,board_init_f()介绍完毕。接下来就要回到start.S,为board_init_r()做准备了。

3.5 为board_init_r()做准备

准备主要分两步:

3.5.1 清空bss

这里要注意的是:在board_init_f()中,对bss段变量(未初始化或初始化为0的静态变量)的赋值不能延续到board_init_r()中,因为它们都会被清空。

3.5.2 重定位栈和gd

这一步只有当定义了CONFIG_SPL_STACK_R宏时才会进行,这样后续代码可以使用更多的栈。由于这两个数据结构的重定位不涉及到改变程序中的静态变量、函数的地址等真正的“重定位问题”,所以我们并不在这里过多介绍,而是在后面U-Boot级重定位时再介绍。

3.6 块设备驱动与U-Boot加载

SPL级的board_init_r()的主要工作是加载U-Boot。我们选择的外部存储是SD卡,在U-Boot中,SD卡被当作一个MMC设备,同时MMC驱动的bind回调中会调用mmc-uclass层的一个mmc_bind()函数,该函数会为MMC设备生成一个块设备类型的子设备,本质上就是讲MMC的读写类操作进一步封装为块设备接口。另外,因为SPI驱动实现起来比直接实现MMC驱动简单得多(而且有Chipyard和SiFive的参考),我们的MMC驱动选择了底层使用SPI驱动函数的“mmc_spi”驱动(该驱动在U-Boot中提供了,见mmc_spi.c),自己只要实现SPI驱动,并作为其父设备即可。综上,整个加载过程涉及到SPI驱动、MMC驱动和块设备驱动。由于U-Boot装在裸分区中,因此不涉及文件系统。

3.6.1 加载-启动流程

先来看加载-启动流程及其如何调用了MMC驱动的块设备接口和MMC接口的。

在SPL级的board_init_r()中,通过4步加载并启动后级镜像:

(a)初始化spl_image:这是一个spl_image_info类型结构体,存放镜像的加载地址、大小等信息。

(b)调用board_boot_order()初始化spl_boot_list:这是一个u32数组,存放要依次尝试的加载方案的ID。事实上,真正的加载方案在别处使用SPL_LOAD_IMAGE_METHOD宏定义,其原型为

#define SPL_LOAD_IMAGE_METHOD(_name, _priority, _boot_device, _method)

该宏会定义一个spl_image_loader类型结构体表示一个加载方案,_boot_device是该方案的ID,会被填入结构体的boot_device字段;_method则是加载函数,被填入load_image字段。

(c)调用boot_from_devices()遍历各方案:该函数中会根据spl_boot_list顺序,遍历执行各方案的load_image()。

(d)调用jump_to_image_no_args():执行完load_image()后,U-Boot已经被成功加载。于是,在对镜像进行一系列预处理后,会执行jump_to_image_no_args()。该函数在arch/riscv/lib/spl.c中有重定义,依次进行:准备参数,invalid icache,使用IPI让非启动核(它们此时还等在secondary_hart_loop中)跳转,最后自己跳转。

以上的过程中,唯有各方案的load_image()用到了MMC。SPL中有一个load_image()的参考实现:spl_mmc_load_image()。对于使用非eMMC(eMMC有硬件分区,略复杂),且不使用文件系统的情况,我们可以裁剪出一个最简版本:

int my_load(...)
{
    static struct mmc *mmc;

    spl_mmc_find_device(&mmc, 第n个设备); // 获取MMC设备,mmc结构体是附着在udevice上的私有数据
    mmc_init(mmc); // 初始化该MMC
    blk_dread(mmc_get_blk_desc(mmc), 起始块号, 块数量, (void*)(加载地址)); // 用块设备接口读

    return 0;
}

可见,分别使用了mmc和块设备接口。接下来,我们将看到这两类接口是如何实现的。

3.6.2 SPI、MMC与块设备

我们从mmc_spi开始。首先,udevice方面,需要在设备树中定义一个compatible属性为"mmc-spi-slot"的MMC设备,且作为一个SPI设备的子设备:

spi: spi@xxxxxxxx {
    ...
    mmc@0 {
        compatible = "mmc-spi-slot";
        ...
    };
    ...

driver方面,MMC的驱动函数最终会调用spi-uclass接口。如这个sendcmd函数(MMC各函数的实现最后往往调一个sendcmd函数,因为所有操作本质上都是各种命令序列),注意此处传入的udevice是mmc的:

static int mmc_spi_sendcmd(...)
{
    ...
        while (i--) {
            ret = dm_spi_xfer(dev, 1 * 8, NULL, &r, 0); // spi-uclass接口函数,入参dev是spi的子设备(mmc)
    ...
}

原来是因为dm_spi_xfer()内部会通过dev->parent获取对应的spi设备:

int dm_spi_xfer(...)
{
    struct udevice *bus = dev->parent; // 这里获取spi
    ...
    return spi_get_ops(bus)->xfer(dev, bitlen, dout, din, flags);  // 调用spi的ops
}

SPI驱动本身反倒没什么特别之处,所以我们不再向下追,转而向上看块设备接口。

一般,在MMC的bind回调中会调用mmc-uclass层定义的mmc_bind()函数,其中生成块设备并绑定名为"mmc_blk"的块设备类型的driver:

int mmc_bind(...)
{
    ...
    /* 调用blk-uclass层接口,生成udevice并与"mmc_blk"driver绑定 */
    ret = blk_create_devicef(dev, "mmc_blk", "blk", IF_TYPE_MMC,
            devnum, 512, 0, &bdev);
    ...
}

该driver也是在mmc-uclass层定义的。块设备类型driver需提供4个ops,分别是read(),write(),erase()和select_hwpart()。最后一个函数非eMMC是不会调用的,而前三个显然可以通过对MMC硬件层进行封装来实现,不再深究。

最后,因为因为涉及到了块缓存,简单介绍一下blk-uclass层对这4个ops的封装,我们以blk-uclass的blk_dread()为例:

/* 函数功能:从block_dev中,从start开始读blkcnt个块,读入*buffer内存中 */
unsigned long blk_dread(struct blk_desc *block_dev, lbaint_t start,
            lbaint_t blkcnt, void *buffer)
{
    ...
    /* 先找缓存中有没有,若找到直接返回 */
    if (blkcache_read(block_dev->if_type, block_dev->devnum,
              start, blkcnt, block_dev->blksz, buffer))
        return blkcnt;
    /* 若没找到,再调用read接口读 */
    blks_read = ops->read(dev, start, blkcnt, buffer);
    /* 若读到了,尝试放进缓存 */
    if (blks_read == blkcnt)
        blkcache_fill(block_dev->if_type, block_dev->devnum,
                  start, blkcnt, block_dev->blksz, buffer);
    ...
}

仔细阅读blkcache_fill()还会发现,该函数只会选择大小适中的内容放进缓存,不涉及整体逻辑,就不在这里分析了。

至此,加载U-Boot的所有原理已经讲解完毕,接下来就到U-Boot级了,篇幅原因,我们下篇继续。

参考

  1. ^GitHub - c-sky/buildroot: Buildroot for T-HEAD XuanTie CPU Series
  2. ^10.2. Running a Design on VCU118 — Chipyard 1.10.0 documentation
  3. ^使用U_BOOT_DRIVER宏静态定义即可。如此定义的结构体在链接时被链接为特定数组的元素,从而可通过一些语法进行遍历。这是利用了ld链接器的一些功能实现的语法,具体可参考U-Boot Linkerlist相关资料。之后提到的U_BOOT_DEVICE、U_BOOT_CMD和UCLASS_DRIVER都是如此定义的。

源:玄铁处理器的Linux移植(二)—U-Boot SPL - 知乎

  • 30
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值