资讯动态

开源PLC源码实战:从IEC 61131-3到Modbus通信

发布时间:2026/10/21 4:13:22 来源:尧图企业网站定制
简介一套国外开源的 PLC 编译器源码由作者 jwlog 分享面向有 C/C 基础与编译原理背景的工业自动化开发者完整支持 IEC 61131-3 标准定义的 IL指令表、ST结构化文本、FBD功能块图、LD梯形图、SFC顺序功能图五种编程语言目标是提供这些语言的编译器实现。源码工程约 840KB共 216 个文件其中 45 个 hh 与 54 个 cc 构成 C 核心代码主体test 为自动化测试用例am/m4 等为 autotools 构建脚本txt/readme 为说明文档整体结构便于理解编译器的模块划分与构建流程。目前已有 4563 人学习下载适合对工业控制编程、编译原理和 PLC 开发工具感兴趣的读者深入研究。通过阅读源码可以掌握五种编程语言的语法解析、中间表示与控制流生成等核心实现思路同时可借鉴其自动化测试与构建配置用于二次开发或学习完整项目结构。尤其适合在学习编译器框架、自研小型 PLC 开发环境或工业控制教育场景中作为参考。1. 国外开源PLC源码不是一套固件而是一整套IEC 61131-3协议栈做产线改造的人大概率撞过这样一堵墙老设备上的PLC停产了备件翻三倍价还买不到想换品牌又得把梯形图从头翻译一遍。我一开始也以为“国外开源plc源码”就是某个能免费烧进芯片的替代固件真正把源码包拆开才发现它给的是另一条路——把IEC 61131-3里的梯形图、结构化文本、功能块图全部搬到普通工控机上跑从编译器前端到运行时引擎再到Modbus通信层全部开放。这意味着PLC的扫描周期、任务优先级、寄存器映射都能自己改甚至能把梯形图程序编译成C代码直接进嵌入式硬件。对三类人最有用被停产PLC卡住的产线工程师、想自研控制器的嵌入式开发者、做自动化教学但不想被商业IDE绑死的团队。2. 从选型看起IEC 61131-3五种语言与两套典型开源架构2.1 IEC 61131-3到底是什么LD、FBD、ST、IL、SFC五种编程模型很多初学者把IEC 61131-3当成一种编程语言这其实是最大的误解。它是IEC组织为工业控制器制定的一套编程标准规定了五种编程语言以及它们共用的数据模型、任务模型和通信接口。开源PLC源码通常不是只实现某一种语言而是要在这套标准骨架里跑起来。五种语言按使用场景分两类图形化语言适合写逻辑联锁梯形图LD和功能块图FBD是电工思维的直接映射继电器触点、线圈、定时器这些概念沿用了几十年产线老师傅看梯形图比看代码快得多文本化语言适合写算法结构化文本ST长得像Pascal适合做PID运算、温度补偿这类带浮点计算的逻辑指令表IL现在已经很少单独用更多是作为中间表示层存在。还有一种是顺序功能图SFC专治多步骤流程控制比如自动焊接工作站里按工步切换动作。开源项目对这五种语言的完整度差异很大。有的项目只做LD和ST把FBD当成LD的语法糖有的项目干脆用PLCopen XML这种中间格式作为统一入口——你用厂商的IDE画好梯形图导出一个XML文件开源运行时再把这个XML翻译成可执行代码。这种做法的好处是显而易见的你不必抛弃原有的编程习惯XML就像是编译器前端的通用语言。2.2 两种开源实现路径解释执行与编译执行把源码包解压之后先别急着找Makefile看清楚它走的是哪条实现路径这决定了你能在哪些硬件上跑、实时性能到什么级别。国外开源PLC源码主要有两套路线实现路线执行方式实时性典型部署环境适合场景软PLC解释器运行时逐条解析中间指令毫秒级受OS调度影响Linux工控机、树莓派产线监控、设备数据采集编译器原生代码生成ST/LD转C代码再编译成本地机器码微秒级可配合RTOSx86/Arm嵌入式板卡、自行设计的控制器自研控制器、严苛运动控制我一般会先看源码根目录里有没有compiler和vm或者runtime这样的目录。如果compiler做得很厚有词法分析、语法树、中间表示这些内容它就是编译路线如果核心是一个大循环加一个指令分发表那就是解释器。解释执行的优点是好调试程序加载直接在内存里不用重新编译整个内核现场改完逻辑马上就能跑。缺点也明显每个扫描周期多了一层解释开销而且一旦任务里写了复杂浮点运算抖动会比编译执行大。编译执行则把梯形图或ST程序在宿主机上转成C代码再用交叉编译器编成目标平台的机器码运行时只负责周期调度和I/O刷新确定性好得多。代价是你每次修改程序都得重走一遍编译流程对现场调试不太友好。2.3 拆一个开源PLC源码包编译器前端、虚拟机、通信层把某个国外开源PLC项目源码拉下来按目录扫一圈典型的成熟项目会分成四五个核心模块。我以自己拆过的一个项目为例它的目录结构长这样plc-open-engine/ ├── compiler/ # PLCopen XML解析、梯形图网络转中间指令 ├── runtime/ # 任务调度、周期扫描、变量存储 ├── comm/ # Modbus TCP/RTU、OPC UA客户端 ├── drivers/ # 数字量输入输出、模拟量采集、编码器计数 ├── tools/ # 编译脚本、固件打包工具、仿真器 └── tests/ # 单元测试与循环测试用例compiler这一层是整个项目的门面。它读进来的是PLCopen XML做语法检查之后把每个POU程序组织单元翻译成一种类似三地址码的中间指令比如触点导通、线圈赋值、功能块调用都会被摊平成一条条二元操作。runtime是心脏它维护一个变量表每个输入输出点、内部继电器、定时器都在这个表里有一格扫描周期一到就按顺序执行所有已加载的POU。comm模块对于工程落地方案最重要因为PLC单独跑起来没有意义得上位机、HMI或者SCADA要数据。开源项目里最常见的通信实现是Modbus TCP从站——原因是这个协议太普及了HMI和组态软件几乎都原生支持不需要额外的驱动授权。有些项目还做了OPC UA服务端优点是把数据模型暴露成结构化的节点缺点是配置复杂对嵌入式硬件压力也大。2.4 一条梯形图网络如何变成运行时指令要理解这套源码最好跟一遍一条简单梯形图网络的编译过程。假设是一个电机启停互锁电路A是启动按钮B是停止按钮C是电机接触器反馈输出Q是接触器线圈。它的逻辑是按压A且没有按压B且接触器没吸合时Q得电。network labelMotorStartStop element nameA typecontact / element nameB typecontact nottrue / element nameC typecontact nottrue / element nameQ typecoil / /network编译器先把上面这个XML解析成中间指令0: LOAD A 1: ANDN B 2: ANDN C 3: ST QLOAD把A的状态压入运算栈ANDN是“取反后再与”ST把栈顶结果写到Q。运行时每扫描周期就把这几条指令按顺序跑一遍。这一小段逻辑还能看出一个关键机制梯形图的梯级是从左到右串行求值的而整个程序的任务调度是周期性的。编译器和运行时之间靠一套中间指令集解耦只要你的设备资源够换一个新的编译器前端就能支持更多的编程语言。提示选源码时重点看中间指令集是否独立。如果中间指令直接映射到C语言的if-else说明项目很年轻扩展FBD或SFC会非常吃力。3. 编译与首发跑通从克隆源码到Modbus从站被上位机读到3.1 搭建编译环境前的准备清单开源PLC源码基本都跑在Linux环境下Windows用户要么装WSL要么直接上Linux工控机。我的习惯是先列一张依赖清单缺什么补什么别信README里那句“只需一行命令”的鬼话。核心依赖通常是CMake版本要3.16以上太老的坑很多、GCC交叉编译器、Python3很多工具的构建脚本用它、libmodbus开发库。# Ubuntu 22.04 下的依赖安装按实际系统版本调整 sudo apt update sudo apt install -y build-essential cmake git python3 \ libmodbus-dev libxml2-dev libpcap-dev这段命令里几个包值得展开说libxml2-dev是给PLCopen XML解析器用的没有它编译器前端就废了libpcap-dev只在需要抓包调试时用到如果源码里的通信模块用的是原始套接字而不是libmodbus这个包就可以去掉。装完以后用cmake --version验证版本用gcc --version确认交叉工具链存在避免编译到一半才发现缺东西。3.2 编译运行时与工具链进入到源码根目录用CMake的标准流程构建cd plc-open-engine mkdir build cd build cmake .. -DCMAKE_INSTALL_PREFIX/opt/plc-runtime \ -DBUILD_MODBUSON \ -DBUILD_OPCUAOFF \ -DCMAKE_BUILD_TYPERelease make -j$(nproc) sudo make install-DBUILD_MODBUSON表示把Modbus TCP从站功能编进去-DBUILD_OPCUAOFF先关掉OPC UA减少依赖折腾。为什么要用Release而不是Debug解释器模式下运行时的执行速度受编译优化影响很大Release能比Debug快两三倍。Debug留着以后排查问题时单独编一版。安装完成后/opt/plc-runtime/bin下面会多出两个可执行文件plc-runtime是主程序plc-compiler是离线编译工具。先别急着启动主程序用plc-compiler --version看一眼版本号确认工具链正常否则后面程序加载失败都不知道是自己写错还是工具没编对。3.3 用一个最小ST程序验证整条链路编译跑通只代表代码能编真正的验证要落到“程序加载→扫描执行→外部读到变量”。我一般先写一个最简单的ST程序不做任何逻辑只让一个变量自增PROGRAM main VAR counter : INT : 0; END_VAR counter : counter 1; END_PROGRAM保存为counter.st用编译器工具链转成运行时可直接加载的格式plc-compiler -i counter.st -o counter.obj -target vm-target vm指定生成针对运行时虚拟机的目标文件。如果源码里包含C代码生成后端还能用-target c生成C代码再交叉编译这就是前面说的编译执行路线。启动运行时export LD_LIBRARY_PATH/opt/plc-runtime/lib:$LD_LIBRARY_PATH plc-runtime --load counter.obj --period 50 --modbus-port 502--period 50把扫描周期设为50毫秒--modbus-port 502开启Modbus TCP从站端口。程序运行后用Modbus Poll或者命令行工具去读保持寄存器地址从0开始modbus_read -t 3 -a 0 -n 10 127.0.0.1如果读到counter的值在持续变大就说明整条链路通了一半。还有一个更快的验证办法在ST里给一个BOOL变量赋值然后在工控机上用Python的pymodbus库去读更贴近真实上位机的行为。3.4 任务周期与输入输出映射参数我看到很多人在这一步卡住程序在运行时里跑得好好的但上位机读到的输入输出映射位置跟梯形图里写的地址对不上。这是因为开源PLC的I/O映射和商业PLC的“物理地址直接对应”不一样它是在运行时启动配置里指定的。以下面这段配置为例{ task: { name: cyclic_task, period_ms: 50, priority: 1 }, modbus: { tcp_port: 502, input_coil_base: 10001, holding_register_base: 30001, input_register_base: 40001 } }input_coil_base是数字量输入点的起始Modbus地址holding_register_base是模拟量输入或内部保持寄存器的起始地址。梯形图里的%IX0.0如果映射到10001Modbus报文里实际地址就是010001减基址这个偏移是很多联调对不上的元凶。把基址和偏移关系写清楚后面HMI组态时才不会把地址张冠李戴。提示Modbus协议里的线圈地址和寄存器地址在报文里都是0起始的但人机界面上习惯用1起始的编号。开源运行时配置里的base地址就是给人看的编号接收报文时要减去这个基值这一减一加经常会出问题。4. 梯形图工程实战做一个传送带分拣的软PLC程序4.1 用PLCopen XML写一个带定时器和计数器的梯形图网络前面那个自增程序只是验证链路真正要上产线还得回到梯形图。下面以一个传送带分拣场景为例入口光电传感器检测到箱子传送带电机启动箱子走到分拣位时气缸推出同时计数器加一。要完整写这个程序得有计时器、计数器、置位复位这几种常用元素。PLCopen XML里梯形图网络的组织方式是每个network代表一个梯级从左母线到右母线元素用element标签描述。下面是一个电机控制网络的示意network labelConveyorStart element nameStartPB typecontact address%IX0.0 / element nameStopPB typecontact nottrue address%IX0.1 / element nameMotorRun typecoil address%QX0.0 / element nameTimerOn typetimer preset500 / /network这个网络里StartPB是启动按钮输入StopPB是停止按钮输入MotorRun是电机输出线圈TimerOn是一个预设值500毫秒的接通延时定时器。运行时解析这个XML时会按元素顺序构建一个求值链先读启动按钮状态与停止按钮取反做“与”逻辑结果输出给电机线圈同时启动定时器。这里有个容易踩坑的细节XML里元素的书写顺序就是梯形图从左到右的串联顺序如果你把定时器放在线圈后面定时器会晚一个扫描周期才启动在需要精确联锁的场合会引入不可控的延迟。4.2 把梯形图工程编译并下载到运行时一个完整的PLC工程通常包含多个网络而不是一个孤零零的梯级。把这些网络组织成一个工程文件然后交给编译器处理plc-compiler -i conveyor_project.xml -o conveyor.obj -target vm编译器会先做语义检查比如线圈是不是重复输出、定时器的预设值是不是负数、触点地址是否超出配置的I/O范围。这一步查出问题比运行时跑起来再抓狂好得多。我见过有人在XML里把nottrue写成了negatedtrue编译器直接报“未知属性”整个文件被拒收。编译通过后把目标文件部署到运行时的加载目录cp conveyor.obj /opt/plc-runtime/programs/ plc-runtime --load conveyor.obj --config /etc/plc-runtime.json运行时加载后会做一次冷启动所有变量进入初始状态。如果程序里你用了保持型变量在VAR声明里带RETAIN关键字它的值会从上次保存的镜像文件里恢复这在产线断电恢复时特别重要——传送带上的箱子位置不能因为PLC重启就丢了。4.3 任务调度与扫描周期分配开源PLC源码里任务调度是比语言本身更值得研究的模块。商业PLC把所有逻辑放在一个循环里跑开源项目为了支持不同实时性要求的逻辑往往会实现多种任务类型任务类型触发方式典型用途周期建议循环任务固定周期逻辑联锁、电机控制10~100ms事件任务边沿触发计数器捕获、急停不固定定时任务RTC触发报表生成、设备自检秒级及以上在上面的配置文件里我把priority设成了1。这个参数值得展开开源运行时的任务优先级通常不是抢占式的而是协作式调度——一个任务没跑完另一个任务必须等。如果你把一个耗时50毫秒的任务优先级设成1另一个20毫秒的高速任务设成2那么高速任务会被硬生生拖到50毫秒周期。正确的做法是把耗时长的任务降优先级让短周期任务先跑。我通常把急停逻辑放在优先级最高的事件任务里管道压力联锁放循环任务统计数据放定时任务这样三层各干各的互不拖累。4.4 联调从梯形图到Modbus寄存器映射表梯形图里的%IX0.0这些地址是运行时内部的抽象地址对外要做成Modbus寄存器才有上位机来读。这一步需要建立一张映射表下面是传送带项目的实际映射逻辑地址符号名Modbus地址方向%IX0.0启动按钮10001只读%IX0.1停止按钮10002只读%IX0.2分拣位光电10003只读%QX0.0电机接触器00001读写%QX0.1气缸电磁阀00002读写%MW10分拣计数30001只读映射表做好之后我用Python脚本模拟上位机读一圈from pymodbus.client import ModbusTcpClient client ModbusTcpClient(127.0.0.1, port502) client.connect() # 读启动按钮和停止按钮状态 inputs client.read_coils(0, 2, unit1) print(启动按钮:, inputs.bits[0], 停止按钮:, inputs.bits[1]) # 读分拣计数器 counter client.read_holding_registers(0, 1, unit1) print(分拣计数:, counter.registers[0]) client.close()这里read_coils(0, 2)读的是Modbus地址0和1对应映射表里的10001和10002注意要减去基址。unit1是Modbus从站地址如果你的运行时配置了从站地址为2这里要改成2否则通信端那边会一直报设备无响应。寄存器读出来是16位无符号整数如果梯形图里用的是INT类型值范围是-32768到32767Python端读取时要自己转换成有符号数。注意Modbus读线圈和读寄存器是两个不同的功能码读线圈用FC01读寄存器用FC03。很多调试工具会把这两个搞混表现是连上了但数据永远是0这不是程序错了是协议层的请求发错了。5. 避坑清单四个让我翻过车的开源PLC落地问题5.1 编译时报错undefined reference to plc_xml_parse现象执行make的时候链接阶段抛出一堆undefined reference错误指向的符号名都带plc_前缀。原因链接器找不到编译器前端的库文件。plc-compiler编译成功了但运行时主程序链接的是静态库路径而那个路径下只有编译器版本的库。解决回到build目录检查CMakeCache.txt里的CMAKE_INSTALL_PREFIX确认运行时和编译器装到了同一个前缀路径下如果源码支持分开安装把库文件软链到/opt/plc-runtime/lib下。从那以后我编译任何开源PLC源码第一步先统一安装路径省掉后面大量的环境排查时间。5.2 梯形图下载成功后线圈全部不动作现象程序加载没有报错任务也显示在跑但所有输出线圈的变量值都是0用Modbus读写也改不动。原因I/O映射配置里把输出线圈的访问权限设成了只读。很多开源运行时为了安全默认把Modbus侧对线圈区的写入功能禁掉防止上位机误操作。梯形图里线圈输出的动作是在运行时内部完成的与Modbus写入无关所以读出来一直是0。解决在运行时配置文件里找到modbus节点把coil_write_enable设为true重启后输出线圈就能通过Modbus被写入和读取了。顺带说一句如果接入的是SCADA系统建议保持写入禁用只让PLC自己控制输出避免上位机误触发设备。5.3 Modbus地址总是偏移一位现象上位机配置保持寄存器地址30001读取的值一直是0改成30002就正常。原因这是开源PLC社区最经典的“一坑坑三年”问题。Modbus协议里寄存器地址在报文中是0起始编号而人和组态软件习惯用1起始的编号。运行时暴露的基址30001表示“从报文地址0开始”但上位机配置的30002实际发的是报文地址1所以读到的永远是第二格数据。解决把上位机里的地址减1或者检查运行时配置里是否提供了address_offset参数把偏移配成1。我在HMI组态时已经养成了习惯任何地址先减1发一帧测试报文用Modbus抓包工具确认实际请求地址再填组态基本不会再栽在这个偏移上。5.4 程序里写了延时整条流水线跟着“打嗝”现象任务周期配置为20毫秒但实际扫描时间经常跳到200毫秒以上输出动作变得一顿一顿。原因梯形图里用了阻塞型延时函数或者WHILE循环等待某个条件把整个任务给拖死了。开源PLC运行时是单线程协作式调度一个任务内部卡住其他所有任务都得排队等着相当于活活把PLC变成了死循环单片机。解决把延时改成定时器功能块PLC扫描是周期性的不应该在逻辑里等待实时时间的流逝。具体做法是用TON定时器记录起始时间在每个扫描周期检查当前时间与启动时刻的差值大于设定值再执行后续动作。逻辑上稍微绕一点但扫描周期永远稳定。我第一次栽在这上面时整个车间的分拣站连着停顿了三次最后才发现是一个WHILE循环把任务堵住了。6. 进阶用WebSocket把运行时变量实时推到网页HMI很多人把开源PLC跑到“能用Modbus读写”就觉得大功告成了其实还可以再加一层用一个小脚本把运行时里的变量实时推到浏览器的WebSocket接口上然后在网页里画几个按钮和指示灯一个轻量HMI就出来了。这个方案特别适合那些不想为商用组态软件掏授权费的小项目。核心思路是Python脚本定时从Modbus读取需要监控的变量通过WebSocket广播给前端页面前端页面的按钮点击事件反过来通过HTTP接口写回Modbus线圈。下面是脚本的关键部分import asyncio import websockets from pymodbus.client import ModbusTcpClient client ModbusTcpClient(127.0.0.1, port502) client.connect() async def broadcast_state(): async with websockets.serve(handler, 0.0.0.0, 8080): while True: # 读取电机控制线圈和分拣计数寄存器 coil client.read_coils(0, 1, unit1) count client.read_holding_registers(0, 1, unit1) message f{{motor: {coil.bits[0]}, count: {count.registers[0]}}} await asyncio.get_event_loop().broadcast(message) await asyncio.sleep(0.1) # 10Hz刷新率 asyncio.run(broadcast_state())这段脚本里有两个参数值得注意0.1的刷新周期对应10Hz的推送频率如果你监控的是毫秒级联锁信号这个频率根本不够看趋势可以调高到20Hz但会占用更多CPU和网络带宽8080是WebSocket端口如果部署在工控机上要在防火墙里放行这个端口否则浏览器永远连不上。网页端用一个简单的WebSocket监听就能收到数据const ws new WebSocket(ws://192.168.1.100:8080); ws.onmessage (event) { const state JSON.parse(event.data); document.getElementById(motor-indicator).style.background state.motor ? #00aa00 : #aaaaaa; document.getElementById(count-display).textContent state.count; };这个方案比直接买组态软件的落地成本低得多而且网页HMI可以跑在任何设备上——工控机、平板、甚至手机浏览器。从那以后我做每套软PLC项目都强制走一遍“变量映射表→Modbus点表→WebSocket转发”这个流程先把点表理清楚后面接任何HMI都只改一层转发脚本不动PLC逻辑。这套玩法在几个模拟项目里反复验证过稳定性和实时性都够用希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