☰
STM32串口空闲中断+DMA接收不定长帧:原理、实现与避坑指南
2026/10/21 3:18:06 网站建设 项目流程

简介:面向STM32F103嵌入式开发者的串口通信实用资源,重点解决UART接收不定长数据时缺乏结束标志的难题。通过利用空闲中断机制,可在数据线静默时及时触发处理,相比逐字节接收更高效稳定,也减少了CPU占用。资源包共180个文件,以C/H源码、启动汇编文件(.s)、工程配置与编译生成文件(如.hex、.axf)为主,整体压缩包仅2.58MB,结构清晰,可在Keil MDK中直接参考或移植。目前已有5300余人学习下载,适合正在调试串口协议、需要构建低开销接收逻辑的开发者。内容提供了完整工程模板和基于标准外设库的示例,涵盖UART初始化、空闲中断开启、中断服务函数编写、中断标志清除等关键步骤,并附带编译脚本与配置文件,方便快速验证和二次开发,对理解STM32底层串口机制也很有帮助。

1. 串口空闲中断收发:从逐字节打断到一帧一打断

做 STM32F103 UART 通信时,最头疼的不是波特率算错,而是帧边界怎么判断。设备上报的不定长数据,如果每来一个字节进一次接收中断,高波特率下 CPU 大半时间都在进中断、清标志、判断帧头,主循环里其他任务经常被饿着。后来我把接收中断改成“空闲中断 + DMA”的方案:总线发完一帧后只要线路空闲够一个字节时间,硬件只触发一次中断,DMA 已经把整帧数据搬进缓冲区。这个项目就是解决这个问题的,适用于 Modbus 采集、AT 指令解析、传感器组网这类“发一帧回一帧”的场景。手边有块 F103C8T6 最小板加一个 USB 转 TTL 就能跑起来,下面从原理、代码到跑通的坑一层层拆开。

2. 架构设计:看清单片机的中断触发链,再写驱动就不慌

要写好空闲中断收发,第一步不是翻代码,而是把硬件侧的触发条件吃透。下面先讲清 IDLE 标志位的脾气,再给分层设计。

2.1 空闲中断的判定逻辑:和“空闲帧”不是一回事

USART 的 IDLE 标志位不是收到一帧就立刻置位,而是当接收线上保持高电平并持续一个字节的时间之后才置位。更直白地说,上一帧最后一个字节已经落地,下一帧到来的间隔如果够一个字节时长,硬件就认为线路空闲,产生空闲中断请求。这里的“空闲”指线状态,和 CAN 里的空帧、UART 里的全 1 空闲帧完全是两码事。

F103 数据手册写得很明确:清除 IDLE 位必须先读 USART_SR,再读 USART_DR。少读任何一个,IDLE 位都不会被硬件清零,中断只会第一次进一次,之后所有帧都静默。如果用标准库,顺序就是 if (USART_GetITStatus(USART1, USART_IT_IDLE)) { USART_ReceiveData(USART1); };HAL 库封装的 __HAL_UART_CLEAR_IDLEFLAG 宏内部已经先读 SR 再读 DR,顺序是正确的,前提是你别在中断里只清标志不读数据。

这个空闲时间的长度跟波特率直接相关。以 9600 波特率、8 数据位 1 停止位为例,一个字节在线上耗时约 1.04 ms,也就是说,两个字节间隔超过 1.04 ms 时,IDLE 就会被置位。调这套代码时,把串口助手的发送间隔调成 20 ms 以上,就能比较稳定地看到每次只进一次空闲中断。这个时间也是后面 Modbus RTU 的 3.5 字符时间判断基础。

2.2 软件分层与关键数据结构:驱动只搬数据,应用只做解析

我把整套收发拆成三层:驱动层负责初始化 USART、DMA、NVIC,并在空闲中断里记录帧长度、切换缓冲区;协议层负责从缓冲区里按帧头、长度、校验字把一帧数据解析出来;应用层只关心有没有一帧完整的命令到达。分层之后,换串口或者改协议,互相之间的影响被限制住。

核心数据结构是一个全局的收发控制块,里面放双缓冲,以及让主循环能感知“收到一帧”的标志。双缓冲的好处是:主循环解析一帧的时候,DMA 已经在往另一片缓冲区写入下一帧,两边互不干扰。定义如下:

