☰
STM32串口通信实战:CubeMX配置、printf重定向与乱码排查
2026/10/5 1:30:29 网站建设 项目流程

调了整整一个下午的串口,最后发现是USB转串口模块的地线没接好,这种经历我相信搞嵌入式的朋友多少都经历过。串口通信在STM32开发里属于绕不开的基础功,但越是基础的东西,越容易在小细节上翻车。这篇文章我把从CubeMX配置串口到printf重定向的完整链路拆开讲一遍,把参数配置、时钟树设置、三种printf重定向方案、中文乱码排查,以及接收方向的中断处理方法全部过一遍,争取让你看完就能直接在自己的板子上复现。

内容主要面向两类人:一类是刚拿到STM32开发板、想用串口打印调试信息的新手;另一类是已经在用CubeMX生成工程,但被printf重定向和中文乱码折磨过的朋友。我尽量把每个操作背后的原理和坑都写清楚,不光是给你步骤。

1. 从CubeMX新建工程开始:串口参数背后的选择逻辑

1.1 异步模式与硬件流控:什么时候需要三根线以上

在CubeMX的Pinout & Configuration界面里,左侧找到Connectivity -> USART1,点开后Mode一栏会看到Asynchronous、Synchronous、Single Wire等选项。绝大多数场景直接选Asynchronous(异步收发),这也是串口通信最常用的模式。

这里先厘清一个概念:USART和UART的差别。USART多了一个S(Synchronous,同步模式),也就是带时钟线的通信方式。我们平时调试用的串口基本都是异步模式,只需要TX(发送)、RX(接收)、GND(地线)三根线。异步意味着收发双方各自用自己的时钟去采样数据,所以波特率必须一致,否则数据必然错乱。

Synchronous模式什么时候用呢?一般是和外设芯片通信,比如某些传感器或音频芯片会要求主机提供时钟信号。如果你只是连接USB转串口模块或者蓝牙模块,Asynchronous就够了,不用多想。

硬件流控(Hardware Flow Control)也是一个容易让人困惑的选项。它用的是RTS/CTS两根线,用来解决"发送方发太快、接收方来不及处理"的问题。51单片机时代大家都不太用流控,STM32的HAL库默认也是Disable。实际项目里,除非你的对端设备明确要求硬件流控,或者你要跑高波特率大数据量且接收缓冲区容易溢出,否则不建议开。开了之后反而要多接两根线,排查问题还要额外看RTS/CTS电平,得不偿失。

1.2 115200-8-N-1是默认答案,但不是唯一答案

串口的Parameter Settings里有一串让人眼花缭乱的选项:Baud Rate(波特率)、Word Length(数据位)、Parity(校验位)、Stop Bits(停止位)。大多数教程会让你设成115200、8位数据、无校验、1位停止位,也就是常说的115200-8-N-1。CubeMX默认值就是波特率115200,数据位8,校验None,停止位1,基本不用动。

但"不用动"不代表"不用懂"。8-N-1的意思是:8个数据位、无校验位、1个停止位。数据位代表一个字节的数据帧里真正承载数据的位数;校验位用于简单的错误检测,可选Even(偶校验)、Odd(奇校验);停止位表示一帧数据传输结束后的电平持续时间,可选1位或2位。

校验位这个选项要特别注意一个细节:如果你选择了Even或Odd校验,CubeMX会自动把Word Length变成9。这是因为8位数据加1位校验,总共需要9个bit的位置。很多新手在这里犯了迷糊:我明明选了8位数据,为什么界面上显示9?就是校验位占了位置。没有特殊需求,保持None就好,ST-Link的虚拟串口、CH340、CP2102这些常见的USB转串口方案默认都不带校验。

波特率的选择则和数据传输量、通信距离有关。9600是老设备常用的低速波特率,长线传输更稳;115200是调试场景的甜点值,速度快且误码率可接受;460800、921600这类高波特率适合短距离大数据量传输。如果你发现数据偶尔出现乱码或者字节错位,降波特率是最快的排查手段之一。

