4、串口调试技巧:UART初始化与打印函数实现、波特率自适应策略、串口重定向到GDB

串口调试,说白了就是Bootloader的「眼睛」和「嘴巴」。没有它,你就像在黑盒子里摸瞎。我做了这么多年固件,可以负责任地说:串口调通了,Bootloader就成功了一半。

4.1 UART初始化——别小看这几行代码

UART初始化看起来简单,但坑不少。我个人习惯先把硬件手册翻到UART章节,确认三件事:时钟源、引脚复用、波特率寄存器配置。

下面是一个典型的UART初始化代码,以STM32为例:

void uart_init(uint32_t baudrate) {
    // 1. 使能时钟
    RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;

    // 2. 配置TX/RX引脚
    GPIOA->MODER |= (2 << 18) | (2 << 20);  // PA9, PA10 复用功能
    GPIOA->AFR[1] |= (7 << 4) | (7 << 8);   // AF7 = USART1

    // 3. 配置UART参数
    USART1->BRR = (SystemCoreClock / baudrate);
    USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;
}

⚠️ 我曾经踩过的坑: 波特率计算时,SystemCoreClock的值一定要确认。有一次我用的外部晶振是8MHz,但代码里写的是16MHz,结果串口全是乱码。排查了整整半天才发现是时钟树配置错了。

4.2 打印函数实现——从putchar到printf

有了UART硬件,接下来就是打印函数。我建议先实现一个最基础的putchar,然后再包装成printf。

// 基础字符输出
void uart_putchar(char c) {
    while (!(USART1->SR & USART_SR_TXE));
    USART1->DR = c;
    if (c == '\n') {
        uart_putchar('\r');  // 自动加回车
    }
}

// 字符串输出
void uart_puts(const char *str) {
    while (*str) {
        uart_putchar(*str++);
    }
}

嗯,这里要注意:很多终端程序(比如Putty、SecureCRT)只认\r\n作为换行。如果你只发\n,打印出来的内容会像楼梯一样歪歪扭扭。我习惯在putchar里自动补\r,省心。

至于printf的实现,有两种方式:

  • 轻量级方案: 自己写一个简单的格式化输出,只支持%d、%x、%s等常用格式
  • 标准方案: 重定向fputc函数,直接使用标准库的printf

我个人更推荐第二种,因为标准库的printf功能完整,而且调试时可以直接用%p打印指针地址,非常方便。

// 重定向fputc到UART
int fputc(int ch, FILE *f) {
    uart_putchar((char)ch);
    return ch;
}

💡 小技巧: 如果Flash空间紧张,可以用--specs=nano.specs链接选项,使用精简版的printf,体积能减少一半以上。

4.3 波特率自适应策略——让Bootloader更智能

你有没有遇到过这种情况:Bootloader烧进去,串口助手设了115200,结果全是乱码。改成9600,还是乱码。最后发现是客户用的终端工具默认是57600。

这就是为什么我坚持要在Bootloader里加入波特率自适应功能。说白了,就是让Bootloader主动去「猜」上位机用的是多少波特率。

实现思路其实不复杂:

  1. Bootloader启动后,先以某个默认波特率(比如115200)等待一段时间
  2. 如果收到特定字符(比如'B'或0xAA),就锁定当前波特率
  3. 如果超时没收到,切换到下一个波特率继续尝试
#define BAUDRATE_TABLE { 9600, 19200, 38400, 57600, 115200, 230400 }
#define BAUDRATE_TIMEOUT_MS  500

uint32_t uart_auto_baudrate(void) {
    uint32_t baudrates[] = BAUDRATE_TABLE;
    uint32_t detected = 0;

    for (int i = 0; i < sizeof(baudrates)/sizeof(baudrates[0]); i++) {
        uart_init(baudrates[i]);
        uart_puts("B");  // 发送握手信号

        uint32_t timeout = BAUDRATE_TIMEOUT_MS;
        while (timeout--) {
            if (uart_getchar() == 'B') {  // 收到回显
                detected = baudrates[i];
                goto found;
            }
            delay_ms(1);
        }
    }

found:
    if (detected) {
        uart_puts("Baudrate detected: ");
        uart_print_dec(detected);
        uart_puts("\r\n");
    }
    return detected;
}

