暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

项目经验分享:基于RT-Thread的可引导Linux的bootloader

开源之夏 2021-09-29
1307

小编叨叨叨

暑期 2021 项目研发倒计时1天,结项报告提交将于9月30日截止,还未提交结项材料的同学要抓紧咯。

开源之夏公众号还会持续面向广大社区及项目承担学生征稿,欢迎大家热情分享自己的研发经验及结项收获!

发送投稿文章至官方联络邮箱:

summer@iscas.ac.cn

添加公众号小编微信投稿

本期带来RT-Thread社区吴松杰同学带来的项目经验分享:基于RT-Thread的可引导Linux的bootloader。



1

项目名称

基于RT-Thread的可引导Linux的bootloader


2

项目综述

rt-boot是一个基于rt-thread接口实现的支持多线程的bootloader,通过RT-Thread的软件生态可以实现更丰富的功能。


3

项目步骤

● 了解当前架构及其特性

了解当前架构Linux启动要求

了解当前架构的RT-Thread实现以确保Linux能正确启动

了解已有实现方案,确定方案可行性并实施

4

项目实施

了解当前架构

目前要求的平台为树莓派4B或qemu-vexpress-a9模拟器,则处理器架构为ARMv8-A和ARMv7-A,其中ARMv8中的AArch64和32位模式在指令,寄存器已经模式等有部分的差异:

 指令,ARM作为RISC精简指令集,很多功能都需要不止一个指令才能实现,相比较x86架构,x86架构指令显得更加清爽,可以通过寻址、比较指令例子进行分析:


c
int main(void) {
unsigned long i = 0x7c00;
if (*(unsigned long *)i > 10) {
i = 0;
} else {
i = 255;
}
return i;
}

x86-asm

main:
cmpq $11, 31744
sbbq %rax, %rax
movzbl %al, %eax
    ret

arm-asm

main:
mov r0, #31744
ldr r1, [r0]
mov r0, #255
cmp r1, #10
movhi r0, #0
    bx      lr

aarch64-asm

main:
mov x1, 31744
mov x0, 255
ldr x1, [x1]
cmp x1, 10
csel x0, x0, xzr, ls
    ret


以上分析可以看到x86下会有更多指令实现程序员的需求;相反,ARM架构寄存器的类型和功能则更多。


● 寄存器,本次项目比较重要的地方,启动Linux必然涉及部分系统寄存器,ARM的通用寄存器也可能是其他特殊寄存器,具体就是编程时操作寄存器的名称不同而不同;虽然在组成上ARM和AArch64相似,但是功能基本都不一样了:

ARMv7-A架构提供了16个32位通用寄存器(R0-R15)和1个程序状态寄存器CPSR(Current Program Status Register)
ARMv8-A架构提供了31个64位通用寄存器(X0-X30)和1个程序状态寄存器组PSTATE(Process State)


● 模式,Linux作为主机系统肯定在特权等级上启动的,ARM和AArch64在模式上区分明显:

ARM中有7种模式
用户模式(USR):正常程序执行模式,不能直接切换到其他模式。
系统模式(SYS):运行操作系统的特权任务,与用户模式类似,但具有可以直接切换到其他模式等特权。
快中断模式(FIQ):支持高速数据传输及通道处理,FIQ异常响应时进入此模式。
中断模式(IRQ):用于通用中断处理,IRQ异常响应时进入此模式。
管理模式(SVC):操作系统保护模式,系统复位和软件中断响应时进入此模式,系统复位或开机、软中断时进入到SVC模式下。
中止模式(ABT):中止模式用于支持虚拟内存或存储器保护,当用户程序访问非法地址,没有权限读取的内存地址时,会进入该模式。
未定义模式(UND):未定义模式用于支持硬件协处理器的软件仿真,CPU在指令的译码阶段不能识别该指令操作时,会进入未定义模式
AArch64中有4种运行模式
EL0:正常程序执行模式,唯一一个非特权级。
EL1:运行操作系统的特权任务,与用户模式类似。
EL2:管理模式,这用于执行管理程序代码,以及虚拟化扩展支持,在ARMv8.1之前都没有Secure模式。
EL3:低级固件,用于执行Secure和 Non-Secure之间转换的代码,只存在Secure模式。


