3. BSP核心概念

各位同学,今天我们来聊聊嵌入式系统里最底层、也最容易被忽视的一环——芯片上电后到底发生了什么。

我记得刚入行那会儿,总觉得代码烧进去就能跑,直到第一次调试一块新板子,上电后串口死活没输出。折腾了两天,最后发现是Bootloader的链接地址配错了。嗯,从那以后,我养成了一个习惯:拿到任何新芯片,第一件事就是看它的启动流程。

3.1 芯片上电启动流程

芯片上电,说白了就是给CPU一个“醒过来”的信号。但CPU醒来后第一件事做什么?它得知道自己从哪里取第一条指令。

大多数ARM Cortex-A系列芯片,上电后CPU会从一个固定的地址开始执行。这个地址通常是0x00000000或者0xFFFF0000,具体看芯片手册。我做过一个i.MX6的项目,它的启动地址就是0x00000000,但那里放的不是用户代码,而是芯片内部固化的ROM代码。

整个启动流程大致分三步:

  1. ROM Boot阶段:芯片内部固化的代码先执行。它会初始化最基本的硬件,比如时钟、DDR控制器。然后根据启动模式(从SD卡、NAND Flash、SPI Flash等)去读取下一级代码。
  2. SPL/Preloader阶段:这是芯片厂商提供的一个微型加载器。它负责初始化DDR,然后把完整的Bootloader从存储介质搬到DDR里。为什么要有这一步?因为Bootloader太大了,芯片内部的SRAM装不下。
  3. Bootloader阶段:U-Boot或者别的Bootloader开始运行。它负责更复杂的硬件初始化,然后加载操作系统内核。

核心要点:ROM Boot是芯片出厂就写死的,你改不了。你能改的,是从SPL开始往后的部分。

你想想看,如果芯片上电后直接去读外部Flash,但此时DDR还没初始化,代码放哪儿?所以这个分阶段启动的设计,其实是不得已而为之。

芯片上电启动流程 阶段1:ROM Boot 芯片内部固化代码 初始化时钟、DDR控制器 阶段2:SPL/Preloader 微型加载器 初始化DDR,加载Bootloader 阶段3:Bootloader U-Boot等完整Bootloader 加载操作系统内核 存储介质(SD卡 / NAND Flash / SPI Flash / eMMC) 读取SPL 读取U-Boot

3.2 Bootloader的作用

Bootloader到底在干什么?很多人以为它就是个“加载内核的工具”。其实远不止这些。

我个人习惯把Bootloader比作一个“硬件管家”。它要做的事情包括:

  • 硬件初始化:设置CPU工作模式、初始化时钟树、配置DDR时序、初始化串口等外设。这些事如果让内核来做,内核会变得非常臃肿。
  • 内存布局规划:决定内核放在DDR的哪个地址、设备树放在哪里、ramdisk放在哪里。这个布局一旦定下来,后面所有软件都得跟着走。
  • 启动参数传递:告诉内核“你的根文件系统在哪儿”、“串口波特率是多少”、“内存有多大”。这些信息通过ATAG或者设备树传给内核。
  • 提供交互界面:U-Boot的命令行可以让你在启动前修改参数、烧写固件、测试硬件。我在调试新板子时,经常先用U-Boot的md命令读内存,确认DDR工作正常。

小技巧:调试阶段,我建议在U-Boot里加一个内存测试命令。曾经有一块板子,DDR布线有问题,跑系统偶尔死机。我就是在U-Boot里反复跑内存读写测试,才定位到是地址线虚焊。

3.3 U-Boot移植基础

U-Boot是目前最主流的Bootloader,支持几乎所有嵌入式芯片。移植U-Boot,说白了就是让U-Boot认识你的板子。

我刚开始移植U-Boot时,面对几千个文件完全不知道从哪儿下手。后来总结出一套套路:

3.3.1 确定板级配置

U-Boot用board/目录来管理不同板子。每个板子目录下通常有:

  • Kconfig:板级配置选项
  • Makefile:编译规则
  • board.c:板级初始化代码
  • dts:设备树文件

