3. UART中断驱动与环形缓冲区
UART驱动,如果只靠轮询(Polling),说白了就是让CPU一直盯着数据线看。这在裸机小项目里还能凑合,一旦上了RTOS或者要处理多路通信,CPU就被活活拖死了。我早年做第一版Bootloader时就吃过这个亏——轮询收数据,结果主循环里其他任务全被堵住,按键响应都延迟了好几秒。
所以,真正实用的UART驱动,必须用中断驱动加环形缓冲区。这一章,我就把这两块的核心机制和实战细节掰开揉碎了讲。
3.1 UART中断机制:从硬件到软件
UART中断,说白了就是串口硬件在特定事件发生时,主动通知CPU来干活。常见的中断事件有这些:
| 中断事件 | 触发条件 | 典型用途 |
|---|---|---|
| RXNE (接收非空) | 接收移位寄存器已收到一个字节,数据可读 | 逐字节接收数据 |
| TXE (发送寄存器空) | 发送数据寄存器已空,可写入下一个字节 | 逐字节发送数据(中断方式) |
| TC (发送完成) | 整个数据帧(含停止位)已发送完毕 | 关闭发送中断、控制RS485收发切换 |
| IDLE (总线空闲) | 接收线空闲超过一个帧时间 | 判断一帧数据接收完成(常用) |
| ORE (溢出错误) | 数据未及时读取,新数据覆盖旧数据 | 错误处理、丢包检测 |
中断处理流程其实不复杂:
- 硬件检测到事件(比如收到一个字节),置位中断标志位。
- 如果该中断在NVIC中已使能,CPU跳转到中断向量表对应的ISR(中断服务函数)。
- ISR中读取数据或写入数据,然后清除中断标志。
- 退出ISR,返回主程序。
关键点:ISR要短、快、狠。千万别在ISR里做复杂处理,比如解析协议、调用printf。ISR只做数据搬运,把数据放到缓冲区就撤。我在项目中见过有人直接在ISR里做CRC校验,结果导致其他低优先级中断被长时间阻塞,系统响应一塌糊涂。
3.2 中断优先级配置:别让优先级坑了你
中断优先级配置,很多人觉得就是设个数字大小。其实没那么简单。ARM Cortex-M系列使用抢占优先级和子优先级的组合。配置错了,高优先级中断可能被低优先级中断阻塞,或者出现优先级反转。
我个人习惯这样配置:
- 时间敏感的中断(如定时器、高波特率UART接收)给最高抢占优先级。
- UART发送中断优先级低于接收中断。因为发送可以等,接收不能丢。
- 非实时任务(如按键、LED)给最低优先级。
举个例子,在STM32上配置UART中断优先级:
// 设置UART接收中断优先级为2,发送中断优先级为3
// 使用4位抢占优先级,0位子优先级(NVIC_PriorityGroup_2)
HAL_NVIC_SetPriority(USART1_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);
HAL_NVIC_SetPriority(USART1_IRQn, 3, 0); // 发送中断共用同一个IRQn,但通过标志位区分
// 注意:实际项目中发送和接收通常共用一个IRQn,在ISR内部分发
我曾经踩过的坑:在一个多串口项目中,我把所有UART中断都设成了相同优先级。结果两个串口同时触发中断时,ISR互相嵌套,导致接收缓冲区溢出。后来我把高波特率的UART接收优先级调高一级,问题就解决了。记住:相同优先级的中断不会互相抢占,只能排队。
3.3 环形缓冲区(Ring Buffer)设计与实现
环形缓冲区,也叫循环队列。它解决的核心问题是:生产者和消费者的速度不匹配。在UART场景里,ISR是生产者(往缓冲区写数据),主循环是消费者(从缓冲区读数据)。
环形缓冲区的基本结构:
- 一块固定大小的内存(数组)。
- 两个指针(或索引):读指针和写指针。
- 写指针追上读指针时,缓冲区满。
- 读指针追上写指针时,缓冲区空。
下面是我常用的环形缓冲区实现,支持单生产者单消费者(无锁):
#define RING_BUFFER_SIZE 256
typedef struct {
uint8_t buffer[RING_BUFFER_SIZE];
volatile uint16_t head; // 写索引,ISR中修改
volatile uint16_t tail; // 读索引,主循环中修改
} ring_buffer_t;
// 初始化
void ring_buffer_init(ring_buffer_t *rb) {
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节(ISR中调用)
bool ring_buffer_write(ring_buffer_t *rb, uint8_t data) {
uint16_t next_head = (rb->head + 1) % RING_BUFFER_SIZE;
if (next_head == rb->tail) {
return false; // 缓冲区满,丢包
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return true;
}
// 读取一个字节(主循环中调用)
bool ring_buffer_read(ring_buffer_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return false; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE;
return true;
}
设计要点:
- 缓冲区大小必须是2的幂?不一定,但用2的幂可以用位运算代替取模,效率更高。
- head和tail用volatile修饰,防止编译器优化导致数据不一致。
- 判断满的条件是 (head+1)%size == tail,留一个空位区分空和满。
- ISR中只写不读,主循环中只读不写,这样不需要加锁。
3.4 解决数据粘包与丢包问题
数据粘包,说白了就是接收端不知道一帧数据从哪里开始、到哪里结束。丢包,就是数据被覆盖或丢失了。这两个问题在UART通信中非常常见。
粘包问题的常见解决方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 固定长度帧 | 每帧数据长度固定,接收方按长度截取 | 简单,但灵活性差,不适合变长数据 |
| 帧头+长度+数据+校验 | 通过帧头定位起始,长度字段确定结束 | 通用性强,但解析逻辑稍复杂 |
| 特殊分隔符 | 用特定字符(如0x0A、0x0D)标记帧结束 | 简单,但数据内容不能包含分隔符(需转义) |
| 空闲中断(IDLE) | 利用UART硬件空闲中断判断帧结束 | 硬件支持即可,无需额外字节,但时间阈值固定 |
我个人最推荐的是帧头+长度+数据+校验的方式。它既支持变长数据,又有校验保证完整性。下面是一个简单的帧解析器示例:
typedef enum {
STATE_IDLE,
STATE_HEADER,
STATE_LENGTH,
STATE_DATA,
STATE_CHECK
} parser_state_t;
parser_state_t state = STATE_IDLE;
uint8_t frame_buffer[256];
uint16_t frame_index = 0;
uint8_t expected_length = 0;
uint8_t checksum = 0;
void uart_parser_feed(uint8_t byte) {
switch (state) {
case STATE_IDLE:
if (byte == 0xAA) { // 帧头
state = STATE_HEADER;
frame_index = 0;
checksum = 0;
}
break;
case STATE_HEADER:
if (byte == 0x55) { // 第二个帧头字节
state = STATE_LENGTH;
} else {
state = STATE_IDLE; // 帧头错误,重新同步
}
break;
case STATE_LENGTH:
expected_length = byte;
checksum ^= byte;
state = STATE_DATA;
break;
case STATE_DATA:
frame_buffer[frame_index++] = byte;
checksum ^= byte;
if (frame_index >= expected_length) {
state = STATE_CHECK;
}
break;
case STATE_CHECK:
if (byte == checksum) {
// 帧接收完成,处理frame_buffer
process_frame(frame_buffer, expected_length);
} else {
// 校验失败,丢弃
}
state = STATE_IDLE;
break;
}
}
丢包问题的常见原因和解决:
- 缓冲区太小:ISR写入速度大于主循环读取速度,缓冲区溢出。解决:增大缓冲区,或提高主循环读取频率。
- 中断优先级不当:高优先级中断长时间占用CPU,导致UART中断无法及时响应。解决:合理配置优先级,确保UART接收中断有足够高的优先级。
- ISR处理时间过长:在ISR中做了太多工作,导致下一个字节到来时还没处理完。解决:ISR只做数据搬运,复杂处理放到主循环或任务中。
- 硬件流控未启用:高速通信时,接收方来不及处理,发送方持续发送导致数据丢失。解决:启用RTS/CTS硬件流控。
我曾经踩过的坑:在一个115200波特率的项目中,我用了64字节的环形缓冲区,主循环每10ms才读取一次。结果ISR写入速度远大于读取速度,缓冲区频繁溢出。后来我把缓冲区扩大到512字节,并把主循环读取间隔缩短到1ms,问题才解决。记住:缓冲区大小和读取频率要匹配,不能盲目设大或设小。
3.5 知识结构总览
下面这张图把本章的核心知识点串起来了,你可以对照着梳理思路:
嗯,这一章的内容就到这里。UART中断驱动和环形缓冲区是嵌入式通信的基础设施,你把它搞扎实了,后面学SPI、I2C、CAN的中断驱动会轻松很多。记住:ISR只做数据搬运,缓冲区大小要匹配读取频率,帧协议要带校验。这些原则,在任何一个项目中都适用。