调试嵌入式程序,串口就是开发者的“眼睛”。尤其当你面对一个刚点亮板子、什么现象都看不出来的系统时,能输出一屏调试信息,基本就等于把程序“看透了”。而Keil MDK下最省事的调试手段,莫过于把标准库的printf重定向到串口:代码里随手一行 printf("state=%d\r\n", state),数据就通过串口线呈现在电脑屏幕上,省去了一步步断点查看变量的痛苦。这篇文章我就把整个流程掰开揉碎讲清楚,从串口底层初始化、fputc函数改写,到MicroLIB的勾选和常见坑位,一次性带你跑通。
我不会给你贴一堆只有结论没过程的代码,而是把每个环节的“为什么”也一并交代。毕竟单片机开发里,能用是一回事,能根据现象快速定位问题才是真正值钱的本事。你跟着做完一遍,不仅能顺利在串口调试助手上看到“Hello World”,万一哪天换了个芯片型号、换个频率,也能自己推导出正确的配置,而不是到处翻别人写好的工程。
适合正在学STM32、标准库或HAL库环境下的新手朋友,也适合那些一直用断点调试、想换个更高效的调试思路的工程师。老手跳过前面基础部分直接看MicroLIB相关的注意事项和问题排查就行。
1. 重定向思路与准备工作
1.1 为什么偏偏是printf
简单说,printf是C语言标准库里的格式化函数,它能把变量、字符串、数字按指定格式拼成一段文本。但在嵌入式环境下,标准输出(stdout)并没有像电脑上那样默认对应到屏幕终端,这时候printf输出的内容要么被丢进虚空,要么直接触发异常。重定向的做法,就是把printf底层干活的“输出函数”换成我们自己的串口发送函数,让格式化后的字符串一个字节一个字节地从UART发出去。
很多人会问:“串口调试信息我直接写个USART_SendString不就行了?费劲搞printf干嘛?”确实,简单的状态指示用裸发送完全够用,但一旦涉及数值拼接、浮点显示、多个参数组合,手写发送函数比用printf费劲太多。比如你要把温度、湿度、电压三个变量一起打印,printf一行搞定,裸发送得先搞三个sprintf再加发送逻辑,还容易出buf溢出问题。
另外一个原因是C标准库的底层设计本身就考虑到了这种“重新定向”的场景,它不是让你去改库内部代码,而是预留了fputc这类钩子函数,你只要按约定实现自己的版本,链接器就会自动把库里的弱定义替换掉。这个机制在Keil MDK和GCC工具链里都成立,只不过不同编译环境踩的坑不一样,稍后详细说。
1.2 动手前的环境清单
我用的是最常见的STM32F103系列芯片,Keil MDK 5环境,串口1工作在115200-8-N-1模式。这套组合在国产ARM、GD32、AT32等芯片上同样适用,因为基础原理一致,只需留意不同芯片的时钟树和寄存器差异。
准备工具如下:
- 开发板一块,最好带板载CH340或FT232之类的USB转串口芯片
- 杜邦线或直接USB线连接电脑
- 串口调试助手,推荐XCOM或SSCOM,Windows下不用安装,绿色版直接打开
- Keil MDK 5.x,装了对应芯片的器件支持包
- STM32标准外设库或HAL库都行,本文示例用标准库,底层寄存器操作部分是通用的
硬件连接方面,USART1的TX(PA9)接USB转串口模块的RX,USART1的RX(PA10)接模块的TX,GND务必连在一起。如果你用的是板载USB转串口,那这块基本已经帮你接好,只需要插USB线和拨对启动模式。
2. 串口底层驱动:让USART先跑起来
2.1 串口初始化与波特率计算
串口通信的本质是“双方约定好一个节奏,然后一位一位地把高低电平传输出去”。这个节奏就是波特率。发送方和接收方必须用同一个波特率,否则收到的是乱码,而且这种乱码不是线路接错那种固定规律,是随机性很强的错乱。
STM32的USART波特率由时钟分频决定,标准库的配置代码通常长这样:
void USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baudrate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); }这里波特率是直接通过库函数写进寄存器的,库内部会做一次除法取整计算。计算公式是:
若PCLK为72MHz,波特率115200时,USARTDIV = 72000000 / (16 × 115200) = 39.0625
这个39.0625会被拆成整数部分和分子部分写进BRR寄存器。所以它并不是无限精确的,但误差通常在0.2%以内,完全满足UART的容错范围。注意:如果你改了系统主频或APB2分频系数,这个计算结果是会变的,移植代码时务必确认RCC的时钟配置和你期望的一致。
2.2 一个字节的发送:判断TC还是TXE
重定向printf的核心不是格式化的逻辑,而是“把字符串里每一个字符发出去”这个动作。这个动作能不能稳,关键在于你是否正确等待了发送状态位。
USART有两个容易混淆的标志位:TXE(发送数据寄存器空)和TC(发送完成)。
- TXE置1,表示数据已经从寄存器搬到移位寄存器,你现在可以往DR寄存器塞下一个字节了
- TC置1,表示整个字节的每一位都已经从TX引脚发出去了
用TXE判断,传输效率更高,因为你可以连续往寄存器里塞数据,不需要等到一帧完全发完。用TC判断逻辑更保险,但效率稍低。不过对调试用途来说没有本质区别,我习惯用TC,处理不慎的话数据不会丢。
一个最简单的字节发送函数:
void USART1_SendByte(uint8_t ch) { USART_SendData(USART1, ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); }如果你用HAL库,对应逻辑是:
void USART1_SendByte(uint8_t ch) { HAL_UART_Transmit(&huart1, &ch, 1, 0xFFFF); }HAL库内部同样会等待TXE或TC状态,只是它把超时机制也封装进去了,阻塞式发送简单粗暴但好用。
2.3 中断接收与环形缓冲的铺垫
本文核心是输出,但很多人的项目其实还需要“收发一体”的能力。如果你只做printf重定向,到上面这一步就已经满足发送要求了。但如果你跟我一样,调试时还想通过串口下发命令来控制板子状态,那就得把接收也规划好。
建议的做法是开串口接收中断,把收到的字节放进一个环形缓冲区,主循环里再做命令解析。这个结构在处理不定长数据时特别好用,因为它把“接收数据”和“处理数据”解耦了。用固定数组存不定长包很容易被覆盖,环形buffer就不会。
串口中断服务函数的雏形大概是:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); ringbuf_push(&rxBuf, data); } }注意在中断里读取USART_ReceiveData这个动作本身就会清掉RXNE标志位,别再多写一次清除操作,那是多余动作。
3. printf重定向核心:fputc改写
3.1 标准库里printf的底层链路
不要被printf这个宏大的名字吓住,它在嵌入式环境下的调用链其实很清晰。当你调用printf("Hello %d", x)时,编译器会按照C库的实现,把格式化后的字符串逐字符地交给一个名为fputc的函数。标准的fputc会往stdout对应的文件描述符写入,但在裸机环境下,根本没有文件系统,所以这条链路需要人为改道。
Keil MDK的ARMCC/AC5编译器和AC6(ARM Compiler 6)对重定向的写法略有差异。AC5下,你只要实现了fputc,并且不勾选MicroLIB,链接器就能自动找到你的版本。AC6下的语法差不多,但要小心头文件声明冲突,建议加上#include <stdio.h>。
ARM GCC工具链用的则是_write函数,这个后面不展开,主要说Keil环境。
3.2 手动改写fputc并处理半主机模式
最简单的fputc实现:
int fputc(int ch, FILE *f) { USART1_SendByte((uint8_t)ch); return ch; }如果你只是用标准库而不勾选MicroLIB,编译链接时极大概率会报出类似“Error: L6915E: Library reports error: __use_no_semihosting was requested, but _sys_exit was referenced”的错误。这是ARM的半主机(Semihosting)机制在作祟。半主机是ARM调试器为了在开发板上模拟PC控制台而设计的一套协议,正常情况下我们根本不需要它,但标准库的默认实现里带着相关函数,链接时就会把它拽进来。
解决方案有两种:一是直接用MicroLIB,它默认不做半主机依赖,更贴近裸机环境;二是在代码里加入如下一段,把半主机函数全部“屏蔽”掉。
#pragma import(__use_no_semihosting) void _sys_exit(int x) { x = x; } void _ttywrch(int ch) { ch = ch; }记得还要提供struct __FILE的定义和fgetc函数,否则会报“__stdout”未定义。
struct __FILE { int handle; }; FILE __stdout;这一套下来,标准库的重定向才能顺顺利利通过链接。不过说实话,如果你只是想在开发板上快速看到printf输出,直接勾MicroLIB是最省事的路径。
3.3 MicroLIB的作用和选择逻辑
MicroLIB是Keil提供的一个精简版C运行库,它最大的特点就是体积小、适合嵌入式。默认标准库会把浮点格式化、文件流、内存分配这些全都带上,有时候一个printf就能让固件凭空多出几十KB。MicroLIB砍掉了不少这些功能,换来的就是最终生成的bin文件大幅瘦身。
配置路径:点击魔法棒(Options for Target),在Target标签页找到“Use MicroLIB”,勾选上即可。就这么一项,之后重新编译,你发现之前跟半主机报错相关的问题基本烟消云散。因为MicroLIB内部实现里,对半主机相关符号的引用已经降到最低,用起来更省心。
但是有个经典坑位:浮点格式化。MicroLIB里的printf对%f的支持是“阉割”过的,默认情况下你打印float类型,出来的是空字符串或者乱码。如果你非要打印浮点数,要么用强制转换拆成整数部分和小数部分分别打印,要么上标准库再加半主机屏蔽代码。我在做PID调试时经常要打印浮点数值,遇到这个坑时最开始也懵了半天。
视图:如果你需要打印浮点数,另一个做法是用sprintf把浮点转成字符串,然后再通过串口发送,但sprintf同样被MicroLIB影响,所以并不能根治。不少工程师干脆自己写一个浮点转字符串的函数,代码量不大,逻辑也不算复杂,后面实用价值很高。
3.4 彻底理解:为什么勾了MicroLIB就不用管semihosting
刚学的时候我也很困惑,为什么同样的代码,勾一个选项差异这么大。后来翻ARM的文档才算看明白:标准库的默认构建里,很多与主机交互的弱函数都指向半主机版本,所谓“半主机”就是调试器和目标板之间通过一个特殊异常进行通信,让开发板能直接使用主机的键盘、显示器、文件等资源。既然你用串口做了自己的输出通道,半主机这套机制自然就不需要了。
MicroLIB没有把这些半主机的钩子编进来,所以它天然适合裸机编程。但它会对文件操作等做“空实现”或者直接返回错误,这也是为什么在MicroLIB下想用fopen、fread这种函数基本行不通,因为嵌入式裸机本来也没有文件系统。如果你的项目要在SD卡上做文件记录,那MicroLIB可能不太适合,你选择标准库方向而不是避而不谈,这样文章信息量更大。
4. 实操演示与验证
4.1 完整代码结构
下面是我在项目里实际用的一套最小代码结构,包含串口初始化、fputc重定向、MicroLIB配置,以及一个验证用的主循环。
#include "stm32f10x.h" #include <stdio.h> void USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baudrate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } void USART1_SendByte(uint8_t ch) { USART_SendData(USART1, ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); } int fputc(int ch, FILE *f) { USART1_SendByte((uint8_t)ch); return ch; } int main(void) { uint16_t counter = 0; USART1_Init(115200); printf("\r\nSystem boot...\r\n"); while (1) { printf("counter=%d\r\n", counter++); for (volatile uint32_t i = 0; i < 7200000; i++); } }这个程序会每隔约一秒钟通过串口打印一行计数器信息。代码看起来简单,但已经包含所有核心要素。
4.2 编译链接的关键配置
在Keil MDK里,除了勾选MicroLIB,还有两处值得检查的设置。第一处是“Options for Target -> C/C++”,在“Language / Code Generation”里确认把优化级别调到一个合适的档位,DEBUG阶段建议用-O0或者默认,因为高了优化后,局部变量可能被优化掉,单步调试时看变量值会不准确。第二处是“Debug”标签页,如果你用的ST-Link,选择ST-Link Debugger,然后Settings里确定Flash Download配置完成。
链接阶段常见的报错我已经在前面提过,如果你没有勾选MicroLIB,很可能报出如下错误:
.\Objects\project.axf: Error: L6915E: Library reports error: __use_no_semihosting was requested, but _sys_exit was referenced此时两种处理路径:路径一,打开MicroLIB,几乎不报错;路径二,在代码里加入半主机屏蔽代码。我通常优先用MicroLIB,只有在确实需要微库不支持的功能时才切换回来,并补上__use_no_semihosting那一整套。
4.3 用串口助手验证输出
把程序下载到板子后,打开XCOM串口助手,选择正确的COM口号。如果你的USB转串口模块是CH340,Windows一般会自动装好驱动;卸载乱装后的COM口不对,显示“Port open failed”这类提示时,先去设备管理器看一眼驱动是否正常。
波特率选择115200,数据位8,停止位1,无校验,然后打开串口。按下开发板Reset按钮,应该立刻看到两行信息,之后每秒打印一个逐渐递增的counter值。如果屏幕上一片空白,或者出现类似couter=??的乱码,别急着怀疑代码,先检查波特率、串口选择、接线GND这三个最基础的项。我遇到最多的状况是串口号选错,或者在HAL库环境里初始化失败导致时钟没跑起来。
另一种情况:你看到了输出,但内容出现“重复”或“缺失”。这种往往是波特率的误差超出容忍范围,此时最需要核实的地方是:你的时钟树配置是否与代码中RCC设定一致。比如把72MHz当成8MHz来跑外设总线,波特率误差会大到你肉眼可见的乱码。
5. 高频问题排查与避坑经验
5.1 printf中文乱码:编码对齐的经典问题
“printf中文乱码”在调试信息里太常见了。很多时候程序逻辑没问题,串口线也没问题,但屏幕上显示出来的是几个莫名的符号。主要原因大多数是字符编码不一致:Keil默认源码编码是ANSI,而串口助手按UTF-8或者GBK来解码,但输出汉字在两者之间转换时错位。
我建议:调试阶段尽量用英文或拼音输出,减少编码问题;如果项目确实必须输出中文,请保证源码文件、编译器编码、串口助手编码三者一致。具体操作是:在Keil的Edit -> Configuration -> Editor -> Encoding里把源码编码设置为UTF-8,同时在串口助手里也选择UTF-8解码,通常能解决。
插一句,某些国产串口助手在UTF-8解码上有bug,换XCOM或者SSCOM基本就好使了。
5.2 一点输出都没有?从这几个方向排查
- 引脚配置错:USART1的TX/RX引脚复用模式对不对,标准库是GPIO_Mode_AF_PP,HAL库要配成GPIO_AF串口复用
- 时钟没开:RCC_APB2PeriphClockCmd这个调用是否真的执行到了
- 串口助手端口选错:打开设备管理器确认COM号
- 板子复位问题:换成外部复位试试,或者加长延时上电
- 断电重来:有些板子下载后要手动复位一次才执行
如果用ST-Link下载,确保没有勾选“Reset and Run”也没有关系,按一下板子上的复位键即可。
5.3 HardFault:中断与printf的冲突
如果程序里同时用了其他中断,并且在中断里调用printf,基本百分之百会触发HardFault或者程序卡死。原因非常直白:printf内部会做一些较重的操作(格式化、循环输出),它不满足中断服务程序短小精悍的原则。而且如果你在中断里和主循环里同时调用printf,还可能造成重入问题,进而打乱底层状态。
我的原则是:中断里只放标志位或者把数据通过环形缓冲暂存,真正的格式化输出放到主循环里统一处理。如果必须在中断里查看特定数据,那就单独用GPIO翻转来指示,或者记录到一个数组里,等中断结束后再打印。
5.4 波特率明明设置一致,为什么还是乱码
很多人把波特率匹配当成第一排查点,但忘了检查外设时钟。标准库工程里,SystemInit应该已经帮你配好72MHz主频,但如果你在别处重新配置了RCC,或者从别的工程拷过来串口初始化代码,那很可能USART外设挂载的APB2时钟不是72MHz而是36MHz甚至8MHz。
这种情况下的“乱码”是稳定的但解码不出来,因为波特率是实际波特率和设定波特率之间的误差。你可以在代码里打印RCC_GetClocksFreq的结果来验证系统时钟,再对着计算一次BRR寄存器的值。还有一个办法:不依赖库函数,直接写BRR。以上这些手段排查后,基本能确定是时钟树配置的问题。
另外,某些USB转串口芯片本身有坑,比如劣质CH340在高速率下不准,建议调试时用9600或者115200这种最常用的速率,不要一上来尝试2Mbps这种超高速,出问题不好分析。
5.5 一份“问题速查表”
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 完全无输出 | COM口选择错误/线没接GND | 设备管理器核对端口,连接GND |
| 乱码 | 波特率不一致或时钟树异常 | 核对波特率,用RCC_GetClocksFreq验证时钟 |
| 输出一次后卡死 | 未等待TC标志或中断里printf | 检查发送等待逻辑,中断内不调用printf |
| 中文显示为问号 | 源码编码和串口助手编码不匹配 | 统一为UTF-8 |
| 编译报L6915E | 标准库+未屏蔽半主机 | 勾选MicroLIB或加__use_no_semihosting |
| printf不输出float | MicroLIB对%f支持不完善 | 转字符串输出或换标准库 |
| 每次打印重复 | 串口助手“按行发送/Hex显示”设置异常 | 取消Hex显示,按文本模式接收 |
6. 我的切身体会:这个功能的实际使用价值
串口printf重定向做完之后,你会明显感受到调试节奏在变。以前我在调一个电机PID参数的时候,每改一次参数都要进调试器看清楚几个变量的变化量,效率很低。搞定printf后,直接在代码里把目标速度、当前速度、PID输出三项拼成一行日志,电脑上就能实时观察曲线变化。整个过程跑下来,参数收敛的速度至少快了一倍。
MicroLIB这个勾选框倒是很小的一个开关,但它对固件大小的影响可能出乎意料。我实测过同样的工程,标准库编译大约30KB,勾选MicroLIB之后能压到20KB以内。当然代价就是我前面说的浮点支持变弱,所以如果对体积不敏感,你也可以选择标准库加半主机屏蔽的方案,稳字当头。
最后分享一个小习惯:我通常在串口调试信息前加一个\r\n,别只写\n。因为某些串口助手只看\r\n才执行换行,只发\n的话屏幕上所有内容都挤在一行。这个细节看起来小,但排查时真能省不少时间。
你现在就可以打开自己的Keil工程,试试把fputc换掉,勾上MicroLIB,然后在主循环里打印一句“Hello World”。看到串口助手里跳出那行字的时候,就算正式踏入“看得见”的嵌入式调试世界了。后面不管是做日志系统、AT指令解析,还是远程升级,这套底子都能直接复用。