ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

VC++实现PLC梯形图转指令表与仿真:MFC绘图与解释执行

VC++实现PLC梯形图转指令表与仿真:MFC绘图与解释执行

简介:这份资源面向工业自动化方向的学习者与工程师,提供一套用VC++编写的PLC梯形图转指令语言工具,并附带模拟仿真能力,帮助读者在PC上完成梯形图解析、逻辑转换、指令编码与运行验证,无需依赖真实硬件即可理解PLC内部工作机制。压缩包共59个文件,约117KB,以cpp与h源码文件为主体,配合bmp、ico等界面位图与图标资源,以及dsw、dsp工程文件和rc资源脚本,构成一个结构完整的MFC工程,便于直接编译调试。程序内含汉语注释,可辅助读者理解各函数与模块的职责。目前已有1223人学习下载,适合作为PLC编程入门、课程实验与工程调试的参考案例,读者可从中掌握梯形图元件识别、MPP与MPS等指令的应用思路,以及输入输出信号交互的仿真方法。

1. 从一张梯形图到一串指令:这个 VC++ 项目到底在解决什么

车间里改一条产线逻辑,工程师在触摸屏上拖两个常开触点、串一个线圈,点保存,PLC 里跑的是另一套东西——指令表。中间那层翻译,平时被编程软件藏得严严实实,一旦要自己做仿真、做教学、做离线校验,就得把这层黑匣子拆开。这个用 VC++ 写的项目干的就是这件事:把梯形图(Ladder Diagram)的图形结构解析成内部节点,再生成对应的指令语言(Instruction List),最后挂一个模拟仿真循环,让没有真实 PLC 的机器也能把逻辑跑起来看结果。适合两类人:一是想搞懂 PLC 编程软件内部怎么把图形变成代码的工控开发者,二是手上有 VC++ 基础、想拿一个完整可编译工程练手 MFC 绘图与解释执行的程序员。它不依赖任何商业 PLC 软件,纯 Win32 桌面程序,编译出来就能画图、翻译、仿真。

2. 工程结构与技术选型:MFC 单文档加一套自研解释器

2.1 为什么是 VC++ 加 MFC,而不是换别的框架

这个项目的年代感和技术栈是绑定的。PLC 编程软件在国内工控圈长期是 VC++ 6.0 到 VS2010 这一档的 MFC 工程,原因很实际:梯形图编辑器本质是一个矢量绘图画布,需要精确的鼠标命中测试、拖拽、双击编辑、右键菜单,MFC 的CDC/GDI虽然老,但坐标变换和双缓冲画起来直接,不用引入额外运行时。项目正文没给具体 IDE 版本,常见做法是用 VS2010 及以上打开.sln,字符集设成多字节或 Unicode 都能编,但老工程多半是 MBCS,第一次编译如果报C2664之类的字符串转换错误,去项目属性里把「字符集」改成「使用多字节字符集」基本能过。

技术选型上可以拆成三层:界面层用 MFC 的视图类承载画布,逻辑层是梯形图元素的数据模型,执行层是一个自己写的指令解释器加扫描循环。没有用任何脚本引擎,指令表是自定义的文本格式,解释器按行解析后塞进一个指令数组,仿真时按扫描周期逐条执行。这种「自己定格式、自己写解释器」的做法在工控教学项目里很常见,好处是可控、能单步、能打印中间状态,坏处是扩展指令要同时改翻译器和解释器两处。

2.2 目录与模块划分

工程解压后大致是这样一个结构(文件名按这类项目的通用组织方式给出,实际以你拿到的源码为准):

PLC_Ladder/ ├── PLC_Ladder.sln ├── PLC_Ladder/ │ ├── MainFrm.cpp/.h 主框架窗口 │ ├── PLC_LadderDoc.cpp/.h 文档类,持有梯形图数据 │ ├── PLC_LadderView.cpp/.h 视图类,画布绘制与鼠标交互 │ ├── LadderElement.cpp/.h 梯形图元素基类与派生(触点、线圈、连线) │ ├── LadderCompiler.cpp/.h 梯形图 -> 指令表 翻译器 │ ├── InstructionVM.cpp/.h 指令解释执行器 │ ├── Simulator.cpp/.h 扫描周期调度与 IO 刷新 │ └── res/ 图标、菜单资源 └── sample/ 示例梯形图与生成的指令表

