4. UART驱动HAL化:轮询模式、中断模式、DMA模式、环形缓冲区设计

UART,串口,嵌入式开发者的老朋友了。从调试信息到外设通信,几乎每个项目都离不开它。但很多新手写UART驱动,就是操作寄存器,收发各一个buffer,能用就行。项目小还好,一旦系统复杂起来,这种写法会让你吃尽苦头。

我个人习惯,拿到一个新MCU,第一件事就是把UART驱动HAL化。把轮询、中断、DMA三种模式封装好,再配上环形缓冲区。这套东西做好了,后面所有串口相关的开发都会很顺畅。

4.1 为什么UART驱动需要HAL化?

说白了,就是要把“怎么发”和“发什么”分开。你的应用层代码不应该关心当前用的是轮询还是DMA,它只需要调用一个 UART_Send() 函数就够了。

我在项目中遇到过这样的情况:产品初期用轮询模式跑原型,验证功能。到了量产阶段,发现轮询占CPU太多,要改成中断。如果驱动和业务代码耦合在一起,那几乎要重写整个通信模块。但如果一开始就做了HAL抽象,改个配置结构体就行。

HAL化的核心目标:
  • 应用层与硬件解耦:上层只调用标准API
  • 模式切换零成本:轮询/中断/DMA通过配置切换
  • 可移植性:换MCU平台时,只改底层HAL实现

4.2 轮询模式:简单但致命

轮询模式,就是CPU死等。发送时,循环检查发送寄存器是否空;接收时,循环检查有没有数据进来。

// 轮询发送一个字节
void UART_Polling_SendByte(uint8_t data) {
    while (!(UART->SR & UART_SR_TXE));  // 等待发送寄存器空
    UART->DR = data;
}

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

这段代码,每个初学者都写过。它的优点是简单、确定性强。但缺点也很明显:CPU被完全占住。你想想看,如果波特率是115200,接收一个字节大约87微秒,这期间CPU什么都干不了。

我曾经踩过的坑: 在一个多任务系统中,用轮询方式接收GPS数据。GPS每秒输出10条NMEA语句,每条约80字节。轮询接收占用了大量CPU时间,导致其他任务频繁超时。后来改成中断+环形缓冲区,CPU占用率从35%降到了不到1%。

轮询模式适合什么场景?我个人认为,只有两种:一是系统初始化阶段的调试输出,二是对实时性要求极高且数据量极小的场合。其他情况,建议用中断或DMA。

4.3 中断模式:最常用的方案

中断模式,就是让硬件在数据到达或发送完成时通知CPU。CPU不用死等,可以去做其他事情。

中断驱动的UART HAL,通常包含这几个部分:

  • 初始化配置: 设置波特率、数据位、停止位、中断使能
  • 发送中断: 发送寄存器空时触发,从buffer取下一个字节发送
  • 接收中断: 接收到数据时触发,把数据存入接收buffer
  • 错误中断: 帧错误、溢出错误等异常处理
// 中断模式初始化示例
void UART_Interrupt_Init(UART_HandleTypeDef *huart) {
    // 配置GPIO、时钟等(略)
    
    // 使能接收中断
    huart->Instance->CR1 |= UART_CR1_RXNEIE;
    
    // 使能发送完成中断(用于发送)
    huart->Instance->CR1 |= UART_CR1_TCIE;
    
    // 使能全局中断(NVIC)
    NVIC_EnableIRQ(huart->IRQn);
}

// 中断服务函数
void UART_IRQHandler(UART_HandleTypeDef *huart) {
    if (huart->Instance->SR & UART_SR_RXNE) {
        uint8_t data = (uint8_t)(huart->Instance->DR & 0xFF);
        // 将数据存入环形缓冲区
        RingBuf_Write(&huart->rxRingBuf, data);
    }
    
    if (huart->Instance->SR & UART_SR_TC) {
        // 发送完成,检查是否还有数据要发送
        if (RingBuf_GetCount(&huart->txRingBuf) > 0) {
            uint8_t data;
            RingBuf_Read(&huart->txRingBuf, &data);
            huart->Instance->DR = data;
        } else {
            // 没有数据了,关闭发送中断
            huart->Instance->CR1 &= ~UART_CR1_TCIE;
        }
    }
}