移植时,最快的方法是找一个和你板子最接近的参考板,复制一份然后改。比如你的板子用i.MX6ULL,那就找board/freescale/mx6ul_14x14_evk/作为模板。

3.3.2 配置DDR参数

这是移植中最容易出问题的地方。DDR的时序参数、驱动强度、ODT配置,任何一个不对,板子就起不来。

我曾经在一个项目里,DDR参数照着参考板抄,结果板子跑起来一会儿就死机。后来用逻辑分析仪抓波形,发现是时钟相位差了90度。嗯,这种问题最头疼,因为不是完全不工作,而是偶尔出错。

/* DDR初始化代码示例(i.MX6ULL) */
static struct mx6_ddr3_cfg mem_ddr3 = {
    .mem_speed = 800,        /* 800MHz */
    .density = 4,            /* 4Gb */
    .width = 16,             /* 16位宽 */
    .banks = 8,              /* 8个bank */
    .rowaddr = 15,           /* 行地址15位 */
    .coladdr = 10,           /* 列地址10位 */
    .trcd = 13.5,            /* tRCD = 13.5ns */
    .trp = 13.5,             /* tRP = 13.5ns */
    .tras = 36,              /* tRAS = 36ns */
};

3.3.3 配置启动设备

U-Boot需要知道从哪个设备读取内核。这个配置在include/configs/目录下的头文件里。

/* 从SD卡启动的配置示例 */
#define CONFIG_SYS_MMC_ENV_DEV     0   /* 使用MMC设备0 */
#define CONFIG_SYS_MMC_ENV_PART    0   /* 分区0 */
#define CONFIG_ENV_OFFSET          0x400000  /* 环境变量偏移 */
#define CONFIG_LOADADDR            0x12000000 /* 内核加载地址 */
#define CONFIG_BOOTCOMMAND \
    "mmc dev 0; " \
    "fatload mmc 0:1 ${loadaddr} zImage; " \
    "fatload mmc 0:1 ${fdt_addr} imx6ull.dtb; " \
    "bootz ${loadaddr} - ${fdt_addr}"

注意:CONFIG_LOADADDR这个地址不能随便选。它必须落在DDR地址范围内,而且不能和U-Boot自身占用的地址重叠。我见过有人把加载地址设成了U-Boot的运行地址,结果内核一加载就把U-Boot覆盖了,板子直接死机。

3.3.4 编译与烧写

U-Boot的编译流程比较固定:

# 1. 配置
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- mx6ull_myboard_defconfig

# 2. 编译
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j4

# 3. 得到u-boot.imx(i.MX系列需要加头部)
# 4. 烧写到SD卡
sudo dd if=u-boot.imx of=/dev/sdb bs=1k seek=1 conv=fsync

这里有个坑:不同芯片厂商对Bootloader的格式要求不一样。i.MX系列需要在U-Boot前面加一个IVT头部,而TI的AM335x则要求MLO格式。所以编译出来的文件不能直接烧,得看芯片手册确认。

3.4 避坑指南

做Bootloader移植这些年,我踩过的坑不少。挑几个典型的说说:

  • 串口没输出:先检查时钟配置,再检查引脚复用。我遇到过GPIO复用没配对,串口引脚被当成了GPIO用。
  • DDR初始化失败:用芯片厂商提供的DDR工具生成参数,别自己手算。我手算过一次,算出来的参数板子根本跑不起来。
  • 启动设备识别不了:检查eFuse或者BOOT引脚的配置。有些芯片支持多种启动方式,但优先级不一样。
  • U-Boot能起来但内核起不来:八成是设备树的问题。检查设备树里的内存大小、串口地址是否和实际硬件一致。

总结一下:启动流程和Bootloader是嵌入式系统的基石。这部分搞明白了,后面做驱动开发、系统移植都会顺手很多。我建议你手头准备一块开发板,按照上面的步骤实际移植一遍U-Boot。纸上得来终觉浅,绝知此事要躬行。