【摘要】本文详细地介绍了基于嵌入式系统中的 OS 启动加载程序 ―― BootLoader 的概念、软件设计的主要任务以及结构框架等内容。 在拿到空PCB板之后,硬件工程师首先会测试各主要线路是否通连(各焊点是否有空焊、断接或短路的情况),然后逐个模块焊接上去。之后需要验证系统上电之后,CPU与各组件的供电电压是否正常,供给CPU的震荡电路能否能够正常起振,外部存储器能否正常读写。当把我们的程序用JTAG工具下载到板子上后,在真正调试系统前需要做好以下检查:
检查完以上各项后,只能证明板子上的电源电路以及CPU是正常的,接下来要继续验证CPU与外围设备,确认板子的正确性与稳定性后,才能进行下一步测试。
首先搞清楚以下几个概念:
一个嵌入式Linux系统通常分为以下4个层次:
引导加载程序是系统加电后运行的第一段代码。以最常见的PC机为例,其中的引导加载程序由 BIOS(其本质就是一段固件程序)和位于启动硬盘 MBR 中的 OS Boot Loader(LILO 、GRUB 等)一起组成。 BIOS 在完成硬件检测和资源分配后,将硬盘 MBR 中的 BootLoader 读到系统的 RAM 中,然后将控制权交给 OS Boot Loader。 Boot Loader 再将内核映象从硬盘读到内存中,然后跳转到内核的入口点去运行,也即开始启动操作系统。
而在嵌入式系统中,通常没有像 BIOS 那样的固件程序(有的也会有),因此整个系统的加载启动任务就完全由 Boot Loader 来完成。系统加电或复位后,所有的 CPU 通常都从某个由 CPU 制造商预先安排的地址上取指令,而嵌入式系统通常都有某种类型的可供事先烧录BootLoader的固态存储设备(比如: ROM、 EEPROM 或 FLASH 等)被映射到这个预先安排的地址上。因此在系统加电后, CPU 将首先执行 Boot Loader 程序。 比如在一个基于ARM7TDMI core 的嵌入式系统中,系统在上电或复位时通常都从0地址处开始执行,而在这个地址上存放的通常就是系统的 Boot Loader 程序。下图就是一个同时装有 Boot Loader、内核的启动参数、内核映像和根文件系统映像的固态存储设备的典型空间分配结构图:
主机和目标机之间一般通过串口建立连接, Boot Loader 软件在执行时通常会通过串口来进行 I/O,比如:输出打印信息到串口,从串口读取用户控制字符等。但因为串口的速率问题,在进行文件传输时会选择以太网连接,并借助TFTP协议进行传输,此时需要在宿主机(主机)上安装一个软件来提供TFTP服务。
本文将从 Boot Loader 的概念、 任务、 框架结构以及 安装等四个方面来讨论。
Boot Loader 的实现依赖于 CPU 的体系结构,因此大多数 Boot Loader 都分为 stage1
和 stage2 两大部分。依赖于 CPU 体系结构的代码,比如设备初始化代码等,通常都放在 stage1中,而且通常都用汇编语言来实现,以达到短小精悍的目的。而 stage2 则通常用 C 语言来实现,这样可以实现给复杂的功能,而且代码会具有更好的可读性和可移植性。 所以它的基本结构如下:
stage1 通常包括以下步骤(以执行的先后顺序):
stage2 通常包括以下步骤(以执行的先后顺序):
负责写驱动程序的工程师要将中断服务程序的地址填入中断矢量表,并必须保证当驱动程序被执行时,中断系统是正常的。一般来说主要做好以下工作:
// ISR模板
//
void isr_template(void)
{
// 将所有通用目的寄存器存到堆栈
//
asm("pushn %r15"); /*将r0 - r15 都存到堆栈中 */
//将ALR与AHR寄存器通过r1存到堆栈
//你无需搞清ALR和AHR是什么寄存器,不同的CPU有不同的寄存器需要存储
//
asm("ld.w %r1, %alr");
asm("ld.w %r0, %ahr");
asm("pushn %r1");
//调用C语言函数your_ISR,即真正ISR要处理的事写在该函数里就行
//
asm("xcall your_ISR");
//从堆栈中取回被调用时的ALR和AHR寄存器的值
//
asm("popn %r1");
asm("ld.w %alr, %r1");
asm("ld.w %ahr, %r0");
//从堆栈中取回r1 - r15的值
//
asm("popn %r15");
//执行中断返回指令,返回被中断的程序
//
asm("reti");
}
在以上各环节中容易出错的地方有:
- 中断优先级寄存器没设正确;
- 中断矢量表中各个entry与中断源的对应关系错误;
- 中断矢量表地址设置错误,很多CPU会要求中断矢量表的地址要设置在偶数地址或是4的倍数,甚至是128KB的倍数。
那如何判断ISR有没有被正确执行呢?一般的方法是选择一个简单的中断源(例如除0错误中断),在其ISR中设定一个断点,然后单步执行,看能否顺利执行ISR程序及正确返回中断发生的地方(除零指令的下一条语句)。
目的:为了获得更快的执行速度,通常把 stage2 加载到 RAM 空间中来执行,因此必须为加载 Boot Loader 的 stage2 准备好一段可用的 RAM 空间范围。
空间大小的确定:由于 stage2 通常是 C 语言执行代码,因此除了内核映象的大小外, 还必须把堆栈空间也考虑进来。此外,空间大小最好是内存页大小(通常是 4KB)的倍数。一 般而言, 1M 的 RAM 空间已经足够了。
地址范围:理论上可以任意安排,比如 blob 就将它的 stage2 可执行映像安排到从系统 RAM 起始地址 0xc0200000 开始的 1M 空间内执行。但是,更推荐将整个 RAM 空间的最顶端的1MB空间留给它。
存储器出问题的地方有:
可读写测试:为了确保所安排的地址范围是可读写的 RAM 空间,必须对地址范围进行测试。
blob 的测试方法(以页为测试单位,测试每页开始的两个字是否是可读写的。其具体步骤如下:
简便方法:对每一个字节依次写入0x00、0xFF、0x55、0xAA,确保每一位都会被写入0与1。
int SRAM_testing(void)
{
int i,counter =0;
//待测RAM起始地址为0x2000000,大小为2MB.
unsigned char *pointer = (unsigned char *)0x2000000;
unsigned char data[4]={0x00,0xFF,0x55,0xAA};
for(i=0; i<4; i++)
{ // 逐一对每个字节写入某特殊值
for(j=0; j<(8*1024*1024); j++)
pointer[i] = data[i]
// 逐一读出每个字节,判断写入的值是否正确
for(j=0; j<(8*1024*1024); j++)
pointer[i]==data[i]?::counter++;
}
return counter; //返回出错字节的个数
}
/***************************************************************
Function Name: calculate_ROM_checksum
Function Purpuse:计算起始地址为0x2000000,size为8MB存储器的校验和
****************************************************************/
unsigned long calculate_ROM_checksum(void)
{
unsigned long checksum = 0;
unsigned char *pointer = 0x2000000;
for(i=0; i<(8*1024*1024); i++)
checksum += pointer[i];
return checksum;
}
堆栈指针的设置是为了执行 C 语言代码作好准备。通常我们可以把 sp 的值设置为(stage2_end-4),也即在RAM 空间的最顶端1MB的结尾处(堆栈向下生长)。
正确设置堆栈(Stack)是函数能否成功调用的前提,在嵌入式系统开发时,系统要自行管理堆栈,如果管理不当,可能会发生函数调用或调用几层之后就死机的状况。因为C语言利用堆栈完成以下事项:
堆栈顶点SP(Stack Point)的配置是一件很重要的事,但却极易被人忽略。主要是在Windows或Linux上编程时,操作系统在产生可执行文件时,linker会自动帮程序加上一段Startup Code,其中就包含了Stack存储器的配置。但在无操作系统的嵌入式系统中,调用任何函数之前都要先为其设置好堆栈空间(Stack Point)。
当用C语言调用了一个函数,例如fun(a,b),编译后的机器码应该包含以下动作:
- 执行指令push,将参数a和b存入Stack,同时堆栈指针SP减一;
- 将当前程序计数寄存器PC的值(也即返回地址:函数调用指令的下一条指令地址)存到堆栈中;
- 执行指令Call,把PC的值设为函数fun()的地址,下一个被执行的指令就是函数的第一条命令。
- 当函数fun执行时,可利用当前SP的值计算出参数a和b的地址;
- 如果函数内部有局部变量,则依次将这些变量存到堆栈中。所以在嵌入式开发中尽量不要定义size太大的变量,否则有栈溢出(Stack Overflow)的风险。
- 当函数执行完毕,CPU会执行ret命令,该命令会从Stack顶层取出返回地址,然后赋值给PC寄存器,则下个指令就会执行函数后面的下一行指令,从而完成函数的调用。
如果SP寄存器没有设定到正确的地址,或是没有配置足够大的存储区域作为栈空间,那么在调用函数时很可能就会出错。下图就是一个栈空间溢出,破坏程序数据段的例子:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C6OLntM4-1669302039177)(https://cdn.jsdelivr.net/gh/Leon1023/leon_pics/img/20201125130733.png)]
为避免以上情况的发生,一般会选择某块RAM 的顶端(最大地址)当作SP寄存器的初值,但具体栈的大小定位多少合适要根据具体软硬件环境和项目要求。一般采用的方法是,刚开始稍微定义大一点,例如2KB-4KB左右,然后让测试人员运行完系统所有功能(函数)后,记录下SP在每次函数调用后的最小值,它与栈顶地址的差就是所需最小栈空间,一般会稍微再放一点。
在设置堆栈指针 sp 之前,也可以关闭 led 灯, 以提示用户我们准备跳转到 stage2。
经过上述这些执行步骤后,系统的物理内存布局应该如下图所示:
如何跳转到stage2的入口(main函数)?
与普通 C 语言应用程序不同的是,在编译和链接 boot loader 这样的程序时,我们不能使用 glibc 库中的任何支持函数,那如何调用main函数呢?为此我们可以采用汇编语言写一个称为”弹簧床“的代码进行跳转:
.text
.globl _trampoline
_trampoline:
bl main
b _trampoline
可以看出,当 main() 函数返回后,我们又用一条跳转指令重新执行 trampoline 程序――当然也就重新执行 main() 函数,这也就是 trampoline(弹簧床)一词的意思所在。
通常包括:
内存映射:在整个 4GB 物理地址空间中有哪些地址范围被分配用来寻址系统的 RAM 单元。嵌入式系统往往只把 CPU 预留的全部 RAM 地址空间中的一部分映射到 RAM 单元上,而让 剩下的那部分预留 RAM 地址空间处于未使用状态。 因此stage2 必须在将存储在 flash 上的内核映像和跟文件系统等读到 RAM 空间中之前检测整个系统的内存映射情况,也即它必须知道 CPU 预留的全部 RAM 地址空间中的哪些被真正映射到 RAM 地址单元,哪些是处于 “unused” 状态的。
内存映射的描述
如下数据结构用来描述 RAM 地址空间中的一段连续(continuous)的地址范围:
typedef struct memory_area_struct {
u32 start; /* the base address of the memory region */
u32 size; /* the byte number of the memory region */
int used;
} memory_area_t;
/*
* used=1 说明这段连续的地址范围已被实现,也即真正地被映射到 RAM 单元上。
* used=0 说明这段连续的地址范围并未被系统所实现,而是处于未使用状态
*/
因此,整个 CPU 预留的 RAM 地址空间可以用一个 memory_area_t 类型的数组来表示,如下所示:
memory_area_t memory_map[NUM_MEM_AREAS] = {
[0 ... (NUM_MEM_AREAS - 1)] = {
.start = 0,
.size = 0,
.used = 0
},
};
内存映射的检测
以下是一个可用来检测整个 RAM 地址空间内存映射情况的简单而有效的算法 :
/* 数组初始化 */
for(i = 0; i < NUM_MEM_AREAS; i++)
memory_map[i].used = 0;
/* first write a 0 to all memory locations */
for(addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE)
*(u32 *)addr = 0;
for(i = 0, addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE) {
/*
* 检测从基地址 MEM_START+i*PAGE_SIZE 开始,大小为
* PAGE_SIZE 的地址空间是否是有效的 RAM 地址空间。
*/
调用 3.1.2 节中的算法 test_mempage();
if ( current memory page is not a valid ram page) {
/* no RAM here */
if(memory_map[i].used )
i++;
continue;
}
/*
* 当前页已经是一个被映射到 RAM 的有效地址范围
* 但是还要看看当前页是否只是 4GB 地址空间中某个地址页的别名?
*/
if(*(u32 *)addr != 0) { /* alias? */
/* 这个内存页是 4GB 地址空间中某个地址页的别名 */
if ( memory_map[i].used )
i++;
continue;
}
/*
* 当前页已经是一个被映射到 RAM 的有效地址范围
* 而且它也不是 4GB 地址空间中某个地址页的别名。
*/
if (memory_map[i].used == 0) {
memory_map[i].start = addr;
memory_map[i].size = PAGE_SIZE;
memory_map[i].used = 1;
} else {
memory_map[i].size += PAGE_SIZE;
}
} /* end of for (...) */
(1) 规划内存占用的布局
包括两个方面:
在规划内存占用的布局时,主要考虑基地址和映像的大小两个方面:
(2)从 Flash 上拷贝
像 ARM 这样的嵌入式 CPU 通常都是在统一的内存地址空间中寻址 Flash 等固态存储设备 的,因此从 Flash 上读取数据与从 RAM 单元中读取数据并没有什么不同。用一个简单的循环就可 以完成从 Flash 设备上拷贝映像的工作:
while(size) {
*dest++ = *src++; /* 字对齐 */
size -= 4; /* 字节计数 */
};
在将内核映像和根文件系统映像拷贝到 RAM 空间中后,就可以准备启动 Linux 内核了。 但是在调用内核之前,应该先设置 Linux 内核的启动参数。
Linux 2.4.x 以后的内核都期望以标记列表(tagged list)的形式来传递启动参数。启动参数标记列表以 标记 ATAG_CORE 开始,以标记 ATAG_NONE 结束。每个标记由标识被传递参数的 tag_header 结构以及随后的参数值数据结构来组成。数据结构 tag 和 tag_header 定义在 Linux 内核源码的 include/asm/setup.h 头文件中:
/* The list ends with 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);
设置 ATAG_MEM
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);
}
}
设置 ATAG_CMDLINE
Linux 内核在启动时可以以命令行参数的形式来接收信息,利用这一点我们可以向内核提供那些内核 不能自己检测的硬件参数信息,或者重载(override)内核自己检测到的信息。
假设有命令行参数字符串"console=ttyS0,115200n8",来通知内核以 ttyS0 作为控制台,且串口采用 "115200bps、无奇偶校验、 8 位数据位"这样的设置。下面是一段设置调用内核命令行参数字符串的示例代码:
char *p;
/* eat leading white space */
for(p = commandline; *p == ' '; p++)
;
/* skip non-exist command lines so the kernel 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);
设置 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; /* 自动加载 */
params = tag_next(params);
设置ATAG_NONE
最后,设置 ATAG_NONE 标记,结束整个启动参数列表:
static void setup_end_tag(void)
{
params->hdr.tag = ATAG_NONE;
params->hdr.size = 0;
}
Boot Loader 调用 Linux 内核的方法是直接跳转到内核的第一条指令处,也即直接跳转到MEM_START+0x8000 地址处。在跳转时,下列条件要满足:
可以如下调用内核:
void (*theKernel)(int zero, int arch, u32 params_addr) = \
(void (*)(int,int, u32))KERNEL_RAM_BASE;
...
theKernel(0, ARCH_NUMBER, (u32) kernel_params_start);
如果,你的嵌入式系统比较简单,不需要带操作系统,那就可以按照如下方式将应用程序映像(代码段、数据段等)拷贝到内存中
有初值的全局变量必须被存储在可执行文件中、被烧录到ROM里。但执行时因为这些全局变量的值会被改变,所以当然不能在ROM里运行,连接时必须寻址到RAM中。正因为这种 “存储在ROM,运行在RAM” 的特性,才有传输data段的需要,且必须在所有程序使用全局变量前完成这些事。
上图中,data段的内容原本在可执行文件中的rodata段之后,但执行时,需要将data段复制到RAM中的bss段之后。连接脚本如下:
.data __END_bss : AT(__END_rodata)
{
__START_data = .;
*(.data);
__END_data = .;
// 定义可在程序中使用的变量“__START_data_LMA”,表示data段的存储起始地址LMA
__START_data_LMA = LOADADDR(.data);
//定义可在程序中使用的变量“__SIZE_DATA”,表示data段的大小
__SIZE_DATA = __END_data - __START_data;
}
传输程序如下:
/**************************************************
Function Name: copy_data_section()
Function Purpuse:将可执行文件中的数据段复制到内存中
***************************************************/
extern unsigned long *__START_data;
extern unsigned long *__START_data_LMA;
extern int __SIZE_DATA;
void copy_data_section(void)
{
int i;
unsigned long *dest = __START_data;
unsigned long *src = __START_data_LMA;
//假设data段的大小是4的整数倍个字节
for(i=0; i<(__SIZE_DATA/4); i++)
dest[i] = src[i];
}
bss段的设定较为简单,因为bss段里的成员都是没有初始值的全局变量,所有根本不需要存储空间,在执行时只要把bss段的执行空间(VMA)都设为0即可。
/*******************************************
定义bss段,起始地址(VMA)从0开始
******************************************/
.bss 0x0 :
{
__START_bss = .;
*(.bss);
__END_bss = .;
//定义可在程序中使用的变量:__SIZE_BSS
__SIZE_BSS = __END_bss - __START_bss;
}
设定bss段为0的代码如下:
/**************************************************
Function Name: clear_bss_section()
Function Purpuse:将bss段清零
***************************************************/
extern unsigned long * __START_bss;
extern int __START_BSS;
void clear_bss_section(void)
{
int i;
unsigned long * dest = __START_bss;
//假设bss段的大小为4的整数倍字节大小
for(i=0; i<(__SIZE_BSS/4); i++)
dest[i] = 0;
}
Attention:在boot阶段,data段和bss段一定要先设定,否则执行期间全局变量的值就不正确。换句话说,在设定完data和bss段之前,boot-load程序是不能使用全局变量的,如果一定要使用,那就避免在定义全局变量时赋值,一定要在程序内明确赋值才行。例如:
//全局变量定义,有初值设定的属于data段,没有初值设定的则属于BSS段
int global_var_1 = 1234;
int global_var_2;
void boot(void)
{
int temp;
//此时机器刚上电,data段所在存储器(VMA)的内容可能为任何值
//甚至可能此时这块存储器还没初始化完毕,根本无法使用
temp = global_var_1; //无意义
//明确的设置后,变量的值才可确认为5678
global_var_1 = 5678;
temp = global_var_1; //temp = 5678
//同理,此时BSS段所在的存储器内容可能为任意值
temp = global_var_2; //无意义
//设定data段
copy_data_section();
//将BSS段全部清零
clear_bss_section();
tmep = global_var_1; //temp = 1234
tmep = global_var_2; //temp = 0
...
}
当某个系统程序或者应用程序模块需要较高的执行速度时,往往可以将他们复制到系统内存中执行。但系统内存往往空间有限,不可能同时全部加载进去。所以我们一般会写一个函数,并寻址到同一个地址,在需要时才做载入的动作。
各种类型的存储器性能由大至小分别为:CPU寄存器、CPU cache、CPU内部RAM、外部SRAM、NOR Flash、SDRAM、Mask ROM、NAND Flash。
NAND Flash:价格低,容量大,可把其想象成类似硬盘的设备,只不过无法直接寻址操作,程序无法再上面直接执行;
NOR Flash:价格高,容量小,但读数据快,可把其想象成可重复写的ROM,程序可在上面直接运行。
Mask ROM:成本高,容量有限,但程序可直接在上面运行;
SDRAM:性价比高,一般作为系统的外置内存,程序可直接在上面运行;
SRAM:价格昂贵,容量小,一般作为系统的内置内存,程序可在上面直接运行。