3、UART中断驱动设计:ISR编写规范与环形缓冲区实现

好,咱们进入第三章。这一章可以说是整个UART驱动的心脏——中断服务函数(ISR)怎么写,数据怎么安全地搬进搬出。我见过太多工程师在这两个地方栽跟头,要么ISR里干了不该干的活,要么缓冲区设计得漏洞百出。今天咱们就把这些坑一个个填平。

3.1 中断服务函数(ISR)编写规范

先说说ISR。很多人觉得ISR不就是个回调函数嘛,收到中断就进去处理一下。其实没那么简单。在RTOS环境下,ISR的写法直接决定了系统的实时性和稳定性。

3.1.1 ISR的核心原则:快进快出

ISR里最忌讳什么?拖泥带水。你想想看,中断一来,CPU正在跑的任务就被打断了。如果你在ISR里磨蹭太久,其他中断就会排队等着,高优先级的事件可能就错过了。

ISR黄金法则:

  • 只做最必要的事——读取数据、清除中断标志、把数据塞进缓冲区
  • 绝对不要调用阻塞函数——比如printf、malloc、延时函数
  • 绝对不要尝试在ISR里等待信号量或互斥锁——那是找死

我个人习惯把ISR分成三部分:

  1. 入口:保存上下文,判断中断源
  2. 核心:读取硬件寄存器,搬运数据到缓冲区
  3. 出口:清除中断标志,触发任务级处理

3.1.2 典型UART ISR代码模板

下面给一个我在FreeRTOS项目里常用的UART接收ISR模板。注意看,ISR里只做数据搬运,真正的协议解析放在任务里做。

// UART接收中断服务函数
void USART1_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    uint8_t data;

    // 1. 检查中断源:接收非空
    if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        // 2. 读取数据寄存器(硬件自动清除RXNE标志)
        data = USART_ReceiveData(USART1);

        // 3. 写入环形缓冲区
        ring_buffer_write(&uart1_rx_buf, data);

        // 4. 通知接收任务(如果它在等待)
        vTaskNotifyGiveFromISR(xUartRxTaskHandle,
                               &xHigherPriorityTaskWoken);
    }

    // 5. 检查中断源:发送完成
    if (USART_GetITStatus(USART1, USART_IT_TC) != RESET)
    {
        USART_ClearITPendingBit(USART1, USART_IT_TC);
        // 发送完成,可以继续发送下一帧
        xSemaphoreGiveFromISR(xTxCompleteSemaphore,
                              &xHigherPriorityTaskWoken);
    }

    // 6. 退出ISR前做上下文切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

我曾经踩过的坑: 有一次在ISR里直接调了printf来调试,结果系统直接死机。后来查了半天才发现,printf内部用了互斥锁,ISR里拿锁直接导致优先级反转死锁。从那以后,我ISR里连log都不敢写了,最多写一个全局标志位,等任务去查。

3.1.3 ISR与任务的通信机制

ISR处理完数据后,怎么通知任务?RTOS提供了几种安全的方式:

通信方式 适用场景 注意事项
任务通知(Task Notification) 单任务等待,轻量级 效率最高,推荐优先使用
二值信号量 任务与ISR同步 注意信号量溢出问题
消息队列 传递数据块 队列长度要够,防止溢出
全局标志位 简单通知,不传递数据 需要volatile修饰,注意原子性

我个人偏好任务通知,因为它不需要额外的内核对象,速度也最快。但如果你需要传递多个数据,消息队列更合适。

3.2 环形缓冲区(Ring Buffer)实现

好,ISR把数据读出来了,放哪儿?直接放全局数组?那肯定不行。ISR和任务同时访问同一个数组,不加保护的话,数据就乱套了。环形缓冲区就是解决这个问题的经典方案。

3.2.1 为什么用环形缓冲区

说白了,环形缓冲区就是一个固定大小的数组,加上两个指针:一个写指针(生产者用),一个读指针(消费者用)。数据写满了就从头开始覆盖,形成一个环。这样做的好处是:

  • 无锁设计:单生产者(ISR)单消费者(任务)场景下,不需要互斥锁
  • 固定内存:不会动态分配,适合嵌入式环境
  • FIFO顺序:先收到的数据先被处理

