1. 为什么还要折腾 snap7 与 MFC 这套组合
工业上位机这个圈子有个很拧巴的现实:新项目都在喊 OPC UA、MQTT、RESTful,但真到了车间现场,你面对的大概率还是一台跑了十来年的西门子 S7-1200 或者 S7-300,网口就在那,协议就是 S7 通信。这时候你想用 C++ 写个带界面的采集工具,绕不开的一个库就是snap7。它是个开源的 S7 协议通信库,跨平台,Windows 上给的是静态库加动态库,接口是纯 C 风格,稳定、轻量、不依赖一堆运行时。
而 MFC 呢,很多人一听就皱眉,觉得是上个时代的产物。但你在工业现场待久了会发现,MFC 做上位机反而有它的道理:编译出来是原生 exe,不依赖 .NET 运行时,拷到工控机上双击就能跑;对话框资源编辑器拖控件快,跟 Win32 API 贴得近,串口、网口、多线程这些底层操作顺手。所以“snap7 + MFC”这个组合,在中小型数据采集、设备监控类项目里,至今仍然是一套非常务实的方案。
这篇笔记就是把我自己在实际项目里踩过的坑、验证过的写法整理出来。核心解决三件事:怎么把 snap7 正确集成进 MFC 工程、怎么用后台线程稳定读写 PLC 而不卡界面、怎么处理连接断开、数据错位这些现场高频故障。适合已经会一点 C++、能看懂 MFC 对话框工程、但第一次接触 S7 通信的开发者;如果你已经用过 snap7 但总在稳定性上翻车,里面的线程模型和排查表也值得对照看看。
需要先说明一点:下面涉及的具体 IP、机架号、数据块编号都是示例,实际项目里要按你的设备组态来填。所有代码基于 snap7 官方库的通用接口,不涉及任何特定厂商的私有封装。
2. 整体方案设计与选型思路拆解
2.1 为什么是 snap7 而不是别的通信方式
先把这个前提讲清楚,不然容易走弯路。跟 S7 系列 PLC 通信,常见路子有这么几条:走 OPC 服务器中转、用西门子官方提供的通信库、或者直接基于 S7 协议自己实现。OPC 方案要多装一个服务器软件,工控机上多一个常驻进程,出问题时排查链路变长;官方库往往绑定特定开发环境,授权和部署都麻烦。snap7 的好处是它把 S7 协议封装成了几十个 C 函数,你链接一个库文件就能直接跟 PLC 的存储区对话,中间没有额外进程,部署就是拷一个 dll 过去。
从架构上看,snap7 支持两种角色:客户端(Client)主动去读写 PLC,服务端(Server)模拟一个 PLC 让别人来读。上位机采集场景里,我们用的基本都是Client 模式。它内部维护一条 TCP 连接,你调用Cli_Connect建立会话,之后每次Cli_ReadArea/Cli_WriteArea就是在这条连接上收发数据包。理解这一点很关键:连接是有状态的,断了之后所有读写都会失败,必须重连,这也是后面稳定性处理的核心。
2.2 MFC 工程里怎么摆放 snap7
集成方式我试过两种,最后固定用第二种。第一种是把 snap7 的源码直接拖进工程编译,好处是调试能跟进库内部,坏处是它有一堆平台相关的宏,混在 MFC 的预编译头里容易打架,编译告警一堆。第二种是用官方预编译好的 lib + dll,工程里只包含头文件、链接 lib、运行时带上 dll。这种方式干净,升级库也方便,我推荐这个。
具体到文件层面,你需要三样东西:snap7.h(头文件,声明所有接口)、snap7.lib(导入库,链接用)、snap7.dll(运行时动态库)。注意 32 位和 64 位要跟你的 MFC 工程配置一致,混用会直接链接报错。MFC 工程默认可能是 32 位,而现在的工控机基本都是 64 位系统,这里要提前想好:如果你的工程还要调用别的 32 位老库,那就统一 32 位;如果没这个包袱,建议直接上 64 位。
2.3 线程模型:为什么绝不能在 UI 线程里读写
这是新手最容易犯的错,也是界面卡死的元凶。S7 通信是网络操作,一次读写从几十毫秒到几秒不等,如果 PLC 响应慢或者网络抖动,你在按钮响应函数里直接调Cli_ReadArea,整个界面就冻住了,用户点什么都没反应,Windows 还会给你标个“未响应”。所以正确的做法是:UI 线程只管界面,通信放在独立的工作线程里跑。
我的做法是开一个专门的采集线程,用AfxBeginThread或者std::thread都行,线程里循环执行“读数据 → 通过自定义消息 PostMessage 给主窗口 → 主窗口更新控件”。这里有个细节:工作线程绝对不能直接操作 MFC 控件,MFC 的控件对象不是线程安全的,跨线程直接调用轻则显示错乱,重则崩溃。必须用PostMessage把数据指针传回 UI 线程,让 UI 线程自己去更新。这个规矩记住,能省掉你后面一大半的诡异 bug。
3. 核心细节解析与实操要点
3.1 连接参数的几个关键字段
Cli_Connect之前要填一个TS7Client结构,里面几个字段必须跟 PLC 组态对上,错一个就连不上。我列个表,把每个字段的含义和常见取值说清楚。
| 字段 | 含义 | 常见取值 | 填错的表现 |
|---|---|---|---|
| Address | PLC 的 IP 地址 | 按现场实际 | 连接超时 |
| Rack | 机架号 | S7-1200/1500 通常为 0 | 连接被拒绝 |
| Slot | 槽号 | S7-1200 通常为 1,S7-300 常为 2 | 连接被拒绝 |
| LocalTSAP | 本地端点 | 一般留空自动分配 | 少见问题 |
| RemoteTSAP | 远端端点 | 一般留空,由 Rack/Slot 推导 | 连接被拒绝 |
Rack 和 Slot 这两个是最容易搞混的。S7-1200 和 S7-1500 的 CPU 是集成式的,槽号一般是 1;而 S7-300 这种模块化的,CPU 通常插在 2 号槽。如果你连不上又确认 IP 没问题,八成是这两个值填错了。实测下来,先试 Rack=0、Slot=1,不行再试 Rack=0、Slot=2,基本能覆盖大部分机型。
3.2 读写区域的地址换算
snap7 读写用的是“区域 + 起始字节 + 字节数”的方式,区域常量有S7AreaPE(输入)、S7AreaPA(输出)、S7AreaMK(位存储区)、S7AreaDB(数据块)。这里有个坑:DB 块的编号是单独传的,不是拼在地址里的。比如你要读 DB100 从第 0 字节开始的 10 个字节,调用是这样的:
byte buffer[10]; int result = Cli_ReadArea(client, S7AreaDB, 100, 0, 10, S7WLByte, buffer);参数依次是:客户端句柄、区域类型、DB 编号、起始字节、读取长度、数据单位、接收缓冲区。S7WLByte表示按字节读,如果要读位或者字,还有S7WLBit、S7WLWord等。新手常犯的错是把 DB 编号当成起始地址的一部分,写成Cli_ReadArea(client, S7AreaDB, 0, 100, 10, ...),结果读出来全是零或者直接报错。
3.3 返回值必须逐个判断
snap7 的函数几乎都返回一个int,0 表示成功,非 0 是错误码。我见过太多人写完Cli_ReadArea(...)就不管返回值了,然后数据不对就到处找原因。每一个通信调用后面都要判断返回值,失败时用Cli_ErrorText把错误码转成文字,打到日志里。这一步在调试期能帮你省下大量时间。
int res = Cli_ReadArea(client, S7AreaDB, 100, 0, 10, S7WLByte, buffer); if (res != 0) { char errText[256]; Cli_ErrorText(res, errText, sizeof(errText)); // 把 errText 写进日志 }提示:错误码里最常见的是连接类错误(比如 0x00010000 系列),一旦出现这类错误,说明连接已经不可用了,不要继续重试读写,应该走重连流程。
3.4 数据字节序的处理
S7 的数据是大端序(高字节在前),而 x86 的 PC 是小端序。如果你读的是一个 16 位整数,直接memcpy到short里,数值是反的。处理方式有两种:一是读回来之后手动交换高低字节,二是用 snap7 提供的S7_GetWord、S7_GetDWord这类辅助函数,它们内部帮你做了字节序转换。我一般用后者,省心。但要注意,如果你读的是一整块结构体,里面字段类型混杂,那就得自己按偏移量逐个字段用辅助函数取值,不能整块强转。
4. 实操过程与核心环节实现
4.1 工程配置:从零把 snap7 接进来
假设你已经有一个 MFC 对话框工程,下面按顺序操作。第一步,把snap7.h放到工程目录或者一个 include 目录下,在stdafx.h或者主对话框的 cpp 里#include "snap7.h"。第二步,把snap7.lib放到工程目录,在项目属性 → 链接器 → 输入 → 附加依赖项里加上snap7.lib,同时在链接器 → 常规 → 附加库目录里加上 lib 所在路径。第三步,把snap7.dll拷到你的输出目录(Debug/Release 下),或者系统目录,保证运行时能找到。
这里有个细节:Debug 和 Release 用的 lib 可能不一样,有些库会区分调试版和发布版。如果链接时报“无法解析的外部符号”,先检查是不是把 Release 的 lib 用在了 Debug 配置里。另外,如果你的工程开了“使用 Unicode 字符集”,而 snap7 的接口是 ANSI 的,传字符串参数时要注意转换,不过 snap7 的接口里字符串参数很少,主要是错误文本,用char数组接收就行。
4.2 建立连接与断线重连的完整逻辑
连接不是一次性的,要封装成一个可以反复调用的函数。我的做法是维护一个连接状态标志,采集线程每次循环先检查状态,没连上就尝试连,连上了才读写。
bool ConnectPLC(TS7Client* client, const char* ip, int rack, int slot) { if (Cli_GetConnected(client)) return true; // 已经连着就别重复连 int res = Cli_ConnectTo(client, ip, rack, slot); if (res == 0) { // 连接成功,可以设置一些超时参数 Cli_SetConnectionParams(client, 0, 0); // 用默认本地端点 return true; } return false; }重连的节奏要控制好,不能失败了一次就疯狂重试,那样会把 CPU 占满。我的经验是失败后 sleep 一到两秒再试,连续失败多次可以适当拉长间隔。另外,Cli_GetConnected这个函数判断的是库内部记录的连接状态,有时候网络断了但库还不知道,所以更可靠的做法是以读写操作的返回值为准:一旦读写返回连接类错误,就主动Cli_Disconnect再重连。
4.3 采集线程的骨架写法
线程函数是整个采集逻辑的心脏,我把它拆成“连接 → 读 → 投递 → 睡一会”四步循环。下面是一个简化但可直接参考的骨架。
UINT CollectThread(LPVOID pParam) { CMainDlg* pDlg = (CMainDlg*)pParam; TS7Client* client = new TS7Client(); byte buffer[64]; while (pDlg->m_bRunning) { if (!ConnectPLC(client, "192.168.0.1", 0, 1)) { Sleep(2000); continue; } int res = Cli_ReadArea(client, S7AreaDB, 100, 0, 64, S7WLByte, buffer); if (res == 0) { // 把数据拷到堆上,PostMessage 传指针给 UI 线程 byte* pData = new byte[64]; memcpy(pData, buffer, 64); pDlg->PostMessage(WM_DATA_READY, (WPARAM)pData, 0); } else { // 读失败,断开重连 Cli_Disconnect(client); } Sleep(200); // 采集周期,按需调整 } delete client; return 0; }这里有几个要点。第一,buffer是栈上的,PostMessage 之后函数就返回了,所以必须new一块堆内存把数据拷过去,UI 线程处理完再delete,否则就是悬空指针。第二,Sleep(200)是采集周期,200 毫秒就是每秒 5 次,你要根据实际需求调,别设太小把 PLC 通信资源占满。第三,循环条件m_bRunning是个成员变量,窗口关闭时置 false,线程就能自然退出,避免程序关了线程还在跑。
4.4 UI 线程接收数据并刷新界面
主对话框里要加一个消息处理函数,响应自定义消息WM_DATA_READY。在这个函数里把传过来的字节数组解析成实际数值,更新到编辑框或者列表控件上。
LRESULT CMainDlg::OnDataReady(WPARAM wParam, LPARAM lParam) { byte* pData = (byte*)wParam; // 假设前两个字节是一个 16 位整数 short value = S7_GetWord(pData, 0); CString str; str.Format(_T("%d"), value); SetDlgItemText(IDC_EDIT_VALUE, str); delete[] pData; // 记得释放 return 0; }消息映射别忘了在BEGIN_MESSAGE_MAP里加ON_MESSAGE(WM_DATA_READY, OnDataReady)。这个模式跑通之后,你要加多少个采集点,无非就是在缓冲区里多解析几个偏移量的事,框架不用动。
5. 常见问题与排查技巧实录
5.1 连接类问题速查
连接不上是最高频的问题,我整理了一张排查表,按顺序过一遍基本能定位。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 连接超时 | IP 不通 | 先用 ping 确认网络可达 |
| 连接被拒绝 | Rack/Slot 填错 | 换 Slot=1 或 2 再试 |
| 连接被拒绝 | PLC 未允许 PUT/GET | 检查 PLC 组态里的通信访问设置 |
| 时连时断 | 网线或交换机问题 | 换线、换端口测试 |
| 连上后立刻断 | 连接数超限 | 确认没有别的程序占着连接 |
其中“PLC 未允许 PUT/GET”这一条特别隐蔽。有些 PLC 出厂默认关闭了通过通信访问数据的功能,你在上位机这边怎么调都连不上,必须去 PLC 的硬件组态里把对应选项打开。这个坑我在项目里遇到过两次,第一次查了大半天。
5.2 数据读出来不对怎么办
数据不对分几种情况。如果读出来全是零,先确认 DB 块编号和起始地址对不对,再确认 PLC 里那个地址是不是真的有数据。如果读出来数值明显偏大或偏小,多半是字节序问题,用S7_GetWord这类函数重新取值。如果读出来偶尔对偶尔错,那可能是采集周期太快,上一次读还没返回你就发了下一次,或者缓冲区被覆盖了,检查线程里有没有对同一块 buffer 并发操作。
还有一种情况是读的字节数超过了 DB 块实际大小,snap7 会返回错误,但错误码不一定直观。这时候把读取长度调小一点试试,能读通就说明是越界了。
5.3 界面卡顿与内存泄漏
界面卡顿基本就是通信跑在了 UI 线程,或者 PostMessage 发得太频繁。前者把通信挪到工作线程,后者把采集周期调大,或者做批量更新,别每读一个点就刷一次界面。内存泄漏则多半是new了数据没delete,尤其是 PostMessage 传指针这种模式,UI 线程处理完一定要释放。我建议在调试期用任务管理器盯着进程的内存占用,如果持续上涨,基本就是泄漏。
注意:工作线程退出前要确保所有 PostMessage 出去的内存都被处理了,否则窗口销毁后消息还在队列里,处理函数访问已销毁的控件会崩溃。稳妥做法是窗口关闭时先置停止标志,等线程真正退出后再销毁窗口。
5.4 几个我踩过的坑
第一个坑是在对话框构造函数里就启动线程,这时候窗口还没创建完,PostMessage 过去控件还不存在,直接崩。正确时机是在OnInitDialog里启动。第二个坑是忘记处理线程退出,程序关了但线程还在跑,任务管理器里进程残留。第三个坑是把 snap7 的客户端对象定义成局部变量,函数一返回对象就析构了,连接也跟着断,必须定义成成员变量或者堆对象,生命周期覆盖整个采集过程。
6. 关于稳定性的一点个人经验
这套 snap7 加 MFC 的方案,我在几个中小型采集项目里都用过,跑几个月不重启是能做到的,但前提是把重连逻辑和线程模型写扎实。我的体会是,工业上位机这东西,功能实现只占三成,剩下七成都在处理异常:网络会抖、PLC 会重启、工控机会休眠,你的程序得能扛住这些。所以别嫌重连代码啰嗦,别嫌返回值判断烦,这些恰恰是现场稳定运行的保障。
另外提一句,snap7 的官方文档虽然不算厚,但每个函数的参数说明都值得仔细看一遍,尤其是错误码那部分。很多问题其实文档里写了,只是我们习惯性地跳过。真遇到卡壳的时候,把错误码转成文字打出来,往往一眼就能看出方向。