简介:这份资源面向具备C++与MFC基础、希望进入工业自动化上位机开发领域的程序员,提供一套基于VC6.0编写、用于三菱PLC编程与调试的完整上位机软件工程,包含可执行程序与全部源代码,可用于理解通信原理、进行二次开发或作为定制化监控软件的模板。压缩包共60个文件,约731KB,以14个h头文件与13个cpp源文件为核心,配合ico、cur、bmp等界面资源,以及dsp、dsw工程文件和rc资源脚本,完整呈现主程序、通信模块与用户界面等模块的组织方式。目前已有1282人学习下载。读者可通过阅读源码掌握VC6.0与MFC下的工控软件架构,了解PLC指令的解析与构建、设备读写调试流程及界面设计思路,并借助示例工程文件对照实践,为开发PLC监控或控制类项目积累可复用的代码基础与排错经验。
1. 三菱PLC上位机源码拆包:一份VC6.0工程能拿来做什么
手里拿到一份标着“三菱PLC上位机编程软件VC6.0源代码完整版”的压缩包,第一反应往往不是兴奋,而是犹豫——这玩意儿是能直接编译跑起来的完整工程,还是一堆散落的.cpp文件?我拆过不少这类老工程,结论是:它更像一份“可运行的通信与梯形图编辑骨架”,而不是一个开箱即用的商业软件。核心价值在于串口通信、协议帧拼装、梯形图指令表编辑这几块,VC6.0的MFC框架把界面和逻辑揉在一起,读起来费劲,但改起来有迹可循。适合谁?手上有三菱FX系列或类似走串行协议的PLC、想自己写个轻量上位机做监控或批量下装程序的开发者;也适合想研究早期工控软件怎么组织通信线程和指令解析的人。别指望它替代官方编程工具,把它当成一个能跑通的通信Demo加编辑器原型,心态就对了。
2. 工程结构与技术栈:MFC单文档骨架下的通信与编辑双主线
2.1 目录与模块划分
这类VC6.0工控工程通常是一个标准的MFC单文档(SDI)或对话框工程,.dsw/.dsp是工作区和项目文件,打开后能看到App类、MainFrm、Doc、View四件套。真正的业务代码集中在几个自定义类里,我按功能把它们归成三块:
| 模块 | 典型文件/类 | 职责 |
|---|---|---|
| 通信层 | CSerialPort / CCommThread | 串口打开关闭、收发字节、超时处理 |
| 协议层 | CPlcProtocol / CMelsecFrame | 帧头帧尾拼装、校验和计算、指令编码解码 |
| 编辑与显示 | CLadderView / CInstructionTable | 梯形图元件绘制、指令表增删改、下装数据组织 |
工程里往往还有一个资源文件(.rc)承载对话框和菜单,串口参数配置、连接状态、指令编辑窗口都挂在这里。VC6.0的ClassWizard痕迹很重,消息映射宏(ON_COMMAND、ON_MESSAGE)满天飞,读代码时先顺着消息入口找,比从头啃类声明快得多。
2.2 技术栈与运行环境
技术栈很朴素:C++ + MFC + Win32串口API(CreateFile/ReadFile/WriteFile或CSerialPort封装),没有第三方通信库,也没有数据库。这意味着它不依赖运行时环境,编译出来就是单个exe加几个dll(MFC动态链接的话)。运行环境要求也低,Windows XP到Win10 32位都能跑,64位系统需要确认工程是否用了32位编译配置。
编译前要确认两件事:一是VC6.0的MFC库是否完整安装(典型安装可能缺静态库),二是工程属性里的字符集——老工程默认多字节,别手贱改成Unicode,否则一堆CString转换报错。我一般先不改任何代码,直接Build,看报错集中在哪,再决定是补依赖还是调配置。
# 典型的VC6命令行编译入口(在工程目录下) msdev PlcHost.dsw /MAKE "PlcHost - Win32 Release" /REBUILD这条命令走的是VC6自带的msdev命令行,/MAKE后面跟配置名,/REBUILD强制全量重编。如果报“cannot open include file”,多半是VC6的Include路径没配全,去Tools→Options→Directories里补上VC98\Include和VC98\MFC\Include。
2.3 通信线程的启动链路
上位机跟PLC打交道,最怕界面卡死,所以这类工程基本都会把串口读写放到工作线程里。典型做法是在View或Doc初始化时创建线程,线程函数里循环读串口缓冲区,读到完整帧就PostMessage回主线程刷新界面。
// 通信线程入口(示意,基于工程常见写法) UINT CCommThread::ThreadProc(LPVOID pParam) { CCommThread* pThis = (CCommThread*)pParam; BYTE buf[1024]; DWORD dwRead = 0; while (pThis->m_bRunning) { // 非阻塞读,超时设短一点避免线程挂死 if (pThis->m_serial.Read(buf, sizeof(buf), &dwRead, 50) && dwRead > 0) { pThis->m_recvBuf.Append(buf, dwRead); // 按协议帧长切分,凑够一帧就投递消息 while (pThis->m_recvBuf.HasCompleteFrame()) { CFrame frame = pThis->m_recvBuf.PopFrame(); ::PostMessage(pThis->m_hWnd, WM_PLC_FRAME, 0, (LPARAM)new CFrame(frame)); } } ::Sleep(10); // 让出CPU,别写成死循环空转 } return 0; }这段代码的关键点有三个:Read的超时参数给50毫秒,既不会长时间阻塞线程,也不会因为太短频繁空转;接收缓冲区用追加再切帧的方式,避免一次读半帧就解析出错;PostMessage传的是堆上new出来的帧对象,主线程处理完要delete,否则内存泄漏。参数上,m_bRunning是线程退出标志,界面关闭时置false再WaitForSingleObject等线程结束,别直接TerminateThread,那是血泪教训。
2.4 协议帧的拼装与校验
三菱FX系列常用的串行协议是ASCII或二进制格式,帧结构一般是:起始符 + 站号 + 指令 + 数据 + 校验 + 结束符。工程里通常有一个BuildFrame函数负责拼装,一个CalcChecksum负责累加和校验。
// 拼装一条读软元件指令(ASCII格式示意) CString CPlcProtocol::BuildReadFrame(int station, const CString& device, int count) { CString body; body.Format(_T("%02dFF%s%04d"), station, device, count); // 站号+指令+软元件+点数 BYTE sum = 0; for (int i = 0; i < body.GetLength(); i++) sum += (BYTE)body[i]; // 累加和校验 CString frame; frame.Format(_T("\x02%s%02X\x03"), body, sum); // STX + 正文 + 校验 + ETX return frame; }逻辑说明:station是PLC站号,多机通信时用来区分;device是软元件地址字符串,比如“D100”;count是读取点数。校验用的是逐字节累加取低8位,这是三菱协议里最常见的做法。参数改的时候注意两点:站号范围通常是0到15,超出PLC不响应;软元件地址的位数要跟PLC型号匹配,FX系列D寄存器一般四位数字,写错位数帧就废了。发出去之后等回复,回复帧的校验要重新算一遍再比对,别偷懒跳过。
3. 从编译到连上PLC:一份可复现的跑通流程
3.1 编译配置与依赖检查
拿到工程先别急着改代码,按顺序做三件事。第一,用VC6打开.dsw,看左侧FileView里有没有缺失的文件(显示为红色问号),缺了就说明压缩包不完整或者路径不对。第二,检查Project→Settings→Link里的库依赖,老工程常链ws2_32.lib或直接调Win32 API,串口部分一般不需要额外库。第三,确认C/C++选项卡里的Preprocessor definitions有没有_AFXDLL,这决定MFC是动态还是静态链接。
# 检查工程里引用的绝对路径(老工程常见坑) findstr /s /i "C:\\" *.dsp *.h *.cpp这条命令扫一遍工程文件和源码里写死的绝对路径。VC6工程经常把Include目录写成作者本机的路径,换台机器就编不过。扫出来之后统一改成相对路径,或者直接在Project Settings里重设。
3.2 串口参数配置与连接
编译通过后运行,界面上一般有串口选择下拉框、波特率、数据位、停止位、校验位几个控件。三菱FX系列默认通信参数常见是9600波特率、7数据位、偶校验、1停止位(具体以PLC侧设置为准,别照抄)。连接流程是:选COM口→设参数→点连接→打开串口→发一条测试帧→收到正确回复才算通。
// 打开串口的典型调用(Win32 API方式) HANDLE hComm = CreateFile(_T("COM3"), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hComm == INVALID_HANDLE_VALUE) { AfxMessageBox(_T("串口打开失败,检查是否被占用")); return FALSE; } DCB dcb = {0}; dcb.DCBlength = sizeof(DCB); GetCommState(hComm, &dcb); dcb.BaudRate = 9600; dcb.ByteSize = 7; dcb.Parity = EVENPARITY; dcb.StopBits = ONESTOPBIT; SetCommState(hComm, &dcb);参数说明:CreateFile的第一个参数是COM口名,COM10以上要写成“\\.\COM10”;第三个参数0表示独占打开,别的程序占着就打不开;DCB结构里ByteSize和Parity必须跟PLC侧一致,不一致的表现是发得出去收不回来,或者收到乱码。打开失败先查设备管理器里端口号,再查有没有别的串口工具占着。
3.3 指令编辑与下装验证
编辑区一般分两块:梯形图视图和指令表视图。梯形图是画元件和连线,指令表是LD/OUT/AND这类助记符。改完指令后点“下装”,程序会把指令表按协议编码成一串写指令帧,逐条或批量发给PLC。验证是否成功,最直接的办法是下装后读回程序区比对,或者让PLC跑一个简单逻辑看输出动作。
提示:第一次连PLC建议先只做“读”操作,确认通信链路稳定后再尝试“写”。写错地址或数据可能改掉PLC里原有程序,手上有备份再动手。
下装流程里容易忽略的是帧间隔。PLC处理指令需要时间,连续发帧之间要加延时(常见20到50毫秒),否则PLC会丢帧或返回忙。工程里如果没做这个延时,自己在下装循环里补一个Sleep。
4. 核心代码拆解:梯形图编辑与指令编码这条链路
4.1 梯形图元件的绘制逻辑
MFC里画梯形图,基本是自绘控件或者直接在View的OnDraw里用GDI画线画矩形。每个元件(常开触点、线圈、定时器)对应一个结构体,存类型、位置、操作数。绘制时遍历元件链表,按类型调不同的绘制函数。
// 元件结构体与绘制入口(示意) struct LadderElement { int nType; // 0常开 1常闭 2线圈 3定时器 CString strOperand; // 操作数,如 "X0" "Y10" "T0 K50" CPoint ptPos; // 网格坐标 }; void CLadderView::OnDraw(CDC* pDC) { for (int i = 0; i < m_arrElements.GetSize(); i++) { LadderElement* p = (LadderElement*)m_arrElements[i]; switch (p->nType) { case 0: DrawNormallyOpen(pDC, p); break; case 1: DrawNormallyClosed(pDC, p); break; case 2: DrawCoil(pDC, p); break; } pDC->TextOut(p->ptPos.x, p->ptPos.y - 16, p->strOperand); } }这段代码的重点不在绘制本身,而在数据结构的设计。元件用链表或CArray存,每个元件带网格坐标,这样拖动、删除、序列化都方便。参数上ptPos是网格坐标不是像素坐标,绘制时再乘网格大小,改缩放比例不用动数据。我见过把像素坐标直接存进元件的写法,换个分辨率全乱套,这是典型的翻车点。
4.2 指令表的生成与解析
梯形图转指令表是编辑器的核心。常开触点转LD/AND,常闭转LDI/ANI,线圈转OUT,定时器转OUT T。转换时按从左到右、从上到下的顺序遍历,遇到并联分支要处理栈操作(MPS/MRD/MPP)。简化版工程往往只支持串联和简单并联,复杂分支要么不支持要么转换出错。
// 单个元件转指令(简化版,只处理串联) CString CLadderDoc::ElementToInstruction(LadderElement* p, BOOL bFirst) { CString ins; switch (p->nType) { case 0: ins = bFirst ? _T("LD ") : _T("AND "); break; case 1: ins = bFirst ? _T("LDI ") : _T("ANI "); break; case 2: ins = _T("OUT "); break; } ins += p->strOperand; return ins; }逻辑说明:bFirst标记是不是这一行的第一个元件,第一个用LD/LDI起头,后面的用AND/ANI串联。参数strOperand直接拼在助记符后面,中间留一个空格。这个简化版没处理并联,实际工程里如果看到MPS/MRD/MPP相关代码,说明作者做了分支栈,读的时候重点看栈的压入弹出时机,那是梯形图转指令最容易错的地方。
4.3 下装数据的组织与发送
指令表生成后,要按PLC协议编码成写指令帧。每条指令对应一个写帧,帧里带目标地址和数据。批量下装时,工程通常维护一个发送队列,发一帧等一帧回复,超时重发。
// 批量下装循环(示意) BOOL CPlcDoc::DownloadProgram() { for (int i = 0; i < m_arrInstructions.GetSize(); i++) { CString frame = m_protocol.BuildWriteFrame( m_nStation, i, m_arrInstructions[i]); if (!m_comm.SendAndWaitReply(frame, 1000)) { AfxMessageBox(_T("下装中断,检查通信")); return FALSE; } ::Sleep(30); // 帧间隔,给PLC处理时间 } return TRUE; }SendAndWaitReply是同步发送加等待,超时1000毫秒。Sleep(30)是帧间隔,前面提过,别省。参数i是程序步地址,不同PLC型号步长不一样,FX系列一般按步递增。下装失败时先看是第几步断的,再单独发那一帧抓回复,比盲目重试有效。
5. 避坑与排查:老工程跑不起来时先看这几处
5.1 编译报“无法打开预编译头文件”
现象:Build时提示stdafx.obj或.pch找不到。原因:VC6的预编译头机制要求先编译stdafx.cpp生成.pch,工程设置里如果勾了“自动使用预编译头”但stdafx.cpp没参与编译,就会报这个。解决:在FileView里确认stdafx.cpp在Source Files下,Project Settings→C/C++→Precompiled Headers选“Use precompiled header file”,然后单独编译一次stdafx.cpp再全量Build。
5.2 串口能打开但收不到任何数据
现象:CreateFile成功,Write也返回成功,但读缓冲区一直空。原因常见三种:波特率或校验位跟PLC不一致;发送的帧校验算错,PLC直接丢弃不回复;串口线是直连线而非交叉线(工控串口接线方式跟普通串口不同)。解决:先用串口调试工具手动发一帧已知正确的帧,确认硬件链路通;再对照协议手册逐字节核对帧格式;最后检查接线,三菱PLC的RS422/RS485接线跟PC的RS232不是一回事,中间可能需要转换器。
5.3 界面卡死或无响应
现象:点连接或下装后界面白屏,任务管理器显示“未响应”。原因:串口读写放在了主线程,Read阻塞或等待回复的循环没让出消息泵。解决:确认通信在工作线程里;如果已经在工作线程,检查主线程有没有在等线程结束时用了死循环WaitForSingleObject而不处理消息,改成MsgWaitForMultipleObjects或者用事件通知。
5.4 下装后PLC程序错乱
现象:下装完成但PLC运行逻辑不对,或者原有程序被覆盖。原因:写指令的起始地址算错,或者帧里的步地址跟PLC实际程序区不匹配;也可能是下装前没做PLC停机,运行状态下写入被拒绝或写偏。解决:下装前先把PLC切到PROG(停机)模式;核对起始步地址和程序容量;第一次写只写一小段测试,读回比对无误再全量写。
5.5 换机器后工程路径报错
现象:拷到另一台电脑,打开工程一堆文件找不到。原因:.dsp里写死了绝对路径,或者源码里#include用了绝对路径。解决:用前面给的findstr命令扫绝对路径,统一改相对路径;VC6的Tools→Options→Directories里把Include/Library路径设成VC安装目录下的标准路径,别依赖作者本机配置。
6. 二次开发改造:把通信层抽出来做成可复用模块
这份工程最值得改的地方,是把通信和协议层从MFC的Doc/View里剥出来,做成不依赖界面的独立模块。原工程里通信线程、帧拼装、串口操作往往跟界面类缠在一起,想换个界面框架或者做成后台服务就得大动。我的做法是定义一组纯C++接口:ISerialPort负责打开关闭收发,IPlcProtocol负责帧的拼装和解析,两者通过回调或队列交互,MFC那边只做消息转发。
// 抽离后的通信接口(不依赖MFC) class ISerialPort { public: virtual bool Open(const char* port, int baud, int dataBits, char parity, int stopBits) = 0; virtual int Read(unsigned char* buf, int len, int timeoutMs) = 0; virtual int Write(const unsigned char* buf, int len) = 0; virtual void Close() = 0; virtual ~ISerialPort() {} };这样改的好处是,同一套协议代码可以挂到Qt、控制台程序甚至服务端,验证时也不用开界面,写个控制台main直接发帧收帧,排查通信问题快得多。改造顺序建议:先把串口封装抽出来,编译成静态库;再把协议帧的拼装解析独立;最后把指令表和梯形图的数据结构跟界面解耦,用观察者模式通知刷新。
验证方法很直接:抽完之后写一个最小测试程序,连上PLC读D100的值,读到了说明通信链路没问题;再发一条写指令写D100,读回比对,一致说明协议层没问题。两步都过,再往界面里接。
| 改造项 | 改造前 | 改造后 | 验证方式 |
|---|---|---|---|
| 串口封装 | 混在View里 | 独立ISerialPort实现 | 控制台收发测试 |
| 协议层 | 跟通信线程耦合 | 独立IPlcProtocol | 单帧拼装比对手册 |
| 数据结构 | 存MFC对象指针 | 纯结构体+容器 | 序列化往返测试 |
有个细节我踩过:抽接口时别把CString带进去,换成std::string或者自定义的定长缓冲,否则模块还是绑死在MFC上。还有线程安全,原工程单线程读写没事,抽成模块后如果多个调用方同时发帧,得加锁或者用发送队列串行化,不然帧会交错,PLC直接不认。从那以后我每次动这类老工控工程,都先把通信层单独编一个测试exe跑通再碰界面,省得在MFC的消息循环里绕圈子。希望帮到你。
本文还有配套的精品资源,点击获取