基于 freemodbus 的主从通信实战:手把手教你打造工业级 Modbus 系统
在工厂车间的控制柜里,你可能见过这样的场景:一台小小的嵌入式网关,通过一根 RS-485 总线连接着温度传感器、电能表和压力变送器,同时又通过以太网向上位机或 HMI 提供数据服务。这些设备之间如何“对话”?答案很可能是——Modbus。
而让这一切成为现实的关键之一,就是freemodbus这个轻量却强大的开源协议栈。它不像某些商业库那样昂贵封闭,也不像裸写协议那样容易出错,而是提供了一套结构清晰、可移植性强的实现方案。
本文不堆砌术语,也不照搬文档,而是带你从工程实践的角度出发,一步步理解并实现一个真正可用的 Modbus 主从系统。无论你是正在开发智能仪表的工程师,还是想搭建数据采集终端的技术人员,这篇文章都能帮你少走弯路。
为什么是 freemodbus?
说到 Modbus 实现,市面上其实有不少选择:libmodbus、QModMaster、甚至自己用 C 手搓一帧帧解析。但如果你的目标平台是资源受限的 Cortex-M 单片机(比如 STM32),那 freemodbus 几乎是一个绕不开的名字。
它有几个硬核优势:
- 纯 ANSI C 编写,不依赖复杂运行时;
- 零动态内存分配,全靠静态变量,不怕内存碎片;
- 模块化设计,RTU、ASCII、TCP 各自独立编译,按需裁剪;
- 硬件抽象层完善,所有底层操作都集中在
port文件夹中,移植极其方便; - BSD 许可证,允许商用,无法律风险。
更重要的是,它的代码逻辑非常干净。没有层层嵌套的对象模型,也没有复杂的事件驱动机制,就是一个简单的轮询架构 —— 对嵌入式开发者来说,这反而是最友好的。
✅ 提示:当前主流版本为 v1.6,官方原生仅支持从站模式(Slave)。若要做主站(Master),需要使用社区增强分支或自行封装。
Modbus 是怎么工作的?先搞懂这几个核心概念
别急着敲代码,先理清楚 Modbus 的基本通信机制。否则即使跑通了 demo,遇到问题也无从下手。
主从模型:谁发命令,谁响应
Modbus 是典型的“主-从”结构:
- 只有主设备可以发起请求;
- 所有从设备只能被动响应;
- 总线上不允许有两个主站。
想象一下老师点名提问:老师问(主),学生答(从)。没人能抢答,也不能主动举手说“我有话要说”。
这种简单粗暴的设计,恰恰保证了总线不会冲突,在工业现场极其实用。
三种常见传输方式
| 模式 | 物理层 | 编码方式 | 校验 | 典型应用 |
|---|---|---|---|---|
| Modbus RTU | RS-485/232 | 二进制 | CRC-16 | 工厂设备联网 |
| Modbus ASCII | RS-485 | ASCII 字符 | LRC | 调试友好,但效率低 |
| Modbus TCP | Ethernet | 二进制 | 无(靠TCP) | 上位机通信、网关桥接 |
我们今天重点讲RTU 和 TCP,因为它们最常用。
四类寄存器地址空间
Modbus 定义了四种标准的数据区,每种都有固定的功能和访问权限:
| 类型 | 功能码示例 | 访问方式 | 典型用途 |
|---|---|---|---|
| 线圈 (Coils) | 0x01, 0x05 | 读/写 | 开关量输出,如继电器控制 |
| 离散输入 (Discrete Inputs) | 0x02 | 只读 | 数字量输入,如按钮状态 |
| 输入寄存器 (Input Registers) | 0x04 | 只读 | 模拟量输入,如温度、电压 |
| 保持寄存器 (Holding Registers) | 0x03, 0x10 | 读/写 | 参数配置、运行状态等 |
记住一点:Modbus 地址从 1 开始编号,但你的数组是从 0 开始的!所以在处理时一定要做偏移转换。
freemodbus 架构拆解:它是如何组织代码的?
freemodbus 的源码目录结构非常清晰,掌握这个结构,移植和调试都会轻松很多。
freemodbus/ ├── demo/ # 示例工程 ├── src/ │ ├── aprs.c # ASCII 处理 │ ├── mbrtu.c # RTU 核心逻辑 │ ├── mbtcp.c # TCP 封装 │ ├── mbutils.c # 工具函数(如字节序转换) │ └── mb.c # 协议核心:事务管理、状态机 ├── port/ # 硬件相关接口(重点!) │ ├── portevent.c # 事件抽象(定时器、信号量) │ ├── portserial.c # 串口收发 │ └── porttimer.c # 定时器控制(用于超时检测) └── include/ └── mb.h # 主头文件整个工作流程可以用一句话概括:
注册回调 → 初始化协议栈 → 启动服务 → 循环调用
eMBPoll()
其中最关键的,是你必须实现port层的几个函数,把硬件能力“注入”到协议栈中。
从站怎么写?四步搞定 Modbus Slave
假设你现在要开发一款温控仪,需要对外提供 Modbus 接口供上位机读取温度值、设置目标温度。这就是典型的从站角色。
第一步:定义编译选项
在mbconfig.h中开启你需要的功能:
#define MB_RTU_ENABLED 1 // 使用 RTU 模式 #define MB_TCP_ENABLED 0 // 不启用 TCP #define MB_MASTER 0 // 当前是 Slave #define MB_PORT_HAS_CLOSE 0波特率、校验方式也可以在这里统一配置:
#define MB_BAUD_RATE 115200 #define MB_PARITY MB_PAR_NONE第二步:实现硬件抽象层(Port)
这是移植的核心部分。你需要实现三个关键模块:
1. 串口驱动(xMBPortSerialInit)
BOOL xMBPortSerialInit( UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity ) { huart2.Instance = USART2; huart2.Init.BaudRate = ulBaudRate; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = (eParity == MB_PAR_EVEN) ? UART_PARITY_EVEN : (eParity == MB_PAR_ODD) ? UART_PARITY_ODD : UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; HAL_UART_Init(&huart2); // 启用中断接收第一个字节 HAL_UART_Receive_IT(&huart2, &ucRTUBuf[0], 1); return TRUE; }2. 定时器控制(用于 3.5 字符时间检测)
Modbus RTU 规定帧间静默时间至少为 3.5 个字符时间。我们可以用定时器来实现:
void vMBPortTimersEnable(void) { uint32_t ticks = get_tick_from_baudrate(); // 根据波特率计算 HAL_TIM_Base_Start_IT(&htim3); // 启动定时器中断 __HAL_TIM_SET_AUTORELOAD(&htim3, ticks); } void vMBPortTimersDisable(void) { HAL_TIM_Base_Stop_IT(&htim3); }当收到第一个字节后启动定时器,如果超时未收完,则认为一帧结束。
3. 事件调度(可选,RTOS 下更高效)
如果你用了 FreeRTOS,可以用信号量通知eMBPoll()有新数据到来,避免空轮询。
第三步:注册数据访问回调函数
这才是你业务逻辑的入口。freemodbus 提供四个回调接口,分别对应四类寄存器。
我们重点关注保持寄存器的读写处理:
eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { static uint16_t holding_regs[64]; // 本地映射数组 int idx_offset = usAddress - 1; // 地址从1开始,数组从0开始! if (idx_offset + usNRegs > 64) { return MB_ENOREG; // 越界保护 } switch (eMode) { case MB_REG_READ: for (int i = 0; i < usNRegs; i++) { pucRegBuffer[i*2] = (holding_regs[idx_offset + i] >> 8) & 0xFF; pucRegBuffer[i*2+1] = holding_regs[idx_offset + i] & 0xFF; } break; case MB_REG_WRITE: for (int i = 0; i < usNRegs; i++) { holding_regs[idx_offset + i] = (pucRegBuffer[i*2] << 8) | pucRegBuffer[i*2+1]; } on_modbus_write_event(idx_offset, usNRegs); // 触发业务更新 break; } return MB_ENOERR; }🔍 关键细节:
- 必须进行大端格式转换(高位在前);
- 写入后建议加入合法性检查,防止非法参数导致崩溃;
- 若涉及 Flash 写入,应异步处理,避免阻塞响应。
第四步:启动协议栈
一切准备就绪后,只需几行代码启动服务:
int main(void) { HAL_Init(); SystemClock_Config(); // 初始化 freemodbus RTU Slave eMBInit(MB_RTU, 0x01, 0, 115200, MB_PAR_NONE); // 注册回调(已在内部自动完成) // eMBRegHoldingCB 等函数会被自动查找 eMBEnable(); // 启用协议栈 while (1) { eMBPoll(); // 主循环:处理 incoming 请求 osDelay(1); // 如果用了 RTOS } }至此,你的设备就已经是一个合格的 Modbus 从站了。用 Modbus Poll 工具连上去试试看吧!
主站怎么做?官方没支持,但我们有办法
freemodbus 官方只支持 Slave,那我想做个主站去读多个电表怎么办?
别慌,有两种主流做法:
方案一:使用社区增强版(推荐新手)
GitHub 上有一个活跃的 fork 分支: armink/freemodbus ,明确支持Master Mode,并且提供了完整的 API。
主要变化包括:
- 新增eMBMasterInit()初始化函数;
- 添加eMBMasterReqReadHoldingRegister()等请求接口;
- 支持自动重试、超时管理和状态查询。
用法也非常直观:
// 向从站 0x02 读取 40001 开始的 2 个寄存器 eMBMasterReqReadHoldingRegister(0x02, 0x0000, 2, &result_buf[0], 500);适合快速原型验证和中小型项目。
方案二:手动组包发送(适合定制化需求)
如果你不想改 core 代码,也可以完全自己构造 Modbus 报文,直接通过串口发送。
下面是一个简洁可靠的 RTU 主站请求函数:
void modbus_master_read_holding(uint8_t slave_addr, uint16_t reg_start, uint16_t reg_count) { uint8_t frame[8]; frame[0] = slave_addr; frame[1] = 0x03; // 功能码:读保持寄存器 frame[2] = (reg_start >> 8) & 0xFF; frame[3] = reg_start & 0xFF; frame[4] = (reg_count >> 8) & 0xFF; frame[5] = reg_count & 0xFF; uint16_t crc = calc_crc16(frame, 6); frame[6] = crc & 0xFF; frame[7] = (crc >> 8) & 0xFF; // 控制 DE 引脚发送使能(RS-485 特有) HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_SET); HAL_UART_Transmit(&huart2, frame, 8, 100); HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_RESET); // 插入最小 3.5 字符时间延迟 delay_us(calculate_interframe_delay(115200)); }接收部分建议使用DMA + IDLE 中断,确保不丢帧:
// 在 UART IDLE 中断中触发 void uart_idle_callback() { uint16_t len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx); process_incoming_frame(rx_buffer, len); HAL_UART_Receive_DMA(&huart2, rx_buffer, BUFFER_SIZE); // 重启 DMA }这种方式灵活性最高,但也要求你对协议细节足够熟悉。
实际工程中的那些“坑”,我们都踩过
理论说得再好,不如实战中的一次失败来得深刻。以下是我在真实项目中总结的经验教训。
❌ 问题1:CRC 校验总是失败
现象:主站收不到响应,或者响应报文被判定为错误。
排查方向:
- 波特率是否一致?尤其是 9600 vs 19200;
- 是否开启了奇偶校验但两边设置不同?
- CRC 计算方式是否正确?注意低位在前!
uint16_t calc_crc16(uint8_t *buf, int len) { uint16_t crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= buf[i]; for (int j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; // 返回时不要反转字节顺序! }⚠️ 注意:返回值直接赋给 buffer[6] 和 buffer[7],不需要交换高低字节!
❌ 问题2:串口接收丢帧
原因:单字节中断接收在高速通信下容易丢失数据。
解决方案:改用DMA + IDLE Line Detection。
STM32 HAL 库支持在 UART 空闲时触发中断,此时可通过 DMA 获取已接收长度,大幅提升可靠性。
❌ 问题3:多任务环境下数据竞争
当你在 FreeRTOS 中同时运行 TCP Server 和 RTU Master 任务时,共享寄存器区很可能被并发访问。
解决方法:加互斥锁。
osMutexId_t reg_mutex; void write_register_safe(int addr, uint16_t val) { osMutexWait(reg_mutex, osWaitForever); holding_regs[addr] = val; osMutexRelease(reg_mutex); }或者干脆使用原子操作(适用于单字节/半字)。
❌ 问题4:主站轮询效率低,响应慢
优化建议:
- 高频采集的设备优先轮询;
- 对离线设备减少重试次数,避免阻塞;
- 设置合理的超时时间(通常 100~500ms);
- 使用非阻塞方式(如消息队列)传递结果。
高级技巧:构建 Modbus 网关(TCP ↔ RTU 桥接)
真正的工业价值往往体现在“连接”上。比如做一个Modbus TCP to RTU 网关,把老式 RS-485 设备接入现代网络系统。
结构如下:
[HMI] ←TCP→ [STM32 + LwIP + freemodbus TCP Slave] ↓ [RS-485 总线] ┌────────┼────────┐ ▼ ▼ ▼ [电表] [温控器] [PLC]实现要点:
- 启动两个协议实例:
- 一个 TCP Slave 监听端口 502;
- 一个 RTU Master 轮询下挂设备; - 共享内存作为缓存区;
- TCP 请求到达时,查询本地缓存返回;
- RTU Master 定期刷新缓存数据。
这样既减轻了总线负载,又提升了响应速度。
调试利器推荐
- Modbus Poll / ModScan:Windows 下最强的 Modbus 测试工具,支持图形化监控;
- Wireshark:抓 TCP 包分析 MBAP 头;
- Saleae Logic Analyzer:看 RS-485 实际波形,查时序问题;
- 串口助手 + HEX 模式:原始但有效。
另外,强烈建议在代码中加入日志宏:
#define MODBUS_DEBUG 1 #if MODBUS_DEBUG #define MB_LOG(fmt, ...) printf("[MODBUS] " fmt "\n", ##__VA_ARGS__) #else #define MB_LOG(...) #endif关键时刻能救命。
写在最后:为什么你还应该学 freemodbus?
也许你会问:“现在都 2025 年了,还在用 Modbus?”
答案是:只要工厂还有 PLC,Modbus 就不会消失。
它不是最先进的,但足够稳定、足够简单、足够普及。而 freemodbus 正是将这一经典协议带到现代嵌入式系统的桥梁。
掌握了它,你就具备了:
- 快速对接工业设备的能力;
- 构建边缘网关的基础技能;
- 理解协议分层与硬件抽象的设计思维。
更重要的是,你会发现:很多所谓的“高级协议”,不过是在 Modbus 的肩膀上加了个壳而已。
所以,不妨今晚就打开 Keil 或 STM32CubeIDE,把 freemodbus 移植到你的开发板上跑起来。第一次看到 Modbus Poll 成功读出数据的那一刻,你会感受到一种久违的、属于嵌入式开发者的纯粹快乐。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考