
简介面向STM32嵌入式开发者的串口接收参考代码包基于HAL库在STM32F405上实现UART接收ASCII数据并以回车作为结束符判定。资源围绕不定长串口数据的接收与解析场景适合正在学习HAL库、希望快速搭建串口收发框架的初学者或项目开发者。压缩包共85个文件、约647KB以50个头文件和22个C源文件为主体涵盖UART、IO、main等模块工程目录按Core、Drivers、MDK-ARM等区域组织同时包含启动汇编文件、Hex固件、CubeMX的.ioc配置、调试脚本与清理脚本可在Keil环境中直接打开对照学习。已有2130人浏览学习。通过这份参考代码可掌握HAL库串口初始化、中断接收、回车结束符判断的完整流程并能理解中断服务函数与主循环的配合方式将其工程结构、配置思路迁移到自己的项目中可有效减少串口收发与底层调试时间。1. 拿到“5 UARTRecIT.zip”后先搞懂它在解决什么做 STM32 串口接收十有八九会踩到同一个坑主循环还在跑业务逻辑背板设备的数据已经过来了等你去读寄存器缓冲区早就被新数据覆盖。回调函数怎么写都不对调试器一挂数据又恢复了——典型的“串口接收玄学”。这份 UARTRecIT.zip看命名就知道不是一个普通例程UART RecReceive ITInterrupt核心就是串口接收中断的完整工程。zip 里大概率不止一个工程文件常见组织方式是 5 个变体工程覆盖轮询、中断、DMA 几种接收路线解决的核心问题是数据到了怎么不失真、不丢失地收下来并且不阻塞你的主循环。适合正在调串口通信的开发者或者被“接收阻塞、丢数据、死机”折磨了一周的人。如果你是第一次听说串口接收还要分模式这个包能帮你把几条路都走一遍少走弯路。2. 为什么 UARTRecIT 值得照着做轮询、中断、DMA 三条路线怎么选2.1 三条路线对比实时性、代码量、CPU 占用完全不一样串口接收无非三种方式轮询Polling、中断Interrupt、DMA。每个 STM32 开发者迟早要在这三者之间做选择选错就是血泪经验。先说轮询主循环每次循环都去读一次接收标志位有数据就取走。代码最简单三行写完但代价是如果主循环里有延时、有长任务数据来了你根本来不及取RXNE 标志位被新数据冲掉丢字节是家常便饭。DMA 则反过来数据到了硬件直接搬进内存缓冲区CPU 全程不参与省心高效但 DMA 有两个问题一是不知道一帧数据什么时候结束二是部分型号的特定串口外设比如某些单片机上的 UART4、UART5压根没有 DMA 请求线DMA 方案直接失效。中断接收是三条路线里的平衡点。每收到一个字节硬件自动跳进中断服务函数你把字节搬进自己的缓冲区然后立刻退出中断主循环该干嘛干嘛。代码量比轮询多但实时性有保障比 DMA 复杂但适用范围广任何带 UART 中断的 STM32 都能用。这份 UARTRecIT.zip 里的“IT”两个字母指的就是这条路线——串口接收中断。包名开头的数字“5”在我见过的情况下通常有两种含义一种是压缩包里打包了 5 个版本的接收工程另一种是工程面向 UART5 这个外设。如果对应的是 STM32 的 UART5那这个包的价值就多了一层因为 UART4、UART5 这两个外设在不少型号上没有 DMA 通道中断接收是唯一的“不丢数据”方案。关键的选型判断点就一个你的主循环是不是能被串口打断如果主循环里全是快任务、没有长阻塞轮询够用根本不用下这个包。但只要主循环里有延迟、有等待标志位、有阻塞式的外设操作轮询就会丢数据中断接收就成了必需品。我做过的串口项目中凡是涉及到模块之间的异步通信最终都走向了中断或 DMA。而 DMA 有个额外前提你的数据帧长度得是固定的或者你能容忍“按缓冲区满来切帧”的粗糙度。不定长的数据DMA 反而要配合空闲中断才玩得转。2.2 5 个变体工程的典型组织方式从简单到复杂一步步加深虽然我手上不能逐个列出 zip 内的文件名但按我拆过类似包的经验这种带数字编号的串口接收资源通常按难度递增排列。比如第一个工程是最简中断接收只收一个字节就回显目的是验证中断通路第二个工程改成接收固定长度的数组用接收完成回调来管理数据第三个工程引入环形缓冲区解决“不确定何时读完”的问题第四个工程加超时判断支持不定长帧第五个工程可能是一个完整的上位机通信协议收发演示。这样的组织顺序本质上是在引导你把串口接收这个“黑匣子”一层层打开先确认硬件通路通了再解决数据存放方式然后解决帧边界问题最后落到业务协议上。如果你拿到手的 zip 内部结构类似建议按这个顺序逐个编译、烧录、验证不要一上来就直接拿第五个工程改业务。跳过前面几步后面出了问题你很难定位是中断没通、缓冲区还是你的协议解析写错了。验证中断通路是否正常最快的办法是回环测试把板子的 TX 和 RX 短接或者用 USB 转 TTL 工具把 PC 串口连到板子发送一个字节看板子是否原样回传。我第一次接触这类工程时直接跳过验证把整个协议栈写完了才发现一个字节都没收到排查了一整天最后发现是中断优先级配置被后来加的定时器中断挤掉了。2.3 中断接收的核心机制RXNE 标志位和你的回调函数STM32 串口接收中断的底层触发机制说穿了就是一个标志位RXNEReceive data register not empty接收数据寄存器非空。每收到一个字节硬件把这个位置 1如果此时 USART 的中断被使能CPU 就会跳进中断服务函数。你读走数据寄存器RXNE 自动清零然后等下一个字节。在 HAL 库框架下你面对的不是裸寄存器而是两个回调函数一个是中断服务函数里被调用的弱函数名字叫 HAL_UART_RxCpltCallback接收完成回调另一个是你每接收一个字节后需要重新“预约”下一次接收的函数 HAL_UART_Receive_IT。这俩必须配对使用少一个都不行。很多人翻车的点在于只写了回调函数但忘了在回调里重新调用一次 HAL_UART_Receive_IT结果第一个字节收进来之后中断就再也不触发了。这个包的工程如果涉及 HAL 库大概率就是围绕“回调 重新预约”这个组合来组织的。它的优点是把硬件细节藏起来了你只需要关心“数据来了放哪”和“怎么再约一次”缺点是一旦你忘了重新预约现象非常隐蔽——不报错、不死机就是数据只收到一个字节。我在给某跨平台系统做串口驱动时就被这个问题坑过整整一个下午从那以后我在所有接收回调里做的第一件事永远是重闯 Receive_IT。3. 配置 UARTRecIT 的接收工程引脚、时钟、参数和中断优先级一个都不能错3.1 初始化代码从 CubeMX 到 HAL_UART_Init 的完整参数链串口接收工程的配置看似简单但真正决定成败的往往是你觉得“不用管”的那几个参数。以下是一份典型的 HAL 库串口初始化代码我加了关键注释照着抄能跑通/* 串口句柄定义 */ UART_HandleTypeDef huart5; /* 串口初始化函数参数链全部从 CubeMX 生成建议手动核查三项 */ void MX_UART5_Init(void) { huart5.Instance UART5; /* 外设基地址UART5 在 F1 系列无 DMA */ huart5.Init.BaudRate 115200; /* 波特率与对端设备必须严格一致 */ huart5.Init.WordLength UART_WORDLENGTH_8B; /* 数据位长度常规选 8 位 */ huart5.Init.StopBits UART_STOPBITS_1; /* 停止位1 位覆盖绝大多数场景 */ huart5.Init.Parity UART_PARITY_NONE; /* 校验位无校验最常见 */ huart5.Init.Mode UART_MODE_TX_RX; /* 收发都使能只收的话也要选这个 */ huart5.Init.HwFlowCtl UART_HWCONTROL_NONE; /* 硬件流控两线制串口必须 NONE */ huart5.Init.OverSampling UART_OVERSAMPLING_16; /* 过采样率默认 16 倍即可 */ if (HAL_UART_Init(huart5) ! HAL_OK) { Error_Handler(); /* 初始化失败进错误处理 */ } }这段代码的逻辑很直接填充结构体、调用 HAL_UART_Init、判断返回值。但我要提醒三个参数。第一个是 BaudRate你对端设备的实际波特率和这里不一致表现就是乱码或者完全收不到115200 是通用默认值但很多工控模块出厂是 9600。第二个是 Parity有些设备用奇偶校验做老式握手如果对端开了校验而你没开数据会间隔性出错。第三个是 Mode即使你只做接收也建议选 UART_MODE_TX_RX 而不是单选 RX因为调试时你要回传数据选 RX 模式想发数据发不出去又得改代码重新编译。引脚配置同样关键。UART5 在标准封装下通常复用到某个 GPIO 端口的具体引脚CubeMX 里你把外设选成 UART5 后它会自动分配引脚你只需要确认这个引脚没有被其他外设占用。一个容易忽略的点如果你的板子上 UART5 的 TX 引脚同时还接了别的功能初始化之后那个功能就失效了排查半天不如先看一眼原理图。3.2 使能接收中断NVIC 优先级和下边这个关键函数初始化完串口不代表数据会自动进你的回调。你需要两件事一是调用 HAL_UART_Receive_IT 使能接收中断并指定数据存放地址二是在 NVIC 里把串口中断优先级设好。代码如下/* 全局接收缓冲区单字节接收回调里转存 */ uint8_t rx_byte 0; /* 使能接收中断收到一个字节后自动进回调 */ HAL_UART_Receive_IT(huart5, rx_byte, 1); /* 中断服务函数HAL 库已经替你把 UART5_IRQHandler 写好了不需要你动 */ /* 接收完成回调每收一个字节进这里一次 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance UART5) /* 多串口时用 Instance 区分来源 */ { /* 把数据存进业务缓冲区这里只做转存不做解析 */ protocol_push_byte(rx_byte); /* 重新预约下一次接收忘掉这一行中断只触发一次 */ HAL_UART_Receive_IT(huart5, rx_byte, 1); } }逻辑拆开看就是三步预约接收、中断触发、重新预约。参数 HAL_UART_Receive_IT 的第三个参数是接收长度这里填 1表示每字节中断一次。如果你改成一次性接收 10 个字节那么要收满 10 个字节才会触发回调中途数据断流则永远不触发——这就是长度参数带来的行为差异。所以对不定长的数据帧最稳妥的用法是长度固定填 1回调里自己拼帧。NVIC 优先级这件事看着不起眼实际是串口接收稳定性的分水岭。HAL 库在 HAL_UART_Init 里不会帮你配 NVICCubeMX 生成的代码里单独有一段HAL_NVIC_SetPriority(UART5_IRQn, 0, 0); /* 抢占优先级 0子优先级 0 */ HAL_NVIC_EnableIRQ(UART5_IRQn); /* 使能 UART5 中断 */优先级数值越小越优先。如果你同时用了定时器中断做延时而定时器优先级比串口高那么串口数据来了会被定时器打断。短时间占用没问题但如果你在定时器中断里做了耗时操作串口字节就会丢。我的习惯是串口接收中断优先级给到最高档即抢占优先级 0因为串口数据是实时物理信号错过就没了而定时器延时的抖动可以通过逻辑补偿。几个中断源共享优先级时子优先级配合抢占优先级一起用但原则不变串口接收优先。3.3 波特率误差:一个表看懂你能不能稳定收发很多人以为波特率只要“写对数字”就行实则不然。STM32 的串口波特率由外设时钟、USARTDIV 分频值决定如果你的外部晶振不是标准的 8MHz、12MHz算出来的实际波特率和标称值就有偏差。接收端和发送端的波特率偏差超过 2% 到 3%数据就会随机出错。我用过几种常见主频组合列一个参考表主频配置串口外设时钟目标波特率实际波特率误差稳定性判断72MHz8M 晶振 x9 PLLAPB1 36MHz115200约 0.08%稳定72MHz8M 晶振 x9 PLLAPB1 36MHz9600约 0.08%稳定48MHz内部 HSIAPB1 24MHz115200约 0.4%可用但不建议长帧108MHz12M 晶振 x9 PLLAPB1 54MHz115200约 0.2%短帧可用长帧谨慎如果你的实际波特率误差落到表中“可用”档短帧问题不大但如果你传输一帧上百字节误差会累积到特性的字节上。这属于“玄学”问题看起来配置都对就是偶尔错一个字节。遇到这种情况我一般直接换一个能被外设时钟整除的波特率而不是苦苦调分频器。比如你用 48MHz 主频115200 误差偏大那就改用 57600误差立刻降下来。功耗和速度的取舍值当你多思考几分钟。4. UARTRecIT 的工程实现了什么环形缓冲区与帧边界处理4.1 环形缓冲区解决“数据来了没地方放”的问题中断回调里直接放解析逻辑是大忌。因为中断里应尽量少干活你写一个复杂的解析函数执行时间一长下一个字节的中断就来不了直接丢数据。正确的做法是中断回调里只把收到的字节压进环形缓冲区主循环里再拿出来解析。环形缓冲区本质是个数组加两个指针——读指针和写指针数据先进先出写满了覆盖最旧的数据。以下是一段简洁的环形缓冲区实现配合上面的接收回调使用/* 环形缓冲区结构体 */ #define RING_BUFFER_SIZE 256 typedef struct { uint8_t buffer[RING_BUFFER_SIZE]; volatile uint16_t head; /* 写指针 */ volatile uint16_t tail; /* 读指针 */ } RingBuffer; RingBuffer uart5_ring; /* 压入一个字节写指针移动 */ void ring_push(RingBuffer *rb, uint8_t data) { rb-buffer[rb-head] data; rb-head (rb-head 1) % RING_BUFFER_SIZE; } /* 弹出一个字节读指针移动 */ int ring_pop(RingBuffer *rb, uint8_t *data) { if (rb-tail rb-head) { return -1; /* 缓冲区空 */ } *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RING_BUFFER_SIZE; return 0; } /* 查询当前缓冲区内数据量 */ uint16_t ring_count(RingBuffer *rb) { return (uint16_t)((rb-head - rb-tail RING_BUFFER_SIZE) % RING_BUFFER_SIZE); }这个代码的逻辑核心是取模运算,指针走到数组末尾自动回卷到开头形成一个环。volatile 关键字写在指针上是为了防止编译器把 head 和 tail 优化到寄存器里——中断和主循环同时访问这两个变量不加 volatileDebug 模式正常Release 模式就出诡异问题这是新手最容易栽的坑。head 和 tail 用 16 位无符号整数配合 256 大小的缓冲区取模运算的代价很低在单片机上也扛得住。缓冲区大小的选择有讲究。256 字节是最常见的起步值如果你的协议单帧数据在 100 字节以内这个大小绰绰有余。但如果你要接收连续的大块数据流比如固件升级包256 字节会频繁覆盖。解决办法是把数组改大但要注意大数组如果定义在函数内部,会占满栈空间直接导致死机。定义成全局变量或静态变量问题就不会出现。我在一个工程里就犯过这个错把 2048 字节的缓冲区定义在函数里跑起来必死机卡了一天才明白是爆栈了。4.2 帧边界怎么定三种策略和它们的适用条件数据收进来了怎么知道一帧从哪开始、到哪结束三种策略固定长度、帧头帧尾、超时判断。UARTRecIT 这类工程包往往三种都演示了一遍。固定长度最简单双方约定好一帧 8 个字节收满 8 个字节处理一次。用 HAL_UART_Receive_IT(huart5, rx_buffer, 8) 就能实现收满 8 字节自动进回调。但它没灵活性对端发来的数据长度一变整个协议就崩了。帧头帧尾是工程里最常见的做法帧头是 0xAA 0x55 之类的特征字节帧尾用校验和或结束标记。解析状态机在收到帧头后开始记录收到帧尾标志一帧完整了。这个方法能应付变长数据但前提是帧尾或长度字段不能和业务数据冲突否则中间的数据里出现帧尾字节帧就提前被切断了。超时判断是我个人最偏爱的方案收到一个字节就重置定时器如果一段时间内比如 5 毫秒没有新字节到来就认为当前帧接收结束。这个方案不用约定帧头帧尾天然支持任意长度特别适合串口助手调试。实现方式有两种一种是由定时器中断配合一个标志位另一种是直接在主循环里用 HAL_GetTick 做超时判断。以下是主循环里的超时接收逻辑#define FRAME_TIMEOUT_MS 5 uint32_t last_rx_tick 0; uint8_t frame_buf[128]; uint16_t frame_len 0; void main_loop_poll_frame(void) { uint8_t byte; /* 从环形缓冲区取出所有新数据 */ while (ring_pop(uart5_ring, byte) 0) { if (frame_len 0) { /* 一帧开始记录时间戳 */ last_rx_tick HAL_GetTick(); } frame_buf[frame_len] byte; /* 防溢出超过缓冲区上限抛弃当前帧重新开始 */ if (frame_len sizeof(frame_buf)) { frame_len 0; } } /* 超时判断帧开始后 5ms 没新数据认为帧结束 */ if (frame_len 0 (HAL_GetTick() - last_rx_tick) FRAME_TIMEOUT_MS) { /* 到这里 frame_buf 里就是完整的一帧交给协议解析 */ parse_protocol_frame(frame_buf, frame_len); frame_len 0; /* 清空准备下一帧 */ } }这段逻辑的核心是push 数据时更新 last_rx_tick轮询时发现超过 5ms 没推数据且缓冲区有残留就切帧。FRAME_TIMEOUT_MS 是超时阈值它的取值取决于你的波特率和帧间隔。波特率 115200每个字节约 87 微秒一帧 32 个字节约 2.8 毫秒那 5 毫秒的超时是安全的。如果你波特率降到 9600一个字节约 1 毫秒32 字节的一帧需要 32 毫秒5 毫秒超时就会把一帧切断这时候你把阈值调到 20 毫秒左右比较合理。这个参数没有固定值,得根据实际波特率和帧长算。4.3 解析放主循环而不是中断为什么这样更稳前面代码里你注意到没有解析函数 parse_protocol_frame 是在主循环里调的不是中断里。这是中断接收工程里最核心的纪律中断收、主循环解。中断里做的事情越少主程序被打断的概率就越低丢数据的风险就越小。拿一个真实的例子说明代价某工控板上串口中断里直接跑了一个 printf 函数做日志输出printf 内部阻塞等待发送完发送 128 字节时波特率 9600约 130 毫秒这个期间串口接收中断全部被堵后续数据全丢。把日志输出挪到主循环丢数据问题立即消失。如果你的中断里非要写点什么请记住一个原则只做赋值、转存、标志位置位不做循环、不调延时、不调阻塞 IO。这块纪律配合环形缓冲区已经是串口接收工程里比较成熟的架构了。UARTRecIT 这种资源我推测工程内部走的就是这条设计路线因为这是现阶段的业界标准做法。5. UARTRecIT 实战避坑现象、原因、解决一条龙5.1 中断只触发一次后续数据再也收不到现象上电后第一个字节能收到回调也进了但之后发什么数据都没反应。原因回调函数里没有重新调用 HAL_UART_Receive_IT。HAL 库的接收中断是一次性的,数据收满指定长度后中断被硬件关闭必须软件重新使能。解决在 HAL_UART_RxCpltCallback 末尾重新调用一次 HAL_UART_Receive_IT同时确认每次用的是同一个数据缓冲地址。我第一次遇到这个问题还以为是中断优先级被人改了花了一多小时查 NVIC 配置最后才发现回调函数里少写一行。从那以后我在所有接收回调里写的第一行永远是重新预约成了肌肉记忆。5.2 数据全部乱码或者完全收不到现象PC 串口助手发字符板子回传的内容对不上波形看了也没错。原因两方面。第一是波特率不匹配PC 端 115200、板端 9600第二是外部晶振和代码里配置的不一致代码按 8MHz 晶振算分频板子上实际是 12MHz 晶振结果串口时钟偏了 1.5%。解决首先核对两边波特率数字一致然后用示波器或逻辑分析仪测 TX 引脚的实际波形数一下 1 位的时间宽度。如果 115200 配置下测到一位约 8.7 微秒那就对应 115200如果实测 11 微秒说明波特率偏了去改用与晶振匹配的时钟配置。5.3 接收丢字节第一批字节收到后面的偶尔丢现象短帧没问题长帧传到后面就缺字节或者高波特率下问题更严重。原因中断里干活太多数据字节到达间隔不够执行完整个中断服务函数。有的开发者喜欢在中断回调里直接拷贝数据到业务数组、做校验、甚至打印日志中断占用时间一长下一个字节的 RXNE 中断就会滞后甚至被硬件覆盖。解决中断回调里的代码精简到只有“存入环形缓冲区 重新预约”其余全部移到主循环。如果中断精简后仍丢数据检查 NVIC 有没有被别的更高优先级中断抢占串口接收优先级按第 3 章说的调成最高。5.4 串口发数据正常但收数据触发不了中断现象板子能发能收回调就是不进。查寄存器发现 RXNE 标志位是 1但程序运行不到回调函数。原因你用的是国产替换芯片或者某个型号的内部 Boot 把 UART5 引脚复用了导致初始化引脚的代码覆盖掉了复用配置。另一种常见原因是单片机处于低功耗模式串口时钟被关了。解决在 HAL_UART_Init 之前检查 GPIO 复用配置确保 RX 引脚模式设置为 GPIO_MODE_AF_PP且上拉电阻配置正确。排查时直接在中断服务函数入口打断点确认中断向量表有没有被配置正确。5.5 使用 DMA 接收时数据半帧就触发回调现象采用 DMA 做接收设定了 256 字节长度数据来了不到 100 字节就触发完成回调缓冲区数据不完整。原因配置 DMA 时设了循环模式或者外设请求被其他 DMA 通道干扰导致传输长度被截断。解决DMA 接收适合固定帧长场景UART5 这种无 DMA 通道的外设就别硬上 DMA 方案老老实实走中断。如果用的是有 DMA 的串口回调里先读 DMA 剩余计数器包含注册的传输长度计数确认实际收到多少字节再处理数据不要默认缓冲区已经填满了 256 字节。6. 进阶用超时切帧实现不定长协议并顺手验证整个接收链路如果你只是收发固定长度数据前面的部分已经够用。真正考验一个串口接收工程是否健壮是不定长协议。工控领域最常见的 Modbus-RTU、自定义 ASCII 协议全是变长的你需要一个可靠的切帧手段。在第 4 章的超时判断基础上我习惯再加一道保险把超时阈值做成可调参数放在一个配置结构体里现场调试时不用重新编译就能改。以下是我在排查串口问题时常用的一段验证代码配合第 4 章的环形缓冲区直接用/* 串口链路自检函数往对端发一串数据收回来做对比 */ int uart_loopback_test(UART_HandleTypeDef *huart, uint8_t *test_data, uint16_t len) { uint8_t rx_byte; uint16_t received 0; uint32_t start_tick HAL_GetTick(); /* 发送测试数据 */ HAL_UART_Transmit(huart, test_data, len, 100); /* 等待回包超时 100ms */ while ((HAL_GetTick() - start_tick) 100) { if (ring_pop(uart5_ring, rx_byte) 0) { if (rx_byte ! test_data[received]) { return -1; /* 数据内容不一致 */ } received; if (received len) { return 0; /* 全部通过 */ } } } return -2; /* 超时未收完 */ }这段代码的思路是发一串已知数据出去让对端设备回环然后逐字节比对收到的内容。如果你手上没有对端设备直接把板子的 TX 引脚和 RX 引脚用杜邦线短接它自己发自己收也能验证串口链路是否完整。返回值三个状态0 代表链路正常、-1 代表字节内容出错、-2 代表超时。拿到出错状态后优先怀疑波特率偏差再去查中断是否被抢占。这个方法是我排查串口问题时最常用的手段比盯着调试器看变量直观得多。字节内容不一致时还有一个常被忽略的因素电气连接。如果你的 TX、RX 之间地线没共好信号电平飘忽不定数据会偶发翻车。尤其是在接外部设备时两边都得共地否则跑着跑着就出问题而且问题随机复现极其折磨人。我经历过最夸张的一次板子和设备相隔两米地线没接短帧全对长帧必错接上地线后一次通过。从那以后我每次做串口通信调试第一件事就是确认地线连接第二件事才是查软件配置。这段经验写在这里希望你调试时少走两小时弯路。整个 UARTRecIT 的核心思路总结下来就是配置中断接收数据进环形缓冲区超时切帧主循环解析。照着这个架构走串口接收的绝大多数问题都不会再找上你。希望帮到你。本文还有配套的精品资源点击获取