云林县网站建设_网站建设公司_交互流畅度_seo优化
2026/1/16 5:58:03 网站建设 项目流程

从零构建工业通信节点:手把手带你把 freemodbus 移植到 STM32

你有没有遇到过这样的场景?项目需要让一个温湿度传感器通过 Modbus 协议和 PLC 对话,或者要给一台小型光伏逆变器加上远程监控能力。这时候,Modbus 几乎是绕不开的选择——它简单、稳定、通用性强,几乎成了工业现场的“普通话”。

但问题来了:自己写协议栈?太费时间还容易出错;买商业库?成本高不说,还不一定能适配你的 MCU。那有没有一种既免费、又轻量、还能跑在 STM32 上的方案?

答案就是freemodbus

今天我们就来干一件“硬核但实用”的事:把 freemodbus 完整地、可靠地、可复用地移植到 STM32 平台上。不是照搬示例代码,而是讲清楚每一步背后的逻辑,让你真正掌握这项技能,以后换颗芯片也能快速上手。


为什么选 freemodbus?因为它真的够“轻”

先说结论:如果你要做的是嵌入式设备上的 Modbus 从机(Slave),freemodbus 是目前最值得推荐的开源方案之一

它是用纯 C 写的,结构清晰,分层明确,最关键的是——只靠实现几个底层接口函数,就能让它跑起来

它的核心设计思想是“硬件抽象”:所有与时钟、串口、中断相关的操作都被封装在一个叫port layer的模块里。你只需要针对自己的平台实现这些函数,剩下的协议解析、CRC 校验、功能码处理全都交给它搞定。

而且资源占用极低:
- Flash 占用通常不到 8KB;
- RAM 消耗控制在 1KB 以内;
- 支持 RTU 和 ASCII 模式,能配置为主机或从机;
- 常见功能码 FC01/FC02/FC03/FC04/FC05/FC06/FC15/FC16 全都支持。

换句话说,哪怕你用的是 STM32F103C8T6 这种“小蓝丸”,也完全带得动。


移植前必知:freemodbus 是怎么工作的?

别急着敲代码,先搞明白它是怎么运作的,否则后面一通操作猛如虎,结果收不到帧、回不了包,连 debug 都无从下手。

Modbus 串行链路的关键:T1.5 和 T3.5

这是很多人踩坑的地方。

Modbus RTU 是基于字符间隔来判断一帧数据是否结束的。不像 TCP 有明确的包头包尾,RTU 只能靠“沉默时间”来界定帧边界。

  • T1.5:两个字节之间的最大允许间隔(1.5 个字符传输时间);
  • T3.5:一整帧结束的标志,即连续超过 3.5 个字符时间没有新数据到达。

举个例子,在 9600bps 波特率下:
- 每位时间 ≈ 104.17μs;
- 一个字符(11位:起始+8数据+校验+停止)≈ 1.146ms;
- 所以 T3.5 ≈ 4ms。

也就是说,只要你在接收过程中发现已经 4ms 没有收到新字节了,就可以认为这一帧结束了,可以开始解析了。

这个定时任务谁来做?定时器中断

所以整个 freemodbus 的运行机制其实是这样的:

主循环调用eMBPoll()→ 协议栈检查是否有完整帧 → 若有,则解析并生成响应 → 发送应答

数据靠 USART 接收中断逐字节捕获 → 每收到一字节启动一次 T3.5 定时

如果 T3.5 超时仍未收到新字节 → 触发帧结束事件 → 通知协议栈处理

这就像两个人打电话:“你说一句,我等你三秒没再说话,我就当你说完了。”


开始移植:五步走通 STM32 + freemodbus

我们以STM32F103C8T6 + HAL 库 + freemodbus v1.6为例,使用 STM32CubeIDE 开发环境。

目标:实现一个 Modbus RTU 从机,地址为 1,支持读写保持寄存器(FC03 / FC16)。

第一步:准备 freemodbus 源码

去官网或 GitHub 下载最新版本(建议 v1.6 或以上)。目录结构大致如下:

freemodbus/ ├── demo/ // 示例工程 ├── src/ │ ├── ascii/ // ASCII 模式实现 │ ├── rtu/ // RTU 模式实现 │ ├── utils/ // 工具函数 │ └── mb.c // 核心协议栈 └── port/ // 端口层模板

我们需要做的就是:
1. 把src文件夹加入工程;
2. 在port目录下创建自己的 STM32 实现;
3. 编写必要的回调函数。


第二步:实现 Port 层 —— 真正的“硬件对接点”

这才是移植的核心!freemodbus 提供了一组标准接口,你要做的就是把这些函数填上具体内容。

1.port.h:类型定义与编译选项
#ifndef PORT_H #define PORT_H #include "stdint.h" #define PR_BEGIN_EXTERN_C extern "C" { #define PR_END_EXTERN_C } #define INLINE inline #define PR_USE_STATIC_INLINE 1 typedef uint8_t BOOL; typedef unsigned char UCHAR; typedef short SHORT; typedef long LONG; typedef unsigned short USHORT; typedef unsigned long ULONG; #ifndef TRUE #define TRUE 1 #endif #ifndef FALSE #define FALSE 0 #endif #endif

