简介:一份面向嵌入式初学者的STM32F103C8T6串口多字节收发例程,主要解决USART1中断接收与数据回传问题。程序通过中断方式接收数据,利用重定向后的printf函数将接收内容发送回电脑端,同时预留自定义处理位置,便于扩展传感器读取、上位机通信等实际应用。工程基于标准外设库编写,包含完整的启动文件、系统时钟配置、串口初始化代码,读者可直观查看中断服务函数与主循环的配合方式。资源包共196个文件,以C源码、H头文件、编译产物(hex、axf、map、lst)及Keil工程文件为主,压缩包约7.1MB,目录结构清晰,可直接打开工程编译烧录。已有2731人学习下载,适合入门级开发者对照调试,也可作为课设或竞赛中的串口通信基础模块。通过该工程可以掌握串口中断配置、printf重定向原理、多字节数据缓存与解析思路,并能快速搭建自己的收发处理框架。 到手一块STM32F103C8T6最小系统板,很多人的第一个进阶需求就是“把串口收发做得像样一点”。单字节收发教程遍地都是,但实际项目里几乎没有谁跟你一次只发一个字节:4G模块一条AT指令回几十个字节,HX711称重模块一次回4字节,磁编码器一帧数据也在6字节以上,更别说你自己要定义的数据帧了。多字节收发看着简单,真写起来涉及中断重装、DMA计数、帧同步、接收缓存溢出这些环节,新手卡在“串口中断接收只收一次”这种情况太常见了。这篇我就以STM32F103C8T6+HAL库为例,把多字节收发从CubeMX配置到代码实现、再到排错思路,一次说透。
1. 项目概述与核心思路
1.1 多字节收发到底难在哪
先给还没踩坑的朋友说清楚一个问题:为什么单字节能用的代码,多字节就不行了?
看很多入门教程给出的收发代码,本质上是这样的循环:
uint8_t data; while (1) { if (HAL_UART_Receive(&huart1, &data, 1, 100) == HAL_OK) { // 处理一个字节 } }这类轮询代码只能应付“调试一下”“点个灯”的场景。一旦对方一秒钟发几十上百个字节,或者数据帧超过两个字节,这种“收一个处理一个”的方式会遇到两个硬伤:
第一,你不知道一帧数据什么时候结束。数据处理和接收交织在一起,遇到稍长的数据帧,前后逻辑互相干扰,最后得到的是一堆没头没尾的字节。
第二,CPU被串口绑死了。主循环里一停顿(比如去跑一个延时、刷新一次屏幕),串口那端的字符就可能被覆盖,出现丢字节。
所以多字节收发的核心难点不在“发”而在“收”,准确说是三点:怎么把一串字节完整存下来、怎么判断一帧数据收完了、怎么不因为处理耗时丢掉下一批数据。后面实现的每一种方案,本质上都是在解决这三个问题。
1.2 三种接收方案横向对比
在F103C8T6上,接收无非三种姿势:轮询、中断、DMA+空闲中断。我直接列个对比表,方便对号入座。
| 方案 | CPU占用 | 实时性 | 适合场景 | 典型坑 |
|---|---|---|---|---|
| 轮询接收 | 高,阻塞 | 差 | 调试、极低速数据 | 主循环一卡就丢数据 |
| 单字节中断收 | 中,每字节一次中断 | 好 | 帧不长、速率不高 | 忘了重装中断,只收一次 |
| DMA+空闲中断 | 低,整帧一次中断 | 最好 | 任意帧长、高波特率 | 配置稍复杂,DMA重装要细心 |
一句话总结我的建议:如果你只是学习验证、做个小实验,用“单字节中断收+缓存数组”就够了;如果你的项目要跑协议、连模块、走长数据帧,直接上DMA+空闲中断,省心得不是一点半点。接下来两种方案的代码我都会给出。
2. 硬件与CubeMX配置要点
2.1 引脚和最小系统板使用注意
F103C8T6有USART1/2/3,最常见的USART1映射在PA9(TX)、PA10(RX),这也是蓝色小板默认引出的那组串口。接USB转TTL模块时记住三件事:交叉接、共地、电平匹配。TX接对方RX、RX接对方TX,GND必须连在一起。电平方面,STM32的串口是3.3V逻辑电平,现在的USB转TTL模块大多带了电平转换,直接用问题不大。但如果你手里是那种老式5V电平的方案,记得加电平转换或者串个电阻分压,不然长期使用芯片有损坏风险。
另外,F103C8T6这芯片是64KB Flash、20KB RAM,小归小,做串口协议解析绰绰有余。不过提醒一句:它是中容量产品,和F103RCT6这类大容量芯片在外设细节上有差别,比如DMA通道映射,后面配置时会专门说到。
2.2 CubeMX里的关键参数
用CubeMX配置USART1接收,几个关键点逐个说。
首先是基本参数:Mode选Asynchronous(异步),Baud Rate我习惯用115200,Data Bits 8,Parity None,Stop Bits 1,流控None。波特率不是越大越好,115200在大部分应用里平衡了速度和稳定性,485总线、4G模块、ESP01S这些设备默认也都能对上。
其次是时钟。USART1挂在APB2总线上,最高72MHz。如果你的工程从HSE 8MHz启动,CubeMX会自动把系统时钟配到72MHz,APB2为72MHz,串口波特率计算不会有误差。但如果你图省事用了内部HSI,或者改了分频,串口实际波特率会偏,表现就是“明明两边都是115200,收到的全是乱码”。所以排查乱码时,第一件事永远是确认时钟树和两边的真实波特率。
然后是中断和DMA的开启。单字节中断接收方案,只需要在NVIC设置里勾上USART1 global interrupt;DMA方案要在DMA Settings里给USART1_RX添加一个DMA通道,注意F103C8T6上USART1_RX的DMA通道是DMA1_Channel5,这个通道映射是固定的,别选错。数据宽度选Byte,方向PeripheralToMemory,模式选Normal还是Circular取决于你想要哪种收法,后面代码里我会分别讲。
最后一步容易被忽略:CubeMX生成代码后,HAL_UART_IRQHandler本身并不会自动启用空闲中断(IDLE interrupt),需要在初始化后手动打开。标准写法是在MX_USART1_UART_Init()尾部追加两行:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);这里的RX_BUF_SIZE要按你工程里声明的接收缓冲区大小来填。
3. 多字节收发程序实现
3.1 单字节中断收+缓存数组:新手最稳妥的起手式
先说方案一的完整实现。CubeMX里初始化好USART1,NVIC里勾上串口中断,然后在main函数里启动一次接收:
// 全局变量 uint8_t rx_byte = 0; // 单字节接收缓冲区 uint8_t rx_buf[64]; // 多字节缓存数组 volatile uint16_t rx_len = 0; // 已接收长度 volatile uint8_t frame_ok = 0; // 一帧数据收完标志 // main() 中开启接收 HAL_UART_Receive_IT(&huart1, &rx_byte, 1);然后重写接收完成回调:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_buf[rx_len++] = rx_byte; // 防溢出:超过缓存上限就从头开始或丢弃 if (rx_len >= sizeof(rx_buf)) { rx_len = 0; } // 判断是否一帧结束(这里以固定尾字节0x0A为例) if (rx_byte == 0x0A) { frame_ok = 1; // 主循环去处理 rx_len = 0; // 复位长度,接收下一帧 } // 关键:重新挂载下一次接收 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }这里面最容易被忽略的就是最后那行HAL_UART_Receive_IT。HAL库的设计是一次接收任务完成(收到指定字节数)后,RXNE中断就被关闭了,如果你不在回调里重新调用一次,串口就再也不会进中断——这就是网上大量“串口中断接收只收一次”问题的根源。记住一句话:用HAL库做中断接收,回调里必须重装接收,这是肌肉记忆。
这个方案适合帧短、数量少的场景,但每收一个字节就进一次中断,波特率上到460800甚至更高时,中断会很频繁,CPU压力大。数据量大时建议用方案二。
3.2 DMA+空闲中断:处理不定长数据帧的正解
方案二解决三个核心问题:接收过程不占CPU、一帧结束自动通知、缓冲区统一管理。
先说原理。DMA负责把串口收到的字节源源不断搬进缓冲区,CPU完全不参与;串口在总线空闲时会产生一次IDLE事件,我们利用这个事件判断“这一帧收完了”。这样一帧数据无论长短,CPU只在帧结束时介入一次。
上代码。注意在CubeMX里DMA模式可以选Normal,便于我们用“每次重启DMA”的思路管理:
#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; // DMA接收缓冲区 uint16_t rx_frame_len = 0; // 实际接收长度 volatile uint8_t rx_frame_ok = 0; // 帧完成标志 // 在 MX_USART1_UART_Init() 最后追加 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);然后修改串口中断服务函数:
void USART1_IRQHandler(void) { // 先调用HAL库原生处理,DMA传输完成等事件在里面处理 HAL_UART_IRQHandler(&huart1); // 再去查空闲标志 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // DMA剩余未传字节数 = 总长度 - 已接收长度 uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); rx_frame_len = RX_BUF_SIZE - remain; if (rx_frame_len > 0) { rx_frame_ok = 1; } // 重启DMA,准备下一帧 HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); } }主循环里处理帧:
while (1) { if (rx_frame_ok) { rx_frame_ok = 0; // 这里对 rx_buf 前 rx_frame_len 个字节做协议解析 // 注意:解析完再重启 DMA 或者直接在上面重启都可以 // 我习惯在中断里立刻重启,防止漏数据 } }这里有两个容易被坑的细节。第一,__HAL_DMA_GET_COUNTER拿到的是DMA剩余计数,当DMA不停接收时,这个值会一直递减;减到0就触发DMA传输完成中断。我们把DMA设为Normal模式而不是Circular,就是为了在IDLE事件发生时,“总长度减剩余长度”就等于本帧字节数,逻辑清爽。第二,IDLE标志必须用__HAL_UART_CLEAR_IDLEFLAG清除,不能只调用HAL_UART_IRQHandler就指望它清掉,否则帧结束标志永远在,你会进到“一帧数据重复处理好几遍”的循环。
再补充一个边界情况:如果数据源连续发送超过256字节、一直没出现空闲,DMA缓冲区会满,这时不会触发IDLE,而是触发HAL_UART_RxCpltCallback。处理方案是在这个回调里把整段数据先搬走或者扩大缓冲区:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 缓冲区满,直接处理整包 ProcessFrame(rx_buf, RX_BUF_SIZE); // 重启DMA继续接收 HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); } }3.3 发送端到底选阻塞、中断还是DMA
接收搞定了,发送同样有讲究。HAL库提供多个发送接口,我按使用场景拆开说。
如果你的项目对实时性要求不高,发送频率低、数据短,直接阻塞发送就够了,发完再干别的:
uint8_t tx_data[8] = {0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; HAL_UART_Transmit(&huart1, tx_data, sizeof(tx_data), 1000);但如果你要在中断里发数据,或者在循环里高频发送,阻塞发送会卡住流程,这时候用中断发送更好:
HAL_UART_Transmit_IT(&huart1, tx_data, sizeof(tx_data));有个坑必须提醒:HAL_UART_Transmit_IT在串口处于忙状态时会直接返回HAL_BUSY,再次调用会失败甚至产生奇怪行为。所以调用前要么加判断,要么用发送忙标志管理一下:
volatile uint8_t tx_busy = 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { tx_busy = 0; // 发送完成,允许下一条 } }至于DMA发送,适合大数据块、高吞吐场景,但使用门槛更高:发送缓冲区必须在传输完成前保持有效,不能是局部变量用完就释放;如果发送频率特别高,还需要双缓冲翻滚。我做传感器数据上报这种场景一般用中断发送就足够了,DMA发送只在大批量升级固件、刷显存时才去动它。
4. 常见问题与排查技巧
4.1 “串口中断接收只收一次”的完整排查
这个问题在技术群里被问了无数次,我总结一下完整的排查顺序。
第一步,看是不是没有重装中断。用方案一的代码,检查HAL_UART_RxCpltCallback最后有没有重新调用HAL_UART_Receive_IT。没有的话,收满指定长度后中断就关了,后续数据自然进不来。
第二步,看是不是回调写错了。HAL_UART_RxCpltCallback是所有串口共用的回调,如果你的工程开了USART1和USART2,一定要在里面判断huart->Instance,否则另一个串口收数据会干扰这个串口的逻辑。
第三步,看DMA方案里有没有正确清IDLE标志、重启DMA。这两个动作少一个,都会导致“只收到前几帧就再也没反应”。
第四步,查NVIC优先级。如果其他外设中断优先级比串口高,且对方在持续长时间占用CPU,串口中断进不来,数据就会堆积甚至丢失。把USART1和对应的DMA中断勾上,并把抢占优先级设到2以内(数字越小优先级越高),会比较稳妥。
4.2 乱码、丢字节、帧错位怎么定位
乱码九成是波特率对不上,或者时钟配置导致波特率偏差。排查方法是打开电脑端的串口助手,用不同波特率去试探,看哪个能正确解析出明文;也可以用逻辑分析仪抓一下RX引脚的波形,直接量出实际波特率。如果量出来和设置值偏差超过2%,基本就是时钟树配错了,回CubeMX检查HSE、PLL和APB分频。
丢字节通常发生在中断处理占时过长。比如在回调里直接做协议解析、打印日志、操作Flash,这些耗时操作会挡住后续串口字节的进入。解决方法很简单:中断里只做“搬运”(把数据放进数组、置标志位),所有解析和处理挪到主循环,这一点对方案一尤其重要。
帧错位是协议设计问题。典型表现是“能收到数据但第一两个字节对不上”,原因是接收端不知道帧从哪里开始。解决方案有两个方向:一是固定帧头帧尾并用状态机解析;二是在协议里加长度和校验字段,解析时先找帧头、再看长度、最后验校验。无脑加帧头不如“帧头+长度+校验”可靠,特别是在传输二进制数据时,数据里可能恰好出现和帧头一样的字节。
4.3 一个简单可靠的自定义帧结构
最后分享一个我用得最顺手的帧结构,纯C就能实现,适合大多数小项目:
// 帧格式: 帧头2字节 | 长度1字节 | 命令1字节 | 数据N字节 | 校验1字节 // 帧头 0xAA 0x55 // 长度 = 命令(1) + 数据(N) + 校验(1) // 校验 对"长度+命令+数据"做累加和取低8位接收解析用状态机,避免在阻塞中找帧头:
typedef enum { FRAME_WAIT_HEAD1, FRAME_WAIT_HEAD2, FRAME_WAIT_LEN, FRAME_WAIT_DATA, FRAME_WAIT_CHECK } FrameState; FrameState state = FRAME_WAIT_HEAD1; uint8_t frame_data[32]; uint8_t frame_len; uint8_t frame_index; uint8_t sum; void FrameParser(uint8_t byte) { switch (state) { case FRAME_WAIT_HEAD1: if (byte == 0xAA) state = FRAME_WAIT_HEAD2; break; case FRAME_WAIT_HEAD2: state = (byte == 0x55) ? FRAME_WAIT_LEN : FRAME_WAIT_HEAD1; break; case FRAME_WAIT_LEN: frame_len = byte; frame_index = 0; sum = 0; state = FRAME_WAIT_DATA; break; case FRAME_WAIT_DATA: sum += byte; frame_data[frame_index++] = byte; if (frame_index >= frame_len - 1) // 数据收完,还差校验字节 state = FRAME_WAIT_CHECK; break; case FRAME_WAIT_CHECK: if (sum == byte) { // 校验通过,frame_len 个数据有效 ProcessCommand(frame_data, frame_len); } state = FRAME_WAIT_HEAD1; // 无论对错都回到找帧头 break; default: state = FRAME_WAIT_HEAD1; break; } }这个状态机可以直接喂给方案一的字节流或方案二的整包处理,实测在115200波特率下跑几千帧没有出过错。
这些东西我都是踩过来的,第一次调多字节收发,我也经历过回调里忘了重装、DMA重启放错位置、IDLE标志不清导致进死循环这种幺蛾子。后来总结的套路就一句话:接收用DMA+空闲中断保底,协议加长度加校验,中断里只搬数据不做业务,问题基本能少一大半。如果你也正准备在F103C8T6上跑多字节通信,建议先用串口助手模拟对端,把帧结构和波特率定死,再逐步加协议层,调试会顺很多。
本文还有配套的精品资源,点击获取