3.2.2 数据结构设计

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

typedef struct {
    uint8_t *buffer;    // 数据缓冲区指针
    uint32_t size;      // 缓冲区大小(必须是2的幂)
    volatile uint32_t head;  // 写指针(ISR写入位置)
    volatile uint32_t tail;  // 读指针(任务读取位置)
} ring_buffer_t;

注意看,head和tail我都加了volatile关键字。为什么?因为这两个变量会被ISR和任务同时访问,编译器可能会优化掉对它们的读取,导致数据不同步。这个细节我当年吃过亏,调试了一整天才发现。

3.2.3 核心操作实现

下面给出写入和读取的代码。这里有个小技巧:缓冲区大小用2的幂,这样取模运算可以用位与代替,速度更快。

// 初始化环形缓冲区
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buf, uint32_t size)
{
    rb->buffer = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节(ISR中调用)
bool ring_buffer_write(ring_buffer_t *rb, uint8_t data)
{
    uint32_t next_head = (rb->head + 1) & (rb->size - 1);

    // 检查是否满(head追上tail)
    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) & (rb->size - 1);
    return true;
}

// 查询当前数据量
uint32_t ring_buffer_available(ring_buffer_t *rb)
{
    return (rb->head - rb->tail) & (rb->size - 1);
}

设计要点: 我故意留了一个空位不用,用来区分“满”和“空”。如果head == tail表示空,head + 1 == tail表示满。这样就不需要额外的计数变量了,也避免了ISR和任务同时修改计数器的竞争问题。

3.2.4 缓冲区大小的选择

缓冲区设多大?太小了容易丢数据,太大了浪费内存。我一般按这个公式估算:

缓冲区大小 = 最大中断间隔时间 × 波特率 / 8 × 2

举个例子:波特率115200,最大中断间隔1ms,那么:

  • 1ms内最多收到:115200 / 1000 / 8 ≈ 14字节
  • 缓冲区大小:14 × 2 = 28字节,取2的幂就是32字节

当然,这只是理论值。实际项目中我会留一些余量,比如设成64或128字节。

3.3 整体架构与数据流

下面这张图展示了UART中断驱动的完整数据流。从硬件接收到数据,到ISR搬运,再到任务处理,每一步都清晰可见。

UART中断驱动数据流架构图 UART中断驱动数据流架构 UART外设 硬件接收/发送 中断触发 中断服务函数 读取数据·清除标志 写入数据 环形缓冲区 head → tail 无锁FIFO 任务读取 接收处理任务 协议解析·业务处理 任务通知/信号量 关键设计原则 1. ISR只做数据搬运,不做协议解析 2. 环形缓冲区实现无锁通信,head和tail必须加volatile 3. 缓冲区大小取2的幂,用位与代替取模提高效率 4. ISR通过任务通知或信号量唤醒接收任务,不直接处理数据 5. 缓冲区满时选择丢新数据(覆盖)还是丢旧数据,根据业务决定

3.4 常见问题与避坑指南

最后,我把自己这些年踩过的坑总结一下,希望能帮你少走弯路。

坑1:ISR里调用阻塞函数

我曾经在一个项目里,ISR中调用了vTaskDelay想做个延时滤波,结果系统直接挂掉。记住,ISR里绝对不能调用任何可能阻塞的API,包括延时、信号量等待、队列发送(除非使用FromISR版本)。

坑2:环形缓冲区溢出处理不当

缓冲区满了怎么办?两种策略:丢旧数据或丢新数据。我建议根据业务场景选择。如果是传感器数据流,丢旧数据更合理;如果是命令帧,丢新数据更安全。但不管哪种,一定要加溢出计数,方便调试时定位问题。

坑3:忘记清除中断标志

这个错误我犯过不止一次。有些MCU的UART中断标志是硬件自动清除的(比如读数据寄存器时自动清除RXNE),但有些需要软件手动清除。不清除的话,ISR会被反复触发,系统直接卡死在中断里。建议写代码前先查清楚数据手册。