4. HAL实战:UART驱动封装:轮询模式与中断模式实现,环形缓冲区设计与应用,实现printf重定向
UART,说白了就是串口。做嵌入式开发的人,没人离得开它。调试打印、跟传感器通信、跟蓝牙模块对接,哪哪都有它。但很多人写UART驱动,就是对着寄存器一顿操作,能用就行。等产品一量产,问题就来了——丢数据、卡死、中断响应不过来。
我个人习惯,不管项目多小,UART驱动一定要做分层封装。底层HAL管寄存器,中间层管数据收发逻辑,上层只管调用。今天我们就拿STM32的HAL库来实战,把UART驱动从轮询到中断,再到环形缓冲区和printf重定向,一层层搭起来。
4.1 轮询模式:简单但别滥用
轮询模式,就是CPU死等。发一个字节,等发送完成;收一个字节,等数据到达。代码写起来最直接,但效率最低。
// 轮询发送
void UART_Polling_SendByte(UART_HandleTypeDef *huart, uint8_t data)
{
HAL_UART_Transmit(huart, &data, 1, HAL_MAX_DELAY);
}
// 轮询接收
uint8_t UART_Polling_RecvByte(UART_HandleTypeDef *huart)
{
uint8_t data;
HAL_UART_Receive(huart, &data, 1, HAL_MAX_DELAY);
return data;
}
这段代码,初学者一看就懂。但我要说——别在产品里这么用。我在一个温控项目里吃过亏,用轮询收传感器数据,结果CPU 90%的时间都在等串口,主循环里的PID计算被严重拖慢。后来改成中断,CPU占用直接降到5%。
- 系统初始化阶段,还没开中断
- 调试阶段,临时看数据
- 极低波特率(如9600以下),且CPU任务极轻
4.2 中断模式:让CPU干正事
中断模式的核心思想:数据来了,硬件通知你;你只管把数据拿走,存好,然后继续干别的。
HAL库的中断接收,需要三步走:
- 使能接收中断:调用
HAL_UART_Receive_IT() - 实现回调函数:
HAL_UART_RxCpltCallback() - 循环使能:每次接收完成,重新使能下一次中断
// 全局变量
volatile uint8_t rx_byte = 0;
// 启动中断接收
void UART_Start_Interrupt(UART_HandleTypeDef *huart)
{
HAL_UART_Receive_IT(huart, &rx_byte, 1);
}
// 中断回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
// 这里处理收到的字节
// 注意:回调里不要做耗时操作!
ProcessReceivedByte(rx_byte);
// 重新使能下一次接收
HAL_UART_Receive_IT(huart, &rx_byte, 1);
}
嗯,这里要注意。回调函数是在中断上下文执行的。你如果在里面做复杂运算、调延时、甚至打印,系统直接崩给你看。我见过一个同事在回调里调了 printf,结果递归中断,栈直接爆了。
- 只做数据搬运,不做数据处理
- 把数据放到缓冲区,然后设个标志位
- 主循环里查标志位,再处理数据
4.3 环形缓冲区:解决数据堆积问题
中断模式有个痛点:如果数据来得快,你处理得慢,数据就丢了。怎么办?加缓冲区。
但普通数组缓冲区有个问题——满了之后,要么覆盖旧数据,要么丢弃新数据。环形缓冲区(Ring Buffer)就是来解决这个的。它像一个循环的队列,写指针追着读指针跑,永远不会溢出(除非你写得比读得快太多)。
// 环形缓冲区结构体
typedef struct {
uint8_t buffer[UART_RX_BUF_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} RingBuffer_t;
// 初始化
void RingBuf_Init(RingBuffer_t *rb)
{
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节
int RingBuf_Write(RingBuffer_t *rb, uint8_t data)
{
uint16_t next = (rb->head + 1) % UART_RX_BUF_SIZE;
if (next == rb->tail) {
return -1; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
// 读取一个字节
int RingBuf_Read(RingBuffer_t *rb, uint8_t *data)
{
if (rb->head == rb->tail) {
return -1; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % UART_RX_BUF_SIZE;
return 0;
}
这段代码里,head和tail用了volatile。为什么?因为中断和主循环会同时访问它们。不加volatile,编译器优化后可能读到缓存值,数据就乱了。
我曾经在一个电机驱动项目里,就因为忘了加volatile,调试了整整两天。每次收完数据,主循环读到的都是旧值。加了volatile,问题秒解。这种坑,踩过一次就记住了。
| 特性 | 环形缓冲区 | 普通数组 |
|---|---|---|
| 内存利用率 | 高(循环复用) | 低(用完需重置) |
| 读写效率 | O(1) | O(1)(但需管理索引) |
| 溢出处理 | 可检测 | 易覆盖 |
| 中断安全 | 天然支持 | 需额外保护 |
4.4 整合:中断 + 环形缓冲区
把中断和环形缓冲区结合起来,就是一套完整的UART接收方案。
// 全局环形缓冲区
static RingBuffer_t uart_rx_buf;
// 中断回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
// 把收到的字节写入环形缓冲区
RingBuf_Write(&uart_rx_buf, rx_byte);
// 重新使能接收
HAL_UART_Receive_IT(huart, &rx_byte, 1);
}
// 应用层读取接口
int UART_ReadByte(uint8_t *data)
{
return RingBuf_Read(&uart_rx_buf, data);
}
你看,应用层根本不用关心中断细节。它只管调 UART_ReadByte(),有数据就返回,没数据就返回-1。这就是分层的好处——底层怎么实现,上层不关心。
4.5 printf重定向:调试利器
做嵌入式开发,printf是调试神器。但标准库的printf默认输出到控制台,在MCU上得重定向到串口。
重定向的核心,就是实现 _write 函数(ARMCC)或 __io_putchar(GCC)。
// 适用于 ARMCC (Keil)
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
// 适用于 GCC (STM32CubeIDE)
int __io_putchar(int ch)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
重定向之后,printf("Hello, World!\n") 就直接从串口输出了。但要注意——printf默认是阻塞的,而且占栈空间很大。我在一个只有8KB RAM的芯片上试过,调一次printf,栈直接涨了1KB多。
- 栈溢出:printf内部会分配缓冲区,小RAM芯片慎用
- 阻塞问题:HAL_UART_Transmit默认是阻塞的,高频率打印会拖慢系统
- 浮点支持:默认不开启,需要在编译器选项里加
-u _printf_float
我的建议是:调试阶段用printf,产品阶段换成自定义的轻量打印函数,比如只打印关键信息,或者用DMA方式发送。
4.6 知识结构总览
下面这张图,把UART驱动的分层结构和数据流向画清楚了。你写代码的时候,脑子里要有这张图。
4.7 避坑指南
最后,把我这些年踩过的坑,列出来给你。每一个都是真金白银换来的教训。
- 中断优先级:UART中断优先级别设太高,否则会抢占系统滴答定时器,导致延时函数不准。我一般设到次高优先级。
- 缓冲区大小:环形缓冲区的大小,要大于最大一帧数据的2倍。为什么?防止读指针追写指针时,数据还没处理完就被覆盖。
- 临界区保护:如果主循环和中断同时操作环形缓冲区,读操作也要关中断。否则读到一半,中断写入了新数据,数据就乱了。
- printf浮点:默认不开浮点支持,打印浮点数会输出空字符串。记得在编译器选项里加
-u _printf_float。 - HAL库超时:
HAL_UART_Transmit的最后一个参数是超时时间。设成HAL_MAX_DELAY会死等,设成0则立即返回。根据场景选。
好了,UART驱动封装这一块,核心就是这些。轮询模式简单但低效,中断模式高效但需要缓冲区配合,环形缓冲区是解决数据堆积的标准方案,printf重定向是调试利器但要注意资源开销。把这四块吃透,你写任何MCU的UART驱动,思路都是一样的。