3、Bootloader与硬件交互:GPIO操作、UART调试、SPI/I2C总线初始化、NAND/NOR Flash驱动

这一章,我们聊聊Bootloader和硬件打交道的那些事。说白了,Bootloader就是个硬件初始化的小管家。你想想看,芯片上电时,外设都处于默认状态,你得一个个把它们唤醒、配置好,才能让后续的代码跑起来。

我个人习惯把硬件交互分成四块:GPIO、UART、SPI/I2C、以及Flash驱动。这四块搞定了,Bootloader的硬件基础就算打牢了。咱们一个一个来看。

3.1 GPIO操作:点亮第一盏灯

GPIO是硬件交互的起点。我记得刚入行那会儿,调试一个新板子,第一件事就是点灯。灯亮了,说明代码在跑,时钟对了,GPIO配置也没问题。

GPIO操作其实就三件事:配置方向、设置电平、读取电平。但在Bootloader里,你得注意一个细节——复用功能。很多引脚默认是GPIO,但你可能需要把它配成UART或SPI功能。

核心要点:GPIO初始化顺序很重要。先开时钟,再配模式,最后设电平。顺序错了,硬件可能不认账。

来看一个实际的例子。我在项目中遇到过一块芯片,GPIO默认是输入模式,但内部上拉没使能。结果读到的电平一直飘,折腾了半天才发现是上拉电阻没开。

/* GPIO初始化示例:点亮LED */
void gpio_led_init(void)
{
    /* 1. 使能GPIO时钟 */
    RCC->AHB1ENR |= (1 << 3);  /* GPIOD时钟使能 */

    /* 2. 配置为输出模式 */
    GPIOD->MODER |= (1 << 26);  /* PD13设为输出 */
    GPIOD->MODER &= ~(1 << 27);

    /* 3. 设置推挽输出,不要开漏 */
    GPIOD->OTYPER &= ~(1 << 13);

    /* 4. 设置速度,一般用中速就够了 */
    GPIOD->OSPEEDR |= (1 << 26);

    /* 5. 默认输出低电平,灯灭 */
    GPIOD->ODR &= ~(1 << 13);
}

void led_on(void)
{
    GPIOD->ODR |= (1 << 13);  /* 高电平点亮 */
}

void led_off(void)
{
    GPIOD->ODR &= ~(1 << 13); /* 低电平熄灭 */
}

小技巧:调试时,我习惯用GPIO翻转来测量代码执行时间。在关键函数前后各放一个GPIO置位和清零,用示波器一看,延迟一目了然。

3.2 UART调试:Bootloader的“眼睛”

UART是Bootloader最重要的调试手段。没有显示屏,没有网络,你靠什么知道代码跑到哪了?靠UART打印。

UART初始化其实不复杂,但有几个坑。我建议你重点关注波特率误差中断配置

为什么会这样?因为Bootloader阶段,系统时钟可能还没稳定,你用默认时钟算出来的波特率,误差可能超过3%。一旦超过这个数,通信就会丢字节。

/* UART初始化示例:115200-8N1 */
void uart_init(void)
{
    uint32_t baud_div;

    /* 1. 使能UART和GPIO时钟 */
    RCC->APB1ENR |= (1 << 17);  /* USART2时钟 */
    RCC->AHB1ENR |= (1 << 0);   /* GPIOA时钟 */

    /* 2. 配置TX/RX引脚 */
    GPIOA->MODER |= (2 << 4);   /* PA2: AF模式 */
    GPIOA->MODER |= (2 << 6);   /* PA3: AF模式 */
    GPIOA->AFR[0] |= (7 << 8);  /* PA2: AF7 */
    GPIOA->AFR[0] |= (7 << 12); /* PA3: AF7 */

    /* 3. 计算波特率:假设时钟84MHz */
    baud_div = 84000000 / (16 * 115200);
    USART2->BRR = baud_div;

    /* 4. 使能发送和接收 */
    USART2->CR1 |= (1 << 3);   /* TE使能 */
    USART2->CR1 |= (1 << 2);   /* RE使能 */
    USART2->CR1 |= (1 << 13);  /* UE使能 */
}

void uart_send_char(char c)
{
    while (!(USART2->SR & (1 << 7))); /* 等待发送完成 */
    USART2->DR = c;
}

注意:UART初始化时,一定要先配GPIO,再配UART外设。我曾经反过来做,结果TX引脚一直输出高电平,查了半天才发现是GPIO模式没设对。

3.3 SPI/I2C总线初始化:与外设对话

Bootloader里用SPI和I2C,主要是为了访问一些外设,比如传感器、EEPROM、或者LCD屏。这两种总线,说白了就是主从通信,Bootloader永远是主机。

我个人觉得,SPI比I2C好调。为什么?因为SPI是同步的,时钟由主机控制,时序问题少。I2C有开漏输出和上拉电阻,硬件上容易出问题。

特性 SPI I2C
信号线数量 4根(MOSI, MISO, SCLK, CS) 2根(SDA, SCL)
通信方式 全双工 半双工
速度 可达几十MHz 标准100kHz/400kHz
硬件复杂度 中(需要上拉电阻)
调试难度

