简介:面向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*50us=50us __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读线圈 →
xMBFuncReadCoils - 0x02读离散输入 →
xMBFuncReadDiscreteInputs - 0x03读保持寄存器 →
xMBFuncReadHoldingRegister - 0x04读输入寄存器 →
xMBFuncReadInputRegister - 0x05写单线圈 →
xMBFuncWriteCoil - 0x06写单寄存器 →
xMBFuncWriteHoldingRegister - 0x0F写多个线圈 →
xMBFuncWriteMultipleCoils - 0x10写多个寄存器 →
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.04ms,T35约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裸机移植本身不复杂,难的是把硬件依赖和协议栈的时序逻辑对齐。只要你按照串口、定时器、回调、主循环这套流程走,再对照常见坑排查,基本半天就能跑通。希望这篇文章能帮你少走我当年走过的弯路。
本文还有配套的精品资源,点击获取