简介:这份资源是面向工业自动化开发者与PLC编程学习者的国外开源PLC编译器项目源码,目标是为IEC 61131-3标准所定义的五种编程语言提供开源实现,涵盖IL、ST、FBD、LD与SFC,可用于理解PLC编程语言的解析、编译与执行机制,适合具备一定C/C++基础、希望深入梯形图与结构化文本底层实现的中高级读者研究参考。压缩包共216个文件,约840KB,以cc与hh源文件为主体,辅以txt说明文档、test测试用例、h头文件及am、m4等构建脚本,另有少量sh脚本与readme、todo等工程说明,整体呈现典型的开源编译工具链目录结构。目前已有4563人学习下载。读者可从中获取五种IEC 61131-3语言的编译器前端实现思路、词法与语法分析相关源码组织方式,以及构建与测试脚本的配置范例,便于对照标准理解工业控制语言的编译流程,并在此基础上开展二次开发或教学实验。
1. 拿到一份国外开源 PLC 源码,先别急着编译
前阵子有个做产线改造的朋友丢给我一个压缩包,说是一套国外开源 PLC 的源码,想让我看看能不能把里面的梯形图编辑器抠出来,接到他们自己的组态软件里。我打开一看,IEC 61131-3 那套东西基本都在:梯形图(LD)、功能块图(FBD)、结构化文本(ST)、指令表(IL)几种语言的编辑器骨架,加上编译成中间表示、再往下生成目标代码的链路。这类开源 PLC 项目对工控开发者来说价值很直接——你不用从零啃 IEC 61131-3 那几百页标准,直接看别人怎么把标准落成数据结构、怎么把梯形图编译成可执行逻辑。适合两类人:一是想理解 PLC 编程环境内部怎么运转的工控软件开发者,二是想拿现成编辑器做二次开发、省掉自研组态前端的人。下面我按「这项目是什么 → 怎么跑起来 → 核心模块怎么拆 → 怎么改」的顺序,把这份源码拆一遍。
2. 项目定位与技术栈:一套 IEC 61131-3 的完整实现骨架
2.1 它到底实现了标准里的哪几块
IEC 61131-3 标准管的是 PLC 编程语言的语法和语义,一份完整的开源实现通常要覆盖四层:语言前端(各语言的解析器和编辑器)、中间表示(把不同语言统一成一种可编译的 IR)、代码生成(把 IR 变成目标平台能跑的代码或字节码)、运行时(扫描周期、变量表、I/O 映射)。这份源码的目录结构基本就是按这四层切的,我拿到手先看顶层目录,能立刻判断出它的完成度。
常见的顶层结构长这样(不同项目命名有差异,但职责划分大同小异):
src/ editors/ # 各语言编辑器前端:LD / FBD / ST / IL ld/ # 梯形图:梯级、触点、线圈、分支 fbd/ # 功能块图:块实例、连线 st/ # 结构化文本:词法、语法、AST compiler/ # 前端 AST -> 中间表示 ast/ ir/ codegen/ # IR -> 目标代码(C / 字节码 / 特定 PLC 指令) runtime/ # 扫描周期、变量内存、标准功能块库 libs/ # IEC 标准功能块:TON、CTU、R_TRIG 等看到editors/ld和compiler/ir同时存在,说明它不是个纯编辑器玩具,而是奔着「能编译能跑」去的。这一点很关键:市面上不少所谓开源 PLC 只做了图形编辑器,导出个 XML 就完事,编译和运行时是空的。判断方法很简单,看codegen和runtime目录里有没有实质代码,以及libs里有没有实现定时器、计数器这些标准功能块——有,才说明它真能跑逻辑。
2.2 技术栈与构建方式
这类项目主流是 C++ 打底(性能和跨平台),编辑器前端可能用 Qt 做图形界面,也可能做成 Web 前端(TypeScript + Canvas/SVG)方便嵌入。我这份源码是 C++ 主体加 CMake 构建,编辑器部分用 Qt Widgets 画梯形图的梯级和连线。判断依据是根目录有CMakeLists.txt,editors/下有.ui文件或QGraphicsScene相关代码。
构建工具链的典型组合:
| 组件 | 常见选型 | 作用 |
|---|---|---|
| 构建系统 | CMake 3.16+ | 跨平台生成 Makefile / VS 工程 |
| 编译器 | GCC 9+ / Clang 10+ / MSVC 2019+ | C++17 是常见底线 |
| GUI 框架 | Qt 5.15 或 Qt 6 | 梯形图、功能块图的可视化编辑 |
| 脚本/测试 | Python 3 + pytest 或 CTest | 编译器前端和运行时的单元测试 |
| 目标代码生成 | C 代码或自定义字节码 | 决定最终在哪跑 |
如果你的机器上没装 Qt,构建会直接卡在find_package(Qt5 ...)那一步,这是新手第一个翻车点,后面避坑章节会细说。技术栈这块我的建议是:先只构建compiler和runtime两个子目标,把编辑器放一边,这样能最快验证「这套源码的编译链路是不是通的」,不被 GUI 依赖拖住。
2.3 和商业 PLC 软件的关系
得说清楚一点:开源 PLC 源码不等于能直接替代商业 PLC 编程软件。商业软件(各家都有自己的)强在设备驱动、在线监控、仿真调试、认证合规这一整套生态。开源项目的价值在于「标准怎么落地」这件事本身——它的 AST 设计、IR 设计、功能块库实现,都是可以直接借鉴的工程范本。我一般把它当「参考实现 + 可裁剪的组件库」用,而不是当成品软件用。想清楚这个定位,后面改造时就不会纠结「为什么它没有某某商业功能」。
3. 跑起来:从零构建到第一个梯形图程序
3.1 环境准备与依赖安装
先确认编译器版本。C++17 是这类项目的常见要求,GCC 低于 9 会在std::filesystem或结构化绑定上报错。Ubuntu 系一条命令装齐基础依赖:
# 基础构建工具与 C++17 编译器 sudo apt update sudo apt install -y build-essential cmake git # Qt 图形依赖(编辑器前端需要,只跑编译器可跳过) sudo apt install -y qtbase5-dev qtchooser qt5-qmake qtbase5-dev-tools # 确认版本 cmake --version # 需要 3.16 以上 g++ --version # 需要 9 以上参数说明:qtbase5-dev提供 Qt5 的核心开发头文件和库,qtchooser用来在多个 Qt 版本间切换。如果你系统里同时有 Qt5 和 Qt6,构建时要用-DCMAKE_PREFIX_PATH明确指向其中一个,否则 CMake 可能找到错的那个,链接阶段报一堆 undefined reference。这一步我踩过,症状是编译全过、链接全挂,看着像代码问题其实是环境问题。
3.2 CMake 构建与常见开关
依赖齐了之后,标准流程是建独立构建目录、跑 CMake、再编译。不要在原目录里直接cmake .,会把生成文件混进源码树,后面清理很麻烦。
# 在源码根目录外建构建目录 mkdir -p build && cd build # 配置:指定安装前缀,关闭不需要的组件 cmake .. \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/usr/local \ -DBUILD_EDITOR=ON \ -DBUILD_TESTS=ON # 并行编译,-j 后面跟 CPU 核数 cmake --build . -j$(nproc) # 跑单元测试,验证编译器和运行时 ctest --output-on-failure参数说明:CMAKE_BUILD_TYPE=Release开优化,调试编译器前端时改成Debug并加-DCMAKE_CXX_FLAGS="-g -O0";BUILD_EDITOR控制是否编译 Qt 图形部分,只想验证编译链路就设OFF,能省掉一大半依赖;BUILD_TESTS打开后ctest才有东西可跑。-j$(nproc)用满所有核,编译大型 C++ 项目时能省不少等待时间。如果ctest里有失败项,先别慌,多半是测试用例依赖了特定路径或环境变量,看--output-on-failure打出来的具体断言。
3.3 写第一个梯形图并编译
构建成功后,一般会产出一个命令行工具(比如plcc之类)和一个 GUI 程序。先用命令行验证编译链路:写一个最简单的梯形图程序,一个常开触点串一个线圈,对应「输入 I0.0 直接驱动输出 Q0.0」。
梯形图在源码里通常用 XML 或自定义文本格式描述,常见结构是「梯级(rung)→ 元素(element)→ 连接(connection)」。一个最小示例(格式因项目而异,看editors/ld下的解析器实现):
<!-- 单梯级:触点 I0.0 驱动线圈 Q0.0 --> <program name="main"> <rung> <contact type="NO" variable="I0.0" /> <coil type="OUT" variable="Q0.0" /> </rung> </program>逻辑说明:contact type="NO"是常开触点,variable指向输入变量;coil type="OUT"是普通输出线圈。编译时前端把这段 XML 解析成 AST,再转成 IR 里的「读变量 → 条件跳转 → 写变量」序列。参数上要注意变量命名必须符合项目的变量表约定,I/Q/M前缀分别对应输入、输出、中间变量,写错了编译器可能不报错但运行时读不到值——这是梯形图调试里最隐蔽的一类问题。
编译命令通常是:
# 把梯形图源文件编译成目标代码 ./build/bin/plcc compile main.ld -o main.c # 或者直接生成字节码交给运行时执行 ./build/bin/plcc run main.ld如果compile成功,打开生成的main.c能看到类似if (I0_0) { Q0_0 = 1; }的代码,说明整条链路是通的。到这一步,你就有了一个可以继续往下拆的活体样本。
4. 核心模块拆解:梯形图从图形到可执行逻辑的链路
4.1 梯形图编辑器:梯级、触点、线圈的数据模型
梯形图编辑器的核心不是画图,是「图形元素 ↔ 数据模型」的双向映射。源码里editors/ld下一般有个LdElement基类和Contact、Coil、Branch等子类,每个元素持有变量引用和连接点(左、右、上、下)。图形层用 Qt 的QGraphicsItem承载,数据层是纯 C++ 对象,两者通过一个LdDocument同步。
关键设计点在于「连接」怎么表达。梯形图的语义是电流从左母线流到右母线,中间经过触点和分支,所以数据模型里每个元素要有明确的输入/输出端口,分支用Branch节点表示并联。我拆这类代码时习惯先找serialize/deserialize函数,因为序列化格式最能暴露数据模型的真实结构——哪些字段是必须的、连接关系怎么存,一目了然。
// 触点元素的数据模型(示意,字段名以实际源码为准) class Contact : public LdElement { public: enum Type { NormallyOpen, NormallyClosed }; Type type; // 常开 / 常闭 std::string variable; // 绑定的变量名,如 "I0.0" Port in, out; // 左右连接端口 // 序列化到 XML,供保存和编译使用 void serialize(XmlWriter& w) override { w.startElement("contact"); w.attr("type", type == NormallyOpen ? "NO" : "NC"); w.attr("variable", variable); w.endElement(); } };逻辑说明:type决定编译时生成「条件为真」还是「条件取反」,variable是变量表里的键,in/out端口在编译阶段被用来做拓扑排序——编译器要按电流流向把元素排成执行顺序,端口连接关系就是排序依据。参数上,variable建议在编辑器层就做合法性校验(是否在变量表中、类型是否匹配),否则错误会推迟到编译甚至运行时才暴露,排查成本翻倍。
4.2 编译器前端:AST 到中间表示
不同语言(LD/FBD/ST/IL)最终都要收敛到同一种中间表示,这是整个项目架构上最值得看的部分。常见做法是定义一套统一的 IR 指令,比如「加载变量、逻辑与、逻辑或、取反、赋值、跳转」,各语言前端只负责把自己的语法翻译成这套指令序列。
以梯形图为例,一个「常开触点 A 串常闭触点 B 驱动线圈 C」的梯级,翻译成 IR 大致是:
LOAD A JMPF end ; A 为假则整条梯级不导通 LOAD B JMPT end ; B 为真则整条梯级不导通(常闭取反) STORE C end:这种「跳转式」IR 的好处是贴近最终代码生成,坏处是分支多时跳转标签管理复杂。另一派做法是先转成 SSA 形式再做优化,代码更干净但实现门槛高。我这份源码用的是前者,简单直接,适合二次开发时快速加新语言——你只要写个前端把新语言翻译成这套 IR 指令,后面的代码生成和运行时完全复用。
// IR 指令的典型定义(示意) struct Instruction { enum Op { Load, Store, And, Or, Not, Jmp, JmpIfFalse, JmpIfTrue } op; std::string operand; // 变量名或跳转标签 int line; // 源文件行号,用于报错定位 };参数说明:operand在Load/Store时是变量名,在Jmp系列时是标签;line保留源行号,编译报错时能指回梯形图的具体梯级,这个字段在调试时非常有用,别省。
4.3 代码生成与运行时:扫描周期怎么落地
代码生成层把 IR 变成目标代码。常见目标是 C 代码(再交给目标平台的编译器)或自定义字节码(交给项目自带的虚拟机)。C 代码路线的好处是能直接嵌进嵌入式工程,字节码路线的好处是跨平台、能在线热更新。
运行时这块,PLC 的核心是「扫描周期」:读输入 → 执行程序 → 写输出,循环往复。源码里runtime目录一般有个ScanCycle或PlcRuntime类,维护输入映像区、输出映像区、中间变量区三块内存,每个周期开始时把物理输入刷进映像区,执行完把映像区刷到物理输出。
// 扫描周期主循环(示意) void PlcRuntime::run() { while (running) { readInputs(); // 物理输入 -> 输入映像区 executeProgram(); // 执行编译后的逻辑,读写映像区 writeOutputs(); // 输出映像区 -> 物理输出 sleepFor(cycleMs); // 固定周期,常见 1~10ms } }逻辑说明:readInputs/writeOutputs是 I/O 抽象层,实际项目里会对接具体硬件驱动或仿真接口;executeProgram跑的是编译产物;cycleMs是扫描周期,设太小 CPU 占用高,设太大响应慢,工控场景常见 1~10ms。二次开发时如果要接真实 I/O,改的就是readInputs/writeOutputs这两个函数,程序逻辑部分不用动——这个分层设计是这套源码最值得学的地方。
4.4 标准功能块库:TON、CTU 这些怎么实现
libs目录里是 IEC 标准功能块的实现,定时器 TON、计数器 CTU、边沿检测 R_TRIG 这些。它们本质是有内部状态的函数块,每个实例有自己的内存。以 TON(接通延时定时器)为例,输入 IN 为真后开始计时,累计到 PT 后输出 Q 置位。
// TON 接通延时定时器(示意) struct TON { bool IN = false; // 输入条件 int PT = 0; // 预设时间(ms) int ET = 0; // 已计时 bool Q = false; // 输出 bool prevIN = false; // 上一周期输入,用于检测上升沿 void update(int deltaMs) { if (IN && !prevIN) ET = 0; // 上升沿复位计时 if (IN) { ET += deltaMs; if (ET >= PT) Q = true; } else { ET = 0; Q = false; // 输入断开即复位 } prevIN = IN; } };逻辑说明:deltaMs是本次调用距上次的毫秒数,由运行时按扫描周期传入;prevIN用来检测上升沿,保证每次 IN 从假变真时计时从头开始。参数上PT的单位要和deltaMs一致,混用秒和毫秒是定时器不准的头号原因。这类功能块的实现看着简单,但边界条件(输入抖动、周期不均)处理得好不好,直接决定运行时稳不稳,值得逐行读。
5. 避坑与排查:构建和运行阶段最容易翻车的几处
5.1 Qt 版本冲突导致链接失败
现象:编译全部通过,链接阶段报大量undefined reference to QWidget::...之类。原因:系统里装了多个 Qt 版本,CMake 找到的头文件版本和链接的库版本不一致。解决:配置时显式指定-DCMAKE_PREFIX_PATH=/usr/lib/x86_64-linux-gnu/cmake/Qt5(路径按实际装的位置改),或者用qtchooser -list-versions看清有哪些版本再选。我一般会在 CMake 配置输出里搜Qt5_DIR确认它到底用了哪个。
5.2 变量表没加载导致运行时全零
现象:程序编译成功,运行时所有输出都是 0,逻辑像没执行。原因:变量表(变量名到内存地址的映射)没加载,运行时读到的输入全是默认值。解决:检查运行时的启动参数有没有指定变量表文件,以及变量表里的变量名和梯形图里用的名字是否完全一致(大小写、前缀都算)。这类问题没有报错,只能靠打印变量表内容来定位,建议在readInputs里加一行调试输出。
5.3 扫描周期设置不当引发逻辑抖动
现象:定时器计时不准,或者输出频繁通断。原因:扫描周期设得比逻辑里最短的定时精度还大,或者周期不固定(用了sleep但没补偿执行耗时)。解决:把周期设成固定值并用「下次唤醒时间 = 本次开始时间 + 周期」的方式调度,而不是「执行完再 sleep 周期」,后者会累积漂移。工控场景对时间敏感的逻辑,周期别超过 10ms。
5.4 编辑器保存的格式和编译器期望的不一致
现象:GUI 里画好的梯形图保存后,命令行编译器解析报错。原因:编辑器序列化时带了 GUI 专用字段(坐标、颜色),编译器前端解析器不认。解决:看serialize和编译器解析器是不是共用同一套 schema,不是的话要么统一 schema,要么在编译前做一次格式转换。这个坑在二次开发加新元素时特别容易踩,加字段前先确认两边都认。
5.5 目标代码生成后变量名被改写
现象:生成的 C 代码里变量名变成了I0_0这种,和源里的I0.0对不上,接真实 I/O 时映射错位。原因:代码生成器对变量名做了合法化处理(点号在 C 里不合法)。解决:在代码生成层维护一张「源变量名 ↔ 生成变量名」的映射表,I/O 绑定时查这张表,别靠字符串替换硬猜。这个映射表在调试时还能当变量监视用,一举两得。
6. 二次开发:把编辑器抠出来接进自己的组态软件
改造思路我按「最小可用」来推:先不碰编译器和运行时,只把editors/ld这块抽成一个独立库,对外暴露「加载梯形图文件 → 编辑 → 保存」三个接口,这样能最快验证它能不能嵌进你的组态前端。具体做法是把LdDocument和图形层一起编成静态库,头文件只暴露文档操作接口,Qt 的QGraphicsView作为控件嵌进你的窗口。
验证方法上,我习惯先写一个最小宿主程序:一个空窗口,中间放梯形图编辑控件,菜单里加「打开/保存」,跑通后再逐步加变量表联动和编译按钮。这样每一步都有可回退的点,不会一上来改一大片然后不知道哪坏了。
几个改造时的具体技巧:
| 改造点 | 做法 | 注意 |
|---|---|---|
| 变量表对接 | 把项目的变量表接口抽象成回调,编辑器不直接读文件 | 别让编辑器依赖具体存储格式 |
| 新增语言元素 | 继承LdElement,实现serialize和 IR 翻译 | 两边 schema 要同步改 |
| 目标平台切换 | 只换codegen子目录,IR 和前端不动 | 先跑通一个最小程序再扩 |
| 在线监控 | 复用变量名映射表,周期性读运行时内存 | 映射表要双向可查 |
最后说个我自己的教训。早些年我拿到这类源码,总想一口气把编辑器、编译器、运行时全改成自己想要的形态,结果改到一半发现前端和 IR 的接口对不上,前面全白干。从那以后我每次动这类项目,都强制先跑通「一个梯形图 → 编译 → 运行 → 看到输出」这条最小链路,再动任何一块,改完立刻回归这条链路。这套源码的价值不在它现在有多完整,而在它的分层足够清楚,你按层改、按链路验,就不会翻车。希望帮到你。
本文还有配套的精品资源,点击获取