简介这是一份PLC梯形图编译器源代码采用C工程实现完整展示从梯形图图形绘制到目标指令生成的全过程面向工业自动化领域开发者、PLC编程工具研究人员以及对编译原理感兴趣的进阶学习者。核心程序由14个头文件和13个CPP文件组成工程配置、位图图标等界面资源与说明文档等合计58个文件整个rar压缩包约212KB模块边界清晰便于按源码、界面、工程文档分类研读。源码覆盖语法解析、语义分析、目标代码生成等编译器关键组件并包含梯形图编辑界面、错误提示、调试辅助等功能能帮助读者理解PLC程序底层编译机制也为二次开发自定义梯形图编辑或编译工具提供可复用的基础框架。资源已有2573人学习适合结合IEC 61131-3标准与PLC硬件指令集进一步拓展实践。 很多做PLC开发的朋友都问过我同一个问题梯形图软件是拿什么写的梯形图本身又是怎么变成PLC能跑的代码的说实话三菱GX Works、西门子TIA Portal这些商业软件里梯形图编译器属于核心机密我们只能看到它「编译成功」的结果却看不到它内部怎么把一张电路图变成一串指令。我自己在尝试写一套开源的PLC梯形图编译器源代码时把词法分析、语法树构建、指令发射这些过程整个走了一遍今天就把关键的设计思路和踩过的坑整理出来。如果你也想自己实现一个梯形图编译器或者只是想弄明白PLC程序从编辑到下载执行之间的那层「黑盒」这篇文章应该能给你一个完整且可落地的技术路线。1. 梯形图编译器到底在编译什么从继电器逻辑到机器指令的跨越1.1 梯形图是一套被画出来的DSL很多人把梯形图当「图」来看但它本质上是PLC厂商定义的一种领域专用语言DSL。只是这种语言的语法不是用花括号和分号表达而是用左母线、右母线、常开触点、常闭触点、线圈、功能块这些图形元素来表达。比如一个最简单的自锁回路画出来是两个触点并联、一个线圈串联但它在逻辑上等价于Q : (I1 OR Q) AND NOT I2这样的布尔方程。编译器要做的事就是把这幅二维图形解析成一棵逻辑语法树再翻译成PLC处理器能逐条执行的指令序列。我在写编译器代码之前卡了很久因为梯形图是二维拓扑而普通编译器的输入是线性文本。后来我意识到只要把梯形图转成一种中间文本格式比如类CSV的元件表编译器前端的词法分析和语法分析就能复用传统编译器思路。实际上IEC 61131-3标准里也有梯形图与指令表IL的互相映射规则这个标准就是梯形图编译器的「语法大纲」。1.2 为什么说“编译器源代码”是工控自主化的关键目前工业现场用的PLC尤其西门子、三菱、汇川这些主流品牌它们的编程软件都自带梯形图编译功能。但如果你要做国产PLC、安全PLC或者要在一个嵌入式裸机环境里直接下载梯形图程序就绕不开自己实现编译这一环。哪怕你只是想在PC端做一个梯形图编辑器的仿真软件比如手机上仿真FX版梯形图这类工具也在热搜里很常见同样需要一个可靠的编译器把梯形图转成指令表或者直接解释执行。自己写编译器源代码还有一个好处可以彻底掌握指令集的边界。商业软件经常把非法的双线圈输出、嵌套过深等问题藏在灰色地带能编过但不保证运行稳定。而自研编译器可以在语义分析阶段就把这些风险暴露出来这对工控安全来说是实打实的价值。2. 编译器源代码的整体骨架一个可运行的迷你实现2.1 词法分析把梯形图文本切成有意义的Token如果梯形图编辑器可以直接导出文本格式很多开源项目用类似 XML 或自定义文本保存图纸那编译器前端第一步就是词法分析。以我用的一个自制文本格式为例一个元件行可能长这样NET 1 I1 --| |--[ NOT I2 ]--( Q )词法分析器扫描这个字符串输出Token序列NET表示网络开始I1是输入地址Token| |是常开触点Token[ NOT I2 ]是常闭触点修饰Token( Q )是输出线圈Token我提前定义了枚举类型typedef enum { TOK_NET, TOK_CONTACT_NO, TOK_CONTACT_NC, TOK_COIL, TOK_ADDON, TOK_LPAREN, TOK_RPAREN, TOK_IDENT, TOK_COMMA, TOK_END } TokenType;这一步的教训是地址解析不要用正则硬匹配因为不同PLC的地址体系差异很大——三菱用X0、Y0、M0西门子用I0.0、Q0.0、M0.0汇川用%IX0.0。我把地址解析单独抽成一个函数逐步处理前缀字母、数字、点号后续适配不同品牌时只改这一处就行。2.2 语法分析从二维图形中还原逻辑拓扑词法分析得到的是线性Token流但梯形图的串并联关系是二维的。我的做法是先把每个网络Network解析为一个「元件网格」即二维数组每个格子存放触点、连线、线圈或空位。这一步相当于把图形坐标转化为结构化的中间表示真正的语法分析不再处理坐标而是处理这个网格。网格里元素的摆放有几个约束同一行通常是一条串联分支不同行通过垂直线表示并联。语法分析需要扫描网格找到触点之间的连接关系。我定义了一个结构体BranchNode代表一个串联节点用next指针串联用parallel_branch链表表示并联分支typedef struct BranchNode { int element_type; // 常开、常闭、功能块、线圈 char address[32]; // 如 I0.0 struct BranchNode *next; struct BranchNode *parallel; // 并联分支头 } BranchNode;扫描时我从左母线开始做深度优先遍历。遇到竖直线时把当前分支拆成多个平行分支然后递归扫描。整个网络的全部逻辑关系最终收敛到一个以线圈为根的表达式树上。这个阶段我犯过最蠢的错误是忘记处理「垂直线连接多个行」的情况——如果不做行优先的邻接分析很容易把并联结构当成串联吃掉。2.3 代码生成把逻辑网络翻译成PLC指令序列语法分析之后每个网络都形成了一棵逻辑表达式树剩下就是遍历这棵树输出PLC指令。以IEC 61131-3的指令表为例常开触点 -LD 地址或AND 地址常闭触点 -LDI 地址或ANDI 地址并联分支 -OR或ORI线圈输出 -OUT 地址功能块调用 -CALL 功能块名, 参数列表代码生成器本质是一个递归下降的表达式求值器。我维护了一个当前累加器的状态是LD还是AND遇到并联时发OR。关键点是分支的嵌套深度——如果并联分支里又有串联分支指令顺序必须严格遵循「左到右、上到下」的扫描顺序否则逻辑就反了。这部分我额外加了一个中间步骤生成 IR中间表示也就是把梯形图翻译成一个与目标平台无关的指令数组然后再通过后端把IR发射成具体PLC的指令。这样做的好处是换一个PLC型号时前端和语法分析完全不用动只需要重写后端发射器。3. 核心数据结构与算法梯形图编译器的“内脏”3.1 网络表的数据结构矩阵比链表更适合二维拓扑刚开始设计时我打算用链表存储触点一个节点指到下一个节点结果一遇到并联就傻眼了并联的分支数量不固定分支之间又有水平连接链表维护起来非常吃力而且后续做触点扫描时要频繁回溯。后来我改成二维矩阵的稀疏存储每个格子放一个Cell结构体typedef struct { int type; // EMPTY, WIRE_H, WIRE_V, CONTACT_NO, CONTACT_NC, COIL, FUNC int addr_id; // 地址索引 int is_connected; // 扫描时是否已被访问 } Cell;矩阵的列代表梯形图的水平位置行代表垂直分支。这样并联结构就是同一行内多个分支间用WIRE_V格子连接视觉上和梯形图一模一样扫描算法也直观从一个触点出发下一步就是看右边相邻的格子类型。不过矩阵会浪费内存一个200*100的网络大部分格子是空的。我的优化是只保存非空格子用一个哈希表(row, col) - Cell来存储扫描时仍然按行列坐标访问。实测一个复杂网络大概几千个格子哈希表比二维数组省了三分之二的内存而且访问速度完全够用。3.2 触点扫描算法DFS如何生成串并联表达式编译器核心的算法就是确定触点之间的逻辑运算顺序。我用的是基于网格的深度优先遍历。假设从左母线开始状态只有两个当前分支是否已闭合也就是说从母线到当前点的路径是否导通。每次走到一个触点如果是常开触点生成LD 地址分支起点或者AND 地址分支中间并继续向右走。如果是常闭触点生成LDI 地址或者ANDI 地址。如果遇到向上或向下的垂直线说明存在并联分支此时先暂时保存当前分支的指令尾位置递归扫描并联分支然后在当前指令序列末尾插入OR最后将两条指令流的输出合并。遇到线圈时生成OUT 地址整个网络结束。这个DFS最需要注意的是「先序遍历」和「后序遍历」的时机。并联分支的OR指令必须插在分支扫描完成之后、回到主干之后。如果顺序反了生成的指令表会把并联结构变成串联结构。我后来写了一个单元测试用例专门用三路并联、每路里又有两个串联触点来验证输出顺序例如I0.0 AND I0.1 OR (I0.2 AND I0.3) OR (I0.4 AND I0.5) OUT Q0.0把这串作为预期结果每次改动算法都跑一遍回归测试。3.3 线圈与指令映射如何输出正确的LD/AND/OUT每个网络以线圈作为终点而且通常是右母线之前唯一的线圈。输出时线圈的指令一定是OUT如果驱动线圈之前有多个触点串联最后一个触点后的指令就是OUT 地址如果线圈是SET/RST类型比如三菱的SET M0就要输出SET M0而不是OUT M0。这块我一开始漏掉了线圈类型字段导致编译器只会输出OUT后面对接三菱PLC时连SET、RST、PLS、PLF全都绕过去了程序跑了半天逻辑不对。后来我在Cell结构体里增加coil_type字段并在代码生成时按类型发射对应指令。另一个容易错的是“取反触点”后的输出。比如[ NOT I0.0 ]--( Q0.0 )必须发射LDI I0.0也就是加载常闭触点。如果把它当成常开处理逻辑就完全反了。我在词法分析阶段就把常开/常闭的语义固化在Token类型里这样语法分析不用猜。4. 兼容多个PLC平台的后端设计从三菱到西门子再到汇川4.1 地址格式差异是编译器后端第一道坎同一个梯形图逻辑在不同PLC上编译出来的指令不同最重要的原因是地址格式和I/O映射不同。比如品牌输入地址示例输出地址示例内部继电器示例指令风格三菱X0Y0M0LD X0, OUT Y0西门子I0.0Q0.0M0.0A I0.0, Q0.0汇川%IX0.0%QY0.0%MX0.0LD %IX0.0, OUT %QY0.0欧姆龙0.00100.00W0.00LD 0.00, OUT 100.00如果你让编译器前端直接使用I0.0这种字符串那么后端发射指令时就得不断做字符串匹配。更好的方案是前端把地址解析成结构体typedef struct { int area; // 0输入, 1输出, 2内部继电器, 3数据寄存器 int byte_addr; int bit_offset; } Address;然后各个后端在发射指令时把Address按目标PLC格式拼成字符串。这样做词法分析、语法分析根本不用关心是三菱还是西门子地址格式差异被隔离在当前阶段。4.2 用中间IR隔离前端与后端我设计了一个简单的IR指令结构typedef enum { IR_LD, IR_LDI, IR_AND, IR_ANDI, IR_OR, IR_ORI, IR_OUT, IR_SET, IR_RST, IR_CALL, IR_RET, IR_END } IROpcode; typedef struct { IROpcode opcode; Address addr; int func_id; // 功能块ID int param_count; } IRInstruction;前端和语义分析只处理IR指令序列不关心最终目标PLC。后端比如三菱后端接收IR然后按三菱指令集的寄存器状态机输出文本指令西门子后端则输出类似STL的指令。这样做还有一个额外的好处我可以在IR层面做优化比如合并连续的同类型触点或者消除相邻的LDIOUT对等效于OUT NOT指令某些PLC支持。优化逻辑只要一次实现所有后端都共享。4.3 指令发射器与汇编器对接的实操细节后端最终输出的指令文本还要交给PLC的汇编器/链接器才能变成机器码。我自己的实现是先输出一个.il文本文件然后调用一个开源的汇编器转换成二进制固件。这里有个大坑指令表里所有的地址必须是实际PLC地址不能是符号名。所以我在IR里保留的Address结构体在后端发射时要查一遍映射表把area0, byte_addr0, bit_offset0映射成目标PLC的输入首地址。比如三菱的X区最小单位是八进制位X0到X7但X8不存在只有X10到X17。如果编译器生成的地址里出现X8汇编器会直接报错。这个坑我是在对接三菱模拟器时发现的后来在地址映射模块里专门加了一个sanitize_address()函数把八进制寻址规则强制检查一遍。西门子的寻址倒是没有这个坑但它的I0.0、Q0.0有位和字节之分编译器得计算字节偏移和位偏移一不小心就容易串位。5. 编译器的语义检查与调试工控代码不能错因为现场没有重试5.1 双线圈检测和变量作用域那些必须尽早报的错普通C编译器有变量重定义和未定义变量的检查梯形图编译器里类似的问题更容易被忽略。我最先踩到的就是双线圈输出——同一个网络或多个网络里同一个输出地址被OUT了两次。在PLC里双线圈会导致最后扫描的网络覆盖前面的输出基本等于程序逻辑错误。我在前端扫描IR序列时建立一个coil_map哈希表记录每个输出地址首次出现的IR指令索引如果再次出现就报错Error: duplicate coil output at network %d: Y0。还有一类问题是未定义地址。比如梯形图里用了D100数据寄存器但没有在变量表里声明。工控编译器通常允许直接使用物理地址但如果目标是国产PLC最好收紧规则强制要求声明。我在symbol_table里维护所有已声明地址未声明的跳警告但不中断编译方便Quick Debug。这在项目前期节省了大量时间。5.2 仿真验证把编译结果放到软PLC里跑一遍编译输出不能只靠肉眼检查我搭了一个简单的软PLC执行器以解释方式执行IR指令。这样可以不依赖真实硬件直接把梯形图编译结果跑起来模拟I0.0上升沿触发Q0.0自锁之类的场景。仿真器里最关键的是模拟PLC的扫描周期先读取输入镜像区然后按指令顺序执行最后把输出镜像区写回物理输出。这样编译器输出的IR如果逻辑不对仿真器里马上能看出触点时序问题。我举一个实际案例一条梯形图I0.0 --[ ]--[ NOT I0.1 ]--( Q0.0 )正常编译产出LD I0.0; ANDI I0.1; OUT Q0.0。我在写代码生成时一开始把ANDI的I给丢了结果仿真器里一直输出LD I0.0; AND I0.1; OUT Q0.0。测试时我模拟I0.0ON, I0.1OFF预期Q0.0通实际却不通。通过仿真器打印每条IR的执行状态一秒就定位到是ANDI变成了AND。这就是仿真器存在的价值——普通编译器错了改一下重新编译就行PLC程序错了可能直接顶掉现场设备。5.3 实测中的两个顽固Bug触点竞争与特殊指令扫描第一个Bug是触点竞争。梯形图里出现同一个触点的常开和常闭版本比如I0.0同时接常开和常闭理论上这是正常的但在DFS扫描时如果我用visited标记访问过的格子会错误的跳过同一地址的另一个触点。解决办法是不要对整个地址做visited而是对网格坐标做访问标记。同一个地址的多个触点它们在网格里坐标不同应该分别扫描。第二个Bug是特殊指令的扫描周期问题。三菱的PLS M0上升沿脉冲和PLF M0下降沿脉冲在梯形图里容易被误当成普通线圈处理。PLS必须在扫描周期内只产生一个扫描周期的脉冲编译器在代码生成阶段如果把它翻译成OUT M0那M0会一直保持ON完全违背语义。我的解决方案是在语义分析阶段识别PLS/PLF指令在IR中增加IR_PLS和IR_PLF仿真器执行时额外维护一个「上一周期该地址的状态」只在状态变化时置位一个周期。针对这个机制我还专门写了测试用例验证连续10个扫描周期内的输出波形。最后分享一个我自己写编译器源代码时的小技巧每个后端都有一份独立的寄存器状态机代码但测试用例是共享的。我维护了一批「黄金测试用例」比如自锁回路、互锁回路、三路并联、嵌套并联、SET/RST复位优先这些用例在每次改完词法或者语法分析后都能跑一遍确保三菱后端过了西门子后端不会挂。这种回归测试的投入比在真机上查半天值多了。如果你也打算入这个坑建议第一版就以「能编译并仿真通过一小批典型回路」为目标不要贪多。等基础管线通了再慢慢加功能块、网络注释、甚至反编译器都是水到渠成的事。本文还有配套的精品资源点击获取