1.3 时钟树配置对串口波特率的影响,漏掉这一步必出问题

这是新手最容易忽略、但影响最直接的环节。串口的波特率不是凭空生成的,它由芯片的外设时钟分频而来。STM32F103系列中,USART1挂载在APB2总线上,USART2和USART3挂载在APB1总线上。APB1和APB2的最高频率不同,默认状态下APB1是36MHz,APB2是72MHz。如果你改了系统时钟或者PLL分频系数,串口波特率跟着就会变。

CubeMX里打开Clock Configuration页面,能看到整个时钟树的图形化配置。用STM32F103C8T6举例:外部8MHz晶振(HSE)经过PLL倍频到72MHz系统时钟,AHB预分频器设为1,APB1预分频器设为2(得到36MHz),APB2预分频器设为1(保持72MHz)。USART1挂APB2,所以它拿到的时钟是72MHz;USART2挂APB1,拿到的时钟是36MHz。

波特率寄存器BRR的计算公式是:USARTDIV = PCLK / (16 * 波特率)。比如USART1在72MHz下要得到115200波特率,USARTDIV = 72000000 / (16 * 115200) ≈ 39.0625。这个值最终会被写入BRR寄存器,小数部分由Fraction位保存。如果你系统时钟不小心配成了64MHz而不是72MHz,实际波特率就会偏,数据在长帧传输时就会出现偶发乱码。

我建议的习惯是:每次用CubeMX生成工程后,先看一眼Clock Configuration里APB1和APB2的时钟频率,心里有个数。特别是当你修改了晶振配置或者PLL倍频系数之后,一定要回头检查串口参数,否则后面排查乱码时,你会怀疑人生。

2. printf重定向的三条路线:MicroLIB、半主机模式与GCC环境

2.1 重定向的本质:printf末尾的fputc才是关键

很多人在串口能收到数据之后,下一件事就是想让printf直接打印调试信息。但直接在主函数里写printf("Hello\r\n"),编译能过,串口助手却什么都收不到。原因很简单:默认情况下,printf的输出目标不是串口,而是"标准输出设备"。在PC上标准输出是显示器,在嵌入式平台没有这个概念,所以你得告诉编译器:"把printf的输出送到串口去"。

这个操作叫重定向(Redirect),核心是改写底层字符输出函数。C标准库的printf最终会调用一个底层函数来逐字符输出,在ARM的Keil环境中是fputc,在GCC的newlib环境中是_write(有的版本也兼容fputc)。你只要重写这个函数,在里面调用HAL_UART_Transmit把数据通过串口发出去,printf就会乖乖走串口了。

这里有个新手容易误解的地方:printf本身是C标准库函数,它跟你用的芯片型号、HAL库还是标准外设库没有直接关系。无论你用什么芯片,重定向的思路都一样,只是底层发送函数不同。理解了这一点,你在换芯片平台时就不会慌。

2.2 Keil最快方案:勾上MicroLIB再重写fputc

如果你用的是Keil MDK,最简单粗暴的方案是勾选MicroLIB。操作路径:Options for Target -> Target标签页 -> 勾选Use MicroLIB。MicroLIB是ARM提供的裁剪版C库,体积小、不依赖半主机模式,专门为嵌入式设计。勾选之后,你再重写fputc,printf就能正常输出到串口了。

代码长这样:

#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }

注意两件事:第一,必须包含stdio.h头文件,否则编译器不认识FILE类型;第二,HAL_UART_Transmit的第三个参数是发送长度,这里每次只发1个字节,因为fputc就是一个一个字符地往输出设备塞。HAL_MAX_DELAY表示无限等待,直到数据发送完成。调试场景这么写没问题,如果是高实时性项目,可以考虑改成中断发送并加超时机制,避免阻塞主循环。

