第3章 UART串口通信:从协议到实战调试

UART串口,说白了就是嵌入式开发者的“第二双眼睛”。我做了这么多年固件,调试新板子第一件事就是先把串口打通。没有串口打印,你连芯片有没有跑起来都不知道。

这一章我们不讲虚的,直接上手UART的三种工作模式——轮询、中断、DMA。顺便把printf重定向搞定,以后调试就舒服多了。

3.1 UART协议基础

UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。它只需要两根线:TX(发送)和RX(接收),再加上共地,就能实现全双工通信。

为什么叫“异步”?因为没有独立的时钟线。收发双方得提前约定好波特率、数据位、停止位这些参数,才能正确解析数据。

UART帧格式(8-N-1为例)

起始位(1bit) + 数据位(8bit) + 校验位(无) + 停止位(1bit) = 10bit/帧

波特率115200时,每bit约8.68μs,传输1字节约86.8μs

我遇到过最坑的事:两块板子波特率都设的115200,但晶振精度不一样,一个偏快一个偏慢,跑几个小时就出现乱码。后来我习惯在产测环节加个波特率自适应检测,省心很多。

参数 常见值 说明
波特率 9600 / 115200 / 921600 越高越快,但受线长和干扰影响
数据位 8bit(最常用) 7bit用于ASCII,9bit用于多机通信
校验位 None / Even / Odd 偶校验Even用得较多
停止位 1bit / 2bit 2bit用于抗干扰场景

3.2 HAL库UART轮询模式

轮询模式最简单,CPU死等。适合数据量小、实时性要求不高的场景,比如初始化时打印版本号。

// 初始化UART
UART_HandleTypeDef huart1;
huart1.Instance = USART1;
huart1.Init.BaudRate = 115200;
huart1.Init.WordLength = UART_WORDLENGTH_8B;
huart1.Init.StopBits = UART_STOPBITS_1;
huart1.Init.Parity = UART_PARITY_NONE;
huart1.Init.Mode = UART_MODE_TX_RX;
HAL_UART_Init(&huart1);

// 发送1字节
uint8_t data = 'A';
HAL_UART_Transmit(&huart1, &data, 1, HAL_MAX_DELAY);

// 接收1字节(阻塞)
HAL_UART_Receive(&huart1, &data, 1, HAL_MAX_DELAY);

注意:HAL_MAX_DELAY意味着无限等待。如果对方没发数据,程序就卡死了。生产环境建议给个超时值,比如100ms。

轮询的缺点很明显——发送期间CPU啥也干不了。我曾经在一个项目里用轮询打印日志,结果电机控制周期被拖到10ms以上,电机抖得像跳舞。后来果断换成中断模式。

3.3 UART中断模式

中断模式的核心思想:发完或收到数据后,硬件自动触发中断,CPU在中断服务函数里处理。这样CPU可以腾出手干别的活。

// 中断方式发送
uint8_t tx_buf[] = "Hello\r\n";
HAL_UART_Transmit_IT(&huart1, tx_buf, sizeof(tx_buf));

// 中断方式接收(启动后等待数据)
uint8_t rx_byte;
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);

中断来了以后,HAL库会调用回调函数。你只需要重写这个函数:

// 接收完成回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        // 处理收到的数据
        process_rx_byte(rx_byte);
        // 重新开启接收中断
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
    }
}

我的习惯:在回调里只做最轻量的处理,比如把数据扔进环形缓冲区。千万别在中断里做printf、延时、复杂计算——中断服务函数要快进快出。

中断模式有个坑:HAL_UART_Receive_IT每次只能接收指定字节数,收完就停了。如果你要持续接收,必须在回调里再次调用它。我刚开始用HAL库时忘了这茬,结果只收到第一个字节就再也不触发了……排查了半天。

3.4 UART DMA模式

DMA模式是终极方案。数据搬运完全由DMA控制器完成,CPU零参与。适合大批量数据传输,比如刷固件、传传感器数据流。

