第三讲: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 实际项目中的经验总结

最后,分享几个我在实际项目中踩过的坑:

  1. 缓冲区大小要合理。太小容易丢数据,太大浪费内存。我一般根据波特率和主循环响应时间来估算。比如115200波特率,主循环10ms响应一次,缓冲区至少需要115200/8/100 ≈ 144字节。我通常会取256。
  2. 注意临界区保护。如果主循环和中断同时操作环形缓冲区,一定要关中断或使用原子操作。我习惯在ring_buf_getring_buf_put内部用__disable_irq()__enable_irq()保护。
  3. 发送缓冲区也要用环形缓冲区。很多初学者只给接收做缓冲区,发送直接阻塞等待。这在低波特率下会严重拖慢系统响应。

嗯,这一讲的内容就到这里。环形缓冲区加中断驱动,说白了就是「用空间换时间,用中断换效率」。你把这个模型吃透了,不光UART,SPI、I2C的驱动都可以用同样的思路来封装。

下一讲,我们会在这个基础上,加入DMA传输,让UART的吞吐量再上一个台阶。到时候你会发现,原来驱动可以写得这么优雅。

课后思考:如果环形缓冲区的大小不是2的幂,位运算就不能用了。你会怎么处理取模运算?有没有其他优化方法?


公众号:蓝海资料掘金营,微信deep3321