开源PLC控制系统:从梯形图到真实信号的全链路源码解析 简介这是一份来自国外开源社区的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 源文件这一步简化是整个项目最值得抄的地方。梯形图里一个网络通常是这种结构左母线上两个触点串联分支上再并联一个触点右边接一个线圈中间还可能夹一个定时器功能块。指令表序列用 LDLoad、AND、OR、STStore这类 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(fLDN I{node.addr}) else: bool_stack.append(fLD I{node.addr}) elif node.type parallel_start: il.append(fOR) elif node.type coil: val AND .join(bool_stack) if bool_stack else 1 il.append(fST 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_TYPERelease -DPLATFORMx86 .. 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-abihard硬浮点平台每轮扫描后对浮点输出做isnan()检查异常时走安全值而不是继续累加。6. 二次开发入手点给运行时加一个自定义功能块拿到源码最常做的事是增加梯形图库里没有的功能块。以“斜坡发生器Ramp”为例沿 编辑器→编译器→运行时 三层各加一笔最后用 Modbus 验证。第一步编辑器元件注册表加图标和参数定义# editor_blocks.py — 注册编辑器侧功能块 register_block( nameRampFB, categoryMath, 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 fif (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 字段名、编译模板、运行时的输入顺序只在真实链路中才会暴露错位单测通过不代表集成能过。一份能完整跑起全套流程的源码值得有这种闭环希望帮到你。本文还有配套的精品资源点击获取