一. UBOOT打补丁

uboot的源码在官方都能下到,但是每个厂家会根据自己的实际情况对代码进行修改,那么很多时候就会把修改后的补丁文件发布出来,用户只需要将补丁文件打入到官方源码即可。

打入jz2440的补丁文件: patch -p1 < ../u-boot-1.1.6_jz2440.patch

1 补丁生成

当前目录创建原文件:1.txx

1
2
3
this is a test
for patch file format
so let us go!

当前目录创建修改后的文件:2.txt

1
2
3
4
this is a test
for linux patch file format
so let us go!
learn it

当前目录创建生成补丁文件:

1
diff -u 1.txt 2.txt > diff.patch

2 补丁分析

生成的补丁文件内容如下:

1
2
3
4
5
6
7
8
--- 1.txt       2023-02-27 04:00:38.833368403 +0000
+++ 2.txt 2023-02-27 04:00:53.506331049 +0000
@@ -1,3 +1,4 @@
this is a test
-for patch file format
+for linux patch file format
so let us go!
+learn it

补丁头:补丁头记录了原始文件和修改后的文件的文件名和创建时间。

1
2
3
4
--- 1.txt       2023-02-27 04:00:38.833368403 +0000
+++ 2.txt 2023-02-27 04:00:53.506331049 +0000
“---” 表示旧文件(原文件)
“+++” 表示新文件(修改后的文件)

补丁块:补丁中的块是用来说明文件的改动情况。

1
2
3
4
5
6
@@ -1,3 +1,4 @@
this is a test
-for patch file format
+for linux patch file format
so let us go!
+learn it

@@ -1,3 +1,4 @@

  1. -1,3表示这个块原文件是从第1行开始,到第3行结束。
  2. +1,4表示这个块修改后是从第1行开始,到第3行结束。

块会缩进一列,该列有三种情况:

  1. 以 “-” 开头的行, 表示该行只在原始文件中存在,也就是要删除的。
  2. 以 “+” 开头的行,表示该行只在修改后的文件中存在,要加上的。
  3. 以空格开头的行,表示该行在原始文件和修改后的文件中都存在,也就是没改动。

3 补丁使用

1
patch -p0 1.txt < diff.patch

-p是指打补丁的时候忽略几层路径,假如生成补丁的时候不是在当前目录生成的,那么在补丁头的时候就会显示要将补丁打到哪里去的路径:

1
2
--- test/1.txt 2023-02-27 04:00:38.833368403 +0000
+++ test/2.txt 2023-02-27 04:00:53.506331049 +0000

test/1.txt就是补丁显示的原文件路径,也就是要打入补丁的地方,但用户的原文件路径和补丁显示的原文件路径一般来说是不一样的,所以用户打补丁一般进入自己原文件的目录,并查看补丁头显示的路径,用-p去掉不属于自己的路径。

比如现在用户在hhh/1.txt,那么他就要去除test/这个路径,所以他应该用:

1
patch -p1 1.txt < diff.patch

二. UBOOT配置

make 100ask24x0_config 配置板级信息,我们搜索Makefile查看具体执行什么:

1_make-xxxconfig

@$(MKCONFIG) $(@:_config=) arm arm920t 100ask24x0 NULL s3c24x0

  1. @ 是指 make 时不输出 make 信息(一行以@开头,则该行命令的输出被抑制)。
  2. $(MKCONFIG) 变量表示 MKCONFIG := $(SRCTREE)/mkconfig(源码目录下的 mkconfig 文件)。
  3. $(@:_config=),$@是指表示目标,即:100ask24x0_config,_config=是指用空替掉换$@目标中的“_config”(_config=后是空)。则$(@:_config=) 就表示 100ask24x0。

最后:“make 100ask24x0_config”便相当于执行下面这个脚本:

mkconfig 100ask24x0 arm arm920t 100ask24x0 NULL s3c24x0

打开mkconfig脚本,根据入参简化后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/bin/sh -e

APPEND=no # Default: Create new config file
BOARD_NAME="" # Name to print in make output

echo "Configuring for ${BOARD_NAME} board..."

#
# Create link to architecture specific headers
#
cd ./include
rm -f asm
ln -s asm-$2 asm
rm -f asm-$2/arch
ln -s ${LNPREFIX}arch-$6 asm-$2/arch
rm -f asm-$2/proc
ln -s ${LNPREFIX}proc-armv asm-$2/proc

#
# Create include file for Make
#
echo "ARCH = $2" > config.mk
echo "CPU = $3" >> config.mk
echo "BOARD = $4" >> config.mk