文档类持有整张图的元素链表,视图类只负责把文档里的元素画出来并把鼠标事件翻译成对文档的修改,这是标准 MFC 文档/视图分离。翻译器和解释器都不碰界面,输入输出都是纯数据结构,这一点对二次开发很关键——你可以把这两个类单独拎出来做单元测试,不用起窗口。

2.3 梯形图元素的数据模型

每个梯形图元素至少要有类型、栅格坐标、连接关系三样东西。触点分常开常闭,线圈分普通、置位、复位,连线分水平和垂直。一个够用的基类长这样:

// LadderElement.h enum ElemType { ET_CONTACT_NO, ET_CONTACT_NC, ET_COIL, ET_COIL_SET, ET_COIL_RST, ET_WIRE_H, ET_WIRE_V }; class CLadderElement { public: ElemType m_type; // 元素类型 int m_row, m_col; // 所在栅格行列,决定绘制位置 CString m_operand; // 操作数,如 X0、Y1、M10 int m_branch; // 所属并联支路编号,串联为 0 CLadderElement* m_next; // 同支路下一个元素 virtual void Draw(CDC* pDC, CPoint origin) = 0; // 各自绘制 virtual ~CLadderElement() {} };

m_row/m_col用栅格而不是像素坐标,是为了让翻译器能按行列顺序扫描,也方便对齐。m_operand存的是字符串而不是解析后的地址,翻译阶段再统一做地址映射,这样改元件地址不用动模型。m_branch是并联支路的关键,梯形图里一个并联块在指令表里要展开成LD/OR序列,靠这个字段区分哪些元素属于同一支路。实际项目里可能还有m_id做唯一标识、m_selected做选中态,不影响主逻辑。

3. 翻译核心:把图形结构压成指令表

3.1 翻译的整体思路:从栅格扫描到栈式生成

梯形图到指令表的翻译,本质是把一张有向图压平成线性指令序列。工业上常见的做法是「按列扫描 + 栈」:从左到右逐列处理,遇到触点就压栈或与栈顶做逻辑运算,遇到并联分支就记录分支起点,遇到线圈就输出结果。这个项目走的是同一条路,只是把栈换成了显式的表达式树,先建树再后序遍历生成指令,好处是并联嵌套再深也不会把栈搞乱。

翻译入口一般长这样:

// LadderCompiler.cpp BOOL CLadderCompiler::Compile(CLadderDoc* pDoc, CStringArray& outIL) { outIL.RemoveAll(); m_exprRoot = BuildExprTree(pDoc); // 第一步:图形 -> 表达式树 if (!m_exprRoot) return FALSE; EmitInstructions(m_exprRoot, outIL); // 第二步:树 -> 指令表 return TRUE; }

BuildExprTree按列扫描文档里的元素,串联用「与」节点连接,并联用「或」节点连接,触点作为叶子节点携带操作数和常开常闭属性。EmitInstructions做后序遍历,叶子输出LD/LDI,与节点输出AND/ANI,或节点输出OR/ORI,最后遇到线圈输出OUT/SET/RST。这样生成的指令表和商业软件的输出在语义上一致,顺序上可能略有差异,但仿真结果相同。

3.2 一个具体例子:起保停电路怎么翻译

拿最经典的起保停电路举例:启动按钮 X0 常开,停止按钮 X1 常闭,输出 Y0 线圈,Y0 常开触点并联在 X0 两端做自锁。翻译器扫描后建出的树是「(X0 或 Y0) 与 X1」,后序遍历输出:

LD X0 ; 取启动按钮常开 OR Y0 ; 并联自锁触点 ANI X1 ; 串联停止按钮常闭 OUT Y0 ; 驱动输出线圈

这四行就是指令表。注意ANI是「与反」,对应常闭触点,如果翻译器把常闭也输出成AND,仿真时停止按钮就失效了,这是这类项目最容易翻车的地方。代码里判断常开常闭靠m_type,ET_CONTACT_NO走LD/AND,ET_CONTACT_NC走LDI/ANI,线圈类型决定末尾是OUT还是SET/RST。

3.3 指令表的文本格式与解析

生成的指令表要能被解释器读回去,所以格式必须固定。项目里用的是「助记符 + 空格 + 操作数」的纯文本,一行一条,分号后是注释。解析函数按行切分,跳过空行和注释行,把助记符映射成枚举,操作数解析成地址索引:

