云计算百科
云计算领域专业知识百科平台

Modbus服务端实现_虚拟PLC

PLCMonitor 内置 Modbus TCP 服务端:让第三方 SCADA 零改造对接

把 PLCMonitor 变成一台"虚拟 PLC",外部 WinCC / 触摸屏 / 能耗网关直接用 Modbus 读数据。libmodbus 在 Windows UCRT64 下的坑、越界读的排查黑洞、寄存器映射地址约定,全部说清楚。


背景:为什么要做 Modbus 服务端?

PLCMonitor 本身已经通过 S7 / Modbus TCP Client / OPC UA 等协议采集了现场数据。但现场还有另一类系统也需要这些数据——能耗管理平台、第三方 HMI、楼宇自控系统……让它们重新接一遍 PLC 通讯栈成本太高。

Modbus TCP 是工业领域最普及的协议,几乎所有 SCADA / HMI 都支持。如果 PLCMonitor 自己就是一个 Modbus Server,第三方系统直接配置地址和端口就能读到数据,零改造。


一、线程模型:N+1 连接线程,无锁热路径

libmodbus 的上下文(modbus_t)不是线程安全的,每个连接必须用独立的 ctx + mapping。

PLCMonitor 的做法:

accept 线程(主线程创建,阻塞在 modbus_tcp_listen 上)

│ onClientAccepted(fd, peerIp) ← QueuedConnection,主线程触发

主线程 ──→ QThread::create(connWorker, params) ← 每连接一线程
├─ 持有独立的 modbus_t* ctx
├─ 持有独立的 modbus_mapping_t* mapping
└─ indication_timeout = 100ms(stop 后 100ms 内退出)

关键约束:所有 QThread 对象都在主线程创建,线程亲和性为主线程,deleteLater() 才有效。工作线程绝不直接 emit 或调用主线程对象,统一走 postMessage() / QMetaObject::invokeMethod(…, Qt::QueuedConnection),以主线程的 m_controller 为 context。


二、寄存器映射:标签地址 → PDU 偏移

地址约定(对齐工业主流)

概念值说明
addr(标签地址) 40001, 40002, … 协议标签地址,4xxxx 范围
offset(PDU 偏移) 0, 1, … offset = addr – 40001
holdStart(寄存器表基址) 恒为 0 寄存器表从 PDU 0 起
holdNb maxEnd + 1 寄存器总数

设计意图:第三方组态默认从 40001 开始填地址,holdStart=0 + offset = addr – 40001 的换算让 40001 直接命中 PDU 0,用户零思考即可连通。

buildTemplate 流程

// 遍历配置中的 mappings,构建 MappingTemplate
for (const ModbusMappingEntry &m : cfg.mappings) {
if (!ModbusHelper::isHoldingRegisterLabel(m.addr)) {
// 非法地址(非 40001–49999 / 400001–465536)→ WARN 跳过
continue;
}
MbMappingEntry e;
e.tagKey = m.tag;
e.addr = m.addr;
e.offset = ModbusHelper::protocolAddressToOffset(m.addr); // addr – 40001
// 类型、字节序、scale 解析…
t->addrToIndex.insert(e.offset, t->entries.size());
t->entries.append(e);
maxEnd = qMax(maxEnd, e.offset + e.regCount 1);
}
t->holdStart = 0;
t->holdNb = qMax(1, maxEnd + 1); // 至少 1,防止 libmodbus 分配 NULL

空洞行为:基址到首个映射之间的 PDU 偏移读回 0(不是异常码 02)。这是 libmodbus 寄存器表初始化的自然结果,实测 holdNb=12 时 PDU 0/8/10/11 成功、PDU 12 才回 02。


三、一个隐藏坑:监听 fd 泄漏导致断连后无法重连

症状

修改 Modbus 配置 → 停用 → 启用,端口仍显示 502,面板显示"运行中",但新客户端 connect 成功却无法收到响应。只能重启应用。

根因(libmodbus 源码,不是猜测)

// libmodbus modbus-tcp.c: modbus_tcp_listen()
// 自建 socket 直接 return new_s,从不写回 ctx->s
ctx->s = 1; // _modbus_init_common 置 -1
// … 创建 socket,bind,listen …
return new_s; // ← 没有 ctx->s = new_s!

而 modbus_close() 内部:

// _modbus_tcp_close()
if (ctx->s >= 0) { // ctx->s 仍是 -1 → 条件不成立
closesocket(ctx->s);
}

结果:stop() 调用 modbus_close(m_listenCtx) 是空操作,监听 socket 泄漏但端口仍被占用。

