4、UART接口规范:UART初始化、发送接收、DMA支持、流控制

UART 这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发器,复杂在于——嗯,你一旦要把它放到 RTOS 里,还要考虑 DMA、流控、多任务并发,坑就来了。

我个人习惯把 UART 的 HAL 层分成四个维度:初始化配置、收发模型、DMA 集成、硬件流控。今天咱们一个一个拆开讲。

核心原则:UART 的 HAL 接口必须做到“任务级无锁”,所有底层寄存器操作都在临界区保护下完成,但上层 API 对用户线程是阻塞或回调的。

4.1 初始化接口设计

初始化是第一步,也是最容易出问题的一步。我在项目中遇到过好几次,芯片上电后 UART 没反应,查了半天发现是时钟没使能——这种低级错误,说白了就是初始化流程没规范好。

我建议的初始化接口长这样:

typedef struct {
    uint32_t    baudrate;       // 波特率,如 115200
    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     dma_tx;         // TX DMA 通道号,0=禁用
    uint8_t     dma_rx;         // RX DMA 通道号,0=禁用
    uint32_t    rx_timeout_ms;  // 接收超时(毫秒)
} uart_config_t;

int uart_init(uart_port_t port, const uart_config_t *cfg);

这里有个细节:rx_timeout_ms 很多人会忽略。你想想看,如果接收端一直等数据,但对方发了一半停了,你的任务就卡死了。所以超时参数必须暴露给用户。

参数 典型值 说明
baudrate 115200 / 921600 根据总线负载选择,高速时注意信号完整性
data_bits 8 绝大多数场景用 8 位,7 位用于老协议
stop_bits 1 2 位停止位用于抗干扰差的链路
parity 0 工业总线常用偶校验,消费类多用无校验
注意:初始化函数内部必须做“去初始化”动作。如果之前已经初始化过,先反初始化再重新配置。否则连续调用两次 uart_init 会导致资源泄漏。

4.2 发送与接收模型

收发接口的设计,我见过两种流派:阻塞式和回调式。我的建议是——都提供,但底层用同一套机制。

阻塞式接口适合简单任务:

int uart_send(uart_port_t port, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int uart_recv(uart_port_t port, uint8_t *buf, uint32_t max_len, uint32_t timeout_ms);

回调式接口适合高并发场景:

typedef void (*uart_tx_cb_t)(uart_port_t port, void *arg);
typedef void (*uart_rx_cb_t)(uart_port_t port, uint8_t *data, uint32_t len, void *arg);

int uart_send_async(uart_port_t port, const uint8_t *data, uint32_t len, uart_tx_cb_t cb, void *arg);
int uart_recv_async(uart_port_t port, uint8_t *buf, uint32_t max_len, uart_rx_cb_t cb, void *arg);

我曾经在一个项目里只提供了阻塞接口,结果三个任务同时发数据,互相阻塞,系统响应直接崩了。后来改成异步 + 内部队列,问题才解决。

经验之谈:阻塞接口内部其实也是用信号量实现的。发送时 pend 一个 TX 完成信号量,接收时 pend 一个 RX 数据信号量。这样底层统一,上层灵活。

4.3 DMA 支持

DMA 是 UART 高性能的关键。没有 DMA,CPU 就得一个字节一个字节地搬数据,尤其是在高波特率下(比如 921600),CPU 占用率能到 30% 以上。

DMA 的 HAL 层设计,我建议遵循以下流程:

// 1. 配置 DMA 通道(在 uart_init 中完成)
// 2. 启动 DMA 传输
int uart_dma_send(uart_port_t port, const uint8_t *data, uint32_t len);
int uart_dma_recv(uart_port_t port, uint8_t *buf, uint32_t max_len);

// 3. 注册 DMA 完成回调
int uart_dma_set_tx_done_cb(uart_port_t port, uart_tx_cb_t cb, void *arg);
int uart_dma_set_rx_done_cb(uart_port_t port, uart_rx_cb_t cb, void *arg);

这里有个容易踩的坑:DMA 缓冲区对齐。很多 MCU 的 DMA 控制器要求源地址和目的地址按 4 字节对齐。我之前用某款国产芯片,没注意这个,DMA 传输偶尔会丢数据,查了两天才发现是地址没对齐。

避坑指南:在 uart_init 内部,如果启用了 DMA,必须检查用户传入的缓冲区地址是否满足对齐要求。不满足时,要么返回错误码,要么内部做一次内存拷贝。

DMA 的环形缓冲区(Ring Buffer)也是常用技巧。接收端用 DMA 循环写入环形缓冲区,CPU 只在需要时读取,这样不会丢数据:

typedef struct {
    uint8_t     *pool;      // 缓冲区池
    uint32_t    size;       // 缓冲区大小(2的幂)
    volatile uint32_t head; // DMA 写入位置
    volatile uint32_t tail; // CPU 读取位置
} uart_ringbuf_t;

为什么 size 要是 2 的幂?因为可以用位运算代替取模,速度快很多。head & (size - 1) 比 head % size 快一个数量级。

4.4 硬件流控

硬件流控(RTS/CTS)在高速传输或长距离通信时几乎是必须的。没有流控,接收端缓冲区满了,发送端还在发,数据就丢了。

HAL 层的流控设计,我建议做到两点:

  • 初始化时配置:通过 flow_ctrl 字段使能 RTS/CTS
  • 运行时自动管理:RTS 引脚由 HAL 层根据 RX 缓冲区水位自动拉高/拉低

代码示例:

// 内部实现:当 RX 缓冲区剩余空间低于阈值时,拉高 RTS
static void uart_rts_manage(uart_port_t port) {
    uart_ringbuf_t *rb = &uart_ctx[port].rx_ringbuf;
    uint32_t free_space = rb->size - (rb->head - rb->tail);
    
    if (free_space < UART_RTS_THRESHOLD) {
        // 拉高 RTS,通知对方停止发送
        GPIO_SetBits(uart_ctx[port].rts_pin);
    } else {
        // 拉低 RTS,通知对方可以发送
        GPIO_ResetBits(uart_ctx[port].rts_pin);
    }
}

阈值设多少合适?我个人习惯设为缓冲区大小的 25%。太小了容易频繁触发,太大了又失去流控意义。

补充一点:CTS 引脚由硬件自动处理,HAL 层不需要干预。但初始化时必须把 CTS 引脚配置为 UART 的 CTS 功能,而不是普通 GPIO。

4.5 整体架构图

下面这张图展示了 UART HAL 层的整体架构,从用户 API 到硬件寄存器的完整链路:

UART HAL 层架构图 用户 API 层 HAL 核心层 初始化 | 阻塞收发 | 异步收发 | 超时管理 环形缓冲区 | 信号量同步 | 回调分发 DMA 管理层 DMA 配置 | 传输控制 流控管理层 RTS/CTS 自动管理 寄存器抽象层 UART 硬件外设 说明:用户 API 不直接操作寄存器,所有路径经过 HAL 核心层调度,DMA 和流控为可选模块

4.6 接口规范清单

最后,我把 UART HAL 层必须提供的接口整理成清单,方便你对照实现:

接口 功能 必选/可选
uart_init 初始化 UART 外设,配置参数 必选
uart_deinit 反初始化,释放资源 必选
uart_send 阻塞发送 必选
uart_recv 阻塞接收 必选
uart_send_async 异步发送(回调) 推荐
uart_recv_async 异步接收(回调) 推荐
uart_dma_send DMA 发送 可选
uart_dma_recv DMA 接收 可选
uart_set_flow_ctrl 运行时配置流控 可选

嗯,UART 这块就讲到这里。接口设计没有标准答案,但有了这套规范,你在不同芯片之间移植时,至少能保证上层应用代码一行都不用改。这就是 HAL 层的价值所在。