超详细BootLoader原理分析

【摘要】本文详细地介绍了基于嵌入式系统中的 OS 启动加载程序 ―― BootLoader 的概念、软件设计的主要任务以及结构框架等内容。 在拿到空PCB板之后,硬件工程师首先会测试各主要线路是否通连(各焊点是否有空焊、断接或短路的情况),然后逐个模块焊接上去。之后需要验证系统上电之后,CPU与各组件的供电电压是否正常,供给CPU的震荡电路能否能够正常起振,外部存储器能否正常读写。当把我们的程序用JTAG工具下载到板子上后,在真正调试系统前需要做好以下检查:

  • 利用调试工具,在程序的第一行设定断点,确定程序有停下来;
  • 检查CPU的程序计数器PC是否正确;
  • 检查CPU内部RAM的内容和我们下载的可执行文件是否相同;
  • 程序的第一行命令为设定CPU状态寄存器,并观察CPU的状态寄存器是否如预期改变;
  • 继续单步执行,确认PC寄存器是否会跟着改变,且每行命令的执行结果都是正确的。

检查完以上各项后,只能证明板子上的电源电路以及CPU是正常的,接下来要继续验证CPU与外围设备,确认板子的正确性与稳定性后,才能进行下一步测试。

1. 引言

首先搞清楚以下几个概念:

  • 一个嵌入式Linux系统通常分为以下4个层次:

    • 引导加载程序:包括固化在固件中的boot代码(可选)和bootloader。
    • Linux内核:已裁剪的适合嵌入式板子硬件的定制内核及其启动参数。
    • 文件系统:包括根文件系统和建立于Flash之上的文件系统。
    • 用户应用程序:特定于用户的应用程序,包括嵌入式图形用户界面,如MiniGUI,QT等
  • 引导加载程序是系统加电后运行的第一段代码。以最常见的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、内核的启动参数、内核映像和根文件系统映像的固态存储设备的典型空间分配结构图:

    image-20221121204903169

  • 主机和目标机之间一般通过串口建立连接, Boot Loader 软件在执行时通常会通过串口来进行 I/O,比如:输出打印信息到串口,从串口读取用户控制字符等。但因为串口的速率问题,在进行文件传输时会选择以太网连接,并借助TFTP协议进行传输,此时需要在宿主机(主机)上安装一个软件来提供TFTP服务。

  • 本文将从 Boot Loader 的概念、 任务、 框架结构以及 安装等四个方面来讨论。


2. Boot Loader概念

  • Boot Loader 就是在内核运行之前运行的一段小程序。通过它我们初始化硬件设备、建立内存空间的映射图,从而将系统的软硬件环境带到一个合适的状态,以便为最终调用操作系统内核准备好正确的环境。
  • Boot Loader 是特定于硬件的,每种不同的 CPU 体系结构都有不同的 BootLoader。有些 BootLoader 也支持多种体系结构的 CPU,比如 U-Boot 就同时支持 ARM 体系结构和 MIPS 体系结构。因此,建立一个通用的 Boot Loader 几乎是不可能的,但我们仍然可以对 BootLoader 的共性概念进行一些归纳总结,以指导用户开发或修改成特定的 Boot Loader。
  • Boot Loader 的启动过程可以是单阶段( Single Stage)也可以是多阶段( Multi-Stage)的,但通常多阶段的 Boot Loader 能提供更为复杂的功能,以及更好的可移植性。从固态存储设备上启动的 Boot Loader 大多都是 2 阶段的启动过程,也即启动过程可以分为 stage 1 和 stage 2 两部分。而至于在 stage 1 和 stage 2 具体完成哪些任务将在下面讨论。
  • BootLoader有两种模式:
    • 启动模式:Boot Loader 从目标机上的某个固态存储设备上将操作系统加载到 RAM 中运行,整个过程并没有用户的介入。这种模式是 Boot Loader 的正常工作模式。
    • 下载模式:Boot Loader 通过串口连接或网络连接等通信手段从主机( Host)下载文件(内核映像或根文件系统映像等)。从主机下载的文件通常首先保存到目标机的 RAM 中,然后再被写到目标机上的FLASH 类固态存储设备中。这种模式通常在第一次安装内核与根文件系统时被使用,以后系统的更新也使用这种模式。该模式下通常都会提供一个简单的命令行接口。
    • 一般来说,启动后会有一个倒计时,若用户未按下任意键,则进入启动模式,否则进入下载模式。

3.Boot Loader的代码结构