Windows 机制(探针实测):SO_REUSEADDR 允许同端口两个活跃监听 socket 共存,但新连接归"先注册"的那个。泄漏的孤儿 socket 吃掉全部新连接,新 accept 线程永远收不到。

修复

// listenOnce() 里补一行:modbus_set_socket 回填 ctx->s
int fd = modbus_tcp_listen(ctx, 0);
modbus_set_socket(ctx, fd); // ← 补这一行
m_listenFd = fd;

// stop() 兜底:modbus_get_socket() < 0 时直接 closeFd
if (modbus_get_socket(m_listenCtx) < 0) {
closeFd(m_listenFd);
m_listenFd = 1;
}


四、越界读:libmodbus 不回调应用层,只能请求前预判

libmodbus 的 modbus_reply() 对越界读静默回异常码 02(Illegal Data Address),不回调应用层,服务端日志里完全没有痕迹。用 mbpoll 测试时只会看到 02 Illegal Data Address,却不知道为什么。

PLCMonitor 的做法:在 modbus_reply() 之前手动预判:

void logOutOfRangeRead(ConnParams &p, int fc, const uint8_t *query, int rc) {
if (rc < 12) return;
const int a = (query[8] << 8) | query[9];
const int qty = (query[10] << 8) | query[11];
const int lo = p.tmpl->holdStart;
const int hi = p.tmpl->holdStart + p.tmpl->holdNb 1;
if (a < 0 || qty < 1 || (a >= lo && a + qty 1 <= hi)) return;

// 每连接每秒最多一条,避免 mbpoll 类工具 1s 轮询时刷屏
const quint64 now = QDateTime::currentMSecsSinceEpoch();
if (now p.rangeLogMs >= 1000) {
qWarning() << "[Modbus]" << p.peerIp << "读地址越界:请求 PDU "
<< a << "×" << qty << ",可读区间 [" << lo << "," << hi << "]";
p.rangeLogMs = now;
} else {
++p.rangeSuppressed;
}
// 回包仍交给 modbus_reply(),保持标准行为不变
}


五、数据类型与字节序

支持的类型

标签类型寄存器数说明
int16 1 有符号 16 位
uint16 1 无符号 16 位
int32 2 有符号 32 位
uint32 2 无符号 32 位
float32(默认) 2 IEEE 754 单精度

字节序

4 种排列,对应工业常见规范:

名称寄存器0寄存器1说明
ABCD 最高字节 最低字节 大端优先
BADC 低字节在高位 交换对内字节
CDAB(默认) 低字在前 高字在后 最常见
DCBA 最低字节 最高字节 小端优先

所有编解码统一到"ABCD 规范字节"中转,字节序只在寄存器层排布:

void valueToWireBytes(double val, ModbusType t, uint8_t b[4]) {
// 统一输出 b[0]=最高字节, b[3]=最低字节
// encodeIntoRegisters 再按 ByteOrder 排布到 regs[0]/regs[1]
}


六、授权门控

Modbus 服务端不是常开基础设施,受 License 模块控制:

  • .lic 文件中 modules 不含 "modbus" → modbusServer()->start() 不调用,端口完全不监听,控制面板显示"未授权"
  • 试用版 / 已授权 → 正常启动

这与其他对外接口(REST API、MQTT)保持一致的门控策略。


七、实际对接场景

第三方系统配置示例(WinCC / 触摸屏 / 能耗网关):

协议: Modbus TCP
地址: PLCMonitor 所在机器的 IP
端口: 502
站号: 1(或配置中的 unit_id)
读取地址:40001 起(对应标签映射的第一个 addr)

无需安装额外驱动,无需改 PLCMonitor 任何代码,配置即通。


总结

要点结论
线程模型 N+1,每连接独立 ctx/mapping,无锁热路径
地址约定 addr=4xxxx,offset=addr-40001,holdStart=0
空洞行为 未映射 PDU 读回 0,不报错
监听 fd 泄漏 libmodbus 不写回 ctx->s,需手动 modbus_set_socket 回填
越界读可观测 请求前预判 + 节流日志,modbus_reply() 静默 02 无法回避
字节序 ABCD / BADC / CDAB(默认)/ DCBA 四种,统一 byte array 中转

项目地址:https://github.com/freddiezhang1990/plcmonitor

如果觉得这篇文章对你有帮助,欢迎点赞收藏,下期继续分享 PLCMonitor 的其他设计实践。

赞(0)
未经允许不得转载:网硕互联帮助中心 » Modbus服务端实现_虚拟PLC
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!