Boot Loader启动过程分析

一、    Boot Loader的概念和功能 

1、嵌入式Linux软件结构与分布在一般情况下嵌入式Linux系统中的软件主要分为以下及部分:

(1)引导加载程序:其中包括内部ROM中的固化启动代码和Boot Loader两部分。而这个内部固化ROM是厂家在芯片生产时候固化的,作用基本上是引导Boot Loader。有的芯片比较复杂,比如Omap3,他在flash中没有代码的时候有许多启动方式:USB、UART或以太网等等。而S3C24x0则很简单,只有Norboot和Nandboot。

(2)Linux kernel 和drivers。

(3)文件系统。包括根文件系统和建立于Flash内存设备之上的文件系统(EXT4、UBI、CRAMFS等等)。它是提供管理系统的各种配置文件以及系统执行用户应用程序的良好运行环境的载体。

(4)应用程序。用户自定义的应用程序,存放于文件系统之中。
在Flash 存储器中,他们的 一般分布如下:

Boot Loader启动过程分析 - singleboy - singleboy的博客 

但是以上只是大部分情况下的分布,也有一些可能根文件系统是initramfs,被一起压缩到了内核映像里,或者没有Bootloader参数区,等等。

2、在嵌入式Linux中为什么要有BootLoader
在linux内核的启动运行除了内核映像必须在主存的适当位置,CPU还必须具备一定的条件:

【1】CPU寄存器设置:

R0=0;

R1=Machine ID(即Machine Type Number,定义在linux/arch/arm/tools/mach-types);

R2=内核启动参数在RAM中起始基地址;

【2】CPU模式:

必须禁止中断(IRQs和FIQs);

CPU 必须工作在是超级保护模式(SVC) 模式;

【3】Cache和MMU的设置:

MMU 必须关闭;

指令Cache可以打开也可以关闭;

数据Cache必须关闭;

但是在CPU刚上电启动的时候,一般连内存控制器都没有配置过,根本无法在内存中运行程序,更不可能处在Linux内核的启动环境中。为了初始化CPU及其他外设,使得Linux内核可以在系统主存中跑起来,并让系统符合Linux内核启动的必备条件,必须要有一个先于内核运行的程序,他就是所谓的引导加载程序(Boot Loader)。

而Boot Loader并不是Linux才需要,是几乎所有的运行操作系统的设备都具备的。我们的PC的BOIS就是Boot Loader的一部分(只是前期引导,后面一般还有外存中的各种Boot Loader),对于Linux PC来说,Boot Loader = BIOS + GRUB/LILO。

正如前面所述,Boot Loader是在操作系统内核运行之前运行的一段小程序。通过这段小程序,我们可以初始化硬件设备,从而将系统的软硬件环境带到一个合适的状态,以便为最终调用操作系统内核准备好正确的环境,最后从别处(Flash、以太网、UART)载入内核映像并跳到入口地址。

由于BootLoader直接操作硬件,所以她严重依赖于硬件,而且依据所引导的操作系统的不同。

二、Boot Loader的工作模式

大多数 Boot Loader 都包含两种不同的操作模式:“启动加载”模式和“下载”模式,这种区别仅对于开发人员才有意义。但从最终用户的角度看,Boot Loader 的作用就是用来加载操作系统,而并不存在所谓的启动加载模式与下载工作模式的区别。

启动加载(Boot loading)模式:

这种模式也称为"自主"(Autonomous)模式。也即 Boot Loader 从目标机上的某个固态存储设备上将操作系统加载到 RAM 中运行,整个过程并没有用户的介入。这种模式是 Boot Loader 的正常工作模式,因此在嵌入式产品发布的时侯,Boot Loader 显然必须工作在这种模式下。

下载(Downloading)模式:

在这种模式下,目标机上的 Boot Loader 将通过串口连接或网络连接等通信手段从主机(Host)下载文件,比如:下载内核映像和根文件系统映像等。从主机下载的文件通常首先被 Boot Loader 保存到目标机的 RAM 中,然后再被 Boot Loader 写到目标机上的FLASH 类固态存储设备中。Boot Loader 的这种模式通常在第一次安装内核与根文件系统时被使用;此外,以后的系统更新也会使用 Boot Loader 的这种工作模式。工作于这种模式下的 Boot Loader 通常都会向它的终端用户提供一个简单的命令行接口。

 象Blob 或U-Boot 等这样功能强大的Boot Loader 通常同时支持这两种工作模式,而且允许用户在这两种工作模式之间进行切换。比如,Blob 在启动时处于正常的启动加载模式,但是它会延时10 秒等待终端用户按下任意键而将 blob 切换到下载模式。如果在 10 秒内没有用户按键,则 blob 继续启动 Linux 内核。

三、Boot Loader 与主机之间进行文件传输协议