Boot Loader 的实现依赖于 CPU 的体系结构,因此大多数 Boot Loader 都分为 stage1
和 stage2 两大部分。依赖于 CPU 体系结构的代码,比如设备初始化代码等,通常都放在 stage1中,而且通常都用汇编语言来实现,以达到短小精悍的目的。而 stage2 则通常用 C 语言来实现,这样可以实现给复杂的功能,而且代码会具有更好的可读性和可移植性。 所以它的基本结构如下:

stage1 通常包括以下步骤(以执行的先后顺序):

  • 硬件设备初始化。
  • 为加载 Boot Loader 的 stage2 准备 RAM 空间。
  • 拷贝 Boot Loader 的 stage2 到 RAM 空间中。
  • 设置好堆栈。
  • 跳转到 stage2 的 C 入口点。

stage2 通常包括以下步骤(以执行的先后顺序):

  • 初始化本阶段要使用到的硬件设备。
  • 检测系统内存映射(memory map)。
  • 将 kernel 映像和根文件系统映像从 flash 上读到 RAM 空间中。
  • 为内核设置启动参数。
  • 调用内核

Stage1

1.硬件初始化

  • 目的:为 stage2 的执行以及随后的 kernel 的执行准备好一些基本的硬件环境。 主要包括以下内容:
    1. 设定状态寄存器,屏蔽所有的中断。 为中断提供服务通常是 OS 设备驱动程序的责任,因此在 Boot Loader 的执行全过程中可以不必响应任何中断。中断屏蔽可以通过写 CPU 的中断屏蔽寄存器或状态寄存器(比如 ARM 的 CPSR 寄存器)来完成。
    2. 设定中断矢量表指针
    3. 设置 CPU 的速度和时钟频率。
    4. 设定存储器控制器,特别是内存(RAM)初始化。 包括正确地设置系统的内存控制器的功能寄存器以及各存储器控制寄存器等。
    5. 设定CPU操作各存储器的时序;
    6. 设定CPU的PIN脚功能;
    7. 初始化外围设备(LCD Controler、USB Controler、SD卡接口等)
    8. 初始化 LED。 典型地,通过 GPIO 来驱动 LED,其目的是表明系统的状态是 OK 还是 Error。如果板子上没有 LED,那么也可以通过初始化 UART 向串口打印 Boot Loader 的 Logo 字符信息来完成这一点。
    9. 关闭 CPU 内部指令/数据 cache。

2. 设置中断向量表

负责写驱动程序的工程师要将中断服务程序的地址填入中断矢量表,并必须保证当驱动程序被执行时,中断系统是正常的。一般来说主要做好以下工作:

  • 中断矢量表数组,详细注解每个entry代表的中断源;
  • 如果是外接中断控制器,要先完成中断控制器的驱动程序,才能开始中断系统的测试。
  • 设定CPU的中断矢量表地址寄存器(有些CPU无中断矢量表地址寄存器,但它会指定某个固定地址为中断矢量表的地址)
  • 设定CPU的中断控制寄存器(优先级、中断允许位等)
  • 确定中断被触发后,对应的ISR会被执行。
  • 提供ISR的范例,让ISR编写者不用知道中断系统的细节。
// 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程序及正确返回中断发生的地方(除零指令的下一条语句)。

