去年做 MES 对接项目,现场全是汇川的 Codesys V3 系 PLC,上位机要实时读写几十个变量。我本来想着有官方 SDK,装上直接调就完了,结果到了现场才发现,官方组件是 COM/OCX 那套老架构,Windows 上还能凑合,工控机却是 Linux,SDK 直接劝退。自己看着文档写代码,文档半年没更新,好几个方法跟实际版本对不上,只能干瞪眼。最后把心一横,抓包,逆向,自己用 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 Task<byte[]> 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的IP
5.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,用一个ConcurrentDictionary<int, TaskCompletionSource<byte[]>>保存等待者,响应一到就按 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 反复调参数要靠谱太多。