[ "$5" ] && [ "$5" != "NULL" ] && echo "VENDOR = $5" >> config.mk

[ "$6" ] && [ "$6" != "NULL" ] && echo "SOC = $6" >> config.mk

#
# Create board specific header file
#
if [ "$APPEND" = "yes" ] # Append to existing config file
then
echo >> config.h
else
> config.h # Create new config file
fi
echo "/* Automatically generated - do not edit */" >>config.h
echo "#include <configs/$1.h>" >>config.h

exit 0

可知执行了如下:

  1. 在include目录下建立了一系列软链接,如:ln -s asm-arm asm,链接文件asm指向 asm-arm。这样做的原因是避免每次都要配置。如#inclue <asm-arm/type.h>是包含 arm 下的type.h 头文件,若是#include <asm-i386/type.h>是包含 i386 架构下的 type.h 头文件。配置时建立链接文件到相应的架构下,这样直接写成 #include <asm/type.h>即可。
  2. 在include目录下创建了config.mk,里面有我们单板的信息。
  3. 在include目录下创建了config.h,里面#include <configs/100ask24x0.h>,该头文件包含板子的配置情况,所以增加一个板子,在 board 目录下新建一个开发板的目录,在include/configs 目录下也要建立一个文件 .h,里在存放的就是开发板 的配置信息。

三. UBOOT编译

配置完毕后,直接输入make即可编译,我们来分析Makefile。

2_make

包含 include 目录下的 config.mk 文件,这个文件是就 make 100ask24x0_config 与 mkconfig 文件一起生成的。include/config.mk 中定义的东西在 Makefile 中用的到。这就是配置和编译结合的过程。包含了 include/config.mk 配置文件后,export 导出这 5 个变量给下级的 Makefile使用。

我们make是要生成uboot.bin,uboot.bin是由uboot生成的。make 编译时,若不指定目标,则 make 就找第一个目标去生成这个目标。all 是这里的第一个目标。

3_make

我们可以将上述变量一一展开,但是现在使用一个取巧的办法,直接看最后编译的输出信息:

4_make

上面的信息可以知道,使用board/100ask24x0/u-boot.lds链接脚本,链接地址为0x33f80000,将很多的文件链接在一起形成了elf文件u-boot,最后这个elf格式的u-boot再转化成二进制的u-boot.bin。那么我们来看看这个链接脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm")
/*OUTPUT_FORMAT("elf32-arm", "elf32-arm", "elf32-arm")*/
OUTPUT_ARCH(arm)
ENTRY(_start)
SECTIONS
{
. = 0x00000000;

. = ALIGN(4);
.text :
{
cpu/arm920t/start.o (.text)
board/100ask24x0/boot_init.o (.text)
*(.text)
}

. = ALIGN(4);
.rodata : { *(.rodata) }

. = ALIGN(4);
.data : { *(.data) }

. = ALIGN(4);
.got : { *(.got) }

. = .;
__u_boot_cmd_start = .;
.u_boot_cmd : { *(.u_boot_cmd) }
__u_boot_cmd_end = .;

. = ALIGN(4);
__bss_start = .;
.bss : { *(.bss) }
_end = .;
}
  1. 可以看到链接脚本将cpu/arm920t/start.o放在了最前面,并且ENTRY(_start)声明了第一条语句的入口。
  2. 链接脚本的链接地址是0开始,为什么最后的链接显示的链接地址是0x33F80000?这个地址是TEXT_BASE,在board/100ask24x0/config.mk定义。我们的SDRAM为64M,地址从0x30000000开始,0x33F80000就是在SDRAM的最顶部空出512k来存放我们的uboot.bin。
  3. __u_boot_cmd_start 这个段是用来存放uboot定义的命令,后续会分析到。
  4. 接下来的任务就是从cpu/arm920t/start.S这个文件入手,分析uboot的源码。

四. UBOOT源码分析

1 UBOOT启动第一阶段

第一阶段做的事情如下:cpu/arm920t/start.S

  1. 进入 SVC 管理模式
  2. 关看门狗
  3. 屏蔽中断
  4. 做些初始化(主要是 SDRAM 的初始化)board/100ask24x0/lowlevel_init.S
  5. 设置调用 C 函数的 SP 栈。
  6. 时钟。
  7. 重定位(从 FLASH 拷代码到 SDRAM)
  8. 清 bss 段
  9. 最后调用 C 函数 start_armboot:执行更复杂的第二阶段

2 UBOOT启动第二阶段

