-
-
基于C#实现CodesysV3符号变量访问
-
发表于: 7小时前 57
-
基于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 的在线通信协议是一套层层封装的二进制报文体系,每层都带自己的头与校验:
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),驱动按如下流程构建符号树:
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 请求:
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 对象。
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,报文组织为:
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. 使用示例
// 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 浏览器)见仓库链接:
34bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8W2k6g2)9J5k6h3y4G2L8g2)9J5c8X3I4A6M7X3y4&6i4K6u0r3b7$3!0V1k6i4y4&6M7#2j5K6c8s2u0A6N6X3g2J5i4K6u0W2k6$3W2@1
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。