4、HAL实战:UART驱动分层封装——中断、DMA与轮询模式的统一接口设计

UART这个外设,说简单也简单,说复杂也复杂。简单在于它就是个串行收发,复杂在于——你得同时伺候好中断、DMA、轮询三种模式。我早年做第一个物联网网关项目时,就吃过这个亏:应用层代码里到处是 if (mode == DMA) 这种判断,改一个bug要翻三四个文件。后来我痛定思痛,决定用HAL分层把UART驱动彻底重构一遍。

说白了,我们要达成的目标就一个:上层调接口时,根本不用关心底层是中断还是DMA在干活。就像你开车踩油门,不用管发动机是自然吸气还是涡轮增压。

4.1 分层架构:把UART拆成三层

我个人习惯把UART驱动拆成三层,每层各司其职:

层级 职责 典型文件
HAL_UART_LL 直接操作寄存器,最底层 hal_uart_ll.c
HAL_UART_DRV 封装中断/DMA/轮询三种模式 hal_uart_drv.c
HAL_UART_API 对外统一接口,不暴露模式细节 hal_uart_api.c

你想想看,如果应用层直接调LL层,那每次换芯片都得重写一遍。但有了中间这层DRV,换芯片时只需要改LL层,API层纹丝不动。

核心原则:上层只能调用相邻下层接口,不允许跨层调用。API层不能直接调LL层的寄存器操作函数。

4.2 统一接口设计:一个结构体搞定所有模式

怎么让上层无感切换模式?我的做法是定义一个操作句柄结构体,里面放函数指针。初始化时根据配置挂载不同的实现。

/* hal_uart_drv.h */
typedef struct {
    void (*init)(UART_HandleTypeDef *huart);
    void (*deinit)(UART_HandleTypeDef *huart);
    int32_t (*transmit)(UART_HandleTypeDef *huart, 
                        uint8_t *pData, uint16_t Size, 
                        uint32_t Timeout);
    int32_t (*receive)(UART_HandleTypeDef *huart, 
                       uint8_t *pData, uint16_t Size, 
                       uint32_t Timeout);
    void (*irq_handler)(UART_HandleTypeDef *huart);
} UART_DrvOps;

/* 三种模式的实例化 */
extern const UART_DrvOps uart_drv_poll;
extern const UART_DrvOps uart_drv_it;
extern const UART_DrvOps uart_drv_dma;

嗯,这里要注意:irq_handler 这个函数指针,轮询模式里直接赋空函数或者返回,中断和DMA模式才真正实现。我在项目中遇到过有人把轮询模式的IRQ处理函数写成死循环,一进中断就卡死——这种坑踩一次就记住了。

4.3 轮询模式实现:简单但别大意

轮询模式最直接,但有个细节很多人会忽略——超时处理。

/* hal_uart_drv_poll.c */
static int32_t UART_Poll_Transmit(UART_HandleTypeDef *huart,
                                   uint8_t *pData, uint16_t Size,
                                   uint32_t Timeout) {
    uint32_t tickstart = HAL_GetTick();
    uint16_t tx_cnt = 0;

    while (tx_cnt < Size) {
        /* 等待发送数据寄存器空 */
        while (!(huart->Instance->SR & USART_SR_TXE)) {
            if ((HAL_GetTick() - tickstart) > Timeout) {
                return HAL_TIMEOUT;  /* 超时返回 */
            }
        }
        huart->Instance->DR = pData[tx_cnt++];
        tickstart = HAL_GetTick();  /* 重置超时计时 */
    }
    /* 等待最后一次发送完成 */
    while (!(huart->Instance->SR & USART_SR_TC)) {
        if ((HAL_GetTick() - tickstart) > Timeout) {
            return HAL_TIMEOUT;
        }
    }
    return HAL_OK;
}

避坑:我曾经在等待TC标志时忘记重置超时计时,结果发送大数据包时频繁超时。每个字节的等待时间应该独立计时,而不是从函数入口开始算总账。

4.4 中断模式实现:环形缓冲区是关键

中断模式的核心是环形缓冲区。没有它,你处理一个字节就得进一次中断,CPU啥也别干了。

