简介:面向Qt桌面开发与工业自动化方向的学习者,这份压缩包给出一个可参考的西门子PLC通信示例,重点演示在Qt工程中接入Snap7库、连接S7系列PLC并完成数据读写的基本做法,适合具备一定C++与Qt基础、希望把上位机界面与PLC通信打通的开发者。包内共8个文件,包含C++头文件与实现文件、Qt界面文件、动态库与静态库以及说明文本,体积约123KB,结构紧凑,便于直接在工程中对照库依赖与调用关系。已有4842人学习下载,说明该主题在工控上位机开发中关注度较高。通过源码可观察连接参数设置、区域读写调用、界面与通信逻辑组织方式,也可从说明文本中梳理库添加和异常处理思路,为自建PLC监控界面提供可复用的参考框架。
1. 从一台 S7-1200 说起:Qt 上位机为什么绕开 Modbus 直接讲 S7 协议
去年帮人看一条包装线的改造,现场是一台 S7-1200,DB100 里挤着急停状态、三轴位置和四个温度值。触摸屏已经在读这些数据,PC 端还要再做一份曲线和班次报表。最初的想法很朴素:让 PLC 加个 Modbus TCP 从站,Qt 里用 QModbusTcpClient 读保持寄存器,半小时就能跑通。真动手才发现预算里没有“改 PLC 程序”这一项——原有逻辑不能碰,DB 块地址也不能挪。
方案只能转向直接讲 S7 协议,用 Snap7 这个开源库把 DB 块原样搬进 Qt。这套思路适合手里有现成 PLC 程序、又要在 Qt 里做上位机界面的场景,也适合把“PLC 通信”当成毕设核心模块的人:不用在梯形图里加一行指令,只要在 PLC 侧开一个远程访问开关,剩下的全在上位机侧完成。
2. 需求拆解与选型:S7 协议、Snap7 与 Qt 三方各管什么
2.1 通信需求拆成四类:周期读、事件写、断线续、跨平台
上位机侧的通信需求看着杂,拆开其实只有四类,每一类对应的设计约束完全不同。第一类是周期读,界面上十几个温度、压力、位置要按固定节奏刷新,快的一百毫秒,慢的一秒,这类需求的关键不在“读得到”,而在“别读太多次”,每多一次请求就多一次往返和一次超时风险。第二类是事件写,操作员点按钮下发设定值或启停命令,这类需求的关键是确认,写下去之后必须回读一次,不能只信函数返回值。第三类是断线续,现场网线被叉车蹭掉、交换机重启、PLC 断电下载程序,都是常态,程序不能靠“重启上位机”来恢复。第四类是跨平台,调试在 Windows 笔记本上,落地在 Linux 工控机上,所以任何依赖 COM 组件、注册表、ActiveX 的方案都不能要。
数据量先算清楚,后面的架构才好定。一个典型的点位表:8 个 REAL 温度压力、12 个 INT 计数、4 个 BOOL 状态位,按 DB 块里连续排布大概 40 到 60 字节。如果每个变量单独发一次请求,一次轮询就是二十多次往返;按局域网内单次往返 5 到 20 毫秒算,光通信就吃掉两三百毫秒,界面刷新周期直接被拖垮。合并成一次 DBRead 只发一个报文,二十毫秒内结束,剩下全是本地解析的时间。这个数量级的差距,是后面所有批量读取设计的出发点。
| 需求类型 | 现场表现 | 设计约束 |
|---|---|---|
| 周期读 | 100 到 500 毫秒刷新状态与模拟量 | 单次报文合并,禁止逐变量请求 |
| 事件写 | 按钮下发设定值、启停、复位 | 写后回读比对,失败要能提示 |
| 断线续 | 网线松脱、交换机重启、PLC 停机 | 状态机加退避重连,界面标记数据过期 |
| 跨平台 | Windows 调试、Linux 工控机落地 | 不依赖 COM、注册表、厂商控件 |
2.2 为什么是 Snap7:协议栈、许可证与体积的取舍
把可选方案摆在一张表上,取舍就很清楚了。Modbus TCP 最省事,Qt 自带客户端类,但它要求 PLC 侧主动开从站并做地址映射,程序不能改的场景直接出局。OPC UA 功能最全,跨品牌集成能力强,代价是 PLC 侧要开服务器、要配置、某些型号还要额外授权,上位机侧的协议栈也是几百兆的体量,对一个只读四十字节的项目来说太重。厂商自带的 OCX 控件属于上一个时代的产物,只能在 Windows 上跑,还得注册组件,换台机器部署就出问题。
Snap7 的位置正好卡在中间:它把 S7 协议栈封成一个几百 KB 的动态库,对外只暴露 C 接口,Windows 下是 dll,Linux 下是 so,Qt 直接链接或者用 QLibrary 动态加载都行。它走的是 ISO-on-TCP,默认端口 102,和 PLC 编程软件用的是同一条通道,所以不需要在梯形图里加任何指令,只要在 PLC 的硬件配置里允许来自远程伙伴的 PUT/GET 访问,再把要读的 DB 块属性里的“优化的块访问”取消掉,地址就按传统偏移排布了,Snap7 才能按绝对偏移去读。
需要提醒一句:开源许可允许集成到商业软件里,但分发动态库时要按条款保留声明,这一点在做交付方案时最好提前写进文档,别等验收前才想起来。另外它只实现了客户端,做不了服务器;如果你还要让别的系统来读你的数据,那是另一个方向的事。
2.3 Qt 侧的角色:只做 IO 调度,不做协议
分工必须一刀切清楚:Snap7 负责把字节搬过来搬过去,Qt 负责什么时候搬、搬完放哪、界面怎么显示。协议解析、重连策略、超时判定这些如果散落在 MainWindow 的槽函数里,代码三个月后自己都读不懂。所以我一般把 Qt 侧的职责收敛成四件事:线程调度(通信跑在独立线程)、定时驱动(QTimer 触发轮询)、数据模型(点位表加缓存,用 QAbstractTableModel 或者简单的信号往外抛)、展示层(曲线、表格、报警条)。
最容易翻车的地方是把 DBRead 直接写在按钮的槽里。一次网络抖动,底层超时可能卡住两三秒,这期间界面完全无响应,操作员以为死机了,反复点,最后点了十几次同一批写命令。通信必须离开 UI 线程,这不是性能优化,是可用性底线。
3. 总体架构:一个工作线程、两层缓存、四个模块
3.1 分层结构:界面层、会话层、协议层、数据层
从上往下四层,每层只和相邻层说话。界面层包括主窗口、实时曲线、报警列表,它不知道 S7 是什么,只认“某个点位的最新值和它的时间戳”。会话层是一个连接状态机,维护四个状态:未连接、连接中、在线、退避等待,所有重连逻辑都在这里,界面只订阅状态变化。协议层就是对 TS7Client 的薄封装,只做三件事:连接、按区域读写、把错误码翻译成人话。数据层是点位表和值缓存,点位表从 CSV 读进来,缓存里每个值都带时间戳。
为什么不把会话层和协议层合成一个类?因为调试时这两个东西的失败模式完全不同。协议层失败是“这次读返回非零”,会话层失败是“连续五次读都失败,应该断开重连”。混在一起写,日志里就分不清是网络偶发丢包还是连接真的断了,排查时只能靠猜。
3.2 四个模块的职责与接口
| 模块 | 职责 | 关键接口 | 出问题先看哪 |
|---|---|---|---|
| PlcWorker | 持有 TS7Client,执行连接、读、写 | connectPlc / pollOnce / writePoint | CltErrorText 翻译出的错误文本 |
| PlcSession | 连接状态机与退避重连 | start / stop / stateChanged | 状态迁移日志的时间间隔 |
| PointTable | 点位定义,名称到偏移、类型的映射 | load(csv) / resolve(name) | CSV 里的 db 号、offset、type 三列 |
| DataHub | 值缓存、变化通知、过期标记 | value(name) / updated(name) | 时间戳是否停止刷新 |
点位表的 CSV 长这样,字段少但每个都不能省:
name,area,db,offset,type,scale Temp_Zone1,DB,100,0,REAL,1.0 Temp_Zone2,DB,100,4,REAL,1.0 Axis_Pos,DB,100,8,REAL,1.0 Run_Flag,DB,100,12.0,BOOL,1 Cycle_Count,DB,100,14,INT,1area支持 DB、M、I、Q 四种就够了,DB 走 DBRead,其余走 ReadArea。offset对 BOOL 写成“字节.位”的形式,解析时拆成字节偏移和位序号,比在 CSV 里单开一列 bit 更直观。scale留给工程量换算,PLC 里存的是 0 到 27648 的原始值还是浮点真值,在这列里说清楚,别在代码里写死。
3.3 线程模型与对象生命周期
Qt 里把工作对象挪到线程的标准写法,坑集中在 parent 和定时器上:
// mainwindow.cpp —— 线程与工作对象的装配 m_thread = new QThread(this); m_worker = new PlcWorker(); // 不能带 parent,否则 moveToThread 无效 m_worker->moveToThread(m_thread); connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); connect(this, &MainWindow::requestConnect, m_worker, &PlcWorker::connectPlc); connect(m_worker, &PlcWorker::dataReady, this, &MainWindow::onDataReady); // 跨线程自动排队 connect(m_worker, &PlcWorker::errorOccurred, this, &MainWindow::onError); m_thread->start(); emit requestConnect(QStringLiteral("192.168.0.10"), 0, 1);要点有三个。第一,new PlcWorker()不能传 parent,带了 parent 的对象moveToThread会直接失败,而且 Qt 只在运行期打一行警告,很容易漏看。第二,跨线程信号默认是Qt::QueuedConnection,参数会被拷贝进事件队列,所以自定义类型必须先qRegisterMetaType,否则连接能建立但收不到。第三,轮询用的 QTimer 必须在工作线程里创建(一般放在connectPlc成功后new QTimer(this)),如果在主线程 new 出来再让 worker 去 start,定时器归属线程不对,超时信号会在错误线程触发,表现为“偶尔卡一下”或者直接崩。
4. 关键实现:DB 块读写、字节序转换与批量轮询
4.1 工程接入:.pro 配置与动态加载两种方式
静态链接最省事,把 snap7 的头文件和库文件按平台分目录放好:
QT += core gui widgets CONFIG += c++11 INCLUDEPATH += $$PWD/3rdparty/snap7 win32: LIBS += -L$$PWD/3rdparty/snap7/win -lsnap7 unix:!macx: LIBS += -L$$PWD/3rdparty/snap7/linux -lsnap7 # 运行时把动态库拷到可执行文件同级目录 win32: QMAKE_POST_LINK += $$quote(cmd /c copy /Y $$shell_path($$PWD/3rdparty/snap7/win/snap7.dll) $$shell_path($$OUT_PWD)) unix:!macx: QMAKE_POST_LINK += $$quote(cp -f $$PWD/3rdparty/snap7/linux/libsnap7.so $$OUT_PWD)如果交付环境不允许你决定库放哪,或者要在插件里按需加载,就用 QLibrary 动态加载,把函数指针存起来。这种写法的好处是缺库时程序还能起来,弹出提示而不是直接黑屏退出;坏处是每个函数都要自己声明签名,头文件里的类型定义还得照抄一份。我一般优先静态链接,只有在“同一套程序要兼容两个不同大版本库”的情况下才动 QLibrary。
4.2 连接与重连:racket、slot 和退避策略
ConnectTo(ip, rack, slot)三个参数里,rack 和 slot 是最容易填错的。S7-1200 和 S7-1500 这类较新的控制器,一般填 rack=0、slot=1;S7-300 和 S7-400 通常是 rack=0、slot=2。填错的典型表现是 TCP 层能连上,但 S7 建链阶段直接失败,错误文本提示连接被拒绝或者协商失败,这时候先怀疑 slot,别急着怀疑网线。
重连不能写成“失败就立刻重试”,否则 PLC 断电那几分钟里,你的程序会以最大速度发连接请求,日志刷屏,还可能被 PLC 侧当成异常流量拒绝一段时间。指数退避是标配:1 秒、2 秒、4 秒、8 秒,封顶 30 秒,连接成功后计数清零。
// plcsession.cpp —— 退避重连的核心逻辑 void PlcSession::onConnectResult(bool ok, const QString &msg) { if (ok) { m_backoffMs = 1000; // 成功即复位退避 setState(State::Online); return; } setState(State::Backoff); m_retryTimer.start(m_backoffMs); // 单次触发,槽里再发起连接 m_backoffMs = qMin(m_backoffMs * 2, 30000); // 1s → 2s → 4s ... 封顶 30s qWarning() << "connect failed:" << msg << "next retry in" << m_backoffMs / 2 << "ms"; }退避期间界面不能只是灰着,要把所有点位标记成“数据过期”,让操作员知道现在看到的是旧值。这条在验收时经常被问到:断网之后界面还显示着最后读到的数字,如果没有任何过期提示,那就是个安全隐患。
4.3 批量读取:一次 DBRead 换二十个变量
读取的实现关键是“按 DB 块合并”。同一个 DB 里从最小偏移到最大偏移加类型长度,中间的空洞也一起读回来,几十字节的空洞远比多一次网络往返便宜。
void PlcWorker::pollOnce() { QMutexLocker locker(&m_mutex); // TS7Client 不是线程安全的 if (!m_connected) return; const int dbNumber = 100; const int start = 0; const int size = 32; // 覆盖点位表里最大偏移 + 长度,向上取整 QByteArray buf(size, Qt::Uninitialized); QElapsedTimer t; t.start(); int rc = m_client->DBRead(dbNumber, start, size, buf.data()); const qint64 cost = t.elapsed(); if (rc != 0) { emit errorOccurred(QString::fromLatin1(CltErrorText(rc))); m_connected = false; // 交给会话层走重连 return; } qInfo("[POLL] cost=%lldms size=%d", cost, size); emit dataReady(buf); // 原始字节抛给上层解析,协议层不碰业务 }size 不能随便放大。S7 单次 PDU 有效载荷通常在两百多字节的量级,虽然库内部会做协商和分包,但把一次读压到 200 字节以内,行为最可预测。真需要读两百字节以上,宁可拆成两次 DBRead,也别指望库内部的分包逻辑在所有型号上都表现一致。
4.4 字节序解析与写入回读校验
S7 是大端,x86 和 ARM 都是小端,直接把 char* 转成 float* 拿到的必然是乱码。老老实实按位拼,比用联合体或者强制类型转换靠谱得多:
// s7codec.h —— 大端解码,全部按无符号逐字节拼接,避免符号扩展 static inline quint16 beU16(const char *p) { return (quint16(quint8(p[0])) << 8) | quint8(p[1]); } static inline qint16 beI16(const char *p) { return qint16(beU16(p)); } static inline float beReal(const char *p) { quint32 raw = (quint32(quint8(p[0])) << 24) | (quint32(quint8(p[1])) << 16) | (quint32(quint8(p[2])) << 8) | quint32(quint8(p[3])); float f; memcpy(&f, &raw, sizeof(f)); // 用 memcpy 规避严格别名问题 return f; }写入是反向的,先按大端拼字节,再调 DBWrite,然后立刻回读同样长度做比对。回读失败不一定是通信问题,很可能是 PLC 程序每个扫描周期都在覆盖这个地址,这种情况在写设定值时特别常见——你在 HMI 上写进去,PLC 逻辑觉得不合法又写回默认值,看起来就是“写不进去”。
bool PlcWorker::writeReal(int db, int start, float value) { QMutexLocker locker(&m_mutex); if (!m_connected) return false; quint32 raw; memcpy(&raw, &value, sizeof(raw)); char out[4] = { char(raw >> 24), char(raw >> 16), char(raw >> 8), char(raw) }; if (m_client->DBWrite(db, start, 4, out) != 0) return false; char back[4] = {0}; if (m_client->DBRead(db, start, 4, back) != 0) return false; return memcmp(out, back, 4) == 0; // 回读一致才算写成功 }5. 避坑与排查:连接不上、数值乱跳、跑几天就崩的三类根因
5.1 连接与配置类:先怀疑开关,再怀疑网线
现象一:TCP 层能 ping 通,但连接始终失败,错误文本提示不可达或协商失败。原因:PLC 侧没有允许远程 PUT/GET 访问,或者 rack/slot 填错。较新的控制器默认关闭远程访问,这是安全策略,不是故障。 解决:在 PLC 硬件配置的防护与安全选项里勾选允许来自远程伙伴的 PUT/GET 通信,重新编译下载;同时核对 slot,新一点的控制器多用 1,老型号多用 2。改完还连不上,用 telnet 打一下 102 端口,确认中间没有设备拦。
现象二:连接正常,DBRead 却持续返回非零错误码,换成 M 区读又能成功。原因:目标 DB 块开了“优化的块访问”,块内变量没有固定绝对偏移,按老地址去读必然越界;或者 DB 号、起始偏移算错了。 解决:在 DB 属性里取消优化的块访问,重新编译并下载,然后对着交叉引用表逐个核偏移。这一步没有捷径,偏移错一个字节,读回来的 REAL 就是另一个数。
5.2 数据与线程类:字节序、越界与对象跨线程
现象三:数值全是 0,或者显示成 1.4e-45 这种离谱的量级。原因:没做字节序转换,把大端字节直接按小端解释;或者 REAL 与 DINT 混用,同一个四字节被当成整数读出来。 解决:所有多字节类型统一走 4.4 节那套解码函数,禁止在业务代码里出现*(float*)buf。另外把点位表的 type 列当成契约,加一个启动自检:解析前先校验“偏移 + 类型长度”不超过本次读取的缓冲区大小。
现象四:程序跑几个小时甚至一两天后崩溃,崩溃点飘忽不定。原因:TS7Client 不是线程安全的,主线程的写操作和轮询线程的读操作同时打在同一个实例上;或者带 parent 的对象跨线程 delete,析构发生在了错误的线程。 解决:一个 TS7Client 只属于一个线程,所有入口加同一把 QMutex;跨线程对象一律new出来不带 parent,用QThread::finished连deleteLater。这条是血泪经验,日志里看不出来,只能靠代码审查兜住。
现象五:写入函数返回成功,回读也一致,但 PLC 侧行为没变化。原因:写到了 PLC 程序每个扫描周期都会覆盖的中间变量;或者写的是输入区,输入映像由现场信号刷新,写进去立刻被冲掉。 解决:约定“设定值只写专门的设定 DB”,并且让 PLC 侧的程序从设定 DB 取值,而不是反过来往设定 DB 写。这条得在接口文档里写死,光靠上位机这边防范没用。
6. 验收与答辩:怎么证明这套通信真的稳
“能连上、能读到数”只是及格线,真要交付或者答辩,得拿出可复现的证据。我习惯在项目里内置一段轮询日志,每轮记录一行,事后直接拿日志做统计,比现场演示有说服力。
// 每次轮询打一行,字段固定,方便用 awk 或脚本统计 qInfo("[POLL] seq=%llu cost=%lldms rc=%d bytes=%d state=%d", ++m_seq, t.elapsed(), rc, size, int(m_session->state()));跑满 72 小时之后,用几行命令就能出结论:grep -c "rc=0"算成功率,awk取 cost 的最大值和 95 分位,看重连次数是不是吻合预期。这套东西答辩时特别管用,评委问“你怎么知道它稳定”,你直接把统计结果摊开,比说十句“我测过”都强。
| 验收项 | 判定方法 | 合格线 |
|---|---|---|
| 首次连接耗时 | 从发起连接到 Online 状态的日志时间差 | 小于 1 秒 |
| 断线恢复 | 拔网线 30 秒后插回,看恢复时间 | 60 秒内自动回到 Online |
| 单次轮询耗时 | 日志 cost 字段的 95 分位 | 小于 50 毫秒 |
| 连续运行 | 72 小时不间断轮询 | 无崩溃,内存增长小于 10% |
| 写入一致性 | 回读比对成功次数 / 总写入次数 | 100%,失败必须弹出提示 |
| 数据过期标记 | 断线后界面状态 | 所有点位在 2 秒内标记为过期 |
还有一个小技巧:答辩或者评审现场,别演示“正常的读取”,演示“断网再恢复”。正常读取谁都会写,断网恢复的日志最能体现你考虑过真实工况。演示前把 PLC 网线拔掉,等退避重连的日志刷够三轮,再插回去,让评委看到状态从 Backoff 跳回 Online、点位从过期变回实时,整个过程不到一分钟,信息量比讲十分钟架构还大。
从那以后我每次接 PLC 相关的活,都会在交付前强制走一遍“拔网线、断电、改偏移”这三项破坏性测试,哪怕甲方只说“能读就行”。因为真正让人返工的从来不是功能没写,而是断线之后那几秒钟里界面还在显示旧值。这份 Qt 加 Snap7 的完整工程里,点位表格式、状态机、字节序工具类和那套压测日志都留了现成的口子,改个 IP 和 DB 号就能对上你自己的现场。希望帮到你。
本文还有配套的精品资源,点击获取