4. 外设驱动移植方法论
UART,说白了就是串口。做MCU开发的人,没有谁离得开它。我个人习惯,拿到一块新板子,第一件事就是把串口调通。为什么?因为它是你与芯片对话的「嘴巴」和「耳朵」。调试信息、日志输出、甚至固件升级,都靠它。
这一章,我们不讲那些虚的理论。直接上手,从HAL库接口分析开始,一步步把printf/scanf重定向搞定,再深入中断接收与DMA发送,最后用环形缓冲区实现一个能与Bootloader交互的实战案例。嗯,都是我在项目里踩过坑、填过土的东西。
4.1 UART的HAL库接口分析
STM32的HAL库,把UART操作封装成了几个核心函数。你想想看,其实底层无非就是配置、发送、接收、中断这几个动作。HAL库把它们包装得挺规整,但有些细节,文档里可不会告诉你。
| 函数 | 用途 | 阻塞/非阻塞 | 我踩过的坑 |
|---|---|---|---|
HAL_UART_Init() |
初始化UART参数 | 阻塞 | 忘记配置GPIO复用,串口死活没输出 |
HAL_UART_Transmit() |
阻塞发送 | 阻塞 | 大数据量时卡死主循环 |
HAL_UART_Receive() |
阻塞接收 | 阻塞 | 超时设置不合理,程序挂起 |
HAL_UART_Transmit_IT() |
中断发送 | 非阻塞 | 中断优先级没配好,丢数据 |
HAL_UART_Receive_IT() |
中断接收 | 非阻塞 | 单字节中断,频繁进中断开销大 |
HAL_UART_Transmit_DMA() |
DMA发送 | 非阻塞 | DMA与CPU争抢总线,缓存未对齐 |
4.2 串口重定向:printf/scanf的魔法
嵌入式里用printf,其实是个「骗术」。标准库的printf最终会调用一个底层字符输出函数——在ARM GCC里是_write(),在IAR里是__putchar()。我们只要把这个函数重写,让它调用UART发送,就成了。
我曾经在一个项目里,因为没重定向_write(),printf直接往屏幕上打,MCU当然没有屏幕,结果程序跑飞了。嗯,从那以后我每次建工程第一件事就是检查这个。
// GCC环境下的printf重定向
int _write(int file, char *ptr, int len)
{
HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY);
return len;
}
// scanf重定向
int _read(int file, char *ptr, int len)
{
HAL_UART_Receive(&huart1, (uint8_t *)ptr, 1, HAL_MAX_DELAY);
return 1;
}
setbuf(stdin, NULL)关闭缓冲。
4.3 中断接收与DMA发送
阻塞模式在裸机开发里还能凑合用,一旦上了RTOS或者需要处理多任务,就必须上中断和DMA了。我个人的经验是:接收用中断,发送用DMA。
为什么?接收是事件驱动的,你不知道数据什么时候来,中断最合适。发送是你主动的,用DMA可以解放CPU,让它去干别的活。
4.3.1 中断接收的配置要点
- 使能UART接收中断:
HAL_UART_Receive_IT(&huart1, buffer, 1),最后一个参数是接收长度,设为1表示每收到一个字节就触发一次中断。 - 在中断回调函数
HAL_UART_RxCpltCallback()里处理数据。 - 记得每次回调后重新调用
HAL_UART_Receive_IT(),否则只触发一次。
4.3.2 DMA发送的实战配置
DMA发送,说白了就是告诉DMA控制器:「帮我把这段内存里的数据搬到UART的发送寄存器,搬完了告诉我一声。」
// DMA发送配置示例
#define TX_BUF_SIZE 256
uint8_t tx_buffer[TX_BUF_SIZE];
// 启动DMA发送
HAL_UART_Transmit_DMA(&huart1, tx_buffer, len);
// 发送完成回调
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
// 可以在这里释放信号量或置标志位
// 告诉主循环:数据发完了,可以发下一包了
}
4.4 实战:环形缓冲区实现与Bootloader交互
好了,前面都是热身。现在我们来点真格的——用环形缓冲区实现一个可靠的UART接收模块,然后与Bootloader交互,完成固件升级。
环形缓冲区,说白了就是一个固定大小的数组,加上两个指针:写指针和读指针。数据写进去,读出来,循环使用。不会像链表那样动态分配内存,也不会像简单数组那样溢出。
4.4.1 环形缓冲区实现代码
#define RING_BUF_SIZE 256
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} ring_buffer_t;
ring_buffer_t rx_ring;
// 初始化
void ring_buffer_init(ring_buffer_t *rb)
{
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节
int ring_buffer_put(ring_buffer_t *rb, uint8_t data)
{
uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
if (next == rb->tail) {
return -1; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
// 读取一个字节
int ring_buffer_get(ring_buffer_t *rb, uint8_t *data)
{
if (rb->head == rb->tail) {
return -1; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
return 0;
}
4.4.2 与Bootloader的交互协议
Bootloader交互,说白了就是一套「握手-命令-响应」的流程。我参与过一个产品,Bootloader和APP之间就是通过UART+环形缓冲区通信的。协议很简单,但很可靠。
| 命令 | 命令码 | 数据格式 | 响应 |
|---|---|---|---|
| 握手 | 0xAA | 无 | 0x55 |
| 擦除扇区 | 0xBB | 扇区号(1字节) | 0xCC(成功)/0xEE(失败) |
| 写入数据 | 0xCC | 地址(4字节)+长度(2字节)+数据 | 0xDD(成功)/0xEE(失败) |
| 校验 | 0xDD | CRC32(4字节) | 0xFF(通过)/0xEE(失败) |
| 跳转 | 0xEE | 无 | 无(直接跳转) |
4.4.3 中断回调与主循环的配合
// 中断回调:只负责把数据放入环形缓冲区
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
ring_buffer_put(&rx_ring, rx_byte);
HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 重新开启中断
}
}
// 主循环:解析命令并响应
void main_loop(void)
{
uint8_t cmd;
while (1) {
if (ring_buffer_get(&rx_ring, &cmd) == 0) {
// 解析命令
switch (cmd) {
case 0xAA: // 握手
send_response(0x55);
break;
case 0xBB: // 擦除
// 读取后续数据...
break;
// 其他命令处理
}
}
// 其他任务...
}
}
index & (SIZE-1),比取模快得多。
好了,这一章的内容就到这里。UART驱动移植看似简单,但真正要做好,细节不少。从HAL库接口的选择,到printf重定向的陷阱,再到中断与DMA的配合,最后用环形缓冲区实现一个可靠的通信模块——每一步都有讲究。你把这些吃透了,以后遇到任何串口相关的需求,心里都有底。