// InstructionVM.cpp BOOL CInstructionVM::LoadProgram(const CStringArray& il) { m_code.RemoveAll(); for (int i = 0; i < il.GetSize(); ++i) { CString line = il[i]; line.Trim(); if (line.IsEmpty() || line[0] == _T(';')) continue; // 跳过空行与注释 int sp = line.Find(_T(' ')); CString op = (sp > 0) ? line.Left(sp) : line; CString arg = (sp > 0) ? line.Mid(sp + 1) : _T(""); arg.Trim(); Instruction ins; ins.op = MnemonicToOp(op); // "LD" -> OP_LD ins.addr = ResolveOperand(arg); // "X0" -> 地址索引 m_code.Add(ins); } return m_code.GetSize() > 0; }

MnemonicToOp是一张静态映射表,ResolveOperand把X/Y/M前缀映射到不同的存储区并算出偏移。这里有个细节:操作数解析失败时不能静默跳过,否则仿真跑起来结果不对还找不到原因,合格的做法是记录行号并弹提示,把错误定位到具体指令。

4. 模拟仿真:扫描周期、IO 刷新与单步调试

4.1 扫描循环怎么搭

PLC 的运行时模型是「读输入 → 执行程序 → 写输出」循环往复,这个项目在 PC 上用定时器模拟。常见做法是挂一个SetTimer,周期设 10 到 50 毫秒,每次触发执行一遍Scan():

// Simulator.cpp void CSimulator::Scan() { RefreshInputs(); // 把界面上的输入开关状态读进输入映像区 m_vm.Reset(); // 复位栈与临时结果 for (int pc = 0; pc < m_vm.GetCodeSize(); ++pc) { m_vm.Step(pc); // 逐条执行,内部维护逻辑栈 } RefreshOutputs(); // 把输出映像区刷到界面指示灯 }

RefreshInputs和RefreshOutputs是仿真和真实 PLC 的边界,界面上的拨动开关对应输入点,输出线圈的状态回写到界面上的指示灯。m_vm.Step是解释器的单步执行,内部用一个bool栈做逻辑运算:LD压栈,AND弹出栈顶与当前值相与再压回,OUT弹出栈顶写输出区。这个栈式执行模型和翻译时的表达式树是镜像关系,两边对上了,仿真结果才可信。

4.2 单步与状态监视

仿真器比真 PLC 多出来的价值就是能停下来看。项目里一般会给解释器加一个「单步」按钮,点一次执行一条指令,同时把当前指令行号、逻辑栈内容、各存储区快照打到界面的列表框里。实现上就是把Scan拆成StepOnce,外部控制pc不自动递增:

// 单步执行一条,返回执行后的 pc int CInstructionVM::StepOnce(int pc) { Instruction& ins = m_code[pc]; switch (ins.op) { case OP_LD: m_stack.push(ReadBit(ins.addr)); break; case OP_LDI: m_stack.push(!ReadBit(ins.addr)); break; case OP_AND: { bool t = m_stack.top(); m_stack.pop(); m_stack.push(t && ReadBit(ins.addr)); break; } case OP_OUT: { bool t = m_stack.top(); m_stack.pop(); WriteBit(ins.addr, t); break; } default: break; } return pc + 1; }

ReadBit/WriteBit按地址索引访问对应的存储区数组,m_stack用std::stack<bool>就够。单步模式下每执行一条就刷新一次监视列表,能清楚看到LD X0之后栈里是true,ANI X1之后变成false,逻辑错在哪一步一目了然。这套东西对教学特别有用,学生能看见「常闭触点取反」到底发生在哪条指令上。

4.3 定时器与计数器的仿真处理

真实 PLC 的定时器计数器是硬件资源,仿真里得自己模拟。常见做法是在存储区里给每个定时器分配「当前值」和「设定值」两个槽,扫描时如果定时器的使能条件为真就累加当前值,到设定值就置位完成位。计数器同理,检测上升沿累加。这部分代码通常单独放在Simulator里而不是解释器里,因为它是「运行时资源」不是「指令语义」。如果项目里暂时没实现,二次开发时补这一块是性价比最高的扩展点,起保停之外的大部分实用逻辑都依赖它。

5. 避坑与排查:编译、翻译、仿真三处高频翻车点

5.1 编译报字符集与 MFC 库缺失

现象:打开工程直接编译,报一堆C2664无法将CString转成const char*,或者链接时报nafxcwd.lib找不到。原因多半是工程按 MBCS 写的,而你的 VS 默认 Unicode,或者安装 VS 时没勾 MFC 组件。解决:项目属性里字符集改成「使用多字节字符集」,VS 安装器里补上「适用于最新 v142 生成工具的 C++ MFC」。改完还报错就检查stdafx.h是否被误删,老工程预编译头是硬依赖。

