深入浅出 freemodbus:如何用状态机与定时器搞定 Modbus RTU 协议
在工业控制现场,你可能见过这样的场景:一台 PLC 通过一根 RS-485 总线,连接着十几个温湿度传感器、电表和执行器。它们之间没有复杂的网络协议栈,也没有 TCP/IP,靠的是一套古老却极其可靠的通信方式——Modbus RTU。
而在这背后,很多嵌入式工程师选择了一个低调但强大的开源工具:freemodbus。它体积小、移植性强、符合标准,尤其适合资源受限的 MCU 平台(比如 STM32、ESP32)。但问题是——
它是怎么仅靠一个串口和定时器,就能准确识别一帧 Modbus 报文的?
本文不讲空泛概念,而是带你“钻进代码”,从帧边界检测、T3.5 定时机制、状态机流转三个实战角度,彻底搞懂 freemodbus 是如何实现 Modbus RTU 的。无论你是初次集成,还是正在调试通信异常,这篇文章都会给你答案。
为什么 RTU 需要“时间”来界定帧?
先抛一个问题:
如果一条数据线上连续传来字节流,没有任何起始位或结束符,你怎么知道哪几个字节属于同一帧?
这正是 Modbus RTU 的核心挑战。
相比 Modbus ASCII 使用冒号:和回车换行\r\n标记帧头尾,RTU 采用的是紧凑的二进制格式,整个报文就是一串字节:
[设备地址][功能码][数据...][CRC低][CRC高]没有显式标记,那怎么办?
靠“静默时间”判断!
根据 Modbus over Serial Line 规范 ,规定:
- 当总线空闲超过3.5 个字符时间(T3.5),表示上一帧已结束;
- 新的一帧必须在下一个字符到达前至少保持 T3.5 的静默;
- 帧内字节之间不能超过1.5 个字符时间(T1.5),否则视为中断。
这就意味着:
✅帧开始= 第一个字节到来
✅帧结束= 接收过程中出现 > T3.5 的空闲
听起来简单,但在代码中如何实现?别急,我们一步步来看。
T3.5 是什么?它是怎么算出来的?
T3.5 不是固定值,而是依赖波特率动态计算的时间阈值。
假设当前波特率为 9600 bps:
- 每个字符 = 11 bit(1 起始 + 8 数据 + 1 停止 + 可选校验)
- 传输一个字符所需时间 ≈ 11 / 9600 ≈ 1.146ms
- 所以 T3.5 ≈ 3.5 × 1.146ms ≈4ms
| 波特率 | 字符时间 | T1.5 | T3.5 |
|---|---|---|---|
| 9600 | ~1.15ms | ~1.7ms | ~4ms |
| 19200 | ~0.58ms | ~0.87ms | ~2ms |
| 115200 | ~0.096ms | ~0.14ms | ~0.34ms |
⚠️ 注意:不同资料对“字符”的定义略有差异(是否包含校验位),freemodbus 默认按 11bit 计算。
这个 T3.5 时间必须精确控制,否则容易把两帧合并成一帧(误判),或者把一帧拆成两段(丢包)。
那么问题来了:
如何让 MCU “感知” 这个时间间隔?
答案是:硬件定时器 + 中断回调
关键机制一:看门狗式定时监控 —— 收到字节就“喂狗”
freemodbus 的设计非常巧妙:它并不轮询串口,而是利用一个独立的硬件定时器作为“接收超时看门狗”。
工作流程如下:
- 串口收到第一个字节 → 启动定时器(设为 T3.5)
- 后续每来一个新字节 →重置定时器
- 如果长时间没数据 → 定时器溢出 → 触发回调函数 → 认定帧接收完成
这种模式就像给一只狗定时投食:
- 只要不断喂(有数据来),它就不会叫;
- 一旦停了太久(> T3.5),它就报警(触发帧结束处理)
对应的 API 是:
void vMBPortTimersEnable(void); void vMBPortTimersDisable(void);典型实现(以 STM32 HAL 为例):
void vMBPortTimersEnable(void) { uint32_t timer_period = usT35TimeOutValue; // 单位:微秒 htim2.Instance = TIM2; htim2.Init.Prescaler = (SystemCoreClock / 1000000) - 1; // 1MHz 计数频率 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = timer_period; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start_IT(&htim2); // 开启更新中断 }当TIM2_IRQHandler触发并进入中断服务函数后,会调用注册好的回调:
void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_UPDATE)) { pxMBFrameCBTimerExpired(); // 告诉协议栈:T3.5 到了! __HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE); } }此时,协议栈就知道:“哦,已经安静够久了,这一帧收完了。”
关键机制二:帧接收函数是如何封装数据的?
来看 freemodbus 中的核心接收函数:
eMBErrorCode eMBRTUReceive(UCHAR *pucRcvAddress, UCHAR **pucFrame, USHORT *pusLength) { eMBErrorCode eStatus = MB_ENOERR; ENTER_CRITICAL_SECTION(); if (!bRxTimeoutOccurred) { UINT16 usTimer = pxMBFrameCBTimerExpired->usMBCallback(); if (usTimer > usT35TimeOutValue) { bRxTimeoutOccurred = TRUE; *pusLength = (USHORT)(pucFrameCur - (UCHAR *)abBuffer); *pucFrame = (UCHAR *)&abBuffer[1]; /* 跳过地址 */ *pucRcvAddress = abBuffer[0]; /* 地址在首字节 */ pucFrameCur = (UCHAR *)abBuffer; /* 复位缓冲区指针 */ } } else { eStatus = MB_EIO; } EXIT_CRITICAL_SECTION(); return eStatus; }我们拆解一下逻辑:
abBuffer是全局接收缓冲区,最大长度通常是 256 字节。pucFrameCur是当前写入位置的指针。- 每次串口中断收到字节,就会存入
*pucFrameCur++。 - 当
pxMBFrameCBTimerExpired回调被触发,说明 T3.5 已到。 - 此时检查是否已有数据 → 若有,则将地址、数据起始指针、长度打包返回给上层解析。
📌关键点:
- 地址字段单独提取,用于判断是否是发给自己的帧。
- 数据部分跳过地址后传给协议解析模块。
- 缓冲区指针复位,准备接收下一帧。
整个过程无需操作系统支持,纯裸机也能跑,非常适合小型 MCU。
关键机制三:状态机驱动协议流转
如果说定时器是“耳朵”,负责听什么时候该收完;
那状态机就是“大脑”,决定下一步该做什么。
freemodbus 内部使用事件驱动的状态机模型来管理协议行为。以下是典型的从机状态流转图(文字版):
STATE_DISABLED ↓ enable() STATE_ENABLED ──────→ STATE_RX_INIT ↓ STATE_RX_RCV ←─── 串口接收中断 ↓ STATE_RX_WAIT_EOF ←── T3.5 超时 ↓ 功能码处理 & 构建响应 ↓ STATE_TX_XMIT ──────→ 发送每个字节 ↓ STATE_TX_WAIT_IDLE ←── 最后一字节发送完成 ↓ T3.5 定时器启动 → 总线释放 ↓ 回到 STATE_ENABLED,继续监听举个例子:当你想读保持寄存器(功能码 0x03),整个流程是这样的:
- 主机发送请求帧:
[0x01][0x03][0x00][0x00][0x00][0x02][CRC] - 从机串口中断逐字节接收,并不断重置 T3.5 定时器
- 数据传完,总线静默 > T3.5 → 定时器超时 → 进入
STATE_RX_WAIT_EOF - 协议栈提取地址
0x01,匹配成功 → 解析功能码0x03 - 查找对应寄存器值(如 0x1234, 0x5678)
- 构造响应帧:
[0x01][0x03][0x04][0x12][0x34][0x56][0x78][CRC] - 切换 RS-485 为发送模式,进入
STATE_TX_XMIT - 通过串口逐字节发送
- 发送完毕 → 进入
STATE_TX_WAIT_IDLE,再次启动 T3.5 定时器 - 等待 T3.5 时间过去 → 自动关闭发送使能,切回接收模式
其中发送部分的关键代码片段如下:
switch (eSndState) { case STATE_TX_IDLE: prvBuildResponseFrame(); // 构建响应 vMBPortSerialEnable(TRUE, FALSE); // 开启发送,关闭接收 pucFrameCur = (UCHAR*)ucMBFrame; usCurrentSendIndex = 0; eSndState = STATE_TX_XMIT; break; case STATE_TX_XMIT: vMBPortSerialPutByte(*pucFrameCur++); // 发送一字节 usCurrentSendIndex++; if (usCurrentSendIndex == usMBFrameLen) { eSndState = STATE_TX_WAIT_IDLE; vMBPortTimersEnable(); // 启动 T3.5,等待总线空闲 } break; }可以看到,每一字节都是手动触发发送的,而不是直接 DMA 一股脑发出去。这样做的好处是:可以精准控制方向引脚(DE/~RE),避免最后一个字节还没发完就切换回接收,导致丢失响应。
实战避坑指南:这些细节你必须注意!
🛑 1. 方向控制太晚?最后一字节丢失!
RS-485 是半双工,发送和接收共用一根线,需要 GPIO 控制 DE/~RE 引脚。
常见错误写法:
vMBPortSerialEnable(TRUE, FALSE); // 先使能发送 for(int i=0; i<len; i++) { USART_SendByte(data[i]); }问题出在哪?
👉 在调用vMBPortSerialEnable和第一个SendByte之间可能有延迟!如果此时总线正忙,第一个字节可能会被吃掉。
✅ 正确做法:在发送第一个字节前才打开 DE,且尽量缩短延迟。
推荐方案:
- 使用单片机的 TXE(发送寄存器空)中断,在中断里开 DE 并发第一个字节
- 或者用硬件自动方向控制芯片(如 SP3485、MAX13487)
🛑 2. 波特率不一致?CRC 总是错!
即使只差几百波特,也会导致 T3.5 计算偏差,进而引起帧粘连或截断。
更糟的是:CRC 是基于实际接收到的字节计算的。如果帧错了,CRC 必然失败。
✅ 解决方法:
- 主从设备务必设置完全相同的波特率、数据位(8)、停止位(1)、校验方式(无/偶/奇)
- 可添加日志打印实际接收的原始帧进行比对
🛑 3. 中断优先级太低?字节丢失!
在高负载系统中,若串口中断优先级低于其他任务(如 PWM、DMA),可能导致 ISR 延迟响应,从而错过字节。
✅ 建议:
- 将 UART 接收中断设为较高优先级(不低于 2 级,NVIC 中)
- 使用 FIFO 缓冲或多缓冲机制降低压力
✅ 最佳实践总结
| 项目 | 推荐做法 |
|---|---|
| 移植性 | 实现port层接口即可跨平台使用 |
| 内存优化 | 禁用不用的功能码(如写多个寄存器)节省 ROM |
| 调试辅助 | 定义MB_LOG_ENABLE输出收发日志 |
| 稳定性 | 使用静态分配缓冲区,避免 malloc/free |
| 性能提升 | 高波特率下可改用 DMA + IDLE Line Detection 替代定时器 |
它不只是“从机”——也能做主机和网关
很多人以为 freemodbus 只能做从机,其实它也支持主站模式(Master Mode),可用于轮询多个设备。
此外,在 IoT 网关中,你可以用它实现:
-RTU to TCP 协议转换:前端接 RS-485 从站,后端走 Ethernet/WiFi 上报云平台
-多路采集终端:一个 STM32 接多个 Modbus 设备,统一打包上传
只要理解了它的底层机制,扩展起来非常灵活。
写在最后:为什么它能成为嵌入式 Modbus 的事实标准?
不是因为它功能最全,也不是因为文档最多,而是因为它做到了几点极致:
- 足够轻:核心代码不到 5KB,RAM 占用极低
- 足够稳:T3.5 定时 + CRC 校验双重保障
- 足够清:分层清晰,HAL 层隔离硬件差异
- 足够活:MIT 许可,可商用、可修改、无约束
如果你正在做一个智能电表、PLC 模块、环境监测节点……
别自己造轮子了,freemodbus 真的是那个“拿来就能用”的答案。
如果你在移植时遇到串口不收数据、T3.5 不触发、CRC 校验失败等问题,欢迎留言交流,我可以帮你一起查!