news 2026/9/8 5:57:51

STM32串口多字节收发实战:从中断到DMA+空闲中断,解决不定长数据帧接收难题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32串口多字节收发实战:从中断到DMA+空闲中断,解决不定长数据帧接收难题

简介:一份面向嵌入式初学者的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上跑多字节通信,建议先用串口助手模拟对端,把帧结构和波特率定死,再逐步加协议层,调试会顺很多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 5:57:39

用Python和Playwright搭建无头浏览器网页截图服务

1. 为什么我需要一台"能截图的浏览器"先讲个实际场景。我之前维护过一个内部报表系统,每周要生成几十份统计页面发给业务方。业务方不看在线版,一定要PDF或者图片,理由是"方便转发、方便存档、看着踏实"。最开始我用的方…

作者头像 李华
网站建设 2026/9/8 5:56:12

mp4转rmvb用什么软件?实测对比后我留下了这几款

为什么还要做 mp4转rmvb ?通常不是出于画质需求,而是为了兼容老旧硬件设备——比如车载导航一体机、老型号的网络机顶盒、某些工控设备的宣传播放屏,它们的硬件解码器只认 RMVB。另外,部分企业内部的老培训系统也规定必须上传 RMV…

作者头像 李华
网站建设 2026/9/8 5:49:34

Python+YOLOv8视频行人检测实战:从环境配置到完整源码

简介:面向计算机视觉学习者的Python行人检测完整工程,适用于智能交通、视频监控等场景中的目标识别与跟踪任务。资源基于OpenCV实现HOG特征提取与SVM分类器训练,并结合简单跟踪算法对视频帧中的行人进行检测与连续追踪,配套说明涵…

作者头像 李华
网站建设 2026/9/8 5:48:09

Windows C盘清理全攻略:系统工具+命令行+开发者缓存实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 5:47:52

数据库系统概念第七版:表结构导入与课后习题高效训练指南

简介:《数据库系统概念(第七版)》表结构及课后习题答案包,面向正在学习数据库原理、需要强化表结构设计与SQL操作的高校学生和自学者。资源围绕教材核心内容,系统梳理ER图设计、主键外键机制、数据类型选择等要点&…

作者头像 李华
网站建设 2026/9/8 5:46:35

LightCC OS:容器化Linux桌面,浏览器直开AI开发环境

这次我们看一个比较有意思的方向:把完整 Linux 桌面直接装进 AI 容器,文件管理器、终端、模型库全部内置,启动后通过浏览器就能操作。项目名字叫LightCC OS,核心解决的痛点是:AI 开发和模型调试通常离不开 Linux&#…

作者头像 李华