5.2 常闭触点翻译成 AND 导致逻辑失效

现象:画了停止按钮常闭,仿真时按下去输出不停。原因:翻译器只按操作数生成AND,没区分常开常闭,m_type判断写漏了。解决:在EmitInstructions的叶子分支里按类型分派,常闭输出LDI/ANI,并在翻译完成后把生成的指令表打印出来人工核对一遍。这个坑几乎每个改翻译器的人都会踩一次,血泪经验是:改完翻译逻辑先跑起保停,四行指令对不上就别往下测。

5.3 并联支路顺序错乱导致结果偶发不对

现象:简单串联电路正常,一加并联自锁就时对时错。原因:按列扫描时并联支路的元素被当成串联处理,表达式树建错了父子关系,m_branch字段没用上。解决:建树时先按m_branch分组,同组内串联、组间并联,再合并成树;调试时把树结构按缩进打印出来,肉眼比对图形。这类问题不看中间结构很难定位,加一个DumpTree函数比单步调试快得多。

5.4 仿真定时器周期与界面刷新打架

现象:仿真跑起来界面卡顿,或者输出指示灯闪烁不同步。原因:SetTimer周期太短,扫描和界面重绘在同一线程抢时间,或者RefreshOutputs里直接UpdateData触发重绘。解决:扫描周期放到 20 毫秒以上,输出刷新只置脏标记,由OnPaint统一重绘;扫描逻辑和界面刷新解耦,别在扫描循环里直接操作控件。

5.5 操作数地址越界不报错

现象:程序能跑但某些输出永远不动作。原因:ResolveOperand对超出存储区范围的地址返回了 0 或 -1,ReadBit越界读到了别的区。解决:解析时校验地址范围,越界记录行号并拒绝加载;ReadBit/WriteBit入口加ASSERT断言,Debug 版直接断下来。这类静默失败最耗时间,宁可加载失败也不要带病运行。

6. 二次开发:从能跑到好用,我一般先动这三处

拿到这类工程,第一件事不是加功能,是让它可验证。我的习惯是先给翻译器和解释器各写一个最小测试:固定输入一张起保停梯形图,断言生成的指令表是那四行,再断言给定输入序列下输出符合真值表。没有这层保护,后面每改一处翻译逻辑都得靠手点界面验证,效率低还容易漏。测试不用引框架,一个main里跑断言就够,把LadderCompiler和InstructionVM从 MFC 依赖里摘出来单独编一个控制台工程,这一步做完,整个项目的可维护性会上一个台阶。

第二处是扩展指令集。现在的翻译器大概率只覆盖LD/AND/OR/OUT/SET/RST这一档,实际梯形图里还有上升沿LDP、下降沿LDF、定时器TON、计数器CTU。扩展时记住一个原则:翻译器和解释器必须同步改,翻译器多输出一种助记符,解释器的MnemonicToOp和StepOnce就要多一个分支,漏一边就是加载成功但执行无效果。我一般会在枚举里留一个OP_UNKNOWN,解析到不认识的助记符直接报错,避免静默跳过。

第三处是仿真可视化。把当前扫描的指令行号、逻辑栈、各存储区做成一个实时面板,比什么都直观。实现上给CInstructionVM加一个GetSnapshot接口返回结构体,界面定时拉取刷新,别让解释器直接持有界面指针。下面这个结构体是我常用的形态:

struct VM_SNAPSHOT { int pc; // 当前指令行号 bool stackTop; // 逻辑栈顶 int stackDepth; // 栈深度 bool inputs[32]; // 输入映像 bool outputs[32]; // 输出映像 };

界面拿到快照后只读展示,解释器完全不知道界面的存在,这样以后换界面框架或者做无界面批量仿真都不用动核心代码。改完这三处,这个项目就从「能演示」变成「能当工具用」了。

最后说个教训。我早期改这类翻译器,图省事直接在EmitInstructions里边遍历边输出,没建中间树,结果一遇到三层嵌套并联就彻底乱套,指令顺序怎么调都不对,查了两天才发现是生成顺序和逻辑结构脱节。从那以后我每次动翻译逻辑,都强制先把表达式树DumpTree打出来看一眼结构对不对,再谈指令生成。希望帮到你。

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

返回列表