C#实现Codesys V3 TCP协议栈:从抓包到远程改PLC IP

发布时间:2026/9/28 1:05:10
C#实现Codesys V3 TCP协议栈:从抓包到远程改PLC IP 去年做 MES 对接项目现场全是汇川的 Codesys V3 系 PLC上位机要实时读写几十个变量。我本来想着有官方 SDK装上直接调就完了结果到了现场才发现官方组件是 COM/OCX 那套老架构Windows 上还能凑合工控机却是 LinuxSDK 直接劝退。自己看着文档写代码文档半年没更新好几个方法跟实际版本对不上只能干瞪眼。最后把心一横抓包逆向自己用 C# 实现了一套 Codesys V3 TCP 协议栈不光能读写变量还能远程修改 PLC 的 IP。这篇文章就是把这条完整的路子走一遍从抓包分析、报文拆解到 C# 代码落地全部摊开讲适合做上位机开发、设备调试工具、或者对 PLC 通信协议感兴趣的人。1. 放着现成SDK不用为什么非要对Codesys V3协议下手1.1 官方方案在实际项目里的三个尴尬先说清楚我在否定官方 SDK 之前是真的把它从头到尾用过一遍的。Codesys V3 官方推荐的通信方式通常是通过 Codesys Gateway再配上 CAA 或者 .NET 的库来访问 PLC 里的符号变量。这套方案在纯 Windows、单机开发的环境下确实好用IDE 能连上数据也能读出来。但一旦到了真实产线问题就冒出来了。第一是组件依赖太重。上位机软件要部署到现场得先装 Gateway 服务、装运行库、注册 COM 组件缺一步就连不上。甲方现场的工控机为了稳定系统是精简版 Windows或者干脆是 Linux 容器这套东西根本装不进去。第二是文档与版本脱节。Codesys 更新频率不算低有些中间版本改了内部接口但开发文档没跟上你照着示例代码敲编译没问题一跑全是异常。第三是调试困难。第三方库封得太死出了错只给你一个不知所云的异常码你根本不知道是网络问题、报文问题还是权限问题现场排查起来心态直接炸。所以与其被 SDK 牵着走还不如自己掌握协议层。只要 TCP 通、报文对任何语言都能写上位机而且想部署到哪就部署到哪没有环境依赖。1.2 这里说的TCP协议栈到底是什么范围先给读者吃个定心丸文章标题里的TCP 协议栈不是让你从零去实现 TCP/IP 四层协议栈那活儿属于操作系统内核。我们真正要做的是应用层协议栈——也就是说底层 TCP 连接用 .NET 自带的 Socket/TcpClient 解决我们负责的是 TCP 之上那一层 Codesys 私有协议的封包、拆包、会话管理和业务指令封装。搞清这个概念非常重要很多初学者一听协议栈三个字就虚了以为要啃 RFC 文档、写拥塞控制。实际投入到现场项目里的协议栈大部分精力都花在两件事上报文格式的对齐和通信异常的处理。你只要把发什么字节能拿到什么结果这个映射关系搞明白了一个上位机通信模块就完成了一大半。1.3 Codesys V3 通信链路里到底有谁Codesys V3 的典型通信链路是客户端 → 网关 → PLC 运行时。网关Gateway是一个独立服务默认监听 11740 端口它负责把来自 IDE 或第三方客户端的请求转发到具体的控制器实例上。在做上位机时你可以选择走网关也可以在某些场景下直连控制器端口。我后期做了一套直连模式这样连网关都不用装部署省了一大截。抓包时你会在 Wireshark 里看到连接建立后客户端会先发一段比较大的数据里面包含版本协商信息然后才进入正常的请求响应循环。整个协议是二进制的不是 Modbus 那种简洁的文本回显风格所以之前没接触过的人第一眼会懵这也是我写这篇文章想解决的问题把那段密集的十六进制字节拆开、揉碎让后来的人少走弯路。提示具体端口号和报文偏移量不同 Codesys 版本会有细微差异本文以我工作的版本3.5.17 系实测为准你手头的版本如果细节对不上用同样的方法抓包重新对照即可。2. 抓包准备与通信骨架摸清端口、角色和报文轮廓2.1 环境搭建一台电脑就够了逆向通信协议核心思路是让两边先跑起来然后偷听它们说话。准备工作不复杂我当时的设备清单是一台装了 Codesys IDE 的 Windows 电脑用来连接 PLC 并执行标准操作一个 Codesys 软 PLC运行时装在 Windows 或者虚拟机里都行当作目标设备Wireshark用来抓取本机访问 PLC 时的全部网络数据如果你是直连物理 PLC那就更简单把电脑和 PLC 接到同一个交换机上在电脑网卡上抓包就行。需要注意的是如果 PLC 和电脑都在同一台机器上跑比如软 PLC IDE抓包要选择 Loopback 环回网卡否则什么都抓不到。真机现场就选有线网卡别选错。下载安装完 Wireshark 以后设置一个抓包过滤器只保留与目标端口相关的流量以免被系统后台流量干扰tcp.port 11740然后启动抓包在 IDE 里执行一次登录 PLC 在线读值 在线写值。操作要尽量慢、尽量少而清晰登录一次、读一个变量、写一个变量、登出。这样后面分析报文时能够把几种不同操作的网络流量对应得明明白白。2.2 从连接时序里先看出个大框架打开抓到的 pcapng 文件不要急着看字节先看连接层级。我抓到的典型流程是TCP 三次握手 客户端 → PLC一个大包几百字节版本/能力协商 PLC → 客户端一个响应包 客户端 → PLC一个较短请求通道建立相关 PLC → 客户端通道建立成功响应 随后是密集的读/写请求-响应流看到这个骨架之后心里就有底了这不是一个无状态协议它有一个明确的会话建立过程类似于先握手报名再干活。接下来做一件很关键的事情把同一个操作重复多遍然后用 Wireshark 的追踪流功能Follow TCP Stream看完整的请求响应序列。你会看到协议内部其实是分层的第一条大请求和后续的小请求报文存在明显相同的头部字节而中间变化的字节集中在某几个区域。这里的变化点就是后面逆向时要重点突破的钥匙。2.3 报文里不变的、变化的、递增的分别意味着什么把几组报文按十六进制排列对比我用笨办法总结出了三个规律不变的字节段通常代表协议版本、服务标识或者帧起始标记。比如我的实测数据里报文的开头总是那几个固定的字节这说明它是一个公共帧头的一部分或者至少有一个版本固定的魔数。会随着操作内容变化的字节段是真正携带业务数据的载荷。比如你分别读取变量 A 和变量 B对比两个请求的报文发现大部分内容一样只有一段明显不同的 ASCII 字符一翻译正好是A和B那么这个区域就是变量名字符串的存放位置。每个请求都会递增的段多半是序列号或者请求 ID。抓包量大了以后你会发现即使执行的是完全相同的读取操作这一段的数值也会按顺序递增而且响应报文里往往会回显同样的数值。这是协议的请求-响应关联机制在做多通道并发通信时用途极大。这个阶段不需要绞尽脑汁理解每一个字节关键是先把哪些字节是头、哪些字节是业务数据、哪些字节是计数的大盘分清。分清了逆向的难度就已经降了一半。3. 逐字节拆报文登录握手与变量读写的真实画像3.1 登录握手包报文头与载荷的分界线在哪协议逆向最忌讳的是上来就看十六进制然后强行猜正确的姿势是先找到长度字段。几乎所有二进制协议都会在载荷前面放一个我接下来有多长的数值找到了它报文头与载荷的分界线就清晰了。以我抓到的登录请求来说报文的大致结构可以归纳为一个公共外层帧再加一段版本协商载荷。外层帧里有两个地方最有价值一个是载荷长度它告诉我抓到的这包数据里哪些是真正的业务数据另一个是协商字段里面包含了客户端版本、协议版本、字符集等一堆参数。我处理这种报文的思路是先抓一个包把长度字段的取值转换成十进制然后数一下长度字段之后到报文结束的字节数如果两者吻合说明长度字段的范围判断正确。然后再改一个参数比如把 IDE 的显示语言改掉重复抓包看哪些字节跟着变了。变了的字节就是语言信息的存储位置没变的则是版本或通道标识。把我实测的数据总结成一张简表方便理解报文区域大致内容判断依据帧头区协议类型、长度固定长度、长度值与载荷吻合会话区会话ID、通道号登录后保持不变重连后改变业务区服务号、功能码、变量路径、数据随操作变化ASCII可见3.2 通道机制为什么响应报文不是按顺序来的继续深挖你会发现一个现象我同时下发了好几个请求但是返回的响应顺序和请求顺序对不上。一开始我以为是丢包重传后来才发现这是 Codesys V3 的通道Channel机制在起作用。协议允许在同一个 TCP 连接上建立多个逻辑通道每个通道独立处理一类请求。所以一个响应包到底属于哪个请求不是靠先来后到来匹配的而是靠报文里的通道标识和请求序号来匹配的。这个设计很像 HTTP/2 的多路复用目的就是减少 TCP 连接数量提高通信效率。这对我们写协议栈非常关键如果只做一个串行请求循环问题不大但如果要并发读写多个变量就必须在客户端维护一个请求 ID 到回调函数的映射表。收到响应后根据 ID 找到对应的等待者把数据喂给它。我在第一版协议栈里就偷懒没做这层映射结果一开多线程读取数据全串位了这是后话。3.3 读变量和写变量的报文差异在哪协议里最常用到的两类业务报文是读变量和写变量。它们的差别主要体现在载荷区的服务功能和变量地址信息上。以读变量请求为例报文载荷的大致画像包含服务标识表明这是一个读操作数据区域长度接下来要访问的变量路径的长度变量路径字符串比如PLC_PRG.counter这种用点分格式表示的符号路径期望的数据类型或句柄信息告诉 PLC 端读出来以后怎么解析字节写变量请求会在读请求的基础上额外追加一个数据区把你想要写入的值的字节串进去。值的字节序默认是小端比如写入整数 1你会看到载荷里出现01 00 00 00而不是00 00 00 01。这个细节极其容易踩坑后面专门讲。这里插一句我这个版本抓到的变量路径是可见的 ASCII 字符串这也是协议比较仁慈的地方。你在 Wireshark 里追踪流时可以肉眼直接在右侧文本栏里看到变量名对定位报文非常有帮助。如果哪一天你发现变量名区域被压缩或加密了那就得先解决载荷压缩和加密的问题难度会上一个台阶但 Codesys V3 目前的主流版本对上层开发还算是友好的。读完上面的报文结构下一步就是用代码把这些结构固化下来做成可复用的类库。4. C# 实现协议栈从连接管理到业务报文的代码落地4.1 模块划分不搞重设计按职责拆四层就行动手写代码之前先把协议栈的模块边界画好。我不太建议把几百行报文的收发逻辑全塞进一个类里后面会越改越乱。比较务实的做法是做四个各司其职的部分Transport传输层封装 TcpClient 的连接、断开、重连发送原始字节接收并缓存数据流。Frame帧编解码负责把载荷封装成符合协议结构的完整报文以及从接收缓存里拆出一个完整的报文。Message消息层定义登录、读变量、写变量、登出等业务消息的构造与解析。Client客户端门面把下面三层串起来对外提供简单的 API如Connect()、ReadSymbol()、WriteSymbol()。这样做的好处是如果协议版本有细微变化通常只需要改 Frame 或 Message 层上层代码完全不用动。4.2 帧封装与中转长度字段和通道号怎么写到一起根据我抓包的归纳协议帧可以抽象成这样通道标识 载荷长度 载荷内容。用 C# 写封装和解析的代码关键是处理好字节序和粘包问题。先看发送方向的帧封装public static class CodesysFrame { public static byte[] EncodePayload(ushort channelId, byte[] payload) { // 4字节2字节通道号(小端) 2字节长度(小端) // 实际协议可能多几个字段按抓包结果调整偏移 var frame new byte[4 payload.Length]; frame[0] (byte)(channelId 0xFF); frame[1] (byte)(channelId 8); frame[2] (byte)(payload.Length 0xFF); frame[3] (byte)(payload.Length 8); Buffer.BlockCopy(payload, 0, frame, 4, payload.Length); return frame; } }接收方向的拆包要稍微复杂一些因为 TCP 是流协议你收到的数据可能是一个包的一半也可能一次收了好几个包。这种场景处理不严谨会出现数据错位、解析崩溃。正确做法是维护一个接收缓冲队列循环取出 4 字节头、解析长度、判断缓冲足够后再取完整载荷public class FrameReader { private readonly byte[] _buffer new byte[8192]; private readonly MemoryStream _stream new MemoryStream(); public void Append(byte[] data) { _stream.Write(data, 0, data.Length); } public byte[]? ReadFrame() { // 先回到可读起点 _stream.Position 0; if (_stream.Length - _stream.Position 4) return null; var header new byte[4]; _stream.Read(header, 0, 4); ushort length (ushort)(header[2] | (header[3] 8)); if (_stream.Length - _stream.Position length) return null; var payload new byte[length]; _stream.Read(payload, 0, length); return payload; } }注意在真正的项目里网络读取必须用异步方法比如ReadAsync配Buffer.BlockCopy避免在 UI 线程里卡死。上面这段是突出核心逻辑投入生产时再做异步化改造。粘包半包问题的本质是不知道一条消息在哪结束长度字段一拿到这个问题的答案就有了。4.3 会话握手从连接到就绪的有限状态机协议栈不能发出去就完事必须跟踪自己的会话状态。我用了最简单直接的方式一个枚举状态字段。public enum SessionState { Disconnected, TcpConnected, HandshakeSent, Ready, Faulted }连接建立后第一步发送版本协商报文报文中带上我们自己约定的客户端版本和运行平台信息。然后等待 PLC 返回握手确认。收到确认后把状态置为 Ready这时候才允许上层执行变量读写。这套流程本质上是一个小的有限状态机强烈建议不要让上层代码直接跳过握手去发业务报文。我曾经图省事在连接成功后立刻去读变量结果 PLC 一律不回包排查了半天才发现 PLC 端会丢弃未经过协商的数据包。握手响应还有一个细节如果 PLC 端版本过旧或者协议不兼容响应包里的状态码会是非 0 值。协议栈里要对这个非 0 状态做显式判断并抛出异常否则上位机看起来连上了实际却无法通信现场排查时极其迷惑。4.4 读写变量的代码落地构造业务报文并处理响应会话进入 Ready 后读写变量就简单了。构造读变量请求的载荷然后交给帧编码层接收到响应后先剥离帧头再定位到载荷中的变量值区域。这里给出一个简洁的读变量实现public async Taskbyte[] ReadSymbolAsync(string symbolPath, CancellationToken ct) { // 1. 构造载荷 var payload new MemoryStream(); payload.Write(BitConverter.GetBytes((ushort)0x1001)); // 服务标识读变量 var pathBytes Encoding.ASCII.GetBytes(symbolPath); payload.Write(BitConverter.GetBytes((ushort)pathBytes.Length)); payload.Write(pathBytes, 0, pathBytes.Length); payload.Write(new byte[4], 0, 4); // 类型句柄占位 // 2. 封装帧并发送 var frame CodesysFrame.EncodePayload(_channelId, payload.ToArray()); await _transport.SendAsync(frame, ct); // 3. 等待并解析响应从帧里取业务数据 var response await WaitResponseAsync(_nextRequestId, ct); var value response.AsSpan(6).ToArray(); // 跳过响应头取数据区 return value; }写变量的逻辑与之类似只是在载荷尾部多追加一个值区var suffix new byte[] { 0x01, 0x00, 0x00, 0x00 }; // 小端写入整数1 payload.Write(suffix, 0, suffix.Length);读到字节后的类型转换要特别小心。bool在 Codesys 里通常是一个字节int是四个字节小端real是 IEEE754 四字节小端string则需要先读长度再按 ASCII/UTF-8 解码。这块我建议在上位机里做一个SymbolType枚举把不同类型字节集合与 C# 类型做映射避免在业务代码里到处散落BitConverter。5. 实战自己写的栈远程读取变量并修改PLC的IP5.1 为什么这个场景很刚需你可能觉得修改 PLC 的 IP不是一个常见的日常操作但实际上设备出厂、产线改造、柜内 PLC 替换时批量配置 IP 是刚需。如果每台设备都抱着电脑接显示器去改效率极低如果先通过某个通信协议连上再远程改效率就完全不一样了。Codesys 修改 IP 的原理并不神秘它也是往某个系统参数区写入新配置然后触发配置生效。我们用自研协议栈做这件事本质上就是写变量的一种特殊形式。有了上面实现的WriteSymbolAsync能力就能把这项工作自动化。5.2 完整操作流程与代码骨架整个操作分四步连接 → 握手 → 写入系统网络参数 → 触发重启或重新加载参数。用我的协议栈写出来大概是这样var plc new CodesysClient(host: 192.168.1.10, port: 11740); await plc.ConnectAsync(); await plc.HandshakeAsync(); // 1. 读取当前IP确认能通信 var current await plc.ReadSymbolAsync(SysCfg.Network.IpAddress); Console.WriteLine($当前IP: {Encoding.ASCII.GetString(current)}); // 2. 写入新IP var newIp 192.168.1.88; await plc.WriteSymbolAsync(SysCfg.Network.IpAddress, Encoding.ASCII.GetBytes(newIp)); // 3. 调用系统配置生效命令 await plc.InvokeActionAsync(SysCfg.Network.ApplyConfig); // 4. 等待设备重启 await Task.Delay(5000); var newAddr 192.168.1.88; await plc.ConnectAsync(newAddr, 11740); // 用新地址重连验证 Console.WriteLine(IP修改完成并重连成功);需要说明的是不同 Codesys 版本里系统网络参数的对象路径、配置生效命令名可能不一样你自己做的时候要以实际 PLC 的符号表为准。如果目标 PLC 不暴露这些系统符号那么你就得走更底层的系统服务报文报文结构也需要额外抓包确认。但思路完全一致找到写入口发配置触发生效。5.3 实测验证方法对照 Wireshark 检查每一包代码写完不要直接上产线先在实验室做一轮严格验证。我的验证套路是先开 Wireshark 抓包再执行改 IP 操作然后把抓到的报文和官方 IDE 做同样操作抓到的报文做对比。重点看三个点报文头长度字段是否正确、服务标识是否为预期的写系统参数命令、载荷里的 IP 字符串是否完整且字节序正确。只要这三者一致基本可以确认自己的栈发出去的东西和官方客户端没区别。然后拿一个可恢复的软 PLC 来测完整流程改完 IP 之后确认 PLC 能通过新地址重新通信。如果软 PLC 出问题重启后一般能恢复真机就要谨慎了建议提前把 PLC 的启动模式设置好避免改坏导致设备起不来。我的建议是这类工具一定要做成带确认提示、带倒计时恢复的交互模式不要一键无差别执行。6. 复盘中那些文档里不会写的坑6.1 大小端与类型长度错一个字节等不到响应是常态第一版协议栈里我把变量长度字段用大端写进去PLC 端解析出来的长度完全不对响应永远等不到。这种问题在抓包里极难发现因为你看到的十六进制看起来差不多甚至报文很工整但对方就是不理你。排查方法只有一个把官方客户端发的包拉出来逐字节对比数清楚长度字段的高低位顺序。另外一个容易出错的地方是string的结尾。有些版本会在字符串末尾补一个\0有些不会有些类型会按 2 字节或 4 字节对齐补位。我的建议是把要写入的每个字符串都按长度 内容 对齐填充的格式预先构造好不要依赖Encoding.ASCII.GetBytes一把梭否则遇到需要对齐的字段载荷长度会和实际不符。6.2 响应匹配没有请求ID映射表并发就是灾难前文提到过Codesys V3 通道机制下响应不按请求顺序返回。如果你在上位机里开了多个线程同时读变量而协议栈只用一个公共字段存最近一次响应那结果一定是数据错乱。正确做法是给每个请求分配一个递增 ID用一个ConcurrentDictionaryint, TaskCompletionSourcebyte[]保存等待者响应一到就按 ID 取回并完成异步任务。这是我这次实现里最值得回头夸自己的一笔。前期偷懒没做上线后被同事反馈读十个变量偶尔有两个读回来的值对调排查了两天才定位到问题。后来加上映射表以后并发读写几百个变量都没再出现错乱。6.3 重连与心跳现场网线一松协议栈就废了工业现场最让人崩溃的不是协议复杂而是物理链路不稳定。网线松了一下、交换机重启了一下TCP 连接就死了。如果协议栈没有检测和重连机制上层就会一直卡在某个await里看起来像程序假死。我后来在 Transport 层加了三个机制才稳住读超时每次等待响应设置超时时间通常 3~5 秒超时后主动断开连接。断线检测底层Socket.ReceiveAsync返回 0 字节时说明对端关闭了连接立刻置状态为Disconnected。自动重连上层调用任何 API 时如果检测到状态为Disconnected先自动执行一次Connect Handshake再执行业务请求。加上这几个机制之后现场网络抖动对上位机的干扰大大降低。一个技巧是重连之后要把通道 ID 重新申请不要沿用旧值否则可能出现逻辑通道错位。6.4 性能优化批量读比单点读香得多最后再说一个性能层面的经验。如果你需要读几十个甚至上百个变量别写一个循环挨个ReadSymbolAsync那样一个来回一个 RTT上位机刷新周期根本压不下来。更好的做法是先看你的 Codesys 版本是否支持批量符号读取接口如果支持就构造一个包含多个符号路径的批量请求如果不支持再退而求其次用多通道并发把不同变量的读请求同时发出去。我最后那版协议栈在批量读模式下200 毫秒内能刷完 300 个变量的轮询而串行版本需要接近 3 秒差距非常明显。优化方向其实不复杂就是减少 RTT、提高并发度但这要求协议栈底层的请求 ID 映射和通道分配足够健壮——这就是前面那些基础工作兑现价值的地方。最后再分享一个小技巧逆向一个协议时我习惯建一份自己的协议特征笔记每抓到一种新报文就记录它的帧头、关键偏移、变化规律和对应的 UI 操作。比如我会写登录偏移 0x02 是长度偏移 0x10 开始是版本字符串写变量服务号 0x1002值区从偏移 14 开始。这份笔记后来成了团队里新人的上手教材比官方文档好用得多。协议这东西只要你的抓包方法对再复杂的私有格式也能一层层剥开。掌握这个方法以后你去对接的就不再是某一个具体 PLC 型号而是一整类支持协议扩展的控制设备这比守着某个 SDK 反复调参数要靠谱太多。