● ARM架构下对内存对齐问题非常敏感,这也是值得注意的地方。


了解当前架构Linux启动要求


由于此部分较严谨,篇幅较长,因此以链接的形式展示:

arm

https://www.kernel.org/doc/html/latest/arm/booting.html


aarch64

https://www.kernel.org/doc/html/latest/arm64/booting.html


了解当前架构的RT-Thread实现以确保Linux能正确启动


由于不需要使用户态,项目选择了使用RTOS版本进行开发,在两个架构的实现下,RT-Thread都使用了MMU(当然PV是1:1的),都在特权级下运行,内核是实时多线程的,rt-boot虽然是软件包,但只要内核能保证bootloader线程使用的堆不被破坏,启动Linux就不会受太大影响,其他问题就是解决最小启动环境以及适当修改libcpu代码了。


了解已有实现方案,确定方案可行性并实施

制定方案

项目不仅仅要实现Linux的启动,还要实现ymodem传输文件,tftp,http加载Linux,经过反复修改确认以下命令:


rt_boot linux <<kernel-path> <kernel-args>|<kernel-path>>

从文件系统加载Linux内核

rt_boot dtb <dtb-path>

从文件系统加载设备树

rt_boot initrd <initrd-path>

从文件系统加载initrd

rt_boot boot

启动已经加载的内核

rt_boot http <http-host|http-host:port> <http-filename> <save-filename>

通过http网络协议下载文件

rt_boot tftp <tftp-host|tftp-host:port> <tftp-filename> <save-filename>

通过tftp网络协议下载文件

rt_boot ymodem <save-filename>

通过ymodem串口传输协议下载文件


其中linux和boot命令至关重要,两个命令决定了Linux能否正常加载和启动


在ARM中,启动方案如下:


在AArch64中,启动方案如下


对于可参考的实现,Uboot,GRUB2就是非常好的例子,当然他们作为专业的bootloader,对于网络,文件各种实现和RT-Thread当然不一样,仅用来确认启动方案是否合理即可。具体验证启动方案最好的办法就是写最小启动器,通过QEMU Loade机制加载内核到物理地址上,如果能够启动说明方案在一定程度上是可行的,以AArch64 Virt QEMU为例:


● linux命令实现

如果成功就可以开始实现bootloader了,RT-Thread默认情况下的堆都是不足以加载Linux的,可以修改堆的大小或者用MMU映射新的一段足够大的内存地址。加载Linux的时候要注意一个问题,Linux不是加载到哪都能运行,比如AArch64就要求在2MB对齐的地址上启动,如果内核头部信息前64字节中的text_offset如果不为0,那就要用memmove将内核移动到当前地址后text_offset的地方,树莓派3/4B 内核就是如此要求,该头部数据结构如下,具体含义注释已说明:


// 来源:

https://www.kernel.org/doc/html/v5.7/arm64/booting.html

struct arm64_kernel_image_header
{
uint32_t code0; /* EFI Executable code */
uint32_t code1; /* EFI Executable code */
uint64_t text_offset; /* Image load offset, little endian */
uint64_t image_size; /* Effective Image size, little endian */
uint64_t flags; /* kernel flags, little endian */
uint64_t res2; /* reserved */
uint64_t res3; /* reserved */
uint64_t res4; /* reserved */
uint32_t magic; /* Magic number, little endian, "ARM\x64" */
uint32_t res5; /* reserved (used for PE COFF offset) */
};


同时Linux启动的地址间接影响initrd、设备树加载地址,一般情况下是这么确定的:

Linux内核地址

load_address + 0x8000

initrd地址

load_address + 0x3000000

ATAG或设备树地址

initrd地址 - 64KB


也由于对齐等问题,早期的内核对加载地址较为严格,还是以Linux官方规定的地址为主。


● dtb命令实现

