4. UART驱动HAL封装:从轮询到DMA的完整实践

UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发器,复杂在于——你要在嵌入式系统里把它用好,就得面对三种模式的选择:轮询、中断、DMA。每种模式都有它的脾气。

我个人习惯把UART的HAL封装分成三层:硬件访问层、传输控制层、数据缓冲层。今天我们就一层层拆开来看。

本章核心知识点:
  • UART初始化与配置的HAL封装要点
  • 轮询模式——简单但CPU占用高
  • 中断模式——实时性好,适合不定长数据
  • DMA传输模式——大吞吐量的最优解
  • 环形缓冲区设计——解决数据生产与消费的速度匹配
UART HAL 分层架构 应用层 (Application) HAL API 层 (uart_init / uart_send / uart_recv / uart_ioctl) 传输控制层 (轮询模式 | 中断模式 | DMA模式) 数据缓冲层 (环形缓冲区 / DMA双缓冲) 硬件抽象层 (寄存器映射 / 中断向量 / DMA通道) 分层职责 应用层:调用API HAL API:统一接口 传输控制:模式切换 数据缓冲:流量控制 硬件抽象:寄存器级

4.1 UART初始化与配置的HAL封装

初始化这块,我见过太多人直接把寄存器操作扔给应用层。你想想看,如果每个模块都直接操作寄存器,那换芯片的时候得改多少代码?

我的做法是:在HAL层定义一个uart_config_t结构体,把所有可配置参数抽象出来。

/* uart_hal.h - UART HAL层配置结构体 */
typedef struct {
    uint32_t        baud_rate;      /* 波特率:9600, 115200, 921600等 */
    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=硬件流控(RTS/CTS) */
    uint8_t         mode;           /* 模式:0=轮询, 1=中断, 2=DMA */
    uint32_t        rx_buf_size;    /* 接收缓冲区大小 */
    uint32_t        tx_buf_size;    /* 发送缓冲区大小 */
    void (*rx_callback)(uint8_t data);  /* 接收回调函数指针 */
    void (*tx_callback)(void);          /* 发送完成回调 */
} uart_config_t;

初始化函数长这样:

/* uart_hal.c - 初始化实现 */
int uart_init(uart_port_t port, const uart_config_t *config)
{
    if (config == NULL) return -1;

    /* 1. 使能UART外设时钟 */
    uart_clock_enable(port);

    /* 2. 配置GPIO复用为UART功能 */
    uart_gpio_config(port);

    /* 3. 设置波特率 - 内部通过分频器计算 */
    uart_set_baudrate(port, config->baud_rate);

    /* 4. 配置数据格式 */
    uart_set_format(port, config->data_bits,
                    config->stop_bits, config->parity);

    /* 5. 根据模式初始化传输控制层 */
    switch (config->mode) {
        case UART_MODE_POLLING:
            /* 轮询模式不需要额外配置 */
            break;
        case UART_MODE_INTERRUPT:
            uart_interrupt_init(port, config->rx_callback);
            break;
        case UART_MODE_DMA:
            uart_dma_init(port, config->rx_buf_size, config->tx_buf_size);
            break;
    }

    /* 6. 使能UART */
    uart_enable(port);

    return 0;
}
我的经验:波特率配置最容易踩坑。很多芯片的UART时钟源来自APB总线,如果APB时钟改了,波特率会跟着漂。我曾经在一个项目里,系统从72MHz超频到96MHz,结果UART波特率从115200变成了153600——对端设备直接罢工。所以初始化时一定要动态计算分频系数,别写死。

4.2 轮询模式——简单但别滥用

轮询模式说白了就是CPU死等。发送时循环检查TXE(发送寄存器空)标志,接收时循环检查RXNE(接收寄存器非空)标志。

/* 轮询模式发送 */
int uart_polling_send(uart_port_t port, const uint8_t *data, uint32_t len)
{
    for (uint32_t i = 0; i < len; i++) {
        /* 等待发送缓冲区空 */
        while (!(uart_get_status(port) & UART_SR_TXE));
        /* 写入数据 */
        uart_write_data(port, data[i]);
    }
    /* 等待发送完成 */
    while (!(uart_get_status(port) & UART_SR_TC));
    return len;
}

/* 轮询模式接收 */
int uart_polling_recv(uart_port_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms)
{
    uint32_t count = 0;
    uint32_t tick_start = get_tick();

    while (count < len) {
        /* 检查超时 */
        if ((get_tick() - tick_start) > timeout_ms) {
            return -2;  /* 超时返回 */
        }
        /* 检查是否有数据 */
        if (uart_get_status(port) & UART_SR_RXNE) {
            buf[count++] = uart_read_data(port);
            tick_start = get_tick();  /* 收到数据后重置超时 */
        }
    }
    return count;
}
注意:轮询模式在接收时一定要加超时机制。我曾经见过一个产品,接收函数没有超时,结果对端设备掉线后,CPU永远卡在while循环里——整个系统死锁。加个超时,最多返回个错误码,系统还能继续跑。