最常见的情况就是,目标机上的 Boot Loader 通过串口与主机之间进行文件传输,传输协议通常是 xmodem/ymodem/zmodem 协议中的一种。但是,串口传输的速度是有限的,因此通过以太网连接并借助 TFTP 协议来下载文件是个更好的选择。

此外,在论及这个话题时,主机方所用的软件也要考虑。比如,在通过以太网连接和TFTP 协议来下载文件时,主机方必须有一个软件用来的提供 TFTP 服务。

四、Bootloader的工作流程

由于Boot Loader的实现依赖与CPU的体系结构,因此大多数的Boot Loader都分为stage1和stage2两个阶段:

1,Bootloader 的第一阶段(Stage1),工作流程

  • 硬件设备初始化
  • 代码重定位,为加载 Boot Loader 的 stage2 准备 RAM 空间
  • 加载t第二阶段代码到RAM空间
  • 设置堆栈跳转到第二阶段代码入口

1.1,硬件设备初始化通常包括如下步骤:(按先后顺序执行):

【1】复位(reset)

【2】设置CPU为超级保护模式(SVC) 即特权模式(Supervisor)

【3】关闭看门狗,不必附加喂狗代码。

【4】屏蔽所有中断,为中断提供服务通常是OS设备驱动程序的责任,因此在 Boot Loader 的执行全过程中可以不必响应任何中断。中断屏蔽可以通过写CPU的中断屏蔽寄存器或状态寄存器(比如 ARM 的 CPSR 寄存器)来完成。

【5】设置系统时钟频率。

【6】初始化内存控制器,包括正确地设置系统的内存控制器的功能寄存器以及各内存库控制寄存器等。

【7】初始化串口等,典型地,初始化UART并向串口打印相关字符信息。

【8】初始化LED。典型地,通过GPIO 来驱动LED,其目的是表明系统的状态是 OK 还是 Error。如果板子上没有 LED,那么也可以通过初始化 UART 向串口打印 Boot Loader 的Logo 字符信息来完成这一点。

【9】关闭 CPU 内部指令/数据 cache。

1.2,代码重定位主要检查自己是否在内存中。如果是跳到堆栈段(stack_setup代码段)设置堆栈,不是就加载自己到RAM空间。

为了获得更快的执行速度,通常把 stage2 加载到 RAM 空间中来执行,因此必须为加载 Boot Loader 的 stage2 准备好一段可用的 RAM 空间范围。

由于 stage2 通常是 C 语言执行代码,因此在考虑空间大小时,除了 stage2 可执行映象的大小外,还必须把堆栈空间也考虑进来。此外,空间大小最好是 memory page 大小(通常是 4KB)的倍数。一般而言,1M 的 RAM 空间已经足够了。具体的地址范围可以任意安排,比如 blob 就将它的 stage2 可执行映像安排到从系统 RAM 起始地址 0xc0200000 开始的 1M 空间内执行。但是,将 stage2 安排到整个 RAM 空间的最顶 1MB(也即(RamEnd-1MB) - RamEnd)是一种值得推荐的方法。

为了后面的叙述方便,这里把所安排的RAM 空间范围的大小记为:stage2_size(字节),把起始地址和终止地址分别记为:stage2_start 和 stage2_end(这两个地址均以 4 字节边界对齐)。因此:

stage2_end = stage2_start + stage2_size

另外,还必须确保所安排的地址范围的的确确是可读写的RAM 空间,因此,必须对你所安排的地址范围进行测试。具体的测试方法可以采用类似于blob 的方法,也即:以 memory page 为被测试单位,测试每个 memory page 开始的两个字是否是可读写的。为了后面叙述的方便,我们记这个检测算法为:test_mempage,其具体步骤如下:

 【1】先保存 memory page 一开始两个字的内容。

 【2】向这两个字中写入任意的数字。比如:向第一个字写入 0x55,第 2 个字写入 0xaa。

 【3】 然后,立即将这两个字的内容读回。显然,我们读到的内容应该分别是 0x55 和 0xaa。如果不是,则说明这个 memory page 所占据的地址范围不是一段有效的 RAM 空间。

 【4】再向这两个字中写入任意的数字。比如:向第一个字写入 0xaa,第 2 个字中写入 0x55。

 【5】然后,立即将这两个字的内容立即读回。显然,我们读到的内容应该分别是 0xaa 和 0x55。如果不是,则说明这个 memory page 所占据的地址范围不是一段有效的 RAM 空间。

 【6】恢复这两个字的原始内容。测试完毕。

为了得到一段干净的RAM 空间范围,我们也可以将所安排的 RAM 空间范围进行清零操作。

1.3,加载Bootloader第二阶段代码到RAM空间,拷贝时要确定两点:(1) stage2 的可执行映象在固态存储设备的存放起始地址和终止地址;(2) RAM 空间的起始地址。Boot Loader启动过程分析 - singleboy - singleboy的博客

