4、UART驱动实战:UART协议基础、波特率配置与时钟树、中断收发与环形缓冲区、DMA收发实现

UART,说白了就是串口。做嵌入式的人,没人能绕开它。我刚开始接触BSP时,第一个调通的外设就是UART。那时候觉得简单,不就是发几个字节嘛。后来踩的坑多了,才发现UART驱动写得好不好,直接决定了整个系统的调试效率和稳定性。

这一章,咱们就把它彻底吃透。

4.1 UART协议基础

UART全称是Universal Asynchronous Receiver/Transmitter。注意“异步”这两个字——收发双方没有独立的时钟线,全靠约定的波特率来对齐每一位。

一个标准的UART数据帧长这样:

起始位 数据位(5~8位) 校验位(可选) 停止位(1/1.5/2位)
0(低电平) LSB先发 奇/偶/无 1(高电平)

空闲时,TX线保持高电平。发送端拉低一个位时间,接收端就知道“要开始了”。然后按约定的速率依次读取数据位。最后停止位拉高,等待下一帧。

关键点:收发双方的波特率误差必须小于2%。我见过一个项目,板子用16MHz晶振,波特率配成115200,结果误差刚好超了。串口能发不能收,查了整整一天。

常用的波特率有9600、19200、38400、115200。115200在大多数MCU上都能做到1%以内的误差,是调试首选。

4.2 波特率配置与时钟树

波特率不是随便填个数字就能用的。它的计算公式是:

波特率 = 外设时钟频率 / (16 × 分频系数)

分频系数通常是一个16位的寄存器,范围1~65535。所以你能得到的波特率是离散的,不是连续的。

举个例子,STM32F4的USART挂载在APB2总线上,时钟频率84MHz。要配115200:

分频系数 = 84,000,000 / (16 × 115200) ≈ 45.57
取整为46,实际波特率 = 84,000,000 / (16 × 46) ≈ 114,130
误差 = (115200 - 114130) / 115200 ≈ 0.93%

0.93%,在2%以内,能用。

注意:有些MCU的UART时钟来自PLL或者独立的时钟源。换时钟树配置时,波特率会跟着变。我习惯在驱动初始化时,先读时钟频率,再算分频系数,而不是写死一个值。

下面这张图展示了UART驱动的整体时钟与数据流:

UART驱动时钟与数据流 时钟源(HSE/PLL) 时钟树(AHB/APB) UART外设 波特率发生器 RX引脚 接收移位寄存器 RDR(接收数据寄存器) TX引脚 发送移位寄存器 TDR(发送数据寄存器) 中断控制器 DMA控制器 时钟源经时钟树分频后供给UART,波特率发生器基于该时钟产生收发时序

4.3 中断收发与环形缓冲区

轮询收发太浪费CPU了。你想想看,如果CPU一直等着串口数据,别的活还干不干了?

中断收发是标准做法。每收到一个字节,硬件触发中断,CPU在中断服务程序里把数据读走。但这里有个坑——中断服务程序里不能做耗时操作,否则会丢数据。

解决方案就是环形缓冲区(Ring Buffer)。

typedef struct {
    uint8_t buffer[RX_BUF_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} ring_buffer_t;

// 写指针(中断中调用)
void rb_write(ring_buffer_t *rb, uint8_t data) {
    uint16_t next = (rb->head + 1) % RX_BUF_SIZE;
    if (next != rb->tail) {
        rb->buffer[rb->head] = data;
        rb->head = next;
    }
}

// 读指针(主循环中调用)
uint8_t rb_read(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return 0; // 空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RX_BUF_SIZE;
    return 1;
}

我的习惯:缓冲区大小设为2的幂次,比如256、512。这样取模可以用位运算代替除法,速度更快。head和tail加volatile,防止编译器优化掉中断和主循环之间的共享变量。

中断服务程序里只做一件事:把数据塞进环形缓冲区。解析协议、处理数据,统统放到主循环里做。这样中断占用的时间极短,不会影响其他中断的响应。

4.4 DMA收发实现

中断收发虽然好,但数据量大时,频繁进中断还是会占用CPU。比如每秒115200波特率,约11.5KB/s,每字节进一次中断,每秒要处理一万多次中断。

DMA就是干这个的。它直接在内存和外设之间搬运数据,搬完了才通知CPU。

DMA收发的配置步骤:

  1. 初始化DMA控制器——选择通道、优先级、数据宽度
  2. 配置UART的DMA请求——使能UART的DMA发送/接收功能
  3. 设置传输参数——源地址、目的地址、传输长度
  4. 使能DMA——启动传输
  5. 注册DMA完成中断——传输完成后做后续处理

以接收为例,DMA配置代码片段:

void uart_dma_rx_init(UART_HandleTypeDef *huart, uint8_t *buf, uint16_t len) {
    // 配置DMA接收
    huart->hdmarx->Instance = DMA1_Channel5;
    huart->hdmarx->Init.Direction = DMA_PERIPH_TO_MEMORY;
    huart->hdmarx->Init.PeriphInc = DMA_PINC_DISABLE;
    huart->hdmarx->Init.MemInc = DMA_MINC_ENABLE;
    huart->hdmarx->Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    huart->hdmarx->Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    huart->hdmarx->Init.Mode = DMA_CIRCULAR; // 循环模式

    HAL_DMA_Init(huart->hdmarx);
    __HAL_LINKDMA(huart, hdmarx, *huart->hdmarx);

    // 启动DMA接收
    HAL_UART_Receive_DMA(huart, buf, len);
}

我曾经踩过的坑:DMA的循环模式和普通模式要分清楚。普通模式传输完一次就停了,适合固定长度的数据包。循环模式适合连续接收,但你要自己处理半满中断和全满中断,否则数据会被覆盖。

DMA发送相对简单,把要发送的数据缓冲区地址告诉DMA,它自己就搬过去了。发送完成后触发中断,你可以在中断里释放缓冲区或者启动下一批数据。

实际项目中,我通常这样搭配:

  • 调试串口:中断收发 + 环形缓冲区,简单可靠
  • 数据串口(如GPS、4G模块):DMA接收 + 环形缓冲区,减少CPU占用
  • 批量发送(如固件升级):DMA发送,CPU可以同时做校验和计算

嗯,UART驱动这块,把协议、时钟、中断、DMA串起来,基本就覆盖了90%的应用场景。剩下的10%,等你遇到具体问题时,再回来翻翻这一章,应该能找到答案。