轮询模式适合什么场景?说白了就是调试阶段和低速率、短数据的场合。比如打印日志、配置命令交互。你要是用轮询模式跑115200波特率、每秒收发几千字节,CPU就别干别的了。

4.3 中断模式——嵌入式的主力选手

中断模式是嵌入式系统里最常用的UART传输方式。它的核心思想是:CPU不等待,数据来了中断通知你。

我建议把中断处理分成两部分:

  • ISR(中断服务函数):只做最轻量的事——读/写数据寄存器,清中断标志
  • 回调函数:在ISR之外执行,处理数据解析、协议组包等耗时操作
/* 中断模式初始化 */
void uart_interrupt_init(uart_port_t port, void (*rx_callback)(uint8_t))
{
    /* 保存回调函数指针 */
    uart_hal_ctx[port].rx_callback = rx_callback;

    /* 使能接收中断 */
    uart_enable_interrupt(port, UART_INT_RXNE);

    /* 配置NVIC优先级 */
    NVIC_SetPriority(uart_get_irqn(port), 2);
    NVIC_EnableIRQ(uart_get_irqn(port));
}

/* UART中断服务函数 */
void UART_IRQHandler(void)
{
    uint32_t status = uart_get_status(UART1);

    /* 接收中断 */
    if (status & UART_SR_RXNE) {
        uint8_t data = uart_read_data(UART1);
        /* 调用回调 - 这里只做数据传递 */
        if (uart_hal_ctx[UART1].rx_callback) {
            uart_hal_ctx[UART1].rx_callback(data);
        }
    }

    /* 发送中断 - 用于发送完一个字节后继续发下一个 */
    if (status & UART_SR_TXE) {
        /* 从发送缓冲区取下一个字节发送 */
        uint8_t next_byte;
        if (ringbuf_read(&uart_hal_ctx[UART1].tx_ring, &next_byte) == 0) {
            uart_write_data(UART1, next_byte);
        } else {
            /* 缓冲区空了,关闭发送中断 */
            uart_disable_interrupt(UART1, UART_INT_TXE);
        }
    }
}
中断模式的关键设计原则:
  • ISR要快进快出,不要在中断里做复杂处理
  • 接收中断和发送中断分开使能,按需开启
  • 发送中断只在有数据要发时才开启,发完立即关闭——这叫"按需中断"

我记得有个项目,同事把AT指令解析直接放在UART中断里,结果中断服务函数执行了5毫秒。系统里还有更高优先级的定时器中断,直接被堵死——电机控制周期从1ms变成了6ms,电机嗡嗡响。后来我把解析移到任务里,中断只做数据入队,问题就解决了。

4.4 DMA传输模式——大吞吐量的最优解

DMA模式,说白了就是让DMA控制器帮你搬数据,CPU可以去做别的事。适合大数据量传输,比如固件升级、高速数据采集。

DMA传输有两种常见配置:

传输方向 DMA通道配置 触发源 传输完成中断
UART RX → 内存 外设到内存,循环模式 UART RXNE事件 半传输完成 + 传输完成
内存 → UART TX 内存到外设,普通模式 UART TXE事件 传输完成
/* DMA模式初始化 */
int uart_dma_init(uart_port_t port, uint32_t rx_buf_size, uint32_t tx_buf_size)
{
    /* 分配DMA缓冲区 */
    uart_hal_ctx[port].dma_rx_buf = malloc(rx_buf_size);
    uart_hal_ctx[port].dma_tx_buf = malloc(tx_buf_size);
    if (!uart_hal_ctx[port].dma_rx_buf || !uart_hal_ctx[port].dma_tx_buf) {
        return -1;  /* 内存分配失败 */
    }

    /* 配置DMA RX通道 - 循环模式 */
    dma_channel_config_t rx_dma_cfg = {
        .src_addr       = (uint32_t)&UART1->DR,
        .dst_addr       = (uint32_t)uart_hal_ctx[port].dma_rx_buf,
        .transfer_size  = rx_buf_size,
        .src_inc        = 0,        /* 外设地址不递增 */
        .dst_inc        = 1,        /* 内存地址递增 */
        .circular       = 1,        /* 循环模式 */
        .half_irq       = 1,        /* 半传输完成中断 */
        .complete_irq   = 1         /* 传输完成中断 */
    };
    dma_channel_init(DMA_CH_RX, &rx_dma_cfg);

    /* 配置DMA TX通道 - 普通模式 */
    dma_channel_config_t tx_dma_cfg = {
        .src_addr       = (uint32_t)uart_hal_ctx[port].dma_tx_buf,
        .dst_addr       = (uint32_t)&UART1->DR,
        .transfer_size  = 0,        /* 初始为0,发送时动态设置 */
        .src_inc        = 1,
        .dst_inc        = 0,
        .circular       = 0,        /* 非循环 */
        .complete_irq   = 1
    };
    dma_channel_init(DMA_CH_TX, &tx_dma_cfg);

    /* 使能UART的DMA请求 */
    uart_enable_dma(port, UART_DMA_RX | UART_DMA_TX);

    return 0;
}
DMA双缓冲技巧:接收使用循环模式时,配合"半传输完成中断"和"传输完成中断",可以实现双缓冲效果。比如缓冲区大小设为1024字节,当DMA写到512字节时触发半传输中断,你处理前半段数据;写到1024字节时触发完成中断,你处理后半段数据。这样数据不会丢失,CPU也有充足时间处理。

