4. 驱动封装核心方法

UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发器,复杂在于——你要怎么把它用好?

我见过不少工程师,UART驱动写了十几年,还是老一套:轮询收一个字节处理一个,中断来了就丢全局变量。结果呢?数据稍微多点就丢包,CPU占用率还高得离谱。

这一章,咱们就来彻底解决这个问题。从轮询模式讲起,到中断+环形缓冲区,再到DMA传输,最后封装成一个带超时机制的通用UART驱动。每一步我都会结合项目中的实际踩坑经验来讲。

4.1 UART轮询模式:最基础,但别滥用

轮询模式,说白了就是CPU死等。发送一个字节,等发送完成;接收一个字节,等数据到来。

// 轮询发送一个字节
void uart_poll_send(uint8_t data) {
    while (!(UART->SR & UART_SR_TXE));  // 等待发送缓冲区空
    UART->DR = data;
}

// 轮询接收一个字节
uint8_t uart_poll_recv(void) {
    while (!(UART->SR & UART_SR_RXNE)); // 等待接收数据
    return (uint8_t)(UART->DR);
}

这段代码,初学者都能写。但问题在哪?

  • CPU 100%占用:while循环一直在跑,啥也干不了
  • 实时性差:如果数据间隔很短,轮询可能漏掉
  • 功耗高:CPU没法进入休眠
避坑提醒:我曾经在一个低功耗项目里用了轮询接收,结果电池续航直接砍半。后来换成中断+环形缓冲区,功耗降了70%。轮询模式只适合调试阶段,或者极低波特率的简单通信。

4.2 中断模式:解放CPU的第一步

中断模式的核心思想是:让外设主动通知CPU。数据来了,你处理一下;处理完了,继续干你的事。

但中断模式有个新问题——数据怎么存?

你想想看,如果中断来了,你直接把数据丢到一个全局数组里,那主循环和中断之间就存在竞争。主循环在读数组,中断在写数组,数据就乱了。

解决方案就是——环形缓冲区(RingBuffer)。

4.3 环形缓冲区设计与实现

环形缓冲区,说白了就是一个固定大小的数组,加上两个指针:读指针和写指针。

  • 写指针:中断服务函数往里写数据
  • 读指针:主循环或应用层从这读数据
  • 当写指针追上读指针,缓冲区满
  • 当读指针追上写指针,缓冲区空
typedef struct {
    uint8_t *buffer;    // 数据缓冲区
    uint32_t size;      // 缓冲区大小
    volatile uint32_t head;  // 写指针(生产者)
    volatile uint32_t tail;  // 读指针(消费者)
} ring_buffer_t;

// 初始化
void rb_init(ring_buffer_t *rb, uint8_t *buf, uint32_t size) {
    rb->buffer = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
bool rb_write(ring_buffer_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) % rb->size;
    if (next == rb->tail) {
        return false;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return true;
}

// 读取一个字节
bool rb_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;
    return true;
}
设计要点:这里我故意留了一个空位来区分“满”和“空”。head == tail 表示空,head+1 == tail 表示满。这样就不用额外维护一个计数变量,效率更高。我在一个高速数据采集项目里用过这个方案,跑1Mbps的UART,零丢包。

为什么用 volatile 修饰 head 和 tail?因为中断和主循环都会修改它们,编译器可能会优化掉这些变量,导致逻辑错误。这个坑我踩过,调试了一整天才发现。

4.4 UART DMA传输封装

中断模式虽然比轮询好,但高频数据下,中断频繁触发,CPU还是会被频繁打断。这时候就该DMA上场了。

DMA传输,说白了就是数据搬运工。CPU告诉DMA:你把UART接收到的数据搬到这个缓冲区,搬完了通知我。然后CPU就可以去睡觉或者干别的了。

