4、Bootloader移植(下):实现板级初始化代码与调试串口驱动

上一讲我们把 Bootloader 的启动流程捋清楚了,也把链接脚本和汇编入口搞定了。这一讲咱们直接动手,把板级初始化代码写出来,再把调试串口跑通。说白了,就是让 Bootloader 真正能在你的板子上「活过来」。

我个人习惯把板级初始化分成两个阶段:board_init_f 和 board_init_r。为什么拆成两个?因为早期初始化时 DDR 还没起来,你只能在 SRAM 或 ROM 里跑;等 DDR 初始化完了,才能把代码 relocate 到 DDR 里,做更复杂的初始化。这个思路在 U-Boot 里沿用多年,咱们自己移植时也这么干。

核心思路:board_init_f 负责「活下来」,board_init_r 负责「活得好」。

板级初始化两阶段流程 _start 入口 board_init_f 阶段 时钟 → 看门狗 → 串口 → DDR 控制器 DDR 初始化完成 board_init_r 阶段 Relocate → 外设 → 环境变量 → 启动内核 board_init_f 在 SRAM/ROM 中运行,board_init_r 在 DDR 中运行

4.1 board_init_f:让板子先「喘口气」

board_init_f 要做的事情很明确:把最基本的硬件初始化好,让 CPU 能跑、串口能打印、DDR 能工作。我一般按这个顺序来:

  1. 关闭看门狗——很多板子默认看门狗是开的,不关掉的话,几秒后就会复位,你啥也干不了。
  2. 初始化时钟——至少让 CPU 主频和总线时钟跑起来,不然串口波特率算不准。
  3. 初始化串口——调试的生命线,后面所有打印都靠它。
  4. 初始化 DDR 控制器——这是重头戏,参数配错直接死机。

代码结构大概长这样:

void board_init_f(void)
{
    /* 1. 关看门狗 */
    watchdog_disable();

    /* 2. 初始化系统时钟 */
    system_clock_init();

    /* 3. 初始化调试串口 */
    debug_uart_init();

    /* 4. 打印一条信息,证明串口活了 */
    debug_uart_puts("Bootloader: board_init_f done\n");

    /* 5. 初始化 DDR */
    ddr_init();

    /* 6. 返回,准备 relocate */
}

我的习惯:每做完一步初始化,就在串口打印一个字符或短消息。比如初始化时钟后打印 'C',初始化 DDR 后打印 'D'。这样如果板子卡住了,看最后打印到哪个字符,就知道哪步出了问题。这招帮我省了无数调试时间。

4.2 board_init_r:把家搬到 DDR 里

DDR 初始化完成后,Bootloader 会把自身代码从 SRAM 复制到 DDR 里,然后跳转到 DDR 中继续执行。这就是 board_init_r 的舞台。

board_init_r 做的事情更丰富:

  • 初始化更多外设(比如网卡、SD 卡控制器)
  • 设置环境变量存储区域
  • 准备启动参数
  • 最终加载并启动内核
void board_init_r(void)
{
    /* 初始化外设 */
    eth_init();
    mmc_init();

    /* 设置环境变量 */
    env_init();

    /* 打印启动信息 */
    printf("Board: MyBoard Rev 1.0\n");
    printf("DRAM:  %d MB\n", get_ram_size() / (1024 * 1024));

    /* 进入主循环,等待命令或自动启动 */
    main_loop();
}

注意:board_init_r 执行时,代码已经在 DDR 里了。如果你在 board_init_f 阶段用了绝对地址访问全局变量,relocate 后这些地址就失效了。我建议 board_init_f 里尽量少用全局变量,或者用位置无关的访问方式。

4.3 调试串口驱动移植:NS16550 实战

调试串口是嵌入式开发的「眼睛」。没有它,你连板子死在哪都不知道。NS16550 是最常见的 UART IP,几乎所有的 ARM SoC 都在用它的变体。

移植 NS16550 驱动,核心就是配好这几个寄存器:

寄存器 偏移地址 作用
RBR/THR 0x00 接收缓冲/发送保持
IER 0x01 中断使能
LCR 0x03 线路控制(数据位、停止位、校验)
LSR 0x05 线路状态(是否可发送/接收)
DLL/DLH 0x00/0x01 波特率分频(需先设置 LCR 的 DLAB 位)

我写串口驱动时,一般先实现三个函数:初始化、发送一个字符、接收一个字符。够用就行,别一开始就搞什么 FIFO 中断,那都是后面优化的事。

/* 串口基地址,根据你的板子修改 */
#define UART_BASE       0x10000000

