4. UART通信抽象:波特率配置、收发缓冲机制、中断与DMA模式封装

UART,说白了就是嵌入式世界里最常用的串行通信接口。我做了这么多年ECU开发,几乎每个项目都离不开它。调试、诊断、固件升级,哪样不得靠UART?今天咱们就来聊聊,怎么在硬件抽象层里把UART封装得既好用又高效。

4.1 波特率配置——别小看这个基础操作

波特率配置看起来简单,不就是往寄存器里写个值吗?嗯,这里要注意。不同MCU的波特率发生器原理差异很大。有的用整数分频,有的带小数分频,还有的是基于定时器。

我个人习惯在HAL层定义一个统一的结构体:

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;

然后提供一个统一的配置接口:

ECU_Status UART_Init(UART_Handle_t *huart, UART_Config_t *config);

这个接口内部会根据不同MCU去计算分频系数。我在项目中遇到过一个问题——某款芯片的波特率计算器有个隐藏bug,当系统时钟是8MHz时,115200波特率算出来的实际值是114800,差了0.35%。平时用着没问题,但接上GPS模块就频繁丢数据。后来我加了个误差检查函数,超过1%就报警。

避坑指南:我曾经因为波特率误差导致整批ECU在高温下通信失败。记住,波特率误差要控制在±2%以内,工业级建议±1%。温度变化会影响晶振频率,这个余量必须留足。

4.2 收发缓冲机制——环形缓冲区是王道

UART通信最怕什么?数据丢失。尤其是在中断频繁、任务优先级复杂的环境下。你想想看,如果CPU正在处理一个高优先级中断,这时候UART又来了一个字节,没缓冲的话直接就丢了。

我推荐用环形缓冲区(Ring Buffer)。它实现简单,内存占用小,而且天然支持生产者和消费者模式。

typedef struct {
    uint8_t *buffer;
    volatile uint16_t head;
    volatile uint16_t tail;
    uint16_t size;
} RingBuf_t;