第二阶段的start_armboot在lib_arm/board.c:

6_start_armboot

5_start_armboot

7_start_armboot

  1. 分配一个类型为 gd_t* 这样的结构体指针的内存空间给 gd全局变量,并且gd全局变量是保存在寄存器中的,使用了__asm__ volatile(“”: : :”memory”)。这个结构体贯穿整个uboot。
  2. 使用函数指针数组,完成一系列初始化。包括nor flash,nand flash,环境变量初始化等。
  3. 最后跳转到main_loop函数,处理命令。

main_loop函数在common/main.c中:

8_main_loop

9_main_loop

10_main_loop

  1. 获取bootdelay倒数计时变量。
  2. 如果在这个倒数计时到达到 0 之前,没有输入空格键,就会启动bootcmd命令。
  3. 否则就进入等待命令输入,然后处理命令的死循环。
  4. UBOOT 的核心就是这些”命令” run_commnd(),分析了这些命令的实现之后,才能明白内核的加载与启动。

3 UBOOT的命令实现

UBOOT 接收串口输入时,会根据串口输入的字符去查找相应的处理函数。最简单的方法就是做一个结构体,这个结构体里面有名字,还有相应的处理函数。即cmd_tbl_t。查找函数如下:

11_find_cmd

这就与我们的链接脚本对应上了,uboot会把所有的命令做成一个cmd_tbl_t结构体,里面有函数名字,对应的处理函数,并把所有的命令结构体都放到__u_boot_cmd_start段中,以后要执行对应的命令时就去该段根据名字查找到对应命令结构体,最后调用其处理函数即可。

究竟是不是如此。我们分析命令的定义即可:

12_uboot_cmd

13_uboot_cmd

最终展开后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cmd_tbl_t __u_boot_cmd_bootm __attribute__ ((unused,section (".u_boot_cmd")))= {
“bootm”,
CFG_MAXARGS,
1, do_bootm,
"bootm - boot application image from memory\n",
"[addr [arg ...]]\n - boot application image stored in memory\n"
"\tpassing arguments 'arg ...'; when booting a Linux kernel,\n"
"\t'arg' can be the address of an initrd image\n"
#ifdef CONFIG_OF_FLAT_TREE
"\tWhen booting a Linux kernel which requires a flat device-tree\n"
"\ta third argument is required which is the address of the of the\n"
"\tdevice-tree blob. To boot that kernel without an initrd image,\n"
"\tuse a '-' for the second argument. If you do not pass a third\n"
"\ta bd_info struct will be passed instead\n
}

故在代码中,所有用 U_BOOT_CMD 宏定义的东西,最终都会定义为:

1
cmd_tbl_t  __u_boot_cmd_##name __attribute__ (...)

这样的结构体特别在于有一个属性。就是将它的段强制指定为 .u_boot_cmd 。所有的命令结构体最终都会被集中在这个段内,很容易根据名字遍历查找。这和RT-Thread自动初始化段类似。

4 UBOOT的终极目的

  1. uboot的最终目的是去启动linux内核,假设我们在bootdelay之前没有按下空格键,他就会执行bootcmd中保存的命令,该命令一般有两条,第一条去flash的某一分区读取我们的linux内核到SDRAM中某一地址,这个地址可以随意,只要不破坏我们的其他内容即可。

  2. uboot是不能识别linux直接编译出来的zImage的,他要经过uboot的脚本给zImage加上64字节的头部成为uImage才能被识别,这个头部包含了内核镜像的信息,内核的加载地址等。

  3. 从flash读取linux内核到SDRAM指定地方后,调用bootcmd的第二条命令bootm addr,该命令最终调用do_bootm函数,然后会分析uImage的头部信息,如果现在的地址不等于内核的加载地址,则还需要把内核重定位到他的加载地址去,最后调用do_bootm_linux跳转到内核真正的入口地址处,然后uboot就一去不复回了。

do_bootm_linux函数如下:

14_do_bootm_linux

15_do_bootm_linux

  1. 首先定义了一个函数指针theKernel,让他指向内核真正的入口地址处。
  2. 最后调用theKernel (0, bd->bi_arch_number, bd->bi_boot_params);
  3. 他有三个参数,第二个参数是芯片ID,内核根据这个ID判断自己是否能够支持该芯片,第三个是uboot要传递给内核的一些信息所在的首地址。
  4. uboot可以将板子的一些信息,按照内核约定的格式存放起来,这个格式叫做tag,把所有的tag都集中放在一起,跳转到内核的时候把这个地址当作参数传递给内核,内核看到该地址就知道了,哦,原来uboot给我“留言”了,放在了“xxx”地址。

