4. UART中断接收实现:单字节中断接收、环形缓冲区设计、临界区保护

好,咱们进入第四章。这一章我打算把UART中断接收的底裤都扒干净。很多工程师写中断服务程序,就是进中断、读数据、存数组、清标志、退出。简单场景下没问题,一旦数据量上来,或者系统里还有其他高优先级中断,这种写法就会出大问题。

我个人习惯,做嵌入式通信第一件事就是设计好接收缓冲区。没有缓冲区的中断接收,就像没有蓄水池的自来水厂——来水猛了就漫一地。

4.1 单字节中断接收的底层逻辑

先看最基础的单字节中断接收。UART外设在收到一个字节后,硬件会置位RXNE(接收非空)标志位。如果使能了接收中断,CPU就会跳转到中断向量表,执行对应的ISR。

代码长这样:

void USART1_IRQHandler(void)
{
    if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        uint8_t data = USART_ReceiveData(USART1);
        // 这里直接处理data?别急,往下看
    }
}

这里有个坑。USART_ReceiveData()这个函数,读的其实是DR寄存器。但DR寄存器在硬件上分两层:RDR(接收数据寄存器)和移位寄存器。你读DR的时候,硬件自动把RDR清空,同时把移位寄存器里的下一个字节搬进RDR。如果你读慢了,下一个字节已经移位完成,RDR还没被读走——那就溢出。

溢出陷阱:当RDR中的数据还没被读走,新的字节又完成了移位,硬件会置位ORE(溢出错误)标志。此时RDR里的数据会被新数据覆盖,旧数据永久丢失。

我在一个工业数据采集项目里遇到过这个问题。当时波特率115200,每80微秒来一个字节。ISR里做了个简单的协议解析,结果高负载时频繁丢包。查了两天才发现是ISR执行时间超过了字节间隔,导致ORE溢出。后来改成ISR只做数据搬运,协议解析放到任务级处理,问题就解决了。

4.2 环形缓冲区设计

解决上面问题的标准方案,就是环形缓冲区。说白了,就是一块固定大小的内存,用两个指针(读指针和写指针)来管理。写指针追着读指针跑,形成一个环。

我常用的环形缓冲区结构体:

typedef struct {
    uint8_t buffer[RING_BUFFER_SIZE];
    volatile uint16_t head;   // 写指针,ISR中更新
    volatile uint16_t tail;   // 读指针,任务中更新
} ring_buffer_t;

为什么head和tail要加volatile?因为head在中断上下文被修改,tail在任务上下文被修改,编译器可能会优化掉对这两个变量的读取,导致逻辑错误。我见过有人不加volatile,调试时开了优化就出鬼畜现象。

写入操作(在ISR中执行):

bool ring_buffer_write(ring_buffer_t *rb, uint8_t data)
{
    uint16_t next_head = (rb->head + 1) & (RING_BUFFER_SIZE - 1);
    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 - 1);
    return true;
}
关键设计点:缓冲区大小必须是2的幂次方。这样可以用位与运算(&)代替取模运算(%),效率高一个数量级。我一般用256字节,256-1=0xFF,位与操作只需要一条指令。

你想想看,如果缓冲区大小是256,head和tail都是uint16_t,那么(head + 1) & 0xFF这个操作,永远不会溢出,也永远不会超过255。这就是环形缓冲区最优雅的地方——没有边界检查的边界检查。

4.3 临界区保护

环形缓冲区写得好,但还有一个致命问题:head和tail的访问不是原子的。

假设这种情况:任务正在读数据,执行到rb->tail = (rb->tail + 1) & mask,刚更新完tail,还没读完数据。这时候中断来了,ISR写了一个字节,更新了head。中断返回后,任务继续执行,但此时tail已经被修改过了,读到的数据可能不对。

这就是临界区问题。解决方案很简单——在访问共享资源时关中断。

uint8_t read_byte_from_ring(ring_buffer_t *rb, bool *ok)
{
    uint8_t data = 0;
    __disable_irq();  // 进入临界区
    if(rb->head != rb->tail) {
        data = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) & (RING_BUFFER_SIZE - 1);
        *ok = true;
    } else {
        *ok = false;
    }
    __enable_irq();  // 退出临界区
    return data;
}
我的习惯:临界区保护的时间越短越好。关中断只保护对共享变量的访问,不要在临界区里做协议解析、打印日志等耗时操作。我曾经在一个项目里看到有人把整个协议栈都放在关中断的区间里,结果系统响应延迟从10微秒飙升到2毫秒。

还有一个细节:有些MCU架构(比如Cortex-M3/M4)支持LDREX/STREX指令,可以实现无锁编程。但说实话,在UART这种场景下,关中断的开销已经足够小,没必要搞复杂。除非你的系统对中断延迟有极端要求(比如1微秒以内),否则老老实实关中断就行。

4.4 完整的中断接收流程

把上面三块拼起来,就是完整的UART中断接收方案。我画了一张流程图,你看一眼就明白了。

UART中断接收完整流程 UART硬件接收1字节 RXNE中断触发 ISR:读取DR寄存器 环形缓冲区是否满? 是 丢弃数据,置溢出标志 否 写入缓冲区,更新head ISR退出,恢复现场

这张图里,我特意把“缓冲区满”的分支画出来了。很多初学者不处理这个分支,结果缓冲区溢出时数据静默丢失。我建议你在调试阶段,把溢出标志打印出来或者用GPIO拉高来指示,这样能及时发现缓冲区大小是否够用。

4.5 性能指标与配置建议

最后给一组实际数据,帮你做设计决策。这是我基于Cortex-M4 @168MHz测出来的:

操作 执行时间(CPU周期) @168MHz耗时
中断响应(压栈+跳转) 12 71ns
读取DR寄存器 2 12ns
环形缓冲区写入(含满判断) 8 48ns
关中断+开中断 4 24ns
ISR总耗时 ~30 ~180ns
结论:在168MHz主频下,单字节中断接收的ISR总耗时约180ns。以115200波特率(每字节约87μs)计算,ISR只占用了0.2%的CPU时间。即使跑到921600波特率(每字节约10.9μs),ISR也只占用1.7%的CPU时间。所以,对于大多数应用场景,中断接收完全够用。

嗯,这里要注意:上面的数据是理想情况。如果你的ISR里做了其他操作(比如协议解析、内存拷贝、打印日志),时间会成倍增加。我建议你把ISR做得尽可能轻量,只做数据搬运和标志更新,其他事情交给任务级代码去处理。

好了,这一章的核心内容就这些。环形缓冲区加临界区保护,是嵌入式通信的基石。你把这个搞透了,后面讲DMA传输时,你会发现很多思路是相通的。