MicroLIB方案的好处是改动最小,三四行代码搞定。坏处是MicroLIB对某些C标准函数的支持不完整,比如浮点数格式化在某些版本下会有问题。如果你只是打印整数和字符串,或者你能接受浮点打印的小毛病,这个方案完全够用。我见过很多量产项目直接勾MicroLIB,稳定得很。

2.3 不勾MicroLIB的标准库方案:必须关掉半主机模式

有些项目因为用了复杂的C库函数,不能勾MicroLIB,那就得用另一个方案:保留标准库,同时处理掉半主机模式。

半主机(Semihosting)是ARM的一个调试机制,它允许目标板上的代码通过调试器使用主机PC的输入输出设备,比如printf直接打印到Keil的Debug窗口。这种方式在调试时挺方便,但有两个问题:一是程序运行到半主机调用时,如果调试器没连接,芯片会卡死在HardFault里;二是它和串口输出有冲突,你必须用__use_no_semihosting指令显式关掉半主机,否则链接器会报错。

完整代码如下:

#include <stdio.h> #pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x = x; } int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }

这段代码里有三个关键点:

  • #pragma import(__use_no_semihosting)告诉链接器不需要半主机支持。
  • 手动定义struct __FILE和__stdout,因为半主机模式被关闭后,标准库需要你自己提供这些符号。
  • _sys_exit必须实现,哪怕是个空函数,否则链接器报错。

另一个常见的半主机函数是_ttywrch,有的标准库版本会调用它做单字符调试输出。保险起见,如果链接器提示找不到这个函数,你也补一个空实现:

void _ttywrch(int ch) { ch = ch; }

2.4 Makefile + VSCode + GCC环境的重定向写法

热词里有人搜"cubemx makefile vscode",说明现在很多人不用Keil,改用GCC工具链加VSCode开发了。CubeMX可以生成Makefile工程,配合arm-none-eabi-gcc编译,体验其实比Keil更接近现代开发流程。

GCC环境下的重定向和Keil不同,因为newlib库的函数接口是_write而不是fputc。不过你重写fputc它也能用,只是更规范的做法是重写_write:

#include <stdio.h> #include <unistd.h> int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }

这段代码一次把整个字符串交给串口发送,比fputc逐字符发送效率高。编译时建议在链接选项里加上--specs=nano.specs,它会启用newlib-nano,裁剪掉不需要的C库功能,减小Flash占用。如果你的printf需要打印浮点数,还要在链接选项里加-u _printf_float,否则浮点数会打印成空字符串。

完整的Makefile追加内容大概是:

LDFLAGS += --specs=nano.specs -u _printf_float

这两个参数缺一不可。nano不开启_printf_float的时候,printf的%f格式化函数不会被链接进来,编译不报错,但运行时不输出浮点,这是个非常隐蔽的坑。

2.5 warning #223-D到底在说什么

很多人编译时会看到一行警告:warning: #223-D: function "printf" declared implicitly。这句话的意思是:编译器在遇到printf时,没有找到它的函数声明。最常见的原因就一个——忘了包含stdio.h头文件。

虽然程序能编译过、能链接、甚至能跑,C89允许隐式声明函数,但隐式声明的返回值会被默认当作int,参数类型不做检查。如果printf的参数传错了,比如把指针传成整型,编译器不会警告,结果就是运行时打印出乱七八糟的值。这种bug非常难排查,所以看到223-D警告,第一件事就是检查所有用到printf的源文件,确保第一行附近有#include <stdio.h>。

这个警告还可能出现在你重写了fputc但没包含头文件的场景。fputc的完整声明是int fputc(int ch, FILE *f),里面的FILE类型来自stdio.h。你如果不包含stdio.h,编译器看到FILE会觉得这是个未知类型,可能报错也可能只给警告。标准做法:在重定向的源文件里,第一行就写#include <stdio.h>,不要犹豫。

3. printf打印中文乱码:四个层面的排查思路

3.1 先确认串口助手的编码设置是否匹配

