简介面向STM32裸机环境的FreeModbus从机应用开发工程适合需要快速落地Modbus RTU通信的嵌入式开发者。该工程在无操作系统条件下实现标准从机应用覆盖协议栈初始化、寄存器读写回调、串口收发与定时器处理等关键环节可辅助完成与上位机或PLC的通信联调也能作为理解Modbus状态机与异常码处理的参考资料。压缩包共227个文件大小8.72MB其中以34个C源码、67个H头文件为主要代码主体并附带HAL库文件、MDK工程配置、编译中间文件以及可直接烧录的hex固件与map映射文件。读者还可从目录结构中找到启动文件、链接脚本、CubeMX的IOC配置、批处理清理脚本和工程备份便于完整还原构建流程。已有94人学习适合刚接触Modbus或FreeModbus移植的STM32开发者借助完整工程可减少协议理解与裸机移植的排错成本快速搭建出自有从机应用。1. 写在前面为什么我推荐裸机移植FreeModbus如果你手里正拿着一块MCU想给它加上Modbus从站功能又没有跑RTOS的打算那FreeModbus基本是绕不开的选择。先别急着去搜源码我先把FreeModbus从机、裸机移植这两个关键词拆开说清楚。FreeModbus是一个开源的Modbus协议栈实现专门为嵌入式设备设计源码精简、结构清晰官方提供了从机和主机两套实现。日常工业现场中90%的设备都是作为从机存在采集PLC下发的指令回传传感器数据或者执行继电器动作。裸机移植的意思就是不用RTOS直接在while(1)主循环里轮询协议栈配合串口中断、定时器中断完成数据收发和帧解析。相比带OS的方案裸机移植最大的优势是省资源、易调试、行为可预期——没有任务调度没有信号量一个低端Cortex-M0甚至51单片机都能跑得很稳。这篇文章适合谁刚接触Modbus的嵌入式初学者想快速在一个裸机工程里跑通从机通信的工程师以及那些被繁重协议细节劝退、想直接看“怎么用”的人。我会从源码结构、移植步骤、数据规划、联调技巧到踩坑经验完整走一遍我在实际项目中移植FreeModbus从机的全过程。2. 移植前的准备先把FreeModbus的“脾气”摸清楚2.1 FreeModbus源码到底长什么样FreeModbus的官方源码目录很清晰拿到手不要慌核心就几个文件。根目录下有一个mb.c这是协议栈的主入口负责状态机切换和功能码分发。然后是各个功能码的实现文件比如mbfuncinput.c处理读输入寄存器、mbfuncholding.c处理读/写保持寄存器、mbfunccoils.c处理线圈读写等。还有与硬件相关的一层目录比如portserial.c和porttimer.c这两个文件是平台相关的移植工作的重头戏就是重写它们。从机应用时你真正要关心的抽象层是mb.c对外暴露的接口。官方设计了一个回调函数机制协议栈解析完报文后会通过一组函数指针调用你注册的处理函数比如eMBRegHoldingCB、eMBRegInputCB、eMBRegCoilsCB、eMBRegDiscreteCB。你只需要在这几个回调里读写自己的数据缓冲区协议栈负责把数据打包成Modbus帧发回去。所以移植前先建立心理预期你不需要读懂Modbus协议的每一个字节只需要把3个硬件依赖搞定串口收发、定时器、字节/帧超时判定然后把4类数据区的回调函数实现好就完成了80%的工作。2.2 从机应用需要实现的回调函数这四个回调函数对应Modbus的四个数据模型在实际工程里它们可能指向同一块内存区域也可能完全独立。我习惯在一块结构体中定义全部业务数据然后让回调函数操作这个结构体。举个例子typedef struct { uint16_t holding_regs[10]; // 保持寄存器可读可写 uint16_t input_regs[5]; // 输入寄存器只读 uint8_t coils[2]; // 线圈可读可写 uint8_t discrete[2]; // 离散输入只读 } modbus_data_t;eMBRegHoldingCB回调的原型是eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode);其中usAddress是寄存器地址从0开始usNRegs是本次操作的数量eMode区分是读还是写MB_REG_READ或者MB_REG_WRITE。注意Modbus协议中的地址是从1开始编号的但FreeModbus传入的是偏移量也就是地址减1后的值。这个细节很容易搞错我第一次移植时直接把协议里的寄存器地址当成偏移量用结果上位机读到的数据永远错位。2.3 定时器与串口的硬件依赖FreeModbus从机通信依赖两个关键的硬件外设串口负责字节收发定时器负责帧延时判定。在RTOS环境下可以用软件定时器但裸机下必须用硬件定时器。官方要求有一个1ms或者更精细的tick定时器用来产生T35帧结束超时和字符间超时T1.5。不过很多移植版本直接把定时器换成“字节超时”和“帧超时”两个变量在串口接收中断里判断相邻两个字节之间的时间间隔这样就简化了定时器管理。我强烈建议在裸机工程里用一个周期定时器中断让一个全局变量g_uiTick累加然后比较时间戳。协议栈内部实际是通过vMBPortTimersEnable和vMBPortTimersDisable这两个函数来控制定时器的开启和关闭帧接收开始时启动定时如果超过帧间隔还没收到下一个字节就认为接收完成触发tEV_FRAME_RECEIVED事件发送完一帧后也会启动定时用于计算响应超时。这是FreeModbus状态机的核心节拍移植时务必保证定时器精度足够。3. 裸机环境下的移植步骤每一行代码都能落地3.1 串口驱动的适配先保证字节收发裸机移植最常用的是stm32 HAL库或者标准外设库串口适配的关键是让协议栈能“按字节”收发。FreeModbus设计为接收时每来一个字节协议栈从串口中断中读走并存入内部缓冲区发送时协议栈逐字节调用发送函数发完一帧再通知发送完成。我以STM32F1系列HAL库为例。首先在portserial.c里实现初始化void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xRxEnable) { __HAL_UART_ENABLE_IT(huart1, UART_IT_RXNE); } else { __HAL_UART_DISABLE_IT(huart1, UART_IT_RXNE); } if (xTxEnable) { __HAL_UART_ENABLE_IT(huart1, UART_IT_TC); } else { __HAL_UART_DISABLE_IT(huart1, UART_IT_TC); } }这里有一个关键点接收使能和发送使能是分离的。协议栈在等待接收时打开RXNE中断一旦收到完整帧会关掉RXNE。发送时打开TC发送完成中断发完最后一位会产生TC中断协议栈据此判断“发送完成”再启动定时器等待下一个请求。如果你用DMA去做串口收发需要额外小心FreeModbus默认的字节处理方式是中断驱动DMA可以实现但必须处理好DMA缓冲区和协议栈接收函数的衔接否则容易出现帧错位。串口中断里需要转发字节和事件void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE) ! RESET) { uint8_t byte (uint8_t)(huart1.Instance-DR 0xFF); if (xMBPortSerialPutByte(byte) TRUE) { pxMBFrameByteReceived(); } } if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) ! RESET) { __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_TC); pxMBFrameSent(); } }pxMBFrameByteReceived是协议栈的接收处理入口每收到一个字节就调用一次。pxMBFrameSent通知协议栈发送完成。这两个函数都要在接收中断和发送中断里被调用务必保证它们在串口中断中执行得足够快不要在中断里做耗时操作。实际测下来115200波特率下一个字节的间隔约87微秒中断处理必须远小于这个时间。3.2 定时器实现帧间间隔的判定定时器移植的核心是提供精确的时基。FreeModbus对定时器的要求是能够产生1个tick的周期中断并且这个tick要小于字符间超时T1.5。T1.5是指在字符传输中两个相邻字符之间的间隔超过1.5个字符时间就认为帧不完整。T35是帧结束超时即一帧结束后3.5个字符时间内没有新字节就认为这一帧完整了。以115200波特率、10位1起始8数据1停止为例1个字符时间是10/115200 ≈ 86.8微秒。T1.5约130微秒T35约304微秒。所以定时器节拍必须小于130微秒取一个整数就选50微秒或者100微秒。如果你用1ms节拍就会错过T1.5判定导致帧接收错乱。这也是很多移植者用FreeModbus填坑后痛骂协议的常见原因——不是协议错是你定时器精度不够。我的做法是使用TIM2作为基础定时器配置成50us一次中断void vMBPortTimersInit(void) { __HAL_TIM_SET_AUTORELOAD(htim2, 7200 - 1); // 72MHz主频7200*50us50us __HAL_TIM_SET_PRESCALER(htim2, 0); HAL_TIM_Base_Start_IT(htim2); }在定时器中断里调用协议栈的定时器处理函数void TIM2_IRQHandler(void) { HAL_TIM_IRQHandler(htim2); } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { pxMBPortTimersTicks(); } }pxMBPortTimersTicks是FreeModbus的时基驱动函数它检查帧超时必要时会触发事件。注意定时器在空闲时是关闭的只在接收或发送期间开启这样能减少中断负载也让超时计算更准确。3.3 初始化流程与主循环调度裸机下的初始化顺序很关键排错了会导致串口一上来就乱码。我推荐的顺序是使能外设时钟配置GPIO、串口、定时器。初始化Modbus协议栈的从机地址、波特率、校验模式。注册4个数据区回调函数通常用默认的寄存器缓冲区。调用eMBInit(MB_RTU, 0x01, 0, 115200, MB_PAR_EVEN)初始化RTU模式。调用eMBEnable()使能从机。进入主循环不断调用eMBPoll()。主循环代码非常简单while (1) { eMBPoll(); // 这里可以放业务逻辑比如采集传感器、控制继电器 application_task(); }关键点是eMBPoll()必须被周期性地快速调用它处理从串口和定时器传来的事件执行状态机转换。如果你在主循环里做了阻塞延时比如HAL_Delay必须保证阻塞时间远小于帧间隔否则会丢请求。我实测下来eMBPoll()一次执行通常几十微秒所以主循环里放一个轻量级业务任务完全没问题但别放耗时几十毫秒的操作。如果确实有耗时任务要么拆成状态机分步执行要么用中断标志位延后处理。3.4 寄存器映射与读写回调的书面实现这里给一个完整可用的保持寄存器回调示例。假设业务数据结构体已经定义好回调只需要把Modbus地址映射到数组下标eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus MB_ENOERR; if ((usAddress usNRegs) REG_HOLDING_NREGS) { if (eMode MB_REG_WRITE) { for (USHORT i 0; i usNRegs; i) { holding_regs[usAddress i] (uint16_t)((pucRegBuffer[i * 2] 8) | pucRegBuffer[i * 2 1]); } } else { for (USHORT i 0; i usNRegs; i) { pucRegBuffer[i * 2] (uint8_t)(holding_regs[usAddress i] 8); pucRegBuffer[i * 2 1] (uint8_t)(holding_regs[usAddress i] 0xFF); } } } else { eStatus MB_ENOREG; } return eStatus; }这段代码理解起来不难pucRegBuffer是协议栈提供的数据交换缓冲区读写时按大端序高字节在前组织。读操作时把寄存器值拆成两个字节放进缓冲区写操作时把缓冲区里的两个字节拼成一个16位寄存器值。这里有一个容易踩的坑当usNRegs大于1时读写循环的地址要用usAddress i而接收缓冲区里的索引是i * 2和i * 2 1。很多新手会把缓冲区索引写成usAddress i * 2这样数据位置就错位了。类似的代码在eMBRegInputCB里几乎一样区别只是只读不处理写模式。线圈和离散输入的回调则是按位处理每个字节包含8个线圈需要处理位偏移建议直接用位域或者用位操作别偷懒。4. 从机应用的数据组织与功能码实现4.1 四类数据区的规划思路Modbus的四种数据对象在FreeModbus里分别对应四个回调函数。实际工程中保持寄存器是最常用的因为PLC和组态软件一般通过03功能码读、06功能码写、16功能码批量写来操作保持寄存器。输入寄存器通常用04功能码读适合放只读的采集量。线圈01读/05写/15批量写适合放开关量。离散输入02功能码读适合放限位开关、按钮状态等只读开关量。我建议在一块结构体内集中管理全部数据而不是用全局数组散落各处。原因有两点一是回调函数访问时方便传指针二是后续做数据持久化掉电保存时可以直接按结构体整体操作。比如typedef struct { uint16_t holding[10]; uint16_t input[10]; uint8_t coils[2]; uint8_t discrete[2]; } app_modbus_data_t; static app_modbus_data_t s_modbus_data;然后四个回调函数都从这个结构体读写。注意寄存器数量和地址范围不要随意扩大FreeModbus默认配置里有一个MB_REG_HOLDING_START和MB_REG_HOLDING_NREGS的宏是在mbconfig.h里定义的。如果你需要修改地址起始值或寄存器数量改这个配置文件就行。4.2 功能码处理背后的FreeModbus机制你可能想知道协议栈是怎么知道上位机读的是保持寄存器还是输入寄存器的答案在mb.c里它根据功能码分发到不同的处理函数。默认情况下0x01读线圈 →xMBFuncReadCoils0x02读离散输入 →xMBFuncReadDiscreteInputs0x03读保持寄存器 →xMBFuncReadHoldingRegister0x04读输入寄存器 →xMBFuncReadInputRegister0x05写单线圈 →xMBFuncWriteCoil0x06写单寄存器 →xMBFuncWriteHoldingRegister0x0F写多个线圈 →xMBFuncWriteMultipleCoils0x10写多个寄存器 →xMBFuncWriteMultipleHoldingRegister这些函数最终都会调用你注册的对应的XX回调。所以如果你在项目里不需要支持某些功能码比如不支持写多个线圈你可以在mbconfig.h里禁用它这样能节省代码量和RAM还能避免误操作。配置宏大概是#define MB_FUNC_WRITE_MULTIPLE_COILS_ENABLED 0 #define MB_FUNC_WRITE_MULTIPLE_REGISTER_ENABLED 1根据项目需求裁剪别一上来就全部打开。我见过一个产品只用了03、04、06三个功能码结果因为开着0x10写多寄存器的功能上位机调试时误发了一条写多寄存器命令把配置参数改乱了。裁剪功能码不仅是省资源也是一种安全防护。4.3 与上位机联调从串口助手到C#上位机移植完成后第一件事是用串口助手手动发测试报文。先发一帧03读保持寄存器从站地址1起始地址0数量1。用16进制发送01 03 00 00 00 01 84 0A最后两个字节是CRC16校验可以用工具计算。 如果协议栈正常工作你会收到01 03 02 00 63 xx xx其中00 63是你寄存器里的值。等串口助手测试通过后再用上位机软件联调。很多工业组态软件如Modbus Poll、ModScan可以直接读取但如果你自己开发上位机比如C#需要注意.NET的串口接收可能拆包需要拼接完整帧再解析这与FreeModbus的帧概念类似。相比Modbus Poll我更推荐先用Modbus Poll测试从站因为它能显示错误计数和帧时间方便排查时序问题。如果上位机直接是C#开发底层通信建议用NModbus库它封装了协议细节只用设置串口参数和从站地址读保持寄存器就一行代码。但如果你是学习协议过程中写上位机那就自己解析踩一次坑比看十遍文档都管用。我当年用C#写了一个简单的串口助手支持CRC16计算和RTU帧封装调试FreeModbus效率极高后面也发给同事用了。工具虽小对开发效率提升很大。5. 移植过程中最常见的坑与排查技巧5.1 波特率误差与定时器精度这是排在坑榜第一位的。比如你用一个12MHz晶振的STM32想得到115200波特率分频之后实际波特率不是精准的115200会有一定误差。误差过大会导致帧间隔判断不准严重时通讯不定时出错。解决办法确认串口波特率生成误差在2%以内最好小于1%如果误差过大考虑换一个晶振频率更合适的MCU或者用内部时钟精细调校。定时器精度同样关键。我之前在8位单片机上试图用主循环软件计数来实现超时结果完全不可用因为主循环的阻塞会影响帧间隔。裸机下务必使用硬件定时器中断定时器节拍小于T1.5且中断服务函数要尽量短。5.2 中断优先级与临界区保护FreeModbox协议栈内部使用了临界区保护默认通过vMBPortEnterCritical和vMBPortExitCritical这两个函数实现。在裸机移植时最常见的临界区实现是关中断、开中断。如果串口中断和定时器中断优先级一样且同时到达可能会产生嵌套导致临界区失效。我遇到过一种奇怪现象从机偶尔不响应或者偶尔返回错误码复位后又正常。排查很久发现是定时器中断和串口中断优先级搭配不合理。FreeModos要求定时器中断优先级应高于串口中断这样才能在串口字节流中来得及更新超时计时同时协议栈内部临界区应屏蔽所有中断。我设置单片机中断分组为2串口中断优先级为2定时器中断优先级为1数值越小优先级越高临界区操作时完全关中断问题就消失了。5.3 超时参数与硬件流控制的误解很多人在裸机移植时忽略了RTU模式的帧间隔参数直接用官方默认值。但官方默认的MB_TIMER_TICK_INTERVAL可能是1ms在某些平台上默认正好能工作换个平台就翻车。需要根据你的实际波特率计算T35然后配置定时器节拍。例如9600波特率下字符时间约1.04msT35约3.64ms用1ms节拍也勉强可以但如果用2ms节拍就不行了。改配置时注意把MB_TIMER_TICK_INTERVAL设为与你的定时器中断周期一致的值否则超时判定会错。另一个容易误解的是硬件流控。FreeModbus默认的RS485接口需要控制DE/RE方向发送时拉高方向引脚发送完毕拉低。有些芯片如MAX485有自动方向控制但很多方案需要GPIO手动控制。你会在xMBPortSerialPutByte或发送完成中断里切换方向。注意在发送完成中断里把方向脚拉低而不是在发送完最后一个字节后立即拉低否则最后一个字节可能还没发完就被切断。问题现象可能原因解决办法上位机一直超时无响应串口没有收到数据检查串口接线、波特率、串口中断是否使能帧间隔判断错误定时器节拍太大改用小于T1.5的定时器周期并配置MB_TIMER_TICK_INTERVAL偶发帧错误波特率不准确或中断优先级不当测量实际波特率调整晶振优化中断优先级寄存器读写错位回调中地址索引写错确认usAddress是偏移量不是协议地址减1测试单寄存器发送方向引脚乱跳没有在发送完成中断中拉低方向脚在TC中断或发送完成回调中拉低DE/RE这些坑我一个一个都踩过每次都是拿示波器看波形、用串口助手逐字节分析才定位到问题。如果你在移植过程中遇到类似情况按表格排查一般能解决。6. 几点个人实战心得最后再分享几个小习惯是我在多个项目里用FreeModBus裸机移植攒下来的经验。第一永远别在主循环里做长时间阻塞。即使你的业务逻辑再简单也要把耗时操作拆到状态机里。FreeModBus的eMBPoll虽然轻量但如果你每调用一次就进一个50ms的延时整个协议栈的状态机就乱了。第二寄存器读写回调里不要放业务逻辑。回调应该只做数据搬运真正的控制逻辑放在主循环或别的任务里。比如上位机写入一个“启动电机”的命令回调里只把这个命令值存到holding_regs[0]主循环检测到该值变化后再去操作GPIO。如果你在回调里直接操作电机很容易因为回调执行时间过长而影响协议栈其他事件。第三先跑通03读取功能再扩展其它功能码。我见过很多新手一上来就全功能码启用结果出了问题不知道是协议栈的事还是自己代码的事。先把读保持寄存器调通这个链路串口、定时器、回调全都跑了一遍后面就是按模板加功能码的事。FreeModBus裸机移植本身不复杂难的是把硬件依赖和协议栈的时序逻辑对齐。只要你按照串口、定时器、回调、主循环这套流程走再对照常见坑排查基本半天就能跑通。希望这篇文章能帮你少走我当年走过的弯路。本文还有配套的精品资源点击获取
