4. 移植环境搭建

UART 这个外设,说简单也简单,说复杂也复杂。简单是因为它本质上就是发数据和收数据,复杂在于不同芯片的 UART 寄存器差异太大了。我早年做项目时,光是移植 UART 驱动就折腾了两周——后来我下定决心,必须做一层抽象,把共性抽出来。

这一章,我们就来聊聊 UART 的 HAL 层该怎么设计。我会从接口定义讲起,然后分别讨论轮询模式和中断模式的实现思路。

4.1 标准UART接口定义

我个人习惯,先把接口定死,再考虑实现。接口就是契约,上层代码只认契约,不关心底层寄存器长什么样。

一个完整的 UART HAL 层,至少需要这几类操作:

  • 初始化与去初始化:配置波特率、数据位、停止位、校验位
  • 发送:单字节发送、多字节发送
  • 接收:单字节接收、多字节接收
  • 流控:硬件流控(RTS/CTS)使能
  • 中断管理:使能/禁用中断、注册回调

下面是我在项目中常用的接口定义,你可以直接拿来用:

/* 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;

/* UART 句柄结构体 */
typedef struct {
    uint8_t      port;          /* 端口号 */
    uart_config_t config;       /* 配置参数 */
    void         *priv;         /* 私有数据指针 */
    void (*rx_callback)(uint8_t data);  /* 接收回调 */
    void (*tx_callback)(void);          /* 发送完成回调 */
} uart_handle_t;