至此,uboot的使命就结束了,还有很多细节没有列举出来,不同芯片的整体流程都大同小异!

五. SPL为何物

1. SPL 名字由来

SPL 全称叫做:Secondary Program Loader,看名字,像是一个什么二级加载相关的;实质上,也是二级加载;众所周知,u-boot 是用来 boot 我们的嵌入式系统的,那直接使用 u-boot 就行了呗,为何还会多出一个 uboot spl 呢?那我们从系统启动开始说起吧;

站在芯片厂商的角度来说,硬件系统一上电,一定是要去某个地址取指令(一般是 0x00000000),然后软件便开始很欢快的运行起来了;通常来讲,SoC 厂家都会做一个 ROM 在 SoC 内部,这个 ROM 很小(成本,你懂的),里面固化了上电启动的代码(一经固化,永不能改,是芯片做的时候,做进去的);这部分代码呢,我们管它叫做 BootROM。

换句话来说,上电后,先接管系统的是 SoC 厂家的 BootROM,他要做些什么事情呢?
他要负责最原始的初始化,这个 BootROM 叫做一级启动程序,而排在后面的就叫二级启动,这就是 SPL 名字的由来;

2. SPL 拿来干嘛

2.1 铺垫

为了讲清楚 spl 的用处,我需要先铺垫一点其他东西;如果是大芯片(不是单片机),外挂了存储设备(eMMC、Nand、SDCard 等)和内存 RAM(SDRAM、DDR 等),通常情况下呢,我们要让系统跑起来,需要先烧写代码,这个烧写代码,其实是将可执行的二进制文件写到外部的存储设备上(eMMC、Nand、SD Card 等);系统上电启动的时候呢,去把他们读到内存中去执行;

前面我们说了,上电后,先执行SoC 厂家自己的 BootROM,其他可执行的程序(u-boot、Kernel)都放(烧写)到了外部存储器上;那么BootROM 的代码除了去初始化硬件环境以外,还需要去外部存储器上面,将接下来可执行的程序读到内存来执行;

既然是读到内存执行,那么这个内存可以不可以是我们板载的 DDR 呢?理论上是可以的,但是,SoC 厂家设计的 DDR 控制器一般会支持很多种类型的 DDR 设备,并且会提供兼容性列表,
SoC 厂家怎么可能知道用户 PCB 上到底用了哪种内存呢?所以,直接把外部可执行程序读到 DDR 显然是不太友好的,一般来说,SoC 都会做一个内部的小容量的 SRAM (又是成本),BootROM 将外部的可执行程序从存储器中读出来,放到 内部SRAM 去执行;

好了,现在我们引出了 SRAM,引出了 BootROM;那么 BootROM 从具体哪个存储器读出二进制文件呢?SoC 厂家一般会支持多种启动方式,比如从 eMMC 读取,从 SDCard 读取,从 Nand Flash 读取等等;上电的时候,需要告诉它,它需要从什么样的外设来读取后面的启动二进制文件;

一般的设计思路是,做一组 Bootstrap Pin,上电的时候呢?BootROM 去采集这几个 IO 的电平,来确认要从什么样的外部存储器来加载后续的可执行文件;比如,2 个 IO,2’b00 表示从 Nand 启动,2’b01 表示从 eMMC 启动,2’b10 表示从 SDCard 启动等等;

当 BootROM 读到这些值后,就会去初始化对应的外设,然后来读取后面要执行的代码;这些 IO 一般来说,会做成板载的拨码开关,用于调整芯片的启动方式;这里,多说一句,读取烧写的二进制的时候呢,需要注意一些细节,比如,SoC 厂家告诉你,你需要先把 SDCard 初始化称为某种文件系统,然后把东西放进去才有效,之类的;因为文件系统是组织文件的方式,并不是裸分区;你按照 A 文件系统的方式放进去,然后 SoC 的 BootROM 也按照 A 文件系统的方式读出来,才能够达成一致;

2.2 SPL具体作用

铺垫得够多的了,这里我们回归主题:spl;前面说了,BootROM 会根据 Bootstrap Pin 去确定从某个存储器来读可执行的二进制文件到 SRAM 并执行;理论上来说,这个二进制文件就可以是我们的 u-boot.bin 文件了;也就是 BootROM 直接加载 u-boot.bin;

