ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

三菱PLC上位机VC6.0源码解析:MFC串口通信与MC协议实战

三菱PLC上位机VC6.0源码解析:MFC串口通信与MC协议实战

简介:基于Visual C++ 6.0与MFC框架开发的三菱PLC上位机编程软件v1.0,附带完整源代码,适用于工业自动化领域开发者、PLC工程师,以及希望学习VC6.0工控编程的C++程序员。该资源可帮助使用者掌握三菱PLC通信协议封装、程序读写、梯形图逻辑调试和上位机界面设计等核心技能。压缩包共60个文件,涵盖14个h头文件、13个cpp实现文件,以及图标、光标、位图等界面资源,同时包含VC6.0工程文件、可执行调试程序与PLC示例文档,整体仅731KB,结构清晰。目前已有1282人学习下载。通过研读源码,能够理解MFC文档视图架构在工控软件中的应用,并对通信模块、指令解析、梯形图编辑与监控界面等功能模块进行拆解学习;在此基础上,还可直接以工程为模板,快速定制满足特定需求的PLC监控或控制软件,是一份兼顾原理与实战的工业上位机开发实践素材。

1. 拿到一套三菱PLC的完整上位机源码,先别急着编译

如果你做过产线设备对接,一定遇到过这种场景:一台老设备是三菱FX系列PLC,上位机软件丢了,或者换了电脑需要重新部署,手头只有一份VC6.0写的上位机源代码。很多开发者第一反应是装VC6、点编译、跑起来——然后大概率卡在串口连不上、界面没反应、数据读不出来这些坑里。这套三菱PLC上位机编程软件VC6.0源代码完整版v1.0,解决的就是这个问题:它是一套完整可编译的Windows上位机源码,基于MFC文档视图架构,串口通信与三菱PLC编程口/MC协议对接,能实现PLC寄存器读写、线圈监控、运行状态显示等基本功能。适合两类人:一是要做工业设备上位机开发的工程师,想找一份能直接改的通信框架;二是接手老旧产线项目,需要快速读懂原有代码逻辑的维护者。

2. 整体架构与关键技术选型:MFC框架下的上位机通信骨架

2.1 源码目录结构与模块划分

拿到压缩包解压后,第一件事是看目录结构。这套源码是典型的VC6.0 MFC工程布局,根目录下包含.dsw工作区文件和.dsp工程文件,这是VC6特有的项目组织方式。源码按功能模块拆成几个核心文件:MainFrm.cpp负责主窗口框架,对应程序的主界面;PLCComm.cpp这一层封装了串口通信和协议封装,是整份源码里最值得读的部分;MonitorView.cpp负责监控界面的视图绘制和数据刷新;DlgParam.cpp是参数设置对话框,用于配置串口号、波特率、PLC站号这些通信参数。

从工程组织方式看,这是一个单文档视图架构(SDI),主窗口左侧是设备状态树,右侧是数据监控视图。这种设计在工业上位机里很常见:菜单栏和工具栏承担操作入口,视图区承担实时数据显示,状态栏显示通信连接状态。代码里还带了几个对话框资源,用于PLC地址配置和手动读写操作。

2.2 通信层设计:从串口到PLC数据的完整链路

这套上位机的通信链路分为三层:界面层发起读写请求,通信层封装协议帧,串口层负责字节收发。界面层不直接操作串口,而是调用PLCComm类提供的接口,比如ReadDRegister(addr, count)或WriteCoil(addr, value),这样上层逻辑不关心协议细节,换协议只需要改PLCComm内部实现。

通信层的核心是一个串口封装类,VC6时代最常用的方案是直接用C++对Windows API的CreateFile、ReadFile、WriteFile进行封装,也有用ActiveX控件如MSComm的。这份源码采用的是API封装方式,好处是不依赖第三方控件,部署时不需要注册OCX。串口参数在DlgParam对话框里配置,写入后保存到注册表,程序启动时自动读回,这种设计避免了每次运行都重新配置参数的问题。

协议层面,源码实现了三菱FX系列编程口协议和MC协议的一部分。这两种协议的主要差异在于帧格式:编程口协议使用RS-422电平,通过SCSI命令帧读写PLC软元件;MC协议则更通用,支持串口和以太网两种载体,帧结构里有完整的子头和网络编号。这套源码以串口MC协议为主,所以核心是处理ASCII码格式的指令帧。