3.准备内存空间

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

  • 空间大小的确定:由于 stage2 通常是 C 语言执行代码,因此除了内核映象的大小外, 还必须把堆栈空间也考虑进来。此外,空间大小最好是内存页大小(通常是 4KB)的倍数。一 般而言, 1M 的 RAM 空间已经足够了。

  • 地址范围:理论上可以任意安排,比如 blob 就将它的 stage2 可执行映像安排到从系统 RAM 起始地址 0xc0200000 开始的 1M 空间内执行。但是,更推荐将整个 RAM 空间的最顶端的1MB空间留给它

  • 存储器出问题的地方有

    • 硬件方面:数据线、地址线连接错误;
    • 软件方面:SRAM、NOR Flash、ROM不需要额外电路,直接可以使用,但SDRAM则还需要额外的SDRAM Controler电路才能使用,程序必须先设定好SDRAM Controler的配置(SDRAM大小、速度等);
    • 外部存储器的时序设置,若时序设定太快,系统会不稳定,太慢,则系统性能变差。一般CPU的Timing设定表会说明应该如何设定。
  • 可读写测试:为了确保所安排的地址范围是可读写的 RAM 空间,必须对地址范围进行测试。

    • blob 的测试方法(以页为测试单位,测试每页开始的两个字是否是可读写的。其具体步骤如下:

      1. 先保存一页最开始两个字的内容。
      2. 再向这两个字中写入任意的数字(向第一个字写入 0x55,第 2 个字写入0xaa)。
      3. 然后,立即将这两个字的内容读回。显然,我们读到的内容应该分别是 0x55 和 0xaa。如果不是,则说明这个页占据的地址范围不是一段有效的 RAM 空间。
      4. 再向这两个字中写入任意的数字。比如:向第一个字写入 0xaa,第 2 个字中写入 0x55。
      5. 然后,立即将这两个字的内容立即读回。显然,我们读到的内容应该分别是 0xaa 和 0x55。如果不是,则说明这个页所占据的地址范围不是一段有效的 RAM 空间。
      6. 恢复这两个字的原始内容,至此测试完毕。
      7. 为了得到一段干净的 RAM 空间范围,我们也可以将所安排的 RAM 空间范围进行清零操作。
    • 简便方法:对每一个字节依次写入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; //返回出错字节的个数  
      }
      
      • 对于只读ROM,如何验证烧录到存储器中的数据和原始映像文件一致呢?一般会采用校验和检验法。即分别计算原始映像文件和烧录到ROM中文件的校验和是否相等。
      /***************************************************************
      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;
      }
      

4. 复制stage2到内存中

  • 明确stage2 的可执行映象在固态存储设备的存放起始地址和终止地址
  • RAM空间的起始地址

5. 设置堆栈指针SP

  • 堆栈指针的设置是为了执行 C 语言代码作好准备。通常我们可以把 sp 的值设置为(stage2_end-4),也即在RAM 空间的最顶端1MB的结尾处(堆栈向下生长)。

    • 正确设置堆栈(Stack)是函数能否成功调用的前提,在嵌入式系统开发时,系统要自行管理堆栈,如果管理不当,可能会发生函数调用或调用几层之后就死机的状况。因为C语言利用堆栈完成以下事项:

      • 存储函数返回地址;
      • 函数调用时的参数传递(参数较多时);
      • 存储函数内部的局部变量;
      • 中断服务程序执行时(发生中断时),存储CPU当前状态及返回地址。

      堆栈顶点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。

  • 经过上述这些执行步骤后,系统的物理内存布局应该如下图所示:

    image-20221121212808759

Stage2

  • 如何跳转到stage2的入口(main函数)?

    • 与普通 C 语言应用程序不同的是,在编译和链接 boot loader 这样的程序时,我们不能使用 glibc 库中的任何支持函数,那如何调用main函数呢?为此我们可以采用汇编语言写一个称为”弹簧床“的代码进行跳转:

      .text
      .globl _trampoline
      _trampoline:
      	bl	main
      	b	_trampoline
      
    • 可以看出,当 main() 函数返回后,我们又用一条跳转指令重新执行 trampoline 程序――当然也就重新执行 main() 函数,这也就是 trampoline(弹簧床)一词的意思所在。

1. 初始化硬件

通常包括:

  1. 重新把 LED 灯点亮,表明我们已经进入 main() 函数执行;
  2. 初始化至少一个串口,以便和终端用户进行 I/O 输出信息;
  3. 初始化计时器等。
  4. 输出一些打印信息,程序名字字符串、版本号等。

2.检测系统内存映射(memory map)

  • 内存映射:在整个 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 (...) */
      
      • 在用上述算法检测完系统的内存映射情况后,Boot Loader 也可以将内存映射的详细信息打印到串口。

3. 将 kernel 映像和根文件系统映像从 flash 上读到 RAM 空间中

  • (1) 规划内存占用的布局

    包括两个方面:

    1. 内核映像所占用的内存范围;
    2. 根文件系统所占用的内存范围。

    在规划内存占用的布局时,主要考虑基地址映像的大小两个方面:

    1. 对于内核映像,一般将其拷贝到从(MEM_START+0x8000) 这个基地址开始的大约 1MB 大小的内存范围内(嵌入式 Linux 的内核一般都不操过 1MB)。
      • 把从 MEM_START 到 MEM_START+0x8000 这段 32KB 大小的内存空出来是为 Linux 内核在这段内存中放置一些全局数据结构,如:启动参数和内核页表等信息。
    2. 对于根文件系统映像,则一般将其拷贝到 MEM_START+0x100000 开始的地方。如果用Ramdisk 作为根文件系统映像,则其解压后的大小一般是 1MB。
  • (2)从 Flash 上拷贝

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

      while(size) {
          *dest++ = *src++; 	/* 字对齐 */
          size -= 4; 		/* 字节计数 */
      };
      

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);
      
      • BOOT_PARAMS 表示内核启动参数在内存中的起始基地址;
      • 指针 params 是一个 struct tag类型的指针。
      • 宏 tag_next() 将以指向当前标记的指针为参数,计算紧临当前标记的下一个标记的起始地址。内核的根文件系统所在的设备 ID 就是在这里设置的 。
    • 设置 ATAG_MEM

      • 在 memory_map[]数组中,每一个有效的内存段都对应一个 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);
        
        • 上述代码中,设置 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; 			/* 自动加载 */
        params = tag_next(params);
        
    • 设置ATAG_NONE

      • 最后,设置 ATAG_NONE 标记,结束整个启动参数列表:

        static void setup_end_tag(void)
        {
            params->hdr.tag = ATAG_NONE;
            params->hdr.size = 0;
        }
        