// 写一个字节
ECU_Status RingBuf_Write(RingBuf_t *rb, uint8_t data) {
    uint16_t next = (rb->head + 1) % rb->size;
    if (next == rb->tail) {
        return ECU_ERR_FULL;  // 缓冲区满了
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return ECU_OK;
}

// 读一个字节
ECU_Status RingBuf_Read(RingBuf_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return ECU_ERR_EMPTY; // 缓冲区空了
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return ECU_OK;
}

这里有个细节——head和tail要声明为volatile。为什么?因为中断和主循环会同时访问它们,编译器优化时可能会把变量值缓存在寄存器里,导致判断出错。我吃过这个亏,排查了两天才找到原因。

经验之谈:缓冲区大小怎么定?我的经验是:接收缓冲区至少是最大数据帧的2倍,发送缓冲区可以小一些。比如CAN诊断报文最长8字节,接收缓冲区设32字节就够用。但如果做固件升级,建议设到1KB以上。

4.3 中断模式封装——别让中断成为噩梦

中断模式是UART最常用的方式。但很多新手写中断服务函数时,喜欢在里面做复杂处理。这是大忌!

中断服务函数的原则:快进快出。只做最必要的事——把数据搬到缓冲区,清标志位,然后赶紧退出。

// UART接收中断服务函数
void UART_RX_IRQHandler(UART_Handle_t *huart) {
    uint8_t data;
    
    // 读取接收数据寄存器
    data = huart->instance->DR;
    
    // 写入环形缓冲区
    if (RingBuf_Write(&huart->rx_buf, data) != ECU_OK) {
        // 缓冲区溢出,记录错误
        huart->error_flags |= UART_ERR_OVERFLOW;
    }
    
    // 清中断标志
    huart->instance->SR &= ~UART_SR_RXNE;
}

你看,这个函数里没有协议解析,没有回调函数,没有复杂逻辑。就是纯粹的搬运工。协议解析放在主循环或者任务里做。

我建议在HAL层提供两个回调注册接口:

typedef void (*UART_RxCallback_t)(uint8_t data);
typedef void (*UART_TxCompleteCallback_t)(void);

void UART_RegisterRxCallback(UART_Handle_t *huart, UART_RxCallback_t cb);
void UART_RegisterTxCompleteCallback(UART_Handle_t *huart, UART_TxCompleteCallback_t cb);

这样上层应用可以注册自己的处理函数,而HAL层只负责在中断里触发回调。解耦做得干净利落。

4.4 DMA模式封装——解放CPU的终极方案

当数据量大的时候,中断模式也有瓶颈。每个字节都进一次中断,CPU频繁上下文切换,效率上不去。这时候就该DMA出场了。

DMA模式说白了就是:CPU告诉DMA控制器「你去把这一串数据从内存搬到UART发送寄存器」,然后CPU就可以去干别的事了。传输完成后再通知CPU。

DMA模式的封装要点:

  1. 内存对齐:DMA通常要求缓冲区地址对齐到4字节或8字节边界
  2. 传输完成回调:DMA传输完成会触发中断,这里要通知上层
  3. 半传输中断:对于大数据块,可以利用半传输中断实现双缓冲
typedef struct {
    uint8_t *tx_buffer;
    uint16_t tx_size;
    volatile uint8_t tx_busy;
    
    uint8_t *rx_buffer;
    uint16_t rx_size;
    volatile uint16_t rx_index;
    
    // DMA句柄(具体MCU相关)
    void *dma_handle;
} UART_DMA_Handle_t;

ECU_Status UART_DMA_Transmit(UART_Handle_t *huart, uint8_t *data, uint16_t len) {
    if (huart->dma.tx_busy) {
        return ECU_ERR_BUSY;  // 上次传输还没完
    }
    
    huart->dma.tx_buffer = data;
    huart->dma.tx_size = len;
    huart->dma.tx_busy = 1;
    
    // 启动DMA传输(具体实现依赖MCU)
    DMA_Start(huart->dma.dma_handle, (uint32_t)data, 
              (uint32_t)&huart->instance->DR, len);
    
    return ECU_OK;
}

核心要点:DMA模式适合大数据量、低延迟要求的场景。比如ECU的Bootloader用UART做固件升级,用DMA模式可以把传输速率从115200提升到921600,而且CPU占用率几乎为零。

4.5 三种模式的统一接口设计

好的抽象层应该让上层应用无感切换模式。我设计了一个统一的UART句柄,内部包含三种模式的实现:

typedef enum {
    UART_MODE_POLLING,   // 轮询模式
    UART_MODE_INTERRUPT, // 中断模式
    UART_MODE_DMA        // DMA模式
} UART_Mode_t;

typedef struct {
    // 公共部分
    UART_Reg_t *instance;   // 寄存器基址
    UART_Config_t config;   // 配置参数
    UART_Mode_t mode;       // 当前模式
    
    // 中断模式相关
    RingBuf_t rx_buf;
    RingBuf_t tx_buf;
    
    // DMA模式相关
    UART_DMA_Handle_t dma;
    
    // 错误标志
    uint32_t error_flags;
    
    // 回调函数
    UART_RxCallback_t rx_callback;
    UART_TxCompleteCallback_t tx_callback;
} UART_Handle_t;

上层应用只需要调用:

UART_Init(&huart1, &config);
UART_SetMode(&huart1, UART_MODE_INTERRUPT);
UART_Receive(&huart1, buffer, size);  // 内部根据模式选择实现

这样设计的好处是:移植到新MCU时,只需要重写底层寄存器操作部分,上层接口完全不变。我在三个不同架构的MCU上验证过这套方案,移植工作量从原来的3天缩短到半天。

特别注意:DMA模式和中断模式不要混用!我曾经在一个项目里,接收用DMA,发送用中断,结果DMA传输完成中断和UART接收中断互相干扰,调试了整整一周才发现是中断优先级没配好。要么全用中断,要么全用DMA,别搞混搭。

好了,UART抽象层的内容就这些。记住,好的抽象不是把代码写得多复杂,而是让上层用起来简单,底层移植起来方便。下一章咱们聊聊SPI通信的抽象设计,那个比UART稍微复杂一点,但思路是相通的。