1.4,设置好堆栈,强调下堆和栈的区别:栈区(stack) 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈;堆区(heap) 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。程序的局部变量存在于(栈)中,全局变量存在于(静态区 )中,动态申请数据存在于( 堆)中全局变量实际上是存在一个(一般来说正常的编译器)可读可写的内存空间,这个空间是在你写程序编译好的空间地址(由编译器决定),是固定的。

堆栈指针的设置是为了执行 C 语言代码作好准备。通常我们可以把 sp 的值设置为(stage2_end-4),因为栈是向下生长的,所以通常把栈指针设在1MB空间的最顶端。此外,在设置堆栈之前,也可以把指示用的LED灯关闭,以提示用户跳转到Stage2。经过以上步骤设置以后,系统的物理内存布局应该如图所示。

1.5,跳转到第二阶段(Stage2)代码入口,在上述一切就绪后,就可以跳转到Boot Loader的Stage2执行了,在ARM系统中是通过修改PC寄存器为合适的地址来实现的。如U-Boot中是这样实现的:

ldr pc, _start_armboot
start_armboot是第二阶段(Stage2)的C程序的入口点。start_armboot是U-Boot执行的第一个C语言函数,完成系统初始化工作,进入主循环,处理用户输入的命令。

2,Bootloader的第二阶段(Stage2)工作流程

  • 初始化本阶段要使用到的硬件设备
  • 检测系统内存映射
  • 加载内核映像和根文件系统映像
  • 设置内核的启动参数
  • 启动内核

2.1,初始化本阶段要使用到的硬件设备,这通常包括:

(1)设置时钟、初始化至少一个串口,以便和终端用户进行 I/O 输出信息;(2)初始化计时器等。在初始化这些设备之前,也可以重新把 LED 灯点亮,以表明我们已经进入 main_loop() 函数执行。

board_init函数设置MPLL、改变系统时钟,它是开发板相关的函数,在board/samsung/smdk2440/smdk2440.c中实现。值得注意的是board_init函数还保存了机器类型ID,这将在调用内核的时候传递给内核。代码如下:

gd->bd->bi_arch_number = MACH_TYPE_S3C2440;   //值为362

串口的初始化函数主要是serial_init,它设置UART控制器,是CPU相关的函数。

2.2,检测系统内存映射(memory map)

所谓内存映射就是指在整个4GB 物理地址空间中有哪些地址范围被分配用来寻址系统的RAM 单元。比如,在SA-1100 CPU 中,从0xC000,0000 开始的512M 地址空间被用作系统的RAM 地址空间,而在Samsung S3C44B0X CPU 中,从 0x0c00,0000 到 0x1000,0000 之间的 64M 地址空间被用作系统的 RAM 地址空间。虽然CPU 通常预留出一大段足够的地址空间给系统 RAM,但是在搭建具体的嵌入式系统时却不一定会实现 CPU 预留的全部 RAM 地址空间。也就是说,具体的嵌入式系统往往只把 CPU 预留的全部 RAM 地址空间中的一部分映射到 RAM 单元上,而让剩下的那部分预留 RAM 地址空间处于未使用状态。由于上述这个事实,因此 Boot Loader 的 stage2 必须在它想干点什么 (比如,将存储在 flash 上的内核映像读到 RAM 空间中) 之前检测整个系统的内存映射情况,也即它必须知道 CPU 预留的全部 RAM 地址空间中的哪些被真正映射到 RAM 地址单元,哪些是处于 "unused" 状态的。

对于smdk2440的开发板,其内存分布是明确的,一般内存起始地址为0x3000 0000,大小为64M = 0x0400 0000。代码如下:

int dram_init(void)

{

     gd->bd->bi_dram[0] . start = PHYS_SDRAM_1;          //即0x3000 0000

     gd->bd->bi_dram[0].size = PHYS_SDRAM_1_SIZE;  //即0x0400 0000

//这两个值都定义在include/configs/smdk2440.h中

}

2.3,将内核映像和根文件系统映像从Flash上读到RAM空间中。

【1】 规划内存占用的布局

这里包括两个方面:(1)内核映像所占用的内存范围;(2)根文件系统所占用的内存范围。在规划内存占用的布局时,主要考虑基地址和映像的大小两个方面。

对于内核映像,一般将其拷贝到从(MEM_START+0x8000) 这个基地址开始的大约1MB大小的内存范围内(嵌入式 Linux 的内核一般都不操过 1MB)。为什么要把从 MEM_START 到 MEM_START+0x8000 这段 32KB 大小的内存空出来呢?这是因为 Linux 内核要在这段内存中放置一些全局数据结构,如:启动参数和内核页表等信息。