接着就是设备树的加载,使用设备树启动的时候要对设备树进行信息做一定的修改,比较启动参数,预留内存等,对于修改设备树的方法,使用libfdt接口进行设备树信息修改即可,以修改启动参数为例:


如果要修改设备其他信息,那就要对设备树进行一定的了解,然后才能使用libfdt进行修改,以下是节选出来的树莓派4B设备树描述:

// 设备树的版本信息
/dts-v1/;
// 如果有公共部分,#include导入即可
// #include "raspi4b.dtsi"


// 保留的内存,如果不想给内核使用的内存可以在这设置(地址,长度)
/memreserve/ 0x0000000000000000 0x0000000000001000;
// 设备树的根节点,设备树的描述必须从这里开始
/ {
memreserve = <0x3b400000 0x4c00000>;
serial-number = "1000000030297805";
// 如果板子名称相同,则用compatible 区分
compatible = "raspberrypi,4-model-b", "brcm,bcm2711";
// 该板子的名称
model = "Raspberry Pi 4 Model B Rev 1.2";
// 表示一个address用几个u32表示
#address-cells = <0x2>;
// 表示一个size用几个u32表示
#size-cells = <0x1>;
// 中断父设备标号
interrupt-parent = <0x1>;


// 节点名称
soc {
compatible = "simple-bus";
#address-cells = <0x1>;
#size-cells = <0x1>;
ranges = <0x7e000000 0x0 0xfe000000 0x1800000 0x7c000000 0x0 0xfc000000 0x2000000 0x40000000 0x0 0xff800000 0x800000>;
dma-ranges = <0xc0000000 0x0 0x0 0x40000000>;
// 类似设备的唯一标识
phandle = <0x41>;
// 设备名称
timer@7e003000 {
compatible = "brcm,bcm2835-system-timer";
// 设备所映射的地址,在soc节点中address-cells为1,size-cells为1,因此这里的0x7e003000 为地址,0x1000为大小
reg = <0x7e003000 0x1000>;
// 中断信息,因为ARMv8有多种定时器,所以此处中断是4个(中断域 中断 触发方式)
interrupts = <0x0 0x40 0x4 0x0 0x41 0x4 0x0 0x42 0x4 0x0 0x43 0x4>;
clock-frequency = <0xf4240>;
phandle = <0x42>;
};
};


cpus {
#address-cells = <0x1>;
#size-cells = <0x0>;
// 设备的启动方式
enable-method = "brcm,bcm2836-smp";
phandle = <0xcf>;


cpu@0 {
// 设备的类型
device_type = "cpu";
compatible = "arm,cortex-a72";
reg = <0x0>;
enable-method = "spin-table";
cpu-release-addr = <0x0 0xd8>;
phandle = <0x26>;
};
};
};


以上只是设备树常用的一些描述,具体详细的描述可以参考:

Devicetree Specification

https://devicetree-specification.readthedocs.io/en/latest/chapter1-introduction.html


设备树在加载的时候也要进行一定的检查,因为设备树如果无效,将会浪费大量的调试时间,当然设备树无效的情况也比较少,以下是设备树头部的数据结构,只需要检查对应的值是否正确即可(注意,设备树存储结构为大端)

// 来源:Linux内核linux/scripts/dtc/libfdt/fdt.h

struct fdt_header {
fdt32_t magic; /* magic word FDT_MAGIC */
fdt32_t totalsize; /* total size of DT block */
fdt32_t off_dt_struct; /* offset to structure */
fdt32_t off_dt_strings; /* offset to strings */
fdt32_t off_mem_rsvmap; /* offset to memory reserve map */
fdt32_t version; /* format version */
fdt32_t last_comp_version; /* last compatible version */


/* version 2 fields below */
fdt32_t boot_cpuid_phys; /* Which physical CPU id we're booting on */
/* version 3 fields below */
fdt32_t size_dt_strings /* size of the strings block */
/* version 17 fields below */
fdt32_t size_dt_struct; /* size of the structure block */
};


ARM下如果不使用设备树那就必须使用ATAG,相信目前已经大部分设备都不用这个了,因此就简单展示一下它的数据结构和用法:


// 来源:

http://www.simtec.co.uk/products/SWLINUX/files/booting_article.html

struct atag_header {
u32 size; /* legth of tag in words including this header */
u32 tag; /* tag value */
};


/*
* Tag name Value Size Description
* ATAG_NONE 0x00000000 2 Empty tag used to end list
* ATAG_CORE 0x54410001 5 (2 if empty) First tag used to start list
* ATAG_MEM 0x54410002 4 Describes a physical area of memory
* ATAG_VIDEOTEXT 0x54410003 5 Describes a VGA text display
* ATAG_RAMDISK 0x54410004 5 Describes how the ramdisk will be used in kernel
* ATAG_INITRD2 0x54420005 4 Describes where the compressed ramdisk image is placed in memory
* ATAG_SERIAL 0x54410006 4 64 bit board serial number
* ATAG_REVISION 0x54410007 3 32 bit board revision number
* ATAG_VIDEOLFB 0x54410008 8 Initial values for vesafb-type framebuffers
* ATAG_CMDLINE 0x54410009 2 + ((cmdline_len + 3) / 4) Command line to pass to kernel
*/


struct atag {
struct atag_header hdr;
union {
struct atag_core core; /* 必须 */
struct atag_mem mem; /* 必须 */
struct atag_videotext videotext;
struct atag_ramdisk ramdisk;
struct atag_initrd2 initrd2;
struct atag_serialnr serialnr; /* 必须 */
struct atag_revision revision;
struct atag_videolfb videolfb;
struct atag_cmdline cmdline;
} u;
};


#define tag_next(t) ((struct tag *)((u32 *)(t) + (t)->hdr.size))
#define tag_size(type) ((sizeof(struct tag_header) + sizeof(struct type)) >> 2)


static void setup_mem_tag(struct atag *params, u32_t start, u32_t len)
{
params->hdr.tag = ATAG_MEM; /* 设置标签类型 */
params->hdr.size = tag_size(atag_mem); /* 标签大小 */


/* 该标签的属性 */
params->u.mem.start = start;
params->u.mem.size = len;


params = tag_next(params); /* 指向下一个标签 */
}


● initrd命令实现

只要加载到正确地址并记录该地址即可,不需要做什么特殊处理


● boot命令实现

在ARM下启动,只需要CPU关闭中断进入SVC模式,关闭MMU和清除缓存等常规操作就能启动Linux,因为基本不用虚拟化,而且对应的操作RT-Thread都实现了相应接口。


在AArch64下启动,注意RT-Thread是运行在EL1上的,用该模式要实现bootloader其实不太“合理”,异常模式应该是越高越好(EL2足矣),这样可以初始化的东西就越多,陷入高级异常的次数也少。具体实现首先还是要关闭中断,根据当前异常等级做不同处理:


这部分的实现就是为了初始化当前异常的环境,都是在关闭MMU和清除缓存关各种Debug, SError, IRQ 和 FIQ操作,然后根据Linux启动要求做处理。


如果该Linux是在EL1下就能启动,不做其他处理,准备调用内核就行。

如果该Linux是在EL2下才能启动,而RT-Thread此时是EL1的状态,那就有两种修改方案:

  • 第一种最简单,由于RT-Thread是在EL2下启动的,只要在启动时直接在vbar_el2系统寄存器上路由一个EL1的中断向量表,在中断处理函数的后续添加hvc处理,然后调用hvc,主动触发异常,就能陷入EL2执行后续启动代码;这里有个关键的地方,栈指针,因为后续还要执行C语言,进入了其他异常就要设置为该异常的栈指针,而启动时没有做相应初始化,需要指定一个新的地址,让程序可以往下运行,而且此方法不仅需要在BSP配置正确的栈空间,还要对AArch64的栈设置比较熟悉,因此这个办法比较投机。hvc_call中的x0其实就是上面带入的in_EL2标签的地址,此时cpu就会跳转到该标签执行EL2要初始化环境的代码了。



  • 第二种就是正统一点的办法,让RT-Thread直接运行在EL2上,这个改动是很大的,首先就是启动代码,在AArch64中高级异常向低级异常跳转靠的是在高级异常中设置elr_elx为低级异常的代码入口,调用eret异常返回。因此,要将即将跳转EL1的代码部分直接砍掉,在EL2下继续运行EL1的初始化代码,还要一个重点就是中断,在hcr_el2中的IMO位域,也就是第4位,这一位决定着EL2能否接收到中断,需要将这一位打开RT-Thread才能正常运行。接下来是MMU的设置,MMU里面对应EL1寄存器的地方都要改成EL2,但还是要注意一个地方,在ARMv8.1之前,HCR_EL2.E2H的值只能是0:



因此,EL2的TCR_EL2只能是如下结构:


因此TCR_EL2对应的位域要和TCR_EL1的设置一样。修改完之后RT-Thread就能在EL2上运行了。
在此基础上,剩下的事情就是执行之前EL2启动Linux环境初始化的代码了。


● http、tftp、ymodem文件下载命令实现

RT-Thread已经通过LWIP实现了网络协议栈,只要对应的BSP移植了网卡驱动就能很轻松得通过网络编程的方式实现该两个命令,但要尤其注意对应的传输协议。至于ymodem,RT-Thread已经实现了底层接口,实现起来也非常轻松。


● 调试手段及常见问题

在编写好初始化代码的情况下,如果发生启动失败问题,可以使用QEMU monitor查看寄存器等其他信息,对vmlinux进行反汇编查找具体失败的地方:


GDB改改配置也可以这么调,但是在物理机上,比如树莓派4B,没有QEMU或者GDB运行难以得知Linux启动失败的地方,幸运的是RT-Thread已经初始化好串口,我们对串口输出的代码进行反汇编(注意,Linux启动部分不使用栈,需要使用-O3优化或者手动修改),这时候需要对Linux启动代码进行一定的修改输出Linux内核具体运行到了第几行,如图为输出一个个位数的输出:


在Linux启动部分调用这些串口输出函数就行了,与此类似的方法还有直接操作GPIO的“点灯大法”也是个不错的调试手段。不过此时还要注意一个地方,如果Linux打开了MMU,串口或者GPIO需要映射新地址才能用,debug本身的代码如果有问题也是很大的问题!

当然,如果kernel运行到了earlycon可以使用的地方,就可以不用这么干了,Linux可以直接输出具体错误,如图中Linux发现rt-boot没有设置reserved内存:


ARM/AArch64下如果定时器中断号没有关闭,Linux后期也可能会在使能IRQ后崩溃,除此之外,只要设备树信息没问题应该就没有其他问题了。

以下为测试成功的视频



5

项目总结

作为bootloader,最重要的事情当然就是”按要求办事“,在硬件合理的情况下,按照Linux启动要求写好代码,是肯定可以启动的。截止至8月25日,除文档外,项目已完全完成,但是这一路下来肯定不是一帆风顺的,在这期间对于文档+代码的开发方式理解得非常深刻,也对ARM架构有了新的认识,做底层架构不光是要有扎实的基础,还要能对代码负责,开发过程中我想得更多得是每一行代码要怎么写,是不是可靠的,必要的。这次开源之夏既是学习,又是挑战的过程,感谢开源之夏主办方为这次活动提供的平台与机会。感谢导师在这过程中对我的指导。


参考资料:


[1] 《Arm® Architecture Reference Manual Armv8, for A-profile architecture》

[2] 《ARM® Cortex® -A Series Version: 1.0 Programmer’s Guide for ARMv8-A 》

[3] 《ARM® Generic Interrupt Controller Architecture version 2.0》

[4] 《Armv8-A Address Translation Version 1.1》

[5] 《AArch64 Programmer's Guides Generic Timer》

[6] 

https://www.rt-thread.org/document/site/#/rt-thread-version/rt-thread-standard/README

[7] https://git.savannah.gnu.org/cgit/grub.git

[8] https://github.com/u-boot/u-boot




本文为吴松杰同学的原创文章,欢迎更多小伙伴关注本公众号并投稿分享。



文章转载自开源之夏,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论