这里有个细节要注意:发送中断的触发时机。很多芯片的发送中断是“发送寄存器空”时触发,而不是“发送完成”时触发。这两个概念不一样。发送寄存器空意味着你可以写入下一个字节,但上一个字节可能还没从移位寄存器发完。如果你连续写入太快,会造成数据覆盖。

我的经验: 发送中断最好用“发送完成”中断(TC),而不是“发送寄存器空”中断(TXE)。TC中断保证上一个字节确实已经发出去了,你再写下一个字节,这样更安全。代价是发送速度会略慢一点,但稳定性好很多。

4.4 DMA模式:高性能的终极方案

DMA模式,就是让DMA控制器直接搬运数据,CPU完全不参与数据搬移。CPU只需要告诉DMA:从哪搬,搬到哪,搬多少。然后就可以去睡觉了。

DMA模式特别适合大数据量的UART通信,比如:

  • 通过串口升级固件(一次几KB到几MB)
  • 高速传感器数据采集(如激光雷达点云数据)
  • 与WiFi/蓝牙模块的AT指令通信
// DMA发送配置示例
void UART_DMA_Send(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size) {
    // 配置DMA通道
    // 源地址:pData,目的地址:UART->DR
    // 传输方向:内存到外设
    // 传输大小:Size
    
    // 使能DMA传输完成中断
    // 启动DMA传输
    
    // 使能UART的DMA发送请求
    huart->Instance->CR3 |= UART_CR3_DMAT;
}

// DMA接收配置示例(使用循环模式)
void UART_DMA_Receive_Circular(UART_HandleTypeDef *huart, uint8_t *pBuffer, uint16_t BufferSize) {
    // 配置DMA通道
    // 源地址:UART->DR,目的地址:pBuffer
    // 传输方向:外设到内存
    // 传输大小:BufferSize
    // 模式:循环模式(Circular Mode)
    
    // 使能DMA传输完成中断(半传输和全传输中断)
    // 启动DMA传输
    
    // 使能UART的DMA接收请求
    huart->Instance->CR3 |= UART_CR3_DMAR;
}

DMA模式有个坑:接收时,如果使用循环模式,你需要知道当前DMA写到了哪个位置。通常通过DMA的“当前数据计数器”寄存器来判断。我建议在DMA的半传输和全传输中断中处理数据,这样你有两个时间点来处理接收到的数据,不会丢失。

模式 CPU占用 实时性 数据量 适用场景
轮询 高(100%占用) 高 小(<100字节/秒) 调试输出、简单控制
中断 低(约1-5%) 中 中(<10KB/秒) 通用通信、传感器数据
DMA 极低(<1%) 低(有延迟) 大(>10KB/秒) 固件升级、高速数据流

4.5 环形缓冲区设计:中断和DMA的基石

不管是中断模式还是DMA模式,都离不开缓冲区。而环形缓冲区(Ring Buffer)是嵌入式系统中最经典、最高效的缓冲区设计。

为什么用环形缓冲区?因为它是固定大小的,不会动态分配内存,而且读写操作都是O(1)复杂度。你想想看,如果每次中断都去malloc一个buffer,那系统早就崩了。

// 环形缓冲区结构体
typedef struct {
    uint8_t *buffer;    // 缓冲区内存
    uint32_t head;      // 写指针
    uint32_t tail;      // 读指针
    uint32_t size;      // 缓冲区大小(必须是2的幂)
    uint32_t count;     // 当前数据量
} RingBuf_t;

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