而对于根文件系统映像,则一般将其拷贝到 MEM_START+0x0010,0000 开始的地方。如果用 Ramdisk 作为根文件系统映像,则其解压后的大小一般是1MB。

【2】从 Flash 上拷贝

由于像 ARM 这样的嵌入式 CPU 通常都是在统一的内存地址空间中寻址 Flash 等固态存储设备的,因此从 Flash 上读取数据与从 RAM 单元中读取数据并没有什么不同。用一个简单的循环就可以完成从 Flash 设备上拷贝映像的工作:

while(count) {

*dest++ = *src++; /* they are all aligned with word boundary */

count -= 4; /* byte number */

};

2.4,为内核设置启动参数。

应该说,在将内核映像和根文件系统映像拷贝到 RAM 空间中后,就可以准备启动 Linux 内核了。但是在调用内核之前,应该作一步准备工作,即:设置 Linux 内核的启动参数。

U-Boot 是通过标记列表向内核传递参数。

setup_memory_tags

setup_commandline_tag

这两个标记列表定义在arch/arm/lib/bootm.c中,需要在定义命令的文件include/configs/smdk2440.h中定义两个命令

#define CONFIG_SETUP_MEMORY_TAGS  1

#define CONFIG_CMDLINE_TAG                  1

Linux 2.4.x 以后的内核都期望以标记列表(tagged list)的形式来传递启动参数。启动参数标记列表以标记ATAG_CORE 开始,以标记 ATAG_NONE 结束。每个标记由标识被传递参数的 tag_header 结构以及随后的参数值数据结构来组成。数据结构 tag 和 tag_header 定义在 Linux 内核源码的/arch/arm/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);  

其中,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 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);

 

请注意在上述代码中,设置 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 void setup_end_tag(void)

{

params->hdr.tag = ATAG_NONE;

params->hdr.size = 0;

}

2.5,启动内核

Boot Loader 调用 Linux 内核的方法是直接跳转到内核的第一条指令处,也即直接跳转到 MEM_START+0x8000 地址处。在跳转时,下列条件要满足:

【1】CPU 寄存器的设置:

R0=0;

注:

@R1=机器类型 ID;关于 Machine Type 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()函数调用应该永远不返回的。如果这个调用返回,则说明出错。

对于ARM构架的CPU来说,都是通过../lib_arm/bootm.c中的do_bootm_linux函数来启动内核的。这个函数中,设置标记列表,最后通过

theKernel = (void (*)(int, int, uint))images->ep;

调用内核。其中,theKernel 指向内核存放的地址(对于ARM构架的CPU,通常这个地址是0x3000 8000)。传递的3个参数如下:

void (*theKernel)(int zero, int arch, uint params);

R0: 0

R1: 机器类型ID -- gd->bd->bi_arch_number = MACH_TYPE_S3C2440;   //值为362

R2: 启动参数标记列表在RAM中的起始地址 0x3000 0100

五、 关于串口终端

在 boot loader 程序的设计与实现中,没有什么能够比从串口终端正确地收到打印信息能更令人激动了。此外,向串口终端打印信息也是一个非常重要而又有效的调试手段。但是,我们经常会碰到串口终端显示乱码或根本没有显示的问题。造成这个问题主要有两种原因:(1) boot loader 对串口的初始化设置不正确。(2) 运行在 host 端的终端仿真程序对串口的设置不正确,这包括:波特率、奇偶校验、数据位和停止位等方面的设置。

此外,有时也会碰到这样的问题,那就是:在 boot loader 的运行过程中我们可以正确地向串口终端输出信息,但当 boot loader 启动内核后却无法看到内核的启动输出信息。对这一问题的原因可以从以下几个方面来考虑:

(1) 首先请确认你的内核在编译时配置了对串口终端的支持,并配置了正确的串口驱动程序。

(2) 你的 boot loader 对串口的初始化设置可能会和内核对串口的初始化设置不一致。此外,对于诸如 s3c44b0x 这样的 CPU,CPU 时钟频率的设置也会影响串口,因此如果 boot loader 和内核对其 CPU 时钟频率的设置不一致,也会使串口终端无法正确显示信息。

(3) 最后,还要确认 boot loader 所用的内核基地址必须和内核映像在编译时所用的运行基地址一致,尤其是对于 uClinux 而言。假设你的内核映像在编译时用的基地址是 0xc0008000,但你的 boot loader 却将它加载到 0xc0010000 处去执行,那么内核映像当然不能正确地执行了。

六、 结束语

Boot Loader 的设计与实现是一个非常复杂的过程。如果能从串口收到那激动人心的

"uncompressing linux

.................. done,

booting the kernel……"

内核启动信息,说明boot loader 已经成功地转起来了!。

THE END
< <上一篇
下一篇>>