第4章:UART串口通信与打印调试

UART串口,说白了就是嵌入式开发者的“眼睛”。没有它,你连芯片在干什么都不知道。我刚开始做裸机时,第一件事就是把串口调通,否则后面所有调试都像在摸黑走路。

这一章我们聚焦四个核心问题:波特率怎么算、中断和轮询怎么选、printf怎么重定向到串口、以及串口丢数据怎么排查。嗯,都是实战中绕不开的坑。

本章核心目标: 在ARM裸机环境下,实现一个稳定、可用的串口打印调试通道,并具备基本的故障定位能力。

4.1 UART硬件基础与波特率计算

UART是异步串行通信,没有时钟线。收发双方得约定好一个“节奏”——这就是波特率。波特率错了,收到的全是乱码,我当年第一次调串口就栽在这上面。

波特率计算公式很简单,但不同芯片的时钟源和分频器略有差异。以常见的STM32F103为例,其USART波特率计算公式为:

波特率 = fCK / (16 × USARTDIV)

其中:
- fCK:外设时钟(通常是PCLK1或PCLK2)
- USARTDIV:一个16位整数+4位小数的分频值

举个例子,如果fCK=72MHz,目标波特率115200:

USARTDIV = 72000000 / (16 × 115200) = 39.0625
整数部分 = 39 (0x27)
小数部分 = 0.0625 × 16 = 1 (0x1)
USART_BRR = (39 << 4) | 1 = 0x271

实际配置时,我建议直接查芯片手册的波特率表格,或者用官方库的宏定义。手动算容易算错,尤其是小数部分。

fCK (MHz) 目标波特率 USARTDIV 实际波特率 误差
72 115200 39.0625 115200 0%
72 9600 468.75 9600 0%
8 115200 4.3403 115107 0.08%
8 921600 0.5425 921659 0.006%
注意: 波特率误差超过2%就可能出现乱码。尤其是高速率(如1Mbps以上),对时钟精度要求更高。我曾经用内部RC振荡器跑921600,结果丢包率高达30%,换成外部晶振才解决。

4.2 轮询模式 vs 中断模式

两种模式各有适用场景。轮询模式简单直接,但会阻塞CPU;中断模式效率高,但代码复杂度上升。我个人的选择标准是:调试阶段用轮询,产品阶段用中断。

轮询模式: 不断检查状态寄存器,有数据就读,有空间就写。

// 轮询发送一个字节
void uart_send_byte(char c) {
    while (!(USART1->SR & USART_SR_TXE));  // 等待发送缓冲区空
    USART1->DR = c;
}

// 轮询接收一个字节
char uart_recv_byte(void) {
    while (!(USART1->SR & USART_SR_RXNE));  // 等待接收数据
    return USART1->DR;
}

中断模式: 使能接收中断,数据到了自动进中断处理函数。

// 初始化时使能接收中断
USART1->CR1 |= USART_CR1_RXNEIE;
NVIC_EnableIRQ(USART1_IRQn);

// 中断服务函数
void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_RXNE) {
        char data = USART1->DR;
        // 将data放入环形缓冲区
        ringbuf_put(&uart_rx_buf, data);
    }
}
实战建议: 中断服务函数里不要做复杂处理,只做数据搬运。我曾经见过有人在中断里调用printf,结果递归进中断,直接栈溢出。数据放到环形缓冲区,主循环再处理,这是标准做法。

4.3 实现printf重定向

printf是C标准库函数,默认输出到控制台。在裸机环境下,我们需要把它重定向到串口。这样就能用printf("%d", var)直接打印变量,调试效率翻倍。

重定向的核心是重新实现_write函数(ARMCC编译器)或__io_putchar(GCC编译器)。

ARMCC (Keil) 环境:

// 重定向fputc到串口
int fputc(int ch, FILE *f) {
    while (!(USART1->SR & USART_SR_TXE));
    USART1->DR = (ch & 0xFF);
    return ch;
}

// 重定向fgetc从串口
int fgetc(FILE *f) {
    while (!(USART1->SR & USART_SR_RXNE));
    return (int)USART1->DR;
}

GCC (ARM GCC) 环境:

// 重定向_write系统调用
int _write(int file, char *ptr, int len) {
    int i;
    for (i = 0; i < len; i++) {
        while (!(USART1->SR & USART_SR_TXE));
        USART1->DR = ptr[i];
    }
    return len;
}

// 重定向_read系统调用
int _read(int file, char *ptr, int len) {
    int i;
    for (i = 0; i < len; i++) {
        while (!(USART1->SR & USART_SR_RXNE));
        ptr[i] = USART1->DR;
    }
    return i;
}
注意: 使用printf会引入标准库,增加代码体积。对于资源受限的MCU(如Flash只有8KB),我建议自己写一个轻量级的打印函数,只支持%d、%x、%s等常用格式,能省下好几KB的空间。

4.4 串口丢数据问题排查

串口丢数据,是嵌入式开发中最常见也最头疼的问题之一。我排查过不下20次,总结下来就几个原因:

  1. 波特率误差过大 — 用示波器量一下TX引脚的波形,看位宽对不对
  2. 接收缓冲区溢出 — 中断服务函数处理太慢,数据被覆盖
  3. 中断优先级冲突 — 高优先级中断长时间占用CPU,串口中断被饿死
  4. 硬件流控未使能 — 高速传输时,接收方来不及处理,数据丢失
  5. DMA配置错误 — 使用DMA时,传输完成中断未正确处理

我曾经遇到一个案例:系统有多个外设中断,串口接收中断优先级设得比较低。结果某个高优先级中断处理函数执行时间过长,串口数据连续丢失。解决办法很简单——把串口接收中断优先级提到最高,或者在高优先级中断里尽量减少处理时间。

另一个常见问题是环形缓冲区太小。假设你的MCU主频72MHz,串口波特率115200,每秒约11.5KB数据。如果主循环处理速度跟不上,缓冲区很快就满了。我一般把环形缓冲区设为1KB以上,并监控缓冲区使用率,超过80%就报警。

排查步骤:
  1. 用示波器或逻辑分析仪抓取TX/RX波形,确认波特率正确
  2. 在中断服务函数入口和出口加GPIO翻转,用示波器测量中断响应时间
  3. 检查环形缓冲区是否溢出,打印缓冲区使用率
  4. 确认中断优先级配置,确保串口中断不被长时间阻塞
  5. 如果使用DMA,检查DMA传输完成标志和中断处理

4.5 本章知识体系

下面这张图总结了UART串口调试的核心流程和关键节点:

UART串口调试知识体系 UART串口调试知识体系 UART硬件配置 波特率计算与配置 轮询 vs 中断模式 printf重定向 时钟源选择 分频值计算 轮询发送/接收 中断+环形缓冲区 DMA传输 _write/fputc实现 轻量级打印函数 串口丢数据问题排查 示波器测波形 检查缓冲区溢出 中断优先级排查 DMA配置检查 硬件流控使能

这张图把UART调试的三大块——配置、模式、故障排查——串起来了。你可以把它当作一个检查清单:调串口时,从上到下过一遍,基本不会漏掉关键点。

个人习惯: 我每到一个新平台,第一件事就是写一个串口驱动,然后跑一个“Hello World”循环打印。串口通了,后面所有调试都有底气了。如果串口不通,先查硬件连接,再查时钟配置,最后查波特率——90%的问题出在这三个环节。