#define RX_BUF_LEN 256 typedef struct { uint8_t buf[2][RX_BUF_LEN]; uint8_t active_buf; volatile uint16_t frame_len; volatile uint8_t frame_ready; volatile uint8_t dma_overrun; } uart_idle_t; extern uart_idle_t g_uart1;

active_buf 表示 DMA 当前正在填哪一片缓冲区;frame_len 记录最近一帧的字节数;frame_ready 置 1 表示有一帧完整数据待处理。volatile 不能省,这些变量会被中断修改,编译器若优化成寄存器副本,主循环读到的永远是旧值。如果不用双缓冲,DMA 持续往同一块缓冲写,主循环还没读完上一帧,新帧就可能覆盖掉旧数据。

2.3 中断路径拆解:从硬件置位到主循环消费

把整条链路拆开看,DMA 收到字节后写入当前缓冲区;当 RX 线持续空闲一个字节时长,USART 硬件将 IDLE 位置 1;中断系统进入 USART1_IRQHandler;中断服务程序先清 IDLE,再读 DMA 剩余计数算出本帧长度,随后把 frame_ready 置 1,把 active_buf 切到另一片,并重新启动 DMA 接收;主循环轮询到 frame_ready 后,调用协议层解析,解析完清标志。

这里有个容易被忽略的细节:DMA 必须配置为循环模式。循环模式下 DMA 的 CNDTR 寄存器会从初始值递减,减到 0 自动重装初始值,同时内存地址会回绕;因此即便一直不停收,DMA 也不会自己停下来。我们正是利用 CNDTR 的剩余值来反推本帧长度:长度 = 缓冲区总长度 - 当前 CNDTR。如果用 FreeRTOS,可以把 frame_ready 替换成一个二值信号量,中断里只 GiveSemaphoreFromISR,任务里 Pend 获取后再解析。裸机环境下,轮询一个 volatile 标志已经足够,代码还直观好调。

选择空闲中断而不是字符中断的收益可以列一个表。假设一帧有 100 字节,逐字节接收中断要进 100 次中断,每次都要读数据寄存器、判断状态;而空闲中断配合 DMA,只进 1 次中断,CPU 在中断入口把帧长记下来就可以返回,搬运工作全由 DMA 在做。效率差距在高波特率长时间通信时非常明显。

方案中断次数/帧CPU 参与字节搬运数据丢失风险
逐字节接收中断N(每字节一次)是高,需及时进中断
空闲中断 + DMA1否低,DMA 后台搬运

清除 IDLE 标志时读出的 DR 数据应该丢弃,因为这时读到的往往是 0xFF 补位数据。上次我把它当成有效数据写进环形缓冲,解析器多出一个字节,查了很久才发现是这个原因。习惯做法是清标志前先读一次 DR,这个读操作不写缓冲区。

3. 核心模块实现:USART + DMA + 环形缓冲,把一帧数据完整收进来

这一章直接给代码。初始化使用 CubeMX 生成基础,再加上空闲中断开启;中断服务函数负责记账和切换缓冲区;如果帧长不确定,再把环形缓冲区接进来。

3.1 初始化配置:USART1、DMA、NVIC 一次性配好

初始化我推荐用 CubeMX 生成基础代码,因为 F103 的时钟树、引脚复用、DMA 请求映射在图形界面里比手写清晰。关键配置项是:USART1 的 PA9/PA10,波特率按实际需求选,DMA 接收请求要绑定 USART1_RX 通道,DMA 模式选 Circular。CubeMX 生成后,在用户代码区补上开启空闲中断这条通知:

