手把手教你用 freemodbus 在嵌入式平台实现 Modbus RTU 通信
你有没有遇到过这样的场景:项目里要用 STM32 接一堆传感器,上位机是 PLC 或 HMI,要求走 Modbus 协议通信?翻遍数据手册、查遍论坛,发现标准协议看似简单,真动手写起来却处处是坑——帧边界判断不准、CRC 校验出错、RS-485 方向切换丢包……最后只能买现成模块,又贵又不灵活。
别急。今天我们就来彻底解决这个问题,带你从零开始,在一个典型的 MCU 平台上(比如 STM32)完整集成freemodbus协议栈,跑通Modbus RTU模式下的从机功能。整个过程不讲空话,只讲实战,让你真正掌握“自己造轮子”的能力。
为什么选 freemodbus?
在工业控制领域,Modbus 几乎是“通用语言”。而要在嵌入式设备上实现它,开发者通常有三个选择:
- 买商业协议栈:稳定但贵,授权费动辄几千上万;
- 自己重写一套:自由度高,但调试成本巨大,一个小 bug 就可能让通信瘫痪;
- 用开源方案—— 而freemodbus正是这个赛道里的“优等生”。
它是用纯 ANSI C 写的,代码清晰、结构模块化,支持 RTU/ASCII/TCP 多种模式,最关键的是——免费 + 可商用 + 社区活跃。GitHub 上持续更新,无数项目验证过它的稳定性,非常适合用于智能仪表、远程 IO、边缘网关等产品开发。
更重要的是,你可以完全掌控每一行代码的行为,而不是黑盒调用 API。这对需要深度定制或排查问题的工程师来说,简直是福音。
Modbus RTU 到底是怎么工作的?
在深入代码前,先搞清楚一个核心问题:RTU 是怎么知道一帧数据什么时候开始、什么时候结束的?
和 TCP 不同,串口没有“连接”概念;和 CAN 不同,它也没有显式的帧头帧尾。RTU 靠的是时间间隔法—— 当总线空闲超过3.5 个字符时间,就认为当前帧结束了。
举个例子:波特率 9600bps,每个字符占 11 位(起始+8数据+校验+停止),那么一个字符的时间是:
$$
T_{char} = \frac{11}{9600} ≈ 1.146ms
$$
所以 3.5T ≈4ms。只要接收端连续 4ms 没收到新字节,就触发“帧完成”事件,开始解析报文。
这也意味着:你的定时器必须足够精准,否则要么误判帧头,要么漏掉整帧。
RTU 报文长什么样?
典型的一条读保持寄存器(功能码 0x03)请求如下:
[0x01][0x03][0x00][0x00][0x00][0x02][0xC4][0x0B]0x01:从站地址0x03:功能码(读保持寄存器)0x00 0x00:起始地址(0)0x00 0x02:读取数量(2个寄存器)0xC4 0x0B:CRC16 校验值(低字节在前)
响应报文为:
[0x01][0x03][0x04][0x12][0x34][0x56][0x78][0xXX][0xXX]其中0x04表示后面有 4 字节数据,对应两个 16 位寄存器的值。
所有这些打包、拆包、校验的工作,freemodbus 都帮你做好了。你要做的,只是告诉它:“哪些寄存器可以被读写”,以及“如何收发字节”。
如何把 freemodbus 移植到你的项目中?
freemodbus 的设计哲学是:协议层与硬件解耦。它通过一组“端口层函数”将底层依赖抽象出来,你只需要实现这几个接口,就能让它跑在任何平台上。
整个移植工作主要集中在以下几个文件:
mb.h // 主头文件 mb.c // 协议核心逻辑 port.h // 端口层抽象定义 port.c // 用户实现的硬件适配层 mbconfig.h // 功能开关配置我们以 STM32 + HAL 库为例,一步步来看关键步骤。
第一步:配置 mbconfig.h
这是整个协议栈的“开关面板”。你需要根据需求开启对应功能。对于一个基本的 RTU 从机,建议配置如下:
#define MB_RTU_ENABLED 1 // 启用 RTU 模式 #define MB_ASCII_ENABLED 0 #define MB_TCP_ENABLED 0 #define MB_SLAVE 1 // 作为从机 #define MB_MASTER 0 #define MB_FUNC_READ_HOLDING_REG_ENABLED 1 // 支持读保持寄存器 #define MB_FUNC_WRITE_HOLDING_REG_ENABLED 1 // 支持写单个/多个寄存器 #define MB_FUNC_READ_INPUT_ENABLED 1 // 支持读输入寄存器其他选项如日志输出、动态内存分配等可根据资源情况关闭以节省空间。
第二步:实现串口驱动与方向控制
RS-485 是半双工总线,发送和接收共用一条线,必须通过 DE/RE 引脚控制方向。这也是最容易出问题的地方。
常见错误是:刚发完数据就立刻切回接收,结果最后一个 bit 还没送出,就被截断了,导致主机收不到完整响应。
正确的做法是:等待发送完成标志(TC)置位后再延时几微秒,再切换方向。
void vMBPortSerialEnable(BOOL bTxEnable, BOOL bRxEnable) { if (bTxEnable) { HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); // 使能发送 __HAL_UART_ENABLE_IT(&huart1, UART_IT_TXE); // 开启发送中断 } else { __HAL_UART_DISABLE_IT(&huart1, UART_IT_TXE); // 等待传输完成 while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET); // 关键延时!确保最后一个 bit 完全发出 delay_us(3); HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET); // 切回接收 } if (bRxEnable) { __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE); // 开启接收中断 } }✅ 提示:使用硬件定时器做 us 级延时,避免阻塞调度器。
第三步:处理帧边界定时器
前面说了,3.5T 是判断帧结束的关键。freemodbus 会调用xMBPortTimersInit(USHORT usTim1Timerout50us)来初始化这个定时器,单位是 50μs。
例如,9600bps 下 3.5T ≈ 4ms = 80 × 50μs,因此你应该设置定时器计数 80。
BOOL xMBPortTimersInit(USHORT usTim1Timeout50us) { uint32_t timer_period = usTim1Timeout50us * 50UL; // 转换为微秒 htim3.Instance = TIM3; htim3.Init.Prescaler = SystemCoreClock / 1000000 - 1; // 1MHz 计数频率 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = timer_period - 1; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; if (HAL_TIM_Base_Init(&htim3) != HAL_OK) return FALSE; HAL_NVIC_SetPriority(TIM3_IRQn, 5, 0); // 设置较高优先级 HAL_NVIC_EnableIRQ(TIM3_IRQn); return TRUE; }在串口接收中断中,每收到一个字节就重启一次定时器:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_IT_SOURCE(&huart1, UART_IT_RXNE)) { uint8_t data = huart1.Instance->RDR; prvvUartRxISR(data); // 交给 freemodbus 内部处理 // 重启 3.5T 定时器 __HAL_TIM_SET_COUNTER(&htim3, 0); HAL_TIM_Base_Start_IT(&htim3); } }当定时器超时,触发回调:
void TIM3_IRQHandler(void) { HAL_TIM_IRQHandler(&htim3); } void vMBPortTimersEnable(void) { HAL_TIM_Base_Start_IT(&htim3); } void vMBPortTimersDisable(void) { HAL_TIM_Base_Stop_IT(&htim3); }这样就能准确捕捉每一帧的边界。
第四步:注册寄存器访问回调函数
freemodbus 提供了一组回调函数指针,用来映射 Modbus 寄存器区到你的变量。
最常见的四个区域:
| 区域 | 功能码 | 描述 |
|---|---|---|
| 0x01 | 读线圈 | DO 输出状态 |
| 0x02 | 读离散输入 | DI 输入状态 |
| 0x03 | 读保持寄存器 | AO 可读写参数 |
| 0x04 | 读输入寄存器 | AI 采集数据 |
你只需实现对应的回调函数即可。例如,保持寄存器读写:
// 假设我们有两个可读写的参数 uint16_t holding_regs[2] = {100, 200}; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { int iRegIndex; eMBErrorCode eStatus = MB_ENOERR; // 地址偏移修正(Modbus 地址从 1 开始) usAddress--; for (iRegIndex = 0; iRegIndex < usNRegs; iRegIndex++) { if ((usAddress + iRegIndex) >= 2) { eStatus = MB_ENOREG; // 超出范围 break; } if (eMode == MB_REG_READ) { pucRegBuffer[0] = (holding_regs[usAddress + iRegIndex] >> 8) & 0xFF; pucRegBuffer[1] = holding_regs[usAddress + iRegIndex] & 0xFF; pucRegBuffer += 2; } else { holding_regs[usAddress + iRegIndex] = (pucRegBuffer[0] << 8) | pucRegBuffer[1]; pucRegBuffer += 2; } } return eStatus; }类似地,还可以实现输入寄存器(如 ADC 采样值)、线圈(继电器控制)等。
第五步:启动协议栈
一切准备就绪后,就可以启动 freemodbus 了:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM3_Init(); // 初始化 freemodbus 栈(RTU 模式,地址 1,波特率 9600,偶校验) eMBInit(MB_RTU, 1, 0, 9600, MB_PAR_EVEN); // 使能协议栈 eMBEnable(); for (;;) { // 主循环轮询 eMBPoll(); // 其他任务处理... osDelay(1); } }注意:eMBPoll()必须周期性调用,通常放在主循环或任务中。它负责检查是否有新帧到达、是否超时、是否需要响应等。
常见问题与避坑指南
❌ 问题 1:主机发请求,但从机无响应
排查方向:
- 是否正确设置了 DE 引脚电平?
- 发送完成后有没有加延时?
- CRC 校验是否匹配?可以用 Modbus 调试工具抓包分析。
❌ 问题 2:偶尔出现 CRC 错误或帧丢失
原因可能是:
- 定时器精度不够,3.5T 判断失误;
- 中断优先级设置不当,被高优先级任务打断;
- 串口 FIFO 溢出(特别是高速率下)。
✅解决方案:
- 使用独立硬件定时器;
- 提高串口和定时器中断优先级;
- 波特率不要过高(建议 ≤38400 用于长距离传输)。
❌ 问题 3:多设备通信冲突
确保每个从站地址唯一!可以在初始化时从 EEPROM 加载地址:
uint8_t dev_addr = read_eeprom(ADDR_STORAGE_OFFSET); eMBInit(MB_RTU, dev_addr, 0, 9600, MB_PAR_NONE);也可以支持通过 Modbus 写寄存器修改地址并保存,实现“软件拨码”。
实际应用场景举例
掌握了这套方法后,你能快速构建以下设备:
- 🌡️ 温湿度采集节点:AI 区放传感器值,DI 区上报报警状态;
- 🔌 智能电表:保持寄存器配置 CT 变比、地址,输入寄存器上传电压电流功率;
- 🛠️ 远程 IO 模块:线圈控制继电器,离散输入监测按钮状态;
- 🔄 协议转换网关:前端接 Modbus RTU 设备,后端转 MQTT 上云。
甚至还能反过来当主机去轮询其他设备(需启用 master 模式),实现数据聚合。
总结一下:你到底学会了什么?
通过这篇文章,你应该已经掌握了:
- ✅ 如何在 STM32 等 MCU 上成功移植 freemodbus;
- ✅ 如何正确处理 RS-485 半双工方向切换;
- ✅ 如何配置 3.5T 定时器实现可靠帧同步;
- ✅ 如何暴露本地变量给 Modbus 主机读写;
- ✅ 如何调试典型通信故障。
这套方案不仅适用于裸机系统,也能轻松集成进 FreeRTOS、RT-Thread 等实时操作系统中。只要替换对应的port.c实现,就能无缝迁移。
更重要的是,你现在拥有了自主开发工业通信节点的能力,不再依赖昂贵的成品模块,产品可控性和灵活性大大提升。
如果你正在做楼宇自控、能源管理、智能制造相关项目,这绝对是一项值得掌握的核心技能。
💡下一步建议:
- 尝试添加 Modbus TCP 支持,做一个 RTU-to-TCP 网关;
- 结合 OTA 升级机制,实现远程固件更新;
- 加入看门狗和心跳检测,提升系统鲁棒性。
欢迎在评论区分享你的移植经验或遇到的问题,我们一起交流进步!