
简介基于STM32F103ZET6的FreeModbus主机与FreeRTOS移植资源面向嵌入式通信与物联网开发者提供一套可对照实践的完整工程。压缩包共1254个文件约24.23MB涵盖612个C源文件、289个头文件以及汇编文件、目标文件、静态库、Keil工程配置和hex/bin烧录文件便于直接编译、烧录与二次开发。资源以STM32F103ZET6_TESTCODE工程为核心融合Modbus RTU主站协议栈与FreeRTOS任务调度展示了在多任务环境下如何拆分通信与业务逻辑提升系统实时性。已有1341人学习下载适合正在研究STM32通信协议栈移植或希望将FreeRTOS集成到实际项目中的开发者参考。 做了大半年工业设备数据采集终于把“STM32移植FreeModbus主机 FreeRTOS”这套组合完整跑通了。这个需求在产线数据上报、PLC网关、智能传感器集中管理这些场景里太常见了但网上能搜到的资料九成都是讲FreeModbus从机怎么移植真正把主机功能做出来、还要在FreeRTOS下稳定跑的教程少之又少。我这篇文章就把整个过程的思路、关键代码和踩过的坑一次说清给正在搞STM32 Modbus RTU主站项目的朋友一个可以直接抄作业的参考。1. 项目背景与整体方案选型1.1 FreeModbus官方版本的“先天不足”FreeModbus v1.6是网上流传非常广的开源Modbus协议栈结构清晰、代码量小非常适合MCU环境。但有一个问题很多人移植完了才发现官方版本只实现了从机Slave功能。也就是说它默认是等在外面接收主站的请求帧解析功能码回数据。整套状态机都是围绕“被动响应”设计的。而实际项目里我的需求恰恰相反STM32要作为主站Master主动去轮询下位机设备读寄存器、写参数。比如去读一台温控仪的当前温度、去写一台变频器的运行频率。这就需要把FreeModbus从底子上改一套主机状态机出来工作量说大不大但关键是要把协议栈的代码结构吃透改对了能省很多事。1.2 为什么选FreeModbus而不是libmodbus可能有人会问Linux下libmodbus不是现成的主站库吗没错但libmodbus依赖Linux的文件操作和系统调用代码相对庞大在STM32这种裸金属环境下移植成本高、资源占用大。FreeModbus虽然官方只做从机但它的分层做得很好——协议无关的核心层和具体的移植层分得清晰改造成主机是顺着框架加东西不是推翻重来。再加上网上FreeModbus的参考资料多遇到问题好查。另一个替代方案是用商业协议栈比如各种收费的Modbus主从库稳定性和功能都没话说但成本和授权限制在这个项目里不划算。自己扩展FreeModbus一是代码完全可控二是能学到协议栈内部的状态机设计思路后面要增加自定义功能码、做广播帧支持都顺手很多。1.3 主机功能扩展的整体思路我的做法是保留FreeModbus原有的从机框架不动在其之上新增一套主机状态机和主机功能码处理逻辑。通过一个编译宏来切换设备角色主站和从站功能放同一份代码里。这样以后如果产品既要当主站去采集数据又要当从站被别人读取状态切换起来非常方便。整体架构从上到下大致是这样应用层业务任务生成“读哪个设备的哪个寄存器”的请求列表协议栈层FreeModbus核心新增主机状态机、主机功能码处理移植层串口收发、定时器、RTOS信号量/事件组通知硬件层STM32串口 RS485收发器 方向控制GPIO2. FreeModbus协议栈剖析与主机扩展设计2.1 协议栈源码结构梳理FreeModbus的源码结构其实很清晰。核心部分和移植部分分开移植部分放在port文件夹里。我把关键文件的职责列一下方便还没上手的朋友快速建立地图文件职责mb.c协议栈初始化、主循环调度入口mbrtu.cRTU模式状态机官方版为从机服务mbfunc.c功能码处理函数入口读线圈、读寄存器等mbport.h移植层接口声明串口、定时器、事件回调都在这portevent.c事件机制裸机和RTOS版本差异主要在这里portserial.c串口底层收发实现porttimer.c定时器时基T3.5字符静默超时官方从机状态机是RTU模式下串口收到帧之后通过一个T3.5定时器来判断一帧是否结束。如果两个字符之间的时间间隔超过3.5个字符时间就认为这一帧接收完成然后把帧交给上层解析。这个T3.5是整个RTU模式的基础主机模式同样依赖它来分帧。2.2 主机关键功能模块的设计官方框架里没有主机功能所以我额外抽象了一个主机状态机核心状态如下typedef enum { STATE_MB_MASTER_IDLE, // 空闲可以发送下一帧 STATE_MB_MASTER_WAIT, // 已发送请求等待从机响应 STATE_MB_MASTER_ERROR // 等待超时或出现帧错误准备恢复 } eMBMasterState;主机发送一帧请求之后状态切到WAIT同时启动一个超时定时器。在超时时间内如果收不到合法响应帧就认为从机无应答状态回到IDLE然后轮询下一台设备。这个超时时间的设置很有讲究我一般根据波特率来算9600波特率下一帧最长256字节按Modbus协议规范主站至少要等够从机处理的时间同时也不能太心软导致轮询周期被拖长。实测下来响应超时设置在50ms到200ms之间比较合理具体看从机设备的处理速度。主机需要支持的功能码也不多我实现了最常用的三个0x03读保持寄存器读取从机的运行数据0x06写单个寄存器比如启停控制、参数修改0x10写多个寄存器批量下发参数表每个功能码对应的请求帧构造和响应帧解析都在独立函数里完成。这里有个细节从机如果返回0x83、0x86、0x90这类异常码主站不能照样当正常数据去解析。我封装了一个统一的响应判断函数先检查功能码最高位是否为1是的话直接提取异常码记录下来方便调试。2.3 与FreeRTOS的接口层设计裸机环境下FreeModbus靠轮询 中断跑但在FreeRTOS里我选择让Modbus协议栈跑在一个专门的任务里串口中断只负责搬运数据处理完一帧之后通过事件组通知任务去解析。这里有几个设计原则是从实际工程里总结出来的中断里不用阻塞函数所有RTOS通信都带FromISR后缀串口DMA接收 空闲中断决定帧边界T3.5定时器做兜底Modbus任务优先级不要设太高毕竟是通信任务给数据处理任务留出CPU事件组比二值信号量好用因为可以区分“收到帧”“发完帧”“超时”等多个事件事件组的定义大致是这个样子#define EVT_RX_FRAME (1 0) #define EVT_TX_DONE (1 1) #define EVT_TIMEOUT (1 2)3. 基于STM32的完整移植实操3.1 串口底层与DMA收发的实现我用的STM32F103HAL库串口1接RS485。接收部分没有用传统的中断逐字节接收而是用了UART空闲中断 DMA环形搬运。这样MCU不需要在每个字节都进中断CPU占用率能降一个量级。初始化部分核心代码如下#define MODBUS_RX_BUF_SIZE 256 uint8_t xRxBuffer[MODBUS_RX_BUF_SIZE]; void ModbusUartInit(void) { __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, xRxBuffer, MODBUS_RX_BUF_SIZE); }在串口中断处理函数里检测到IDLE标志就说明DMA接收停止了一段空闲这时读取DMA剩余计数就能算出这一帧实际收了多少字节void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); uint16_t usLen MODBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (usLen 0) { xRxFrameLen usLen; xEventGroupSetBitsFromISR(xModbusEventGroup, EVT_RX_FRAME, xHigherPriorityTaskWoken); } HAL_UART_Receive_DMA(huart1, xRxBuffer, MODBUS_RX_BUF_SIZE); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }这里有个排坑经验DMA停止之后再重新启动前务必清掉接收标志。否则连续两帧之间的空闲中断可能偶发丢失导致从机响应丢帧、主机误判超时。3.2 定时器时基与超时控制T3.5定时器是从机分帧的关键主机端也需要一个超时定时器来等从机响应。我用了TIM3做1ms时基直接在定时器中断里对超时计数变量做累加没有额外加RTOS定时器省资源而且实时性更好。T3.5时间怎么算Modbus RTU规定两个字符之间的最大静默时间不能超过1.5个字符时间一帧结束时必须有3.5个字符时间的静默。一个字符在串口上占11位1起始 8数据 1校验 1停止无校验则少1位。最常见配置是无校验 1停止位所以1字符时间 10bit / 波特率 T3.5 3.5 × 10 / 波特率以9600波特率计算T3.5 3.5 × 10 / 9600 ≈ 3.65ms。代码里向上取整设为4ms。对于115200波特率T3.5只有约0.3ms这种情况下定时器中断要做成0.1ms级别的时基才能保证精度。所以我把时基定成1ms同时用硬件定时器捕获来做更精细的静默时间判断比单纯靠RTOS tick准得多。3.3 FreeRTOS任务划分与信号量交互整个系统的任务划分我按轻重缓急拆成了三个ModbusTask优先级5栈512字轮询下位机发送请求、处理响应DataProcessTask优先级3栈256字拿到数据之后做业务处理、存数据库、刷新显示WatchdogTask优先级6栈128字喂独立看门狗系统异常时快速复位ModbusTask 的核心循环代码如下void ModbusTask(void *pvParameters) { TickType_t xLastWakeTime xTaskGetTickCount(); const TickType_t xFrequency pdMS_TO_TICKS(200); for (;;) { ModbusMasterPoll(); // 轮询所有设备的请求列表 vTaskDelayUntil(xLastWakeTime, xFrequency); } }任务里我只做了轮询和解析真正的串口收发全部由中断和DMA完成。中断收完一帧后置位事件ModbusTask等待事件组时如果有EVT_RX_FRAME就去解析这帧数据。这样串口数据处理和请求发送在时间上解耦不会出现RS485半双工下“发送还没结束就乱收数据”的尴尬。3.4 主机请求发送与数据接收解析流程一帧主机请求的发送过程我封装成公共函数。以读保持寄存器为例eMBMasterErrorCode eMBMasterReadHoldingRegisters(USHRT usAddr, USHRT usRegAddr, USHRT usRegNum) { // 组装请求帧从机地址 功能码0x03 寄存器起始地址 寄存器数量 CRC // 调用 xMBMasterUtilSendFrame() 发送 // 状态机切到 WAIT启动超时定时器 }发送时RS485方向控制是关键。STM32的串口发送是异步的调用HAL_UART_Transmit返回时数据可能还在移位寄存器里。如果在发送函数返回后立刻把RS485的DE引脚拉低就会把帧尾截断。我的做法是等发送完成标志TC置位后再切方向HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); if (HAL_UART_Transmit(huart1, pFrame, usLen, 100) HAL_OK) { while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET); } HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET);接收解析同样在协议栈内部完成。收到一帧后先校验从机地址是否匹配、CRC是否正确再判断功能码会不会是异常响应最后缓存响应数据供业务层读取。这套流程下来ModbusTask 在200ms轮询周期内可以稳定搞定4到6台从机的数据采集。4. 调试、踩坑与常见问题实录4.1 堆栈溢出检测一个让人头疼的问题FreeRTOS接手之后最隐蔽的坑就是任务栈溢出。ModbusTask里如果定义了大的局部数组比如uint8_t ucFrameBuf[256]再加上函数调用链里的其他局部变量栈低于256字很容易溢出。常见表现是系统运行一段时间后任务突然跑飞、HardFault或者某些变量值莫名其妙被改写。我的调试方法分两步。第一步开启FreeRTOS的栈溢出检测#define configCHECK_FOR_STACK_OVERFLOW 2把这项设成2后FreeRTOS每次任务切换都会主动检查栈指针是否越界。第二步使用uxTaskGetStackHighWaterMark()在任务循环里打印最小剩余栈UBaseType_t uxRemain uxTaskGetStackHighWaterMark(NULL); printf(ModbusTask stack remain: %u\n, uxRemain);实测下来ModbusTask 稳定运行时剩余栈最好保持在100字以上。如果低于50字就要加栈了。我把ModbusTask初始栈设成512字四台设备轮询的压力测试下剩余栈约180字算安全。4.2 延时函数卡死十有八九是SysTick冲突这个坑太经典了。项目里原本有裸机时代的delay_ms()函数不确定是不是基于SysTick做的延时。如果裸机延时函数依赖SysTick中断而FreeRTOS接管SysTick之后中断优先级和中断处理函数已经被RTOS改写了直接调用裸机延时就会卡死或者时间不准。表现就是程序跑到一半死掉debugger暂停发现卡在delay函数里。解决方案很简单FreeRTOS环境下任务内一律用vTaskDelay()或vTaskDelayUntil()。如果确实需要us级别的短延时应答我用的是HAL_Delay()也只适合50ms以上短延时则关掉调度器切回裸延时或者用DWT时钟计数器static void DWT_Delay_us(uint32_t us) { DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; while (DWT-CYCCNT us * (SystemCoreClock / 1000000)); }这个函数在临界区外调用不会阻塞RTOS调度实测在Modbus发送间隔控制中很好用。4.3 RS485收发切换的时序坑RS485是半双工总线收发方向切换如果处理不当轻则丢帧重则整个总线冲突。我最早用延时法控制方向延时时间不好把握延时太短会截断最后一位延时太长又浪费总线吞吐。后来改成上文说的等TC标志问题彻底解决。另外还有一个很多人忽略的点发送完切回接收之后不要立刻开始准备收下一帧。RS485总线在方向切换瞬间会有电平毛刺如果串口此时正好开启了接收会把毛刺当成0x00字节接收进来扰乱状态机。我在切换方向后加了一个极短的稳定延时用DWT实现约0.2ms实测效果很好。4.4 常见问题速查表问题现象可能原因解决方法从机始终无响应A/B线接反、总线无终端电阻调换A/B线120欧电阻并入总线两端偶发性响应超时DMA接收未清标志导致丢帧每次停止DMA后重新使能中断里清IDLE标志收到0x83类异常码功能码不支持或寄存器地址非法查看从机文档确认寄存器范围和功能码支持情况程序跑飞、HardFault任务栈溢出开启栈溢出检测降低任务内局部数组大小串口发出乱码波特率不匹配、晶振精度不够核对从机波特率检查时钟配置轮询周期比预期长很多响应超时时间设置过大把超时时间合理缩小按从机最慢响应时间定除了表里的内容还有两个容易忽略的点。一是任务优先级倒置问题如果项目里还有显示任务、存储任务优先级设置不当会导致Modbus任务长时间得不到运行轮询周期抖动很大我建议Modbus任务优先级不要低于大部分业务任务但也不能高于看门狗。另一个是CRC校验表的存放位置放在const区而不是RAM区省内存还提速代码里顺手就改了。5. 关于这套方案的一点个人体会把FreeModbus从机改成主机再把FreeRTOS集成进去整个过程最大的体会是协议栈移植的难点从来不在跑通官方例程而在搞懂它的状态机设计逻辑然后在此基础上做扩展。FreeModbus的代码组织方式不复杂但每一层该干什么分得很清楚我花了两三天通读源码之后再改主机功能就有一种“顺着作者的思路继续写”的感觉。FreeRTOS那边重点是把中断和任务之间的数据流设计干净能用事件组就用事件组别在中断里处理太多逻辑。最后再分享一个实用小技巧调试Modbus通信这种强时序的协议我强烈建议在代码里预留一个DUMP_FRAME宏开关开了之后把所有收发帧以十六进制格式打印出来。这个习惯帮我无数次快速定位到是地址不匹配、CRC算错、还是从机异常应答尤其是在现场调试被业主盯着的时候能少很多焦虑。这套方案后续还可以继续扩展比如增加对广播地址0xFF的支持、把轮询列表做成可动态配置、甚至加上MODBUS TCP网关功能。基础架构对了这些功能都是搭积木的事。本文还有配套的精品资源点击获取