
1. 串口通信到底在说什么1.1 为什么嵌入式调试离不开串口很多朋友刚接触STM32的时候第一件事就是想让它“跑起来”。但程序烧进去之后芯片到底在干什么、跑到哪一步了、某个变量的值是多少这些信息如果不借助外部手段你根本看不见。这时候串口通信就派上用场了。简单来说串口是芯片和外部世界之间一条最简单的数据通道通过两根线TX发送、RX接收就能把数据送到电脑上显示或者接收电脑发来的指令。我经常会用一个比喻如果没有串口单片机就像一个闷头干活的工人你只能通过灯亮不亮这种简单信号判断状态有了串口这个工人终于开始跟你说话了你问他“你现在在做什么”他能把内部状态一条条报给你。对于STM32开发来说串口通信是调试信息的“生命线”也是各种传感器模块、GPS模块、蓝牙模块、WiFi模块交互的基础接口。从学习路线的角度看串口通信通常在GPIO、定时器之后学习是理解单片机数据收发机制的关键一步。它不涉及复杂的时序协议只要配置对波特率、数据位、停止位就能稳定收发数据所以很多教程都把它作为通信外设的第一课。本篇文章就是基于STM32CubeMX以下简称CubeMX这个图形化配置工具从零开始完成串口的配置、代码生成和实际收发让你既明白原理也知道怎么落地到一个真实的工程里。1.2 UART/USART的关键参数怎么理解在动手配置之前建议先把几个关键概念弄明白不然配置界面里参数一多就容易懵。波特率Baud Rate每秒传输的比特数。常用的有9600、115200等。这个参数必须收发双方一致否则收到的就是乱码。数据位Data Bits一帧数据携带的有效位数量常用8位。停止位Stop Bits标志一帧数据结束常用1位也有0.5位、2位的配置。校验位Parity用于简单检错可以设置无校验、奇校验、偶校验。平时通信很少用到校验位一般选None。这组参数本质上定义了双方“说话”的节奏8N1即8位数据位、无校验、1位停止位是嵌入式领域最通用的默认配置绝大多数设备出厂协议都支持这种格式。STM32上的串口外设叫做USART通用同步异步收发器或UART通用异步收发器两者区别在于USART多了同步时钟功能但日常异步串口通信中我们通常直接使用UART的叫法。F103C8T6这颗芯片上一共有3个U外设USART1、USART2、USART3。各外设引脚分布不同比如USART1通常映射到PA9TX和PA10RXUSART2的默认引脚是PA2和PA3。配置之前先想好自己要用哪一个、引脚会不会和其他外设冲突这是新手容易踩的坑。2. 用CubeMX配置一个能用的串口2.1 新建工程与时钟树设置如果你还没安装好CubeMX和对应的芯片支持包先补上这一步。CubeMX是ST官方推出的STM32芯片图形化配置工具新版本下载安装后需要打开软件加载STM32F1系列的支持包。这个过程比较简单软件第一次启动会提示安装固件包也可以手动点击Help → Manage embedded software packages勾选需要的型号下载。新建工程时在Part Number搜索框里输入芯片型号比如常用的STM32F103C8T6双击进入配置界面。这里提醒一句不要急着改引脚先把System Core里的RCC选项配置好。在System Core → RCC中将HSE设为Crystal/Ceramic Resonator这样板载8MHz晶振就能作为外部高速时钟。然后进入Clock Configuration页面把HCLK设置成72MHz输入8MHz软件会自动算出各总线分频系数。这一步不是串口特有的但如果你漏掉了后面串口波特率可能会出现莫名其妙的误差实测中确实遇到不少工友踩过这个坑。时钟树设置好之后再回到Pinout Configuration界面进行串口引脚配置。2.2 USART的引脚选择和参数配置在左侧Categories列表里展开Connectivity找到USART1点击后右侧会显示USART1的引脚配置页面。选择Mode为Asynchronous异步通信此时CubeMX会自动把TX和RX映射到默认引脚PA9、PA10。如果你用的板子引脚不同可以在芯片图上按住引脚手动映射到其他位置但建议优先使用默认映射因为很多最小系统板和开发板都按这个默认引脚连接了USB转串口芯片。接着在Configuration区域设置波特率等参数。我习惯把波特率设为115200、数据位8、无校验、停止位1这也是我写串口协议时最常用的配置。如果你要连接的外部设备说明书要求9600或其他波特率记得改回来。这里点开NVIC Settings勾选USART1 global interrupt使能串口全局中断。如果你后面要使用中断接收功能这一步必须提前开否则代码层面调用了HAL_UART_Receive_IT也不会响应。关于USART1还是USART2的选择有一点值得注意USART1挂在APB2总线上时钟频率是72MHzUSART2和USART3挂在APB1总线上时钟频率是36MHz。CubeMX会自动根据你的总线时钟计算波特率寄存器值所以普通通信两者都够用。但如果你后面要用更高速率的调试或复杂协议优先用USART1容错性更高。配置完成后点击右上角的GENERATE CODE生成工程。生成的代码里已经帮你初始化好了GPIO和USART你不用手写一行寄存器配置这也是CubeMX最大的价值把重复性、机器化的初始化工作交给工具把时间留给业务逻辑。2.3 让printf直接输出到串口串口配置好之后很多人的第一个需求是想在代码里用printf打印调试信息。但默认情况下标准库的printf是往屏幕输出的对于单片机来说根本没有屏幕所以需要做一个“重定向”把printf的输出目标改到串口。在生成的工程里找到usart.c文件在下方添加代码重写fputc函数。以KEIL MDK环境为例经典写法如下#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }这样你在main函数里调用printf(Hello STM32\r\n)数据就会通过USART1发送到电脑的串口助手软件上。这里有两个小细节要注意记得勾选KEIL工程里的Use MicroLIB选项否则标准库的重定向在嵌入式环境里可能链接不过。路径是Options for Target → Target → Code Generation → 勾选Use MicroLIB。字符串结尾建议带上\r\n回车换行否则串口助手上所有打印会挤在同一行非常影响观察。如果你用的是STM32CubeIDEGCC工具链fputc的写法稍有不同需使用__io_putchar函数。但思路完全一样就是把输出重定向到HAL_UART_Transmit。这个方法我在调试时几乎天天用项目的每个状态节点都打印一条日志定位问题的速度能快好几倍。3. 收发数据的三种姿势怎么选3.1 阻塞式发送与接收适合小数据量CubeMX生成的HAL库里串口发送和接收都提供了三种模式阻塞Blocking、中断Interrupt、DMA。先说阻塞式。阻塞式发送对应的接口是HAL_UART_Transmit接收是HAL_UART_Receive。它的特点是函数会一直等待数据发送完成或接收达到指定长度才返回执行下一行代码。调用非常简单char msg[] Hello STM32\r\n; HAL_UART_Transmit(huart1, (uint8_t *)msg, strlen(msg), 1000);最后一个参数是超时时间单位毫秒。如果在超时时间内没完成发送函数返回超时错误码。这个机制避免了程序卡死在等待上。实测下来阻塞式发送在115200波特率下发送几个字节几乎不会感觉到延迟适合打印调试信息、发送状态命令这种低频、短数据的场景。但阻塞式接收就有点尴尬了。HAL_UART_Receive(huart1, buffer, len, timeout)会一直等到接收够len个字节才返回这在不知道对方什么时候发数据、发多长数据的串口通信场景中很难用。你没法预期一帧数据的长度如果设置超时时间太长程序就卡在那里干等设置太短数据还没到就超时返回了。所以实际项目里阻塞接收只用在“明确知道数据会在某个时刻到达并且长度固定”的场景比如读取模块传感器的固定长度上报数据。3.2 中断接收接收不定长数据的两种做法真正实用的接收方式是中断接收。它的核心逻辑是接收缓冲区里每进来一个字节硬件就触发一次中断芯片暂停当前任务去把字节保存下来然后继续跑主程序。CubeMX生成的初始化代码里如果你使能了NVIC中断就可以调用HAL_UART_Receive_IT来启动接收uint8_t rx_byte; HAL_UART_Receive_IT(huart1, rx_byte, 1);注意这个函数只调用一次并不能实现连续接收所有数据。它配置好接收一个字节后中断回调函数HAL_UART_RxCpltCallback会在每收到一个字节时被调用。标准做法是在回调函数里保存这个字节然后再次调用HAL_UART_Receive_IT启动下一次接收。这就是“一个字节一中断”的处理方式示例代码#define RX_BUFFER_SIZE 128 uint8_t rx_buffer[RX_BUFFER_SIZE]; uint8_t rx_byte; uint16_t rx_index 0; volatile uint8_t rx_complete_flag 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_buffer[rx_index] rx_byte; if (rx_index RX_BUFFER_SIZE) { rx_index 0; } HAL_UART_Receive_IT(huart1, rx_byte, 1); } }但这种方式只能按固定字节流保存并不知道一帧数据什么时候结束。比如接收一个完整的AT指令“ATNAMEABC\r\n”系统需要检测到行尾才认为这是一条完整指令。比较常见的做法有两种一种是判断是否收到\r\n等结束字符把它视作一帧边界另一种是启动一个定时器每次收到字节时重置定时器如果一段时间比如10ms没有新数据到来就认为当前帧接收完毕。第二种方式叫“空闲超时判断”在很多协议栈里都能看到类似思路。如果你用的是STM32F4以上的芯片还有更好的方案硬件会提供一个空闲事件IDLE Interrupt这个中断在一帧数据接收完毕、总线上出现空闲时自动触发。可惜F103系列本身没有IDLE中断所以只能靠软件超时判断。不过别灰心F1系列用定时器加中断配合也完全可以满足绝大多数场景如果后面换成F4系列这个机制的实现会方便很多。3.3 DMA搬运让串口不再抢CPU时间第三种方式是DMA接收。DMA相当于一个“搬运工”它可以在CPU不参与的情况下把串口收到的数据自动搬到内存缓冲区。CPU只需要在DMA完成或出现空闲事件后处理数据即可适合大数据量、高速率、高频率的通信场景。在CubeMX里配置USART1时在页面下方DMA Settings选项卡中点击Add添加USART1_RXDirection选择Peripheral To Memory模式选择Normal或者Circular。如果你希望接收缓冲装满后自动从头开始覆盖写入就选Circular模式这个模式在做连续数据流采集时很好用。配置完成后重新生成工程代码里就能调用DMA接收函数#define BUFFER_SIZE 256 uint8_t dma_rx_buffer[BUFFER_SIZE]; HAL_UART_Receive_DMA(huart1, dma_rx_buffer, BUFFER_SIZE);调用一次后底层DMA就会自动工作。每次接收满设定的BUFFER_SIZE就会触发一次HAL_UART_RxCpltCallback回调。但如果你要处理“可变长度的数据帧”单纯靠DMA接收满就会漏掉不足一段的数据。常见的配合方案是DMA IDLE空闲中断或者DMA 定时器超时判断把一帧结束的边界识别出来。用DMA有一个很大的坑如果缓冲区被写满了CPU还没来得及处理新数据会覆盖掉旧数据造成丢帧。实际项目中我一般把缓冲区开得足够大并且在回调里尽快把数据搬走而不是直接在回调里做复杂的解析。有一个技巧是把缓冲区设为2的幂次大小用环形队列管理读写指针做位与运算实现回绕这样既高效又不容易出错。4. 串口实战中常见的坑与排查实录4.1 串口无输出先查这几处一个很常见的问题代码烧进去串口助手什么都收不到。我碰到这种情况一般按以下顺序排查检查串口助手的端口号是否选对。USB转串口模块插入电脑后设备管理器里会多出一个COM口不同电脑编号不同一定要在串口助手里选对。检查接线是否交叉。单片机的TX要接USB转串口模块的RX单片机的RX要接模块的TX。很多新手按“TX对TX、RX对RX”接法结果当然收不到。板子引脚如果有丝印标注最好按引脚功能逐个确认。检查共地。单片机和USB转串口模块之间除了TX、RX两根信号线还要连接GND。如果地不共信号电平参考点不同通信很容易失败。检查波特率、数据位、停止位是否一致。这一项和串口助手里设置的参数必须严格对应任何一个不同都会导致乱码或完全无输出。检查CubeMX里的引脚映射和实际接线是否一致。如果你配置的是USART1的PA9/PA10但接线接在别的引脚上自然不会有数据。检查板子的BOOT跳线。如果芯片处于BOOT模式而不是Flash启动模式程序根本没跑起来那串口也不会有输出。将BOOT0拉低恢复到正常启动模式。以上排查顺序基本覆盖了我能遇到的90%的“无输出”问题。只要把供电、接线、共地、参数、引脚、启动模式这六项全部确认一遍串口不工作的概率已经很低了。4.2 中文乱码和波特率不符乱码的本质是收发双方对“每个比特持续多长时间”的理解不一致。比如发送方按115200的节奏发送接收方却按9600的节奏去采样那解析出来的二进制数据肯定是错的。这种情况优先检查双方的波特率设置包括USB转串口模块驱动是否稳定、串口助手软件是否不小心改了参数。还有一个容易被忽略的因素波特率误差。当你把HSE配置成内部RC振荡器HSI而不是外部晶振HSE时由于内部RC振荡器本身的精度不够实际产生的波特率会和理论上有所偏差。通信距离短、数据量少时可能感觉不到问题但长时间传输大批量数据时偶尔会出现误码。所以配置时钟树时尽量使用外部晶振保证波特率精度。另外串口助手软件里如果显示中文乱码先别急着怀疑单片机用串口助手直接发送一段英文ASCII字符测试。如果英文正常中文乱码往往是串口助手的编码设置不对切换一下显示编码常见的有GBK和UTF-8就能解决。这个问题很容易让人误判为单片机代码有bug跑偏排查方向。4.3 为什么HAL_UART_Receive_IT只进一次中断这是HAL库开发中最经典的一个坑调用HAL_UART_Receive_IT之后第一帧数据能正常接收并进入回调但第二帧数据就不再响应了。原因很简单HAL_UART_Receive_IT函数本身是“一次性”的每次接收完成后串口接收中断就被关闭了你必须再次调用这个函数才能重新使能接收。如果不调用硬件不会再触发接收中断。所以标准的回调写法中最后一定要记得再次调用HAL_UART_Receive_IT这就是所谓的“重挂载”。另外还有一个细节是中断回调里尽量不要做耗时太长的事情比如直接printf因为这会拖慢中断处理的节奏导致后续字节丢失。正确做法是把收到的数据存入缓冲区置一个标志位然后在main函数的while循环里处理这个缓冲区。如果你重挂载后发现中断还是只能进一次还有一个可能你在回调里用了HAL_UART_Transmit去回显数据而发送过程占用了串口外设影响了接收状态。虽然HAL库内部做了互斥保护但还是建议“接收只存数据发送留在主循环”简化问题排查难度。4.4 用调试助手验证数据逻辑的小技巧串口通信调试到后期很多问题往往不在串口本身而是帧解析逻辑不对。这时候一个灵活好用的串口助手段会起很大作用。常用的工具有XCOM、SSCOM等它们都支持ASCII和Hex两种显示模式。当接收到的数据在ASCII模式下看起来像乱码时切换到Hex模式看数据字节往往能看出帧格式的问题。调试时我习惯先定义一个简单的帧协议比如帧头0xAA 0x55帧尾0x0D 0x0A中间放数据长度和有效数据。这样不仅方便自己定位问题也让串口的数据收发变得可预测。先用串口助手手动发送一帧测试数据观察单片机返回的结果是否符合预期如果不对就逐步减少数据长度用最简单的回环测试发送什么返回什么来确认串口底层是否稳定再逐步加入协议解析逻辑。分段验证的思路能大大降低排错难度。还有一个实用的小技巧给每个状态节点加上编号在发送端串口助手里预期收到的数据在接收端单片机的处理代码里把实际处理结果回传出来。两边对比一下子就能发现是哪一步的处理逻辑出了问题而不至于在一片无头绪的调试日志中迷失方向。拓展与番外从串口小白到通信高手这篇文章写到这串口通信的基础内容其实已经全部覆盖了。按照我自己的体验学串口这件事最重要的是先不要急着一口气吃成胖子。先把CubeMX配置流程跑熟然后用轮询方式打印出第一行“Hello STM32”再逐步试通中断接收、不定长帧解析最后再上DMA传输。每个阶段都能解决一个实际问题学习动力就不会断。当你把串口通信跑通之后会发现很多之前觉得高深的概念其实都不难理解RS232、RS485是可以直接拿来对比学习的变体CAN总线、SPI、I2C这些通信协议也都有相通的思想——双方约定好电平、时序、帧格式然后一条一条地传数据。串口就像一个“通信入口”撬开它之后嵌入式世界里其他通信外设的学习难度会明显下降。串口调试还会让你的开发习惯发生改变。以前程序出问题你只能盯着逻辑反复看、猜现在你可以直接打印关键变量的值观察程序运行轨迹找出哪一个判断条件没有进入、哪一个循环跳不出去。这个技能在很多传感器项目、电机驱动项目、上位机交互项目里都特别吃香属于那种“学会之后一定会用”“用到之后一定离不开”的核心基本功。我在实际使用中还有一个小习惯不管做什么STM32项目都会预留一个串口用于调试日志输出。哪怕产品最终可以不接串口开发调试阶段也一定要留这能让定位bug的时间从几小时缩短到几分钟。举例来说上一个涉及电机控制的项目我用串口打印PID控制器的目标值和实际值曲线很快就能看出响应过快还是滞环太大省去了反复烧录的流程。如果你在照着文章操作的过程中遇到问题欢迎翻开CubeMX重新检查一遍配置项再看生成的HAL库代码。很多疑问在对照初始化代码之后就迎刃而解了。祝调试顺利串口数据一收一发明明白白。