模式 CPU占用 实时性 适用场景
轮询 100% 差 调试、低波特率
中断 中(取决于数据量) 好 中等数据量、不定长
DMA 极低(仅传输完成中断) 极好 大数据量、固定长度
// DMA接收初始化(以STM32为例)
void uart_dma_rx_init(UART_HandleTypeDef *huart, uint8_t *buf, uint32_t len) {
    // 配置DMA
    HAL_UART_Receive_DMA(huart, buf, len);
}

// DMA传输完成回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    // 数据已自动存入buf
    // 通知应用层处理
    uart_dma_data_ready(huart->Instance);
}

DMA模式有个经典问题:怎么处理不定长数据?

我的做法是:DMA开启循环模式(Circular Mode),配合空闲中断(IDLE Interrupt)。当UART总线空闲时,触发中断,此时从DMA缓冲区中读取当前写指针位置,就知道收到了多少数据。

实战经验:我在一个物联网网关项目里,用DMA+空闲中断处理4路UART同时接收数据,每路115200波特率,CPU占用率不到5%。如果用中断模式,CPU占用率至少30%以上。

4.5 封装带超时机制的UART驱动

好了,现在我们把上面这些技术整合起来,封装成一个完整的UART驱动。这个驱动要支持:

  • 轮询模式(调试用)
  • 中断模式+环形缓冲区(通用)
  • DMA模式(高性能)
  • 超时机制(防止死等)
typedef struct {
    UART_TypeDef *instance;     // UART外设基地址
    ring_buffer_t rx_rb;        // 接收环形缓冲区
    ring_buffer_t tx_rb;        // 发送环形缓冲区
    uint32_t timeout_ms;        // 超时时间(毫秒)
    uint32_t last_tick;         // 上次操作时间戳
    uart_mode_t mode;           // 当前模式
} uart_device_t;

// 带超时的接收函数
int32_t uart_recv_timeout(uart_device_t *dev, uint8_t *data, uint32_t timeout_ms) {
    uint32_t start = get_tick_ms();
    
    while (get_tick_ms() - start < timeout_ms) {
        if (rb_read(&dev->rx_rb, data)) {
            return 0;  // 成功
        }
        // 让出CPU,避免忙等
        os_delay(1);
    }
    return -1;  // 超时
}

// 带超时的发送函数
int32_t uart_send_timeout(uart_device_t *dev, const uint8_t *data, uint32_t len, uint32_t timeout_ms) {
    uint32_t start = get_tick_ms();
    uint32_t sent = 0;
    
    while (sent < len) {
        if (get_tick_ms() - start > timeout_ms) {
            return -1;  // 超时
        }
        if (rb_write(&dev->tx_rb, data[sent])) {
            sent++;
            // 触发发送中断或DMA
            uart_tx_trigger(dev->instance);
        } else {
            os_delay(1);
        }
    }
    return 0;
}
超时机制的重要性:我曾经在一个工业控制项目里,没有加超时机制。结果有一次通信线被干扰,程序卡在接收函数里,整个系统死机。从那以后,我所有的驱动函数都加了超时参数,宁可返回错误,也不能死等。

这个驱动封装的核心思想是:上层应用不关心底层是轮询、中断还是DMA。它只需要调用 uart_recv_timeout 和 uart_send_timeout,设置好超时时间,剩下的交给驱动层处理。

下面这张图展示了整个UART驱动的分层架构:

UART驱动分层架构图 应用层(APP) API封装层(带超时机制的收发接口) 模式选择层(轮询 / 中断 / DMA) 轮询模式 中断模式 + RingBuffer DMA模式 硬件抽象层(HAL / 寄存器操作) UART硬件外设

从这张图可以看得很清楚:上层应用只跟API封装层打交道,底层怎么实现,它不关心。这就是分层架构的好处——解耦。

最后,我想说一句:UART驱动看似简单,但真正写好、写稳,需要你对硬件、中断、DMA、数据结构都有深入理解。这一章的内容,是我多年项目经验的总结,希望能帮你少走弯路。