但是这里有一个问题,就是 SRAM 很贵,一般来说,SoC 的片上 SRAM 都不会太大,一般 4KB、8KB、16KB…256KB不等;但是呢,u-boot 编译出来却很大,好几百KB,放不下!

放不下怎么办?比如,我们的 u-boot 有 300KB,SRAM 有 8KB,外部 DDR 1GB,有两种办法:

  1. 做一个小一点的 boot 程序,先让 BootROM 加载这个小的程序,后面再由这个小 boot 去加载 u-boot;这个小的 boot 就叫做 spl,它很小很小(小于SRAM大小),它先被 BootROM 加载到 SRAM 运行,那么这个 spl 要做什么事情呢?最主要的就是要初始化 DDR Controller,然后将真正的大 u-boot 从外部存储器读取到 DDR 中,然后跳转到大 u-boot;
  2. 放不下就放不下呗,BootROM 加载多少算多少,但是这部分uboot要完成初始化DDR,并把完整的uboot搬运到DDR去运行;u-boot 的前面 8K 被加载进入 SRAM 执行,u-boot 被截断,我们就需要保证在 u-boot 的前 8KB 代码,把板载的 DDR 初始化好,把整个 u-boot 拷贝到 DDR,然后跳转到 DDR 执行;

方案一:SPL流程如下:先假设,我们的“货”,都已经放置到了外部存储器上,也就是绿色部分;

16_SPL

  1. 上电后BootROM 执行最原始的初始化,根据 Bootstrap Pin 来确定启动设备,初始化外设;
  2. 从存储器读取 SPL;
    —————- 以上部分是 SoC 厂家的事情,下面是用户要做的事情 —————-
  3. SPL 被读到内部 SRAM 执行,此刻,控制权已经移交到我们的 SPL 了;
  4. SPL 初始化外部 DDR;
  5. SPL 使用从外部存储器读取 u-boot 并放到 DDR;
  6. 跳转到 DDR 中的 u-boot 执行;
  7. 加载内核;

方案二:可能会涉及两次重定向;

第一次:

  1. 第一次在lowlevel_init返回后,进入board_init_f函数之前,lowlevel_init设置好了时钟和ddr,为uboot的第一次重定位做好准备。我们的bootrom只拷贝了uboot的前几k代码到sram执行,而大部分uboot的代码还在SD卡中没有载入内存,没有载入sram的原因是之前我们使用的是内部SRAM,容量不足以放下整个uboot,而现在已经初始化好了外部DRAM,拥有了512MB内存,是完完全全够放下整个uboot,所以这里的第一次重定位就是将SD卡中完整的uboot.bin载入到ddr中(链接地址处),然后绝对跳转到ddr中board_init_f函数去运行。
  2. 在重定位到链接地址之前的代码只能用位置无关码,也就是相对跳转,不能绝对跳转,因为绝对跳转是跳到链接地址上,但是重定位之前链接地址处那里我们还没有放代码呢。
  3. 重定位到DDR链接地址并跳转到board_init_f后,就可以使用函数指针等全局的变量了(board_init_f中使用了函数指针数组完成一系列初始化);注意这里不是跳到DDR中uboot开始地址。

第二次:

  1. 第二次在board_init_f函数最后会调用汇编实现的ENTRY(relocate_code),在前面第一次重定位将u-boot从SD卡拷贝到DDR中时,是拷贝到链接地址的,也就是说那个时候u-boot的运行地址就和链接地址一致了,可以正常运行完整个u-boot,这里又再次重定位,只不过是为了将u-boot搬到内存的高地址去运行,是因为防止内核解压的时候覆盖了u-boot本身。
  2. 但是要注意,如果这次像第一次那样将uboot复制到DDR高地址,然后绝对跳转过去执行,后续函数中如果有函数调用或使用全局变量的时候,还是会回到链接地址处,也就是DDR低地址处的uboot,这就与我们本意相悖,所以relocate_code最后还要修改DDR高地址的uboot.bin的rel段的数据,那是所有需要重定位的变量、函数的地址信息,加上DDR低地址到DDR高地址的偏移量即可。
  3. relocate_code结束后使用绝对地址跳转到DDR高地址处的uboot.bin的board_init_r函数入口,也就是在DDR高地址继续执行Uboot,注意这里不是跳到DDR中高地址uboot的开始地址。第二次重定位也称自举,形象的说就是把自己举高高。

你可能存在疑问,为什么要两次重定位呢,直接第一次就复制到DDR的高地址不行吗?其实我也是这样想的,可能具体芯片其自己的考虑,一般来说是一次就重定位到DDR顶部的。