中文乱码是搜索热词里的高频词,也是实际开发里最让人抓狂的问题之一。乱码的原因不一定是单片机端,可能是串口助手的设置不对。常见的串口助手有XCOM、SSCOM、PuTTY、MobaXterm等,它们接收数据后需要按某种编码解码并显示。如果你在代码里写的是UTF-8编码的中文,而串口助手用GBK解码,显示出来就是乱码。

排查方法很简单:先发纯英文和数字测试,比如printf("Hello 123")。如果英文和数字完全正常,只有中文乱码,那问题基本确定在字符编码上。然后检查串口助手的编码设置,看它支持GBK还是UTF-8。有的工具(比如正点原子的XCOM)默认GB2312,有的(比如MobaXterm)默认UTF-8。统一两边编码,乱码立刻消失。

补充一点:STM32F103的Flash里存的就是字节流,它不在乎你写的是中文还是英文,编译器把字符串常量按源码文件的编码转换成对应的十六进制字节。所以源码文件是什么编码,串口发出的就是什么字节。搞清楚这个逻辑,乱码问题就变成"源码编码、串口助手的解码方式"两边是否匹配的问题。

3.2 源码编码与编译器字符集的匹配问题

在Keil MDK中,Keil默认把源文件按系统区域编码处理,中文Windows下通常是GB2312。如果源码文件本身是UTF-8(比如你用VSCode编辑后保存为UTF-8,然后拿到Keil里打开、编译),Keil会把UTF-8的字节当作GB2312来理解,生成的字符串常量字节流可能和你预期不一致。反过来,如果你在Keil里写的UTF-8中文,拿到串口助手用UTF-8看有时正常有时乱码,多半是编译器转换时出了问题。

我在实际项目中踩过的坑是这样的:用VSCode写代码,文件保存为UTF-8,然后拿到Keil里编译烧录,串口助手切换到UTF-8,中文依然乱码。后来发现Keil的Editor设置里有个Encoding选项,默认是System Default (Chinese GB2312)。我把源码文件在VSCode里重新保存为UTF-8 with BOM,并在Keil里把编码改成UTF-8,问题才解决。带BOM是为了让Keil能正确识别文件是UTF-8,不带BOM的UTF-8在Keil里会被当作ANSI处理。

GCC工具链则没有这个问题,它默认按UTF-8处理源码,前提是源码本身就是UTF-8。所以用VSCode + GCC的方式开发,中文处理反而更省心。总之,乱码发生时不要只盯着单片机代码,先确认你的工具链是什么、源码是什么编码、串口助手用什么解码,三者一致了,乱码自然消失。

3.3 波特率误差与时钟漂移造成的"半个字符"乱码

有一种乱码和编码无关,现象是整个字符串里每个字符都不对,或者每隔几个字符错一个。这种情况优先怀疑波特率误差。如前所述,串口波特率由时钟分频而来,如果系统时钟不是标准的72MHz,或者对端设备的时钟精度太差,实际波特率就有偏差。数据位越多,偏差积累越大,帧尾的位就可能被采样错。

判断方法:用示波器看TX引脚的波形,测量一位的宽度。115200波特率下一位应该是约8.68微秒。没有示波器的话,可以试着把波特率降到9600看乱码是否消失。高速时错、低速时对,基本就是时钟误差问题。

另一个容易忽略的点:有些劣质USB转串口模块用的晶振精度不足,标称115200实际偏差很大。这时候不要怀疑你的STM32,先换个模块试试。说到底,串口通信是收发双方的事,发送端(STM32)正确,接收端(模块)精度不够,同样会乱码。

3.4 浮点与长整型格式化被裁剪的问题

最后一种"乱码"特别有迷惑性:printf里明明写了printf("value = %.2f\r\n", 3.14),串口助手什么也没显示,或者显示value = 后面是空白。这不是乱码,是浮点格式化函数没被链接进去。

Keil不勾MicroLIB时,有些版本的库默认不包含浮点打印支持,编译环境可能报错也可能不报错。GCC环境下就是之前说的-u _printf_float参数问题。解决办法是显式开启浮点格式化支持。

