4、Bootloader移植(下):实现板级初始化代码与调试串口驱动
上一讲我们把 Bootloader 的启动流程捋清楚了,也把链接脚本和汇编入口搞定了。这一讲咱们直接动手,把板级初始化代码写出来,再把调试串口跑通。说白了,就是让 Bootloader 真正能在你的板子上「活过来」。
我个人习惯把板级初始化分成两个阶段:board_init_f 和 board_init_r。为什么拆成两个?因为早期初始化时 DDR 还没起来,你只能在 SRAM 或 ROM 里跑;等 DDR 初始化完了,才能把代码 relocate 到 DDR 里,做更复杂的初始化。这个思路在 U-Boot 里沿用多年,咱们自己移植时也这么干。
核心思路:board_init_f 负责「活下来」,board_init_r 负责「活得好」。
4.1 board_init_f:让板子先「喘口气」
board_init_f 要做的事情很明确:把最基本的硬件初始化好,让 CPU 能跑、串口能打印、DDR 能工作。我一般按这个顺序来:
- 关闭看门狗——很多板子默认看门狗是开的,不关掉的话,几秒后就会复位,你啥也干不了。
- 初始化时钟——至少让 CPU 主频和总线时钟跑起来,不然串口波特率算不准。
- 初始化串口——调试的生命线,后面所有打印都靠它。
- 初始化 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 编译与烧录验证
代码写完了,怎么验证?我一般分三步走:
- 编译:确保没有语法错误和链接错误。
- 烧录:用 JTAG 或 SD 卡把镜像烧到板子上。
- 验证:上电看串口输出。
编译命令示例(以 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 就算有了「生命体征」。后面的事情——加载内核、传参数、启动系统——都是在这个基础上搭积木。