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库的中断接收,需要三步走:

  1. 使能接收中断:调用 HAL_UART_Receive_IT()
  2. 实现回调函数:HAL_UART_RxCpltCallback()
  3. 循环使能:每次接收完成,重新使能下一次中断
// 全局变量
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,问题秒解。这种坑,踩过一次就记住了。

环形缓冲区 vs 普通数组:
特性 环形缓冲区 普通数组
内存利用率 高(循环复用) 低(用完需重置)
读写效率 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重定向的坑:
  • 栈溢出:printf内部会分配缓冲区,小RAM芯片慎用
  • 阻塞问题:HAL_UART_Transmit默认是阻塞的,高频率打印会拖慢系统
  • 浮点支持:默认不开启,需要在编译器选项里加 -u _printf_float

我的建议是:调试阶段用printf,产品阶段换成自定义的轻量打印函数,比如只打印关键信息,或者用DMA方式发送。

4.6 知识结构总览

下面这张图,把UART驱动的分层结构和数据流向画清楚了。你写代码的时候,脑子里要有这张图。

UART驱动分层架构与数据流 UART驱动分层架构与数据流 应用层(printf / 业务逻辑) 中间件层(环形缓冲区 + 数据管理) HAL驱动层(轮询 / 中断 / DMA) 硬件层(UART外设寄存器) 数据流向 ① 硬件收到数据 ② 中断触发,HAL回调 ③ 写入环形缓冲区 ④ 应用层读取使用 三种模式对比 轮询:简单,CPU占用高 中断:高效,需缓冲区 DMA:最高效,适合大数据 关键设计原则 • 中断回调只做数据搬运,不做处理 • 环形缓冲区用 volatile 保护共享变量 • printf 重定向注意栈大小和阻塞问题

4.7 避坑指南

最后,把我这些年踩过的坑,列出来给你。每一个都是真金白银换来的教训。

  • 中断优先级:UART中断优先级别设太高,否则会抢占系统滴答定时器,导致延时函数不准。我一般设到次高优先级。
  • 缓冲区大小:环形缓冲区的大小,要大于最大一帧数据的2倍。为什么?防止读指针追写指针时,数据还没处理完就被覆盖。
  • 临界区保护:如果主循环和中断同时操作环形缓冲区,读操作也要关中断。否则读到一半,中断写入了新数据,数据就乱了。
  • printf浮点:默认不开浮点支持,打印浮点数会输出空字符串。记得在编译器选项里加 -u _printf_float。
  • HAL库超时:HAL_UART_Transmit 的最后一个参数是超时时间。设成 HAL_MAX_DELAY 会死等,设成0则立即返回。根据场景选。

好了,UART驱动封装这一块,核心就是这些。轮询模式简单但低效,中断模式高效但需要缓冲区配合,环形缓冲区是解决数据堆积的标准方案,printf重定向是调试利器但要注意资源开销。把这四块吃透,你写任何MCU的UART驱动,思路都是一样的。