另外,如果你打印的是long long类型的变量,要确认格式符用的是%lld和%llu。有些裁剪版C库对64位整数的格式化支持不完整,表现为打印出一半数字。遇到这种问题,建议先打印简单的几个数值,确认基础输出没问题,再逐步增加格式复杂度,缩小问题范围。

4. 接收方向怎么处理:中断收包与不定长数据帧判断

4.1 HAL_UART_Receive_IT的坑与正确打开方式

串口通信不只是往外打印,很多时候还要接收上位机发来的指令,然后执行操作。HAL库提供的接收API里,HAL_UART_Receive_IT是最常用的,它启动一次中断接收、指定接收N个字节后触发回调函数。但这里有个新手必踩的坑:这个函数只生效一次,接收完N个字节后,中断自动关闭,不会再接收下一批数据。你必须在回调函数里再次调用HAL_UART_Receive_IT来重新开启接收。

一个标准的单字节中断接收写法:

uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 处理接收到的字节 process_byte(rx_byte); // 重新开启下一次接收 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } } int main(void) { // 初始化等代码省略 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); while (1) { // 主循环 } }

注意回调函数里的参数huart要判断一下Instance,因为一个工程里可能用了多个串口,如果不判断,所有串口的接收完成都会进同一个回调,容易串数据。

4.2 一字节中断+空闲判断的裸机数据帧方案

如果你要接收不定长的数据帧,比如上位机发来类似AT+CMD\r\n的指令,怎么知道一帧数据结束了呢?业界常用方案是"接收中断 + 空闲中断"(IDLE Interrupt)。空闲中断是指串口在一段时间内没有收到新数据时触发的中断,正好用来判断"这一帧可能发完了"。

HAL库里可以这样开启IDLE中断:

__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

然后在串口中断服务函数里判断:

void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 一帧数据接收完成,处理缓冲区的数据 frame_ready = 1; } }

配合一个环形缓冲区,把每个接收到的字节塞进去,空闲中断到来时统一处理帧数据。这个方案的好处是不需要提前知道帧长度,适合最常见的以固定结束符(如换行)或者不定长指令为单位的通信协议。裸机环境下这样处理完全够用,不需要上RTOS。

至于环形缓冲区的实现,网上有很多版本,原则就是一个:写指针只由中断更新,读指针只由主循环更新,两者都做取模运算,缓冲区满时要么覆盖要么丢弃并置溢出标志。

4.3 用回声实验验证收发链路

解决了接收中断,建议先做一个最基础的回声实验:收到什么就原样发回什么。这个实验能同时验证TX和RX两条链路,是排查串口通信问题的经典手段。

void process_byte(uint8_t byte) { HAL_UART_Transmit(&huart1, &byte, 1, HAL_MAX_DELAY); }

用USB转串口模块连接PA9(USART1_TX)和PA10(USART1_RX),注意交叉连接:模块的TX接单片机的RX,模块的RX接单片机的TX。然后在串口助手里发送任意字符,比如发送"ABC",应该能收到"ABC"回显。如果回显乱码、缺字符或者完全没反应,就按上一章提到的排查顺序走一遍。

5. 串口实战中最容易翻车的几个现场问题

5.1 排查串口问题,先固定排查顺序

遇到串口不通或者数据异常,我的习惯是按固定顺序排查,不跳步。这个顺序是:接线->电平->参数->软件->硬件,每一步都有明确验证方法。

第一,接线。TX和RX是否交叉连接?GND是否连接?很多新手只接了TX和RX,忘了共地。串口通信的电平是相对地线的,不共地,接收端采到的信号根本不对。第二,电平。STM32的USART引脚是TTL电平(3.3V),如果对端是RS232电平(正负12V),必须经过MAX3232等电平转换芯片。直接连会烧引脚或者完全收不到数据。第三,参数。波特率、数据位、校验位、停止位是否完全一致?任何一个不匹配,出来的数据都是错的。第四,软件。检查CubeMX是否生成了正确的GPIO和USART初始化代码,引脚有没有被别的外设占用。第五,硬件。用示波器或者万用表检查引脚是否有波形,换一个USB转串口模块试试。

