3. UART串口通信调试:从初始化到故障定位
UART串口,做嵌入式的人天天打交道。说它是调试的“生命线”一点不为过。CH32系列芯片的UART模块,功能挺全,但坑也不少。这一章,我把这些年调串口踩过的坑、总结的经验,一次性说清楚。
本章核心知识点:
- UART初始化与波特率设置的底层逻辑
- 串口打印调试法的正确姿势
- 数据丢帧问题的系统化定位方法
- 中断接收与轮询模式的实战对比
3.1 UART初始化与波特率设置
初始化UART,第一步就是配时钟。CH32的UART挂在不同总线上,有的在APB1,有的在APB2。我见过不少新手,初始化半天没反应,最后发现是时钟没使能。
波特率设置,这里有个关键点——误差。理论上波特率误差要小于2%,实际项目中我建议控制在1%以内。为什么?
你看CH32的波特率计算公式:
BaudRate = FCLK / (16 * USART_BRR)
// USART_BRR = FCLK / (16 * BaudRate)
FCLK是UART的时钟源,BRR是波特率寄存器值。问题来了:BRR是整数,除出来有小数怎么办?
我曾经踩过的坑: 用8MHz时钟配115200波特率,算出来BRR=4.34,取整后实际波特率是125000,误差高达8.5%。结果就是——数据偶尔对,偶尔错,查了整整一天。
正确的做法是:先算理论BRR,再反算实际波特率,确认误差在可接受范围。如果误差太大,要么换晶振,要么用PLL倍频。
下面是我常用的初始化模板:
void UART1_Init(uint32_t baudrate) {
uint32_t brr;
float error;
// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// 2. GPIO配置:TX推挽输出,RX浮空输入
GPIO_InitTypeDef gpio = {0};
gpio.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10;
gpio.GPIO_Mode = GPIO_Mode_AF_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio);
// 3. 计算BRR并检查误差
brr = SystemCoreClock / (16 * baudrate);
error = (float)SystemCoreClock / (16 * brr) / baudrate - 1;
if(error > 0.02 || error < -0.02) {
// 误差超过2%,报警
printf("Warning: baudrate error %.2f%%\n", error*100);
}
// 4. 配置UART
USART_InitTypeDef uart = {0};
uart.USART_BaudRate = baudrate;
uart.USART_WordLength = USART_WordLength_8b;
uart.USART_StopBits = USART_StopBits_1;
uart.USART_Parity = USART_Parity_No;
uart.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
uart.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_Init(USART1, &uart);
// 5. 使能
USART_Cmd(USART1, ENABLE);
}
个人习惯: 我会在初始化函数里加一个波特率误差检查,如果误差超标直接打印警告。这样上电一看日志就知道有没有隐患,不用等出了问题再回头查。
3.2 串口打印调试法
printf是调试利器,但嵌入式里用printf有个前提——你得把输出重定向到UART。CH32的GCC工具链里,重定向很简单:
int _write(int file, char *ptr, int len) {
for(int i = 0; i < len; i++) {
while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);
USART_SendData(USART1, ptr[i]);
}
return len;
}
这里有个细节:USART_FLAG_TC是发送完成标志,不是发送缓冲区空标志。用TC能保证每个字节都发完再发下一个,不会丢数据。但代价是——慢。如果你在中断里用这个方式打印,CPU会被死死拖住。
我建议的做法是:
- 调试阶段: 用阻塞式printf,简单可靠
- 发布版本: 用DMA+环形缓冲区,或者干脆关掉打印
另外,打印信息要分级。我自己的习惯:
| 级别 | 宏定义 | 使用场景 |
|---|---|---|
| 错误 | LOG_E(fmt, ...) | 系统无法继续运行 |
| 警告 | LOG_W(fmt, ...) | 异常但可恢复 |
| 信息 | LOG_I(fmt, ...) | 关键状态变化 |
| 调试 | LOG_D(fmt, ...) | 详细流程跟踪 |
你想想看,如果所有打印都用printf,调试信息一多,根本分不清哪个是重点。分级之后,发布时只需要把LOG_D关掉,其他保留,线上问题也能快速定位。
3.3 数据丢帧问题定位
数据丢帧,是UART调试里最让人头疼的问题之一。现象很典型:数据偶尔少几个字节,或者出现乱码。
我总结了一个排查清单,按优先级排列:
- 检查波特率误差 —— 用示波器看波形,测量实际波特率
- 检查FIFO溢出 —— 看USART_SR的ORE位是否被置1
- 检查中断响应时间 —— 中断里不能做耗时操作
- 检查缓冲区大小 —— 环形缓冲区是否溢出
- 检查硬件连接 —— TX/RX是否接反,地线是否共地
核心原则: 丢帧的本质是“数据来了,但CPU没来得及取走”。要么是CPU太忙,要么是缓冲区太小,要么是波特率不匹配。
我曾经遇到一个案例:设备每隔100ms发一包128字节的数据,波特率115200。理论上传输时间约11ms,CPU完全来得及。但实际就是丢帧。查了半天,发现是中断里有个printf打印调试信息,打印一次要20ms,直接把下一个中断堵死了。
解决方案很简单:中断里只做数据搬运,打印放到主循环。
3.4 中断接收与轮询模式对比
这两种模式,说白了就是“主动问”和“等着通知”的区别。
| 对比项 | 轮询模式 | 中断模式 |
|---|---|---|
| CPU占用率 | 高(持续查询) | 低(有数据才响应) |
| 实时性 | 取决于轮询频率 | 高(硬件触发) |
| 代码复杂度 | 低 | 中(需处理临界区) |
| 适用场景 | 简单系统、低速率 | 多任务、高速率 |
轮询模式我一般只在两种情况下用:一是芯片资源实在紧张,二是调试初期验证硬件。正常产品里,几乎都用中断。
中断接收的标准写法:
// 环形缓冲区
#define RX_BUF_SIZE 256
volatile uint8_t rx_buf[RX_BUF_SIZE];
volatile uint16_t rx_head = 0, rx_tail = 0;
void USART1_IRQHandler(void) {
if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {
uint8_t data = USART_ReceiveData(USART1);
uint16_t next = (rx_head + 1) % RX_BUF_SIZE;
if(next != rx_tail) { // 缓冲区未满
rx_buf[rx_head] = data;
rx_head = next;
} else {
// 缓冲区溢出,丢帧
// 可以在这里置一个标志位
}
}
}
注意: 中断里访问的变量,主循环里也要访问,一定要加volatile。我见过有人忘了加,优化后的代码直接跳过变量读取,数据永远不对。
主循环里取数据:
while(rx_tail != rx_head) {
uint8_t data = rx_buf[rx_tail];
rx_tail = (rx_tail + 1) % RX_BUF_SIZE;
// 处理数据
}
这个环形缓冲区的设计,说白了就是用空间换时间。256字节对于大部分物联网应用足够了。如果数据量特别大,可以改成1024或者用DMA。
我建议: 新项目直接上中断+DMA,CH32的UART支持DMA传输,CPU几乎零负担。轮询模式只用来做硬件测试,产品里别用。
嗯,UART调试这块,说到底就是三个字——稳、准、快。初始化要稳,波特率要准,响应要快。把这三点做到位,串口通信基本不会出大问题。