注意:如果你用的是 C++ 项目才需要extern "C"包裹,否则可以去掉。


2.portevent.c:事件驱动支持(简化版)

freemodbus 使用事件机制协调各模块。对于单线程裸机系统,我们可以直接返回TRUE表示事件已处理。

#include "port.h" #include "mbport.h" BOOL xMBPortEventInit(void) { return TRUE; } BOOL xMBPortEventPost(eMBEventType eEvent) { return TRUE; } BOOL xMBPortEventGet(eMBEventType *eEvent) { return FALSE; } // 不启用事件队列

大多数情况下,xMBPortEventGet返回FALSE即可,因为我们主要靠eMBPoll()轮询。


3.porttimer.c:T3.5 定时器实现

这是最关键的一步。我们用 TIM7 实现 4ms 定时(以 9600bps 为例)。

#include "port.h" #include "mbport.h" #include "stm32f1xx_hal.h" static USHORT usT35TimeOut50us; // 单位:50μs static void (*pvTimerExpiredISR)(void) = NULL; // 初始化定时器(单位:微秒) BOOL xMBPortTimersInit(USHORT usTim1TimerOut50us) { usT35TimeOut50us = usTim1TimerOut50us; // 配置 TIM7,假设系统时钟 72MHz,预分频后为 1kHz(1ms周期) __HAL_RCC_TIM7_CLK_ENABLE(); htim7.Instance = TIM7; htim7.Init.Prescaler = 7200 - 1; // 得到 10kHz (100μs) htim7.Init.CounterMode = TIM_COUNTERMODE_UP; htim7.Init.Period = (usTim1TimerOut50us * 50) / 100 - 1; // 转换为计数值 htim7.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Start_IT(&htim7); return TRUE; } // 注册超时回调(由协议栈调用) void vMBPortTimersEnable(void) { __HAL_TIM_SET_AUTORELOAD(&htim7, usT35TimeOut50us * 50 / 100 - 1); __HAL_TIM_CLEAR_FLAG(&htim7, TIM_FLAG_UPDATE); HAL_TIM_Base_Start_IT(&htim7); } void vMBPortTimersDisable(void) { HAL_TIM_Base_Stop_IT(&htim7); } // 定时器中断服务函数(需在 stm32f1xx_it.c 中调用) void TIM7_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim7, TIM_FLAG_UPDATE) && __HAL_TIM_GET_IT_SOURCE(&htim7, TIM_IT_UPDATE)) { __HAL_TIM_CLEAR_IT(&htim7, TIM_IT_UPDATE); pvTimerExpiredISR(); // 通知协议栈 T3.5 已到 } } // 设置回调函数指针 void vMBPortSetTimerExpiredCB(void (*cb)(void)) { pvTimerExpiredISR = cb; }

关键点:
-usTim1TimerOut50us是协议栈传进来的值,表示 T3.5 时间(单位 50μs);
- 我们在vMBPortTimersEnable()中动态设置重载值,确保适应不同波特率;
- 中断触发后调用pvTimerExpiredISR(),这是协议栈内部注册的帧结束处理函数。


4.portserial.c:串口收发对接
#include "port.h" #include "mbport.h" #include "usart.h" // HAL 的 huart1 static void (*pvByteReceivedCb)(void) = NULL; static void (*pvFrameSentCb)(void) = NULL; BOOL xMBPortSerialInit(UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { // 已由 MX_USART1_UART_Init() 初始化,此处仅做参数检查 return TRUE; } void vMBPortSerialEnable(BOOL bRxEnable, BOOL bTxEnable) { if (bRxEnable) { HAL_UART_Receive_IT(&huart1, &ucRxBuf, 1); // 启动接收中断 } else { HAL_UART_AbortReceive(&huart1); } if (bTxEnable) { // 发送由协议栈控制,发送完成后需调用 pvFrameSentCb() } } // 字节接收回调(由中断调用) void vMBPortSerialPutByte(UCHAR ucByte) { HAL_UART_Transmit(&huart1, &ucByte, 1, 10); } void vMBPortSerialGetByte(UCHAR *pucByte) { *pucByte = ucRxBuf; } // 注册回调函数 BOOL xMBPortSerialCreate(void (*byte_rxd_cb)(void), void (*frame_sent_cb)(void)) { pvByteReceivedCb = byte_rxd_cb; pvFrameSentCb = frame_sent_cb; return TRUE; } // 在 UART 中断中调用此函数传递接收到的字节 void MB_UART_RxCallback(uint8_t byte) { vMBPortCBByteReceived((UCHAR*)&byte); // 交给协议栈 }

记得在USART1_IRQHandler中添加:

void USART1_IRQHandler(void) { uint32_t isrflags = huart1.Instance->ISR; uint32_t cr1its = huart1.Instance->CR1; if ((isrflags & USART_ISR_RXNE) && (cr1its & USART_CR1_RXNEIE)) { uint8_t data = (uint8_t)(huart1.Instance->RDR); MB_UART_RxCallback(data); } if ((isrflags & USART_ISR_TC) && (cr1its & USART_CR1_TCEIE)) { __HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_TC); vMBPortCBTransmitterEmpty(); // 通知协议栈可继续发 } }

第三步:实现寄存器访问回调 —— 数据映射的核心

当你收到一条 FC03 请求:“读取保持寄存器 0~1”,freemodbus 就会自动调用你注册的回调函数去拿数据。

#define REG_HOLDING_START 0x0000 #define REG_HOLDING_NREGS 10 uint16_t au16HoldingRegisterBuffer[REG_HOLDING_NREGS]; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus = MB_ENOERR; int16_t iRegIndex; if ((usAddress >= REG_HOLDING_START) && (usAddress + usNRegs <= REG_HOLDING_START + REG_HOLDING_NREGS)) { iRegIndex = (int16_t)(usAddress - REG_HOLDING_START); switch (eMode) { case MB_REG_READ: while (usNRegs > 0) { *pucRegBuffer++ = (uint8_t)(au16HoldingRegisterBuffer[iRegIndex] >> 8); *pucRegBuffer++ = (uint8_t)(au16HoldingRegisterBuffer[iRegIndex]); iRegIndex++; usNRegs--; } break; case MB_REG_WRITE: while (usNRegs > 0) { au16HoldingRegisterBuffer[iRegIndex] = *pucRegBuffer++ << 8; au16HoldingRegisterBuffer[iRegIndex] |= *pucRegBuffer++; iRegIndex++; usNRegs--; } break; } } else { eStatus = MB_ENOREG; } return eStatus; }

你可以把这个数组连接到 ADC 采样值、继电器状态、PID 参数等等,实现真正的“数据绑定”。


第四步:主程序初始化

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM7_Init(); // 初始化 Modbus RTU 从机模式 if (eMBInit(MB_RTU, 1, 0, 9600, MB_PAR_EVEN) != MB_ENOERR) { Error_Handler(); } // 使能协议栈 if (eMBEnable() != MB_ENOERR) { Error_Handler(); } for (;;) { eMBPoll(); // 主轮询函数,必须持续调用 } }

就这么简单?没错。只要eMBPoll()被不断调用,协议栈就会自动处理 incoming 帧。


第五步:RS485 方向控制(实用技巧)

如果使用 MAX485 芯片,还需要控制 DE/RE 引脚决定收发方向。

可以在发送开始和结束时操作 GPIO:

// 在发送前拉高 DE void vMBPortSerialEnable(BOOL bRxEnable, BOOL bTxEnable) { if (bTxEnable) { HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_SET); // 发送使能 // 启动发送(可用中断或DMA) } else { HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_RESET); // 回到接收 } }

并在发送完成中断中关闭 DE:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_RESET); vMBPortCBTransmitterEmpty(); // 通知协议栈发送完成 } }

常见问题与调试秘籍

❌ 问题1:主机发请求,但从机没反应

排查思路
- 波特率、校验方式是否一致?
- T3.5 定时是否准确?可在定时器中断加 LED 闪烁测试;
- 是否忘了调用eMBPoll()
- 中断优先级是否设置正确?UART 接收中断优先级一定要高于定时器!

❌ 问题2:收到乱码或 CRC 错误

  • 检查晶振精度,特别是使用内部 RC 时偏差较大;
  • 查看串口配置是否为 8-E-1(Even Parity);
  • 使用逻辑分析仪抓波形,确认帧格式是否正确。

✅ 提升稳定性的小技巧

技巧说明
使用 IDLE Line DetectionSTM32 USART 支持空闲中断,比定时器更精准检测帧结束
启用堆栈溢出检测在 STM32CubeIDE 中开启-fstack-usage分析
关闭未使用功能码修改mbconfig.h,禁用不需要的功能减少体积
添加看门狗防止协议栈异常卡死

结语:不止于“能用”,更要“好用”

看到这里,你应该已经掌握了如何将 freemodbus 成功移植到 STM32 的全流程。但这不是终点,而是起点。

你可以进一步扩展:
- 加入 Modbus TCP 支持(配合 LWIP);
- 实现主站功能轮询多个设备;
- 将寄存器映射到 Flash 参数区,支持掉电保存;
- 集成 FreeRTOS,把eMBPoll()放进独立任务运行。

freemodbus 最大的价值不只是“省了授权费”,而是让你真正理解工业通信的本质。当你亲手实现了一个通信节点,下次面对 CANopen、Profibus、甚至自定义协议时,你会发现——原来它们也没那么神秘。

如果你正在做一个物联网采集终端、智能电表、PLC 扩展模块……不妨试试这个组合:STM32 + freemodbus + RS485,低成本、高兼容、易维护,妥妥的工业级解决方案。

正在尝试移植?遇到了奇怪的问题?欢迎在评论区留言交流,我们一起 debug 到天亮。

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

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

立即咨询