4、HAL接口标准化:UART接口标准化设计——阻塞、中断与DMA模式封装

UART,串口,嵌入式开发里最常用的外设,没有之一。调试打印、传感器通信、AT指令交互,哪哪都离不开它。但问题也来了——不同芯片的UART寄存器长得完全不一样,驱动代码经常被写死在一个项目里,换个MCU就得重写。

我早年吃过这个亏。一个产品从STM32换到GD32,UART底层几乎全改,上层协议栈也跟着遭殃。从那以后,我下定决心:必须把UART接口标准化,让上层代码永远不碰寄存器。

这一章,我们就来聊聊UART的HAL接口怎么设计。核心思路就一句话:把阻塞、中断、DMA三种模式封装成统一API,上层只管收发,底层随便换。

4.1 为什么UART接口需要标准化?

你想想看,一个典型的UART收发流程:

  • 配置波特率、数据位、停止位、校验位
  • 发送一个字节或一包数据
  • 接收数据,可能用轮询、中断或DMA
  • 处理错误(溢出、帧错误、校验错误)

不同MCU的寄存器操作天差地别。但上层应用在乎这些吗?不在乎。它只想知道:发出去没有?收到没有?出错了没有?

所以,标准化的目标就是:定义一套与硬件无关的UART操作接口,底层实现可以随时替换。

核心原则:接口稳定,实现可变。上层代码只依赖接口,不依赖具体芯片。

4.2 接口设计:三模式统一抽象

我个人习惯把UART操作分成三个层次:

  1. 配置层:波特率、模式、流控等参数设置
  2. 收发层:阻塞、中断、DMA三种模式的发送/接收
  3. 控制层:中断使能、DMA通道绑定、错误处理

下面这张图展示了整体架构:

UART HAL接口标准化架构 应用层(协议栈/业务逻辑) UART标准化API接口 uart_init / uart_send / uart_recv / uart_ioctl 阻塞模式 轮询发送/接收 中断模式 回调驱动收发 DMA模式 内存直接搬运 硬件抽象层(寄存器操作 / 中断向量 / DMA控制器) 上层不关心底层用哪种模式,只调用统一API

4.3 接口定义:头文件里的契约

先看接口定义。我习惯用一个结构体把UART设备抽象出来:

/* uart_hal.h */
#ifndef UART_HAL_H
#define UART_HAL_H

#include <stdint.h>
#include <stddef.h>

/* UART配置参数 */
typedef struct {
    uint32_t baudrate;      /* 波特率 */
    uint8_t  data_bits;     /* 数据位:5/6/7/8 */
    uint8_t  stop_bits;     /* 停止位:1/2 */
    uint8_t  parity;        /* 校验:0无/1奇/2偶 */
    uint8_t  flow_ctrl;     /* 流控:0无/1硬件 */
} uart_config_t;

/* 收发模式枚举 */
typedef enum {
    UART_MODE_BLOCKING = 0, /* 阻塞模式 */
    UART_MODE_INTERRUPT,    /* 中断模式 */
    UART_MODE_DMA          /* DMA模式 */
} uart_mode_t;

/* 回调函数类型 */
typedef void (*uart_rx_cb_t)(uint8_t data);
typedef void (*uart_tx_cb_t)(void);
typedef void (*uart_err_cb_t)(uint32_t err_flags);

/* UART设备句柄 */
typedef struct {
    uint8_t      port_id;       /* 端口号 */
    uart_mode_t  mode;          /* 当前模式 */
    uart_config_t config;       /* 配置参数 */
    void*        hw_regs;       /* 硬件寄存器基址 */
    uart_rx_cb_t rx_callback;   /* 接收回调 */
    uart_tx_cb_t tx_callback;   /* 发送完成回调 */
    uart_err_cb_t err_callback; /* 错误回调 */
} uart_handle_t;

/* 标准API */
int uart_init(uart_handle_t* handle, const uart_config_t* config);
int uart_send(uart_handle_t* handle, const uint8_t* data, size_t len);
int uart_recv(uart_handle_t* handle, uint8_t* buffer, size_t len);
int uart_ioctl(uart_handle_t* handle, uint32_t cmd, void* arg);

#endif /* UART_HAL_H */

这里有几个设计要点:

  • 句柄模式:所有操作都通过句柄,支持多实例
  • 回调分离:中断和DMA模式依赖回调,阻塞模式不用
  • ioctl扩展:特殊控制(如流控开关、中断使能)通过ioctl统一入口

我的经验:ioctl的cmd参数建议用枚举+位域,不要用魔数。我曾经见过一个项目用0x01、0x02这种数字,三个月后没人记得0x03是什么意思。

4.4 阻塞模式实现:简单但危险

阻塞模式最直接,发送时CPU死等,接收时轮询。适合低负载场景。

/* 阻塞模式发送 */
int uart_send_blocking(uart_handle_t* handle, const uint8_t* data, size_t len) {
    for (size_t i = 0; i < len; i++) {
        /* 等待发送寄存器空 */
        while (!(UART_SR(handle) & UART_SR_TXE));
        /* 写入数据 */
        UART_DR(handle) = data[i];
    }
    /* 等待发送完成 */
    while (!(UART_SR(handle) & UART_SR_TC));
    return 0;
}

