简介:面向工业自动化与C++/MFC学习者,这款基于Microsoft Visual C++ 6.0开发的三菱PLC上位机软件提供完整可运行程序与源工程,旨在帮助理解PLC通信、梯形图逻辑解析及界面设计,支撑二次开发或定制化监控系统。压缩包共60个文件,约731KB,以h头文件、cpp源文件为主,配合dsp/dsw工程文件、ico/cur图标与bmp位图资源、lad梯形图样例、exe可执行程序等,结构清晰,便于直接编译研读。目前已有1282人学习/下载。通过阅读源代码,可掌握VC6.0与MFC在工控软件中的实际应用,包括PLC指令构建、数据读写、调试工具实现等具体技术细节。整个包既是学习样例,也可作为模板快速搭建自己的PLC上位机项目,对从事自动化软件开发的工程师尤其有价值。
1. 这项目是干什么的:一份能直接打开的VC6.0上位机源码,解决工控上位机“从零写起”的痛
如果你的日常工作离不开PLC上位机开发,一定经历过这种场景:项目要得很急,需要快速搭一个能读PLC寄存器、能实时显示点位状态的小工具,结果光是串口收发、协议解析、界面刷新这三件事就够加班到深夜。这份VC6.0上位机完整源码,本身就是一套可直接编译运行的完整工程,不是残缺的Demo,不是只有空壳的框架。它把MELSEC协议族的串口通信、帧解析、批量读写、状态监控、参数配置做成了一个开箱即用的模板,拿到手编译就能跑,跑通就能往上加功能。
适合两类人:一是刚接触工控上位机、想搞明白“上位机到底怎么跟PLC说话”的开发者,这份源码把协议的每个字节怎么拼、每帧怎么校验都摊开了;二是正在赶工期的工程师,与其从头写通信层,不如直接在现有代码上改设备地址、改寄存器范围、换UI布局,省掉最容易被坑的底层调试时间。这份源码就是这个作用:一边回答“协议是什么”,一边直接给你“能跑的代码”。
2. 技术选型与整体架构:MFC文档视图 + 串口封装 + 协议解析三层分离
2.1 技术栈为什么是VC6.0和MFC
很多刚接触这份源码的开发者会先问一句:都什么年代了,为什么还在用VC6.0?这里的原因其实很实际。工控场景里,老设备、老系统的占比不低,很多产线的工控机还在Windows XP甚至更老的嵌入式Windows上跑,而VC6.0编译出来的程序不需要装额外的运行库,部署最简单。MFC的文档/视图架构天然适合做这种“单窗口 + 多面板”的监控工具,CFormView做参数面板、CListView做报警记录、CStatic做指示灯,都比自绘控件省力得多。
这份源码的工程组织方式也是老派的MFC风格,包含标准的App类、Doc类、View类,再加上自己封装的通信类和协议类。它没有用现代分层架构的包名,但类的职责划分是清楚的:界面层不碰串口,串口层不碰协议,协议层只负责拼帧和解帧。整个源码按这个思路拆成几大模块,先对整体有个概念,再往里看代码才不会迷路。
2.2 目录结构与关键类划分
拿到源码解压后,首先看工程目录。主目录下是.dsw和.dsp工程文件,用VC6.0打开.dsw即可加载整个工作区。源码的类文件分布大致如下:
- App与MainFrame:负责程序启动、主窗口框架、工具栏状态栏
- PLCCommView:主监控界面,包含设备列表树、点位状态表格、实时数值显示
- CSerialPort:串口通信封装类,负责打开/关闭串口、读写数据、线程收包
- CMelsecProtocol:MELSEC协议解析类,负责拼装读/写命令帧、校验和计算、响应帧解析
- CPlcDevice:设备对象类,保存一个PLC连接的站号、波特率、寄存器起始地址、点位映射等参数
- ParamDlg:参数设置对话框,配置串口号、站号、寄存器范围、轮询周期
这种“通信类 + 协议类 + 设备对象类”的模式,最大的好处是换PLC型号时不用动界面。比如同一套代码要对接不同协议的PLC,只需要新增一个协议类,保持接口一致,设备类里切换即可。后面二次开发时,大多数人扩展MODBUS协议也是这么干的。
2.3 核心数据流:从界面点击到PLC寄存器
整体工作流程可以理解为一条循环链路。主界面启动后,先通过ParamDlg读取配置参数,初始化串口;然后一个后台定时器(或工作线程)按照设定的轮询周期,周期性调用协议类去组装读命令帧;帧经过串口发送到PLC,PLC返回响应帧后,协议类负责解析出寄存器数值;最后界面线程通过PostMessage把数值更新到表格控件上。写操作则是相反的流程:用户在界面上修改数值,触发写入函数,先拼写命令帧,发送后等待响应,再刷新显示。
这套链路里有个容易被忽视的设计点:界面刷新和串口收发是分离的。串口数据到达后,通信类通过回调或者Windows消息机制通知界面线程,而不是直接在串口线程里操作控件。这样做避免了MFC控件在多线程下死锁的经典问题,也是二次开发时不要轻易打破的一个约定。
3. 核心模块拆解:串口通信与MELSEC协议代码精读
3.1 串口通信层:怎么打开、怎么收、怎么避免丢帧
源码里的CSerialPort封装了Win32串口API,核心逻辑都在CreateFile、ReadFile、WriteFile这几个调用上。打开串口的参数设置很有讲究,直接看代码:
// CSerialPort.cpp 关键片段 BOOL CSerialPort::Open(CString portName, DWORD baudRate, BYTE dataBits, BYTE stopBits, BYTE parity) { m_hCom = CreateFile( portName, // 例如 "COM3",注意Win10以上可能需要 "\\\\.\\COM10" 格式 GENERIC_READ | GENERIC_WRITE, // 读写方式打开 0, // 串口不能共享 NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, // 使用异步IO,避免读卡死 NULL); if (m_hCom == INVALID_HANDLE_VALUE) return FALSE; DCB dcb; GetCommState(m_hCom, &dcb); dcb.BaudRate = baudRate; dcb.ByteSize = dataBits; dcb.Parity = parity; dcb.StopBits = stopBits; SetCommState(m_hCom, &dcb); // 设置超时,这是防止“读操作卡住界面”的关键 COMMTIMEOUTS timeouts = {0}; timeouts.ReadIntervalTimeout = 10; // 两个字节间最大间隔10ms timeouts.ReadTotalTimeoutMultiplier = 0; timeouts.ReadTotalTimeoutConstant = 50; // 单次读最多等50ms SetCommTimeouts(m_hCom, &timeouts); // 创建工作线程 m_threadRun = TRUE; AfxBeginThread(CommThreadProc, this, THREAD_PRIORITY_NORMAL); return TRUE; }这里几个参数值得细看。ReadIntervalTimeout设置为10毫秒,是为了处理PLC响应帧的边界——PLC返回一帧数据时,字节与字节之间间隔很短,如果间隔超过10毫秒就认为一帧已经结束了,可以交给上层解析。ReadTotalTimeoutConstant设置为50毫秒,相当于最坏情况等待时间的兜底。这两个超时值在调试时是首先怀疑的对象,如果现场出现“数据能收到但解析总是半截”的情况,多半是间隔超时设得太短,PLC响应慢导致帧被切断。
接收线程的逻辑是while循环里不断调用ReadFile,读到的数据追加到一个内部缓冲区,再尝试从缓冲区里按帧头、帧尾提取完整数据帧。源码里用了一个简单的环形缓冲区,大小为4096字节,这对于PLC上位机通信足够用了。如果你要扩展成多个PLC并发读取,需要给每个连接建一个独立的CSerialPort实例,并且把缓冲区独立开。
3.2 MELSEC协议帧格式:拼帧前必须看懂的字节布局
整个源码最核心的“硬知识”就在协议类的拼帧和解帧逻辑里。MELSEC协议在串行通信下常用有两种帧格式:一种是ASCII格式,每个字节拆成两个十六进制字符发送,可读性好;另一种是二进制格式,直接发原始字节,效率高。这份源码两种都实现了,通过一个协议格式标志位切换。
看一段读寄存器命令的拼帧代码:
// CMelsecProtocol.cpp 构建读命令帧(二进制格式) BOOL CMelsecProtocol::BuildReadFrame(BYTE* buffer, int* len, BYTE stationNo, WORD startAddr, WORD pointCount) { int idx = 0; buffer[idx++] = 0x10; // STX 帧头,二进制格式固定0x10 buffer[idx++] = stationNo; // 站号(0~31),与PLC参数里的站号一致 buffer[idx++] = 0xFF; // PLC编号,一般固定0xFF buffer[idx++] = 0xFF; // CPU编号,固定0xFF buffer[idx++] = 0x03; // CPU监视定时器长度,单位250ms,可延长 buffer[idx++] = 0x01; // 指令:0x01=位读取,0x00=位写入 buffer[idx++] = 0x00; // 子指令:0x00=按字读取 // 起始地址的拆分:MELSEC协议地址是16位的,分高低字节 buffer[idx++] = (BYTE)(startAddr & 0xFF); // 低字节 buffer[idx++] = (BYTE)((startAddr >> 8) & 0xFF); // 高字节 // 读取点数,二进制格式最大一次读256点 buffer[idx++] = (BYTE)(pointCount & 0xFF); buffer[idx++] = (BYTE)((pointCount >> 8) & 0xFF); buffer[idx++] = 0x03; // ETX 帧尾 // 和校验:从站号到ETX的所有字节累加,取低8位 BYTE sum = 0; for (int i = 0; i < idx; i++) { sum = (BYTE)(sum + buffer[i]); } buffer[idx++] = sum; // SUM校验字节 buffer[idx++] = 0x0D; // CR 回车 buffer[idx++] = 0x0A; // LF 换行 *len = idx; return TRUE; }这段代码就是整个协议的骨架。注意几个关键点:站号必须和PLC侧的通信参数一致,否则PLC根本不会理你这帧;起始地址是16位的,这决定了单个连接最多寻址64K字,如果工程规模超过这个范围,要么分多个站号,要么扩展协议头;读取点数上限是256点(对应二进制格式),一次读超出范围会被PLC直接拒绝。这个限制在实际项目中经常碰到,比如一个D寄存器区有500个字要显示,就不能一次读完,需要在循环里分批读。
协议类的另外一半是解析响应帧。PLC响应帧的格式是:STX + 站号 + PLC编号 + 指令码 + 结束码(2字节)+ 数据 + ETX + SUM + CR + LF。源码里对应一个ParseReadResponse函数,先是校验SUM,然后检查结束码是否为0,不为0直接记日志,等于0才继续取数据。判断结束码这步很重要,很多上位机出现“读到一堆莫名其妙的值”都是因为忽略了结束码,PLC报错了但上位机还在解析帧体。
3.3 寄存器地址映射:D寄存器、M继电器、X输入怎么写地址
源码里有一个地址映射表,这是二次开发时几乎必改的地方。MELSEC协议的地址和PLC编程软件里的软元件编号不是一一对应的,需要查映射表换算。以最常用的几个为例:
- D寄存器(数据寄存器):地址从0x0000开始,D0对应0x0000,D100对应0x0064
- M继电器(内部继电器):地址从0x0100开始,M0对应0x0100
- X输入端子:地址从0x0180开始,X0对应0x0180
- Y输出端子:地址从0x01C0开始,Y0对应0x01C0
这套映射规则写死在协议类的ConvertAddress函数里。你打开源码搜索“0x0100”或者“0x0180”,就能找到这张映射表。实际使用时,界面上让用户填“M100”这样的软元件名,内部转换为协议的地址字后去拼帧。
这里有个常见的翻车点:地址换算时把十进制和十六进制搞混。比如D100,编程软件里显示的是十进制100,但协议地址换算时要先转成0x64,再拆高低字节。如果直接把100当成十六进制0x100去拼帧,实际读取的会是D256。源码里专门写了一个注释强调这个问题,二次开发者也建议保留这个注释在代码里。
3.4 界面显示与刷新:PostMessage刷新模式
界面模块用的是CListView或者自绘的CStatic,负责把寄存器值刷新成十进制、十六进制或二进制显示。刷新不是用Timer直接操作控件,而是发Windows消息:
// PLCCommView.cpp 在串口线程或定时器里投递消息 void CPlcCommView::OnTimer(UINT_PTR nIDEvent) { // 组装点位数据到结构体 PLC_POINT_DATA data; data.addr = currentAddr; data.value = plcDevice.ReadRegister(currentAddr); // PostMessage是非阻塞的,不会卡住通信线程 PostMessage(WM_UPDATE_POINT_VALUE, data.value, data.addr); CView::OnTimer(nIDEvent); } // 界面线程处理刷新消息 LRESULT CPlcCommView::OnUpdatePointValue(WPARAM wParam, LPARAM lParam) { int value = (int)wParam; int addr = (int)lParam; // 找到表格中对应addr的行,更新显示 m_listCtrl.SetItemText(row, 1, FormatValue(value)); return 0; }这种做法的好处是:通信线程只管收发和解析,界面线程只管刷新,两边通过消息解耦。如果你不加这个机制,直接在串口线程里调SetItemText,在VC6的MFC下经常会出现控件闪烁甚至直接死锁的情况。这是一份看似普通的源码里最值得借鉴的工程经验。
4. 编译与运行:VC6.0环境配置和完整跑通步骤
4.1 环境准备:老工程在Win10/11上的兼容处理
拿到源码后,第一件事不是解压,而是确认本机编译环境。如果你还在用VC6.0原版,建议在Windows XP或Windows 7(32位)虚拟机里编译,避免VC6的IDE在Win10下出现文本编辑器输入异常等兼容问题。如果坚持在Win10/11上操作,可以按以下步骤处理:
- 安装VC6.0后,右键VC6.0的MSDEV.EXE图标,属性里选择“兼容性”标签,勾选“以Windows XP SP3兼容模式运行”
- 工程文件.dsw如果双击没反应,用VC6的File > Open Workspace打开,不要用“打开方式”关联
- 编译前确认工程配置是“Win32 Debug”还是“Win32 Release”,建议先编Release版本,运行效率高且不依赖调试运行库
- 如果VC6提示“An error occurred while opening the document”之类的错误,大部分情况是路径有中文或空格,把源码解压到纯英文、纯数字路径下,例如D:\PLC_Demo\,能避开绝大多数坑
4.2 编译参数和链接顺序:哪些配置建议保持默认
VC6工程有几个配置项,建议保持源码默认,不要随意调整。字符集必须是“Not Set”(即MBCS多字节),不要改成Unicode,因为源码里的CString、wsprintf等都是按MBCS写的,强行改Unicode会冒出一堆类型不匹配的编译错误。运行时库选择“Multithreaded (/MT)”而不是“Debug Multithreaded (/MTd)”,这样生成的exe可以直接拷到不带开发环境的工控机上跑。
另外,源码依赖的库只有系统的kernel32、user32、gdi32,没有第三方库,所以不需要配置附加依赖项。如果你在链接时看到“unresolved external symbol _AfxMessageBox”或类似MFC符号无法解析,检查一下工程属性里“Microsoft Foundation Classes”是否选了“Use MFC in a Shared DLL”,同时确认资源的头文件resource.h没有被改名。
4.3 第一次运行:接不上PLC也能验证的两种方式
源码编译通过后,双击exe会先弹出参数设置对话框。在没有实体PLC的情况下,有两种方法验证整个链路是通的。
第一种是串口回环测试。找一根USB转串口线,把TX和RX短接,然后在上位机软件里任意选一个寄存器,点击读取。此时数据从串口发出去后会原样收回来,协议解析时帧头帧尾都不匹配,会触发“接收数据异常”的错误日志。能在调试输出窗口看到日志,至少证明串口通信类和线程调度是正常的。
第二种是用串口调试助手模拟PLC。在电脑上虚拟一对串口(可以使用第三方虚拟串口工具,原理是生成COM1/COM2并桥接),PLC监控程序跑在COM1,上位机软件连COM2。PLC监控程序按照MELSEC协议响应上位机的读命令,手工构造一个响应帧发送回来。这种方式能完整验证协议解析代码的正确性,适合在开发电脑上先把上位机调通,再去现场对接真机。
4.4 修改PLC连接参数的正确位置
对接真实PLC时,需要修改的参数集中在两个地方:对话框界面和配置文件的默认值。程序启动时打开参数设置界面,里面有串口号、波特率、数据位、停止位、校验位、站号、轮询周期这些选项,选择正确后点击确认,程序会把这些参数保存到一个INI配置文件中。下次启动时自动读取INI文件,不需要重新配置。
如果PLC的通信参数和源码默认值不一致,直接在界面上改就行,不需要重新编译代码。唯一的例外是如果PLC连接方式不是串口而是以太网,那就不能靠改INI文件了,需要新写一个以太网协议类,这个在最后一章会展开讲。
5. 避坑指南:VMware里跑生产系统,最容易踩的6个坑
5.1 编译报错“fatal error C1010: unexpected end of file while looking for precompiled header directive”
现象:打开工程直接按F7编译,编译器报C1010错误,提示“在查找预编译头指令时意外遇到文件结尾”。原因:VC6的“预编译头”机制要求每个.cpp文件在开头包含stdafx.h,但工程里有个文件漏了这句。解决:在报错的.cpp文件第一行加上#include "stdafx.h",注意必须是第一行,前面不能有任何注释空行之外的代码。这个报错在从别处拷源码时非常常见,因为文件包含顺序在拷贝过程中被改了。
5.2 程序能启动但串口打不开,返回错误码2
现象:点击连接按钮后,状态栏提示“打开串口失败,错误码2”。原因:错误码2是ERROR_FILE_NOT_FOUND,意思是系统里不存在这个串口号。常见情况是USB转串口设备插入后分配的端口号不是COM1,而是COM5、COM8或者更大的数字。很多工控机装了多路串口卡后,端口号会往后排。解决:打开设备管理器,在“端口(COM和LPT)”里查看实际端口号,回到软件参数设置里选对再连接。另外要留意一个细节:串口号大于9时,CreateFile传参里必须写成“\\.\COM10”格式,源码里已经处理了这个情况,但参数保存时仍然建议用标准格式。
5.3 数据读得回合制:时而通、时而不通,且间隔有规律
现象:读取PLC数据时,第一次点击读取成功,第二次就没反应,第三次又成功,节奏大概1:1。原因:多半是串口缓冲区里有残留数据导致帧错位。第一次读取发出的命令帧没有收到完整响应,残留了半个帧在缓冲区里,第二次读取时协议解析类把那半个残留帧当成响应解析失败,导致读操作被丢弃。解决:在每次发送命令之前,先调用串口类的清空缓冲区函数PurgeComm。源码在BuildReadFrame之前有一行注释“// 清空接收缓冲区,避免上次残留影响本次解析”,确认这行没有被注释掉。如果你的代码丢了这行,补上即可。
5.4 中文注释乱码,但代码逻辑正常
现象:用VC6打开源码,中文字符串和注释显示成乱码,但程序运行正常。原因:源码文件的编码是GBK/GB2312,而你的VC6安装在简体中文系统上时应该默认正常,出现乱码是因为文件被其他工具(比如某些下载工具或Git)自动转成UTF-8了。解决:不要用记事本打开并保存源码文件,记事本保存会改变编码。推荐用支持GBK的编辑器查看,或者把文件转回GB2312编码再编译。
5.5 运行库冲突:Release编译后在陌生电脑上启动报错“没有找到MFC42.DLL”
现象:把编译好的exe拷到一台没装过VC6的电脑上,双击出现“由于应用程序配置不正确,应用程序未能启动”或“没有找到MFC42.DLL”。原因:程序是动态链接MFC库的,目标机器上缺MFC运行库。解决:在工程设置里把“Use MFC in a Shared DLL”改成“Use MFC in a Static Library”,重新编译。这样exe体积会变大不少,但换来的好处是单文件可直接运行,不用在工控机上装运行库。生产环境的部署建议,一律用静态编译版本。
5.6 地址映射差一倍:界面显示D100,PLC里读出来却是D200或完全乱码
现象:界面配置读取D100,实际PLC返回的是D200的值,或者读出的数值完全不符合业务含义。原因:地址换算错了,协议地址的步进和软元件编号的步进不一致。D寄存器是按字寻址的,换一个寄存器类型(比如X输入)是位寻址,位的地址是按字节步进的。解决:不要凭感觉换算地址,用协议手册里的地址映射表逐条核对。源码ConvertAddress函数的注释里已经列出了常用映射,改代码时把这块当成硬性遵循的标准即可。
6. 二次开发:把固定协议改成可扩展的多协议框架
拿到这份源码之后,最值得做的一件事,是把它从“单一MELSEC协议的可用工程”重构成“多协议可扩展的上位机通信框架”。我在前面提到过,协议解析类CMelsecProtocol的职责很纯粹,对外暴露的接口就几个:BuildReadFrame、ParseReadResponse、BuildWriteFrame。如果新接入的设备协议不同,不需要动界面和通信层,只要新增一个协议类并统一接口。下面给出改造思路和关键代码。
第一步是定义一个通用协议接口,让设备和协议解耦。常见做法是抽象一个虚基类:
// IProtocol.h 定义一个统一的协议接口 class IProtocol { public: virtual ~IProtocol() {} virtual BOOL BuildReadFrame(BYTE* buffer, int* len, WORD startAddr, WORD count) = 0; virtual BOOL ParseReadResponse(BYTE* buffer, int len, WORD* values, int* valueCount) = 0; virtual BOOL BuildWriteFrame(BYTE* buffer, int* len, WORD startAddr, WORD count, WORD* values) = 0; virtual CString GetProtocolName() = 0; };CMelsecProtocol改为继承IProtocol,原有函数签名稍作调整即可。仿照它实现一个CModbusRTUProtocol,把Modbus的03功能码读保持寄存器、06功能码写单寄存器的帧格式填进去。要注意的是Modbus的CRC16校验算法和MELSEC的SUM累加算法完全不同,这部分要单独写一个CRC16函数,网上有标准查表法实现,直接移植即可。
第二步是在设备类CPlcDevice里增加一个协议指针成员,界面层通过下拉框动态创建协议对象。这一步改完之后,PLC型号选择就能做成配置化的了,新增设备时不需要重新编译主程序,只需要在配置文件里注册协议名称和参数即可。我在自己项目里实践过这个改造,从两份不兼容协议扩展到四种协议,界面层代码改动量不超过30行。
第三步是加一个数据记录模块。这套源码原本没有历史数据存储功能,但上位机监控一般都会要求保存曲线或者报表。改造方法不复杂:在读取成功的位置追加一条记录函数,把时间戳、设备号、寄存器地址、数值写入数据库。对于工控场景,推荐用SQLite或者轻量级CSV文件即可,不建议上重型数据库。写数据库的IO操作不要放在通信线程里,用一个生产者消费者队列来缓冲。
最后说一个我从这份源码里学到最深的教训:任何上位机代码,协议解析部分一定要打日志,而且日志要带时间戳和十六进制原始数据。我最初改造时偷懒没加日志,现场对接一台新设备时,响应帧错位后根本看不清是哪里拼错了,来回调了整整一天才定位到是一个字节的偏移量算错。从那以后,我每次写协议类都会强制走一遍完整的数据流,从拼帧、发送、接收、解析到显示,每个环节都加一行调试日志,把原始帧和数据值同时打出来。这个习惯救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取