// 写入一个字节
bool RingBuf_Write(RingBuf_t *rb, uint8_t data) {
    if (rb->count >= rb->size) {
        return false;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = (rb->head + 1) & (rb->size - 1);  // 利用位运算实现循环
    rb->count++;
    return true;
}

// 读取一个字节
bool RingBuf_Read(RingBuf_t *rb, uint8_t *data) {
    if (rb->count == 0) {
        return false;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    rb->count--;
    return true;
}

注意看 (rb->head + 1) & (rb->size - 1) 这个操作。它要求缓冲区大小必须是2的幂(如64、128、256、512)。这样可以用位运算代替取模运算,效率高很多。我在项目中习惯用256字节的环形缓冲区,既不会浪费内存,也足够应对大部分场景。

环形缓冲区设计要点:
  • 缓冲区大小必须是2的幂,用位运算实现循环
  • 读写操作必须原子化,或者在关中断下进行
  • 区分“满”和“空”的状态:用count计数,或者留一个空位
  • 中断服务函数中只写不读,应用层只读不写,避免竞争

4.6 三种模式的统一HAL接口

好了,现在三种模式都实现了,环形缓冲区也准备好了。怎么把它们统一成一个HAL接口呢?

我的做法是定义一个配置结构体,里面包含模式选择、缓冲区指针、回调函数等。初始化时根据配置选择不同的底层实现。

// UART HAL配置结构体
typedef enum {
    UART_MODE_POLLING = 0,
    UART_MODE_INTERRUPT,
    UART_MODE_DMA
} UART_Mode_t;

typedef struct {
    UART_TypeDef *Instance;     // UART外设基地址
    UART_Mode_t   Mode;         // 工作模式
    uint32_t      BaudRate;     // 波特率
    RingBuf_t     *pTxRingBuf;  // 发送环形缓冲区
    RingBuf_t     *pRxRingBuf;  // 接收环形缓冲区
    void (*RxCallback)(uint8_t data);  // 接收回调
    void (*TxCompleteCallback)(void);  // 发送完成回调
} UART_Config_t;

// 统一的发送接口
bool UART_Send(UART_HandleTypeDef *huart, uint8_t *pData, uint32_t Size) {
    switch (huart->Config.Mode) {
        case UART_MODE_POLLING:
            // 轮询发送
            for (uint32_t i = 0; i < Size; i++) {
                UART_Polling_SendByte(huart, pData[i]);
            }
            return true;
            
        case UART_MODE_INTERRUPT:
            // 中断发送:将数据写入环形缓冲区,使能发送中断
            for (uint32_t i = 0; i < Size; i++) {
                if (!RingBuf_Write(huart->Config.pTxRingBuf, pData[i])) {
                    return false;  // 缓冲区满
                }
            }
            huart->Instance->CR1 |= UART_CR1_TCIE;
            return true;
            
        case UART_MODE_DMA:
            // DMA发送
            UART_DMA_Send(huart, pData, Size);
            return true;
            
        default:
            return false;
    }
}

这样,应用层调用 UART_Send() 时,完全不需要关心底层是轮询还是DMA。换模式?改一下配置结构体里的 Mode 字段就行。

我个人的习惯: 在调试阶段用轮询模式,方便打断点看数据。功能稳定后,切换到中断模式。如果发现CPU占用过高,再升级到DMA模式。整个过程只需要改一行配置代码,应用层完全不用动。

4.7 知识体系总览

下面这张图,把UART驱动HAL化的整体结构梳理清楚了。你可以把它当作一个设计蓝图。

UART驱动HAL化架构图 应用层(Application) HAL API层(UART_Send / UART_Recv / UART_Init) 模式选择层(配置结构体:Mode字段) 轮询模式 while循环等待 中断模式 IRQ + 环形缓冲区 DMA模式 DMA控制器搬运 硬件抽象层(寄存器操作 / 中断向量 / DMA通道) 物理层(UART外设 / TX/RX引脚) 核心设计思想:应用层 → HAL API → 模式选择 → 硬件抽象 → 物理层,层与层之间通过接口解耦

从这张图可以看得很清楚:应用层在最上面,它只跟HAL API打交道。HAL API根据配置的模式,分发到不同的底层实现。每个底层实现都直接操作硬件寄存器,但对外暴露的接口是完全一致的。

嗯,这就是UART驱动HAL化的精髓。轮询、中断、DMA,三种模式各有优劣,没有银弹。但通过合理的抽象,你可以让它们在同一个框架下和谐共存。环形缓冲区则是连接中断/DMA和应用层的桥梁,设计得好不好,直接影响到系统的稳定性和性能。

我曾经在一个项目中,因为环形缓冲区设计得太小(只有32字节),导致高速数据接收时频繁丢包。后来改成256字节,问题就解决了。所以,缓冲区大小的选择也很关键,要根据你的数据速率和CPU处理能力来权衡。