第三讲:UART驱动封装实战——环形缓冲区设计与中断驱动模型
大家好,欢迎来到第三讲。
上一讲我们聊了GPIO的封装思路,很多同学反馈说「原来驱动还能这么写」。今天咱们继续深入,聊聊UART这个最常用的外设。说实话,UART驱动我写过不下二十个版本,从最早的裸机轮询到现在的RTOS驱动框架,踩过的坑真不少。
这一讲的核心就两个东西:环形缓冲区和中断驱动模型。你想想看,如果没有缓冲区,MCU处理一个字节就要进一次中断,那CPU还干不干别的了?
3.1 为什么需要环形缓冲区?
先问大家一个问题:UART接收数据时,最怕什么?
我个人觉得,最怕的就是数据丢失。比如你正在处理一个耗时任务,串口突然来了一包数据,等你忙完再去读,后面的字节已经被覆盖了。
环形缓冲区就是来解决这个问题的。它本质上是一个固定大小的数组,配合两个指针(读指针和写指针),实现先进先出的数据管理。
核心思想:写指针追着读指针跑,数据永远不会被覆盖——除非缓冲区真的满了。
3.2 环形缓冲区的数据结构设计
我在项目中习惯这样定义环形缓冲区:
/* ring_buffer.h */
typedef struct {
uint8_t *buffer; // 数据存储区
uint16_t size; // 缓冲区大小(必须是2的幂)
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} ring_buffer_t;
嗯,这里要注意:size我建议设置为2的幂,比如64、128、256。为什么?因为可以用位运算代替取模,效率高很多。
举个例子:
// 普通写法
head = (head + 1) % size;
// 高效写法(size必须是2的幂)
head = (head + 1) & (size - 1);
我曾经在一个低功耗项目里,用位运算优化后,中断服务函数执行时间从3.2μs降到了1.8μs。别小看这1.4μs,在高波特率下可能就是丢包和稳定的区别。
3.3 环形缓冲区的核心操作
一个完整的环形缓冲区,至少需要这几个接口:
| 函数名 | 功能 | 备注 |
|---|---|---|
| ring_buf_init | 初始化缓冲区 | 清空指针,绑定内存 |
| ring_buf_put | 写入一个字节 | 返回是否成功 |
| ring_buf_get | 读取一个字节 | 返回是否成功 |
| ring_buf_is_empty | 判断是否为空 | head == tail |
| ring_buf_is_full | 判断是否已满 | (head + 1) % size == tail |
这里有个小技巧:我习惯让缓冲区始终留一个空位。也就是说,当(head + 1) % size == tail时,认为缓冲区已满。这样做的好处是,head == tail可以唯一表示「空」状态,不会和「满」状态混淆。
避坑指南:我曾经在某个产品中忘了留这个空位,结果缓冲区满和空都是head==tail,调试了整整两天才发现。从那以后,我再也不敢偷懒了。
3.4 中断驱动模型的设计思路
有了环形缓冲区,中断驱动模型就水到渠成了。说白了就是:
- 接收中断:每收到一个字节,就往环形缓冲区里塞
- 主循环/任务:从环形缓冲区里取数据,做协议解析
- 发送中断:发送缓冲区非空时,触发中断发送下一个字节
我画个简单的流程图给你看:
UART接收中断
↓
读取DR寄存器 → 存入环形缓冲区
↓
清除中断标志 → 退出中断
主循环
↓
检查环形缓冲区是否为空
↓
非空 → 取出数据 → 协议解析
你想想看,这样设计的好处是什么?中断服务函数只做最轻量的事情——存一个字节,然后立刻退出。所有耗时的协议解析、数据处理,都放在主循环里做。
3.5 完整的UART驱动封装示例
好了,理论说完了,咱们直接上代码。这是我个人比较喜欢的一种封装风格:
/* uart_driver.h */
#include "ring_buffer.h"
typedef struct {
USART_TypeDef *instance; // 硬件外设基地址
ring_buffer_t rx_buf; // 接收环形缓冲区
ring_buffer_t tx_buf; // 发送环形缓冲区
void (*rx_callback)(uint8_t data); // 可选:接收回调
} uart_t;
/* 初始化UART */
void uart_init(uart_t *uart, uint32_t baudrate);
/* 发送一个字节(非阻塞) */
void uart_send_byte(uart_t *uart, uint8_t data);
/* 发送一包数据(非阻塞) */
void uart_send_data(uart_t *uart, uint8_t *data, uint16_t len);
/* 接收一个字节(非阻塞) */
int uart_recv_byte(uart_t *uart, uint8_t *data);
/* 中断服务函数(在中断向量表中调用) */
void uart_irq_handler(uart_t *uart);
中断服务函数的实现大概是这样的:
void uart_irq_handler(uart_t *uart) {
// 接收中断
if (/* 接收中断标志置位 */) {
uint8_t data = uart->instance->DR;
ring_buf_put(&uart->rx_buf, data);
// 如果有回调函数,调用它
if (uart->rx_callback) {
uart->rx_callback(data);
}
}
// 发送中断
if (/* 发送中断标志置位 */) {
uint8_t data;
if (ring_buf_get(&uart->tx_buf, &data)) {
uart->instance->DR = data;
} else {
// 发送缓冲区空了,关闭发送中断
// 关闭发送中断标志位
}
}
}
重要提醒:中断服务函数里千万不要做耗时操作!比如协议解析、内存分配、打印调试信息。我曾经见过有人在中断里调用printf,结果波特率一高,系统直接死机。
3.6 实际项目中的经验总结
最后,分享几个我在实际项目中踩过的坑:
- 缓冲区大小要合理。太小容易丢数据,太大浪费内存。我一般根据波特率和主循环响应时间来估算。比如115200波特率,主循环10ms响应一次,缓冲区至少需要115200/8/100 ≈ 144字节。我通常会取256。
- 注意临界区保护。如果主循环和中断同时操作环形缓冲区,一定要关中断或使用原子操作。我习惯在
ring_buf_get和ring_buf_put内部用__disable_irq()和__enable_irq()保护。 - 发送缓冲区也要用环形缓冲区。很多初学者只给接收做缓冲区,发送直接阻塞等待。这在低波特率下会严重拖慢系统响应。
嗯,这一讲的内容就到这里。环形缓冲区加中断驱动,说白了就是「用空间换时间,用中断换效率」。你把这个模型吃透了,不光UART,SPI、I2C的驱动都可以用同样的思路来封装。
下一讲,我们会在这个基础上,加入DMA传输,让UART的吞吐量再上一个台阶。到时候你会发现,原来驱动可以写得这么优雅。
课后思考:如果环形缓冲区的大小不是2的幂,位运算就不能用了。你会怎么处理取模运算?有没有其他优化方法?
公众号:蓝海资料掘金营,微信deep3321