2.3 为什么选择轮询刷新而不是事件驱动

监控界面的数据刷新选择了定时器轮询方式,而不是事件驱动。原因很简单:三菱PLC的串口通信是请求-响应模式,上位机发一条指令,PLC返回一条响应,没有主动推送机制。所以无论用哪种方式,最终都要靠上位机主动周期性地发送读指令来获取数据。源码里在视图类里创建了一个定时器,定时周期默认500毫秒,到点后依次发送各监控地址的读请求,收到响应后更新界面显示。

这种设计逻辑清晰,但有一个隐蔽的问题:串口是半双工模式,一轮轮询内只能有一个未完成的请求。如果第一个请求还没收到响应就发第二个,两个帧会在串口线上撞车,导致通信错乱。源码里通过一个信号量或忙标志位来处理这个问题:上一帧没处理完,下一帧直接丢弃,等下一个定时周期再发。

// PLCComm.h 核心接口定义 class CPLCComm { public: BOOL OpenPort(CString strPort, DWORD dwBaudRate, BYTE byDataBits, BYTE byStopBits, BYTE byParity); void ClosePort(); BOOL ReadDRegister(int nAddr, int nCount, WORD* pData); BOOL WriteDRegister(int nAddr, int nValue); BOOL ReadCoil(int nAddr, BOOL* pStatus); BOOL WriteCoil(int nAddr, BOOL bStatus); BOOL IsOpened(); private: BOOL SendAndWaitResponse(BYTE* pSendBuf, int nSendLen, BYTE* pRecvBuf, int* pRecvLen, DWORD dwTimeout); BYTE CalcCS(BYTE* pBuf, int nLen); // 校验和计算 HANDLE m_hCom; // 串口句柄 CRITICAL_SECTION m_csComm; // 串口访问临界区 BOOL m_bOpened; };

这个类的设计意图很明确:OpenPort负责建立串口连接,包括设置波特率、数据位、停止位、校验位,并创建接收线程;ReadDRegister和WriteDRegister是上层最常用的两个接口,对应MELSEC协议的D寄存器读写;SendAndWaitResponse是私有方法,内部实现完整的一发一收交互并带超时控制。m_csComm临界区保证了多线程环境下串口操作不冲突——接收线程在读取数据时,发送请求的线程不能同时写入串口。

// SendAndWaitResponse 实现:发送指令帧并等待PLC响应 BOOL CPLCComm::SendAndWaitResponse(BYTE* pSendBuf, int nSendLen, BYTE* pRecvBuf, int* pRecvLen, DWORD dwTimeout) { BOOL bRet = FALSE; if (!m_bOpened || pSendBuf == NULL || pRecvBuf == NULL) return FALSE; EnterCriticalSection(&m_csComm); // 清空接收缓冲区,避免读到上一帧残留数据 PurgeComm(m_hCom, PURGE_RXCLEAR | PURGE_TXCLEAR); DWORD dwWritten = 0; WriteFile(m_hCom, pSendBuf, nSendLen, &dwWritten, NULL); // 等待接收数据,按总超时时间轮询 DWORD dwStart = GetTickCount(); DWORD dwTotalRecv = 0; while (GetTickCount() - dwStart < dwTimeout) { DWORD dwAvail = 0; COMSTAT comStat; ClearCommError(m_hCom, NULL, &comStat); dwAvail = comStat.cbInQue; if (dwAvail > 0) { DWORD dwRead = 0; ReadFile(m_hCom, pRecvBuf + dwTotalRecv, dwAvail, &dwRead, NULL); dwTotalRecv += dwRead; // 判断是否收到完整的帧(这里需要协议层判断帧尾或长度) if (dwTotalRecv >= 5 && IsCompleteFrame(pRecvBuf, dwTotalRecv)) { bRet = TRUE; break; } } Sleep(10); } *pRecvLen = (int)dwTotalRecv; LeaveCriticalSection(&m_csComm); return bRet; }

这里的实现有一个值得学习的地方:接收数据不是ReadFile一次就完事,而是通过ClearCommError查询输入缓冲区字节数,然后循环读取直到满足完整帧条件或超时。这是因为串口数据是分片到达的,PLC的响应帧可能拆成多段到达,一次ReadFile可能只读到半个帧。IsCompleteFrame函数是协议层的一个重要接口,负责判断缓冲区里是否已经组装出一帧完整数据,判断依据是帧头、长度字段或帧尾标志。发送前用PurgeComm清空缓冲区,可以避免上一次通信的残留数据被误读为本次响应。

