2026/10/16 8:23:46

STM32 FreeModbus-RTU主机模式数据接收函数传递全解析

STM32 FreeModbus-RTU主机模式数据接收函数传递全解析 提起 FreeModbus-RTU 主机模式很多人第一反应是“这不是网上现成代码一堆吗”可真到自己用 STM32 做一主多从采集时卡住我的不是功能码怎么拼反而是“数据接收函数怎么从串口中断一路传回应用层”这个看似简单的问题。从底层串口收到一个字节到协议栈拼出一帧完整报文再到应用层拿到寄存器数值中间每一层都在传递数据但怎么传、用什么函数传、传完谁来处理一旦设计得乱整个主机程序就会变成一团互相割不开的全局变量和标志位。这篇内容我想把 FreeModbus-RTU 主机模式下的数据接收函数传递完整拆开讲一遍先搞明白主机模式接收路径和从机模式有什么不一样再讲怎么配置移植、怎么设计回调函数和底层读函数注入最后给一套可以直接抄的 C 代码骨架以及我实际调帧时序时踩过的坑。适合正在用 STM32 做 Modbus 主机采集、传感器轮询、设备网关的同学基础薄弱也没关系我会尽量把每个“为什么”都讲清楚。1. 项目需求拆解主机模式的数据接收到底难在哪1.1 主机模式和从机模式接收路径的区别FreeModbus 最原始的版本是从机模式逻辑上是“被动应答”也就是从机不停地监听串口收到主机发来的请求帧校验通过后构造响应帧发回去。从机模式下接收路径相对简单所有串口字节都属于请求帧协议栈内部的接收状态机一帧一帧地啃啃完就调用上层回调。但主机模式完全反过来逻辑是“主动询问”主机先发请求帧然后等待从机应答。这个“等”字就带来了一大堆问题。第一个问题是怎么判断一帧从机响应已经收完了第二个问题是超时了怎么办第三个问题是收上来的数据怎么交还给应用层。从机模式里“收到请求帧”的事件是由数据到达触发的时间上是确定的主机模式里“收到响应帧”的事件却是先发请求之后才可能出现而且可能永远不来。所以主机模式必须额外处理应答超时不能像从机那样只管收。更麻烦的是主机发送的请求帧和从机响应的帧在串口线上是分时出现的。如果不用 RS485 方向切换光这一点就够头疼发送完自己的请求后必须把收发器从发送状态切回接收状态且切换时机和字节时序严格相关。很多人在这一步就把状态机搞乱了导致第一个从机字节刚回来方向引脚还没切回来数据被硬件丢掉了。1.2 “函数传递”的三个层面这个标题里的“数据接受函数传递”我理解下来其实包含三层意思很多人只盯着其中一层所以代码怎么写都不顺。第一层是底层串口字节的读取函数怎么传入协议栈。也就是说协议栈不认识你的 HAL 库它只认识一个uint8_t ReadByte(void)之类的函数指针。你通过函数指针把底层的串口读字节能力注入到协议栈里协议栈才能在状态机需要时主动去取数。这一层是“向上抽象”属于设备驱动层到协议栈层的传递。第二层是完整帧数据怎么从协议栈传递到应用层。协议栈解析完一帧从机响应后不能直接把数据塞进某个全局变量就算完事更合理的做法是提供一个注册回调函数的接口让应用层声明“我关心这个事件帧来了就调用我”。这一层是“向下分发”属于协议栈到业务逻辑层的传递。第三层是回调函数内部的数据怎么从中断上下文传递到 main 循环。很多方案里回调是在串口接收中断的上下文里被调用的这时候你可以在回调里只是把帧数据拷贝到一个临时缓冲并且置一个标志位真正耗时的处理放到主循环或低优先级任务里去。这一步处理不好就会出现“回调明明执行了但主循环拿到的数据是坏的”这种诡异问题。搞清楚这三个层面后面所有代码都是围绕它们展开的。2. FreeModbus-RTU 主机模式移植与配置要点2.1 串口和定时器资源分配我把这个项目跑在了一块 STM32F1 系列的板子上主频不高但也足够跑 Modbus RTU 9600 波特率。硬件上需要一路串口、一个通用定时器、一个 RS485 方向控制引脚。串口用中断方式收字节定时器用来做帧间隔和应答超时的计时。资源分配的思路是这样串口负责收字节收到一个就喂给协议栈的接收函数定时器负责维护 Modbus RTU 的 3.5 字符间隔计时也就是判断一帧是否结束的静默时间。FreeModbus 的从机模式一般也会配置定时器但主机模式下这个定时器还要承担“发送请求后等从机应答”的超时计时所以定时器的中断优先级建议比串口低一点避免在字节密集到达时互相干扰。我这里用的定时器是 TIM2分频到 1MHz基本是 1us 一个 tick。这样算时间很方便在中断里读取当前计数值再比较差值就能判断间隔。有一点要注意定时器溢出要处理好不能用简单的current timeout这种比较方式否则 16 位计数器回绕时会误判成超时。用无符号差值比较是常规做法。串口中断和定时器中断的优先级分配我特意让串口中断优先级高于定时器。原因是字节已经到硬件了丢掉就是真的丢掉而定时器稍微晚几十微秒处理并不会影响 3.5 字符间隔的判定精度。如果你用的是 FreeRTOS还需要考虑中断和任务之间的同步这个后面说。2.2 主机模式初始化流程不管你用的是哪个 FreeModbus 移植分支主机模式的初始化步骤大体上是固定的。我的项目里是这样做的。先配置串口为 8 数据位、无校验、1 停止位也就是常见的 8N1波特率 9600。然后配置定时器分频和溢出周期开定时器更新中断但先不启停。接着初始化 Modbus 主机状态相关的数据结构设置本机作为主机的角色注册底层读函数和写函数。最后eMBMasterEnable()使能协议栈主循环里不断调用eMBMasterPoll()来驱动状态机。这里有个很容易走偏的地方主机模式的初始化不要在一个函数里把“串口配置”“定时器配置”“协议栈使能”全部做完后就以为完事了。建议分开成几个函数每个函数只干一件事。我在调试时最常碰到的就是串口初始化在后协议栈使能在前结果协议栈里存的波特率参数和实际串口不一致发出的请求完全是乱码。2.3 时间参数3.5 字符间隔与应答超时Modbus RTU 帧与帧之间需要至少 3.5 个字符时间的静默间隔这是协议区分两帧的唯一硬件依据。这个参数需要根据波特率计算。以 9600 波特率、8N1 格式为例一个字符含 1 个起始位、8 个数据位、1 个停止位共 10 个 bit。1 个 bit 时间是 1 / 9600 ≈ 104.17us一个字符时间约 1.0417ms3.5 个字符时间就是约 3.65ms。如果串口配置是 8E1带偶校验时一帧有 11 个 bit3.5 字符时间约 4.01ms。所以做时间参数时一定要先明确你的串口校验位然后再算不能直接抄网上某个数值。应答超时则要结合具体从机的响应速度来定。Modbus 协议规定从机最迟要在收到请求后的 1 秒内开始响应但实际设备往往在几十毫秒内就回了。我一般会把超时设在 100ms 到 500ms 之间具体要看从机类型。设太短某些响应慢的仪表会被误判为超时设太长整个轮询周期会被拖得很难看。这个超时的计时起点也不是“发送完最后一个字节”那一刻而应该是“方向引脚切回接收模式、允许接收从机数据”的那一刻。有些代码把请求发出后就启动超时计时但这中间如果方向切换需要额外时间实际等待窗口就会偏长。严谨的做法是在发送完请求帧后清空定时器切换方向后再启动超时。3. 核心方案回调注册与底层读函数注入3.1 双方向函数传递设计我最终采用的方案是“双方向函数传递”。所谓双方向指的是底层驱动到协议栈、协议栈到应用层这两个方向各有一组函数指针。向上抽象的方向我定义了一个底层字节读取函数指针。协议栈需要读取串口字节时不是直接调用HAL_UART_Receive而是调用这个函数指针。初始化时我把一个包装函数传进去包装函数内部再调用串口读寄存器的相关操作。这样做的好处是协议栈本身和具体的 MCU 硬件库完全解耦换到另一颗芯片时只需要改这个底层包装函数。向下分发的方向我定义了一个帧完成回调函数指针。当协议栈解析完一帧完整的从机响应后会调用这个回调把解析好的帧结构体指针传出去。应用层在启动时注册自己的处理函数就能在每次收到完整帧时自动被调用。这个机制用 C 语言实现并不复杂本质就是函数指针加一个注册函数。很多人会问为什么不直接在协议栈里调用一个固定名字的函数原因是工程里可能同时存在多个模块比如显示模块要刷新数据、存储模块要记录日志、控制模块要判断报警条件。如果协议栈里写死调用某个函数每次新增模块都要去改协议栈源码。用回调注册的方式应用层可以注册一个分发函数再由这个分发函数去通知各个模块协议栈源码完全不用动。3.2 数据结构和回调接口定义我把帧结构体定义得尽量简单方便在不同模块之间传递。typedef struct { uint8_t addr; /* 从机地址 */ uint8_t func; /* 功能码 */ uint16_t regAddr; /* 起始寄存器地址 */ uint16_t regCount; /* 寄存器数量 */ uint8_t data[32]; /* 可变数据区 */ uint16_t dataLen; /* 数据区长度 */ uint16_t crc; /* 校验和调试用 */ } ModbusFrame_t;回调函数的类型定义如下typedef void (*ModbusFrameCallback)(const ModbusFrame_t *frame);协议栈内部维护一个静态函数指针变量static ModbusFrameCallback s_frameCallback 0;对外提供一个注册函数void ModbusMaster_RegisterFrameCallback(ModbusFrameCallback cb) { s_frameCallback cb; }在保护现场的前提下协议栈解析完成一帧时这样调用if (s_frameCallback ! 0) { s_frameCallback(frame); }这里我刻意把函数指针初始化为 0并每次调用前判断。原因是如果应用层还没注册回调就收到了从机响应直接解引用空指针会导致 HardFault。在一个大的工程里初始化顺序往往不完全受控多一个空指针判断成本极低但能避免很多调试时间。3.3 注册与调用时机注册回调的时机建议放在 Modbus 主机初始化完成后、开启协议栈之前。这样能保证从第一帧响应到达开始应用层就能处理不会漏帧。调用时机则由协议栈完全控制。一个字节一个字节进入串口中断后协议栈内部状态机积累数据直到 CRC 校验通过并且帧间隔超时确认当前帧结束这时才触发回调。也就是说回调不是收到最后一个字节时就立刻触发而是等确认“没有后续字节”后才触发。这个确认时间就是 3.5 字符间隔。所以在 9600 波特率下每帧真正的完成时刻会比最后一个字节到达时刻晚大约 3.65ms。这一点在调试时必须心里有数。如果拿逻辑分析仪看串口波形最后一帧字节和回调里置起的某个 GPIO 电平变化之间应该有一个大约 3.6ms 的间隔。如果没有这个间隔说明你的帧结束判定可能是靠字节数量写死的而不是靠时间间隔判断这种代码在从机应答数据长度发生变化时会出大问题。4. 完整代码实现与关键点解读4.1 串口中断收字节我用的是 STM32 的串口接收中断每收到一个字节就调用一次协议栈的字节处理函数。这里的关键是不要在中断里做耗时操作只把字节喂进去其他事交给协议栈。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t byte USART_ReceiveData(USART1); ModbusMaster_RxByte(byte); } }如果用的是 HAL 库通常会走HAL_UART_RxCpltCallback但那是在你调用了HAL_UART_Receive_IT之后才会触发的。每次回调结束后还需要重新调用一次接收函数才能继续收下一字节这种“收一字节、重开一次接收”的模式要特别小心一旦某个分支忘了重新打开接收整条链路就静默了。我后来在实际项目里改成了接近上面这种寄存器操作的写法每来一个字节直接读数据寄存器然后喂给协议栈。这样做至少不会出现“接收被意外关闭”的坑而且中断开销也非常小。4.2 协议栈状态机消费字节ModbusMaster_RxByte是协议栈的入口它内部的处理逻辑本质上是一个小状态机。每次进入时先判断与上一字节的间隔是否已经超过 3.5 字符时间。如果超了说明上一个帧已经结束当前字节应该作为新帧的第一个字节如果没超说明当前字节属于正在接收的同一帧。void ModbusMaster_RxByte(uint8_t byte) { static uint8_t frameBuf[256]; static uint16_t frameLen 0; static uint16_t lastTick 0; uint16_t now TIM_GetTick(); if ((uint16_t)(now - lastTick) FRAME_35_TICK) { frameLen 0; } lastTick now; if (frameLen sizeof(frameBuf)) { frameLen 0; } frameBuf[frameLen] byte; if (frameLen 8) { uint16_t crcCalc ModbusCRC16(frameBuf, frameLen - 2); uint16_t crcRecv frameBuf[frameLen - 2] | (frameBuf[frameLen - 1] 8); if (crcCalc crcRecv) { ModbusFrame_t frame; frame.addr frameBuf[0]; frame.func frameBuf[1]; frame.dataLen frameLen - 4; memcpy(frame.data, frameBuf[2], frame.dataLen); frame.crc crcRecv; if (s_frameCallback ! 0) { s_frameCallback(frame); } frameLen 0; } } }这段代码里我用了uint16_t无符号差值比较时间差即使定时器 tick 溢出回绕也不会出错。frameLen 8这个判断是基于 Modbus RTU 最小帧长度算的地址 1 字节、功能码 1 字节、数据至少 2 字节、CRC 2 字节最小也有 6 字节。如果你只读单个输入状态响应帧会比较短但绝不可能少于 6 字节。所以这里用 8 只是我项目里的保守值实际你可以精确算。CRC 校验这里一定要用 Modbus 标准算法不是简单累加和。常见的实现是多项式 0xA001、初值 0xFFFF 的查表法或按位计算法。查表法速度快适合在主循环里做按位法代码短适合在中断里偶尔调用。我是在接收中断里做 CRC所以用的查表法把 256 项 CRC 表放到一个只读数组里。4.3 应用层回调处理从机应答应用层回调的核心原则是只保存数据、置标志不处理复杂逻辑。拿一个典型的读保持寄存器场景来说从机地址是 0x01功能码 0x03返回 2 个寄存器共 4 个字节数据。static volatile uint16_t s_regValues[4]; static volatile uint8_t s_newFrameFlag 0; static void App_OnModbusFrame(const ModbusFrame_t *frame) { if (frame-addr ! 0x01) return; if (frame-func ! 0x03) return; /* 响应数据区寄存器数量(1) 字节数(1) 寄存器值(N) */ if (frame-dataLen 5) return; uint8_t byteCount frame-data[1]; s_regValues[0] (uint16_t)((frame-data[2] 8) | frame-data[3]); s_regValues[1] (uint16_t)((frame-data[4] 8) | frame-data[5]); s_newFrameFlag 1; }注意这里我把s_regValues声明成了volatile因为它在中断上下文中被写入在主循环中被读取。如果不加volatile编译器优化后主循环可能一直使用寄存器里的旧副本表现就是你明明回调里更新了数据主循环里读出来却没变化。回调函数里不要做浮点运算不要做复杂计算也不要去调用需要等待的驱动函数。中断里做浮点运算不但慢还可能破坏调用现场调用类似HAL_Delay的阻塞函数更是灾难。我的习惯是回调里只做“拷贝 置标志”具体的数据处理放到主循环的App_Task里。4.4 把整个链路串起来在主函数里初始化完成后就是一个大循环。FreeModbus 的协议栈轮询函数eMBMasterPoll必须放在主循环里它负责驱动发送请求、处理应答超时等状态机。我的主循环简化后是这样int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); MX_GPIO_Init(); MX_TIM2_Init(); ModbusMaster_RegisterFrameCallback(App_OnModbusFrame); ModbusMaster_Init(9600, 0x01); eMBMasterEnable(); while (1) { eMBMasterPoll(); App_Task(); } }App_Task里做的事情是void App_Task(void) { static uint32_t lastPoll 0; uint32_t now HAL_GetTick(); /* 每 500ms 发起一次读请求 */ if (now - lastPoll 500) { lastPoll now; eMBMasterReqReadHoldingRegisters(0x01, 0x0000, 2); } if (s_newFrameFlag) { s_newFrameFlag 0; /* 这里拿 s_regValues 去做显示、存储、报警判断等操作 */ } }这种“请求在 main 循环里主动发起、响应在中断里解析、数据通过回调保存到全局变量”的模型是我在几个项目里反复调整后觉得最顺手的结构。它既不会让协议栈回调拖慢中断也能保证应用层对数据的读取是拿到一个完整帧的快照而不是半帧状态。5. 调试实录时序、CRC、粘包和踩坑5.1 用逻辑分析仪和串口助手确认帧时序我调这个项目时最大的经验是不要只盯着代码猜问题一定要把串口波形拉出来看。一个几十块钱的逻辑分析仪比你在代码里写一百条调试打印都有用。先看主机发出去的请求帧是否符合 RTU 格式。用逻辑分析仪抓串口 TX 引脚波特率设置成 9600应该看到一帧数据后面跟着至少 3.5 个字符时间的空闲电平。如果发送完请求后紧接着又发了一帧说明你的状态机没等到从机响应就重复发送了典型的错误是把请求也当成了接收链路的一部分。再看 RS485 方向引脚切换。发送请求时方向引脚拉起发送结束后拉低。理想情况下方向引脚由高变低的时间点应该早于从机开始响应的时间点。如果方向引脚切换过晚从机响应头几个字节在硬件上就被收发器屏蔽了表现就是你明明看到请求帧发送正常但永远等不到响应。最后看 RX 引脚上的响应帧。正确的响应帧应该在请求帧结束后的某个间隔后出现这个间隔不会小于 3.5 字符时间但通常也很短。如果响应帧前有非常长的空闲可能从机处理慢如果根本没有响应先别怀疑协议栈先用一个 USB 转串口模块单独测试从机确认从机本身没问题再回来查你自己的收发切换。5.2 常见问题速查表我把自己踩过和帮别人排查过的问题整理成了一张表遇到类似现象可以按表排查。现象可能原因解决方法请求帧发出去后无响应RS485 方向引脚没切回接收用示波器/逻辑分析仪检查方向引脚电平偶尔能收到偶尔收不到帧结束间隔阈值设置不对重新按波特率和校验位计算 3.5 字符时间数据全是乱码串口波特率、校验位配置不一致核对主从两端的串口参数CRC 校验一直失败字节序放反Modbus CRC 是低字节在前确认接收端组合顺序回调执行了但应用层数据没更新缺少 volatile 修饰中断和主循环共享的变量必须加 volatile响应帧被截断接收缓冲太小或溢出保护太激进增大 buffer或按协议最大帧长设置缓冲定时器中断频繁导致字节丢失中断优先级不合理串口中断优先级高于定时器中断这里特别要说一下 CRC 字节序问题。Modbus RTU 帧里 CRC 是低字节在前、高字节在后。也就是发送时先发 CRC 低 8 位再发高 8 位。接收时你组帧的顺序也必须是buf[len - 2]作为低字节然后用buf[len - 2] | (buf[len - 1] 8)还原成 16 位值。如果写成反向CRC 校验必然会失败而且是每一帧都会失败。5.3 中断上下文里不要干重活我在最早一版代码里回调函数里直接做了显示刷新结果发现问题很隐蔽。当时波特率是 115200一个字符时间约 87us3.5 字符间隔约 304us。显示刷新那段函数执行时间超过 300us导致下一个帧间隔判定失败状态机把下一帧开头的前几个字节和当前帧混在一起表现为数据偶尔错位而且错位规律不明显。后来我把回调里所有重活全部移出只留memcpy和置标志主循环再去处理。这个改动之后连续跑了一周都没有再出现错位。这个经验其实在很多嵌入式项目里都适用中断里只做“最短的、确定的”事剩下的丢给循环或任务。如果你的工程里用了 RTOS回调里还可以用xSemaphoreGiveFromISR或者xQueueSendFromISR这类中断安全接口来通知任务。这样既能保证不丢事件又能把处理任务放到普通线程里执行优先级可以灵活调整。5.4 内存占用和轮询周期实测这个项目我用的是 STM32F103 系列内部 RAM 不算大但 Modbus 这个量级的应用完全够。接收缓冲我开了 256 字节帧结构体里的数据区开 32 字节实际每帧不超过 32 字节足够存下绝大多数从机响应。CRC 查表表是 512 字节放在 Flash 里不占 RAM。轮询周期方面一主一从读 2 个寄存器波特率 9600整个请求-响应周期实测在 25ms 左右。这个时间包含请求帧发送约 5ms、从机响应等待约 10ms、响应帧接收和帧间隔约 5ms、外加一点裕量。如果做 4 个从机的轮询每个从机间隔 500ms一轮下来大概 2 秒多完全能满足工业现场实时性要求不高的采集场景。如果想把轮询周期压到更短可以先把波特率提到 19200 或 38400一帧时间会等比缩小。但同时 3.5 字符间隔也会缩短对定时器的精度要求更高代码里的时间参数必须重新计算。我的建议是现场线缆长、干扰多的场合稳定优先9600 波特率比高波特率可靠得多不要为了快而牺牲抗干扰能力。6. 工程化扩展多从机轮询与异常码处理6.1 多从机轮询怎么调度实际项目里很少只带一个从机多半是一台主机管几个甚至十几个设备。多从机轮询不能简单地在主循环里写一串请求函数挨个调用因为从机响应是异步的你必须等上一帧处理完、超时判断结束后才能发下一帧。我的做法是设计一个简单的轮询表表里每个条目包含从机地址、功能码、起始寄存器、寄存器数量、轮询周期。主循环用一个索引轮流扫描这张表每次只处理一个请求等这个请求的结果返回或者超时之后索引才加一。typedef struct { uint8_t addr; uint8_t func; uint16_t regAddr; uint16_t regCount; uint16_t periodMs; } PollItem_t;这种设计的好处是新增一个从机只需要在表里加一行不用改动主循环逻辑。坏处是如果某个从机经常超时整个轮询会被它拖慢。为了规避这个问题可以给每个从机设置独立的超时计数连续超时超过 N 次后就把该从机标记为离线跳过它并继续轮询后面的设备。这样一台离线设备不会拖死整个总线。6.2 异常码与错误恢复从机如果收到非法请求不会回复正常响应帧而是回复一个异常响应帧。这个帧的地址和功能码格式跟正常响应很像但功能码的最高位置 1并在数据区第一个字节返回异常码。比如你请求的功能码是 0x03异常响应帧的功能码会是 0x83异常码 02 表示非法数据地址。回调解析时必须区分正常帧和异常帧。我习惯在回调里判断func 0x80如果为真就走异常处理分支。异常处理分支一般包括记录异常码、增加错误计数、更新设备状态等。注意不要把异常帧的数据区当成正常数据去解析否则会得到完全无意义的结果。对于超时的处理我采用的策略是连续超时计数。单次超时不一定代表设备坏了有可能是现场干扰导致帧损坏。连续 3 次超时再判定设备离线避免误报。离线后仍会周期性尝试重新通信防止设备恢复后不能自动重新上线。6.3 后续可以继续优化的方向这套方案跑下来已经能满足多数中小规模的 Modbus 主机采集需求。但如果后续项目规模变大有几个方向值得继续改进。第一个方向是数据接收从“逐字节中断”改成“DMA 空闲中断”。DMA 能把一串字节自动搬运到内存空闲中断能指示一帧传输结束两者配合可以让 CPU 在接收期间基本不介入。协议栈收到完整帧数据后再解析而不是每来一个字节都触发一次中断。这个方案在波特率较高、帧较长时能明显降低 CPU 占用。第二个方向是引入环形缓冲区。当前的接收逻辑在状态机里直接累加帧数据如果一帧还没结束时有更高优先级中断把主流程打断很久可能导致帧间隔误判。用环形缓冲加独立解析任务能把“收数据”和“解析数据”彻底分离架构上更干净。第三个方向是给回调分发机制做一个订阅表。现在的项目是一个回调函数发给所有模块将来模块多了直接改成数组注册、逐个通知也是顺手的事。我个人在实际项目调试中最大的感受是Modbus 主机模式下最难的不是协议栈本身而是层与层之间的数据交接设计。函数指针用好了底层驱动、协议栈、应用层三者就能各自独立修改、各自测试用不好全局变量满天飞改一处崩三处。这套“底层读函数注入 上层回调注册 中断只置标志”的模型我用了几个项目之后基本就不再改了你如果要做类似功能完全可以把它作为基础骨架再根据你自己的业务往上加东西。