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 种排列,对应工业常见规范:
| 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 的其他设计实践。
网硕互联帮助中心



评论前必须登录!
注册