// 组装一条读D寄存器的MC协议指令帧(ASCII模式) BOOL CPLCComm::ReadDRegister(int nAddr, int nCount, WORD* pData) { if (!m_bOpened || pData == NULL) return FALSE; BYTE bySend[64]; int nLen = 0; // MC协议ASCII帧:帧头 + 子头 + 网络号 + 站号 + 指令 + 起始地址 + 读取点数 + 校验和 bySend[nLen++] = 0x05; // ENQ 帧头,请求帧 bySend[nLen++] = '0'; // 子头,ASCII模式固定为30 bySend[nLen++] = 'F'; // 网络编号(ASCII十六进制,0x0F = 255) bySend[nLen++] = 'F'; bySend[nLen++] = '0'; // 站号 bySend[nLen++] = '0'; bySend[nLen++] = '0'; // PLC编号 bySend[nLen++] = '0'; bySend[nLen++] = '0'; // 模块编号 bySend[nLen++] = '0'; bySend[nLen++] = '0'; // 请求数据长度(高字节先发) bySend[nLen++] = '0'; bySend[nLen++] = '0'; bySend[nLen++] = '0'; bySend[nLen++] = 'B'; // 请求数据长度 = 0x000B(11字节) bySend[nLen++] = '1'; // 指令:读字 bySend[nLen++] = '4'; // 0x0144 批量读取字单元 // 起始地址格式:软元件类型 + 地址号 // D寄存器类型码为 0xA8(PLC侧),地址转为16进制ASCII char szAddr[5]; sprintf(szAddr, "%04X", nAddr); // 地址号16进制 bySend[nLen++] = 'D'; // 软元件类型 bySend[nLen++] = '0'; bySend[nLen++] = '0'; bySend[nLen++] = szAddr[0]; bySend[nLen++] = szAddr[1]; bySend[nLen++] = szAddr[2]; bySend[nLen++] = szAddr[3]; bySend[nLen++] = 'A'; // 字软元件标识 bySend[nLen++] = '8'; // 读取点数转ASCII char szCnt[5]; sprintf(szCnt, "%04X", nCount); bySend[nLen++] = szCnt[0]; bySend[nLen++] = szCnt[1]; bySend[nLen++] = szCnt[2]; bySend[nLen++] = szCnt[3]; // 发送前扫描请求帧,计算校验和 BYTE byCS = CalcCS(bySend, nLen); bySend[nLen++] = byCS; bySend[nLen++] = 0x0D; // CR,帧结束符 bySend[nLen++] = 0x0A; // LF BYTE byRecv[128]; int nRecvLen = 0; if (!SendAndWaitResponse(bySend, nLen, byRecv, &nRecvLen, 1000)) return FALSE; // 解析响应帧,提取寄存器值 // 响应帧格式:ACK + 子头 + 网络号 + 站号 + 数据长度 + 结束代码 + 数据区 if (byRecv[0] != 0x06) // ACK 应答帧 return FALSE; // 数据区在响应帧的固定偏移位置,每个寄存器值占4个ASCII字符 for (int i = 0; i < nCount; i++) { int nOffset = 11 + i * 4; char szVal[5] = { byRecv[nOffset], byRecv[nOffset+1], byRecv[nOffset+2], byRecv[nOffset+3], 0 }; pData[i] = (WORD)strtol(szVal, NULL, 16); } return TRUE; }

这段代码是一份非常标准的MC协议ASCII读取实现。有几个细节值得注意。

第一个是帧头用的0x05,这是ENQ字符,表示这是上位机发起的请求帧。PLC正常响应时返回0x06(ACK),异常时返回0x15(NAK)。在接收解析时要区分这两种情况,NAK响应通常代表指令格式错误或地址越界,源码里没有细分错误码,只返回FALSE,实际调试时可以把byRecv缓冲区打出来看PLC返回的具体错误码。

第二个是地址格式的编排。MC协议里软元件类型和地址号分开表示,D寄存器用0xA8类型码,在ASCII模式下写成两个字符"A8"。地址号虽然也是十六进制,但和类型码之间没有分隔符,拼接时要非常小心。我见过不少改这个协议的开发者把地址高低字节写反,导致读到的数据永远是0或者乱值,排查方法是用串口调试助手抓取实际发出的帧,和协议文档逐字节比对。

