4、HAL实战:UART驱动抽象:轮询、中断、DMA三种模式的统一接口封装。

UART,串口,嵌入式开发里最常用的外设,没有之一。调试打印、跟传感器通信、跟蓝牙模块对接,哪哪都离不开它。

但有个问题很头疼:不同芯片的UART寄存器不一样。换个MCU,底层驱动就得重写。更麻烦的是,收发数据有三种模式——轮询、中断、DMA。每次切换模式,上层应用代码也得跟着改。

这合理吗?不合理。

所以这一章,我们就来解决这个问题。用HAL(硬件抽象层)把UART的三种模式封装成统一接口。上层只管调用 UART_Read()、UART_Write(),底层具体怎么跑,交给HAL去管。

4.1 为什么需要统一接口?

先看个反面教材。我早期做项目时,代码里到处都是这样的调用:

// 轮询发送
while(!(USART1->SR & USART_SR_TXE));
USART1->DR = data;

// 中断接收
void USART1_IRQHandler(void) {
    if(USART1->SR & USART_SR_RXNE) {
        rx_buf[rx_idx++] = USART1->DR;
    }
}

// DMA发送
DMA1->CPARx = (uint32_t)&USART1->DR;
DMA1->CMARx = (uint32_t)tx_buf;
DMA1->CCRx |= DMA_CCRx_EN;

看着眼熟吧?每个模式一套写法,上层业务逻辑里混着底层寄存器操作。后来换芯片,这些代码全废了,从头改。

统一接口的价值就在这里:把“做什么”和“怎么做”分开。上层只关心“我要发100个字节”,底层决定“用中断发还是DMA发”。

核心目标:

  • 上层调用统一API,不感知底层模式
  • 切换模式只需改初始化参数,不改业务代码
  • 换MCU平台时,只替换HAL层,应用层零改动

4.2 接口设计:三模式统一API

接口怎么设计?我个人习惯用结构体函数指针,或者直接定义一组全局API。这里我选择后者,更直观。

先定义操作句柄:

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

#include <stdint.h>

/* 传输模式枚举 */
typedef enum {
    UART_MODE_POLLING = 0,
    UART_MODE_INTERRUPT,
    UART_MODE_DMA
} uart_mode_t;

/* 配置参数结构体 */
typedef struct {
    uint32_t       baudrate;
    uint8_t        data_bits;    /* 5/6/7/8 */
    uint8_t        stop_bits;    /* 1/2 */
    uint8_t        parity;       /* 0=none, 1=odd, 2=even */
    uart_mode_t    tx_mode;      /* 发送模式 */
    uart_mode_t    rx_mode;      /* 接收模式 */
    uint32_t       tx_buf_size;  /* 中断/DMA用 */
    uint32_t       rx_buf_size;
} uart_config_t;

/* 统一API */
int32_t uart_init(uint8_t port, uart_config_t *cfg);
int32_t uart_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t uart_read(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms);
int32_t uart_ioctl(uint8_t port, uint32_t cmd, void *arg);

#endif

你看,uart_write 和 uart_read 的参数完全一样。不管底层是轮询、中断还是DMA,上层调用方式不变。

uart_ioctl 是扩展接口,用来做控制类操作,比如:

  • 清空接收缓冲区
  • 设置波特率
  • 使能/禁止中断
  • 获取当前状态

4.3 三种模式的实现要点

接口定好了,接下来看底层怎么实现。每种模式的特点和坑,我一个个说。

4.3.1 轮询模式

轮询最简单,CPU死等。适合短数据、低实时性场景。

/* 轮询发送 */
static int32_t uart_polling_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
    uint32_t i;
    uint32_t start_tick = get_tick();

    for(i = 0; i < len; i++) {
        /* 等待发送寄存器空 */
        while(!(UART_REG(port)->SR & UART_SR_TXE)) {
            if((get_tick() - start_tick) > timeout_ms) {
                return -1;  /* 超时 */
            }
        }
        UART_REG(port)->DR = data[i];
    }
    /* 等待发送完成 */
    while(!(UART_REG(port)->SR & UART_SR_TC)) {
        if((get_tick() - start_tick) > timeout_ms) {
            return -2;
        }
    }
    return len;
}

注意:轮询接收时,如果对方没发数据,程序会卡死在等待循环里。所以一定要加超时机制。我曾经有个项目,就因为没加超时,对方死机后整个系统跟着卡死,看门狗都救不回来。

4.3.2 中断模式

中断模式适合不定长数据接收,CPU不用空等。

/* 中断发送:使用环形缓冲区 */
static int32_t uart_irq_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
    /* 把数据写入环形缓冲区 */
    ringbuf_write(&uart_ctx[port].tx_ring, data, len);
    /* 使能发送中断 */
    UART_REG(port)->CR1 |= USART_CR1_TXEIE;
    return len;
}

/* 中断服务函数(由芯片中断向量表调用) */
void uart_irq_handler(uint8_t port) {
    if(UART_REG(port)->SR & USART_SR_TXE) {
        if(ringbuf_read(&uart_ctx[port].tx_ring, &ch) == 0) {
            UART_REG(port)->DR = ch;
        } else {
            /* 数据发完,关中断 */
            UART_REG(port)->CR1 &= ~USART_CR1_TXEIE;
        }
    }
    if(UART_REG(port)->SR & USART_SR_RXNE) {
        ch = UART_REG(port)->DR;
        ringbuf_write(&uart_ctx[port].rx_ring, &ch, 1);
    }
}

中断模式的核心是环形缓冲区。发送时把数据塞进缓冲区,开中断让硬件慢慢发;接收时中断把数据放进缓冲区,上层轮询或等待读取。