/* HAL 层接口 */
int32_t uart_init(uart_handle_t *handle);
int32_t uart_deinit(uart_handle_t *handle);
int32_t uart_send_byte(uart_handle_t *handle, uint8_t data);
int32_t uart_send_buffer(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_receive_byte(uart_handle_t *handle, uint8_t *data);
int32_t uart_receive_buffer(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_set_flow_ctrl(uart_handle_t *handle, uint8_t enable);
int32_t uart_irq_enable(uart_handle_t *handle, uint8_t enable);

关键设计点:句柄中的 priv 指针指向底层寄存器基地址或设备结构体。这样上层代码只操作句柄,底层实现可以随意切换。

4.2 轮询模式实现

轮询模式,说白了就是 CPU 死等。发送时一直检查发送寄存器是否为空,接收时一直检查数据是否到达。这种方式在裸机或简单任务中很常见,但会浪费 CPU 资源。

我早期做的一个温控项目,就是用轮询方式跑 UART。当时波特率只有 9600,CPU 大部分时间都在空转。嗯,那时候还没意识到中断的重要性。

轮询发送的核心逻辑:

int32_t uart_send_byte(uart_handle_t *handle, uint8_t data) {
    /* 等待发送寄存器为空 */
    while (!(UART_REG(handle->port, SR) & TX_EMPTY));
    /* 写入数据 */
    UART_REG(handle->port, DR) = data;
    return 0;
}

轮询接收的核心逻辑:

int32_t uart_receive_byte(uart_handle_t *handle, uint8_t *data) {
    /* 等待接收数据到达 */
    while (!(UART_REG(handle->port, SR) & RX_NOT_EMPTY));
    /* 读取数据 */
    *data = UART_REG(handle->port, DR);
    return 0;
}

注意:轮询模式一定要加超时机制。我曾经遇到过硬件故障导致发送寄存器一直不空,程序就死锁在那里。加个超时判断,能避免很多麻烦。

4.3 中断模式实现

中断模式就优雅多了。CPU 不用空等,数据到了或者发送完成了,硬件会主动通知你。你想想看,这对系统整体性能的提升有多大。

中断模式的设计要点:

  • 发送中断:发送寄存器为空时触发,在中断中填充下一个字节
  • 接收中断:接收到数据时触发,在中断中读取并存入缓冲区
  • 环形缓冲区:中断和主程序通过环形缓冲区交换数据,避免数据丢失

我曾经在一个多串口项目中,用中断模式配合 DMA,实现了 4 路 UART 同时收发,CPU 占用率不到 5%。要是用轮询,估计早就崩了。

中断服务函数示例:

void uart_irq_handler(uart_handle_t *handle) {
    uint32_t status = UART_REG(handle->port, SR);
    
    /* 接收中断 */
    if (status & RX_NOT_EMPTY) {
        uint8_t data = UART_REG(handle->port, DR);
        /* 存入环形缓冲区 */
        ringbuf_push(&handle->rx_buf, data);
        /* 调用用户回调 */
        if (handle->rx_callback) {
            handle->rx_callback(data);
        }
    }
    
    /* 发送中断 */
    if (status & TX_EMPTY) {
        uint8_t data;
        if (ringbuf_pop(&handle->tx_buf, &data) == 0) {
            UART_REG(handle->port, DR) = data;
        } else {
            /* 没有数据了,关闭发送中断 */
            uart_irq_enable(handle, 0);
        }
    }
}

经验之谈:中断服务函数里尽量少做事,把数据丢进缓冲区就赶紧退出。耗时的处理放到主循环或任务中去做。这是嵌入式开发的黄金法则。

4.4 流控实现

硬件流控(RTS/CTS)在高速通信中很有用。它通过硬件信号线来控制数据流,防止接收方来不及处理导致数据丢失。

流控的 HAL 抽象其实很简单:

int32_t uart_set_flow_ctrl(uart_handle_t *handle, uint8_t enable) {
    if (enable) {
        /* 使能 RTS/CTS 功能 */
        UART_REG(handle->port, CR) |= RTS_EN | CTS_EN;
    } else {
        /* 禁用流控 */
        UART_REG(handle->port, CR) &= ~(RTS_EN | CTS_EN);
    }
    return 0;
}

底层实现时,要注意不同芯片的流控引脚映射。我记得有一次在 STM32 上移植,RTS 引脚默认是推挽输出,但实际需要开漏模式,折腾了半天才找到问题。

4.5 轮询 vs 中断:如何选择?

对比项 轮询模式 中断模式
CPU 占用 高,持续占用 低,只在收发时占用
实时性 依赖轮询频率 硬件触发,响应快
实现复杂度 简单 中等,需要缓冲区管理
适用场景 低速率、简单任务 高速率、多任务、实时要求高
功耗 高 低,CPU 可以休眠

我个人建议:新产品直接上中断模式,别犹豫。轮询模式只适合调试阶段或者极低成本的方案。

4.6 UART HAL 层架构图

下面这张图展示了 UART HAL 层的整体架构,从上层应用到底层硬件,每一层各司其职:

UART HAL 层架构图 UART HAL 层架构图 应用层(用户代码) HAL 接口层(uart_init / uart_send / uart_receive ...) 模式选择:轮询模式 / 中断模式 / DMA 模式 MCAL 层(寄存器操作 / 中断服务函数) 硬件层(UART 外设寄存器) HAL 层隔离了应用与硬件,更换芯片只需重写 MCAL 层,上层代码无需修改

从这张图可以看出,HAL 层处于中间位置,向上提供统一接口,向下屏蔽硬件差异。你只要把 MCAL 层按芯片重新实现一遍,应用代码完全不用动。

核心思想:接口稳定,实现可变。这就是 HAL 层的价值所在。

4.7 避坑指南

最后分享几个我踩过的坑:

  • 波特率误差:不同芯片的时钟源不同,计算出的波特率可能有误差。超过 3% 就会丢数据。我建议在初始化时做一次误差检查。
  • 中断优先级:多串口项目中,中断优先级设置不当会导致数据丢失。高波特率的串口优先级要设高一些。
  • 环形缓冲区大小:缓冲区太小容易溢出,太大浪费内存。一般按波特率计算:缓冲区大小 > 波特率 / 8 / 中断频率。
  • 流控信号极性:不同芯片的 RTS/CTS 极性可能相反。移植时一定要看数据手册。

嗯,UART 的 HAL 抽象就聊到这里。记住一点:接口设计要简洁,实现要灵活。这样你的代码才能经得起跨平台移植的考验。