void UART_Idle_Init(void) { // CubeMX 已生成 MX_GPIO_Init / MX_DMA_Init / MX_USART1_UART_Init __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 开串口空闲中断 HAL_UART_Receive_DMA(&huart1, (uint8_t *)g_uart1.buf[0], RX_BUF_LEN); }

最后这一行与普通 HAL_UART_Receive_IT 不同,它不会让每收一个字节都进一次中断,而是让 DMA 在后台持续搬运。HAL_UART_Receive_DMA 内部会把 DMA 通道使能,并把 CNDTR 设成 RX_BUF_LEN。注意 g_uart1.buf[0] 是二维数组首地址,DMA 需要的是内存地址,要强制转成 uint8_t *。NVIC 里 USART1 中断优先级建议设成中等偏上的抢占优先级,裸机设抢占优先级 2、子优先级 0 即可。

如果跑 FreeRTOS,要确保串口中断的抢占优先级低于 configMAX_SYSCALL_INTERRUPT_PRIORITY,否则从 ISR 里调用 HAL 相关接口可能进临界区时触发断言。另外,DMA 的传输完成中断不要勾选,原因在后文 4.5 节会展开。

3.2 空闲中断服务函数:记录帧长并切换缓冲区

接收中断服务函数是全项目最核心的一段。代码并不复杂,但要注意顺序:先判断 IDLE 标志,再清标志,之后读 DMA 剩余计数,算出收到多少字节。

void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 先读 SR 再读 DR uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t received = RX_BUF_LEN - remain; if (received > 0 && received <= RX_BUF_LEN) { g_uart1.frame_len = received; g_uart1.frame_ready = 1; HAL_UART_DMAStop(&huart1); // 停掉旧 DMA,防止地址漂移 g_uart1.active_buf ^= 1; HAL_UART_Receive_DMA(&huart1, (uint8_t *)g_uart1.buf[g_uart1.active_buf], RX_BUF_LEN); } } HAL_UART_IRQHandler(&huart1); }

__HAL_UART_CLEAR_IDLEFLAG 宏先读 SR 再读 DR,把硬件状态清干净。received 是缓冲区总长度减 DMA 剩余计数,得到当前已经被 DMA 搬进内存的字节数。接着马上切换缓冲区并重新启动 DMA。为什么先 HAL_UART_DMAStop 再启动?因为循环模式下 DMA 可能还在继续跑,如果直接调用 Receive_DMA,CNDTR 会被配置成新值,但旧缓冲区尾部残留数据可能和新数据粘连,用 Stop 强制停一下更稳妥。

HAL_UART_IRQHandler 放在最后,是为了把 HAL 库里其他 UART 中断(比如错误中断)统一处理。如果遗漏这个调用,UART 错误中断标志可能一直挂起,反复进入中断,出现“主循环啥也没干 CPU 却满了”的假象。实际编译时,这个函数必须在 main.c 或 stm32f1xx_it.c 里能链接到,别在中断服务函数里死循环。

3.3 环形缓冲区:让连续多帧数据不丢包

双缓冲能满足单帧收发的场景,但碰上连续不停下来的数据流,比如上位机以 10 ms 间隔发帧,解析还没完成下一帧就到了。环形缓冲区更适合做这种流水。环形缓冲区本身不解决帧边界问题,它只负责把中断里收到的字节暂存下来,让主循环按自己的节奏取。

#define RING_BUF_SIZE 512 typedef struct { uint8_t data[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_t; uint16_t ring_used(ring_t *r) { // 512 是 2 的幂,用与运算实现回绕 return (uint16_t)(r->head - r->tail) & (RING_BUF_SIZE - 1); } void ring_push(ring_t *r, uint8_t byte) { r->data[r->head] = byte; r->head = (r->head + 1) & (RING_BUF_SIZE - 1); } int ring_pop(ring_t *r, uint8_t *byte) { if (ring_used(r) == 0) return -1; *byte = r->data[r->tail]; r->tail = (r->tail + 1) & (RING_BUF_SIZE - 1); return 0; }

ring_used 返回当前缓冲区里待读取的字节数,head 是写入方(中断)位置,tail 是读取方(主循环)位置。两个都是 uint16_t,减法回绕正好符合环形逻辑。注意 ring_used 里的减法不能去掉 volatile,否则编译器可能把 head 缓存到寄存器,主循环判断时读的是旧值。缓冲区大小必须是 2 的幂,与运算才能等价于取模;如果改成 100 这种数,需要改成% 100,那计算开销会变大。

把空闲中断和环形缓冲接起来时,空闲中断里不再直接切双缓冲,而是把 received 个字节从当前 DMA 缓冲区逐个 push 进 ring_t,然后 frame_ready 置 1。主循环里不断 pop 字节喂给解析器,直到 ring_used 为 0。这样即使主循环暂时被其他任务卡住,数据也不会立刻丢,只是延迟消费。

3.4 帧解析状态机:从字节流里拎出一帧完整命令

收到字节流后,应用层需要识别帧头。我常用一个简单帧格式:0xAA + Length + Data + XOR 校验。解析器用状态机实现,每个字节进来都走一遍当前状态。

typedef enum { WAIT_HEAD, WAIT_LEN, WAIT_DATA, WAIT_XOR } parse_state_t; void parser_feed(uint8_t byte) { static parse_state_t state = WAIT_HEAD; static uint8_t len; static uint8_t data_cnt; static uint8_t xor_sum; switch (state) { case WAIT_HEAD: if (byte == 0xAA) { xor_sum = 0; state = WAIT_LEN; } break; case WAIT_LEN: len = byte; xor_sum ^= byte; data_cnt = 0; state = (len > 0) ? WAIT_DATA : WAIT_XOR; break; case WAIT_DATA: xor_sum ^= byte; if (++data_cnt >= len) state = WAIT_XOR; break; case WAIT_XOR: if (xor_sum == byte) { // 帧校验通过,此时解析出的数据已经按序流出 // 应用层可以把一个完整命令投递出去 } state = WAIT_HEAD; break; } }

状态机的好处是不需要把整帧存下来,每个字节处理完就丢掉,内存占用固定。主循环里只要不断从 ring_t 里 pop 出一个字节喂给 parser_feed,解析器就会自己完成同步和校验。如果数据里出现噪音,会停留在 WAIT_HEAD 等待下一个 0xAA,天然具备抗干扰能力。这里 length 字段也参与了异或校验,能发现长度被干扰的问题;如果 length 本身错了,数据计数和校验都会对不上,最终在 WAIT_XOR 失败然后回到 WAIT_HEAD。

4. 避坑清单:空闲中断收发最容易翻车的五个点

下面这些坑都是实际调试中见过或者踩过的,每条按现象、原因、解决三步写,方便排查问题时对号入座。

4.1 清 IDLE 位的顺序写反,之后再也不进空闲中断

现象:串口助手发送第一帧,板子能收到 frame_len 为正常值,但从第二帧开始 frame_ready 永远是 0,主循环一直停在等待状态。

原因:我在中断里只调用了 __HAL_UART_GET_FLAG 判断 IDLE,没有去读 DR。IDLE 标志虽然被看到,但硬件并不知道你读过了,清标志的动作没有完成,后续帧不会再次触发相同中断。更隐蔽的情况是,标准库代码里先读 SR 再读 DR,顺序没问题;而不同版本 HAL 库的 __HAL_UART_CLEAR_IDLEFLAG 宏实现有差异,要确认当前封装是否正确。

解决:无论用哪个库,清标志时按顺序强制读一下 SR 和 DR。HAL 版本直接调用 __HAL_UART_CLEAR_IDLEFLAG;如果还不放心,手动加一句volatile uint8_t tmp = huart1.Instance->DR;确保 DR 被读过。从那以后我把清标志写成独立函数,每次先读 SR 再读 DR,屏蔽库版本差异。

4.2 DMA 循环模式下算帧长,得到的全是历史剩余量

现象:通电后第一帧收到的长度是对的,从第二帧开始长度变成上一次剩余量,数据错位,解析出来的命令全废。

原因:F103 的 DMA 在循环模式下,CNDTR 寄存器会回绕,DMA 的当前传输地址也在不断前进。第一次启动 DMA 时地址从头开始,帧结束时用总长度减剩余量没问题。但第二次接收开始时,DMA 地址已经跑到缓冲区中间,如果还用缓冲区总长度减当前剩余量,得到的是“从启动以来一共搬了多少字节”,而不是本帧新增的字节数。

解决:在空闲中断里判断一帧结束时,先通过 __HAL_DMA_GET_COUNTER 拿剩余量,再立即切换 DMA 到新缓冲区,并在切换时把地址重新指到新缓冲区首地址。上文 3.2 节代码里先 DMAStop 再 Receive_DMA,就是为了让 DMA 重新从首地址开始。之前我图省事不 Stop 直接重新配置,翻车概率极高。

4.3 两帧间隔太短被当成一帧,数据粘包

现象:上位机连续快速发送两条数据,板子把两条数据合成一条,帧长超出协议预期,解析器报校验错。

原因:空闲中断判定的“空闲”是一个字节时间。如果上一帧最后一个字节和下一帧第一个字节之间间隔小于一个字节时间,USART 硬件认为线路还没有空闲,不会置 IDLE,于是两帧数据被 DMA 连续搬进同一缓冲区,只有一个空闲中断。

解决:在应用层额外加一个超时判据。常见做法是在解析器里维护一个字节计数器,并在主循环中判断“如果当前时间与上次收到字节的时间超过若干个毫秒,则把当前缓冲区做短帧处理”。也可以降低波特率,增大帧间间隔。对 Modbus RTU 这类协议,空闲中断本身需要配合 3.5 字符时间,这在第 6 章专门展开。

4.4 RTOS 环境里 NVIC 优先级分组被改,串口中断直接饿死

现象:裸机下串口收发正常,把代码移植到 FreeRTOS 后,串口中断只进来一次,之后再也不响应,其他任务照常跑。

原因:FreeRTOS 在启动任务调度器时会配置 NVIC 优先级分组,它要求所有中断的抢占优先级不能高于固定阈值。若 CubeMX 生成时把串口中断优先级设为 0(最高),而 FreeRTOS 内部中断期望优先级低于 configMAX_SYSCALL_INTERRUPT_PRIORITY,中断可能被屏蔽,或者触发断言。

解决:给 USART1 中断设成比 FreeRTOS 内核临界区允许值更低的优先级。裸机时设 2 没事,RTOS 下建议打开 FreeRTOSConfig.h,把硬件中断抢占优先级设为大于等于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 的数值。并且不要在中断里调用非 ISR 安全的 HAL 函数,像 HAL_UART_Receive_DMA 要确认临界区保护在这种优先级下可用。

4.5 误开 DMA 传输完成中断,多启动一次 Receive_DMA

现象:调试时打开了 DMA 传输完成中断,DMA 中断回调里调用 HAL_UART_Receive_DMA 想继续接收,结果缓冲区数据错乱,偶尔丢一帧。

原因:DMA 每搬完一整段缓冲区就会触发一次传输完成中断,这个中断语义是缓冲区满,而不是一帧收到。在缓冲区满时,DMA 已回绕并重新开始覆盖,此时重新启动 Receive_DMA 会把 CNDTR 重装为全长度,但地址已不是首地址,后续长度计算全错。串口接收模式下我们真正关心的是线路空闲,不是缓冲区满。

解决:关闭 DMA 传输完成中断,只使用空闲中断来识别帧边界。如果确实需要知道缓冲区满,可以在 DMA 传输完成回调里做一个统计或向上位机报警,但不要重新启动 Receive_DMA。要重新启动也应该先 DMAStop,和空闲中断处理保持一致。

5. 运行与移植:从 CubeMX 配置到 Keil 烧录验证

这一章讲清楚工程怎么建、怎么接线、怎么验证。照着做,新手也能把项目跑起来。

5.1 硬件接线与串口参数

先做物理层面的连接。F103C8T6 板子用 USB 供电,USB 转 TTL 模块也插电脑,两者之间的连接如下:

开发板引脚外设功能接到 USB 转 TTL
PA9USART1_TXRX
PA10USART1_RXTX
GND地线GND

注意 USB 转 TTL 的 TX 接 PA10,RX 接 PA9,交叉连接。共地必须接,不然电平参考不一致,收的数据容易乱码。串口参数可以用 115200 8 N 1,测试时延时发送一帧如 0xAA 0x03 0x01 0x02 0x03 0x01(长度 3,数据异或结果 0)。波特率越高,空闲判定时间越短,但主动发送间隔保持几十毫秒没问题。

5.2 用 CubeMX 生成基础工程

打开 CubeMX,选 STM32F103C8T6,时钟树设 72 MHz。然后配置 USART1:Mode 选 Asynchronous,波特率 115200。DMA Settings 添加 USART1_RX,Mode 选 Circular。再配置 NVIC,把 USART1 global interrupt 勾选上。工程生成时 Toolchain 选择 MDK-ARM。

这里有几个容易漏的点。第一,DMA 接收的 Memory Address Increment 要勾上,不然 DMA 永远写第一个地址。第二,NVIC 里优先级数值在 CubeMX 里设置 Actual preemption priority 为 2,子优先级 0。第三,DMA 的传输完成中断不要勾,避免 4.5 节里的坑。生成完后,在 main.c 的 USER CODE 段调用 UART_Idle_Init。

5.3 在 Keil 里加驱动代码和主循环

CubeMX 生成的工程已经包含 usart.c、dma.c,我们只需要把第 3 章里的 g_uart1 定义、uart_idle.c 等功能模块加进工程。在 main.h 里声明 extern,在 main.c 的 USER CODE BEGIN 区域定义:

/* USER CODE BEGIN 0 */ uart_idle_t g_uart1; /* USER CODE END 0 */

主循环代码要做的只有一件事:查询 frame_ready。置位后取出帧长度,调用协议解析。注意解析完后一定要把 frame_ready 清 0,否则下一次空闲中断再置位之前,主循环会一直重复解析同一帧。

while (1) { if (g_uart1.frame_ready) { g_uart1.frame_ready = 0; uint8_t *frame = g_uart1.buf[!g_uart1.active_buf]; parser_feed_frame(frame, g_uart1.frame_len); } HAL_Delay(1); }

这里用 !active_buf 拿到不是 DMA 正在写的那片缓冲区,因为 frame_ready 置位后 DMA 已经切换走了。如果用的是环形缓冲版本,则改成从 ring_t 里持续 pop 字节喂给 parser_feed,直到 ring_used 为 0。双缓冲方案下,如果一帧长度超过 RX_BUF_LEN,DMA 会继续往缓冲区写,可能覆盖正在解析的上一帧数据。解决方法是把 RX_BUF_LEN 设成大于最长帧,或者改成环形缓冲方案。

5.4 调试技巧:用串口助手回环测试

烧写后打开串口助手,发送一帧带协议的数据,板子解析后可以通过打印把收到的长度和十六进制内容发回调试串口。如果没回显,先检查是不是把 USB 转 TTL 接到 PA9/PA10 之外了,再看代码里有没有开空闲中断。另一个快速验证方法是加一个 LED 翻转语句:在空闲中断里翻转 PB12。发送一帧,LED 亮灭一次,就能直观确认中断有没有进,比连调试器排“没进中断”快得多。

注意 HAL_Delay(1) 在中断里绝对不能调用。调试时如果想在中断里做动作,只能用硬件寄存器翻转或写非阻塞计数。如果收到的是长度对但内容全 0,多半是 DMA 的 Memory Address Increment 没开;如果收到长度 255,先查是不是忘记清 IDLE 标志,导致每次进中断后读的是相同的残留状态。

6. 二次开发技巧:把空闲中断接收器改成 Modbus RTU 接收器

6.1 空闲中断如何适配 Modbus RTU 帧间隔

Modbus RTU 规定帧间隔是 3.5 个字符时间,而空闲中断判定的是 1 个字符时间。所以直接用空闲中断会把连续两帧间隔小于 3.5 字符时合并,在大于 1 字符但小于 3.5 字符时拆得不对。改造思路是:空闲中断作为“字节流到达”的唤醒信号,再启动一个定时器,定时时间设为 3.5 字符时间;定时器到超时前如果有新字节到达就重置定时器,超时未到就认为一帧结束。这样既保留 DMA 搬运,又符合 Modbus 的帧间隔要求。

6.2 多实例扩展:把驱动从全局变量抽象成对象

把 g_uart1 这种全局结构体改成接口型操作:定义 uart_idle_dev_t 结构体,包住缓冲区、状态、回调指针。初始化时注册串口句柄、DMA 句柄和一个回调,每个串口都有自己的空闲中断服务函数,调用同一个处理逻辑。这样复制代码到另一个串口时,不用大段改名字,只要注册一个新实例。

typedef struct { UART_HandleTypeDef *huart; DMA_HandleTypeDef *hdma; uint8_t (*buf)[RX_BUF_LEN]; uint8_t active_buf; volatile uint16_t frame_len; void (*on_frame)(uint8_t *data, uint16_t len); } uart_idle_dev_t;

中断里调用 dev->on_frame,应用层传入 Modbus 的解析函数。多串口场景下,每个串口一个实例,互不干扰。

6.3 性能优化:中断只做记账,解析放任务

最开始我在中断里做了数据复制,把一整帧拷到另一个 buffer 再置位。后来发现高波特率下耗时明显,就把复制逻辑全部搬到主循环和任务里,中断里只留三条语句:清标志、算长度、切换缓冲区。从那以后我每次写串口驱动都强制走一遍“这个操作能不能放到主循环”,能放就绝不在中断里做。这套思路从 F103 用到后面其他 MCU 都没翻车,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询