/* 阻塞模式接收 */
int uart_recv_blocking(uart_handle_t* handle, uint8_t* buffer, size_t len) {
    for (size_t i = 0; i < len; i++) {
        /* 等待接收数据 */
        while (!(UART_SR(handle) & UART_SR_RXNE));
        buffer[i] = UART_DR(handle);
    }
    return 0;
}

警告:阻塞模式在RTOS环境下会挂起整个任务。如果接收超时,系统可能死锁。建议加超时机制,或者干脆不用阻塞模式做长数据收发。

4.5 中断模式实现:事件驱动

中断模式是嵌入式开发的标配。发送用中断逐字节,接收用中断缓存。

/* 中断模式发送 */
int uart_send_interrupt(uart_handle_t* handle, const uint8_t* data, size_t len) {
    /* 保存数据到发送缓冲区 */
    handle->tx_buf = data;
    handle->tx_len = len;
    handle->tx_idx = 0;
    
    /* 使能发送中断 */
    UART_CR(handle) |= UART_CR_TXEIE;
    
    /* 触发第一个字节发送 */
    UART_DR(handle) = handle->tx_buf[handle->tx_idx++];
    return 0;
}

/* 发送中断服务函数 */
void uart_tx_isr(uart_handle_t* handle) {
    if (handle->tx_idx < handle->tx_len) {
        UART_DR(handle) = handle->tx_buf[handle->tx_idx++];
    } else {
        /* 发送完成,关闭中断 */
        UART_CR(handle) &= ~UART_CR_TXEIE;
        if (handle->tx_callback) {
            handle->tx_callback();
        }
    }
}

接收中断类似,但需要环形缓冲区来缓存数据。我习惯用双缓冲,一个给ISR写,一个给应用读。

4.6 DMA模式实现:零CPU干预

DMA模式是性能王者。CPU只需要配置一次,DMA控制器自动搬运数据。

/* DMA模式发送 */
int uart_send_dma(uart_handle_t* handle, const uint8_t* data, size_t len) {
    /* 配置DMA通道 */
    dma_channel_config_t dma_cfg;
    dma_cfg.src_addr  = (uint32_t)data;
    dma_cfg.dst_addr  = (uint32_t)&UART_DR(handle);
    dma_cfg.transfer_size = len;
    dma_cfg.src_inc   = DMA_INC_ENABLE;   /* 源地址递增 */
    dma_cfg.dst_inc   = DMA_INC_DISABLE;  /* 目标地址固定 */
    
    dma_config(handle->dma_tx_ch, &dma_cfg);
    dma_start(handle->dma_tx_ch);
    
    /* 使能UART的DMA发送 */
    UART_CR(handle) |= UART_CR_DMAT;
    return 0;
}

/* DMA完成回调 */
void uart_dma_tx_complete(uart_handle_t* handle) {
    /* 关闭DMA使能 */
    UART_CR(handle) &= ~UART_CR_DMAT;
    if (handle->tx_callback) {
        handle->tx_callback();
    }
}

性能对比:发送1KB数据,阻塞模式约消耗CPU 100%时间,中断模式约5%,DMA模式接近0%。但DMA有启动延迟,短数据(<16字节)用中断反而更快。

4.7 三种模式对比与选择

特性 阻塞模式 中断模式 DMA模式
CPU占用率 高(100%轮询) 低(仅中断处理) 极低(硬件搬运)
实时性 差(阻塞等待) 好(事件驱动) 好(后台运行)
代码复杂度 低 中(需环形缓冲) 高(DMA配置)
适用场景 调试、短数据 通用收发 大数据、高速率
内存开销 无 中(缓冲区) 低(DMA自动管理)

怎么选?我一般这样判断:

  • 调试打印、初始化配置 → 阻塞模式,简单直接
  • 传感器数据、AT指令交互 → 中断模式,平衡性能与复杂度
  • 音频流、高速日志、文件传输 → DMA模式,CPU解放出来干正事

4.8 避坑指南:我踩过的雷

做UART标准化这么多年,有些坑说出来都是泪:

  • 波特率误差:不同晶振下波特率计算可能不准。我遇到过标称115200实际只有114800的情况,通信偶尔丢字节。解决方案:用整数分频,或者加自动波特率检测。
  • 中断嵌套:高波特率下中断频繁,如果中断优先级设置不当,可能丢失数据。建议UART中断优先级设高,且不要在中断里做耗时操作。
  • DMA缓存对齐:有些DMA控制器要求源地址和长度对齐到4字节。我曾经传一个奇数长度的包,DMA直接卡死。解决方案:检查芯片手册,必要时做内存对齐拷贝。
  • 环形缓冲区溢出:中断模式下如果应用层读得慢,缓冲区会溢出。建议加水位告警,或者用流控通知对端暂停发送。

曾经有一次,我在一个产品里用了DMA+环形缓冲区的组合,结果DMA传输完成中断和UART空闲中断同时触发,导致数据重复读取。后来加了互斥标志才解决。记住:中断共享资源一定要保护。

4.9 小结

UART接口标准化的核心,就是把阻塞、中断、DMA三种模式封装成统一API。上层代码永远调用uart_send和uart_recv,底层实现随便换。这样,换MCU时只需要重写底层驱动,上层协议栈纹丝不动。