/* hal_uart_drv_it.c */
typedef struct {
    uint8_t buffer[UART_RX_BUF_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} UART_RingBuf;

static UART_RingBuf rx_ring;

static int32_t UART_IT_Receive(UART_HandleTypeDef *huart,
                                uint8_t *pData, uint16_t Size,
                                uint32_t Timeout) {
    uint32_t tickstart = HAL_GetTick();
    uint16_t received = 0;

    while (received < Size) {
        /* 检查环形缓冲区是否有数据 */
        while (rx_ring.head == rx_ring.tail) {
            if ((HAL_GetTick() - tickstart) > Timeout) {
                return HAL_TIMEOUT;
            }
        }
        pData[received++] = rx_ring.buffer[rx_ring.tail];
        rx_ring.tail = (rx_ring.tail + 1) % UART_RX_BUF_SIZE;
    }
    return HAL_OK;
}

中断服务函数里只做一件事:把收到的字节塞进环形缓冲区,然后清标志位。千万别在中断里做复杂处理,我见过有人直接在ISR里调用回调函数去解析协议——结果中断嵌套把自己搞死了。

个人经验:环形缓冲区的head和tail一定要加volatile,否则编译器优化后可能读到缓存值。我调试过一个诡异bug,开了-O2优化后收不到数据,关了就好了——就是volatile没加。

4.5 DMA模式实现:双缓冲是王道

DMA模式适合大数据量传输,但有个问题:DMA传输完成中断触发时,你只有一次机会把数据拿走。如果处理慢了,下一批数据就覆盖了。

我的方案是双缓冲:

/* hal_uart_drv_dma.c */
typedef struct {
    uint8_t buffer_a[UART_DMA_BUF_SIZE];
    uint8_t buffer_b[UART_DMA_BUF_SIZE];
    volatile uint8_t active_buffer;  /* 0: buffer_a, 1: buffer_b */
    void (*rx_complete_cb)(uint8_t *data, uint16_t len);
} UART_DmaContext;

static void UART_DMA_RxComplete(DMA_HandleTypeDef *hdma) {
    UART_DmaContext *ctx = &uart_dma_ctx;
    uint8_t *completed_buf;

    /* 获取刚填满的缓冲区 */
    if (ctx->active_buffer == 0) {
        completed_buf = ctx->buffer_a;
        ctx->active_buffer = 1;
        /* 立即切换DMA目标到buffer_b */
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, ctx->buffer_b, UART_DMA_BUF_SIZE);
    } else {
        completed_buf = ctx->buffer_b;
        ctx->active_buffer = 0;
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, ctx->buffer_a, UART_DMA_BUF_SIZE);
    }

    /* 回调通知上层处理 */
    if (ctx->rx_complete_cb) {
        ctx->rx_complete_cb(completed_buf, UART_DMA_BUF_SIZE);
    }
}

这样DMA永远有一个缓冲区在后台收数据,上层处理另一个缓冲区时不会丢数据。我在一个4G模块的AT指令解析项目里用这个方案,115200波特率下跑满负荷,一包数据都没丢过。

4.6 统一接口的最终呈现

有了上面这些,API层就清爽了:

/* hal_uart_api.c */
static const UART_DrvOps *drv_ops;

int32_t HAL_UART_Init(UART_HandleTypeDef *huart, UART_Mode mode) {
    switch (mode) {
        case UART_MODE_POLL: drv_ops = &uart_drv_poll; break;
        case UART_MODE_IT:   drv_ops = &uart_drv_it;   break;
        case UART_MODE_DMA:  drv_ops = &uart_drv_dma;  break;
        default: return HAL_ERROR;
    }
    return drv_ops->init(huart);
}

int32_t HAL_UART_Transmit(UART_HandleTypeDef *huart,
                           uint8_t *pData, uint16_t Size,
                           uint32_t Timeout) {
    return drv_ops->transmit(huart, pData, Size, Timeout);
}

int32_t HAL_UART_Receive(UART_HandleTypeDef *huart,
                          uint8_t *pData, uint16_t Size,
                          uint32_t Timeout) {
    return drv_ops->receive(huart, pData, Size, Timeout);
}

应用层调 HAL_UART_Transmit() 时,根本不知道底层是轮询还是DMA。换模式只需要改初始化时的参数,其他代码一行不动。

设计精髓:函数指针表 + 编译期常量实例化。既保留了C语言的性能,又实现了类似C++虚函数的多态效果。在资源受限的MCU上,这是最优雅的解法。

4.7 三种模式的选择策略

最后说说怎么选。我一般按这个原则:

  • 轮询模式:调试阶段、低波特率(9600以下)、短数据包(几个字节)。简单可靠,但别用在产品里。
  • 中断模式:中等数据量、不定长接收、需要低延迟响应。我80%的项目用这个。
  • DMA模式:大数据量(几百字节以上)、固定长度传输、CPU负载敏感的场景。比如OTA升级、日志输出。

我曾经在一个项目里把三种模式都暴露给应用层,让产品经理根据场景配置——结果他们全选了中断模式,因为最简单。所以,接口统一了,选择权交给上层,但默认值要选最稳妥的那个。

UART驱动分层封装架构图 应用层 (Application) HAL_UART_API (统一接口层) 轮询模式 (Poll) 中断模式 (IT) DMA模式 HAL_UART_LL (寄存器操作层) UART硬件外设 核心设计:函数指针表 + 编译期常量实例化,实现C语言层面的多态

好了,这一套分层封装下来,UART驱动就变成了可插拔的积木。下次换芯片?重写LL层就行。换模式?改初始化参数就行。应用层的同事再也不用追着你问“这个接口在中断模式下能不能用”了。