🔑 核心要点: 上位机也需要配合。我通常在PC端写一个小脚本,收到'B'后立即回发'B'。这样Bootloader就能通过「收到回显」来判断波特率是否匹配。

4.4 串口重定向到GDB——调试的终极武器

说到GDB,很多人的印象还停留在「用JTAG/SWD连接调试器」。但你知道吗?串口也可以作为GDB的通信通道。这在某些场景下特别有用:比如调试器没带、目标板没有JTAG接口、或者你想远程调试。

实现原理其实很简单:GDB支持通过串口与GDB Server通信。你只需要在目标板上运行一个GDB Server,它通过串口接收GDB的命令,然后操作目标CPU。

具体步骤:

  1. 在目标板上实现GDB Server stub:这是一个精简版的GDB Server,只实现必要的命令(比如读内存、写内存、设置断点、单步执行等)
  2. 配置串口参数:GDB默认使用115200波特率,8数据位,无校验,1停止位
  3. 在PC端连接target remote /dev/ttyUSB0:115200

这里我分享一个我实际用过的简化版GDB命令处理框架:

void gdb_handle_command(void) {
    char cmd = uart_getchar();

    switch (cmd) {
        case '?':  // 查询状态
            uart_puts("S05");  // 信号5 = SIGTRAP
            break;

        case 'g':  // 读寄存器
            gdb_read_registers();
            break;

        case 'G':  // 写寄存器
            gdb_write_registers();
            break;

        case 'm':  // 读内存
            gdb_read_memory();
            break;

        case 'M':  // 写内存
            gdb_write_memory();
            break;

        case 'c':  // 继续执行
            gdb_continue();
            break;

        case 's':  // 单步执行
            gdb_step();
            break;

        default:
            uart_puts("E01");  // 未知命令
            break;
    }
}

⚠️ 我曾经踩过的坑: GDB协议是二进制+ASCII混合的,数据包用$开头,#结尾加校验和。我第一次实现时忘了处理校验和,结果GDB一直报「packet too long」。后来才发现是校验和计算错了,导致GDB认为数据包不完整。

串口重定向到GDB的好处是显而易见的:

  • 不需要额外硬件:一根USB转串口线就够了
  • 支持远程调试:通过网络串口服务器,你可以在办公室调试千里之外的设备
  • 调试信息实时可见:GDB的print命令可以直接输出到串口终端

不过也要注意,串口GDB的速度远不如JTAG。设置断点、单步执行这些操作还行,但如果你要下载几百KB的固件,那速度会让你怀疑人生。我一般只在没有调试器的情况下才用串口GDB,平时还是老老实实用J-Link。

4.5 实战经验总结

最后,我把这些年串口调试的坑和心得整理一下:

问题 现象 解决方案
乱码 打印内容全是乱码 检查波特率、时钟配置、引脚复用
不回显 发送数据没反应 检查TX/RX是否接反、电平是否匹配
丢字符 偶尔少几个字符 检查流控设置、中断优先级
打印卡死 程序跑到printf就死机 检查堆栈大小、是否重入了中断

串口调试,说白了就是「慢工出细活」。初始化时多花几分钟确认参数,后面调试能省下几个小时。我个人习惯是在Bootloader的每个关键节点都加一句打印,比如「进入main函数」、「初始化完成」、「跳转到APP」。这样一旦出问题,看打印就能定位到是哪个环节挂了。

嗯,关于串口调试就先聊这么多。下一章我们讲讲Flash操作和固件下载,那才是Bootloader的核心战场。