从零开始玩转 freemodbus:手把手教你实现寄存器读写
在工业控制领域,设备之间要“说话”,靠的不是语言,而是通信协议。而说到串行通信里的“普通话”,Modbus绝对当仁不让。它简单、开放、稳定,几乎成了 PLC、传感器、仪表这些嵌入式设备之间的通用信使。
如果你正在做一个需要对外提供 Modbus 接口的项目——比如一个温控仪、数据采集模块或者智能电表——那么freemodbus就是你不可错过的好帮手。这个轻量级开源协议栈,专为资源受限的 MCU 设计,代码清晰、移植方便,特别适合用来快速搭建一个功能完整的 Modbus 从机(Slave)。
今天我们就抛开理论堆砌,直奔实战主题:
👉 如何用 freemodbus 实现保持寄存器的读写?
👉 怎么对接底层串口和定时器?
👉 在真实项目中如何规划地址映射?
一步步来,带你把协议栈真正“跑起来”。
freemodbus 到底是个啥?先搞清它的脾气
freemodbus 不是商业库,也不是大而全的解决方案,它的定位很明确:
“我就是一个专注做 Modbus 从机的小工具。”
由 Nikolaus Schulz 开源维护,完全遵循 Modbus 协议规范,纯 C 编写,不依赖操作系统,能在 Cortex-M3/4/7、AVR、甚至 8051 上运行。RAM 占用通常不到 1KB,Flash 几 KB 起步,裁剪后可以更小。
但它也有“短板”:
❌ 它只支持 Slave 模式(不能当主机发起请求)
❌ 没有现成的 HAL 驱动,硬件层得你自己填
✅ 但正因如此,你才能彻底掌控每一帧数据的收发逻辑
所以别指望“调个 API 就通”,freemodbus 更像是一套乐高积木,你需要自己拼出完整结构。
它是怎么工作的?
想象一下你的单片机正在安静地运行着主循环:
while (1) { eMBPoll(); // ← 关键就在这句 }这行eMBPoll()是整个协议栈的“心跳”。它内部是一个状态机,负责处理以下事情:
- 检查有没有收到新的字节(来自串口中断)
- 判断是否构成完整的一帧(利用 T3.5 时间间隔)
- 解析功能码、地址、长度
- 调用你写的回调函数去拿数据或写数据
- 组包响应并启动发送
整个过程是事件驱动 + 主循环轮询结合的方式。也就是说:
中断负责“喂”数据给协议栈,
eMBPoll负责“消化”这些数据。
这种设计让它既适用于裸机系统,也能轻松集成进 RTOS 环境。
四类寄存器怎么管?关键在于四个回调函数
Modbus 规定了四种标准数据区:
| 类型 | 访问方式 | 常见用途 |
|---|---|---|
| 离散输入(DI) | 只读 | 外部开关量输入 |
| 线圈(Coil) | 可读写 | 控制继电器等输出 |
| 输入寄存器(IR) | 只读 | ADC 采样值、温度等模拟量 |
| 保持寄存器(HR) | 可读写 | 用户配置参数、运行状态 |
在 freemodbus 里,这些区域都不是直接暴露出去的。你想让主机访问哪块内存,必须通过回调函数告诉协议栈:“来吧,我可以帮你读或写。”
核心就是下面这四个函数原型(定义在mb.h中):
eMBErrorCode eMBRegInputCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs); eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode); eMBErrorCode eMBRegCoilsCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNCoils, eMBRegisterMode eMode); eMBErrorCode eMBRegDiscreteCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNDiscrete);我们重点看最常用的保持寄存器读写回调:eMBRegHoldingCB
写好回调函数:数据怎么进出我说了算
假设我们要管理一组 16 位寄存器,起始地址为 40001,共 10 个寄存器。先定义本地缓冲区:
#define REG_HOLDING_START 1 // Modbus 地址偏移(从1开始) #define REG_HOLDING_NREGS 10 // 寄存器数量 static uint16_t usHoldingRegisterBuf[REG_HOLDING_NREGS] = {0};然后实现回调函数:
eMBErrorCode eMBRegHoldingCB( UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus = MB_ENOERR; int16_t 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 (int i = 0; i < usNRegs; i++) { // 大端格式打包:高字节在前 *pucRegBuffer++ = (UCHAR)(usHoldingRegisterBuf[regIndex + i] >> 8); *pucRegBuffer++ = (UCHAR)(usHoldingRegisterBuf[regIndex + i] & 0xFF); } break; case MB_REG_WRITE: for (int i = 0; i < usNRegs; i++) { // 先取高字节,再取低字节 usHoldingRegisterBuf[regIndex + i] = (*pucRegBuffer++ << 8); usHoldingRegisterBuf[regIndex + i] |= *pucRegBuffer++; } break; } } else { eStatus = MB_ENOREG; // 返回异常码 0x02:非法地址 } return eStatus; }📌几个关键点一定要注意:
- Modbus 地址从 1 开始,但我们数组是从 0 开始的,所以要做减法转换。
- 数据传输是大端模式(Big-Endian),即高位字节先发。你在拆包时必须严格按顺序处理高低字节。
- 返回值决定主机看到什么:
-MB_ENOERR→ 正常响应
-MB_ENOREG→ 返回异常帧,错误码 0x02(非法数据地址)
如果你忘了做地址偏移,或者高低字节颠倒了,主机就会收不到正确数据,甚至报错。
底层对接:串口和定时器你得亲自上
freemodbus 本身不管硬件,它只关心“有没有收到字节”和“时间到了没”。所以你需要实现两个关键模块:串口驱动和T3.5 定时器。
串口部分:中断来了要“打招呼”
协议栈要求你实现这几个函数:
BOOL xMBPortSerialInit(UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity); void vMBPortSerialEnable(BOOL bRxEnable, BOOL bTxEnable); BOOL xMBPortSerialGetByte(UCHAR *pucByte); BOOL xMBPortSerialPutByte(UCHAR ucByte);其中最重要的是中断服务程序。以 STM32 HAL 为例,在 USART 接收中断中你要通知 freemodbus:
void USART1_IRQHandler(void) { uint8_t ch; UART_HandleTypeDef *huart = &huart1; if (__HAL_UART_GET_FLAG(huart, UART_FLAG_RXNE)) { ch = (uint8_t)(huart->Instance->DR); xMBPortSerialReceiveISR(&ch, 1); // ← 这个函数会唤醒协议栈 } if (__HAL_UART_GET_FLAG(huart, UART_FLAG_TXE)) { vMBPortSerialTransmitISR(); // 发送完成处理 } }⚠️ 注意:
xMBPortSerialReceiveISR是 freemodbus 提供的 ISR 包装函数,不要自己直接操作缓冲区!
另外,vMBPortSerialEnable(TRUE, FALSE)表示开启接收、关闭发送,用于切换 RS485 收发方向控制(DE/RE 引脚)。
定时器部分:T3.5 决定帧边界
RTU 模式下,没有起始/结束标志符,靠的是字符间的空闲时间判断一帧是否结束。这个时间就是T3.5——大约等于 3.5 个字符传输时间。
计算公式如下:
T_char = 11 / 波特率 (11位:起+数+校+停) T35 ≈ 3.5 × T_char ≈ 38.5 / 波特率(秒)例如波特率为 9600:
T35 ≈ 4ms → 需要设置定时器触发时间为 4ms而在 freemodbus 中,定时器单位是50μs,所以传给初始化函数的参数是:
USHORT usTimeOut50us = 4000 / 50 = 80;你可以用 SysTick、TIM 定时器或其他任何能产生周期中断的机制实现:
BOOL xMBPortTimersInit(USHORT usTimeOut50us) { // 假设系统时钟 72MHz uint32_t ticks = SystemCoreClock / 1000000 * 50 * usTimeOut50us - 1; SysTick->LOAD = ticks; SysTick->VAL = 0; SysTick->CTRL = 0; // 先不启动 return TRUE; } void vMBPortTimersEnable(void) { SysTick->VAL = 0; SysTick->CTRL |= SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk; } void vMBPortTimersDisable(void) { SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk; }每次收到一个字节,协议栈会自动调用vMBPortTimersEnable()重置计时器。如果超时未收到新字节,则触发prvvTIMERExpiredISR(),表示帧已完整接收。
实战案例:做个温控仪,远程读温度设阈值
现在我们来搭一个真实的场景:基于 STM32 的温度控制器。
功能需求
- 使用 DS18B20 获取当前温度
- 支持 Modbus RTU 协议(RS485 接口)
- 上位机可读取当前温度、设定目标温度、控制加热开关
寄存器映射表设计
| Modbus 地址 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 40001 | 当前温度 | Holding Reg | 只读,放大10倍存储(如 256 = 25.6°C) |
| 40002 | 设定温度 | Holding Reg | 可读写 |
| 40003 | 加热使能 | Holding Reg | 0=关闭,1=开启 |
| 40004 | 心跳计数 | Holding Reg | 测试用,每秒自增 |
对应代码中的数组索引:
#define TEMP_CURRENT 0 // 对应地址 40001 #define TEMP_SET 1 // 对应地址 40002 #define HEATER_ENABLE 2 // 对应地址 40003 #define HEARTBEAT 3 // 对应地址 40004主循环中定期更新温度值:
float fTemp = DS18B20_GetTemp(); usHoldingRegisterBuf[TEMP_CURRENT] = (uint16_t)(fTemp * 10); // 每秒递增心跳 if (tick_1s_flag) { usHoldingRegisterBuf[HEARTBEAT]++; tick_1s_flag = 0; }同时根据设定值判断是否开启加热:
if (usHoldingRegisterBuf[HEATER_ENABLE] && usHoldingRegisterBuf[TEMP_CURRENT] < usHoldingRegisterBuf[TEMP_SET]) { HAL_GPIO_WritePin(HEATER_PORT, HEATER_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(HEATER_PORT, HEATER_PIN, GPIO_PIN_RESET); }这样一来,上位机只要往 40002 写数值、往 40003 写 1,就能远程控温了。
常见坑点与调试秘籍
别以为编译通过就能通信顺利,实际调试中这些坑你很可能遇到:
🔧问题1:主机读回来的数据总是错的或乱码
➡️ 检查:是不是高低字节顺序反了?记住 Modbus 是大端!
➡️ 解决:确保打包时先放>>8,再放&0xFF
🔧问题2:主机提示“非法地址”或“无响应”
➡️ 检查:地址偏移对了吗?Modbus 地址从 1 开始,数组从 0 开始
➡️ 解决:regIndex = usAddress - REG_HOLDING_START;
🔧问题3:偶尔丢帧或响应慢
➡️ 检查:T3.5 时间设置准不准?波特率越高,T3.5 越短
➡️ 解决:重新计算usTimeOut50us,必要时加一点裕量(+1~2)
🔧问题4:多任务环境下数据被改写
➡️ 检查:是否有其他线程或中断修改了寄存器数组?
➡️ 解决:使用原子操作、关中断保护、或加互斥锁(RTOS 下)
💡调试建议:
- 加一个 LED,在eMBPoll中闪烁,确认协议栈在跑
- 用串口打印原始帧内容,观察收发是否正常
- 用 Modbus 调试助手(如 ModScan/ModSim)测试基本读写
最后说几句掏心窝的话
freemodbus 看似门槛不高,但真要把它用稳、用好,还是得沉下心来理解它的机制。它不像某些商业库那样“一键启用”,但也正因为如此,你才拥有最大的自由度。
当你第一次看到主机成功读出你设备里的温度值时,那种成就感是无可替代的。
掌握了这套方法,你不光能做温控仪,还能扩展到:
- 把浮点数拆成两个寄存器传输
- 实现自定义功能码处理特殊命令
- 结合 FreeRTOS 实现多协议并行(CAN + Modbus)
- 移植到 ESP32、nRF52、GD32 等各种平台
而且你会发现,一旦熟悉了这一套模式,下次再接类似的协议——不管是 Modbus TCP 还是自定义私有协议——思路都是一样的:收数据 → 解析 → 回应 → 发出去。
所以,别怕麻烦,动手试试吧。
下一个能独立搞定工业通信的工程师,可能就是你。
如果你在移植过程中遇到了具体问题,欢迎留言交流,我们一起解决。