// DMA方式发送
uint8_t large_buf[1024];
HAL_UART_Transmit_DMA(&huart1, large_buf, 1024);

// DMA方式接收(循环模式)
HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
// 开启半满/全满中断
__HAL_DMA_ENABLE_IT(&hdma_usart1_rx, DMA_IT_HT | DMA_IT_TC);

DMA接收常用“双缓冲区”或“循环缓冲区”模式。数据满了半满或全满时触发中断,CPU去取数据。这样CPU几乎零开销。

三种模式对比

轮询:简单但阻塞,适合调试阶段

中断:不阻塞CPU,适合不定长小数据

DMA:零CPU占用,适合大数据量连续传输

我曾经在一个Bootloader项目里用DMA接收固件包,配合双缓冲,边收边写Flash,速度比中断模式快了3倍。嗯,DMA的配置稍微复杂点,但性能提升是实打实的。

3.5 串口重定向printf

printf是调试神器。但STM32默认的printf输出到stdout,需要重定向到UART。

// 在usart.c或main.c中添加
#include <stdio.h>

int __io_putchar(int ch)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
    return ch;
}

// 如果使用MicroLIB,还需要
int fputc(int ch, FILE *f)
{
    return __io_putchar(ch);
}

然后在Keil工程选项里勾选“Use MicroLIB”,或者在STM32CubeIDE里勾选“Use Newlib-nano”。之后就可以愉快地写printf了:

printf("System initialized, clock=%lu Hz\r\n", HAL_RCC_GetSysClockFreq());
printf("ADC value: %d, temp: %.2f°C\r\n", adc_val, temp);

注意:printf默认是阻塞的。如果在中断里调用printf,而UART正在发送,可能会死锁。我的做法是:中断里只用环形缓冲区记录日志,主循环里统一打印。

3.6 串口调试助手使用

工欲善其事,必先利其器。我常用的串口工具有:

  • SSCOM:轻量、稳定,支持多串口
  • Putty:支持SSH和串口,跨平台
  • MobaXterm:功能强大,支持标签页
  • Serial Port Monitor:抓包分析用

调试时几个关键设置:

  1. 波特率、数据位、停止位、校验位必须和代码一致
  2. 流控(RTS/CTS)默认关闭,除非你硬件接了
  3. 发送新行选CR+LF(\r\n),很多终端只认这个
  4. HEX显示模式用于看原始数据,文本模式看字符串

我调试时习惯同时开两个串口:一个接目标板打印日志,另一个接逻辑分析仪抓波形。两边对照着看,问题定位快很多。

3.7 本章知识体系

下面这张图把UART通信的核心脉络串起来了:

UART串口通信知识体系 UART协议基础 轮询模式 中断模式 DMA模式 printf重定向 调试助手使用 环形缓冲区 调试工具:SSCOM / Putty / MobaXterm / 逻辑分析仪 常见坑点 波特率偏差 → 乱码 | 中断未重新开启 → 只收一次 | printf在中断中 → 死锁

3.8 避坑指南与经验总结

最后把我这些年踩过的坑集中说一下:

  • 波特率偏差:晶振精度不够时,115200以上可能出问题。用逻辑分析仪实测一下实际波特率。
  • 中断优先级:UART中断优先级别设太高,否则高优先级中断会打断它,导致丢数据。
  • DMA配置顺序:先配DMA,再配UART。顺序反了可能DMA不工作。
  • printf缓冲区:默认printf有缓冲区,可能不会立即输出。加个fflush(stdout)或换行符\r\n。
  • 电平转换:STM32是3.3V,如果接5V的USB转串口模块,注意TX/RX直连可能烧IO。加个电平转换芯片最稳。

我的调试三板斧:

1. 先用轮询模式确认硬件通不通

2. 换成中断模式处理不定长数据

3. 大数据量上DMA,配合环形缓冲区

每一步都验证通过再往下走,别一口气全上。

UART串口看似简单,但用好它能让调试效率翻倍。把这一章的内容吃透,后面Bootloader的串口升级部分你就已经打好基础了。