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的内存布局和链接脚本。嗯,那也是个容易踩坑的地方。