很多搞STM32的朋友,学串口的时候最容易卡壳的点就两个:一个是CubeMX图形化配置出来一堆代码看不懂,另一个是printf死活不能往串口助手打印东西。这篇我就把从CubeMX建立工程到printf重定向的完整链路拆开揉碎了讲一遍,全程基于STM32F103C8T6和HAL库,你跟着操作就能跑通,还能顺便搞明白背后到底发生了什么。
这篇东西适合谁看?刚接触STM32、被串口通信折腾过、或者想系统梳理CubeMX+HAL库串口机制的开发者。我默认你已经装好了STM32CubeMX和Keil MDK,如果还没装,先去把环境搞定,装的时候记得把固件包一并下载了,不然新建工程的时候会卡在选芯片型号那一步。
1. 整体设计与思路拆解
1.1 为什么串口是嵌入式开发的第一块敲门砖
串口(UART)在嵌入式系统里的地位,基本相当于人类世界的语言交流。它不需要复杂的协议栈,两根线(TX、RX)就能实现设备之间的数据传输,调试信息打印、传感器数据上报、与上位机通信、甚至Bootloader固件升级,全都能靠串口完成。很多工程师判断一块板子有没有正常工作,第一件事就是打开串口助手看有没有启动日志,这已经成了行业默认动作。
STM32的USART外设在所有型号上基本都有,它支持全双工通信,也就是可以同时发送和接收。硬件层面只需要注意TX接RX、RX接TX这个交叉连接的规则,如果两个设备直接TX接TX、RX接RX,数据是通不了的。另外还要共地,这个细节经常有人忽略,结果就是数据偶尔对偶尔乱码,查了半天发现是地电位不一致。
我见过不少初学者在串口这块栽跟头,并不是因为串口本身难,而是被一堆抽象概念绕晕了。什么波特率、数据位、停止位、奇偶校验,每个概念单独拿出来都不难理解,但组合在一起,再加上CubeMX里那一堆下拉框,就很容易让人懵圈。其实这些参数只有一个目的:让通信双方按照同样的节奏和规则收发数据。
1.2 为什么选择CubeMX+HAL库这套组合
在CubeMX出现之前,写STM32串口程序的主流方式是操作寄存器或者使用标准外设库(SPL)。寄存器方式最直接,但对芯片手册的熟悉程度要求很高,一个USART外设的控制寄存器就有十几个,每个寄存器里还有不同的位域,配置起来非常容易出错。标准库好一些,封装了一层API,但底层配置逻辑还是得自己写,初始化代码动辄几十行。
CubeMX的出现解决了一个核心痛点:把外设初始化代码从手工编写变成了图形化配置。你只需要在界面上勾选想要的功能、填入对应的参数,它就能自动生成一套完整的初始化代码,底层那些寄存器操作全帮你搞定了。更重要的是,它生成的是HAL库代码,HAL库在标准库的基础上进一步抽象,提供了更统一、可移植性更强的API接口。
那HAL库和标准库到底应该选哪个?我的观点是:新项目直接用HAL库,除非你有特殊的性能要求或者需要兼容老代码。原因有三点:一是HAL库是ST官方当前主推的方向,资料和社区讨论更多;二是HAL库代码抽象层次更高,换芯片型号或者换外设时,应用层代码基本不用动;三是CubeMX和HAL库天然集成,用CubeMX生成工程就不可避免接触到HAL库。
当然,HAL库也有被人诟病的地方,比如代码量大、执行效率比寄存器方式低、抽象层次高导致底层控制不够灵活。但在绝大多数应用场景下,这些缺点换来的开发效率和可维护性提升是完全值得的。
1.3 串口通信的核心参数到底在配置什么
串口通信的本质,就是把并行的数据变成串行的比特流,按照约定的时间间隔一位一位地发送出去。这个时间间隔就是波特率,单位是bps,表示每秒钟传输多少位。通信双方必须以相同的波特率工作,否则接收方采样到的数据就是错乱的。
一个常见的波特率是115200,这个数字看起来奇怪,但它的来源有历史原因,早期晶振频率选择和分频计算导致了一些约定俗成的波特率值。简单理解就是:只要收发双方约定好一个值,并且误差在容忍范围内,通信就没问题。STM32的USART波特率由外设时钟经过分频计算得到,CubeMX会自动算出最接近你设定值的分频系数,如果算出来的实际波特率和设定值偏差太大,界面上会有提示。
除了波特率,还需要配置数据帧格式:数据位通常是8位,停止位是1位或2位,校验位可选无校验、奇校验、偶校验。这组参数组合起来就是串口通信的帧格式,收发双方必须完全一致才能正确解析数据。CubeMX默认配置是115200-8-N-1,也就是波特率115200、8位数据位、无校验、1位停止位,这也是绝大多数场景下的标准配置。
2. CubeMX串口配置全流程
2.1 新建工程与芯片选型
打开STM32CubeMX,点击New Project进入芯片选型界面。这里有两种方式查找芯片:一种是输入具体的型号名称,比如你要用STM32F103C8T6,直接在搜索框输入即可,下方的列表中就会过滤出对应芯片。另一种是通过系列、封装、内存大小等条件组合筛选,适合还没确定具体型号的情况。
选好芯片后双击进入主界面,系统会提示你是否要初始化所有外设到默认状态,选择Yes就可以。这一步会自动帮你把系统时钟、GPIO等基础配置初始化好,省去不少手动配置的麻烦。
在实际选型的时候,我建议你多留意一下芯片的封装和资源。STM32F103C8T6是LQFP48封装,有20KB的RAM和64KB的Flash,两个USART(USART1和USART3),资源不算丰富但胜在便宜、资料多、社区活跃,非常适合学习和简单项目。如果你的项目用到了多个串口还要跑协议栈,建议选更大容量的型号,比如STM32F103RCT6或者STM32F407系列。
2.2 RCC时钟配置与原理
在主界面的左侧分类栏中,找到System Core -> RCC(Reset and Clock Control),这里配置的是系统时钟源。对于STM32F103系列,常用的配置是HSE(外部高速晶振),选择Crystal/Ceramic Resonator。这一步的意义在于:使用外部晶振比内部RC振荡器精度高很多,串口通信对时钟精度很敏感,如果时钟源偏差太大,产生的波特率误差也会变大,长时间通信时累积的误差会导致乱码。
配置完时钟源后,进入Clock Configuration选项卡,这里可以调整系统时钟树。对于F103系列,典型配置是系统时钟(SYSCLK)72MHz,APB1总线时钟36MHz,APB2总线时钟72MHz。USART1挂在APB2上,USART2和USART3挂在APB1上,这一点很重要,因为波特率计算的基准时钟就是外设挂载的总线时钟。
我用的是8MHz外部晶振,CubeMX会自动计算各个分频器的值。如果你用的是其他频率的晶振,需要手动调整PLL配置。具体操作是:在HSE输入框填入你的外部晶振频率,然后在PLL Source Mux选择HSE,再配置PLL Multiplier让系统时钟达到目标频率。CubeMX会用红色字体提示不合理的配置,方便你调整。
2.3 USART引脚模式与参数配置
找到Connectivity -> USART1,在Mode旁边会有一个下拉框。我们做的是基本的收发通信,所以选择Asynchronous(异步模式)。选择之后,界面会自动分配USART1的默认引脚,F103C8T6上USART1的默认引脚是PA9(TX)和PA10(RX),当然你也可以选择在引脚映射中手动重新分配。
接下来要配置的参数按其重要性排列:
波特率(Baud Rate)输入115200,这是参数配置里最核心的一项。字长(Word Length)选择8 Bits,也就是一个字节(Byte)。奇偶校验(Parity)选择None,即无校验。停止位(Stop Bits)选择1。这三个组合起来就是经典的8N1格式,绝大多数串口调试工具默认就是这种格式。
在Advanced Parameters部分,你还会看到一些高级配置项,比如过采样(Oversampling),默认16倍过采样是全速工作模式,用这个就行。另外还有使能发送和接收的选项,默认是分开的,如果需要同时使用,记得发送和接收都要勾选。
关于引脚复用的细节:USART的TX、RX引脚并不是普通GPIO,它们被复用了USART功能。CubeMX会自动把PA9、PA10设置为复用推挽输出和复用输入模式,并且使能对应的USART时钟和GPIO时钟。这就是HAL库的好处,你在界面上勾选一下,底层GPIO模式、时钟使能全帮你自动配好了。
2.4 NVIC中断配置
如果你只是做简单的查询式收发,NVIC(嵌套向量中断控制器)可以不配置。但实际项目中,串口接收通常都配合中断使用,因为你不知道数据什么时候会来、来多少字节,用查询方式会让CPU空转等待,浪费资源。
在NVIC Settings选项卡中,勾选USART1 global interrupt。如果你还需要配置中断优先级,可以根据项目需求设定抢占优先级和子优先级。对于大多数单片机项目,串口中断的优先级可以设置得比主循环高,但比紧急的定时器中断低,这样就可以保证数据不丢失,同时不扰乱其他关键任务的实时性。
有一点容易忽略:CubeMX生成工程后会自动在启动文件(startup_stm32f103xb.s)中声明并定义USART1_IRQHandler的中断服务函数,你需要在代码中实现这个函数并在其中调用HAL_UART_IRQHandler(&huart1)。如果忘了调用HAL库的中断处理函数,中断即使触发也只会进入死循环或者直接被忽略,这是新手常踩的一个坑。
2.5 生成工程代码
全部配置完成后,点击右上角的GENERATE CODE按钮。在弹出的对话框中,选择Toolchain/IDE为MDK-ARM(就是Keil),然后设置工程名称和存放路径。这里有个关键点:工程路径绝对不能包含中文或空格,否则Keil编译的时候会报各种奇怪的错误,而且这些错误很难通过看报错信息定位到是路径问题。
代码生成完成后,点击Open Project可以直接打开Keil工程。你会发现CubeMX给你生成了一套完整的工程结构:Core目录下包含main.c、stm32f1xx_it.c(中断处理文件)、stm32f1xx_hal_msp.c(外设使能和引脚配置)等文件,Drivers目录下是HAL库源码和CMSIS核心文件。整个工程可以直接编译烧录,但我们现在连串口打印的代码都还没写,接下来就进入实战环节。
3. HAL库串口收发机制与代码实现
3.1 先读懂CubeMX生成的初始化代码
打开main.c,你会看到MX_USART1_UART_Init这个函数,里面有一段初始化代码。核心部分是UART_InitTypeDef这个结构体的各个字段赋值,对应我们之前在界面里配置的那些参数。函数最后一行会调用HAL_UART_Init(&huart1),这个函数是HAL库对外提供的初始化入口,它会根据结构体配置的内容,把寄存器写到对应的值,完成USART1的初始化。
在main.c的main函数里,还有一个关键的调用:HAL_UART_MspInit,这个函数在stm32f1xx_hal_msp.c中实现。它的作用比较特殊:配置外设底层的时钟、GPIO引脚、以及中断相关配置。你可以把它理解为外设初始化的一部分——HAL_UART_Init负责配置串口本身的寄存器(如波特率、字长等),HAL_UART_MspInit负责配置串口外部的支撑资源(如引脚复用模式、时钟使能、中断优先级)。
虽然这些初始化代码是由CubeMX自动生成的,我建议你还是认真读一遍,至少要知道初始化流程分成了哪两部分,这样以后遇到串口不工作的问题时,第一反应是去检查GPIO配置对不对、时钟有没有开,而不是在应用代码里瞎找。
3.2 轮询方式发送与接收
HAL库提供了三个层次的串口数据收发API,分别是轮询(Blocking)、中断(Interrupt)和DMA。最基础也最容易理解的是轮询方式,它的特点是:发送数据时,函数会在循环里等待串口数据发送完成才返回;接收数据时,函数会一直等,直到收到指定长度的数据才返回。
发送一字节:HAL_UART_Transmit(&huart1, (uint8_t *)&byte_data, 1, HAL_MAX_DELAY);发送多字节:HAL_UART_Transmit(&huart1, buffer, len, HAL_MAX_DELAY);接收多字节:HAL_UART_Receive(&huart1, buffer, len, HAL_MAX_DELAY);
第四个参数是超时时间(Timeout),用HAL_MAX_DELAY表示一直等待直到完成。使用轮询方式收发数据,代码逻辑最简单直观,适合在初始化阶段打印日志、或者在主循环里周期性发送数据。缺点也很明显:如果接收端一直没有数据,HAL_UART_Receive会一直阻塞在那里,导致整个程序卡住。
所以轮询方式不适合做实时性要求高的场景。主循环里如果需要等待不定长度的数据,建议用中断方式,或者配合空闲中断(IDLE)实现不定长接收。
HAL_UART_Transmit在发送过程中如果开启了中断,还能提供超时机制,防止死等。在HAL库实现中,它内部使用了信号量(Semaphore)和状态机(State Machine),如果你去看HAL库源码,会发现其实内部实现还挺复杂。初学者先会用API就行,不用急着啃源码。
3.3 中断方式接收的原理与实现
中断接收的核心思想:数据到达后,硬件会自动触发USART中断,CPU暂停当前任务去执行中断服务函数,处理完数据后再回到原来的任务继续执行。这样CPU就不需要一直轮询接收,效率高很多。
HAL库中断接收的使用方式分两步。第一步是调用HAL_UART_Receive_IT(&huart1, buffer, length)启动中断接收,这个函数执行后立即返回,程序继续向下执行。底层通过寄存器配置了接收中断使能,当收到length个字节后会自动调用回调函数。
第二步是重写中断回调函数HAL_UART_RxCpltCallback(这个函数是一个弱函数(weak),你在用户代码中重新实现时,编译器会用你的函数替代弱函数)。数据接收完成后,HAL库会调用这个回调函数,你在这个函数里处理收上来的数据。
这里需要特别注意:HAL_UART_Receive_IT是一次性的,也就是说,它一次只接收指定长度的数据,收完之后就不会再继续接收了。如果你想要持续接收数据,必须在回调函数里再次调用HAL_UART_Receive_IT。我见过很多新手代码只调用了一次HAL_UART_Receive_IT,结果发现只能收到一条数据后面就再也没反应了,就是这个原因。
另外,串口中断服务函数应该在stm32f1xx_it.c中实现:void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); }。CubeMX生成工程时,这个函数已经帮你创建好了,你只需要在对应的中断处理文件中调用HAL_UART_IRQHandler即可。
3.4 空闲中断+DMA实现不定长接收
如果说中断接收是入门,那空闲中断(IDLE Line Interrupt)加DMA就是不间断接收数据的进阶玩法,也是工业项目中最常用的串口接收方案。
空闲中断的触发条件是:串口在一段时间内没有收到新的数据,硬件认为这一帧数据接收完毕,就会触发一个空闲事件。利用这个机制,我们就可以实现不定长数据的接收——数据什么时候结束不知道,但空闲中断能告诉我们这次接收结束了。
DMA在这个方案里的作用是不依赖CPU,直接把串口接收到的数据搬到内存缓冲区。CPU只需要在空闲中断触发时去缓冲区读取数据,不需要逐字节处理。这样即使有几千字节的数据到达,CPU也可以专注于其他任务。
配置方式上,CubeMX中在USART1的DMA Settings选项卡里添加一个接收方向的DMA请求(USART1_RX),模式选择Normal,数据宽度选择Byte。然后在NVIC Settings里使能USART1全局中断和DMA中断。代码实现大致是:初始化时调用HAL_UART_Receive_DMA(&huart1, buffer, buffer_size)启动DMA接收,然后实现HAL_UARTEx_RxEventCallback回调函数。空闲中断触发后,该回调函数会被调用,在回调中读取剩余数据长度,处理数据后再次启动DMA接收。
这个方案能扛住实际项目中几乎所有的串口数据接收需求。我做的不少产品串口通信频率不低、数据长度变化也大,就是用这套机制跑得很稳定。如果你刚上手,建议先把普通中断接收跑通,再尝试加入DMA。
3.5 发送机制的底层行为剖析
串口发送在HAL库里有几个实现细节值得理解。当你调用HAL_UART_Transmit时,库函数会在发送数据前检查串口的发送状态,如果上一次发送还没完成会返回超时错误。对于轮询方式,就是简单地逐字节写入数据寄存器(DR),然后等待发送完成标志位(TXE)置位。
使用中断方式发送的过程则更复杂一些。HAL库会把你要求的发送任务挂到内部的发送状态机中,然后使能发送中断(TXE中断),每发送一个字节,硬件触发一次中断,在中断服务函数里判断数据是否已全部发送完毕,如果没发完就继续填入下一个字节。发送完成后会关闭发送中断,并调用HAL_UART_TxCpltCallback回调。
所以如果你大量使用中断方式发送数据,会频繁触发串口发送中断,CPU负载会增加。对于简单的调试信息打印,用轮询方式就够了,而对于需要在短时间内发送大量数据的场景,建议使用DMA发送。
DMA发送的原理是:把数据缓冲区地址配置给DMA控制器,然后启动DMA传输。DMA硬件会自动把缓冲区里的数据逐步搬运到串口的DR寄存器,全程不需要CPU干预。CPU只需要在DMA传输完成中断中做收尾工作。这种方式发送效率最高,尤其适合利用串口传输数据块或文件的情况。
4. printf重定向的原理与实现
4.1 printf重定向到底干了一件什么事
printf是C标准库中的函数,它把所有输出内容先格式化成字符串,然后调用一个底层输出函数来真正把字符串送出去。在PC开发中,printf输出到控制台和终端。在STM32这样的嵌入式环境中,我们自然希望printf的输出能通过串口发送出去,这就需要把printf的底层输出函数重新指向串口发送函数。这个过程就叫printf重定向。
不同的C编译器使用的底层函数不同,所以重定向的方式也不同。我们最常使用的Keil MDK环境有两条路可以走:默认使用微库(MicroLIB),切换到微库后只需要重写fputc函数;不使用微库,则需要重写fputc和fgetc,并且还要处理一些底层文件操作函数的钩子。
那为什么启动微库重定向printf步骤更简单?因为微库本来就是ARM专门为嵌入式环境裁剪过的轻量级C库,它简化了stdio的实现,很多PC标准库里的复杂机制如文件缓冲、多线程安全等都被去掉了,所以fputc这个底层输出函数的接缝更容易接入。
4.2 Keil微库方式重定向详解
这是最简单、最常用的方式,总共只有两步。第一步是在Keil中打开Options for Target,然后在Target选项卡里勾选Use MicroLIB。
第二步是在你的代码中(我一般放在usart.c或者main.c的末尾)添加两个函数:
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; } int fgetc(FILE *f) { uint8_t ch = 0; HAL_UART_Receive(&huart1, &ch, 1, HAL_MAX_DELAY); return ch; }这段代码的意思是:当标准库调用fputc时,就把这个字符通过HAL_UART_Transmit发送出去。fgetc则对应接收数据的入口,当使用scanf时会被调用。如果只是用printf打印,fgetc写不写都无所谓。
配置完成后,你在代码里直接写:
printf("Hello STM32!\r\n"); printf("Count: %d\r\n", count);就能在串口助手上看到输出内容了。
4.3 不使用微库的重定向写法
有些情况下你不想用微库,可能是为了使用标准C库更完整的功能,或者项目规范里禁用了微库。这时重定向就要复杂一些,除了fputc和fgetc之外,还需要处理底层的文件描述符相关问题。
具体来说,需要把标准库内部依赖的几个底层系统调用函数补上,这些函数在标准库中默认是指向一个错误处理的。添加以下代码:
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; } int fgetc(FILE *f) { uint8_t ch = 0; HAL_UART_Receive(&huart1, &ch, 1, HAL_MAX_DELAY); return ch; } int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } int _read(int fd, char *ptr, int len) { HAL_UART_Receive(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } int _close(int fd) { return -1; } int _lseek(int fd, int ptr, int dir) { return 0; } int _fstat(int fd, struct stat *st) { st->st_mode = S_IFCHR; return 0; } int _isatty(int fd) { return 1; }这些函数中,_write和_read是标准库stdio函数底层的系统调用接口。printf格式化完数据后,通过_write把数据输出;scanf通过_read把输入数据读进来。_close、_lseek、_fstat、_isatty这些则是因为标准库层面在进行文件描述符初始化时可能需要调用,如果不实现,有些标准库配置会导致程序启动时崩溃。
我用这种方式写的时候,一开始就在_retarget(我也不确定名字的来源)上面栽过跟头。不使用微库时,系统库会默认调用_sys_open之类的钩子函数,Keli标准库的底层实现和ARMCC的库绑定方式不一样,所以上面的几个钩子函数必须都实现,缺一个编译能过,运行直接进硬件错误中断。
所以我个人的经验是:如果只是想要printf打印调试信息,直接用微库它不香吗,省心又不追求极致体积。不勾选微库的方式适合复杂项目和产品级代码,那边需要标准库完整功能的场景确实有,但日常学习和简单Demo完全没必要。
4.4 浮点数打印支持与格式控制技巧
使用printf重定向之后,很多新手会碰到一个问题:printf("Temperature: %.2f\r\n", temp);这个浮点数打印在串口助手上显示不了,或者一直显示一个奇怪的数字,甚至直接卡死。
用Keil微库的前提下还出现这问题,基本就是编译器选项的问题。Keil MDK默认把printf系列函数做成了精简版,为了提高编译效率和减小体积,浮点数格式化功能是关闭的。你需要勾选Options for Target中的MicroLIB后,还会发现浮点打印只支持部分格式。原因在ARMCC编译器里,printf的浮点支持需要额外链接浮点处理模块。
具体来说,如果你使用ARMCC(即Keil默认的编译器),在Options for Target -> C/C++里,需要把Optimization设置为适当的级别,同时在Linker选项卡中勾选Use Memory Layout from Target Dialog。如果你还是打印不出浮点数,检查是不是在printf参数里用了%lf而实际传入的是double或float类型。C标准中,printf可接受float传给可变参数时被提升为double,用%f即可。用%lf在某些库实现中也能工作,但最保险还是用%f。
另外还要注意一个坑:微库模式下,printf默认不支持超长字符串拼接和格式化字符串存储位置不在内部RAM的情况。使用格式化字符串时,编译器有时会把它放到Flash中,微库访问Flash区域没有任何问题,所以这一般不是问题。
关于格式控制技巧,有几个是调试时候很有用的:
- 打印十六进制:
printf("Reg: 0x%02X\r\n", reg_val); - 打印二进制:C标准库没有
%b,可以用位操作自己写,或者用itoa转换。 - 限制小数位:
%.2f表示两位小数;%6.2f表示数据宽度6位、两位小数。 - 左对齐:
%-10d表示输出宽度10字符并左对齐。
4.5 printf重定向塞进中断的减灾方案
串口打印调试信息,最常见的场景是在主循环里打印状态,或者在事件发生时打印一条日志。但当你在中断服务函数里调用printf要非常小心。因为printf需要处理完整个格式化的过程,在微库模式下,它还会调用fputc并等待串口发送完成,这会占据大量中断处理时间。更重要的是,如果一个系统里多个外设中断都调用printf,可能会出现数据交错、状态错乱。
我自己在写一个多传感器采集项目时就碰到过这种问题,定时器中断和外部中断里都加了printf,程序运行一会儿就卡死了。排查后才发现,是printf在发送完成前再次被中断打断,导致HAL_UART_Transmit的内部状态没有被正确恢复。
针对这个问题的解决方案有几种:
- 用
HAL_UART_Transmit的带超时版本,加上超时退出,别用无限等待。 - 在发送前加一个互斥锁或临界区保护,保证同一时刻只有一个地方正在使用printf。
- 更稳妥的做法是:中断里只用DMA发送,通过DMA请求发送一个预先格式化好的字符串缓冲区,不等待发送完成。
- 终极做法:直接把中断里的日志信息放入一个环形缓冲区(Ring Buffer),主循环里统一格式化输出。这样中断里只做简单赋值操作,printf完全放在主循环,既安全又不会阻塞中断。
volatile uint8_t log_buffer[256]; volatile uint16_t log_head = 0; volatile uint16_t log_tail = 0; void log_push(uint8_t data) { uint16_t next = (log_head + 1) % sizeof(log_buffer); if(next != log_tail) { log_buffer[log_head] = data; log_head = next; } }这种环形缓冲区的方案在项目里非常好用,尤其是在有多个中断源的场景下,直接把中断里的值push到缓冲区,再把打断重入的问题全部交还给主循环处理。
5. 常见问题与排查技巧实录
5.1 串口收不到数据或输出乱码
这个问题的出现频率极高。如果你的程序烧录进去以后串口一点反应都没有,先别急着怀疑代码,按照下面的顺序逐个排查:
首先,确认你的USB转串口模块接线是否正确。STM32的TX接USB转串口模块的RX,STM32的RX接模块的TX。这个顺序很多人一忙就接反,接反的典型现象是程序完全跑着但串口助手收不到任何东西。
其次,确认串口助手设置的波特率、数据位、停止位、校验位与CubeMX里配置的参数完全一致。我调试的时候见过有人在CubeMX里用115200,在串口助手里选9600,结果出来一片乱码。这类低级错误很常见,先把两边参数对齐。
第三,如果参数都没问题,检查一下你的最小系统板是否有外部晶振,以及CubeMX里配置的HSE频率是否与板子上晶振的实际频率一致。我手上的STM32F103C8T6蓝色板子有的用的是8MHz晶振,有的可能用的是其他频率,如果配置和实际不一致,系统时钟会偏差,波特率跟着偏差,进而造成乱码。
最后,如果你用了PC的USB转串口模块,且驱动没装好或者串口号被占用,同样会通信失败。WIN10/11系统一般会自动装上CH340或CP2102的驱动,如果不行就手动安装一下。
5.2 printf重定向后程序卡死或HardFault
程序卡死在printf调用上是嵌入式开发中极其常见的现象。其原因多半不是你写的代码逻辑有问题,而是底层钩子没有配对,或者标准库剪裁导致的冲突。
用微库的模式下,程序卡死在printf的最常见原因是USART1尚未初始化就直接调用了printf,只是C库调用fputc发送时,HAL_UART_Transmit在等待发送完成标志位,而串口并没有在上电初始时就被配置好。解决思路就是确保在main函数中先调用MX_USART1_UART_Init(),再调用printf。但注意CubeMX生成的工程中,初始化代码顺序是时钟→GPIO→外设→应用逻辑,你把printf放在应用逻辑里调用就不会出现这个问题。
如果使用非微库的模式,程序进入HardFault,大概率就是缺了_sys_open、_isatty这类钩子函数。ARMCC标准库初始化时会调这几个函数,你可以在源码里搜索__stdout相关的符号,也可直接在工程里加入前面提到的几个底层函数。
还有一种情况是printf打印的字符串异常导致格式解析错误,比如给%d传了一个浮点数,给%s传了一个非法地址。这类问题在编译阶段不会报错,但运行期间会有各种诡异行为。排查方法是用串口助手中断调试模式单步执行,逐条看printf参数,逐个格式化字符检查。
5.3 HAL_UART_Receive_IT只能收到一次数据
这个我前面提过,HAL_UART_Receive_IT是单次接收模式,它接收完你设定的长度后自动关闭接收中断,必须再次调用它才能继续接收。忘记重新调用的表现就是:程序第一次能收到数据,后面就再也不进回调了。
正确的持续接收写法是在RxCpltCallback回调函数里再次调用接收函数:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { // 处理接收到的数据 process_received_data(rx_buffer, 1); // 再次启动接收 HAL_UART_Receive_IT(&huart1, rx_buffer, 1); } }这种逐字节接收写起来简单,但每个字节都触发一次中断和一次回调,如果数据量大,CPU开销不容忽视。所以对于批量接收场景,推荐直接在回调中把数据放入缓冲区,主循环集中处理。
还有一种更高效的方式是把Receive_IT的长度设为整个缓冲区大小,然后设置空闲中断,通过空闲中断来识别一帧数据的结束。配合DMA接收,效率更高。
5.4 串口Debug助手显示乱码但发送正常
如果STM32发给PC的串口数据在串口助手上显示乱码,但PC发给STM32的数据能被正确解析,一种可能是波特率有偏差。上面已经提过,如果外部晶振频率配置不对,波特率误差会很大。
另一种可能跟接地有关。USB转串口模块与STM32板子之间如果不共地,信号参考电压不一致,会导致接收数据错乱。解决办法是把两者之间用杜邦线连一根GND线。这种情况在笔记本USB供电和使用面包板连接时尤其常见。
还有一种很隐蔽的原因:串口助手的字符编码设置。串口助手里如果设置的是UTF-8编码,而你的printf输出的中文是GB2312编码(Keil默认中文编码可能是GB2312),显示就会乱码。多数串口助手支持切换编码格式,把编码设置改成GBK或GB2312就行。如果你想输出UTF-8编码,需在Keil的Edit->Configuration里把Encoding改为UTF-8,但注意这可能导致代码注释出现中文乱码,适用范围有限。
5.5 中断优先级冲突导致串口中断不响应
在使用了FreeRTOS或者其他中断的工程中,串口中断不响应可能不是串口本身的问题,而是中断优先级配置冲突。ARM Cortex-M3内核支持抢占优先级和子优先级,抢占优先级高的可以打断优先级低的。
如果串口中断的抢占优先级设置和SysTick定时器或者某个紧急中断发生冲突,且串口中断的优先级被设置为不可抢占,那么在一定时间窗口内串口数据会丢失。尤其是高度依赖实时时钟和调度器的系统,串口中断几乎总是在最底层被排挤。
CubeMX中NVIC配置会为外设设置合理的默认优先级,但它不知道你的系统整体优先级策略。你需要在NVIC Settings里手动协调。一个通用经验是:实时性要求最高的中断(如系统节拍)优先级最高(数值最小),串口这类外设的优先级放中间(比如抢占优先级2、子优先级0),不要设置的比系统时钟还高,否则中断嵌套混乱会导致很难排查的问题。
6. 实战案例:用printf重定向后的串口数据分发
6.1 帧协议解析的简单实现框架
平时调试串口,打印纯字符串只是入门。真正用到工程中的时候,串口更多是承担协议交互的功能。我简单分享一下我项目里经常用的一个数据分发框架,它基于中断接收和空闲判断来实现不定长帧的解析。
接收侧,在RxCpltCallback(或RxEventCallback)中把收到的字节写入环形缓冲区。主循环中,每10毫秒检查一次缓冲区长度,如果有数据,就进入协议解析流程。协议格式可以自己定义,我常用的是帧头(2字节,固定值如0xAA 0x55)+ 长度(1字节,可变)+ 数据(N字节)+ 校验(1字节,CRC8或累加和)。
#define FRAME_HEADER1 0xAA #define FRAME_HEADER2 0x55 uint8_t rx_ring_buf[256]; uint16_t rx_ring_head = 0; uint16_t rx_ring_tail = 0; void uart_rx_byte(uint8_t data) { uint16_t next = (rx_ring_head + 1) % sizeof(rx_ring_buf); if(next != rx_ring_tail) { rx_ring_buf[rx_ring_head] = data; rx_ring_head = next; } } int8_t uart_frame_parse(uint8_t *payload, uint8_t max_len) { uint8_t data, is_escape = 0; uint8_t len = 0, frame_len = 0; static uint8_t state = 0; // 状态机简化:寻找帧头 -> 读取长度 -> 读取数据 -> 校验 while(rx_ring_head != rx_ring_tail) { data = rx_ring_buf[rx_ring_tail]; rx_ring_tail = (rx_ring_tail + 1) % sizeof(rx_ring_buf); switch(state) { case 0: if(data == FRAME_HEADER1) state = 1; break; case 1: if(data == FRAME_HEADER2) state = 2; else state = 0; break; case 2: len = data; if(len > max_len) { state = 0; return -1; } state = 3; frame_len = len; break; case 3: payload[len - frame_len] = data; frame_len--; if(frame_len == 0) { state = 0; return len; } break; default: state = 0; break; } } return 0; }这个状态机解析思路虽然简单,但它是不少协议栈的原型。你可以在帧尾加一个校验字节,然后在状态机里加一个校验状态;也可以在帧头加设备地址字节,用于进行多机通信。用这个思路做一个小小的自定义通信协议,比一个个字节去判断要稳健和容易调试很多。
6.2 多字节NACK/ACK应答通信的例子
在一些需要可靠通信的场景(比如烧录、配置参数、传感器校准),我们会要求上位机与单片机之间进行应答式交互。一个常见的模式是:上位机发送一帧指令,单片机解析后执行,再回复一帧ACK(确认)或者NACK(否认)。
在实现上,只要在收到完整帧且校验通过后,把要返回的状态打包成同样的协议格式,再调用HAL_UART_Transmit发送出去即可。这种方案的关键在于,协议格式必须双方完全一致,而且需要规定好超时重发机制(比如上位机在100ms内没收到ACK就重发),以保证通信不会因为单帧丢失而卡死。
我做过一个温控板,下位机通过串口和PC上位机通信,上位机每秒下发一次目标温度,单片机执行后返回当前温度。用上面这个协议框架,跑了两周都没出过通信故障。如果你要做类似的上位机联调,建议外加一个简单的CRC8校验函数,避免因RS485线路干扰导致数据被误改。
6.3 用printf重定向对接日志系统的思路
printf重定向做好了,一个额外的收益是,你可以很方便地对接一个轻量级日志系统。常见的做法是,把日志级别(DEBUG/INFO/WARN/ERROR)封装成带时间戳的打印宏,然后全部走printf输出:
#define LOG_DEBUG(fmt, ...) printf("[DBG] %lu " fmt "\r\n", HAL_GetTick(), ##__VA_ARGS__) #define LOG_INFO(fmt, ...) printf("[INF] %lu " fmt "\r\n", HAL_GetTick(), ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) printf("[ERR] %lu " fmt "\r\n", HAL_GetTick(), ##__VA_ARGS__)这样每一条日志都会带上系统运行的时间戳(HAL_GetTick返回毫秒),对定位问题时序帮助非常大。尤其当程序出bug时,光靠自己一遍一遍地看代码远不如直接看日志里的时间线来得高效。
更进一步的方案是,把日志输出从串口切换到文件或者SD卡,只需要把fputc对应的输出通道替换成写文件函数,其余代码几乎不用动。这也是printf重定向分工带来的一个好处。
7. 关于串口调试效率的一些个人经验
串口通信看着基础,但你要是真把它用顺了,整个项目调试效率能提升一个档次。我在项目里经常用到的串口调试辅助手段有几个值得分享:
第一,开发初期,优先打开串口DMA接收加空闲中断,不要图简单用轮询。轮询调试时好使,一旦功能多了,主循环里一个delay就可能导致数据丢帧,那时候再开DMA调整,可能代码已经写了好几千行。数据结构如果一开始就是为DMA接收设计的,后面扩展自然顺滑。
第二,在PC端串口调试工具的选择上,不建议用那些花里胡哨的。界面简洁、能自定义发送周期、支持HEX和ASCII切换、能保存多条命令,这几个功能就足够了。我用过不少工具,最终还是固定在自带文本编码切换和定时发送功能的那款上。调试协议时,HEX发送模式查看边界字节很有用;日常打印日志,ASCII模式就够。
第三,如果你的项目涉及多块板子或者复杂协议交互,尝试用一个逻辑分析仪去抓串口波形。串口协议是时序信号,逻辑分析仪能直观显示每一bit的电平变化,是排查波特率偏差、信号质量问题的利器。它从硬件级别上能看到的东西,串口助手根本看不出来。
第四,写串口接收处理逻辑时,尽量不要在中断回调函数里做大量计算和解析,回调函数的执行时间越短越好。把数据丢到缓冲区里,让主循环去分析,进程会顺畅很多。这个原则在不同单片机和不同外设上通用,是嵌入式开发的铁律。
最后,很多开发者会有一种倾向,觉得串口太简单没什么好研究的,遇到问题也不愿意深究。但实际上,串口收发机制里涉及的异步通信、中断、DMA、缓冲区设计、协议解析这些概念,几乎构成了嵌入式系统开发的底层思维框架。把串口搞透,再去学SPI、I2C、CAN这些复杂的通信协议,会发现很多思路是相通的。
我在实际使用中发现,把上述配置和思路完整走通一遍之后,串口这条线基本就稳固了。以后再做项目,无论换什么型号的MCU,打开CubeMX只需要大概五分钟就能把串口环境重新搭起来。这种顺手的感觉,其实才是我们折腾底层外设的最终目的——不是为了配置而配置,而是为了把有限的时间花在真正有挑战的功能逻辑上。