/* 寄存器偏移 */
#define UART_RBR        (UART_BASE + 0x00)
#define UART_THR        (UART_BASE + 0x00)
#define UART_IER        (UART_BASE + 0x04)
#define UART_LCR        (UART_BASE + 0x0C)
#define UART_LSR        (UART_BASE + 0x14)

/* 初始化串口:115200, 8N1 */
void debug_uart_init(void)
{
    unsigned int divisor;

    /* 设置波特率:先设置 DLAB 位 */
    divisor = 50000000 / (16 * 115200);  /* 假设时钟 50MHz */
    writeb(0x83, UART_LCR);              /* 8位数据,1位停止,无校验,DLAB=1 */
    writeb(divisor & 0xFF, UART_BASE + 0x00);  /* DLL */
    writeb((divisor >> 8) & 0xFF, UART_BASE + 0x04); /* DLH */
    writeb(0x03, UART_LCR);              /* 清除 DLAB,恢复 8N1 */
}

/* 发送一个字符 */
void debug_uart_putc(char c)
{
    /* 等待发送缓冲区空 */
    while (!(readb(UART_LSR) & 0x20));
    writeb(c, UART_THR);

    /* 如果是换行,自动补回车 */
    if (c == '\n')
        debug_uart_putc('\r');
}

/* 接收一个字符 */
char debug_uart_getc(void)
{
    /* 等待数据到达 */
    while (!(readb(UART_LSR) & 0x01));
    return readb(UART_RBR);
}

避坑指南:波特率计算时,别忘了除数是 16 倍波特率。我曾经在一个项目里把公式写成了 时钟 / 波特率,结果串口输出全是乱码。查了两天才发现是除数公式错了。你想想看,乱码问题十有八九是波特率不匹配,或者时钟频率算错了。

4.4 编译与烧录验证

代码写完了,怎么验证?我一般分三步走:

  1. 编译:确保没有语法错误和链接错误。
  2. 烧录:用 JTAG 或 SD 卡把镜像烧到板子上。
  3. 验证:上电看串口输出。

编译命令示例(以 ARM GCC 为例):

arm-none-eabi-gcc -c -g -O2 -mcpu=cortex-a7 \
    -ffreestanding -nostdlib \
    start.S -o start.o

arm-none-eabi-gcc -c -g -O2 -mcpu=cortex-a7 \
    -ffreestanding -nostdlib \
    board.c -o board.o

arm-none-eabi-ld -T board.lds \
    start.o board.o -o bootloader.elf

arm-none-eabi-objcopy -O binary \
    bootloader.elf bootloader.bin

烧录方式取决于你的板子。如果是用 JTAG,我习惯用 OpenOCD + GDB:

openocd -f board/myboard.cfg &
arm-none-eabi-gdb bootloader.elf
(gdb) target remote :3333
(gdb) load
(gdb) continue

如果一切顺利,串口终端上应该看到:

Bootloader: board_init_f done
Board: MyBoard Rev 1.0
DRAM:  512 MB

我的经验:第一次上电时,别指望一次成功。我通常会先确认串口硬件连接是否正确——用示波器量 TX 引脚有没有波形。如果 TX 脚一直没动静,那问题不在软件,在硬件或者时钟配置上。

4.5 常见问题与排查思路

移植过程中,我遇到过不少坑。这里列几个典型的:

现象 可能原因 排查方法
串口无输出 时钟未初始化 / 波特率不对 / GPIO 复用未配 检查时钟树、量 TX 波形、核对 GPIO 功能选择
输出乱码 波特率不匹配 / 时钟频率算错 重新计算分频系数,确认串口终端波特率一致
DDR 初始化后死机 时序参数不对 / 电压不匹配 对照 DDR 数据手册检查 timing 参数,用示波器量 DQS 信号
Relocate 后跑飞 链接脚本地址不对 / 全局变量未重定位 检查 .lds 文件,确认 relocate 前后地址映射正确

嗯,这里要注意:排查问题时,一次只改一个变量。别同时改时钟和串口参数,否则你永远不知道是哪个改好的。

我个人习惯在 board_init_f 里加一个 LED 闪烁函数。如果串口没起来,至少 LED 能告诉我代码执行到哪一步了。这招在调试初期特别管用。

好了,这一讲的内容就这些。把 board_init_f 和 board_init_r 写对,把 NS16550 串口驱动跑通,你的 Bootloader 就算有了「生命体征」。后面的事情——加载内核、传参数、启动系统——都是在这个基础上搭积木。