第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:抓包分析用
调试时几个关键设置:
- 波特率、数据位、停止位、校验位必须和代码一致
- 流控(RTS/CTS)默认关闭,除非你硬件接了
- 发送新行选CR+LF(\r\n),很多终端只认这个
- HEX显示模式用于看原始数据,文本模式看字符串
我调试时习惯同时开两个串口:一个接目标板打印日志,另一个接逻辑分析仪抓波形。两边对照着看,问题定位快很多。
3.7 本章知识体系
下面这张图把UART通信的核心脉络串起来了:
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的串口升级部分你就已经打好基础了。