第三个是校验和算法。CalcCS函数对所有发送字节做累加和,取低8位,转换成两个十六进制ASCII字符。注意校验和的计算范围是ENQ之后、校验和之前的所有字节,包括子头、网络号、长度、指令和数据。在接收端同样需要校验响应的校验和,源码里省略了这一步,建议保留,因为工业现场串口的电气干扰并不罕见。

3. 监控界面的实现:定时刷新、地址表与报警显示

3.1 监控视图的刷新机制

MonitorView类是整个程序的界面核心,它持有的数据结构是一个监控地址表,每一项包含软元件类型(D寄存器或M线圈)、起始地址、读取长度、当前值和上一次值。界面上用列表控件展示这个地址表,每一行显示一个地址的名称、数值和状态。刷新逻辑放在OnTimer里,定时器触发后,遍历地址表,对每一项调用PLCComm对应的读取接口,拿到结果后更新列表控件。

一个容易踩坑的地方是界面的数据更新必须在UI线程完成,但不能因为通信阻塞卡死界面。这套源码的处理方式是:OnTimer函数里只发起读取请求和更新已返回的数据,如果PLCComm读取超时,直接跳过本次刷新,不阻塞后续地址的读取。这样一台通信异常时,其他数据还能继续刷新。

那如果PLC响应很慢,一个地址要等1000毫秒超时,整个轮询周期就拉长了。这就要求超时时间设置得合理:太短会频繁误判通信失败,太长会让界面看起来像卡死。源码里通信超时是1000毫秒,轮询周期是500毫秒,我建议改成读取超时300毫秒或更长一点,轮询周期自适应调节,比如连续三次读取失败就把轮询周期拉长到2000毫秒,成功后再缩短回500毫秒。

3.2 参数对话框与配置持久化

参数对话框承担两个职责:串口参数配置和PLC通信参数配置。串口参数就是最常见的波特率(9600/19200/38400)、数据位(默认7位或8位)、校验位(偶校验/无校验)、停止位。三菱PLC编程口默认参数是9600波特率、8数据位、偶校验、1停止位,而MC协议走串口时通常用9600、8数据位、无校验、1停止位。这套源码默认不带编程口协议完整实现,所以默认配置按MC协议来。

配置持久化用注册表实现,在对话框的OnOK里写注册表,在MainFrm的OnCreate或InitInstance里读注册表。写注册表的键值包括:串口号、波特率、数据位、校验位、停止位、站号。这里有一个小技巧:把注册表路径固定为软件名+版本号,避免不同版本的程序配置互相覆盖。

// 注册表读写配置参数的标准做法 void CDlgParam::SaveConfig() { CWinApp* pApp = AfxGetApp(); // 注册表主键由框架管理,这里保存子键 pApp->WriteProfileInt(_T("PLC"), _T("PortNo"), m_nPortNo); pApp->WriteProfileInt(_T("PLC"), _T("BaudRate"), m_nBaudRate); pApp->WriteProfileString(_T("PLC"), _T("PortName"), m_strPortName); pApp->WriteProfileInt(_T("PLC"), _T("StationNo"), m_nStationNo); pApp->WriteProfileInt(_T("PLC"), _T("Timeout"), m_nTimeout); } void CDlgParam::LoadConfig() { CWinApp* pApp = AfxGetApp(); m_nPortNo = pApp->GetProfileInt(_T("PLC"), _T("PortNo"), 1); m_nBaudRate = pApp->GetProfileInt(_T("PLC"), _T("BaudRate"), 9600); m_strPortName = pApp->GetProfileString(_T("PLC"), _T("PortName"), _T("COM1")); m_nStationNo = pApp->GetProfileInt(_T("PLC"), _T("StationNo"), 0); m_nTimeout = pApp->GetProfileInt(_T("PLC"), _T("Timeout"), 1000); }

用GetProfileInt和WriteProfileInt来读写注册表,是VC6时代MFC程序最标准的做法。好处是框架自动处理好注册表路径,不用手动操作注册表API。第一个参数是主键,第二个是子键名,第三个是默认值。GetProfileInt在键不存在时返回默认值,所以升级版本时新增配置项不会报错。写成两个独立函数的好处是可以在调试时单独调用LoadConfig来验证配置是否正确读取。

3.3 手动读写与在线监控

