恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
开源PLC源码解析:从梯形图编译器到实时运行时的完整实现
首页
资讯中心
/
开源PLC源码解析:从梯形图编译器到实时运行时的完整实现
开源PLC源码解析:从梯形图编译器到实时运行时的完整实现
发布时间:2026/10/17 16:45:27
简介国外开源PLC编译器项目源码收录为zip压缩包面向工业自动化开发者与嵌入式软件工程师专门实现IEC 61131-3标准中IL、ST、FBD、LD、SFC五种PLC编程语言的解析与编译流程。包内共216个文件主体为54个cc源文件与45个hh头文件用于语言前端、代码生成与运行时支撑同时包含38个txt说明文档、12个test测试脚本及配置构建相关文件整体约840KB结构与标准开源工程一致便于按模块阅读。项目代码展示了从词法语法分析到目标代码生成的完整实现路径并附带autotools构建体系与测试用例适合希望深入理解PLC编译原理、标准语言映射关系或在Linux环境下做二次开发的工程师参考。目前已有4562人浏览学习作为研究型开源资源具备不错的参考价值。1. 一份能自编译自运行的国外开源 PLC 源码从 IDE 到实时内核设备现场有一台服役多年的老 PLC厂商早不更新 IDE 了想加一段运动控制逻辑却连梯形图编辑器都打不开。我当时拿到这份国外开源 PLC 源码第一反应是“终于可以把整套 IEC 61131-3 开发链握在自己手里了”。它不是某个 demo而是一整套工具链带梯形图编辑器的 IDE、把梯形图编译成共享库的编译器、能跑周期扫描的实时运行时以及对外提供 Modbus 通信的从站模块。编译后一份工程产物就是一个 .so 文件运行时动态加载它按任务周期反复扫描。适合研究 PLC 内部实现的人也适合想给自家设备做私有编程环境的团队。2. 整体架构拆解从梯形图拖拽到寄存器刷新的五条管线拿到源码后我直接扫了一遍顶层目录整套项目被拆得非常规整。IDE 负责图形编辑和代码生成编译器负责把图形语言降级成 C 代码运行时负责动态加载和执行通信层负责把内部变量映射到 Modbus 寄存器。四个部分之间用共享库边界隔开IDE 编译产生的产物运行时根本不关心它长什么样只要符号表对得上就能跑。这就是典型的“编辑器 / 编译器 / 运行时”三段式设计。2.1 技术栈画像Python IDE、C 运行时和 Modbus 通信这套源码在技术选型上很务实。IDE 用 Python 加 PyQt5 构建负责梯形图绘制、工程管理和代码生成编译器后端混合了 Python 和 C运行时则是纯 C 实现以保证调度器的实时性。我拿到源码时先在本地把环境过了一遍Python 侧依赖都集中在 requirements.txt 里运行时用 CMake 构建。目录结构大致如下plc-studio/ ├── ide/ │ ├── app.py # IDE 入口 │ ├── editor/ # 梯形图/FBD 图形编辑器 │ ├── compiler/ # AST、ST 中间码、C 代码生成 │ ├── project/ # 工程文件解析与保存 │ └── ui/ # 对话框、元件面板、监控面板 ├── runtime/ │ ├── CMakeLists.txt │ ├── src/ # 调度器、POU 加载、通信栈 │ └── hal/ # 硬件抽象层模拟器与真实设备共用 ├── libs/ │ ├── modbus/ # Modbus TCP/RTU 从站实现 │ └── iec_types/ # IEC 61131-3 基础数据类型 └── tests/ └── integration/ # 端到端测试脚本这个结构对二次开发特别友好。IDE 和运行时只通过.so文件通信编译器负责把 PLC 工程转成这个 .so运行时负责加载它。IDE 侧可以单独改 Python 代码而不影响调度器运行时侧也可以换成自己的硬件抽象层去适配真实 PLC 主板。我一般会先改runtime/hal/把模拟器的 GPIO 换成真实总线驱动IDE 根本感知不到。2.2 工程与运行时边界PLC 工程的物理组成IDE 在保存工程时不会导出单一文件而是生成一个工程目录包含 POU 源文件、变量映射表、任务配置和资源文件。POU 是 IEC 61131-3 里的核心概念程序、功能块、函数三种不同类型分别存在不同文件里。项目里用 JSON 描述变量映射用 XML 描述任务和资源配置。下面是我打开一份示例工程后看到的典型结构demo_project/ ├── project.json # 工程元数据包含目标运行时版本 ├── pou/ │ ├── main.plc # 主程序 POU梯形图源码 │ └── fb_motor.plc # 功能块 POU电机控制逻辑 ├── resources/ │ ├── tasks.xml # 任务周期、优先级、看门狗配置 │ └── io_mapping.json # %IX/%QX 与硬件通道的映射 └── build/ └── libuser.so # 编译产物运行时直接加载project.json 里记录的是“这台设备需要用哪个运行时版本、哪些 POU 是入口”tasks.xml 决定调度器怎么跑这些 POU。我改得最多的是 io_mapping.json因为不同 PLC 底板的 IO 地址定义差异很大这份文件就是硬件适配的翻译层。编译产物 libuser.so 是黑匣子运行时通过一组固定导出符号找到入口函数剩余的逻辑全部封装在共享库里。2.3 数据流主线梯形图节点如何变成内存地址和任务周期从画布上拖一个触点开始到 PLC 输出线圈真正通电数据要经过一条固定的流水线。IDE 把触点和线圈保存成图结构每个节点携带变量名和类型编译器先把图结构转成 IEC 61131-3 的标准中间表示再降级成 C 代码最后编译成共享库。运行时加载共享库后在启动阶段把变量名注册到符号表每个变量会拿到一个内存地址然后调度器按任务配置周期扫描。流程图不用画这条主线用文字描述就是梯形图节点 → 项目 AST → ST 中间码 → C 代码 → libuser.so ↓ 运行时符号表注册 → 内存地址 → 任务周期扫描 ↓ Modbus 映射表 → 外部寄存器读写这里有一个容易忽略的边界梯形图上的%QX0.0并不是硬件引脚本身而是运行时内部变量的别名。运行时启动时按 io_mapping.json 把变量绑定到 HAL 通道上。所以梯形图程序里写的是逻辑地址物理地址是运行时启动时配的这个行为对二次开发非常关键。想测试新硬件时改映射文件而不改 PLC 程序。3. 完整运行流程在模拟 PLC 上跑通第一个启保停程序我拿到源码后最关心的就是能不能快速跑起来。整个流程分为四步先编译运行时再启动 IDE接着写一个启保停梯形图最后用 Modbus 工具验证线圈状态。这套流程走通后基本上所有二次开发都能沿着这条路迭代。3.1 环境准备与编译步骤环境上需要 Python 3.8 以上版本、CMake 3.16 以上版本和一个 C17 编译器。IDE 的 Python 依赖用 pip 安装运行时用 CMake 单独构建。我这里在 Ubuntu 上操作命令如下# 安装 IDE 依赖 pip install -r requirements.txt # 构建运行时 mkdir -p runtime/build cd runtime/build cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc) # 启动 IDE cd ../../ide python app.py三条命令分别对应三个阶段的产物Python 依赖是 IDE 的图形界面和编译模块CMake 构建是给模拟器用的可执行文件和库IDE 启动后就能建工程了。有个细节是运行时编译产物默认输出在 runtime/build 目录IDE 在下载程序时会自动搜索这个路径如果自己改了构建目录需要到 IDE 设置里同步路径。3.2 写一个启保停梯形图从元件拖拽到生成共享库启动 IDE 后新建工程主 POU 默认打开梯形图编辑器。经典的启保停电路需要三个元件常开触点“启动”、常闭触点“停止”、线圈“电机”。我把启动按钮接在左母线经过停止按钮的常闭触点再经过电机线圈的常开触点并联支路最后回到右母线。IDE 工具栏上拖拽元件即可不需要手写任何代码。保存编译后IDE 会生成如下 ST 中间码# 梯形图编译生成的 ST 中间码示意 motor : (start OR motor) AND NOT stop;这行直接体现了启保停电路的逻辑——启动信号或当前电机状态为真同时停止信号不为真电机线圈才通电。这里motor既是线圈变量又是反馈触点变量梯形图里的自锁就是靠把这个线圈变量回读为触点实现的。ST 中间码随后被降级成 C 函数最终编进 libuser.so。3.3 启动模拟运行时与 Modbus 验证程序编译成功后IDE 会把 libuser.so 下载到 runtime/build 目录然后自动启动模拟运行时。模拟运行时监听两个端口一个用于 IDE 调试通信另一个用于 Modbus TCP 从站。我习惯直接用 Python 的 pymodbus 客户端去验证线圈读写from pymodbus.client import ModbusTcpClient client ModbusTcpClient(127.0.0.1, port5020) client.connect() # 模拟按下启动按钮写线圈地址 0 为 True client.write_coil(0, True, unit1) # 读取电机线圈状态 result client.read_coils(1, 1, unit1) print(Motor coil:, result.bits[0])这段脚本背后发生的事是写线圈地址 0 相当于按下启动按钮地址 1 被映射到电机线圈变量。如果梯形图逻辑正确读回来的电机线圈应该是 True再把停止按钮对应地址写成 True电机会变 False自锁失效。Modbus 地址到 PLC 变量的转换由运行时启动时读取的映射表决定默认偏移是 0所以脚本里地址 0 和地址 1 直接对应工程里的前两个%IX变量。3.4 调试点监控变量和执行时序光能读线圈还不够调试梯形图程序需要看内部变量的实时变化。模拟运行时提供了一套简单的文本监控接口通过 IDE 的监控面板输入变量名即可看到当前周期刷新后的值。我调试自锁逻辑时会同时监控start、stop、motor三个变量观察从按下启动按钮到电机吸合的完整时序。这个监控接口的数据不是实时推送的而是 IDE 按 200ms 间隔轮询所以在看门狗超时类问题排查时监控面板的数值会滞后实际执行情况这一点在跑运动控制时容易造成误判。4. 核心实现剖析梯形图编译器和扫描周期调度器的内部链路整个项目里最有技术含量的部分集中在两处梯形图编译器和运行时调度器。梯形图编译器要处理图形结构到文本代码的降级运行时调度器要保证逻辑在确定的时间窗口内被扫描。这两部分我拆开读了一遍源码把关键链路捋出来了。4.1 梯形图 AST把图纸变成可遍历结构梯形图编辑器在保存和编译时先把图形元件转成抽象语法树 AST。每个元件是一个节点节点之间通过连接关系组成图。AST 里分为三类基本节点接触点、线圈和功能块它们统一继承一个基类。IDE 的 project 模块里定义如下# 梯形图 AST 节点定义简化版 class LadderNode: def __init__(self, node_id, var_name, node_type): self.node_id node_id self.var_name var_name self.node_type node_type # contact_norm_open / coil / function_block self.left None # 左母线侧连接 self.right None # 右母线侧连接 self.branches [] # 并联支路仅对触点/线圈有效 class LadderNetwork: def __init__(self): self.nodes {} # node_id - LadderNode self.start_node_id None # 左母线第一个节点这个数据结构把梯形图的电流路径直接映射成对象引用关系。node_type决定编译器的处理分支branches列表存的是并联支路的根节点。解析时 IDE 从start_node_id出发沿left/right指针深度优先遍历遇到branches就递归处理并联逻辑。编译出错时IDE 能通过node_id反查到画布上的具体元件这是我调试自己写的功能块时最常用的定位手段。4.2 从 AST 到 ST 中间码极简下沉编译AST 不能直接生成 C 代码项目里先把它编译成 ST 中间码理由很简单ST 的表达能力覆盖梯形图的全部语义而且 ST 到 C 的翻译是机械性的不容易出错。编译器核心是一个 Visitor 类逐个节点分发class StGenerator: def visit_network(self, network): st_lines [] for node in self._walk_forward(network): st_lines.append(self._dispatch(node)) return \n.join(st_lines) def _dispatch(self, node): if node.node_type contact_norm_open: return f({node.var_name}) if node.node_type contact_norm_closed: return f(NOT {node.var_name}) if node.node_type coil: return f{node.var_name} : {self._expr_stack.pop()} if node.node_type function_block: return self._generate_fb_call(node)_dispatch方法按照节点类型生成 ST 片段接触点生成布尔表达式线圈把栈顶表达式赋值给目标变量。串联的接触点通过_expr_stack用 AND 拼接并联支路用 OR 拼接。这是最朴素的编译策略梯形图的串并联结构本质上就是一个布尔表达式树。我在阅读时注意到项目并没有做复杂的优化生成的 ST 代码和手写的几乎一样直白。这反而让我觉得放心因为生成的 C 代码可以逐行对照。功能块调用被单独处理因为要处理参数输入和输出引脚这一块是后面二次开发的重点。4.3 运行时任务调度周期扫描与看门狗运行时侧的核心是一个固定周期的扫描循环。C 实现里主任务按配置文件创建若干个调度表项每个表项绑定一个 POU 函数指针和执行周期。整段代码用条件变量控制节奏// 运行时任务调度器核心循环 void Runtime::scanLoop() { while (running_) { auto next_tick std::chrono::steady_clock::now() period_; // 读取物理输入缓存到镜像区 hal_-readInputs(input_image_); // 按优先级执行所有 POU for (auto task : tasks_) { if (task.should_run(scan_count_)) { task.execute(input_image_, output_image_); } } // 输出镜像区刷到物理输出 hal_-writeOutputs(output_image_); // 触发看门狗喂狗 watchdog_.kick(); // 等待下个周期起点 std::this_thread::sleep_until(next_tick); scan_count_; } }循环里最重要的原则是“输入刷新—逻辑执行—输出刷新”三段式。输入输出都有镜像区防止程序在扫描中途修改物理引脚造成状态不一致。should_run根据扫描计数判断本周期是否轮到该任务执行周期不同的任务可以挂在一个循环上。看门狗在每轮循环结束时踢一下如果某个任务卡死导致循环超时看门狗会强制复位。这段实现给我最大的启发是PLC 的实时性不等于操作系统硬实时而是“固定周期内必须完成约定动作”。只要任务数不超出 CPU 能力普通 Linux 加条件变量就能跑出不错的周期稳定度。4.4 Modbus 从站与变量映射表运行时对外提供 Modbus TCP 从站服务核心是把内部变量映射到 Modbus 寄存器地址。映射关系在启动时从 io_mapping.json 加载运行时维护一个地址路由表0x 区映射到线圈变量1x 区映射到触点输入4x 区映射到寄存器变量。这张表本质上是“PLC 变量名到 Modbus 地址”的翻译层。struct ModbusMapping { uint16_t modbus_addr; // Modbus 协议地址 std::string var_name; // PLC 变量名 uint16_t bit_offset; // 位偏移0 表示字节对齐 };我测试时踩过一个坑梯形图里用的是%QX0.0映射表里却是%QX0导致 Modbus 地址 0 读出来的线圈和 IDE 监控面板看到的值完全对不上。后来习惯就是每次改映射表后先跑一遍地址回环测试从 Modbus 写一个值再到 IDE 监控面板看对应变量是否变成同一值。地址偏一位这种事视觉检查永远发现不了。5. 避坑清单五个常见到让人怀疑人生的现场这套源码跑起来不难但改起来有几个坑属于“不看源码绝对猜不到原因”的类型。我照着实战中遇到的问题整理成一份避坑清单每条都带着现象、原因和解决路径。5.1 动态库加载失败编译成功但运行时说找不到 libuser.so现象是 IDE 下载完程序运行时立刻报错error while loading shared libraries: libuser.so: cannot open shared object file。我先怀疑文件没拷过去确认文件在目录里还报同样的错这就玄学了。原因其实是编译产物链接了额外的依赖库而运行时的工作目录和 IDE 的下载目录不一致动态链接器搜不到依赖路径。解决方式是给 CMake 显式设置 RPATH让 libuser.so 优先从自身所在目录找依赖库。set(CMAKE_INSTALL_RPATH $ORIGIN) set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)这样运行时不管从哪里启动都能在 .so 所在目录找到兄弟依赖。从那以后凡是给运行时新增功能块库我都强制把 RPATH 检查放在编译配置第一行。5.2 梯形图元件全部渲染成空白块现象是工程能打开但画布上的触点和线圈全部变成灰色方块双击没有任何反应。我一开始怀疑是 PyQt5 的图形缓存问题清缓存没用。后来打开 IDE 日志才发现解析器在读取 .plc 文件时元件标签是Contact_Norm_Open而解析器只认小写contact_norm_open。原因就是图形库升级后标签命名规则变了旧的工程文件格式没做向后兼容。解决方式是在解析器入口加一层标签归一化把所有标签转小写后再进分发逻辑。项目里这个函数在ide/project/loader.py里我补了十几行代码兼容旧文件。这个坑的教训是图形工程的源文件也是代码格式变了必须有迁移脚本不能指望手工改文件。5.3 扫描周期抖动到毫秒级运动控制直接报废现象是同一个工程在模拟器里跑得好好的换到真实 PLC 板子上扫描周期从 1ms 抖到 5ms伺服电机直接啸叫。原因不在调度器而在 HAL 层模拟器读输入是直接访问内存数组真实板子的hal_-readInputs()里却做了阻塞式系统调用几毫秒时效全部耗在总线读写上。解决方式是把所有阻塞式 IO 挪出主循环。HAL 层要提供两个接口readInputsNonBlocking()和pollInputsReady()主循环先查询输入是否就绪就绪才读否则跳过本轮扫描并计数超时。硬实时要求高的场合还得给运行时线程配实时调度策略sched_setscheduler这个我实测能把抖动压到微秒级。5.4 Modbus 请求总是超时地址偏移 1 造成的订单事故现象是客户端能连上端口但读写线圈时随机超时重试偶尔成功。我查了很久最终发现是变量映射表里%IX0.0被映射到了 Modbus 地址 0而运行时内部对%IX0.0的第 0 位做了偏移实际对应地址 1。协议层地址和 PLC 地址的基址约定不一致导致每次访问都错位。解决方式是在 io_mapping.json 里明确两个基准地址的换算关系不允许在代码里隐式偏移。改完后我在测试脚本里加了一条断言从 Modbus 读到的地址值必须和 IDE 监控面板的值完全一致否则测试直接失败。5.5 看门狗误复位任务周期和看门狗周期设成一样长现象是设备运行十几分钟后自动重启没有规律。看门狗日志显示超时被触发但任务本身执行时间只有周期的六成。原因不是任务慢而是周期任务和通信任务共享同一个线程当 Modbus 突发大量请求时通信任务占用了调度器的时间窗口导致整个扫描循环结束时间超过了看门狗阈值。解决方式是任务周期和看门狗超时至少要留出 50% 余量并把通信任务降级到独立线程绝不能和扫描循环煮在同一锅里。6. 二次开发实战把一个自定义功能块塞进编译器和运行时读这套源码最大的价值不是学会怎么用梯形图画逻辑而是知道怎么往工具链里加自己的东西。我拿“平均值滤波功能块”为例把完整的改造链路走一遍。这个功能块有四个引脚使能、输入值、采样窗口、输出值。要在 IDE 里把它变成可拖拽的元件编译器能把它翻译成 ST 调用运行时能真正执行必须同步改四个文件。第一步是在 IDE 的元件注册表里加图元定义告诉编辑器这个块叫什么、引脚怎么排列。第二步是在编译器的 AST 分发逻辑里加一个分支把功能块节点翻译成 ST 函数调用。第三步是在 ST 编译器的 C 代码生成阶段输出一个对avg_filter函数的调用。第四步是在运行时符号表里导出这个函数。// 运行时新增功能块的导出函数 extern C float avg_filter( bool enable, float input, uint32_t window_size, float* output) { static float buffer[256]; static uint32_t count 0; if (enable) { buffer[count % window_size] input; count; float sum 0; for (uint32_t i 0; i window_size; i) { sum buffer[i]; } *output sum / window_size; } return *output; }这段代码必须导出为 C 符号不能有 C 名字修饰。运行时加载共享库后按功能块名称在符号表里查这个地址查不到就报错。我第一版改造漏了符号导出声明运行时报 undefined symbol就是这个原因。验证方式很简单新建一个工程拖入平均值滤波块输入一个周期变化的模拟量信号用 Modbus 读输出值观察它是否收敛到平均值附近。这套改造流程走完才算把这套源码的技术栈消化完。从那以后我每次添加新指令或功能块都强制走一遍这四步修改清单元件表、AST 分发、ST 生成、运行时符号表。少一步翻车概率极高。希望帮到你。本文还有配套的精品资源点击获取