5. 调用内核

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

  1. CPU 寄存器的设置
    • R0=0;
    • R1=机器类型 ID;//机器ID可以参见 linux/arch/arm/tools/mach-types。
    • R2=启动参数标记列表在 RAM 中起始基地址;
  2. CPU模式
    • 必须禁止中断( IRQs 和 FIQs);
    • CPU 必须 SVC 模式;
  3. Cache 和 MMU 的设置:
    • MMU 必须关闭;
    • 指令 Cache 可以打开也可以关闭;
    • 数据 Cache 必须关闭;

可以如下调用内核:

void (*theKernel)(int zero, int arch, u32 params_addr) = \
    (void (*)(int,int, u32))KERNEL_RAM_BASE;
...
theKernel(0, ARCH_NUMBER, (u32) kernel_params_start);

6. 启动不带操作系统的应用程序

如果,你的嵌入式系统比较简单,不需要带操作系统,那就可以按照如下方式将应用程序映像(代码段、数据段等)拷贝到内存中

(1)载入data段

有初值的全局变量必须被存储在可执行文件中、被烧录到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];    
}
(2)设定bss段

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
    ...
}
(3)载入text段

当某个系统程序或者应用程序模块需要较高的执行速度时,往往可以将他们复制到系统内存中执行。但系统内存往往空间有限,不可能同时全部加载进去。所以我们一般会写一个函数,并寻址到同一个地址,在需要时才做载入的动作。

各种类型的存储器性能由大至小分别为:CPU寄存器、CPU cache、CPU内部RAM、外部SRAM、NOR Flash、SDRAM、Mask ROM、NAND Flash。

NAND Flash:价格低,容量大,可把其想象成类似硬盘的设备,只不过无法直接寻址操作,程序无法再上面直接执行;

NOR Flash:价格高,容量小,但读数据快,可把其想象成可重复写的ROM,程序可在上面直接运行。

Mask ROM:成本高,容量有限,但程序可直接在上面运行;

SDRAM:性价比高,一般作为系统的外置内存,程序可直接在上面运行;

SRAM:价格昂贵,容量小,一般作为系统的内置内存,程序可在上面直接运行。

(4)从NAND Flash启动的架构:

image-20221126193224237

  • 启动流程为
    • 上电后,CPU内置程序会从NAND Flash的特定地址(一般是第一个block块地址)读出Boot-Loader程序到CPU的内部内存中。
    • CPU将控制权交给内部存储器中的Boot-Loader;
    • Boot-Loader初始化SDRAM,再从NAND Flash中将主程序载入到SDRAM中;
    • Boot-Loader将控制权交给主程序。

4. 串口设置常见问题

1. 串口终端显示乱码或根本没有显示

  • 造成这个问题主要有两种原因:
    1. boot loader 对串口的初始化设置不正确。
    2. 运行在 host 端的终端仿真程序对串口的设置不正确,这包括:波特率、奇偶校
      验、数据位和停止位等方面的设置。

2. boot loader 运行过程中可以向串口输出信息,但启动内核后无法看到启动信息

  • 排查步骤
    1. 确认内核在编译时配置了对串口终端的支持,并配置了正确的串口驱动程序。
    2. 确认 boot loader 对串口的初始化设置和内核对串口的初始化设置一致。
    3. 确认boot loader对 CPU 时钟频率的设置 和内核对 CPU 时钟频率的设置一致。
    4. 确认 boot loader 所用的内核基地址和内核映像在编译时所用的运行基地址一致。
  • 10
    点赞
  • 111
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Leon_George

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值