手把手教你用 freemodbus 在 MCU 上实现 RTU 从机功能
最近在做一个工业传感器项目,客户要求必须支持 Modbus RTU 协议接入 PLC 系统。虽然市面上有不少现成的模块,但为了节省成本、提升灵活性,我决定直接在 STM32 上“手搓”一个 Modbus 从机。经过几天调试踩坑,终于跑通了——整个过程其实并不复杂,关键是要搞清楚freemodbus到底是怎么工作的。
今天就来分享一下我的实战经验,不讲空话套话,只讲你真正需要知道的东西:如何从零开始,在你的嵌入式设备上跑起一个稳定可靠的 Modbus RTU 从机。
为什么选 freemodbus?
说到 Modbus 协议栈,freemodbus几乎是嵌入式开发者的默认选项。它轻量、开源(MIT 许可)、纯 C 编写,最关键的是——可移植性极强。无论你是用 STM32、GD32、ESP32,还是跑 FreeRTOS 或裸机系统,都能轻松对接。
它的核心设计思想就是“分层解耦”:协议逻辑和硬件驱动完全分离。你只需要实现几个底层接口函数,剩下的解析、组包、CRC 校验、超时判断全都交给它处理。这种架构让开发者能专注业务逻辑,而不是陷在通信细节里出不来。
官网:http://www.freemodbus.org/
GitHub 上也能找到多个维护良好的分支版本,比如基于 STM32 HAL 的移植示例。
freemodbus 是怎么工作的?一张图说清全流程
先别急着写代码,我们得先理解它的运行机制。想象一下这样的场景:
PLC(主站)通过 RS-485 总线发来一条指令:“读取设备地址为 1 的保持寄存器 40001,长度为 2。”
你的 MCU 收到这串字节后,要能正确识别、响应,并返回数据。
freemodbus 就是帮你完成这个过程的“中间人”。它的工作流程可以用下面这条链路概括:
[串口接收到字节] → 触发中断 → 存入缓冲区并重启 T35 定时器 ↓ 定时器超时(帧结束)→ 通知协议栈处理完整报文 ↓ 解析地址、功能码、CRC → 匹配回调函数 → 读写用户数据区 ↓ 组装应答帧 → 启动发送 → 发送完成后插入静默期整个过程的核心在于两个机制:逐字节接收 + 帧边界判定。
关键点:RTU 模式靠“时间”定帧
Modbus RTU 使用二进制编码,不像 ASCII 那样有明确的起始/结束字符(如:和\r\n)。那它是怎么知道一帧数据什么时候开始、什么时候结束的?
答案是:3.5 字符时间规则。
也就是说,当串行线上连续空闲超过 3.5 个字符传输时间时,就认为当前帧已经结束,接下来收到的新字节属于下一帧。
举个例子:波特率为 9600bps,每个字符(11bit:1起始+8数据+1校验+1停止)耗时约 1.14ms,那么 3.5 字符时间 ≈ 4ms。只要两个字节之间的间隔大于 4ms,就视为帧间间隔。
freemodbus 用一个叫T35 Timer的定时器来实现这一点。每收到一个字节就重置一次定时器,一旦超时,立即触发帧处理流程。
如何对接硬件?端口层是你必须跨过的坎
freemodbus 提供的是协议层能力,真正的收发还得靠你自己实现。这部分工作集中在port.c和port.h文件中,主要包括三大接口:
- 串口收发
- T35 定时器管理
- 事件通知机制
这些统称为“端口层”(Port Layer),是你移植 freemodbus 的核心任务。
串口接收:中断 + 缓冲区
最常见的方式是在 USART 接收中断中调用prvvUARTRxReadyISR(),把接收到的字节喂给协议栈:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t byte = USART_ReceiveData(USART1); prvvUARTRxReadyISR(byte); // 告诉 freemodbus 收到了一个字节 } }同时,你需要确保 T35 定时器被正确启动和复位。通常使用 SysTick 或通用定时器实现,精度建议控制在 1ms 以内。
串口发送:注意半双工控制
RS-485 是半双工总线,发送和接收共用一对差分线。因此,必须通过 GPIO 控制 DE(Driver Enable)引脚来切换方向。
在发送前打开 DE,在最后一个字节发出后延迟一段时间再关闭 DE,避免“自己打断自己”。
freemodbus 提供了vMBPortSerialEnable(BOOL bTXEnable, BOOL bRXEnable)接口,你可以在这里操作 GPIO:
void vMBPortSerialEnable(BOOL bTXEnable, BOOL bRXEnable) { if (bTXEnable) { GPIO_SetBits(RS485_CTRL_PORT, RS485_DE_PIN); // 开启发送使能 } else { GPIO_ResetBits(RS485_CTRL_PORT, RS485_DE_PIN); // 关闭发送使能 } if (bRXEnable) { // 允许接收 } }发送过程由eMBPoll()内部自动调度。你只需实现xMBPortSerialPutByte()函数,将字节放入发送缓冲区即可。
寄存器映射:你的数据在哪里,怎么访问?
Modbus 定义了四种标准寄存器类型:
| 类型 | 功能码 | 地址范围 | 访问方式 |
|---|---|---|---|
| 线圈(Coils) | 0x01 / 0x05 / 0x0F | 00001~09999 | 位操作,读写 |
| 离散输入(DI) | 0x02 | 10001~19999 | 位操作,只读 |
| 输入寄存器(IR) | 0x04 | 30001~39999 | 字操作,只读 |
| 保持寄存器(HR) | 0x03 / 0x06 / 0x10 | 40001~49999 | 字操作,读写 |
但在 freemodbus 中,这些地址是以“偏移量”的形式传入回调函数的。比如主机请求读取 40001,实际传给你的usAddress是 0;请求 40005,则是 4。
所以你要做的,就是根据这个偏移量去访问对应的内存区域。
实现保持寄存器读写:经典模板来了
下面是一个典型的eMBRegHoldingCB回调函数实现:
#define REG_HOLDING_START 0 // 对应协议地址 40001 #define REG_HOLDING_NREGS 10 static uint16_t usRegHoldingBuf[REG_HOLDING_NREGS]; eMBErrorCode eMBRegHoldingCB( UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus = MB_ENOERR; int16_t i, regIndex; if ((usAddress >= REG_HOLDING_START) && (usAddress + usNRegs <= REG_HOLDING_START + REG_HOLDING_NREGS)) { regIndex = (int16_t)(usAddress - REG_HOLDING_START); switch (eMode) { case MB_REG_READ: for (i = 0; i < usNRegs; i++) { *pucRegBuffer++ = (UCHAR)(usRegHoldingBuf[regIndex + i] >> 8); *pucRegBuffer++ = (UCHAR)(usRegHoldingBuf[regIndex + i] & 0xFF); } break; case MB_REG_WRITE: for (i = 0; i < usNRegs; i++) { usRegHoldingBuf[regIndex + i] = (*pucRegBuffer++ << 8); usRegHoldingBuf[regIndex + i] |= *pucRegBuffer++; } break; } } else { eStatus = MB_ENOREG; // 地址越界 } return eStatus; }⚠️ 注意:所有数据都按大端序存储!高位字节在前,低位字节在后,这是 Modbus 的硬性规定。
如果你不小心用了小端序打包,主站看到的数据就会错乱。更危险的是,有些人直接memcpy(&value, pucRegBuffer, 2),这在某些平台上可能没问题,但在严格对齐或大小端不同的系统上会出问题。
初始化与主循环:别忘了调用 eMBPoll()
一切准备就绪后,最后一步是启动协议栈。
初始化步骤
int main(void) { // 硬件初始化 SystemInit(); UART_Config(); // 波特率、校验位等 Timer_T35_Init(); // T35 定时器,建议精度 1ms GPIO_RS485_Init(); // DE/RE 引脚控制 // freemodbus 初始化 eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); // 从机地址=1,无校验 eMBEnable(); // 启动监听 while (1) { eMBPoll(); // 必须周期性调用!建议放在主循环或定时任务中 Delay_ms(1); // 可选:防止 CPU 占满 } }关键提醒
eMBPoll()必须被周期性调用,频率越高越好(一般 ≥1kHz),否则会影响响应实时性。- 如果你在 RTOS 中使用,可以把
eMBPoll()放在一个低优先级任务中循环执行。 - 不要阻塞在这个函数里!它只是检查是否有待处理事件。
调试技巧:那些年我踩过的坑
刚开始调试时,经常遇到“主机发命令,但从机没反应”的情况。别慌,按以下顺序排查:
✅ 1. 抓包验证物理层通信
用 USB-RS485 转换器连接 PC,配合QModMaster或ModScan工具发送测试命令,同时用串口助手抓原始数据流。
看看是不是真的收到了01 03 00 00 00 02 C4 0B这样的帧。如果没有,说明硬件连接或波特率设置有问题。
✅ 2. 检查 T35 定时器是否准确
这是最常见的问题!如果定时器不准,会导致帧无法正确闭合。
例如:波特率 115200,每个字符约 0.087ms,3.5 字符 ≈ 0.3ms。如果你的定时器最小单位是 1ms,那就永远达不到 0.3ms 的精度,结果就是帧一直“收不完”。
解决方案:
- 使用更高频率的定时器(如 TIM6 @ 10kHz)
- 或者启用 DMA + 空闲中断(IDLE Line Detection)来检测帧结束,效率更高
✅ 3. 地址映射别搞混了!
再次强调:协议地址 ≠ 回调函数中的usAddress!
| 协议地址 | 回调中 usAddress |
|---|---|
| 40001 | 0 |
| 40002 | 1 |
| … | … |
建议定义清晰的宏或枚举:
typedef enum { REG_HUMIDITY = 0, REG_TEMPERATURE, REG_FAN_SPEED, REG_ALARM_STATUS, } HoldingRegAddr_t;这样代码可读性强,也不容易出错。
✅ 4. RS485 发送使能要延时关闭
很多初学者在发送完最后一字节后立刻拉低 DE 引脚,导致最后一个字节还没完全送出就被截断。
正确的做法是:在发送完成中断(TC 标志)中再关闭 DE,或者加一个微小延时(如 100μs)。
高级玩法:不只是读写寄存器
掌握了基础之后,你可以玩更多花样:
自定义功能码
freemodbus 支持扩展功能码(0x40~0x7F 或 0x80 以上)。你可以注册自己的处理函数,实现固件升级、远程复位、参数批量导出等功能。
结合 RT-Thread 或 FreeRTOS
在操作系统环境下,可以将eMBPoll()放入独立线程,结合消息队列与其他模块通信。还可以利用系统的定时器服务简化 T35 实现。
多协议共存
有些产品既要 Modbus 又要 CAN 或 MQTT。freemodbus 的模块化设计允许你轻松集成多个协议栈,共享同一套寄存器池。
写在最后:为什么你应该掌握这项技能?
Modbus 可能不是最先进的协议,但它足够简单、足够稳定、足够普及。在全球数以亿计的工业设备中,每天都有海量的 Modbus 报文在流动。
作为一名嵌入式工程师,掌握 freemodbus 不仅意味着你能快速交付项目,更代表你具备了构建标准化通信节点的能力。无论是做智能电表、温湿度变送器,还是参与 IIoT 平台建设,这都是不可或缺的基础功底。
更重要的是,当你亲手实现过一次完整的协议栈对接,你会对“通信”这件事有更深的理解——数据是如何跨越电气信号、变成有意义的信息的。
下次如果你的设备需要联网,不妨试试自己动手实现一个 Modbus 从机。你会发现,原来所谓的“工业协议”,也没那么神秘。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。