对于嵌入式系统工作过一段时间的人来说,Bootloader应该是一个不算陌生的概念。
对于linux的系统来说,从软件角度通常分成4层:
●Bootloader
●Linux内核
●文件系统
●用户应用程序
1. Bootloader概念
Bootloader是处理从系统开机初始化硬件开始直到启动操作系统内核这段过程的一段程序,是系统中最早运行的程序,和硬件有很大的相关性。
从CPU的角度来说,上电后并非立刻就可以运行操作系统的,系统的相关硬件必须初始化(CPU工作模式的设置,内存初始化,关中断,关闭MMU/Cache等等等等),根据当前硬件条件判定当前是启动模式还是下载模式,然后走相应的功能分支。
所以一句话概括bootloader的工作——初始化系统的软硬件环境,使之满足操作系统的运行条件。当把一切软硬件环境都配置好之后,系统的控制权才会交给OS的内核,这个时候Bootloader就功成身退了。
由于涉及到针对底层硬件的操作,所以很多代码会用汇编语言编写,也需要对系统硬件的工作有一个正确的完整的认识,这样对工程师的要求自然就高了。同时,Bootloader的可移植性就不会太好。
Bootloader有单阶段的也有多阶段的,目前比较多的是采用2个阶段,stage1和stage2。stage1一般都由汇编码编写,stage2一般由C语言编写.
大多数Bootloader都包含两种不同的操作模式:
● 启动加载模式:这种模式也称自主模式,在这个模式下bootloader从某个存储设备上加载操作系统到RAM,用户不需要介入这个过程
● 下载模式:这种模式下bootloader将通过串口/USB/网络等等手段从主机下载文件,写入存储设备。
现在的Android智能机就是一个很典型的例子,平时开机用户不需要介入,属于正常的启动加载模式,当用户刷机时显然就变成了下载模式。
2. 常见的Bootloader
● U-boot
这是现在使用最多的bootloader之一,是sourceforge上的一个开源项目。支持ARM,MIPS,PowerPC,x86等处理器,同时支持linux,VxWorks,NetBSD, QNX等操作系统。
● PPCBoot
这是德国DENX小组开发的用于多种嵌入式CPU的Bootloader引导程序。目前支持ARM,MIPS,PowerPC等处理器。
● RedBoot
Redhat公司随ECOS发布的一个开源项目。可以通过串口和以太网口与GDB进行通信和调试应用程序。
● ARMBoot
这也是sourceforge上的一个开源项目,设计只针对ARM的处理器结构,所以在ARM内核的平台上移植比较方便。
● Blob
赫赫有名的一款强大的bootloader。
● Vivi
韩国mizi公司开发的bootloader,适用于ARM9的处理器。
3. Bootloader启动流程
由于结构上Bootloader由两部分组成,所以启动过程也就分成2个阶段,stage1和stage2。
stage1阶段:
● 硬件设备初始化
准备好基本的硬件环境。
● 关中断
Bootloader中没必要响应任何中断,中断是设备驱动管理所需要考虑的内容。
● 设置CPU的速率和时钟频率
一般系统的节拍是由晶振来提供的,这里CPU的速率和时钟频率并非可以任意设置,是需要和系统的配置相匹配的。
如果在匹配的情况下,时钟设置的高一些,系统运行速度可以变快一点。实验中发现下载Image可以变快。
不过这个参数如果不使用主板供应商提供的,而是自行设置,那么需要多多测试,有些参数设置后会导致不稳定,有时下载会正常,有时会出错。
● RAM初始化
我们使用的RAM,实际上都是通过一个控制器连接到系统上的,所以必须针对控制器的寄存器进行一些设置
● 初始化LED
这个就如同PC主板上一样,很多板子都会设计一些灯,不同的错误可以有不同的灯亮起来,可以用于调试,传递简单的一些信息
● 关闭MMU,指令/数据Cache
因为bootloader中是使用的都是实地址,所以需要关闭MMU。因为需要装载Kernel的镜像,镜像的数据必须回写到RAM,所以必须关闭数据Cache。
至于指令Cache,资料上说可以不必关闭,我实际遇到过系统出错的情况,关闭指令Cache就好了,原因并没有分析出来,所以推荐还是关闭。
● 为加载stage2准备RAM空间
因为stage2往往是用C语言来写的,所以需要准备C语言运行时,这里需要准备的RAM空间就不能仅仅是stage2的大小了,必须加上必要的堆栈大小。另外,最好考虑使用memory page的倍数。
这里还需要保证准备的RAM空间是确实可用的,常见的测试算法如下:
1. 先保存 memory page 一开始两个字的内容。
2. 向这两个字中写入任意的数字。比如:向第一个字写入 0x55,第 2 个字写入 0xaa。
3. 然后,立即将这两个字的内容读回。显然,我们读到的内容应该分别是 0x55 和 0xaa。如果不是,则说明这个 memory page 所占据的地址范围不是一段有效的 RAM 空间。
4. 再向这两个字中写入任意的数字。比如:向第一个字写入 0xaa,第 2 个字中写入 0x55。
5. 然后,立即将这两个字的内容立即读回。显然,我们读到的内容应该分别是 0xaa 和 0x55。如果不是,则说明这个 memory page 所占据的地址范围不是一段有效的 RAM 空间。
6. 恢复这两个字的原始内容。测试完毕。
但这种算法即使通过也不见得没问题,我曾经遇到过一次系统内存回卷的情况,后一半的内存复写了前一半的内存,这样的算法对检查这样的错误是无能为力的,只能具体情况具体分析。
● 复制stage2到RAM空间中
拷贝时要确定两点:(1)stage2 的可执行映象在固态存储设备的存放起始地址和终止地址;(2) RAM 空间的起始地址。
● 设置堆栈
通常我们可以把 sp 的值设置为(stage2_end-4),也即在RAM 空间的最顶端(堆栈向下生长)
执行完之后内存布局情况应该如下图所示:
● 跳转到stage2的C入口
修改PC指针即可。
stage2阶段:
● 初始化本阶段要用到的硬件设备
这里面内容就更加丰富了,随着现在bootloader为了实现各种需求,需要初始化更多的相关硬件。
初始化至少一个串口,以便于和终端通信。还需要初始化计时器等等的。
● 检测系统内存映射
所谓内存映射就是指在整个 4GB 物理地址空间中有哪些地址范围被分配用来寻址系统的 RAM 单元。
虽然 CPU 通常预留出一大段足够的地址空间给系统 RAM,但是在搭建具体的嵌入式系统时却不一定会实现 CPU 预留的全部 RAM 地址空间。也就是说,具体的嵌入式系统往往只把 CPU 预留的全部 RAM 地址空间中的一部分映射到 RAM 单元上,而让剩下的那部分预留 RAM 地址空间处于未使用状态。由于上述这个事实,因此 BootLoader 的 stage2 必须在它想干点什么 (比如,将存储在 flash 上的内核映像读到 RAM 空间中) 之前检测整个系统的内存映射情况,也即它必须知道 CPU 预留的全部 RAM 地址空间中的哪些被真正映射到 RAM 地址单元,哪些是处于"unused" 状态的。
可以用如下数据结构来描述 RAM 地址空间中的一段连续(continuous)的地址范围:
typedef structmemory_area_struct {
u32 start; /* the base address of thememory region */
u32 size; /* the byte number of the memoryregion */
int used;
} memory_area_t;
这段 RAM 地址空间中的连续地址范围可以处于两种状态之一:
(1)used=1,则说明这段连续的地址范围已被实现,也即真正地被映射到 RAM 单元上。
(2)used=0,则说明这段连续的地址范围并未被系统所实现,而是处于未使用状态。
基于上述 memory_area_t 数据结构,整个 CPU 预留的 RAM 地址空间可以用一个 memory_area_t 类型的数组来表示,如下所示:
memory_area_tmemory_map[NUM_MEM_AREAS] = {
[0 ... (NUM_MEM_AREAS - 1)] = {
.start = 0,
.size = 0,
.used = 0
},
};
● 加载内核映像和根文件系统映像
这里包括两个方面:(1)内核映像所占用的内存范围;(2)根文件系统所占用的内存范围。在规划内存占用的布局时,主要考虑基地址和映像的大小两个方面。
对于内核映像,一般将其拷贝到从(MEM_START+0x8000) 这个基地址开始的大约1MB大小的内存范围内(嵌入式 Linux 的内核一般都不操过 1MB)。为什么要把从MEM_START 到 MEM_START+0x8000 这段 32KB 大小的内存空出来呢?这是因为 Linux 内核要在这段内存中放置一些全局数据结构,如:启动参数和内核页表等信息。
而对于根文件系统映像,则一般将其拷贝到MEM_START+0x0010,0000 开始的地方。如果用 Ramdisk 作为根文件系统映像,则其解压后的大小一般是1MB。
● 为内核设置启动参数
应该说,在将内核映像和根文件系统映像拷贝到 RAM 空间中后,就可以准备启动 Linux 内核了。但是在调用内核之前,应该作一步准备工作,即:设置 Linux 内核的启动参数。
Linux2.4.x 以后的内核都期望以标记列表(tagged list)的形式来传递启动参数。启动参数标记列表以标记ATAG_CORE 开始,以标记 ATAG_NONE 结束。每个标记由标识被传递参数的tag_header 结构以及随后的参数值数据结构来组成。数据结构 tag 和 tag_header 定义在 Linux 内核源码的include/asm/setup.h头文件中:
/* The list endswith an ATAG_NONE node. */
#define ATAG_NONE 0x00000000
struct tag_header {
u32 size; /* 注意,这里size是字数为单位的 */
u32 tag;
};
……
struct tag {
struct tag_header hdr;
union {
struct tag_core core;
struct tag_mem32 mem;
struct tag_videotext videotext;
struct tag_ramdisk ramdisk;
struct tag_initrd initrd;
struct tag_serialnr serialnr;
struct tag_revision revision;
struct tag_videolfb videolfb;
struct tag_cmdline cmdline;
/*
* Acorn specific
*/
struct tag_acorn acorn;
/*
* DC21285 specific
*/
struct tag_memclk memclk;
} u;
};
在嵌入式 Linux系统中,通常需要由 Boot Loader 设置的常见启动参数有:ATAG_CORE、ATAG_MEM、ATAG_CMDLINE、ATAG_RAMDISK、ATAG_INITRD等。
比如,设置 ATAG_CORE 的代码如下:
params = (struct tag *)BOOT_PARAMS;
params->hdr.tag = ATAG_CORE;
params->hdr.size = tag_size(tag_core);
params->u.core.flags = 0;
params->u.core.pagesize = 0;
params->u.core.rootdev = 0;
params = tag_next(params);
其 中,BOOT_PARAMS 表示内核启动参数在内存中的起始基地址,指针 params 是一个 struct tag 类型的指针。宏 tag_next() 将以指向当前标记的指针为参数,计算紧临当前标记的下一个标记的起始地址。注意,内核的根文件系统所在的设备ID就是在这里设置的。
下面是设置内存映射情况的示例代码:
for(i = 0; i <NUM_MEM_AREAS; i++) {
if(memory_map[i].used) {
params->hdr.tag =ATAG_MEM;
params->hdr.size =tag_size(tag_mem32);
params->u.mem.start =memory_map[i].start;
params->u.mem.size =memory_map[i].size;
params = tag_next(params);
}
}
可以看出,在 memory_map[]数组中,每一个有效的内存段都对应一个 ATAG_MEM 参数标记。
Linux 内核在启动时可以以命令行参数的形式来接收信息,利用这一点我们可以向内核提供那些内核不能自己检测的硬件参数信息,或者重载(override)内核自 己检测到的信息。比如,我们用这样一个命令行参数字符串"console=ttyS0,115200n8"来通知内核以 ttyS0 作为控制台,且串口采用 "115200bps、无奇偶校验、8位数据位"这样的设置。下面是一段设置调用内核命令行参数字符串的示例代码:
char *p;
/* eat leading white space */
for(p = commandline; *p == ' '; p++)
;
/* skip non-existent command lines so thekernel will still
* use its default command line.
*/
if(*p == '\0')
return;
params->hdr.tag = ATAG_CMDLINE;
params->hdr.size =
(sizeof(struct tag_header) + strlen(p) + 1 +4) >> 2;
strcpy(params->u.cmdline.cmdline, p);
params = tag_next(params);
请注意在上述代码中,设置 tag_header 的大小时,必须包括字符串的终止符'\0',此外还要将字节数向上圆整4个字节,因为 tag_header 结构中的size 成员表示的是字数。
下面是设置 ATAG_INITRD 的示例代码,它告诉内核在 RAM 中的什么地方可以找到 initrd 映象(压缩格式)以及它的大小:
params->hdr.tag = ATAG_INITRD2;
params->hdr.size = tag_size(tag_initrd);
params->u.initrd.start =RAMDISK_RAM_BASE;
params->u.initrd.size = INITRD_LEN;
params = tag_next(params);
下面是设置 ATAG_RAMDISK 的示例代码,它告诉内核解压后的 Ramdisk 有多大(单位是KB):
params->hdr.tag= ATAG_RAMDISK;
params->hdr.size= tag_size(tag_ramdisk);
params->u.ramdisk.start= 0;
params->u.ramdisk.size= RAMDISK_SIZE; /* 请注意,单位是KB */
params->u.ramdisk.flags= 1; /* automatically load ramdisk */
params =tag_next(params);
最后,设置 ATAG_NONE 标记,结束整个启动参数列表:
static voidsetup_end_tag(void)
{
params->hdr.tag = ATAG_NONE;
params->hdr.size = 0;
}
● 调用内核
Bootloader调用linux内核的方法是直接跳转到内核的第一条指令处,也即跳转到MEM_START+0x8000地址处。
在跳转时,下列条件要满足:
1. CPU 寄存器的设置:
R0=0;
R1=机器类型 ID;关于 MachineType Number,可以参见 linux/arch/arm/tools/mach-types。
R2=启动参数标记列表在 RAM 中起始基地址;
2. CPU 模式:
必须禁止中断(IRQs和FIQs);
CPU 必须 SVC 模式;
3. Cache 和 MMU 的设置:
MMU 必须关闭;
指令 Cache 可以打开也可以关闭;
数据 Cache 必须关闭;
如果用 C 语言,示例代码如下:
void(*theKernel)(int zero, int arch, u32 params_addr) =
(void (*)(int, int,u32))KERNEL_RAM_BASE;
……
theKernel(0,ARCH_NUMBER, (u32) kernel_params_start);
注意,theKernel()函数调用应该永远不返回的。如果这个调用返回,则说明出错。