避坑指南:我曾经在中断服务函数里调用 printf 做调试,结果递归调用把自己搞死了。记住:ISR里不要做耗时操作,不要调用可能触发中断的函数。

4.3.3 DMA模式

DMA模式适合大数据量、高速传输。CPU只需要配置好,剩下的DMA控制器搞定。

/* DMA发送 */
static int32_t uart_dma_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
    /* 等待上一次DMA传输完成 */
    while(uart_ctx[port].dma_tx_busy) {
        if((get_tick() - uart_ctx[port].start_tick) > timeout_ms) {
            return -1;
        }
    }
    uart_ctx[port].dma_tx_busy = 1;
    uart_ctx[port].start_tick = get_tick();

    /* 配置DMA:外设地址=UART数据寄存器,内存地址=data */
    DMA_Config(port, DMA_DIR_MEM_TO_PERIPH, 
               (uint32_t)&UART_REG(port)->DR, (uint32_t)data, len);
    DMA_Enable(port);
    return len;
}

/* DMA完成中断回调 */
void uart_dma_tx_complete(uint8_t port) {
    uart_ctx[port].dma_tx_busy = 0;
}

DMA模式要注意几个点:

  • 缓冲区对齐:有些DMA控制器要求缓冲区地址按4字节对齐
  • Cache一致性:带Cache的MCU上,DMA和CPU访问同一块内存时,需要做Cache刷新或使用非Cache内存
  • 传输完成标志:DMA传输完成不等于UART发送完成,还要等UART的TC标志

三种模式对比:

特性 轮询 中断 DMA
CPU占用率 高(空等) 中(进出中断) 低(配置完就不管)
实时性 差(阻塞) 好 好
适合场景 短数据、调试 不定长、交互式 大数据块、高速
代码复杂度 低 中 高
内存开销 无 环形缓冲区 DMA描述符+缓冲区

4.4 统一调度:模式切换与回调机制

接口统一了,但底层怎么知道当前用哪种模式?

我的做法是在 uart_init 里根据配置注册不同的函数指针:

/* 函数指针表 */
typedef int32_t (*write_func_t)(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms);
typedef int32_t (*read_func_t)(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms);

static write_func_t write_table[3] = {
    uart_polling_write,   /* UART_MODE_POLLING */
    uart_irq_write,       /* UART_MODE_INTERRUPT */
    uart_dma_write        /* UART_MODE_DMA */
};

static read_func_t read_table[3] = {
    uart_polling_read,
    uart_irq_read,
    uart_dma_read
};

int32_t uart_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
    if(port >= UART_PORT_MAX) return -1;
    uart_mode_t mode = uart_ctx[port].config.tx_mode;
    return write_table[mode](port, data, len, timeout_ms);
}

这样,上层调用 uart_write 时,自动路由到对应的模式实现。想切换模式?改一下 uart_config_t 里的 tx_mode 就行。

另外,中断和DMA模式需要回调机制通知上层数据收发完成。我定义了一组回调函数:

typedef void (*uart_callback_t)(uint8_t port, uint32_t event, void *arg);

/* 事件定义 */
#define UART_EVENT_TX_COMPLETE  0x01
#define UART_EVENT_RX_COMPLETE  0x02
#define UART_EVENT_RX_TIMEOUT   0x03
#define UART_EVENT_ERROR        0xFF

int32_t uart_set_callback(uint8_t port, uart_callback_t cb, void *arg);

上层注册回调后,底层在中断或DMA完成时调用它。这样上层不用轮询,效率更高。

4.5 整体架构图

下面这张图展示了UART HAL层的整体结构:

UART HAL 分层架构 应用层(业务代码) 统一API层 uart_init() | uart_write() | uart_read() | uart_ioctl() 模式调度层(函数指针表路由) 轮询模式 CPU空等,简单直接 中断模式 环形缓冲区+ISR DMA模式 DMA控制器搬运 硬件抽象层(寄存器操作、DMA配置、中断向量) UART_REG() | DMA_Config() | NVIC_EnableIRQ() 硬件层(UART外设) 上层不感知底层模式,切换模式只需修改初始化参数

4.6 使用示例

最后看一个完整的使用例子,感受一下统一接口的便利:

/* main.c */
#include "uart_hal.h"

/* 回调函数 */
void uart_event_cb(uint8_t port, uint32_t event, void *arg) {
    if(event == UART_EVENT_RX_COMPLETE) {
        /* 收到数据,做处理 */
        process_data(port, (uint8_t*)arg);
    }
}

int main(void) {
    uart_config_t cfg;

    /* 配置:中断接收,DMA发送 */
    cfg.baudrate  = 115200;
    cfg.data_bits = 8;
    cfg.stop_bits = 1;
    cfg.parity    = 0;
    cfg.tx_mode   = UART_MODE_DMA;
    cfg.rx_mode   = UART_MODE_INTERRUPT;
    cfg.tx_buf_size = 256;
    cfg.rx_buf_size = 256;

    uart_init(0, &cfg);
    uart_set_callback(0, uart_event_cb, NULL);

    /* 发送数据 - 底层用DMA */
    uint8_t msg[] = "Hello HAL!";
    uart_write(0, msg, sizeof(msg), 1000);

    /* 接收数据 - 底层用中断 */
    uint8_t buf[64];
    uart_read(0, buf, sizeof(buf), 5000);

    while(1) {
        /* 主循环做其他事 */
    }
}

你看,业务代码里完全看不到寄存器操作,也看不到中断或DMA的配置细节。想换模式?改一下 cfg.tx_mode 和 cfg.rx_mode 就行。

个人建议:初期开发调试时用轮询模式,简单可靠。功能稳定后再切到中断或DMA。这样出了问题,先排除硬件和协议问题,再优化性能。