4. UART通信层:UART协议基础、波特率配置、环形缓冲区设计、DMA传输优化

UART,说白了就是串口通信。在POS机这种嵌入式设备里,它几乎是标配——跟打印机通信、跟密码键盘交互、甚至调试信息输出,都离不开它。我做了这么多年嵌入式,UART调试过的bug,少说也有几十个了。

这一章,咱们就聊聊UART通信层的设计。我会从协议基础讲起,然后深入到波特率配置、环形缓冲区,最后聊聊DMA优化。嗯,都是实战中踩过的坑。

4.1 UART协议基础

UART全称是Universal Asynchronous Receiver/Transmitter。异步的,意味着没有时钟线。收发双方靠约定的波特率来同步。

一个典型的UART数据帧长这样:

起始位(1) + 数据位(5~8) + 校验位(0/1) + 停止位(1/2)

比如最常见的配置:8N1,就是8位数据、无校验、1位停止位。加上1位起始位,一帧总共10位。

我在项目中遇到过一个问题:某款打印机要求7位数据位、偶校验。当时没仔细看手册,直接用8N1去发,结果打印机死活不认。折腾了半天才发现是数据位配置错了。所以啊,通信前一定要确认对方的协议参数

关键参数速查表:

参数常见值说明
数据位8大多数场景用8位
校验位无/奇/偶噪声环境建议启用
停止位12位用于低速设备
波特率115200POS机常用这个

4.2 波特率配置

波特率决定了通信速度。但要注意,波特率不是越高越好。你想想看,线长了、干扰大了,高速就容易出错。

POS机里,跟打印机通信通常用9600或19200。跟主机通信,115200比较常见。我个人习惯是:能跑115200就不跑9600,但前提是硬件环境支持。

波特率配置的核心,其实是分频系数的计算。以STM32为例:

// 假设时钟频率为72MHz,目标波特率115200
// USART_BRR = 时钟频率 / (16 * 目标波特率)
// = 72000000 / (16 * 115200) = 39.0625

// 整数部分 = 39
// 小数部分 = 0.0625 * 16 = 1

USART->BRR = (39 << 4) | 1;  // 实际波特率 = 72000000 / (16 * 39.0625) = 115200

这里有个坑:分频系数算出来如果不是整数,就会有误差。误差超过2%,通信就可能不稳定。我曾经在一个项目里,因为用了8MHz的外部晶振,算出来的分频系数误差刚好超过2%,结果波特率怎么调都不对。后来换成内部RC振荡器,反而好了。

避坑指南:

我曾经在批量生产时发现,同一批板子有的通信正常,有的乱码。查了半天,发现是晶振精度问题。便宜的晶振误差能达到±50ppm,两个设备一叠加,波特率就对不上了。所以,批量产品建议用内部RC或者高精度晶振

4.3 环形缓冲区设计

UART接收数据,最怕的就是丢数据。你想想看,CPU正在处理别的事,串口数据来了,如果不及时读走,就会被覆盖。

环形缓冲区就是解决这个问题的。说白了,就是一个固定大小的数组,加上读指针和写指针。数据来了,写到写指针位置;CPU有空了,从读指针位置读。

我常用的环形缓冲区结构体:

typedef struct {
    uint8_t *buffer;    // 缓冲区指针
    uint16_t size;      // 缓冲区大小
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buffer_t;

关键操作就两个:写和读。

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

// 读一个字节
int ring_buffer_get(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空了
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return 0;
}

这里要注意:head和tail要用volatile修饰。因为中断和主循环都会访问它们,编译器优化可能会出问题。我见过有人没加volatile,结果调试时正常,Release版本就丢数据。

个人经验:

缓冲区大小怎么定?我的经验是:至少能存下最大数据帧的2倍。比如打印机一帧数据256字节,缓冲区就设512。太小了容易丢,太大了浪费内存。POS机内存本来就紧张,别浪费。

4.4 DMA传输优化

用中断方式接收UART数据,每个字节都要进一次中断。波特率115200时,每秒约11520个字节,也就是11520次中断。CPU大部分时间都在处理中断,干不了正事。

DMA(直接存储器访问)就是干这个的。它可以在不经过CPU的情况下,直接把UART数据搬到内存里。CPU只需要在DMA传输完成时处理一次中断。

我建议的DMA配置思路:

// 以STM32为例,配置UART接收的DMA
// 1. 使能DMA时钟
RCC->AHBENR |= RCC_AHBENR_DMA1EN;

// 2. 配置DMA通道
DMA1_Channel5->CPAR = (uint32_t)&USART1->DR;  // 外设地址
DMA1_Channel5->CMAR = (uint32_t)rx_buffer;     // 内存地址
DMA1_Channel5->CNDTR = RX_BUFFER_SIZE;         // 传输长度
DMA1_Channel5->CCR = DMA_CCR_MINC |            // 内存地址递增
                      DMA_CCR_CIRC |            // 循环模式
                      DMA_CCR_TCIE;             // 传输完成中断

// 3. 使能DMA
DMA1_Channel5->CCR |= DMA_CCR_EN;

这里有个关键点:循环模式。开启循环模式后,DMA传输完指定长度会自动从头开始。配合环形缓冲区,简直完美。

DMA传输完成中断里,我一般这样处理:

void DMA1_Channel5_IRQHandler(void) {
    if (DMA1->ISR & DMA_ISR_TCIF5) {
        // 传输完成,更新环形缓冲区的写指针
        // 注意:DMA传输的是半字还是字节,要跟UART数据位匹配
        ring_buffer.head = RX_BUFFER_SIZE - DMA1_Channel5->CNDTR;
        DMA1->IFCR |= DMA_IFCR_CTCIF5;  // 清除中断标志
    }
}

DMA vs 中断 性能对比:

方式CPU占用率适用场景
轮询100%调试用,别用在产品里
中断约30%低速通信,数据量小
DMA约5%高速通信,数据量大

我做过一个测试:同样是115200波特率,接收1KB数据。中断方式CPU占用率约28%,DMA方式只有4%。差距很明显。

DMA的坑:

我曾经在一个项目里,DMA配置好了,但数据就是不对。查了半天,发现是DMA和外设的时钟域不同步。有些芯片的DMA和UART挂在不同的时钟总线上,需要做同步处理。另外,DMA传输完成中断里不要做太多事,否则会影响下一次传输。

4.5 实战建议

好了,总结一下我的建议:

  • 协议参数:先确认对方的参数,再配置自己的。别想当然。
  • 波特率:算好分频系数,误差控制在2%以内。批量产品注意晶振精度。
  • 环形缓冲区:大小设为2的幂,方便取模运算。head和tail加volatile。
  • DMA:能用DMA就别用中断。但要注意DMA和外设的时钟同步问题。

最后说一句:UART看似简单,但坑不少。我见过太多人因为波特率配置不对、缓冲区溢出、DMA配置错误而浪费大量时间。希望这一章能帮你少走弯路。

下一章,咱们聊聊I2C通信层。那玩意儿比UART复杂多了,但掌握了也很实用。