按照这个顺序,90%的串口问题都能在十分钟内定位。不要一上来就怀疑芯片坏了或者HAL库有bug,绝大多数情况是你自己的线接错了。

5.2 劣质USB转串口模块和供电纹波:干扰的头号元凶

USB转串口模块这个配件,看着不起眼,作用却很大。CH340、CP2102、FT232是常见的主控芯片方案。CH340便宜但品控参差不齐,CP2102和FT232相对更稳。我遇到过用某杂牌模块,通信距离超过20厘米就开始丢字节,换成FT232模块后同样距离完全正常。如果你的串口在短距离调试就出乱码,换个模块试试,成本低见效快。

供电纹波的问题则更隐蔽。STM32开发板如果用劣质USB线或者电源适配器供电,纹波大的时候会影响内部时钟和电平阈值,表现为串口数据间歇性乱码。特别是电机启动、继电器吸合这类大电流动作瞬间,串口乱码概率明显升高。排查方法:给单片机和电机分开供电,或者在电源入口加一个大电容和103/104去耦电容;更简单的做法,把USB转串口模块也重新插拔一下,有时候只是接触不良引起的地电位漂移。

5.3 引脚冲突与CubeMX报警信息的解读方法

CubeMX在生成代码前会检查引脚复用冲突,界面上会用黄色或红色三角号标注冲突引脚。这是很多新手容易忽略的提示。比如USART1默认位于PA9和PA10,如果你同时还在PA10上接了别的外设,CubeMX会报警告,生成的代码可能不会包含USART1的初始化,或者编译时报GPIO配置冲突。

STM32F103C8T6这种小封装芯片引脚资源紧张,项目功能一多,串口引脚和SPI、I2C、定时器引脚打架的情况很常见。解决方案有三个:一是调整外设引脚映射,STM32的USART支持引脚重映射(Remap),可以把USART1映射到PB6/PB7等其它引脚;二是在CubeMX的Pinout视图里用鼠标直接拖拽功能到空闲引脚,CubeMX会自动完成配置;三是最土的办法,换一个引脚资源更丰富的型号。

我用一个表格总结一下串口调试时的常见问题、现象和解决办法,方便你直接对照排查。

现象大概率原因解决办法
完全收不到数据TX/RX接反或未共GND交叉接线,确认GND连通
数据乱码,英文也乱波特率不一致或时钟误差检查波特率设置和系统时钟
只有中文乱码,英文正常源码编码和串口助手编码不匹配统一GBK或UTF-8编码
打印一段后停止USB转串口模块不稳定或供电不足换模块,加强供电
浮点数不显示浮点格式化函数未链接GCC加-u _printf_float,或调整库
接收回调只触发一次忘记重新调用Receive_IT回调函数里重新使能接收

再补充一个容易被忽略的点:热词里有"stm32f103c8t6 串口通信"和"基于stm32的四开关buck-boost双向升降压数字电源",说明不少人在做电源、电机这类项目时用串口做上位机调试。这种场景下,串口通信质量和PCB布局、功率地上的干扰关系很大。我的建议是:调试线尽量短,信号线不要和功率线平行走线太长,必要时在TX/RX线上串联33欧姆左右的电阻,在一定程度上能减小振铃和干扰。

最后分享一个我个人的习惯:调试阶段在串口发送函数外面包一层宏,比如#define DBG_PRINT(...) printf(__VA_ARGS__),之后想关闭打印时,把这行宏替换成空定义就行。量产固件里不关调试打印,不仅浪费Flash,还可能因为串口被占用而影响真正的业务通信。另外,printf格式化里尽量用\r\n做换行,特别是和某些Windows串口助手配合时,只用\n可能会导致不清行。这些小细节,都是踩过坑之后才记得住的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询