30、实战项目:从零开始制作一个简单的Bootloader,支持Fastboot命令

说实话,讲到这里,终于到了最让人兴奋的部分。前面我们聊了那么多Fastboot协议、分区表、USB通信,都是纸上谈兵。今天咱们就真刀真枪干一场——从零写一个能跑起来的Bootloader,并且让它听懂Fastboot命令。

我记得自己第一次写Bootloader时,心里其实挺没底的。那时候网上资料少,全靠啃芯片手册和Linux源码。现在条件好多了,但核心思路没变。说白了,Bootloader就干三件事:初始化硬件、加载镜像、跳转执行。Fastboot只是给它加了个“遥控器”。

项目目标与范围

咱们这个项目不搞花架子,目标很明确:

  • 在QEMU模拟的ARM平台上运行
  • 支持串口输出日志
  • 实现基础的Fastboot命令:getvardownloadflashboot
  • 能通过USB与PC端fastboot工具通信

你可能会问:“为什么选QEMU?” 嗯,原因很简单——不用烧录,不用买开发板,一台电脑就能搞定。我在实际项目中踩过太多硬件坑了,调试Bootloader时动不动就变砖,那叫一个痛苦。用模拟器至少能保证软件逻辑是对的。

整体架构设计

先看看咱们这个Bootloader的模块划分。我画了张图,方便你理解整体脉络:

简易Bootloader架构图 主入口 (start.S) 硬件初始化 (uart_init, timer_init) 主循环 (main_loop) Fastboot命令处理 本地镜像加载与跳转 getvar / download / flash / boot 从Flash读取内核 → 跳转执行

整个流程其实很直白:上电后从汇编入口开始,初始化串口和定时器,然后进入主循环。主循环里就两个分支——要么检测到USB有Fastboot请求,走命令处理;要么超时后直接加载本地镜像启动。

第一步:汇编启动代码

任何Bootloader的第一步都是汇编。为什么?因为C语言需要栈,而栈指针在复位时还没设置。来看咱们的 start.S

.section .text.start
.globl _start

_start:
    /* 设置栈指针,指向SRAM顶部 */
    ldr sp, =_stack_top

    /* 关闭中断,避免干扰 */
    cpsid if

    /* 清零BSS段 */
    ldr r0, =_bss_start
    ldr r1, =_bss_end
    mov r2, #0
1:
    cmp r0, r1
    itt lo
    strlo r2, [r0], #4
    blo 1b

    /* 跳转到C语言主函数 */
    bl main

    /* 如果main返回,死循环 */
halt:
    wfi
    b halt

这段代码我几乎每个项目都会复用。注意那个 cpsid if,关中断是必须的——在栈和中断向量表没准备好之前,任何中断都会导致不可预知的行为。我曾经在一个项目里忘了关中断,结果芯片上电后随机跳转到奇怪的地方,查了整整两天才找到原因。

第二步:串口驱动与日志输出

调试Bootloader,没有串口日志就像蒙眼走路。咱们实现一个极简的UART驱动:

// uart.c
#define UART_BASE 0x10009000  // QEMU virt平台的UART地址

void uart_init(void) {
    volatile unsigned int *reg = (void *)UART_BASE;
    reg[1] = 0x00;  // 关中断
    reg[2] = 0xC7;  // 设置帧格式
    reg[3] = 0x0E;  // 使能发送和接收
}

void uart_putc(char c) {
    volatile unsigned int *reg = (void *)UART_BASE;
    while (reg[5] & (1 << 6));  // 等待发送FIFO空
    reg[0] = c;
}

void uart_puts(const char *s) {
    while (*s) {
        if (*s == '\n') uart_putc('\r');
        uart_putc(*s++);
    }
}

这里有个小技巧:uart_puts里遇到换行符自动加回车。很多终端模拟器只认\r\n,不加回车的话输出会乱掉。我刚开始做嵌入式时就被这个坑过,打印出来的日志像爬楼梯一样斜着走。

第三步:USB设备枚举与Fastboot协议

这是最核心的部分。Fastboot协议本质上就是USB Bulk传输,咱们需要让QEMU识别为一个USB设备。在QEMU virt平台上,我们可以用chipidea USB控制器。

先定义USB描述符:

// usb_descriptor.c
struct usb_device_descriptor dev_desc = {
    .bLength = 18,
    .bDescriptorType = 1,  // 设备描述符
    .bcdUSB = 0x0200,      // USB 2.0
    .bDeviceClass = 0xFF,  // 厂商自定义
    .idVendor = 0x18D1,    // Google
    .idProduct = 0xD001,   // Fastboot
    .iManufacturer = 1,
    .iProduct = 2,
    .bNumConfigurations = 1,
};

为什么用Google的VID和PID?因为PC端的fastboot工具默认只识别这些ID。当然你也可以用自己定义的,但那样每次都得加-i 0xXXXX参数,挺麻烦的。

Fastboot命令处理的核心是个状态机:

// fastboot.c
void fastboot_process_cmd(const char *cmd) {
    if (strncmp(cmd, "getvar:", 7) == 0) {
        const char *var = cmd + 7;
        if (strcmp(var, "version") == 0) {
            fastboot_okay("0.5");
        } else if (strcmp(var, "product") == 0) {
            fastboot_okay("qemu_arm");
        } else {
            fastboot_fail("unknown var");
        }
    } else if (strncmp(cmd, "download:", 9) == 0) {
        unsigned size = simple_strtoul(cmd + 9, NULL, 16);
        fastboot_download(size);
    } else if (strcmp(cmd, "flash:boot") == 0) {
        fastboot_flash_boot();
    } else if (strcmp(cmd, "boot") == 0) {
        fastboot_boot();
    } else {
        fastboot_fail("unknown command");
    }
}

这里有个细节:download命令后面跟的是十六进制的大小值。PC端会先发这个命令,然后紧接着发数据包。咱们需要准备好接收缓冲区,并且每收完一个包就回复DATA<size>

核心要点:Fastboot的通信模式是“一问一答”。PC发命令,设备回复OKAYFAIL。下载数据时,设备回复DATA表示准备好接收。千万不要搞错顺序,否则PC端会一直等待超时。

第四步:镜像加载与跳转执行

当收到boot命令时,咱们需要把之前下载到内存的镜像直接执行。这里的关键是找到正确的入口地址:

// boot.c
void boot_linux(void *kernel_addr) {
    // 对于ARM Linux,通常入口在镜像起始偏移0x8000处
    void (*kernel_entry)(int, int, void *) = kernel_addr + 0x8000;

    uart_puts("Booting kernel...\n");

    // 关闭中断,禁用MMU和缓存
    asm volatile(
        "cpsid if\n"
        "mrc p15, 0, r0, c1, c0, 0\n"
        "bic r0, r0, #0x1000\n"   // 清除I位
        "bic r0, r0, #0x0005\n"   // 清除M、C位
        "mcr p15, 0, r0, c1, c0, 0\n"
        "isb\n"
        ::: "r0"
    );

    // 传递参数:机器类型、ATAG列表地址
    kernel_entry(0xFFFFFFFF, 0x100, NULL);
}

注意:跳转到内核之前,必须关闭MMU和缓存。因为内核启动时会重新建立页表,如果缓存里有脏数据,会导致不可预知的崩溃。我见过有人忘了关MMU,结果内核启动到一半就卡死,串口最后一条日志是“Uncompressing Linux...”,然后就没然后了。

第五步:链接脚本与构建系统

最后,咱们需要一个链接脚本,把各个段放到正确的位置:

// bootloader.ld
SECTIONS
{
    . = 0x40000000;  /* QEMU virt平台RAM起始地址 */

    .text : {
        *(.text.start)
        *(.text*)
    }

    .rodata : {
        *(.rodata*)
    }

    .data : {
        *(.data*)
    }

    .bss : {
        _bss_start = .;
        *(.bss*)
        _bss_end = .;
    }

    _stack_top = 0x40080000;  /* 栈顶在RAM顶部 */
}

构建命令也很简单:

arm-none-eabi-gcc -c -O2 start.S -o start.o
arm-none-eabi-gcc -c -O2 uart.c -o uart.o
arm-none-eabi-gcc -c -O2 fastboot.c -o fastboot.o
arm-none-eabi-gcc -c -O2 boot.c -o boot.o
arm-none-eabi-ld -T bootloader.ld start.o uart.o fastboot.o boot.o -o bootloader.elf
arm-none-eabi-objcopy -O binary bootloader.elf bootloader.bin

运行与测试

启动QEMU并加载咱们的Bootloader:

qemu-system-arm -M virt -kernel bootloader.bin -nographic -device usb-ehci

然后在PC端执行:

fastboot getvar version
fastboot getvar product
fastboot flash boot my_kernel.bin
fastboot boot

如果一切顺利,你会看到串口输出类似这样的日志:

Bootloader v1.0 starting...
UART initialized.
USB initialized, waiting for fastboot...
Received command: getvar:version
Sending OKAY: 0.5
Received command: download:00400000
Downloading 4194304 bytes...
Download complete.
Received command: boot
Booting kernel...
Uncompressing Linux... done, booting the kernel.

调试小技巧:如果fastboot工具提示“”,多半是USB枚举没成功。检查一下QEMU是否加载了USB控制器模块,或者试试用lsusb看有没有Google的设备。我一般会在USB初始化成功后串口打印一条“USB configured”消息,这样一眼就能看出问题出在哪一步。

踩坑总结

做这个项目时,我遇到过几个典型问题,列出来给你参考:

  • USB描述符长度不对——Fastboot工具对描述符校验很严格,长度多一个字节或少一个字节都会导致枚举失败。我建议用USB分析仪抓包对比。
  • BSS段没清零——全局变量默认值是0,但如果BSS段没清零,那些变量就是随机值。我遇到过因为一个全局标志位没清零,导致Fastboot状态机永远卡在“等待命令”状态。
  • 栈溢出——Bootloader的栈通常只有几KB,如果函数调用层级太深或者局部变量太大,很容易栈溢出。我习惯在栈底放一个哨兵值,每次主循环检查一下。

好了,这个实战项目到这里就结束了。代码量不大,但涵盖了Bootloader的核心流程。你可以在GitHub上找到完整源码(搜索“simple-fastboot-bootloader”),建议自己动手编译运行一遍。遇到问题别怕,调试Bootloader本身就是最好的学习过程。