C#.NET 实现 EtherNet/IP 适配器栈:CIP 与隐式 IO
简介:面向IO适配器设备的EtherNet/IP协议栈资料,适合需要开发与罗克韦尔自动化产品兼容通信方案的C#.NET开发者及工业以太网学习者,重点解决设备接入、连接管理与CIP数据交换等问题。压缩包共230个文件,约830KB,以167个HTML文档、20个头文件和15个C源文件为主,另含PNG图示、工程文件、EDS配置、PDF说明及构建脚本,便于从文档、接口声明到协议实现逐层查阅。内容围绕连接管理器、数据链路、网络层、应用服务与API展开,预览可见cipconnectionmanager.c、cipioconnection.c、encap.c、cipmessagerouter.c等模块,适合梳理IO连接建立、报文封装与路由、错误处理等实现思路。已有625人学习,可作为理解EtherNet/IP适配器通信细节和搭建兼容性测试环境的参考。
1. 一个能跑通的 EtherNet/IP 适配器,往往死在第三包报文上
很多人第一次写 EtherNet/IP 适配器,都会经历同一个错觉:RegisterSession 成功返回、ListIdentity 能被扫描到、显式报文读 Identity 对象也能拿到正确的厂商号和序列号,于是以为大功告成。可主站一切换到 Class 1 隐式连接,第三包、第五包 IO 数据就开始丢,或者连接莫名其妙被主站单方面拆掉。
问题几乎从来不在字节序解析上。EtherNet/IP 表面上像「TCP 上跑的一问一答」,骨子里却是三样东西耦合出来的:一张按 Class/Instance/Attribute 编址的对象表、一套有明确生灭的会话与连接状态机、以及一对靠 RPI 节拍自说自话的生产者/消费者。用 EtherNet/IP stack 配合 c#.net 落地时,真正把人卡住的是这三者的边界在哪里,以及谁该在什么时候改谁的状态。搞不清这条边界,代码能跑通,但接不了真实主站。
2. 封装层与 CIP:为什么一条 IO 数据要跨三层报文
任何一条 EtherNet/IP 报文,在字节层面都同时属于三层:最外面是 24 字节的封装头(Encapsulation Header),中间是 CIP 报文(服务码 + EPATH + 服务数据),最里面才是设备语义——读一个属性,或者发一批 IO 字节。把这三层揉成一层处理是新手最常见的结构错误,因为最外层关心的是「哪个会话、哪个命令」,中间层关心的是「打到哪个对象的哪个属性」,最内层关心的是「这些字节表示什么类型」。三层的错误码体系也完全不同:封装层用 0x64 表示会话无效,CIP 层用 0x05 表示路径解析失败,混着用会让主站拿到看不懂的返回。
2.1 24 字节封装头:Session Handle 是状态,不是令牌
封装头是定长的,全部小端。它的字段不多,但每一个都有明确的「谁来填、谁来回填」的责任划分:
| 偏移 | 字段 | 长度 | 谁负责 |
|---|---|---|---|
| 0 | Command | 2 | 请求方填,响应方原样回 |
| 2 | Length | 2 | 头部之后的数据长度 |
| 4 | Session Handle | 4 | 适配器分配,请求方后续携带 |
| 8 | Status | 4 | 适配器填,请求方为 0 |
| 12 | Sender Context | 8 | 请求方填,适配器必须原样回填 |
| 20 | Options | 4 | 固定 0 |
Command 里最常用的几个是 0x0063 ListIdentity、0x0065 RegisterSession、0x0066 UnRegisterSession、0x006F SendRRData、0x0070 SendUnitData。前三个负责发现和建会话,后两个是真正承载 CIP 报文的载体。解析这 24 个字节不需要任何对象依赖,完全可以写成一个纯函数:
// 封装头是定长 24 字节、全小端;这里用 Span 直接切片,不产生任何堆分配 public static bool TryParseHeader(ReadOnlySpan<byte> buf, out EncapHeader h) { h = default; if (buf.Length < 24) return false; h.Command = BinaryPrimitives.ReadUInt16LittleEndian(buf[..2]); h.Length = BinaryPrimitives.ReadUInt16LittleEndian(buf[2..4]); h.SessionHandle = BinaryPrimitives.ReadUInt32LittleEndian(buf[4..8]); h.Status = BinaryPrimitives.ReadUInt32LittleEndian(buf[8..12]); // Sender Context 对适配器是不透明的,只做搬运,绝不解释其内容 buf.Slice(12, 8).CopyTo(h.SenderContext); h.Options = BinaryPrimitives.ReadUInt32LittleEndian(buf[20..24]); // Length 只描述头部之后的数据,超长或对不上就是主站侧的问题,直接拒绝 return buf.Length >= 24 + h.Length; }这里有个细节值得单独说:Sender Context 是 8 字节不透明数据,有些主站会用它做请求/响应配对,一旦适配器把它清零返回,对方可能认为这条响应不属于自己而静默丢弃——从现象上看就是「明明回了包,主站说超时」。而 Session Handle 的性质完全不同,它是适配器自己发出去的一个句柄,RegisterSession 请求里传 0,回复里给一个非零值,之后的每一个 SendRRData 都必须带着这个值回来,否则适配器应该回封装状态 0x64。换句话说,Session Handle 是适配器侧的状态,不是主站发来的令牌,它的生命周期由适配器管。
2.2 CIP 报文与 EPATH:Class/Instance/Attribute 怎么变成路由
封装层剥掉之后,剩下的就是 CIP 报文。请求侧的结构是:服务码(1 字节)、请求路径长度(1 字节,单位是「字」而不是字节)、请求路径(EPATH)、服务数据。响应侧则是服务码 | 0x80、保留字节、General Status、Additional Status Size、Additional Status,最后才是响应数据。
EPATH 是这套协议里最像「指针」的东西,它由一串段(Segment)组成,常见的段类型是:
- 0x20 / 0x21:8 位 / 16 位 Class ID
- 0x24 / 0x25:8 位 / 16 位 Instance ID
- 0x30 / 0x31:8 位 / 16 位 Attribute ID
- 0x2C:8 位连接点(Connection Point),只在连接路径里出现
一个典型的 Forward Open 连接路径长这样:20 04 24 64 2C 65 2C 66,翻译过来是「Assembly 类(0x04)、实例 0x64(输入装配)、连接点 0x65(输出装配)、连接点 0x66(配置装配)」。这条路径把三个装配实例绑在一次连接上,所以适配器在解析 Forward Open 时,不能只取第一个实例号就完事,必须把整条路径按序拆开,分别对应到输入、输出、配置三块内存。
// 解析 EPATH:只处理适配器真正会遇到的段类型,遇到不认识的段立即失败而不是跳过 public static bool TryParseEPath(ReadOnlySpan<byte> path, out uint classId, out uint instanceId, out List<byte> connectionPoints) { classId = 0; instanceId = 0; connectionPoints = new List<byte>(2); int i = 0; while (i < path.Length) { byte seg = path[i++]; switch (seg) { case 0x20: classId = path[i++]; break; case 0x21: classId = BinaryPrimitives.ReadUInt16LittleEndian(path[i..]); i += 2; break; case 0x24: instanceId = path[i++]; break; case 0x25: instanceId = BinaryPrimitives.ReadUInt16LittleEndian(path[i..]); i += 2; break; case 0x2C: connectionPoints.Add(path[i++]); break; default: // 逻辑段(0x64/0x65 之类)出现在连接路径里说明主站用了路由,本栈不支持 return false; } } return true; }路径长度字段的单位是「字」,所以当路径字节数是奇数时,报文末尾会补一个填充字节。这个填充字节在解析时要跳过、在校验长度时要算进去,漏掉它就会在解析下一条报文时整体错位,而且错位后的报文往往还能「解析成功」,只是字段全是乱值——这就是最典型的黑匣子式故障。
2.3 显式与隐式:为什么要拆成 TCP 44818 和 UDP 2222
显式报文走 TCP 44818,一问一答,可靠、有序、可以很长;隐式报文走 UDP 2222,是生产者/消费者模型,没有重传,靠 RPI 节拍和序列号判断新旧。同一个适配器必须同时在这两个端口上监听,而且两个通道共享同一份对象状态:显式报文可能正在读 Assembly 的第 3 号属性,隐式报文同时在往同一块内存写 IO 数据。
这个拆分不是为了性能好看,而是语义决定的。隐式 IO 数据的核心诉求是「最新的值」,不是「所有的值」。如果某一帧丢了,重传一帧旧数据比直接丢掉更危险——主站会以为设备还停在上一拍的状态。所以设计上干脆不给隐式报文做可靠传输,让丢包变成一次可被 RPI 容忍的抖动。而显式报文正好相反,配置参数、诊断信息丢一个字节都可能让设备行为错乱,必须走 TCP。
理解了这一点,就明白为什么适配器不能用「收到就处理」的写法:UDP 2222 上收到的报文必须先校验连接 ID 和序列号,序列号不新于当前值的直接丢弃,不进入状态机。
3. 适配器对象模型:栈的核心是一张对象表,不是一堆回调
主站眼里的适配器不是「一个 socket 服务」,而是一台有身份、有装配、有连接管理能力的虚拟设备。它通过 EPATH 找到某个类的某个实例的某个属性,然后读写。所以栈的骨架应该是一张以 Class ID 为键的处理器表,每个处理器负责一整类对象的语义,而不是把「读序列号」「写输出」这种动作硬编码在一个巨大的 switch 里。
3.1 必须实现的五个对象类
一份能通过基本互操作测试的适配器,至少要实现下面这些类。少一个不会立刻崩,但会在某个特定主站上表现成「扫描得到、连不上」:
| Class ID | 名称 | 必需实例 | 关键属性 |
|---|---|---|---|
| 0x01 | Identity | 1 | 厂商号、设备类型、产品码、版本、状态、序列号、产品名 |
| 0x02 | Message Router | 1 | 无可见属性,只承载 Unconnected_Send |
| 0x04 | Assembly | 100/101/102 | 属性 3 是数据、属性 4 是长度 |
| 0x06 | Connection Manager | 1 | 承载 Forward Open / Forward Close |
| 0xF5 | TCP/IP Interface | 1 | IP、掩码、网关、配置方式 |
Identity 是主站认识你的第一张名片,其中版本属性是两个字节(主版本 + 次版本),产品名是 SHORT_STRING(第一个字节是长度,后面是 ASCII)。这里有个常见的翻车点:产品名的长度字节写成了整个属性的字节数,导致主站读出来的名字后面跟着一串乱码,某些上位软件会因此判定 EDS 与实际设备不匹配,直接拒绝组态。
Connection Manager 是隐式连接的入口,它收到的是 Forward Open(服务码 0x54)和 Large Forward Open(0x5B)。这个对象本身没有多少属性可读,它更像一个「动词处理器」——收到服务码就去建连接、改连接参数、拆连接。
3.2 Assembly 的内存布局:输入、输出、配置三个实例怎么对齐
Assembly 类的实现质量,直接决定了 IO 数据会不会错位。它的结构其实非常简单:每个实例就是一个字节数组,属性 3 读出来是这个数组的原始内容,属性 4 读出来是它的长度(UDINT),属性 2 是最大长度(只读)。
关键在于「谁来写、谁来读」。约定俗成是这样:
- 实例 100 是输出装配(O→T),主站写、适配器读,映射到设备的实际输出通道
- 实例 101 是输入装配(T→O),适配器写、主站读,映射到设备的实际输入通道
- 实例 102 是配置装配,连接建立时由主站下发,长度可变
注意这里的方向词很容易记反:站在适配器角度,「输出」是主站输出给你的,「输入」是你输入给主站的。一旦在代码里把这两个数组接反,表现出来是主站能建连接、能看到数据在跳,但读写全是反的,排查起来非常费时间。
真正高效的做法是让 Assembly 直接持有Memory<byte>,隐式报文的收发路径上不做任何拷贝:UDP 收到的负载直接写进实例 100 对应的那块内存,生产者循环直接从实例 101 那块内存拼包发出去。这样「映射」这个动作在运行时根本不存在,它只是两块内存的别名关系。
配置装配稍微特殊一点。它在 Forward Open 的连接路径里被指定,但配置数据本身往往是在连接建立之后,主站用显式的 Set_Attribute_Single 写进来的。所以适配器要在「连接已建立、配置还没写完」这个中间态里保持稳定,不能因为配置装配长度为 0 就拒绝后续的写请求。
3.3 用类 ID 分发的处理器表
把上面这些对象抽象成统一接口之后,Message Router 的实现就只剩下查表和转发:
// 每个 CIP 类实现这一个接口,Message Router 只管按 Class ID 查表 public interface ICipObject { ushort ClassId { get; } // 返回 false 表示实例/属性不存在,由调用方翻译成 CIP 状态码 0x05 bool TryGetAttribute(ushort instance, ushort attribute, Span<byte> dst, out int written); // value 的长度已经由请求侧保证,这里只做边界检查 bool TrySetAttribute(ushort instance, ushort attribute, ReadOnlySpan<byte> value); } public sealed class MessageRouter { private readonly Dictionary<ushort, ICipObject> _objects = new(); public void Register(ICipObject obj) => _objects[obj.ClassId] = obj; // 返回 CIP General Status:0x00 成功,0x05 路径解析失败,0x08 服务不支持 public byte Dispatch(byte service, uint classId, uint instance, ushort attr, ReadOnlySpan<byte> request, Span<byte> response, out int written) { written = 0; if (!_objects.TryGetValue((ushort)classId, out var obj)) return 0x05; switch (service) { case 0x0E: // Get_Attribute_Single return obj.TryGetAttribute((ushort)instance, attr, response, out written) ? (byte)0x00 : (byte)0x05; case 0x10: // Set_Attribute_Single return obj.TrySetAttribute((ushort)instance, attr, request) ? (byte)0x00 : (byte)0x05; case 0x01: // Get_Attributes_All:只有 Identity 真正需要 return obj is IdentityObject id && id.TryGetAll(response, out written) ? (byte)0x00 : (byte)0x08; default: return 0x08; } } }这段代码的价值不在于它多复杂,而在于它把「服务码语义」和「对象语义」彻底分开了。Connection Manager 需要额外支持 0x54/0x5B/0x4E,那就让它自己在TrySetAttribute之外再实现一个动词入口,而不是往 Message Router 里加分支。等到要加 Parameter 对象、加自定义诊断对象时,只需要新增一个实现类并 Register 一次,路由层一行都不用改。
4. Forward Open 与隐式报文:连接是「谈」出来的
显式报文随时可以发,隐式报文不行——必须先通过一次 Forward Open 把双方的角色、方向、节拍、数据长度全部谈定。这次谈判的产物是一组连接 ID 和一份定时器配置,之后双方就靠这份约定各自闷头发 UDP,不再有请求-响应关系。所以 Forward Open 解析错了,后面所有 IO 数据都是错的,而且错误往往延迟几秒才显现。
4.1 一次 Forward Open 到底确定了什么
Forward Open 的请求数据是一段固定顺序的字段串,最容易被忽略的是它的对齐方式:里面的保留字段必须跳过,连接路径长度以「字」为单位且路径要补齐到偶数字节。
// Forward Open 请求体的字段顺序是固定的,任何一处偏移算错,后面的 RPI 和路径全是乱值 private static ForwardOpenRequest ParseForwardOpen(ReadOnlySpan<byte> d) { var r = new ForwardOpenRequest(); int p = 0; r.PriorConnectionId = BinaryPrimitives.ReadUInt32LittleEndian(d[p..]); p += 4; r.TimeTick = d[p]; p += 1; r.O2TConnectionId = BinaryPrimitives.ReadUInt32LittleEndian(d[p..]); p += 4; r.T2OConnectionId = BinaryPrimitives.ReadUInt32LittleEndian(d[p..]); p += 4; r.ConnectionSerial = BinaryPrimitives.ReadUInt16LittleEndian(d[p..]); p += 2; r.OriginatorVendorId = BinaryPrimitives.ReadUInt16LittleEndian(d[p..]); p += 2; r.OriginatorSerial = BinaryPrimitives.ReadUInt32LittleEndian(d[p..]); p += 4; r.TimeoutMultiplier = d[p]; p += 1; p += 3; // 保留字段,必须跳过而不是读成数据 r.O2TRpi = BinaryPrimitives.ReadUInt32LittleEndian(d[p..]); p += 4; r.O2TParams = BinaryPrimitives.ReadUInt16LittleEndian(d[p..]); p += 2; r.T2ORpi = BinaryPrimitives.ReadUInt32LittleEndian(d[p..]); p += 4; r.T2OParams = BinaryPrimitives.ReadUInt16LittleEndian(d[p..]); p += 2; r.TransportTrigger = d[p]; p += 1; int pathWords = d[p]; p += 1; r.ConnectionPath = d.Slice(p, pathWords * 2).ToArray(); // 长度单位是字 // 低 9 位是这条方向上的数据字节数,两个方向可以不一样长 r.O2TSize = (ushort)(r.O2TParams & 0x01FF); r.T2OSize = (ushort)(r.T2OParams & 0x01FF); return r; }这里面有三组信息必须正确落库。第一组是 RPI,单位是微秒,常见值从 1 毫秒到上百毫秒;它决定了生产者循环的节拍,也决定了看门狗的窗口。第二组是连接尺寸,O→T 和 T→O 是两个独立的长度,一个 4 字节输入、2 字节输出的传感器完全合法,不要假设两边等长。第三组是连接 ID:多数主站会在请求里就把两个 ID 填好,适配器原样保存、在响应里回填即可;如果请求里 O→T 方向是 0,说明主站要你分配,那就自己生成一个非零值写进响应,并在后续收到的每一帧里用它做校验。
响应里还要回填应用层状态:成功是 0x00,连接资源不足是 0x01,路径不匹配是 0x05,目标不支持的连接类型是 0x08,RPI 不被接受是 0x07。主站拿到非零状态会直接放弃建链,并在诊断里留下一句极其笼统的「连接失败」——所以适配器侧的日志必须把原始状态码打出来,否则查起来就是玄学。
4.2 生产者循环:序列号、Run/Idle 头与发送节拍
连接建立之后,适配器在 T→O 方向上变成了纯粹的生产者:按 RPI 节拍读取输入装配的内容,拼成一个 SendUnitData 报文,往主站的 IP 和端口发过去。这个报文的结构是封装头 + CPF(Common Packet Format)两项:连接地址项(带连接 ID 和序列号)和连接数据项(带序列号、Run/Idle 头、然后是装配数据)。
// Class 1 生产者:不等主站请求,按 RPI 自己发;序列号 16 位循环,用于对端丢弃过期帧 private async Task ProduceLoopAsync(Connection conn, CancellationToken ct) { var period = TimeSpan.FromMicroseconds(conn.T2ORpi); // RPI 是微秒 using var timer = new PeriodicTimer(period); var buffer = ArrayPool<byte>.Shared.Rent(1024); try { while (await timer.WaitForNextTickAsync(ct)) { int len = BuildUnitData(buffer, conn); await conn.Socket.SendToAsync(buffer.AsMemory(0, len), SocketFlags.None, conn.OriginatorEndPoint, ct); } } finally { ArrayPool<byte>.Shared.Return(buffer); } } // 组装 SendUnitData:封装头 24 字节 + CPF 两个 item + Run/Idle 头 4 字节 + 装配数据 private static int BuildUnitData(byte[] buf, Connection conn) { var span = buf.AsSpan(); int p = 24; // 先留出封装头,最后回填长度 // Item 1:连接地址项,类型 0x00A1,内容是连接 ID + 序列号 BinaryPrimitives.WriteUInt16LittleEndian(span[p..], 0x00A1); p += 2; BinaryPrimitives.WriteUInt16LittleEndian(span[p..], 0x0004); p += 2; BinaryPrimitives.WriteUInt32LittleEndian(span[p..], conn.T2OConnectionId); p += 4; // Item 2:连接数据项,类型 0x00B1,序列号之后才是真正的 IO 数据 BinaryPrimitives.WriteUInt16LittleEndian(span[p..], 0x00B1); p += 2; BinaryPrimitives.WriteUInt16LittleEndian(span[p..], (ushort)(2 + 4 + conn.T2OSize)); p += 2; BinaryPrimitives.WriteUInt16LittleEndian(span[p..], conn.T2OSequence++); p += 2; BinaryPrimitives.WriteUInt32LittleEndian(span[p..], 0x00000001); p += 4; // 0x01 = Run conn.InputAssembly.Span[..conn.T2OSize].CopyTo(span[p..]); p += conn.T2OSize; WriteEncapHeader(span, 0x0070, p - 24, conn.SessionHandle, conn.SenderContext); return p; }这里有两个数字必须刻在脑子里。一个是序列号:16 位、逐帧自增、溢出回绕,对端用它判断帧的新旧,丢掉比当前值小的帧。它的初值没有硬性要求,但从 0 开始并且每帧都加是最省事的做法,一旦漏加,主站会认为数据停在原地,表现为「连接正常但数值不变」。另一个是 Run/Idle 头的 4 个字节:0x00000001 表示运行,0x00000000 表示空闲。它是装配数据前面的固定前缀,不是装配数据的一部分——把这两个混在一起,输出数据的第一个字节就会永远错位。这个坑我在两类不同的设备上都见过,症状都是「末位输出通道乱跳」。
4.3 看门狗:为什么 4×RPI 收不到就得拆连接
O→T 方向同样有明确的时间约束:如果在RPI × TimeoutMultiplier之内没收到任何一帧,适配器必须认为连接已断,释放连接 ID、停止生产者循环、把状态反映到 Identity 的状态字节上。工程上通常按 4×RPI 来设计看门狗,因为多数主站给的乘数在 3 到 4 之间,按 4 算不会误杀,也不会让设备在断链后长时间保持上一拍输出。
这里的设计动机很直白:工业现场最怕的不是「设备停了」,而是「设备还在按三分钟前的数据动作」。所以超时之后绝对的默认动作是进入安全状态——输出归零、或者按组态保持、或者停机,具体取决于设备用途,但绝不能什么都不做。
实现上还有一层:定时器精度。用System.Threading.Timer做 RPI 节拍时,回调可能在毫秒级上抖动,RPI 越小抖动占比越大;用PeriodicTimer配合TimeSpan.FromMicroseconds会稳定一些,但要接受操作系统调度粒度带来的下限。数据本身不可靠,所以单帧延迟几毫秒通常可以接受;但如果节拍整体漂移,比如每秒少发 5 帧,主站的看门狗就会间歇性触发,表现为「隔几分钟断一次、重连又好了」。
5. 联调避坑:适配器栈最常见的五类翻车
下面这五条基本覆盖了从「勉强能连」到「连续跑一周不断」之间的差距。它们有一个共同特征:现象都和代码里看起来最不重要的那一行有关。
5.1 会话与封装层
第一条,RegisterSession 之后不校验 Session Handle。现象是主站建会话成功、读一次 Identity 也成功,之后所有请求全部超时。原因是适配器把会话句柄当成了「一次性凭证」,事实上它是后续每个 SendRRData 都必须匹配的状态位,主站如果拿到 0x64 会认为会话已失效并重新注册,表现出来就是反复重连。解决办法很朴素:在封装层维护一个currentSession字段,RegisterSession 时生成非零值,UnRegisterSession 时清零,其余命令进来先比一次,不匹配直接回 0x64 并且不进入 CIP 层。
第二条,Sender Context 被清零返回。现象是抓包工具里能看到适配器确实回了响应,但主站侧日志全部是超时。原因是部分主站用这 8 个字节做请求响应配对,清空之后它对不上号。解决办法是原样搬运,不要试图「规范化」它——这 8 个字节对适配器来说永远是不透明的。
第三条,ListIdentity 只在 TCP 上实现。现象是直连 IP 能通,但主站的扫描功能永远发现不了设备。原因是设备发现走的是 UDP 44818 的广播,请求方发广播、适配器单播回复,回复内容除了 Identity 对象的关键属性之外,还要带一个明确的服务端口字段。只在 TCP 上实现等于放弃了整个自动发现流程。
5.2 连接参数与 RPI
第四条,把 RPI 当成毫秒。现象是连接建立成功,但主站随即报超时或者立刻断开。原因是 RPI 的单位是微秒,10 毫秒要写成 10000。这类错误在代码里完全不显眼,因为两个数字都是合法的整数,只有在实际节拍跑起来之后才会暴露——发快了主站丢帧,发慢了看门狗先跳。
第五条,连接路径长度没有按字对齐。现象是 Forward Open 返回 0x05(路径解析失败)或者干脆返回 0x13(配置无效)。原因是路径长度字段的单位是字,而路径本身可能包含奇数个字节,报文里必须补一个填充字节,解析侧也要把填充算进偏移。写解析器时最稳妥的做法是先算pathWords * 2得到总字节数,再从这个长度里切数据,而不是自己数段长度。
5.3 数据映射与字节序
第六条,把 Run/Idle 头当成了输出数据。现象是第一个输出通道永远错位或者乱跳,其余通道正常。原因是 O→T 数据项的前 4 个字节是 Run/Idle 标志,不属于装配数据。解决办法是在写装配之前统一跳掉这 4 个字节,并且顺手校验一下它的值:0x00000001 正常运行、0x00000000 进入空闲,其他值说明对方发的报文结构和你的预期不一致,这时候宁可拒绝也不要猜。
第七条,对 Assembly 里的字节做字节序翻转。现象是主站读到的整型参数是个天文数字。原因是 CIP 的报文头、连接参数、RPI 全是大端……不,全是小端,而装配数据本身是「原始字节流」,它的内部结构由 EDS 描述,适配器不该去解释。翻转一次就会把本来就正确的数据搞错。正确的边界是:只对协议字段做小端解析,装配数据一律当byte[]透传。
6. 设计取舍:托管语言写工业协议栈,哪些地方不能「优雅」
把栈写出来不难,难的是决定哪些地方用优雅的抽象、哪些地方必须退回最笨的写法。这几年的血泪经验是:越靠近报文字节的地方越不能抽象,越靠近业务逻辑的地方越应该抽象。
最典型的一处取舍是内存。用byte[]加 LINQ 拼报文在 C# 里写起来最舒服,但生产者循环每秒要跑几十上百次,每次分配一个几百字节的数组,GC 的 Gen0 回收会周期性地打断节拍,表现出来是「RPI 抖动偶尔超过看门狗窗口」。所以收发路径上我一律用三件套:ArrayPool<byte>.Shared租缓冲、Span<byte>切片、BinaryPrimitives读写。代价是代码里到处是偏移量p += 4,可读性明显下降,但换来的是零分配和可预测的节拍。
第二处取舍是并发模型。最直觉的写法是每个连接一个 Task,用await包住整个循环;实际上更稳的是「一个收包循环 + 一个生产者循环 + 一把状态锁」。原因是 Forward Open 会同时改动连接表、装配内存和定时器三处状态,如果每条报文都从线程池拿线程去改,锁的粒度会碎到无法推理。把状态变更收敛到少数几个明确的入口,比追求异步的「优雅」重要得多。
第三处取舍是可测试性。把栈拆成「纯字节函数」和「Socket 外壳」两层,收益远超预期:封装头解析、EPATH 解析、Forward Open 解析这些都能脱离网络单测,构造一段byte[]就能验证。我一般会强制自己做到——任何一个解析函数,输入是ReadOnlySpan<byte>,输出是结构体或状态码,不碰静态字段、不碰 Socket。凡是绕不过去要碰 Socket 的,说明这个函数的职责划分错了。
| 方案 | 优点 | 代价 | 适用位置 |
|---|---|---|---|
| 每条报文分配数组 | 写法直观 | Gen0 抖动,节拍不稳 | 显式报文、低频路径 |
| ArrayPool + Span | 零分配、节拍可控 | 偏移量手写,易错 | 隐式报文收发 |
| 每连接一个 Task | 代码短 | 状态竞争难推理 | 仅适合纯转发场景 |
| 收包 + 生产者双循环 | 状态边界清晰 | 需要自己做队列 | 适配器主路径 |
还有一个容易被低估的点:时间。工业协议栈对「准时」的要求高于对「快」的要求。一次响应慢 20 毫秒没人会在意,但节拍抖 5 毫秒就可能触发对端看门狗。所以凡是和 RPI 相关的定时,我都用单调时钟推算目标时刻,而不是依赖「睡多久」——Thread.Sleep(10)在负载高的时候可能睡 25 毫秒,累加几轮就漂了。
从那以后我每次接一个新的主站,都会强制走一遍同样的流程:先只建会话不发 IO,看显式报文是否稳定;再只建连接不写数据,抓三分钟包看 RPI 的抖动分布和序列号是否连续;最后才打开 IO 映射,逐字节对比装配内容。这套流程看着笨,但它能把「协议栈的错」和「组态的错」彻底分开,省下的排查时间远超它本身的成本。这份用 C# 实现的 EtherNet/IP 适配器栈,价值也正在这里——它把封装层、对象模型、连接状态机这三层按边界切开了,你可以照着它逐层对照自己的实现,也可以直接拿其中的解析函数和生产者循环当骨架。希望帮到你。
本文还有配套的精品资源,点击获取