程序除了自动轮询监控,还提供手动读写功能。菜单栏里有"手动操作"选项,弹出一个对话框,输入要读写的软元件类型、地址、值,执行一次性的读写操作。在通信调试阶段这个功能很重要:不需要在监控地址表里加一堆临时地址,随手就能验证某个地址的读写是否正确。我一般拿到源码后会先用手动读写功能测试串口通信,通了再配置监控地址表,这样能把问题定位在协议层还是界面层。

在线监控功能还有一个"启动/停止"开关。启动时定时器执行轮询读取,停止时暂停定时器,界面停留在最后一帧数据。这个开关逻辑简单,但很实用——当你需要点击界面上的其他操作按钮而不希望数据一直刷新干扰鼠标操作时,暂停刷新能减少干扰。

4. 编译运行与PLC对接:从代码到设备

4.1 VC6工程在Windows 10/11上运行的兼容性问题

VC6.0是2000年前后的产物,在Windows 10/11上编译运行有已知的兼容性问题。最典型的两个:一个是IDE可能在启动时报错,提示"无法定位程序输入点"之类,需要以管理员身份运行或设置兼容模式;另一个是编译后生成的exe在系统里可能被提示缺少MFC42.DLL或MSVCRT.DLL。

这套源码有一个值得借鉴的处理:在工程设置里选择了静态链接MFC,而不是动态链接DLL。这样一来生成的exe不依赖系统装有MFC运行库,直接拷到任何Windows系统上都能跑。如果拿到手的工程是动态链接的,可以在Project Settings → General → Microsoft Foundation Classes里改成"Use MFC in a Static Library",重新编译即可。静态链接的缺点是exe体积变大,单个文件大约1.5MB到2MB,但对工控机这种不频繁更新的环境来说,稳定性和部署简洁性比体积重要得多。

另外,VC6在Windows 10上调试时,断点可能无法命中,或者程序一运行就弹出"0xC0000005"内存访问异常。这通常是编译器版本对较新系统API兼容性不足导致的,常见的解决办法是加一个兼容性清单或在代码里显式调用SetProcessDEPPolicy(L"DEP_Disable")来关闭数据执行保护。不过,这个做法会降低系统安全性,只在调试阶段临时使用。

4.2 串口接线与协议匹配

代码层面编译通过之后,真正的挑战在于和PLC设备对接。先说物理连接:这套源码默认走串口MC协议,PC需要有一个真正的串口或USB转串口适配器,连接到PLC的编程口。三菱FX系列PLC的编程口是Mini-DIN 8针圆形接口,接线需要按针脚定义连接,常见的错误是把发送和接收接反,导致上位机发的指令PLC根本收不到。

再说协议匹配。三菱PLC上位机对接有两种常用协议:编程口协议和MC协议。编程口协议是FX系列专用,走RS-422,帧格式和MC协议完全不同,指令集也不同。这套源码实现的是MC协议ASCII模式,适用于FX系列(需PLC侧设置)和Q系列部分型号。如果你的PLC是FX3U且只支持编程口协议,直接套用这套源码的通信层是连不上的,需要把帧格式换成编程口专用格式。

我有一个简洁的判断方法:打开PLC编程软件(某三菱品牌专用配置工具),在PLC参数里找"通信设置"或"协议设置",看当前有没有开放MC协议的串口通道。如果PLC侧只配置了编程口协议,那上位机无论如何也连不通,需要先在PLC侧改配置或加一个支持MC协议的通信模块。

4.3 用虚拟串口和PLC仿真器预调试

在没有真实PLC的情况下,强烈建议先用虚拟串口工具加PLC仿真器把程序的通信链路跑通。做法是创建一对虚拟串口(COM1和COM2),程序打开COM1,仿真器打开COM2。仿真器按照MC协议响应来自COM2的请求帧,这样就能在完全脱离硬件的情况下验证上位机的帧封装和解析逻辑。

需要注意,PLC仿真器和真实PLC的响应时序可能不同:仿真器响应几乎是实时的,真实PLC存在扫描周期和通信处理延迟,响应时间随负载变化。在仿真器上调通的超时参数,到现场可能需要重新调整。但至少能确认协议帧组帧正确、解析逻辑无误,省去了现场抓帧排查的麻烦。

这套源码自带的一套测试方法值得一提:在PLCComm类里加一个调试模式,把发送帧和接收帧以十六进制字符串格式写入日志文件。开着日志连一次PLC,回来打开日志文件看记录,协议帧每个字节对得上,通信就通了大半。

