简介:这份面向工业自动化方向开发者与Qt学习者的压缩包,聚焦在桌面端通过Snap7开源库与西门子S7系列PLC建立通信。内容围绕连接建立、I/O与变量读写、界面展示和异常处理展开,适合已具备C++与Qt基础、希望把PLC通信能力接入实际上位机项目的中级读者参考。包内共8个文件,约123KB,涵盖h头文件与cpp源文件、ui界面文件、dll动态库、lib导入库以及txt配置说明,分别对应示例逻辑、界面布局、Snap7依赖与工程集成指引,可用于搭建最小可运行Demo,便于在Qt Creator中快速对照配置。该资源已有4842人学习下载。通过示例源码可了解Client连接、read_area与write_area调用、数据类型映射及错误处理思路,配置文档则补充库文件添加、项目依赖设置与常见集成注意事项,帮助读者缩短环境搭建与联调时间,并据此扩展远程监控与控制功能。
1. 产线看板要读 PLC,绕不开的那套最小实现
产线上要一块看板,把 S7 系列 PLC 里的线速度、报警字、工位计数实时拉到工控机上,现场没有组态授权,也不让装额外服务——这类活我一般用一个 Qt Widgets 上位机加 snap7 直接对上。这个压缩包给的就是这么一套东西:用 snap7 的客户端接口跟 PLC 建 S7 连接,按 DB 块偏移读写,Qt 这边管界面、定时轮询和断线重连。它适合手上已经有装着 Qt 的工控机、想自己做采集程序的开发者,也适合刚接触 PLC 通信、想知道“读一个 DB 号到底发生了什么”的人。代码量不大,但连接参数、字节序、线程模型这几个坑一个不少。
2. 工程结构与环境:从 .pro 到第一次握手成功
拿到压缩包先别急着点运行,先看清楚它把哪几层拆开了。这类项目最容易翻车的地方不是算法,而是依赖放错位置、库位数对不上、连接参数拍脑袋填。我把目录摊开讲一遍,再说编译和第一次连接要改哪几个值。
2.1 目录拆解:通信、界面、配置三块分开
解压后的结构大致是这样,不同人写法会有出入,但骨架基本一致:
PlcComm/ ├── PlcComm.pro # qmake 工程文件 ├── main.cpp ├── plc/ │ ├── plcclient.h/.cpp # snap7 封装,只负责收发 │ ├── plcworker.h/.cpp # 跑在子线程里的轮询器 │ └── plctypes.h # 点位结构体、错误码枚举 ├── ui/ │ ├── mainwindow.h/.cpp │ └── mainwindow.ui ├── config/ │ └── points.json # 点位表,改地址不用重编译 └── third_party/ └── snap7/ ├── include/snap7.h └── lib/ # snap7.lib / libsnap7.soplc/这一层是最值得先读的,它不引用任何界面头文件,意味着可以单独拎出来做单元测试或者塞进控制台程序。config/points.json把 DB 号、起始偏移、数据类型外置,现场调试时改配置比改代码重新编译快得多,这个设计在后期加点位时能省下大量时间。
工程文件里要盯两件事:头文件路径和库的位数。snap7 是原生库,Qt 用的是 MSVC 64 位,就必须链接 64 位的snap7.lib,32 位库混进来会在链接期报一堆符号找不到。
# PlcComm.pro 关键片段 QT += core gui widgets INCLUDEPATH += $$PWD/third_party/snap7/include DEPENDPATH += $$PWD/third_party/snap7/include win32 { # 注意位数要和 Qt Kit 对齐,MSVC64 配 snap7 的 x64 库 LIBS += -L$$PWD/third_party/snap7/lib/win64 -lsnap7 } unix:!macx { LIBS += -L$$PWD/third_party/snap7/lib/linux -lsnap7 } SOURCES += main.cpp \ plc/plcclient.cpp \ plc/plcworker.cpp \ ui/mainwindow.cppINCLUDEPATH决定编译器能不能找到snap7.h,LIBS决定链接器能不能找到实现。Windows 下如果运行时提示缺 DLL,把snap7.dll拷到可执行文件同目录,或者加一条QMAKE_POST_LINK自动拷贝,别指望它自己找得到。
2.2 编译与首次连接:四个参数先填对
编译本身没什么花样,Qt Creator 打开.pro,选好 Kit,构建。真正卡人的是第一次连接,四个参数必须对:
| 参数 | 典型值 | 说明 |
|---|---|---|
| ip | 192.168.0.10 | PLC 的以太网口地址,工控机要在同网段 |
| rack | 0 | 机架号,一体式 CPU 基本都是 0 |
| slot | 1 | 槽号,S7-1200/1500 常填 1,S7-300 常见 2 |
| port | 102 | S7 协议默认端口,一般不动 |
先 ping 通再谈连接,ping 不通就别在代码里找原因了。ping 通了但连接返回非 0,九成是 rack/slot 填错,或者 PLC 侧没放开对外访问权限。这部分在第五章展开,这里先记住一个判断顺序:网络 → 端口 → rack/slot → PLC 侧权限。
main.cpp里做的事很少,把主窗口拉起来就行:
#include "ui/mainwindow.h" #include <QApplication> int main(int argc, char *argv[]) { QApplication a(argc, argv); // 高分屏工控机上不开这个,界面字会糊成一团 QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); MainWindow w; w.show(); return a.exec(); }AA_EnableHighDpiScaling这行在普通桌面项目里可有可无,在工控机上很关键——现场屏幕分辨率五花八门,不开缩放,点位表格会挤成一列看不全。
3. 通信核心链路:一次 DBRead 调用走过的四层
很多人以为 snap7 就是个“读 PLC 的函数”,调一下就有数。实际一次读取要穿过连接管理、超时控制、分块策略、错误映射四层,任何一层偷懒,现场都会以“偶尔读不到”的形式还回来。这一章按调用顺序拆。
3.1 连接与超时:把 client 当稀缺资源管
snap7 的TS7Client不是线程安全的,一个实例同时只能被一个线程用。我一般一个采集线程持有一个实例,不共享、不池化,简单粗暴但不出事。
// plc/plcclient.h #pragma once #include <QObject> #include <QByteArray> #include "snap7.h" class PlcClient : public QObject { Q_OBJECT public: explicit PlcClient(QObject *parent = nullptr); ~PlcClient() override; bool connectTo(const QString &ip, int rack, int slot); void disconnectFromPlc(); bool isConnected() const; bool readDb(int dbNumber, int start, int size, QByteArray &out); bool writeDb(int dbNumber, int start, const QByteArray &data); signals: void errorOccurred(int code, const QString &text); private: TS7Client *m_client = nullptr; };头文件只暴露bool和QByteArray,把 snap7 的类型挡在实现里,后期换库或者加一层模拟器,界面代码一行都不用动。
// plc/plcclient.cpp bool PlcClient::connectTo(const QString &ip, int rack, int slot) { if (m_client) { delete m_client; m_client = nullptr; } m_client = new TS7Client(); int rc = m_client->ConnectTo(ip.toUtf8().constData(), rack, slot); if (rc != 0) { // CltErrorText 把数字错误码翻成人话,日志里直接能看 emit errorOccurred(rc, QString::fromUtf8(CltErrorText(rc))); delete m_client; m_client = nullptr; return false; } // 超时一定要设,默认值在弱网现场能把你挂到怀疑人生 m_client->SetRecvTimeout(2000); m_client->SetSendTimeout(2000); return true; }ConnectTo的第二个、第三个参数就是 rack 和 slot,传错立刻返回错误码,不会静默失败。SetRecvTimeout单位是毫秒,设 2000 意味着最坏情况下这次调用阻塞两秒;如果这段代码跑在 UI 线程,界面就冻两秒,这是后面要单独开线程的直接原因。错误码别只写进qDebug,接到信号里显示到状态栏,现场排查时能省一次远程。
3.2 分块读与写回:别一次性怼几千字节
DB 读取有两个硬约束:单次长度有上限,且一次失败你无法定位是哪个偏移出的问题。我的做法是默认小块读,需要整块时再拼。
bool PlcClient::readDb(int dbNumber, int start, int size, QByteArray &out) { if (!m_client || size <= 0) return false; out.resize(size); int rc = m_client->DBRead(dbNumber, start, size, reinterpret_cast<void *>(out.data())); if (rc != 0) { out.clear(); emit errorOccurred(rc, QString::fromUtf8(CltErrorText(rc))); return false; } return true; }DBRead(db号, 起始字节, 长度, 缓冲区)四个参数,长度单位是字节不是位。返回值 0 表示成功,非 0 一律当失败处理,不要用“数据看着像对的”来自我安慰。
整块读取时按 512 字节切:
bool PlcClient::readDbChunked(int db, int start, int total, QByteArray &out) { const int kChunk = 512; // 小块读,出错能定位到具体偏移 out.clear(); out.reserve(total); for (int offset = 0; offset < total; offset += kChunk) { int len = qMin(kChunk, total - offset); QByteArray part(len, Qt::Uninitialized); int rc = m_client->DBRead(db, start + offset, len, part.data()); if (rc != 0) { emit errorOccurred(rc, QString("DB%1 offset %2").arg(db).arg(start + offset)); out.clear(); return false; } out.append(part); } return true; }分块的好处很实际:日志里能直接看到是DB1 offset 1536读失败了,而不是一句“DB1 读取失败”。写回同理,DBWrite参数顺序一致,但写操作要先做长度校验,写入长度超过 DB 实际长度,PLC 侧会直接拒绝。
3.3 把轮询挪到子线程:界面不卡的前提
QTimer在哪个线程创建就在哪个线程触发。把 worker 对象moveToThread之后,定时器和DBRead都在子线程里跑,主线程只管刷新界面。
// 启动采集线程 m_thread = new QThread(this); m_worker = new PlcWorker(); // worker 内部持有 PlcClient 和 QTimer m_worker->moveToThread(m_thread); connect(m_thread, &QThread::started, m_worker, &PlcWorker::start); connect(m_worker, &PlcWorker::dataReady, this, &MainWindow::onDataReady); connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); m_thread->start();start()里做的事是建连接、起定时器:
void PlcWorker::start() { if (!m_client->connectTo(m_cfg.ip, m_cfg.rack, m_cfg.slot)) { // 首次连不上不要退线程,交给重连定时器 m_reconnectTimer->start(3000); return; } m_pollTimer->start(m_cfg.pollMs); // 常见 100~500ms }dataReady跨线程发出去会自动走队列连接,界面侧拿到的是一份已解析好的结构体,不碰原始字节。轮询周期别贪快,200ms 已经够看板用了,50ms 以下对 PLC 的通信资源是实打实的压力。
4. 数据映射层:DB 偏移、大小端与点位表驱动
拿到QByteArray只是拿到一串字节,真正决定项目好不好维护的是映射层。硬编码data[4]这种写法,点位一多就是灾难;而字节序搞反,读出来的浮点数会是天文数字——这个坑几乎人人都踩过一次。
4.1 大端解析:Real 和 Int 的正确打开方式
S7 协议在网络传输里是大端序,而 x86 机器是小端,直接memcpy到float变量里必然错。
// plc/plctypes.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 static_cast<qint16>(beU16(p)); } static inline float beReal(const char *p) { // 先按大端拼成 32 位整数,再原样拷进 float,避免类型双关告警 quint32 raw = (quint32(quint8(p[0])) << 24) | (quint32(quint8(p[1])) << 16) | (quint32(quint8(p[2])) << 8) | quint32(quint8(p[3])); float f = 0.0f; memcpy(&f, &raw, sizeof(f)); return f; } static inline bool beBit(const char *p, int bit) { return (quint8(p[0]) >> bit) & 0x01; // bit 取 0~7 }beReal没有用reinterpret_cast<float*>,是因为那样写在不同编译器上行为不一致,memcpy是最稳的写法。beBit对应DBX地址,比如DB1.DBX4.0就是偏移 4 的字节取第 0 位。
把这些函数串成按类型取值:
QVariant PlcClient::valueAt(const QByteArray &buf, int offset, const QString &type, int bit) { if (offset < 0 || offset >= buf.size()) return {}; const char *p = buf.constData() + offset; if (type == "bool") return beBit(p, bit); if (type == "int") return beI16(p); if (type == "dint") return qint32((quint32(quint8(p[0])) << 24) | (quint32(quint8(p[1])) << 16) | (quint32(quint8(p[2])) << 8) | quint32(quint8(p[3]))); if (type == "real") return beReal(p); return {}; }offset越界判断不能省。DB 实际长度和配置表对不上时,这个判断能把越界访问挡在崩溃之前,日志里还能打印是哪个点位配置错了。
4.2 JSON 点位表:改地址不重编译
点位表长这样,字段不多但够用:
{ "plc": { "ip": "192.168.0.10", "rack": 0, "slot": 1, "pollMs": 200 }, "points": [ { "name": "lineSpeed", "db": 1, "start": 0, "type": "real", "desc": "线速度" }, { "name": "goodCount", "db": 1, "start": 4, "type": "dint", "desc": "良品计数" }, { "name": "motorOn", "db": 1, "start": 8, "type": "bool", "bit": 0, "desc": "主机运行" }, { "name": "alarmWord", "db": 1, "start": 10, "type": "int", "desc": "报警字" } ] }解析时按 DB 号聚合,同一个 DB 只读一次,别一个点位发一次请求:
// 把点位按 db 分组,算出每组要读的 [minStart, maxEnd) QMap<int, QPair<int, int>> ranges; for (const auto &pt : m_points) { auto &r = ranges[pt.db]; if (r.second == 0 && r.first == 0) { r.first = pt.start; r.second = pt.start + 4; } r.first = qMin(r.first, pt.start); r.second = qMax(r.second, pt.start + 4); // 按最大类型长度 4 字节兜底 }这点很关键:10 个点位如果分散在同一个 DB 的连续区间里,一次读取就够,按点位逐个发请求会把轮询周期撑爆。读回来之后再按偏移查表解析,映射成QHash<QString, QVariant>发给界面,界面只认名字不认地址。
5. 避坑与排查:五类现场翻车记录
下面这些不是从文档里抄的,是这类项目在现场最常撞的几堵墙。每条按“现象 → 原因 → 解决”写,照着对号入座就行。
5.1 连接类:连不上和连得上但不通
现象一:ConnectTo返回非 0,错误文本是连接被拒绝。
原因通常是三类:工控机和 PLC 不在同一网段;PLC 侧没开放对外访问权限;rack/slot 填错。有次我按 S7-300 的习惯填了 slot 2,接的却是一体式 CPU,折腾半小时才发现。
解决顺序固定下来:先ping确认网络,再用端口探测确认 102 端口可达,然后核对 rack/slot,最后去 PLC 侧确认访问权限已放开。四步走完还连不上,才怀疑库的位数和运行时 DLL。
现象二:连接成功,但第一次DBRead就失败。
原因多半是 DB 号不存在,或者该 DB 不允许外部访问。有些块默认是优化访问的,按绝对偏移读会直接报错。
解决:在编程软件里确认 DB 号和长度,把目标 DB 的优化块访问关掉再下载。这个改动会重新分配偏移,改完必须重新核对点位表。
5.2 数据类:读到了但数不对
现象三:Real 类型读出来是 0,或者一个巨大的数。
原因就是字节序。S7 是大端,x86 是小端,直接按本机字节序解释 4 个字节,得到的位模式完全对不上。
解决:所有多字节类型统一走beU16/beReal这类函数,禁止在业务代码里直接memcpy到数值变量。改完之后拿一个已知值(比如 123.5)写进 DB 验证一次,别只看“不为 0”就算过。
现象四:Bool 点位永远读成 false。
原因通常是位偏移写错了,或者把DBX4.0记成了偏移 0 的第 4 位。位地址是“字节.位”,字节在前。
解决:确认配置里start是字节偏移、bit是 0~7 的位号。调试时把整个字节按二进制打出来,一眼就能看出是哪一位在动。
5.3 运行期:跑着跑着就不对了
现象五:界面隔一段时间卡一下,日志里没有异常。
原因是同步读取跑在了 UI 线程,一次超时两秒,界面就冻两秒。网络越差越明显,看着像“偶发卡顿”,其实每次都卡,只是平时快得没被注意。
解决:把PlcClient和QTimer一起moveToThread,界面只接收解析好的结果。改完之后用定时器打点,确认主线程没有被超过 50ms 的阻塞。
现象六:连续跑十几个小时,突然全部读失败,重启程序又好了。
原因是连接被中间网络设备断掉,而代码里没有心跳和重连,m_client还挂着一个已经死掉的句柄。
解决:加一个独立的健康检查定时器,定期读一个固定的小地址;失败次数超过阈值就Disconnect再ConnectTo。重连要有退避,3 秒、5 秒、10 秒递增,别死循环猛冲,否则 PLC 的连接资源会被占满,反而谁都连不上。
6. 二次开发:把通信层做成可替换的采集服务
这套代码真正值得改的地方不是界面,是通信层的边界。原始版本里PlcWorker直接持有PlcClient,看起来简单,但想加一个“模拟数据源”做离线演示,就得改 worker,改完还得改回去。我的做法是先抽接口:
| 扩展点 | 改动位置 | 用途 |
|---|---|---|
| 数据源接口 | IPlcSource抽象类 | 真机、模拟器、回放文件三选一 |
| 点位表 | points.json | 加点位不改代码 |
| 写操作 | PlcClient::writeDb | 参数下发、配方切换 |
| 数据缓存 | worker 内部环形缓冲 | 断线时界面显示最后有效值 |
接口定成三个方法就够:
class IPlcSource { public: virtual ~IPlcSource() = default; virtual bool open(const PlcConfig &cfg) = 0; virtual bool read(const QList<PointDef> &pts, QHash<QString, QVariant> &out) = 0; virtual void close() = 0; };真机实现内部还是那套ConnectTo+ 分块DBRead,模拟器实现则按点位表随机造数。界面只依赖接口,切换数据源在配置文件里改一个字段,连重新编译都不用。
加写操作时有个血泪经验:写之前一定先读回当前值做对比,再写,写完再读一次确认。PLC 侧对写入长度和地址对齐有要求,写超长会整包被拒,而错误码不会告诉你“是长度超了”还是“地址不对”。我一般把写操作封装成“读-改-写”三步,并且只允许写单个点位,批量写走单独的确认流程。
验证方法也很朴素:先用模拟器把界面和数据流跑通,再切真机;真机上先用一个只有 4 字节的测试 DB 验证读写闭环,确认无误后再挂生产点位表。每次改点位表,我都会强制走一遍“读一次、打印原始字节、人工核对”的流程——这个习惯救过我好几次,有一次是配置表里 DB 号少写了一位数,日志里全是“读取成功但数值为 0”,靠打印原始字节才定位到读的根本不是那个块。希望帮到你。
本文还有配套的精品资源,点击获取