4.5 环形缓冲区设计——数据流的"蓄水池"

环形缓冲区,也叫循环队列。它的作用就是解耦数据生产者和消费者。生产者(中断/DMA)往缓冲区写数据,消费者(应用任务)从缓冲区读数据,两者互不阻塞。

我设计环形缓冲区时,最关注三个指标:

  • 缓冲区大小:要能容纳最大数据突发量
  • 读写速度:生产者写入不能阻塞,消费者读取要高效
  • 线程安全:中断和任务之间不能产生竞态
/* 环形缓冲区结构体 */
typedef struct {
    uint8_t     *buffer;        /* 数据缓冲区 */
    uint32_t    size;           /* 缓冲区大小(必须是2的幂) */
    uint32_t    head;           /* 写指针 */
    uint32_t    tail;           /* 读指针 */
    uint32_t    count;          /* 当前数据量 */
} ringbuf_t;

/* 初始化 */
int ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint32_t size)
{
    /* 确保size是2的幂,方便取模运算 */
    if ((size & (size - 1)) != 0) return -1;

    rb->buffer = buf;
    rb->size   = size;
    rb->head   = 0;
    rb->tail   = 0;
    rb->count  = 0;
    return 0;
}

/* 写入一个字节 - 中断中调用 */
int ringbuf_write(ringbuf_t *rb, uint8_t data)
{
    uint32_t next_head;

    /* 计算下一个写位置 */
    next_head = (rb->head + 1) & (rb->size - 1);

    /* 检查是否满 */
    if (next_head == rb->tail) {
        return -1;  /* 缓冲区满,数据丢失 */
    }

    rb->buffer[rb->head] = data;
    /* 内存屏障 - 确保数据写入完成后再更新指针 */
    __DSB();
    rb->head = next_head;
    rb->count++;
    return 0;
}

/* 读取一个字节 - 任务中调用 */
int ringbuf_read(ringbuf_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) {
        return -1;  /* 缓冲区空 */
    }

    *data = rb->buffer[rb->tail];
    __DSB();
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    rb->count--;
    return 0;
}
环形缓冲区避坑指南:
  • 缓冲区大小必须是2的幂:这样可以用位与运算(&)代替取模(%),性能提升明显。我测试过,在Cortex-M4上,取模运算要12个周期,位与只要1个周期。
  • 内存屏障不能省:在Cortex-M系列上,中断和主循环之间共享数据时,编译器可能会优化乱序执行。加上__DSB()或__DMB()确保数据一致性。
  • 满和空的判断要清晰:我习惯用head == tail判空,用(head + 1) % size == tail判满。这样会浪费一个字节的空间,但逻辑清晰,不会混淆。

我曾经在一个物联网网关项目里,UART接收GPS数据,波特率115200,每秒大约输出10KB数据。一开始用中断+环形缓冲区,缓冲区设了2KB。结果GPS模块冷启动时,数据会突然爆发到20KB/s,缓冲区直接溢出。后来我把缓冲区扩大到8KB,并且加了水位告警——当缓冲区使用超过75%时,通知应用层加快处理速度。这才稳住了。

4.6 三种模式的对比与选型建议

特性 轮询模式 中断模式 DMA模式
CPU占用率 高(持续占用) 低(仅中断时占用) 极低(几乎不占用)
实时性 差(受主循环影响) 好(中断响应) 好(硬件自动传输)
数据吞吐量 低(受CPU速度限制) 中(受中断频率限制) 高(接近外设极限)
代码复杂度 低 中 高
适用场景 调试、低速命令交互 通用通信、不定长数据 大数据量、高速传输
缓冲区需求 不需要 需要环形缓冲区 需要DMA缓冲区

选型建议其实很简单:

  • 调试阶段:先用轮询模式,简单可靠,出问题好定位
  • 产品开发:默认用中断模式+环形缓冲区,覆盖90%的场景
  • 大数据量:比如固件OTA、日志记录、高速传感器数据采集,上DMA
我的建议:在HAL层把三种模式都实现好,通过uart_config_t.mode字段切换。这样同一个驱动代码,调试时用轮询,发布时切中断,性能不够时改DMA——不用改应用层代码。这就是HAL抽象的价值。