5. 实战避坑:三菱PLC上位机开发最常见的五个翻车现场

5.1 串口打不开,程序直接崩溃

现象:运行程序后点击"打开通信",提示串口打开失败,或者程序直接弹窗报错退出。

原因:这套源码的OpenPort函数内部直接调CreateFile打开串口,如果指定的串口不存在或被其他程序占用,CreateFile返回INVALID_HANDLE_VALUE,而源码里没有对返回值做充分校验,后续代码拿无效句柄继续操作,触发异常。

解决:给OpenPort增加充分的失败处理逻辑。打开失败时弹窗提示具体原因,同时把串口号下拉框的选项列出来,让用户自己选存在的串口。还可以在打开前用QueryDosDevice枚举系统现有串口,把不存在的串口从下拉框里过滤掉。

// 枚举系统可用串口 void EnumAvailablePorts(CComboBox* pCombo) { pCombo->ResetContent(); for (int i = 1; i <= 32; i++) { CString strPort; strPort.Format(_T("COM%d"), i); // 尝试打开串口,能打开说明存在 HANDLE hTest = CreateFile(strPort, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hTest != INVALID_HANDLE_VALUE) { pCombo->AddString(strPort); CloseHandle(hTest); } } }

参数说明:这个函数尝试打开COM1到COM32,能成功打开就把串口号加入下拉框。注意CreateFile打开串口时,共享模式必须设为0,这是串口设备区别于普通文件的关键点,如果不传0而传FILE_SHARE_READ,后续其他程序就无法访问这个串口。

5.2 读数据全是零或固定乱码

现象:串口连接正常,程序不报错,但读回来的D寄存器数值始终是0,或者显示成完全无法理解的大数。

原因:这通常是两个因素叠加造成的。一是协议帧的地址编码错误,比如D100的地址号写成十六进制后高位和低位交换;二是校验和计算错误,PLC收到校验不合法的帧,直接返回NAK,程序收到NAK后按正常解析流程走,读出来自然是垃圾数据。

解决:用串口调试助手(常见串口调试工具)打开同一个串口,手动发一帧自己拼好的读指令,观察PLC返回的原始字节。对照协议文档逐字节核对。重点检查地址字段的软元件类型码和地址号排列,以及校验和的范围是否正确。可以写一段日志代码把发送帧和接收帧都打印出来,对比成功的样例帧和失败的样例帧,差异一眼就能看出来。

5.3 程序运行时界面卡死

现象:打开通信后,界面每隔一秒卡顿一次,点击菜单栏按钮要等很久才有反应。

原因:串口通信的读取操作同步执行在主线程。具体说,OnTimer触发读取,读取函数内部进入SendAndWaitResponse循环,循环里是Sleep(10)等待响应,在等待期间UI线程无法处理窗口消息,用户的所有操作都排队。PLC响应慢或丢帧时,超时等待时间会叠加,卡顿感更明显。

解决:把通信读取移到一个独立的工作线程中,工作线程循环执行读取操作,读取完成后通过PostMessage通知UI线程更新界面。UI线程只负责收到通知后刷新列表控件,完全不碰串口。这套源码里临界区的设计就是为了支撑这个改造——工作线程进入临界区保护串口操作,UI线程用PostMessage异步接收结果,不需要跨线程直接访问共享缓冲。

5.4 换了电脑连不上PLC

现象:程序在公司开发机上正常,部署到产线工控机上后,串口打不开或连不上PLC。

原因:产线工控机上的串口可能不是COM1或COM2,而是USB转串口映射出来的COM5、COM8之类。程序配置里默认串口号是COM1,参数配置没有重新设置。更隐蔽的问题是USB转串口芯片驱动没有安装,系统里根本没有串口设备节点。

解决:先用设备管理器确认工控机上串口号是多少,在程序的参数对话框里改成对应COM口。如果是USB转串口,确认驱动装好后,在设备管理器里把串口号固定下来——比如始终固定为COM3,避免每次插拔USB后串口号漂移,程序中配置的参数失效。

5.5 PLC返回异常,程序不提示

现象:PLC侧程序切换了运行模式,或者通信被其他设备占用,上位机界面没有任何提示,数据停留在最后一次成功的值,看起来一切正常。

