首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
0
0
基于C#实现CodesysV3符号变量访问
发表于: 2026-9-7 11:05
292
基于C#实现CodesysV3符号变量访问
Coder_shen
2026-9-7 11:05
292
# 基于C#实现CodesysV3符号变量访问 > CODESYS V3 是目前工业控制领域应用最广泛的软 PLC 运行时之一。本文基于一套完整的 C# 驱动实现,介绍如何从零实现 CODESYS V3 底层通信协议栈,完成连接、登录、符号变量树枚举以及变量批量读写,让上位机(SCADA、HMI、MES、数据采集)无需 OPC 服务器即可直接访问 PLC 符号变量。 ## 1. 背景与动机 CODESYS V3 Runtime 运行在工业 PC 或嵌入式控制器上,变量默认以**符号(Symbol)**形式组织,例如 `Application.PLC_PRG.nCounter`、`Application.GVL.TMP_BOOL`。上位机访问这些变量的常见方案有: | 方案 | 优点 | 缺点 | | --- | --- | --- | | OPC UA / OPC DA 服务器 | 标准、生态成熟 | 需要额外安装配置运行时组件,占用控制器资源 | | Modbus TCP | 实现简单 | 只能访问被映射到寄存器区的少量变量,不支持符号名 | | 厂商 SDK / 网关 | 稳定 | 授权费用高、平台受限、引入中间环节 | | **直接实现 CODESYS V3 协议** | 无中间件、按符号名直接读写、批量性能好 | 需要逆向并正确实现协议栈细节 | 本文介绍的 `CodeSysCommDriver` 是一个纯 C#(.NET Framework 4.0)实现,覆盖了 CODESYS V3 协议从传输层到应用层的完整报文收发,并提供与 OPC 类似的**符号浏览(Browse)/ 批量读 / 批量写** API。 ## 2. CODESYS V3 通信协议分层 CODESYS V3 的在线通信协议是一套**层层封装**的二进制报文体系,每层都带自己的头与校验: ```mermaid flowchart TB subgraph TCP 11740 / UDP 1740 A["Block Driver Layer<br/>magic 0xE8170100 + 总长度"] B["Datagram Layer<br/>magic 0xC5、服务号、地址、4字节对齐填充"] C["Channel Layer<br/>OpenChannel / AppBlock / ACK / KeepAlive"] D["Service Layer<br/>命令组 CmdGroup + 子命令 SubCmd + SessionId"] E["Tag Layer<br/>TLV 结构(7bit 变长整数编码)"] end A --> B --> C --> D --> E ``` - **Block Driver Layer**:仅 TCP 使用,8 字节头(4 字节 magic `0xE8170100` + 4 字节总长度),用于粘包拆包。 - **Datagram Layer**:6 字节头,magic 为 `0xC5`,包含跳数、优先级、服务号(NS 名称服务 / 通道管理等)、收发地址长度等位域;地址后按 4 字节对齐填充。 - **Channel Layer**:通道管理消息(打开/关闭通道、KeepAlive、通道错误)与应用数据块消息。大数据包被拆分为 `ApplicationBlockFirst`(20 字节头,含 CRC32 与剩余长度)+ 若干 `ApplicationBlock`(12 字节头),接收方对每个数据块回 `ApplicationAck` 做流控。 - **Service Layer**:20 字节头,`protocol_id` 固定为 `0xCD55`,随后是命令组 `CmdGroup`(如 `CmpIecVarAccess = 0x09`、`CmpDevice = 0x01`)、子命令 `SubCmd`、`session_id`、内容长度等。 - **Tag Layer**:所有业务数据(变量名、类型、数值)都以 Tag 承载。Tag 是 TLV 结构:ID 与长度使用 **7bit 变长整数**编码,ID ≥ 0x80 的 Tag 为父节点,可嵌套子 Tag。 一个重要的工程约束是 **PDU 最大长度 516 字节**(`MAX_PDU_SIZE`)。批量读写大量变量时,必须正确实现多包拆分与 ACK 流控,否则控制器会停止应答。 ## 3. 整体架构设计 驱动按职责分为六层,从上到下依次为: | 类 | 职责 | | --- | --- | | `CodeSysController` | 对外门面:连接/断开、符号树加载与浏览、批量读写、类型转换、缓存与并发控制 | | `CodeSysV3Channel` | 通道会话:Send/Read 请求应答、SessionCreate、Login/Logout、设备信息 | | `CodeSysV3Device` | 传输设备:TCP/UDP Socket、收发线程、粘包拆包、KeepAlive、ACK 流控 | | `CodeSysV3Protocol` | 协议编解码:各层报文构建与解析、Tag 层编解码 | | `CodeSysV3Structures` | 线格式结构体:Datagram/Channel/Service 各层头部、`Tag`、`Crc32` | | `CodeSysV3Encryption` | 登录口令加密(挑战-应答机制) | | `CdesysGlobalVarXmlFile` | 离线符号表:解析 CODESYS 导出的 Symbol configuration XML | | `CodesysVarTreeNode` / `ItemAddress` / `PValue` | 符号树节点、浏览结果项、类型化值包装 | 调用方只与 `CodeSysController`、`ItemAddress`、`PValue` 三个公开类型打交道,其余均为 `internal`。 ## 4. 连接、会话与登录 ### 4.1 传输层探测 CODESYS 控制器可能走 UDP(端口 1740)或 TCP(端口 11740)。驱动先通过 UDP 向端口 1740 发送 `AddressResolve` 名称服务请求,若得到目标地址则选 UDP;否则回退 TCP,向 11740 发送 `NameResolve` 请求验证。 ### 4.2 获取设备信息与打开通道 连接成功后,通过名称服务(NS)获取设备信息:`node_name`、`device_name`、`vendor_name`、`firmware_str`。随后发送 `OpenChannelRequest`,报文包含 CRC32 校验和与接收缓冲区大小(`0x0007d000`),应答中携带通道号 `channel_id`,后续所有业务报文都基于该通道。 ### 4.3 会话创建与登录 不同固件版本握手流程略有差异(实测固件 3.5.17 无需 `SessionCreate`,驱动按版本号自动判断跳过)。登录采用**挑战-应答(challenge-response)**机制:客户端将控制器下发的挑战值与登录口令一起做摘要计算后提交,控制器校验通过后返回 `session_id`,此后所有 Service Layer 请求都必须带上它。出于安全考虑,口令摘要算法的实现细节此处从略。 ### 4.4 心跳保活 控制器会周期性向通道发送 `KeepAlive` 消息,驱动收到后原样回发确认。每累计 6 次 KeepAlive,驱动触发一次内部事件,重新执行 `CmpIecVarAccess` 的 `0x07` 子命令刷新会话句柄(`tag_0x38` / `tag_0x3a`)——这些句柄会随控制器会话状态变化而失效,必须周期性更新。 ## 5. 符号变量的枚举与解析 ### 5.1 在线符号树遍历 符号变量访问的核心命令组是 `CmpIecVarAccess`(0x09),驱动按如下流程构建符号树: ```mermaid sequenceDiagram participant C as C# Driver participant P as CODESYS Runtime C->>P: CmpIecVarAccess 0x07(获取根符号) P-->>C: 0x30 根节点名 + 0x38/0x3a 会话句柄 loop 按需展开 C->>P: CmpIecVarAccess 0x08(0x40 标志 + 0x39 句柄 + 0x32 地址 + 0x30 路径) alt 中间节点 P-->>C: 0x30 子节点列表 else 叶子节点 P-->>C: 0x31 类型信息 + 0x44 end end C->>P: CmpIecVarAccess 0x09(0x32 + 0x33 块地址) P-->>C: 0xB4 块(0x31 成员 / 0x35 元素类型 / 0x36 数组信息) ``` - **0x07**:获取顶层符号(Application 等根节点); - **0x08**:给定路径浏览子节点。响应中出现 `0x30` 说明是中间节点(如程序、功能块、全局变量表),出现 `0x31` 说明是叶子变量,其数据区第 3 字节即**类型码**(`tag_0x31.data[2]`); - **0x09**:对结构体(STRUCT)与数组(ARRAY)节点,携带节点类型信息末尾 8 字节作为块地址,请求其成员/元素布局。`0x36` 标签描述数组维数、各维起止下标与总字节数。 驱动对一维/二维/三维数组分别展开,按 `arrName[0]`、`arrName[i,j]`、`arrName[i,j,k]` 的形式为每个元素构造类型信息 Tag 并挂入符号树。 ### 5.2 离线符号表(Symbol configuration XML) 在线遍历每个节点都需要一次网络往返。若工程较大,可在 CODESYS IDE 中为 Application 添加 **Symbol configuration** 对象,导出 XML 符号表;驱动解析其 `TypeList`(TypeSimple / TypeArray / TypeUserDef)与 `NodeList`,在本地构建同样的符号树,实现零网络开销的浏览。解析器做了两点关键处理: - **延迟展开**:数组与嵌套结构体通过 `Func<Node, List<Node>>` 展开动作在首次访问 `Children` 时才生成,避免加载时递归构建整个大数组; - **循环引用保护**:结构体间可能出现间接类型循环(如 `T_A → ARRAY OF T_B → T_B → T_A`),展开链携带 `visitedTypes` 集合,检测到环立即停止展开。 ## 6. 变量读取流程 读取分两步:**注册(可选、可缓存)**与**读取**。 ### 6.1 变量注册(SubCmd 0x01) 按变量名列表组织 Tag 请求: ```csharp Tag tag_0x13 = new Tag(0x13, new byte[] { 0x48, 0x00, 0x01, 0x00 }); // 标志 tag_lst.Add(tag_0x13); tag_lst.Add(tag_0x3a); // 会话句柄 tag_lst.Add(new Tag(0x18, BitConverter.GetBytes((uint)count))); // 变量个数 foreach (var name in variableNames) { data_buffer.AddRange(BitConverter.GetBytes((ushort)(varLen + 1))); data_buffer.AddRange(encoding.GetBytes(name)); data_buffer.AddRange(generateSuffix(varLen)); // 补齐到4字节边界 tag_lst.Add(new Tag(0x19, data_buffer.ToArray(), 0x42)); // 名称+类型请求 } ``` 响应中的 `0x10` Tag 是本次注册列表的**读取句柄**。驱动以变量名列表的 SHA256 为键缓存该句柄(`Tag_0x10Cache`),同一组变量的重复读取不再注册。 ### 6.2 读取(SubCmd 0x03) 携带注册句柄发送 `0x03` 请求,响应中每个变量的数据位于 `0x1d` Tag(跳过 4 字节头部即为原始字节数据),错误信息位于 `0x20`。驱动按变量类型把原始字节转换为 .NET 对象。 ```mermaid sequenceDiagram participant C as C# Driver participant P as CODESYS Runtime Note over C: 首次:按变量名列表构建 0x13/0x3a/0x18/0x19 C->>P: CmpIecVarAccess 0x01(注册变量组) P-->>C: 0x10 读取句柄(缓存于 Tag_0x10Cache) C->>P: CmpIecVarAccess 0x03(携带 0x10 句柄) P-->>C: 0x1d 各变量数据 + 0x20 错误码 ``` ### 6.3 多包传输与 ACK 流控 由于 PDU 上限 516 字节,批量读取的应答会被拆成多个 `ApplicationBlock`。接收线程按 `blk_id` 递增顺序拼接载荷,每收到一个块即回发 `ApplicationAck(blk_id)`,控制器收到 ACK 后继续发送下一块;`ApplicationBlockFirst` 头中的 `remaining_data_size` 用于判断整条消息是否收齐,收齐后一次性唤醒等待的调用方。 ## 7. 变量写入流程 写入使用 SubCmd 0x04,报文组织为: ```csharp Tag tag_0x13 = new Tag(0x13, new byte[] { 0x00, 0x00, 0x00, 0x00 }); Tag tag_0x21 = new Tag(0x21, nameBytes + suffix); // 变量名 Tag tag_0x1e = new Tag(0x1e, writeData); // 值数据 ``` `0x1e` 的值数据布局为:**偏移 0 处 2 字节为数据长度,偏移 4 处开始为数值本体**(布尔与 8 字节以下类型用 8 字节缓冲,64 位类型用 12 字节缓冲)。应答中的 `0x20` 状态码为 0 表示写入成功。 字符串写入有一个容易踩坑的细节:CODESYS 端字符串缓冲区长度固定,写入前必须先读取一次该变量获取缓冲长度,再按 `[长度-3][数据...]` 的格式打包(WSTRING 为 `[长度-2]` + Unicode 数据)。驱动用 `stringVarResLenCache` 缓存每个字符串变量的缓冲长度,避免每次写入都多一次读取。 ## 8. 数据类型映射与转换 驱动定义 `CodesysVarType` 枚举与 CODESYS IEC 类型码一一对应,读取时将原始字节按小端序转换为 .NET 类型: | IEC 类型 | 类型码 | C# 类型 | IEC 类型 | 类型码 | C# 类型 | | --- | --- | --- | --- | --- | --- | | BOOL | 0x00 | bool | USINT | 0x0A | byte | | BYTE | 0x02 | byte | UINT | 0x0B | ushort | | WORD | 0x03 | ushort | UDINT | 0x0C | uint | | DWORD | 0x04 | uint | ULINT | 0x0D | ulong | | LWORD | 0x05 | ulong | REAL | 0x0E | float | | SINT | 0x06 | sbyte | LREAL | 0x0F | double | | INT | 0x07 | short | STRING | 0x10 | string (ASCII) | | DINT | 0x08 | int | WSTRING | 0x11 | string (Unicode) | | LINT | 0x09 | long | TIME/DATE 等 | 0x12+ | uint/ulong | 类型解析采用**三级回退**:先查浏览缓存 `varCache`,再查 XML 符号树,最后沿 PLC 在线树按路径查找,保证无论符号表来自在线遍历还是离线文件都能正确转换。 ## 9. 工程化细节 这个驱动在生产环境打磨过,几个值得借鉴的细节: - **并发控制**:`SendAndRecv` 全程持 `_lock`,串行化「发请求-等应答」,杜绝通道上请求应答错位;符号树结构修改由 `_treeLock` 保护;批量加载期间置 `_isBulkLoading` 抑制心跳请求,避免心跳与遍历争抢锁导致级联延迟。 - **句柄管理**:对外暴露的 `IntPtr` 句柄实为 `GCHandle` 指针,每个节点只分配一次并缓存;重建符号树时以**栈迭代**递归释放全部句柄(防止深树栈溢出与内存泄漏)。 - **收包热路径**:后台线程 `BlockingCollection` 解耦 socket 接收与协议解析;拆包用位运算读取小端长度、`Buffer.BlockCopy` 搬运,避免 LINQ 临时分配。 - **缓存体系**:注册句柄缓存(`Tag_0x10Cache`,SHA256 键)、结构体/数组块缓存(`specificBlockCache`)、字符串缓冲长度缓存、浏览结果缓存(`varCache`)各司其职。 - **异常隔离**:心跳失败、单次遍历通信失败均被捕获隔离,不影响主流程;协议层统一抛 `CodeSysProtocolV3Exception`。 ## 10. 使用示例 ```csharp // 1. 连接控制器 var controller = new CodeSysController(); controller.UserName = "admin"; controller.Password = "admin"; controller.Connect("192.168.250.187"); // 2. 加载符号树:在线遍历 IntPtr root = controller.LoadSymbolsFromPLC(); // 或加载 CODESYS 导出的符号配置 XML(离线,零网络开销) // IntPtr root = controller.LoadSymbolsFromFile(@"D:\PLC\App.Device.Application.xml"); // 3. 收集全部叶子变量 List<ItemAddress> items = controller.LoadAllSymbolicInfo(); // 4. 类型化单点读写 controller.ReadInt("Application.PLC_PRG.nCounter", out int n); controller.WriteInt("Application.PLC_PRG.nCounter1", 234); controller.WriteBool("Application.GVL.TMP_BOOL", true); controller.WriteString("Application.GVL.TMP_STR", "OKay1"); // 5. 批量读写(一次注册、持续轮询) var names = new List<string> { "Application.GVL.TMP_BOOL", "Application.GVL.TMP_DINT" }; controller.Read(names, out List<PValue> values); // 批量读,类型化结果 controller.Write("Application.GVL.TMP_DINT", new ValueDInt(102)); // PValue 写入 // 6. 断开 controller.Disconnect(); ``` 典型批量读性能:10 个变量一组单次往返约 1ms 量级(取决于网络与控制器负载),配合注册句柄缓存可满足高频轮询场景。 ## 11. 总结 本文完整梳理了 CODESYS V3 协议的层次结构、连接登录、符号树遍历、批量读写与类型转换,并总结了驱动在并发、缓存、句柄管理上的工程实践。相比 OPC 方案,直连协议栈没有中间件依赖,符号名直读直写,非常适合对部署轻量性与响应时延有要求的工业上位机场景。驱动还附带 PLCShell 远程命令与文件上传下载(CmpFileTransfer)模块,可在此基础上继续扩展。 相关测试 Demo(含 GUI 浏览器)见仓库链接: **<mark class="encrypted">608K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8W2k6g2)9J5k6h3y4G2L8g2)9J5c8X3I4A6M7X3y4&6i4K6u0r3b7$3!0V1k6i4y4&6M7#2j5K6c8s2u0A6N6X3g2J5i4K6u0W2k6$3W2@1i4K6u0m8i4K6u0m8</mark>
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
Coder_shen
6
发帖
4
回帖
10
RANK
关注
私信
他的文章
[原创]零依赖解析西门子 STEP 7 工程:一个 .NET 离线变量表解析器的设计全记录
215
[原创]基于 .NET 4 实现从欧姆龙 Sysmac Studio工程文件解析全局符号变量表
191
基于C#实现CodesysV3符号变量访问
292
[原创]C#实现西门子S71200与S71500控制器优化DB块符号访问
6631
基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树
168
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部