4. UART中断驱动:基于中断的UART收发与环形缓冲区
UART通信,说白了就是串口收发数据。很多初学者喜欢用轮询方式——CPU一直在那等着,啥也干不了。我刚开始做项目时也这么干,结果一个简单的GPS数据解析就把主循环卡死了。后来我才明白,中断才是嵌入式开发的灵魂。
这一章,我们来聊聊UART中断驱动,以及它的好搭档——环形缓冲区。
4.1 为什么必须用中断?
先看一个场景:你的MCU要同时处理按键扫描、LED闪烁、传感器采集,还要通过UART接收GPS模块每秒发送的NMEA数据。如果用轮询,CPU得不停检查UART接收寄存器有没有新数据。你想想看,这得浪费多少CPU时间?
中断的好处很明显:
- 实时响应:数据来了立刻通知CPU,不耽误事
- CPU解放:没数据时CPU可以干别的活
- 不丢数据:尤其是高速率通信,轮询很容易漏掉字节
核心原则:中断服务函数(ISR)要短、平、快。千万别在ISR里做复杂处理,比如字符串解析、协议解码——这些事交给主循环去做。
4.2 环形缓冲区:中断与主循环的桥梁
中断来了,数据存哪?直接扔全局变量?那肯定不行。主循环还没处理完,下一个中断又来了,数据就被覆盖了。
环形缓冲区(Ring Buffer)就是解决这个问题的。它像一个循环队列,ISR只管往里面写数据,主循环只管从里面读数据。两者互不干扰。
4.2.1 环形缓冲区的工作原理
说白了,就是一个固定大小的数组,加上两个指针:
- 写指针(write index):ISR往这个位置写数据,写完往后移
- 读指针(read index):主循环从这个位置读数据,读完往后移
当指针到达数组末尾时,自动回到开头——这就是“环形”的由来。
我的习惯:缓冲区大小选2的幂次方,比如64、128、256。这样可以用位运算代替取模运算,效率更高。
4.2.2 数据结构定义
/* 环形缓冲区结构体 */
typedef struct {
uint8_t *buffer; /* 缓冲区指针 */
uint16_t size; /* 缓冲区大小(2的幂次方) */
volatile uint16_t head; /* 写指针(ISR使用) */
volatile uint16_t tail; /* 读指针(主循环使用) */
} ring_buffer_t;
注意volatile关键字。为什么?因为head和tail分别在中断和主循环中被修改,编译器可能会优化掉这些变量的读取。加上volatile,告诉编译器“每次都要从内存读,别给我缓存”。
4.2.3 核心操作函数
环形缓冲区需要四个基本操作:初始化、写、读、判空。我直接给出代码,你拿去就能用。
/* 初始化 */
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buf, uint16_t size) {
rb->buffer = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
/* 写入一个字节(ISR中调用) */
int ring_buffer_put(ring_buffer_t *rb, uint8_t data) {
uint16_t next = (rb->head + 1) & (rb->size - 1);
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) & (rb->size - 1);
return 0;
}
/* 判断是否为空 */
int ring_buffer_is_empty(ring_buffer_t *rb) {
return (rb->head == rb->tail);
}
我曾经踩过的坑:在ISR里调用ring_buffer_get,在主循环里调用ring_buffer_put。这会导致数据错乱。记住:ISR只写,主循环只读,这个规矩不能破。
4.3 UART中断接收实战
理论讲完了,咱们来点实战。以STM32为例,配置UART接收中断。
4.3.1 初始化配置
/* 定义环形缓冲区 */
#define RX_BUF_SIZE 256
static uint8_t rx_buf[RX_BUF_SIZE];
static ring_buffer_t uart_rx_rb;
/* UART初始化 */
void uart_init(void) {
/* 初始化环形缓冲区 */
ring_buffer_init(&uart_rx_rb, rx_buf, RX_BUF_SIZE);
/* 配置UART(省略时钟、GPIO配置) */
USART1->BRR = 0x341; /* 115200bps @ 72MHz */
USART1->CR1 |= USART_CR1_RE | USART_CR1_TE; /* 使能收发 */
USART1->CR1 |= USART_CR1_RXNEIE; /* 使能接收中断 */
NVIC_EnableIRQ(USART1_IRQn); /* 使能NVIC中断 */
USART1->CR1 |= USART_CR1_UE; /* 使能UART */
}
4.3.2 中断服务函数
void USART1_IRQHandler(void) {
uint8_t data;
if (USART1->SR & USART_SR_RXNE) {
data = (uint8_t)(USART1->DR & 0xFF);
ring_buffer_put(&uart_rx_rb, data); /* 存入缓冲区 */
}
/* 处理溢出错误 */
if (USART1->SR & USART_SR_ORE) {
(void)USART1->DR; /* 读DR清除ORE标志 */
}
}
4.3.3 主循环读取数据
int main(void) {
uint8_t ch;
uart_init();
while (1) {
/* 从环形缓冲区读取数据 */
if (ring_buffer_get(&uart_rx_rb, &ch) == 0) {
/* 处理接收到的字节 */
process_received_byte(ch);
}
/* 其他任务 */
handle_led();
handle_sensor();
}
}
4.4 UART中断发送
接收搞定了,发送呢?其实发送也可以用中断,但思路刚好反过来:
- 发送缓冲区:主循环往里面写数据
- 发送中断:UART发送完成中断触发时,从缓冲区取下一个字节发送
这样做的好处是,发送长数据时CPU不用一直等。比如你要发100个字节,开启发送中断后,CPU只管把数据塞进缓冲区,剩下的交给硬件和ISR。
关键点:发送中断的触发条件是“发送数据寄存器为空”(TXE)或“发送完成”(TC)。一般用TXE中断,效率更高。
4.5 知识体系总览
下面这张图总结了UART中断驱动与环形缓冲区的整体架构。你可以把它当作设计参考。
4.6 常见问题与避坑指南
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 数据丢失 | 缓冲区太小,ISR写入速度超过主循环读取速度 | 增大缓冲区,或优化主循环处理速度 |
| 数据错乱 | ISR和主循环同时操作同一个变量,没有加volatile | 检查所有共享变量是否加了volatile |
| 中断嵌套导致死锁 | 高优先级中断中调用了环形缓冲区操作函数 | ISR中只做最简单的数据搬运,复杂逻辑放主循环 |
| 缓冲区溢出未处理 | 没有检查ring_buffer_put的返回值 | 每次写入后检查返回值,溢出时做错误处理 |
我曾经踩过的坑:有一次调试一个GPS模块,数据总是断断续续。查了半天,发现是环形缓冲区大小设成了255,不是2的幂次方。位运算(head + 1) & (size - 1)在size=255时完全失效。改成256后一切正常。嗯,从此我记住了:缓冲区大小必须是2的幂次方。
4.7 小结
这一章我们聊了UART中断驱动的核心——环形缓冲区。说白了,就是用空间换时间,用中断换效率。你只要记住三个要点:
- ISR只写,主循环只读——这是铁律
- 缓冲区大小选2的幂次方——位运算效率高
- 共享变量加volatile——防止编译器优化