原因:源码里的读取接口返回TRUE/FALSE,但界面刷新逻辑没有对返回状态做处理。读取失败时直接跳过更新,界面保留旧值,操作人员无法区分"数据正常"和"通信已中断"。

解决:在界面增加一个通信状态标志。读取成功时更新数据并置状态为"在线";连续失败达到N次时,把状态行改为"通信断开",并将所有监控项显示为灰色。建议用红色或黄色背景闪烁表示警告。代码里可以加一个通信状态结构体,记录连续成功次数、连续失败次数、最后一次通信时间,在状态栏里显示这些信息。

6. 二次开发:把这套源码改造成你自己的跨设备采集程序

拿到这套源码后,最常见的二次开发需求是:把只支持三菱PLC串口通信的上位机,扩展成支持多设备、多协议的采集平台。这里的改造思路是:保持界面层和通信层分层结构不变,把PLCComm类抽取成通信接口,新增设备只需要实现同一个接口,界面层代码几乎不用动。

第一步是定义统一的设备通信接口,包含连接、断开、读取数据点、写入数据点、获取设备状态这五个核心方法。把PLCComm改造实现这个接口,再为其他设备写对应的实现类。

// 抽象设备通信接口 class IDeviceComm { public: virtual BOOL Connect(CString strConfig) = 0; virtual void Disconnect() = 0; virtual BOOL IsConnected() = 0; virtual BOOL ReadDataPoint(CString strAddress, CString& strValue) = 0; virtual BOOL WriteDataPoint(CString strAddress, CString strValue) = 0; virtual CString GetDeviceStatus() = 0; };

接口设计的核心是标准化入参。ReadDataPoint里的strAddress是一个自定义的地址字符串,格式为"类型:地址号",比如"D100"读取D寄存器100号,"M50"读取M线圈50号。每个设备实现类自己解析这个字符串并转换成对应协议的真实地址字节,这样界面层不需要知道各类设备协议的地址格式差异。

第二步是改造界面层的地址表。原版地址表的结构是固定的软元件类型加地址号,改成通用结构后,增加一个"设备编号"字段。监控界面的每一行对应一台设备的一个数据点,刷新时先根据设备编号找到对应的IDeviceComm实例,再调用ReadDataPoint。

第三步是处理混合协议的轮询调度。如果同时连接一台三菱PLC和一台某品牌仪表设备,两台设备的响应速度完全不同,同一个轮询周期可能不合适。我的做法是给每台设备单独设置轮询频率,比如PLC每秒刷新一次,仪表设备每五秒刷新一次。定时器仍然统一触发一个调度函数,调度函数根据每台设备的"上次刷新时间"和"轮询周期"决定本周期是否发起请求。

// 按设备轮询周期调度通信请求 void CMonitorView::OnTimerRefresh() { // 遍历所有设备实例 for (int i = 0; i < m_nDeviceCount; i++) { if (!m_devices[i].IsConnected()) continue; // 判断是否到达该设备的轮询周期 DWORD dwNow = GetTickCount(); if (dwNow - m_devices[i].GetLastPollTime() >= m_devices[i].GetPollInterval()) { // 发起该设备所有数据点的读取请求 m_devices[i].PollAllPoints(); m_devices[i].UpdateLastPollTime(dwNow); } } }

这种调度方式有一条潜在风险:串口是共享资源,多个设备不能同时在同一个串口上通信。如果有两台设备挂同一个串口(比如通过RS485总线并联),必须在调度函数里加一个互斥锁,确保任何时刻只有一台设备在收发数据。如果只有一台PLC,直接沿用源码里的单线程轮询方式即可,不需要这个调度框架。

实话说,把通信层从具体协议中抽离出来,短时间内未必能省多少开发量,但如果你的项目长期会接入多种设备,这个改造的收益会在后续每一次新增设备时体现出来。每新增一种设备,只需要写一个实现类,做协议层转换,界面、调度、日志、报警这些框架代码完全复用。这套源码的价值,更多在于它把MFC界面框架和PLC通信基础都打好了,你只需要专注于自己业务相关的部分。

如果你需要这份完整的三菱PLC上位机编程软件VC6.0源代码完整版v1.0源码包,直接在项目资源下载页里获取压缩包,按文章里的步骤编译运行即可。从那以后,我每次在接触一个新上位机项目时,都会先确认设备侧协议重配一遍模拟环境再开工,这个习惯帮我省了不少生产现场的加班时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进