简介:这是一份来自国外开源社区的PLC编程语言编译器源码包,遵循IEC 61131-3标准,完整支持IL、ST、FBD、LD、SFC五种工业控制编程语言,适合嵌入式开发者、自动化工程师及编译器爱好者学习研究标准实现与编译流程。压缩包共216个文件,约840KB,以C/C++源码为主(含54个cc、45个hh文件),同时配备基于Automake的构建脚本、测试用例、配置文件和说明文档,组织形式规范,便于对照理解工程结构与编译原理。资源已吸引4563人学习下载,具备较好的参考价值。通过研读源码,读者可掌握语法树解析、中间代码生成、目标代码输出等编译器核心环节,并了解如何将IEC标准转化为可运行的PLC程序,对从事工业控制软件研发或研究开源编译器架构具有直接的借鉴意义。
1. 开源PLC控制系统:从梯形图到真实信号的全链路源码
对于做非标设备的人来说,PLC 选型是个几乎没有回头路的事。有次我调试一台三工位转盘机,程序逻辑简单,但上位机要走自定义报文,翻遍手册没法原生支持,只能加中间模块转接。后来我把控制器换成了一套开源 IEC 61131-3 运行时加梯形图编辑器,自己把协议封装成功能块,改逻辑再不用等固件。这套源码适合想摆脱单一品牌绑定的设备工程师,也适合想做控制器底座的嵌入式开发者。
这套源码的价值集中在三处:梯形图和结构化文本编译器、Linux 上的 PLC 运行内核、Modbus 通信点。拿到手之后,你能看到梯形图如何编译成 C 代码,再由周期任务执行为真实电平。下文按架构、代码、搭建、踩坑、二次开发的顺序拆开讲。
2. 整体架构解析:梯形图如何一路变成端子上的电平
2.1 编辑器数据模型:梯形图先存成 XML 逻辑图
拿到工程源码,先别急着编译,把目录结构捋清楚。这套项目采用前后端分离思路:编辑器和运行时是两套独立工程,中间通过一个“工程文件”传递程序。编辑器负责把梯形图网络画出来、连触点、连线圈,最终保存成 XML 数据模型。
梯形图在编辑器内部不是位图,而是树状图结构。一个网络(Network)包含若干节点,节点分触点(Contact)、线圈(Coil)、功能块(FB)、连线(Wire)。触点和线圈分常开、常闭两类,功能块挂输入输出引脚。XML 里记录了每个元件的坐标、类型、连接关系——编译时其实不关心坐标,只读取连接图。
常见的开源方案会先把并联和串联的触点网络转换成一棵逻辑树,再转成指令表(IL, Instruction List)。这一转换相当于把鼠标画的图变成一维指令流,后面无论转 C 代码还是交给解释器执行,都从这层中间表示走。我拆这个工程时最深的体会是:图形界面只是皮,数据模型设计得好,编译器和运行时才能干净。
2.2 编译器工作台:从逻辑树生成 C 源文件
编译器的核心作用是把逻辑树翻译成 C 语言代码,这比在解释器里逐条执行快得多,而且可以直接编进单片机和工控机。生成器输入是一份结构化中间表示,输出是 C 源文件,这一步简化是整个项目最值得抄的地方。
梯形图里一个网络通常是这种结构:左母线上两个触点串联,分支上再并联一个触点,右边接一个线圈,中间还可能夹一个定时器功能块。指令表序列用 LD(Load)、AND、OR、ST(Store)这类 IEC 61131-3 标准操作码表达。编译器先解析出逆波兰栈,布尔运算压栈出栈,遇到功能块就调用对应 C 函数。
每个生成的网络对应 C 文件里一个void net_xxx(void)函数,输入写成全局数组下标,输出写回输出映像区。这样梯形图网络和 C 函数基本一一对应,断点调试时能直接定位到具体网络,不用对着晦涩的字节码猜逻辑。
2.3 运行时内核:扫描周期与多任务调度
运行时是 C 程序,跑在 Linux 上,靠 POSIX 多线程模拟 PLC 的周期扫描。默认主循环周期 10ms,分三件事:读输入映像区(IR)、执行用户逻辑、写输出映像区(OR)。输入 I、输出 Q、标志继电器 M 都定义为字节数组,用户逻辑像操作寄存器一样按下标访问,这是梯形图底层的标准做法。
这套架构里有一个关键设计:任务挂接。用户逻辑被编译成函数指针数组task_100ms[]、task_10ms[],按周期毫秒数挂到不同槽位。时间驱动的功能块(气缸动作、定时器)由运行时按 tick 计数自动调用,梯形图里不需要显式写延时循环,这在多任务 PLC 里是刚需。
运行内核同时提供一个看门狗线程和通信线程。通信线程维护 Modbus 协议栈,将 IR/OR/M 区映射到保持寄存器和线圈区,上位机通过协议直接监视和改写 PLC 内部状态。三层各干各的,互不阻塞,这是架构上比较成熟的地方。
3. 核心模块代码拆解:三段可直接抄进自己工程的代码
3.1 梯形图网络转指令表:用栈把并联关系压平
# net_to_il.py — 梯形图网络转指令表(IL)操作码 def serialize_network(network): il = [] for rung in network.rungs: bool_stack = [] for node in rung.nodes: if node.type == "contact": if node.normally_closed: bool_stack.append(f"LDN I{node.addr}") else: bool_stack.append(f"LD I{node.addr}") elif node.type == "parallel_start": il.append(f"OR") elif node.type == "coil": val = " AND ".join(bool_stack) if bool_stack else "1" il.append(f"ST Q{node.addr} // 由 {val} 驱动") bool_stack = [] return il这段代码把梯形图网络看成逻辑树而不是像素图形。每次遇到触点就压入布尔值,并联分支通过OR合并回栈顶,最终生成的 ST 指令直接对应 IEC 61131-3 助记符。无论下游转 C 还是交给解释器,这层指令表都是稳定中间表示,后续代码生成只在指令表上做模式匹配。
参数说明:I{addr}是输入映像区变量,Q{addr}是输出线圈地址;LDN常闭触点取反,LD常开触点直通。生成结果可读性强,出问题可以直接对照梯形图原图查。真实工程里还会在这里加一个网络编号注释,方便运行时日志反查位置。
3.2 扫描周期与看门狗:C 代码骨架
// plc_scan.c — 主扫描循环与看门狗复位 #define SCAN_PERIOD_US 10000 // 主循环周期 10ms static volatile uint8_t io_lock = 0; void io_lock_acquire(void) { while (__sync_lock_test_and_set(&io_lock, 1)) { } } void io_lock_release(void) { __sync_lock_release(&io_lock); } void *plc_task(void *arg) { struct timespec ts; ts.tv_sec = 0; ts.tv_nsec = SCAN_PERIOD_US * 1000L; while (1) { io_lock_acquire(); read_inputs((uint8_t *)IR); // 批量采集 DI 到 IR 区 for (int i = 0; i < task_count; i++) { user_tasks[i](&ctx); // 执行生成的用户 C 函数 } write_outputs((uint8_t *)OR); // 按 OR 镜像刷新端子 wdt_reset(); // 喂看门狗 io_lock_release(); nanosleep(&ts, NULL); } return NULL; }这个循环最核心的设计是输入输出镜像区。read_inputs把端子状态一次性拍进内存数组,用户逻辑在一个扫描周期内读到的值始终一致,不会发生“同一个按钮这一行读到 1、下一行读到 0”的抖动问题。所有输出修改只写 OR 区,逻辑全执行完再统一送端子——这是 PLC 安全和可预测性的基础。
参数说明:task_count编译期确定,user_tasks是生成的函数指针数组;io_lock用原子指令实现轻量锁,防止通信线程读 I/O 区时用户线程正在写。看门狗复位在扫描末尾,如果主循环卡在某个功能块内,看门狗超时后强制重启,保护外部设备不过度输出。
3.3 通信层:Modbus 寄存器映射与字节序处理
// modbus_map.c — 将 IR/OR/M 区暴露为 Modbus 地址 int modbus_read_holding(uint16_t addr, uint16_t *buf, int count) { for (int i = 0; i < count; i++) { uint16_t idx = addr + i; if (idx < sizeof(M_area) / 2) { buf[i] = __builtin_bswap16(M_area[idx]); } else if (idx < sizeof(IR) / 2) { buf[i] = __builtin_bswap16(IR[idx - sizeof(M_area)/2]); } else { return -1; } } return count; }通信层把 PLC 内部寄存器数组映射到 Modbus 保持寄存器空间。这里有个血泪经验:Modbus 协议规定多字节数据大端传输,x86 是天然小端,直接 memcpy 会导致上位机读到的高低字节倒置,调试时发现寄存器值翻倍、减半基本都是它引起。所以每次读写都做bswap16,串口和 TCP 统一走这个函数。
映射规则写成数组配置表,改 PLC 变量地址不用动协议代码,只改表。通信线程每 50ms 轮询外部请求,不占用主扫描周期,即使上位机高频轮询也不会拖慢梯形图执行节奏。
4. 本地搭建与调试攻略:三分钟跑起一个能点灯的演示工程
4.1 编译环境与依赖清单
这个工程在 Linux 下编译最顺利。建议先在一个 x86 Linux 虚机把整体流程跑通,再迁移到目标开发板。依赖就三块:CMake 3.10 以上构建工具、libmodbus 开发库、sqlite3(编辑器保存工程历史用)。
# 拉取源码并初始化子模块 git clone <项目仓库地址> plc-src cd plc-src git submodule update --init --recursive # 安装依赖(Debian/Ubuntu 系) sudo apt install cmake gcc libmodbus-dev sqlite3 # 构建运行时 mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release -DPLATFORM=x86 .. make -j4 plc_runtime构建成功生成plc_runtime可执行文件,不依赖图形界面,无桌面系统也能跑。注意PLATFORM参数决定编译哪套 I/O 底层驱动:x86 平台使用虚拟串口和 GPIO 模拟器,方便开发阶段不接硬件就验证逻辑。真机移植时改成对应平台的驱动文件即可。
4.2 构建演示工程:从编辑器到生成 C 代码
源码目录demo/里附带一个演示工程“流水灯”:三个输出轮流点亮,间隔 500ms。工程文件是 XML 格式(.plcproj),编译后生成generated_plc.c。构建流程如下:
# 方式一:命令行编译(无需打开编辑器) ./bin/plc_compile --input demo/blink.plcproj --output build/generated_plc.c # 方式二:编辑器界面编译,保存后自动生成 ./bin/plc_editor demo/blink.plcproj & # 把生成 C 与运行时内核一起编译 gcc -Iruntime -Ibuild build/generated_plc.c runtime/*.c \ -o build/blink_demo -lpthread -lmodbusplc_compile是命令行版编译前端,接收工程 XML 后把梯形图网络转成 C 函数输出,整个过程几十毫秒。plc_editor是图形编辑器,带实时错误检查,比如线圈重复输出、悬空触点会直接标红,新手建议用编辑器。
启动编译好的程序:
./build/blink_demo -p /dev/ttyS0 -a 32 # -p 指定 Modbus 串口设备;x86 模拟时用虚拟串口对 # -a 指定 Modbus 从站地址(1~247),这里设 32 号站启动后终端显示主循环实际周期、看门狗状态、任务清单。看到Scan: 10.12ms avg之类日志就说明运行时进入稳定周期。
4.3 用 Modbus 工具验证执行结果
跑起来后用mbpoll命令行工具读线圈状态,模拟上位机监控:
# 读 8 个线圈(Q0.0~Q0.7),从地址 0 开始 mbpoll -m rtu -b 9600 -p /dev/ttyS0 -t 0 -a 32 -r 0 -c 8 -0预期输出是地址 0 到 2 的线圈每隔约 500ms 依次点亮,顺序循环——这正是梯形图里 TON 定时器链的效果。如果读到某个线圈始终不变化,优先检查生成 C 代码里该地址的 ST 指令,再查 IL 中间表示是否正确。跑通这一步说明 编辑器→编译→运行→外部协议 的整条链路正常,后面接真机就只是换驱动的事。接下来要讲真正折磨人的地方:部署到真实硬件时的坑。
5. 避坑指南:部署和扩展时最容易踩的五个坑
5.1 输出线圈高频抖动:镜像区没锁住
现象:梯形图里同一个输入驱动两个输出,实际看到其中一个输出不停闪烁,时通时断。
原因:运行时在每个子任务里分别调read_inputs(),子任务与主任务线程并发执行时,输入端子电平正在变化,读到的状态不一致导致输出跟着电平反复。
解决:把read_inputs()移出子任务,只在主扫描循环开头读一次,写入 IR 区后加锁;主循环内所有功能块只访问 IR/OR 区。改完抖动彻底消失。
5.2 看门狗误触发:功能块里有阻塞调用
现象:系统偶尔重启,日志最后一句是wdt timeout,但单步执行看不出问题。
原因:某个功能块内部调用了阻塞式串口读,等待外部设备应答,把 10ms 扫描周期拖成几百毫秒。看门狗在超时阈值内没收到清零标记,直接触发复位。
解决:耗时操作一律丢通信线程,功能块内部用非阻塞方式读缓存;或者把该任务挂到 100ms 槽位,看门狗阈值放宽到 300ms 留足余量。那之后我加功能块第一件事是检查体内有没有阻塞点。
5.3 Modbus 寄存器值翻倍或减半
现象:上位机写 1 到保持寄存器,读回来是 256;写 256 读回来是 1,配置表检查无数次没异常。
原因:x86 是小端机,Modbus 协议强制大端传输。寄存器里存 16 位整数时,交换字节序导致低字节跑到高字节位,正好翻 256 倍;32 位浮点更乱。
解决:统一走bswap16封装,进出协议栈都做字节序修正;映射表里注明寄存器类型(int16/int32/float),严禁无脑 memcpy。这类问题玄学成分高,但根因基本都是字节序。
5.4 输入点表错位:物理端子与地址没对齐
现象:某台设备上 I0.3 按钮按下去永远读 0,但用万用表测端子有 24V 电平。
原因:硬件抽象层的GPIO_PIN_MAP[]数组按厂商标号定义,与实际接线图和设备端子丝印不一致,程序用了错误的数组索引。
解决:平台更换后先跑一次自检工程:所有输入循环置 1 输出到 LED 灯,逐个按下按钮核对灯位,把实际映射打印出来比对。这个步骤省下的排查时间远超开发时间,属于花钱买不来的后悔药。
5.5 浮点功能块输出毛刺
现象:PID 功能块输出偶尔跳到 100 或 -100 再回落,上位机曲线有毛刺,梯形图逻辑查不出异常。
原因:源码把 float 硬转成 uint32 传入功能块,目标平台浮点未对齐,读到未初始化高字节;或者运行时传入参数时指针没按 4 字节对齐。
解决:功能块参数统一用结构体包裹,编译加-mfloat-abi=hard(硬浮点平台);每轮扫描后对浮点输出做isnan()检查,异常时走安全值而不是继续累加。
6. 二次开发入手点:给运行时加一个自定义功能块
拿到源码最常做的事是增加梯形图库里没有的功能块。以“斜坡发生器(Ramp)”为例,沿 编辑器→编译器→运行时 三层各加一笔,最后用 Modbus 验证。
第一步,编辑器元件注册表加图标和参数定义:
# editor_blocks.py — 注册编辑器侧功能块 register_block( name="RampFB", category="Math", in_params=[ ("Target", "REAL", 50.0), # 目标输出值 ("Delta", "REAL", 1.0), # 每周期步进增量 ], out_params=[("Output", "REAL", 0.0)] )第二步,编译器代码生成器加模板:
# codegen_templates.py — 生成 C 调用语句 def generate_ramp(inst): return ( f"{inst.out_var} += {inst.delta};\n" f"if (fabs({inst.target} - {inst.out_var}) < {inst.delta}) " f"{inst.out_var} = {inst.target};\n" )第三步,运行时内核增加函数并注册到任务数组:
// user_blocks.c void ramp_func(FB_CTX *fb, float target, float delta) { float step = (target > fb->out) ? delta : -delta; fb->out += step; if (fabs(target - fb->out) < delta) { fb->out = target; } // 输出限幅保护 fb->out = (fb->out > 100.0f) ? 100.0f : fb->out; fb->out = (fb->out < -100.0f) ? -100.0f : fb->out; fb->event_update = 1; }这样在梯形图里拖一个 RampFB,目标 50、增量 1 的配置下,输出从 0 爬到 50 恰好 50 个扫描周期,即 500ms 完成。从 Modbus 保持寄存器读输出值,能看到一条平滑上升的曲线——注意这个调用周期是可变参数,实际效果随主循环时间基准线性变化。
这套改法验证了插件化改造的关键点:编辑器负责描述、编译器负责生成、运行时负责执行,三层耦合点只有参数表。把三层拆开的好处是改动基本不影响扫描周期——在编辑器里多拖十个功能块,运行时只多几次函数调用,开销几乎不占周期。
经验教训是,每次加完功能块都要强制走一遍 编辑器拖拽→编译→Modbus 读值 的闭环,不要只在命令行里跑 C 函数。因为编辑器保存的 XML 字段名、编译模板、运行时的输入顺序,只在真实链路中才会暴露错位,单测通过不代表集成能过。一份能完整跑起全套流程的源码值得有这种闭环,希望帮到你。
本文还有配套的精品资源,点击获取