OpenPLC Editor v4 Modbus 配置
OpenPLC 编辑器使用基于 Modbus 的自定义调试协议作为其主要机制,以实现实时的 PLC 变量检查与强制写入。与标准的 Modbus(读取线圈和寄存器)不同,编辑器在 Modbus PDU 层注入了五个专有功能码(0x41–0x45),支持在 TCP 和 RTU 两种传输实现中进行程序验证、批量变量读取和写入强制。本文档将深入剖析该协议的架构、双传输客户端的实现,以及将前端与特定传输层细节隔离的端口抽象机制。
自定义功能码协议
OpenPLC 运行时通过五个非标准 Modbus 功能码暴露调试功能,这些功能码运行在标准 Modbus ADU/TDU 帧结构之上。TCP 和 RTU 两种传输方式共享完全相同的功能码,仅帧结构和错误检测层有所不同。
| 功能码 | 十六进制 | 名称 | 方向 | 用途 |
|---|---|---|---|---|
DEBUG_INFO | 0x41 | 调试信息 | 请求 | 保留用于信息查询 |
DEBUG_SET | 0x42 | 调试设置 | 请求→响应 | 在目标上写入或强制修改变量值 |
DEBUG_GET | 0x43 | 调试获取 | 请求→响应 | 通过索引读取单个变量 |
DEBUG_GET_LIST | 0x44 | 调试获取列表 | 请求→响应 | 通过索引数组批量读取多个变量 |
DEBUG_GET_MD5 | 0x45 | 调试获取 MD5 | 请求→响应 | 获取运行中程序的 MD5 哈希值以进行验证 |
运行时使用从ModbusDebugResponse枚举中提取的状态字节进行响应:
| 状态码 | 十六进制 | 含义 |
|---|---|---|
SUCCESS | 0x7E | 操作成功完成 |
ERROR_OUT_OF_BOUNDS | 0x81 | 变量索引超出程序的变量表范围 |
ERROR_OUT_OF_MEMORY | 0x82 | 目标无法为响应分配内存 |
功能码 0x41–0x45 占据了 Modbus 规范中的“用户定义”范围(标准用户定义范围为 0x65–0x6F,但 OpenPLC 运行时接受 0x41 及以上的自定义代码)。请确保任何中间 Modbus 网关或防火墙配置为允许这些非标准代码通过,而不进行过滤。
双传输架构
编辑器实现了两种传输客户端,它们共享相同的 PDU 层协议,但在帧结构、连接语义和错误检测方面存在根本差异:
适配器层在运行时根据当前设备配置选择合适的传输方式 —— 网络连接的运行时使用 TCP,串口/USB 连接的设备使用 RTU,集成的 AVR 模拟器则使用注入的虚拟串口。
ModbusTcpClient — 网络传输
ModbusTcpClient封装了原生的 Node.jsnet.Socket,通过标准 Modbus TCP 帧结构与 OpenPLC 运行时通信。每个请求都被包装在Modbus TCP 应用报文头 (MBAP)中,包含 2 字节的事务 ID、2 字节的协议 ID(始终为0x0000)、2 字节的长度字段和 1 字节的单元 ID。
连接与并发模型
客户端通过 Promise 链互斥锁 (sendRequestMutex) 强制执行严格的请求串行化。这可以防止共享 TCP 套接字上出现响应交错 —— 这是一个关键问题,因为 Modbus TCP 协议除了事务 ID 之外,没有内在的请求-响应关联机制。互斥锁将每次sendTcpRequestImpl调用链接起来,确保同一时间只存在一个进行中的请求:
sendRequestMutex = sendRequestMutex.then( () => sendTcpRequestImpl(request).then(resolve, reject), () => sendTcpRequestImpl(request).then(resolve, reject) )事务 ID 会以模运算方式自动递增 ((id + 1) % 65536),并在每次响应时进行验证,以检测过期或不匹配的回复。
关键操作
MD5 验证(getMd5Hash):构建一个带有字节序校验值 (0xDEAD) 的 12 字节 MBAP 帧请求。响应包含编译后的 ST 程序的 MD5 哈希值(以 UTF-8 字符串形式),使编辑器能够在启动调试会话之前验证正在运行的程序是否与当前项目匹配。
批量变量读取(getVariablesList):接受一个变量索引数组,并将它们打包到单个请求中。响应包含 PLC 滴答计数(4 字节UInt32BE)、最后处理的索引(2 字节UInt16BE)、响应大小字段和原始变量数据缓冲区。这种批处理对于高效轮询至关重要 —— 调试器可以在单次往返中读取所有监视的变量,而无需为每个变量单独发出请求。
变量写入/强制(setVariable):发送变量索引、强制标志(1 = 覆盖 PLC 逻辑,0 = 释放强制)、数据长度和原始值字节。当force为false且未提供valueBuffer时,将写入单个零字节作为占位符,从而有效地释放对该变量的任何活动强制。
ModbusRtuClient — 串口传输
ModbusRtuClient使用 Modbus RTU 帧结构通过串口进行通信,其中每一帧都以通过预计算查找表(256 项高低字节表)计算的CRC-16校验和作为结束。此客户端处理与 Arduino 及类似开发板的物理串口连接,以及 AVR 模拟器注入的虚拟串口。
CRC-16 计算
CRC 使用标准的 Modbus RTU 多项式与两个 256 字节的查找表 (CRC_HI_TABLE和CRC_LO_TABLE) 进行计算。该算法处理帧的每个字节,将高 CRC 字节与数据字节进行异或运算以索引两个表,然后交换高低字节。生成的 16 位 CRC 以大端序附加到帧的末尾。
当 RTU 客户端收到响应时,它会验证 CRC,但将不匹配视为非致命错误—— OpenPLC 调试器故意忽略 CRC 错误,因为基于 Arduino 的目标设备可能会产生边缘串口时序,导致偶尔的 CRC 不匹配而不会造成数据损坏。这是一个权衡调试可靠性而牺牲严格协议合规性的有意设计取舍。
帧组装与检测
与 TCP 中 MBAP 头提供显式长度信息不同,RTU 帧没有长度字段。客户端使用基于静默的超时来检测帧完成:在接收数据后,它会等待FRAME_COMPLETE_TIMEOUT_MS(10ms) 以获取额外字节。当在此时间窗口内没有更多字节到达时,即认为帧已完成,剥离 CRC,并在剩余 PDU 的前面填充 6 个零字节,以创建与 TCP 客户端 MBAP 风格解析兼容的统一响应格式:
const paddedResponse = Buffer.alloc(6 + responseWithoutCrc.length) paddedResponse.fill(0, 0, 6) responseWithoutCrc.copy(paddedResponse, 6)这种填充技巧允许两种传输方式在适配器层中共享相同的 PDU 解析逻辑。
Arduino 引导程序延迟
当连接到物理 Arduino 开发板时,构造函数会在串口打开后引入ARDUINO_BOOTLOADER_DELAY_MS(2500ms) 的延迟。此延迟允许 Arduino 引导程序超时并在发送第一个 Modbus 请求之前将控制权转移给用户程序 —— 如果没有此延迟,早期请求将被引导程序消耗并静默丢弃。
MD5 重试策略
getMd5Hash方法实现了一个重试循环,包含MD5_REQUEST_MAX_RETRIES(3 次尝试)和MD5_REQUEST_RETRY_DELAY_MS(尝试间隔 500ms)。这弥补了串口通信相比 TCP 更高的错误率 —— 连接后的第一个 MD5 请求可能会因目标仍在初始化而失败,因此重试提供了优雅的恢复机制,而不会将错误暴露给 UI。
用于模拟的注入串口
RTU 客户端支持通过ModbusRtuClientOptions中的serialPort选项依赖注入预构建的串口。提供此选项时,客户端跳过SerialPort的构建,直接使用注入的端口。这就是 AVR 模拟器集成提供虚拟串口的机制 —— 模拟器创建一对VirtualSerialPort,将一端注入 RTU 客户端,并用模拟的 PLC 响应驱动另一端。
DebuggerPort — 传输抽象
端口层中的DebuggerPort接口将 TCP/RTU 的区别从前端完全隐藏。它定义了映射到底层 Modbus 功能码的七个操作,以及连接生命周期和事件订阅方法:
| 端口方法 | 底层 Modbus 操作 | 关键返回字段 |
|---|---|---|
connect() | 打开 TCP 套接字或串口 | { success, error? } |
disconnect() | 关闭套接字/端口 | { success } |
getVariablesList(indexes) | DEBUG_GET_LIST(0x44) | { success, tick, lastIndex, data, error? } |
setVariable(index, force, buffer) | DEBUG_SET(0x42) | { success, error? } |
verifyMd5(expectedMd5) | DEBUG_GET_MD5(0x45) | { match, actualMd5, error? } |
readProgramMd5(path, board) | 读取磁盘上编译的 ST 产物 | { success, md5?, error? } |
readDebugFile(path, board) | 读取磁盘上的.dbg变量映射 | { success, content?, error? } |
onDisconnected(callback) | 套接字/端口关闭事件 | Unsubscribe函数 |
isConnected() | 套接字/端口状态检查 | boolean |
编辑器适配器通过 Electron IPC Bridge 将这些调用路由到实际ModbusTcpClient或ModbusRtuClient实例所在的主进程。Web 适配器将通过带有 WebRTC 或 HTTP 传输的DebugBridge单例进行路由。这种架构分离意味着前端调试器 UI 完全不知道目标是经由 TCP、串口还是模拟器到达的。
前端调试器集成
调试器有机组件通过 Zustand 工作区存储消费 Modbus 数据管道。两个关键的存储字段驱动调试显示:
debugTick:由getVariablesList返回的 PLC 循环计数器,用于将变量快照与执行进度相关联。debugDataStale:指示轮询数据可能过时的标志(例如,重新连接后)。
该组件使用两个 Hook ——useDebugBoolValuesMap和useDebugNonBoolValuesMap—— 将来自getVariablesList的原始Buffer数据解析为类型化的 JavaScript 值。布尔变量映射为0/1用于图表渲染,而数值变量则根据其 IEC 61131-3 数据类型进行解析。
调试器为每个变量维护一个滚动时间序列缓冲区,最多可达MAX_BUFFER_SECONDS(600 秒 / 10 分钟),并具有可配置的显示范围(1s、10s、30s、1m、5m、10m)。暂停/恢复控制在不断开 Modbus 会话的情况下停止数据摄取 —— 暂停时,追加新数据点的useEffect会提前返回,但适配器层中的轮询循环会继续从目标读取数据,以防止套接字缓冲区累积。
传输对比
| 特性 | ModbusTcpClient | ModbusRtuClient |
|---|---|---|
| 传输方式 | TCP 套接字 (net.Socket) | 串口 (serialport) 或虚拟端口 |
| 帧结构 | MBAP 头 (7 字节: TxID + ProtocolID + Length + UnitID) | RTU 帧 (SlaveID + FC + Data + CRC16) |
| 错误检测 | TCP 校验和(由操作系统处理) | 基于查找表的 CRC-16(不匹配时为非致命错误) |
| 帧定界 | MBAP 头中的显式长度 | 基于静默的超时 (10ms 字符间间隙) |
| 并发 | Promise 链互斥锁 (以 TxID 验证作为备份) | Promise 链互斥锁 + 输入缓冲区刷新 |
| 连接延迟 | 无 | 2500ms Arduino 引导程序延迟 |
| MD5 重试 | 无(单次尝试) | 3 次重试,间隔 500ms |
| 响应标准化 | 原始 MBAP 响应 | 6 字节零填充前缀,用于 MBAP 兼容性 |
| 典型目标 | 网络上的 OpenPLC 运行时 | Arduino/串口设备或 AVR 模拟器 |