SPI初始化时,有个参数特别容易搞错——时钟极性(CPOL)和时钟相位(CPHA)。不同的从设备,要求的模式不一样。我建议你写一个通用的SPI配置函数,把模式作为参数传进去。

/* SPI初始化示例:模式0,CPOL=0,CPHA=0 */
void spi_init(void)
{
    /* 1. 使能时钟 */
    RCC->APB2ENR |= (1 << 12);  /* SPI1时钟 */
    RCC->AHB1ENR |= (1 << 0);   /* GPIOA时钟 */

    /* 2. 配置引脚 */
    GPIOA->MODER |= (2 << 10);  /* PA5: SCK, AF */
    GPIOA->MODER |= (2 << 12);  /* PA6: MISO, AF */
    GPIOA->MODER |= (2 << 14);  /* PA7: MOSI, AF */
    GPIOA->AFR[0] |= (5 << 20); /* PA5: AF5 */
    GPIOA->AFR[0] |= (5 << 24); /* PA6: AF5 */
    GPIOA->AFR[0] |= (5 << 28); /* PA7: AF5 */

    /* 3. 配置SPI */
    SPI1->CR1 = (1 << 2) |      /* 主模式 */
                (0 << 1) |      /* CPOL=0 */
                (0 << 0);       /* CPHA=0 */
    SPI1->CR1 |= (1 << 6);     /* SPI使能 */
}

uint8_t spi_transfer(uint8_t data)
{
    while (!(SPI1->SR & (1 << 1))); /* 等待发送缓冲区空 */
    SPI1->DR = data;
    while (!(SPI1->SR & (1 << 0))); /* 等待接收完成 */
    return SPI1->DR;
}

经验之谈:调试I2C时,如果总线一直卡在忙状态,十有八九是SDA或SCL被拉低了。用示波器看一下波形,比看代码管用得多。

3.4 NAND/NOR Flash驱动:Bootloader的“粮仓”

Flash驱动是Bootloader的重头戏。Bootloader本身存在Flash里,它还要从Flash里读取内核镜像。所以,Flash驱动的好坏,直接决定了系统能不能启动。

NOR Flash和NAND Flash,虽然都叫Flash,但脾气完全不同。

  • NOR Flash:支持XIP(片上执行),读速度快,写速度慢。适合存代码。
  • NAND Flash:密度大,成本低,但读写都需要经过页缓冲,而且有坏块问题。适合存数据。

我记得有一次,项目里用了NAND Flash,Bootloader启动时老是读到坏数据。查了半天,发现是ECC校验没开。NAND Flash的位翻转是常态,没有ECC,数据分分钟出错。

/* NOR Flash读操作示例:直接地址访问 */
uint32_t nor_read_word(uint32_t addr)
{
    return *(volatile uint32_t *)addr;
}

/* NOR Flash写操作示例:需要解锁和编程命令 */
void nor_write_word(uint32_t addr, uint32_t data)
{
    /* 1. 解锁 */
    *(volatile uint16_t *)0x5555 = 0xAA;
    *(volatile uint16_t *)0x2AAA = 0x55;
    *(volatile uint16_t *)0x5555 = 0xA0;

    /* 2. 写入数据 */
    *(volatile uint16_t *)addr = (uint16_t)data;

    /* 3. 等待编程完成 */
    while (!(*(volatile uint16_t *)addr & 0x80));
}

/* NAND Flash读页示例:需要发送命令和地址 */
void nand_read_page(uint32_t page_addr, uint8_t *buffer)
{
    int i;

    /* 1. 发送读命令 */
    NAND_CMD_REG = 0x00;

    /* 2. 发送地址(5字节:列地址+行地址) */
    NAND_ADDR_REG = 0x00;           /* 列地址低8位 */
    NAND_ADDR_REG = 0x00;           /* 列地址高8位 */
    NAND_ADDR_REG = page_addr & 0xFF;       /* 行地址低8位 */
    NAND_ADDR_REG = (page_addr >> 8) & 0xFF; /* 行地址中8位 */
    NAND_ADDR_REG = (page_addr >> 16) & 0xFF; /* 行地址高8位 */

    /* 3. 发送确认命令 */
    NAND_CMD_REG = 0x30;

    /* 4. 等待R/B引脚变高 */
    while (!(NAND_STATUS_REG & 0x40));

    /* 5. 读取页数据(假设页大小2048字节) */
    for (i = 0; i < 2048; i++) {
        buffer[i] = NAND_DATA_REG;
    }
}

避坑指南:我曾经在NOR Flash擦除操作时,忘了检查状态寄存器。结果擦除还没完成,我就去读数据了,读回来的全是乱码。记住:擦除和编程操作,一定要等状态寄存器指示完成。

3.5 小结

这一章的内容,说白了就是Bootloader和硬件打交道的四个基本功。GPIO是基础,UART是调试手段,SPI/I2C是扩展接口,Flash驱动是核心存储。

我个人觉得,学这些最好的方法就是动手。找一块开发板,从点灯开始,一步步把UART调通,再试试读写Flash。遇到问题别慌,用示波器看波形,用逻辑分析仪抓时序,问题总能找到。

下一章,我们会聊聊Bootloader的内存布局和链接脚本。嗯,那也是个容易踩坑的地方。