4、UART驱动封装实战:环形缓冲区设计,中断与轮询模式封装
UART 驱动,几乎是每个嵌入式工程师都会碰到的活。但说实话,很多人写了几年,驱动还是「能用就行」的水平——数据偶尔丢一个字节,或者在高波特率下卡死,查半天找不到原因。
我个人习惯,做 UART 驱动第一步不是写寄存器,而是先把数据通道想清楚。数据怎么进来?怎么出去?中间要不要缓冲?缓冲多大?这些想明白了,代码自然就稳了。
4.1 环形缓冲区:UART 驱动的「心脏」
先问一个问题:为什么需要环形缓冲区?
你想想看,MCU 处理数据的速度,跟外设发送数据的速度,往往不在一个节奏上。UART 接收一个字节,可能只要几十微秒,但你要解析这个字节、做协议判断、甚至写 Flash,那可能就要几毫秒。如果没有缓冲区,数据来了你来不及取,那就丢了。
环形缓冲区,说白了就是一个固定大小的数组,加上两个指针:一个写指针(生产者),一个读指针(消费者)。数据写进去,读出来,首尾相连,循环使用。
核心要点:环形缓冲区解决了「生产者和消费者速度不匹配」的问题。中断服务程序只管往缓冲区里塞数据,主循环慢慢取出来处理,互不干扰。
我曾经在一个项目里,用了一个 64 字节的环形缓冲区,结果在 115200 波特率下连续接收数据包,偶尔会丢包。排查了半天,发现是缓冲区太小,中断来得太快,写指针追上了读指针。后来改成 256 字节,问题就解决了。
4.1.1 环形缓冲区数据结构
先看一个典型的环形缓冲区结构体定义:
typedef struct {
uint8_t *buffer; // 缓冲区基地址
uint32_t size; // 缓冲区大小(必须是2的幂)
volatile uint32_t head; // 写指针(生产者)
volatile uint32_t tail; // 读指针(消费者)
} ring_buffer_t;
这里有个小技巧:size 最好设置为 2 的幂。为什么?因为取模运算 head % size 可以用位运算 head & (size - 1) 替代,速度快很多。在中断服务程序里,每一微秒都很宝贵。
4.1.2 核心操作函数
环形缓冲区一般需要这几个操作:
| 函数 | 功能 | 注意事项 |
|---|---|---|
ring_buf_init |
初始化缓冲区 | 清空 head/tail,传入 buffer 地址和 size |
ring_buf_put |
写入一个字节 | 检查是否已满,满则返回错误 |
ring_buf_get |
读取一个字节 | 检查是否为空,空则返回错误 |
ring_buf_is_full |
判断是否已满 | head + 1 == tail 视为满(留一个空位) |
ring_buf_is_empty |
判断是否为空 | head == tail 视为空 |
写一个 ring_buf_put 的示例:
int ring_buf_put(ring_buffer_t *rb, uint8_t data) {
uint32_t next_head = (rb->head + 1) & (rb->size - 1);
if (next_head == rb->tail) {
return -1; // 缓冲区已满
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return 0;
}
我的习惯:判断满的时候,故意留一个空位不用。这样「满」和「空」的状态就不会混淆。head == tail 是空,head + 1 == tail 是满,逻辑清晰,不容易出 bug。
4.2 中断模式封装:让数据「自己进来」
中断模式,是 UART 驱动最常用的方式。数据来了,硬件自动触发中断,你在中断服务程序里把数据塞进环形缓冲区。主循环该干嘛干嘛,不用轮询。
但中断模式有个坑:中断服务程序里不能做耗时操作。我曾经见过有人直接在 UART 中断里做协议解析、调用 printf、甚至写 EEPROM……结果系统直接卡死,或者丢数据丢到怀疑人生。
警告:中断服务程序里只做「最小必要操作」——把数据放进缓冲区,清中断标志,然后立即退出。解析、处理、打印,统统放到主循环里做。
4.2.1 中断服务程序封装
一个典型的 UART 接收中断服务程序:
void USART1_IRQHandler(void) {
uint8_t data;
if (LL_USART_IsActiveFlag_RXNE(USART1)) {
data = LL_USART_ReceiveData8(USART1);
ring_buf_put(&rx_ring_buf, data);
LL_USART_ClearFlag_RXNE(USART1);
}
// 发送中断类似,从 tx_ring_buf 取数据发送
}
你看,就这么几行。读数据、塞缓冲区、清标志,完事。主循环里再调用 ring_buf_get 把数据取出来处理。
4.2.2 中断使能与配置
初始化的时候,需要配置 UART 的中断:
void uart_interrupt_init(void) {
// 配置 UART 参数(波特率、数据位、停止位等)
LL_USART_Init(USART1, &usart_init_struct);
// 使能接收中断
LL_USART_EnableIT_RXNE(USART1);
// 使能 UART
LL_USART_Enable(USART1);
// 使能 NVIC 中断
NVIC_EnableIRQ(USART1_IRQn);
}
避坑指南:我曾经在使能中断之前忘了使能 UART 外设,结果中断一直不触发,查了半天。顺序很重要:先配参数,再开中断,最后使能外设。反过来可能会产生意外中断。
4.3 轮询模式封装:简单但可靠
轮询模式,说白了就是主循环里不断检查 UART 的接收标志位。有数据就读,没数据就跳过。简单、直接、没有中断上下文切换的开销。
但轮询模式有个致命缺点:如果主循环里有耗时操作,数据就可能丢。所以它一般用在两种场景:
- 低波特率、低数据量:比如 9600 波特率,一秒才 960 字节,主循环跑得过来。
- 调试阶段:先轮询跑通,再切中断,方便定位问题。
4.3.1 轮询收发函数
int uart_polling_send(uint8_t data) {
// 等待发送缓冲区空
while (!LL_USART_IsActiveFlag_TXE(USART1));
LL_USART_TransmitData8(USART1, data);
return 0;
}
int uart_polling_receive(uint8_t *data) {
if (LL_USART_IsActiveFlag_RXNE(USART1)) {
*data = LL_USART_ReceiveData8(USART1);
return 0;
}
return -1; // 无数据
}
注意接收函数:它不会阻塞等待,而是检查有没有数据,有就返回,没有就返回 -1。这样主循环可以继续做其他事。
4.4 中断与轮询的统一接口
好的驱动设计,应该让上层代码不关心底层用的是中断还是轮询。我习惯封装一个统一的 UART 接口:
typedef struct {
int (*init)(void);
int (*send)(uint8_t data);
int (*receive)(uint8_t *data);
int (*send_buf)(uint8_t *buf, uint32_t len);
int (*receive_buf)(uint8_t *buf, uint32_t len);
} uart_drv_t;
然后分别实现中断版和轮询版的 uart_drv_t。上层代码只需要调用 uart_drv.send() 和 uart_drv.receive(),底层怎么实现的,它不管。
设计思想:接口统一,实现分离。换驱动模式的时候,只需要换一个结构体指针,上层代码一行都不用改。这就是模块化的好处。
4.5 本章知识结构图
下面这张图,把 UART 驱动的核心逻辑串起来了:
4.6 实战建议与避坑总结
最后,把我这些年做 UART 驱动踩过的坑,总结成几条建议:
- 缓冲区大小要留余量:理论上 64 字节够用,但实际项目里建议至少 256 字节。数据突发的时候,缓冲区大一点,系统就稳一点。
- 中断优先级别设太高:UART 中断优先级设得比系统心跳还高,可能会导致其他任务饿死。一般设中等优先级就够了。
- 别忘了处理溢出错误:UART 有溢出标志(ORE),如果没清掉,后续数据就收不到了。中断里最好检查一下。
- 调试时先用轮询:新板子第一次跑 UART,先用轮询模式确认硬件没问题,再切中断。不然中断一开,出了问题你都不知道是硬件还是软件。
我的习惯:每个 UART 驱动都保留一个「调试开关」,编译时可以选择用中断还是轮询。开发阶段用轮询,调试方便;发布阶段切中断,性能更好。一个宏定义搞定,省心。
嗯,UART 驱动封装的核心就这些。环形缓冲区是基础,中断和轮询是两种实现方式,统一接口是模块化的关键。把这些想清楚,写出来的驱动不仅稳定,而且好维护、好移植。