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主动去「猜」上位机用的是多少波特率。
实现思路其实不复杂:
- Bootloader启动后,先以某个默认波特率(比如115200)等待一段时间
- 如果收到特定字符(比如'B'或0xAA),就锁定当前波特率
- 如果超时没收到,切换到下一个波特率继续尝试
#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。
具体步骤:
- 在目标板上实现GDB Server stub:这是一个精简版的GDB Server,只实现必要的命令(比如读内存、写内存、设置断点、单步执行等)
- 配置串口参数:GDB默认使用115200波特率,8数据位,无校验,1停止位
- 在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的核心战场。