☰
C# SECS 通信实例源代码:HSMS 分帧与事务配对实战
2026/10/15 12:05:06 网站建设 项目流程

简介:这是一份面向半导体设备自动化开发者的C# SECS/HSMS通信实例源代码,基于TCP/IP协议栈实现设备与主机之间的消息交互,适合已具备一定C#基础、希望切入半导体通信领域的工程师学习与二次开发。压缩包共1293个文件,约32.7MB,以655个dll依赖库、260个cs源码文件为主,辅以csproj工程文件、xml/json配置、pdb调试符号及少量前端资源,完整保留了secs4net项目的工程结构。目前已有8386人学习下载。源码覆盖socket连接建立、SECS/HSMS消息的序列化与反序列化、多线程异步收发、异常处理与日志记录等关键环节,并封装了便于调用的通信接口,读者可据此理解协议消息格式、掌握C#网络编程实践,并借鉴其分层设计与排错思路,将SECS通信能力集成到自己的设备端或主机端应用中。

1. C# SECS 通信实例源代码:从“能连上”到“能跑稳”的那条分界线

半导体设备上位机开发里,SECS/GEM 几乎是绕不开的一环。设备端说 HSMS,主机端说 SECS-II,中间还夹着一条 TCP 链路,任何一层没对齐,现象都是“连不上”或者“连上就断”。很多工程师第一次拿到一份 C# SECS 通信实例源代码,跑起来看到日志里蹦出 S1F1、S1F2,以为大功告成,结果一上产线就翻车:偶发断连、消息粘包、事务超时、W-bit 回错。这份代码真正值钱的地方,不是“能收发一条消息”,而是它把链路状态机、消息分帧、事务配对、超时重试这几件事写成了可复用的骨架。这篇笔记面向做设备上位机、EAP、MES 对接的 C# 开发者,把一份典型 SECS 通信实例拆成能照着复现的步骤,顺带把参数和坑讲透。

2. 先搞清 SECS-II 与 HSMS 在 C# 里各自负责什么

2.1 为什么不能把 SECS 当成普通 TCP 协议来写

SECS 这套协议分两层:SECS-II 定义消息语义,也就是 Stream 和 Function,比如 S1F1 是 Are You There,S1F2 是 On Line Data;HSMS 定义会话与传输,负责在 TCP 之上建立 Selected 状态、做消息分帧、管理事务。很多新手直接拿TcpClient收字节流,按\n或固定长度切包,这在 SECS 里必然出问题,因为 HSMS 消息头是 4 字节长度前缀加 10 字节头,长度字段本身还可能被拆到两次Receive里。

常见做法是:把 HSMS 当成一个独立的会话层来写,SECS-II 只负责把S1F1这种消息体编码成字节数组,交给 HSMS 去发。这样分层之后,链路重连、心跳、事务超时都归 HSMS 管,业务代码只关心“我发了一个 S1F1,等 S1F2 回来”。

2.2 HSMS 消息头的 10 个字节到底怎么排

HSMS 消息头固定 10 字节,前面还有 4 字节长度前缀,长度值等于 10 加消息体长度。头本身的结构是:

偏移长度字段说明
02SessionID会话 ID,设备端常用 0xFFFF
21HeaderByte2包含 W-bit 和 Stream
31HeaderByte3Function
41PType协议类型,SECS-II 为 0
51SType会话类型,0 表示数据消息
64SystemBytes事务标识,请求与回复必须一致

HeaderByte2的高位是 W-bit,表示是否需要回复。比如 S1F1 通常 W-bit 为 1,S1F2 回复时 W-bit 为 0。SystemBytes是事务配对的唯一依据,不是消息序号,这一点在写并发请求时特别关键。

2.3 用 C# 定义消息头结构体与编解码

下面这段代码是 HSMS 头的最小可用实现,直接可以放进项目里当基础类型:

public struct HsmsHeader { public ushort SessionId; // 会话ID,设备端常用 0xFFFF public byte HeaderByte2; // 高1位W-bit,低7位Stream public byte HeaderByte3; // Function public byte PType; // 协议类型,SECS-II为0 public byte SType; // 会话类型,0为数据消息 public uint SystemBytes; // 事务标识 public bool WBit { get => (HeaderByte2 & 0x80) != 0; set => HeaderByte2 = (byte)(value ? (HeaderByte2 | 0x80) : (HeaderByte2 & 0x7F)); } public byte Stream { get => (byte)(HeaderByte2 & 0x7F); set => HeaderByte2 = (byte)((HeaderByte2 & 0x80) | (value & 0x7F)); } // 编码为10字节头 public byte[] ToBytes() { var buf = new byte[10]; buf[0] = (byte)(SessionId >> 8); buf[1] = (byte)(SessionId & 0xFF); buf[2] = HeaderByte2; buf[3] = HeaderByte3; buf[4] = PType; buf[5] = SType; buf[6] = (byte)(SystemBytes >> 24); buf[7] = (byte)(SystemBytes >> 16); buf[8] = (byte)(SystemBytes >> 8); buf[9] = (byte)(SystemBytes & 0xFF); return buf; } // 从10字节头解析 public static HsmsHeader FromBytes(byte[] b, int offset) { return new HsmsHeader { SessionId = (ushort)((b[offset] << 8) | b[offset + 1]), HeaderByte2 = b[offset + 2], HeaderByte3 = b[offset + 3], PType = b[offset + 4], SType = b[offset + 5], SystemBytes = (uint)((b[offset + 6] << 24) | (b[offset + 7] << 16) | (b[offset + 8] << 8) | b[offset + 9]) }; } }

逻辑说明:WBit和Stream共用HeaderByte2,用位运算拆开,避免手工拼字节时把 W-bit 覆盖掉。SystemBytes用大端序写入,这是 HSMS 规定的网络字节序。参数上唯一需要按项目改的是SessionId,设备端主动连接主机时通常填0xFFFF,主机端回复时原样带回。

2.4 分帧:为什么必须用长度前缀而不是读一次算一次

HSMS 的 4 字节长度前缀是分帧的唯一依据。正确做法是维护一个接收缓冲区,先确保有 4 字节,读出长度,再确保有长度字节,凑齐才解析。下面是一个简化的分帧循环:

private readonly List<byte> _buffer = new List<byte>(); private void OnDataReceived(byte[] chunk) { _buffer.AddRange(chunk); while (true) { if (_buffer.Count < 4) return; int len = (_buffer[0] << 24) | (_buffer[1] << 16) | (_buffer[2] << 8) | _buffer[3]; if (len < 10 || len > 1024 * 1024) // 防御异常长度 { _buffer.Clear(); throw new InvalidOperationException("HSMS长度异常"); } if (_buffer.Count < 4 + len) return; var msg = _buffer.GetRange(4, len).ToArray(); _buffer.RemoveRange(0, 4 + len); Dispatch(msg); // 交给上层解析 } }

逻辑说明:_buffer是粘包和拆包的唯一真相来源,不要用TcpClient的DataAvailable去猜。参数上len的下限是 10,因为头本身 10 字节;上限设 1MB 是防御性写法,正常 SECS 消息很少超过几 KB。Dispatch里再调用HsmsHeader.FromBytes解析头,根据SType决定是数据消息还是控制消息。

3. 用 C# 把 HSMS 会话状态机跑起来

3.1 Selected 状态不是连上 TCP 就自动有的

HSMS 的会话状态分 Not Connected、Connected、Selected 三种。TCP 连上只到 Connected,必须由主动方发Select.req,被动方回Select.rsp,双方才进入 Selected,之后才能发数据消息。很多实例代码省略了这一步,直接发 S1F1,结果设备端不认。

常见做法是:主动连接方在 TCP 连接成功后立刻发Select.req,SType填 1;收到Select.rsp且SType为 2 后,把状态置为 Selected,再启动心跳。被动方收到Select.req后回Select.rsp,同样进入 Selected。

3.2 控制消息的 SType 取值与最小实现

控制消息不携带 SECS-II 消息体,SType取值如下:

SType名称方向说明
1Select.req主动方到被动方请求进入 Selected
2Select.rsp被动方到主动方响应选择
3Deselect.req任意请求退出 Selected
4Deselect.rsp任意响应退出
5Linktest.req任意心跳请求
6Linktest.rsp任意心跳响应
7Reject.req任意拒绝无效消息
9Separate.req任意请求断开

心跳用 Linktest,SType为 5,对方回 6。心跳间隔一般设 10 到 30 秒,超时时间设间隔的 2 到 3 倍。下面是一个发送控制消息的方法:

public void SendControl(byte sType, uint systemBytes) { var header = new HsmsHeader { SessionId = 0xFFFF, HeaderByte2 = 0, // 控制消息W-bit为0 HeaderByte3 = 0, PType = 0, SType = sType, SystemBytes = systemBytes }; var headBytes = header.ToBytes(); var frame = new byte[4 + 10]; int len = 10; frame[0] = (byte)(len >> 24); frame[1] = (byte)(len >> 16); frame[2] = (byte)(len >> 8); frame[3] = (byte)(len & 0xFF); Array.Copy(headBytes, 0, frame, 4, 10); _stream.Write(frame, 0, frame.Length); }

逻辑说明:控制消息没有消息体,长度固定 10。SystemBytes对 Linktest 来说可以自增,但回复时必须原样带回,否则对方无法配对。参数上SessionId在控制消息里通常也是0xFFFF,部分设备要求与数据消息一致,按对端文档改。

3.3 事务配对:SystemBytes 怎么生成和回收

每个需要回复的数据消息都要分配一个SystemBytes,并在发送后挂起一个等待项。收到回复时用SystemBytes找到等待项,完成TaskCompletionSource。下面是一个事务管理器的核心:

private readonly ConcurrentDictionary<uint, TaskCompletionSource<HsmsMessage>> _pending = new ConcurrentDictionary<uint, TaskCompletionSource<HsmsMessage>>(); private int _sysByteSeed = 1; public async Task<HsmsMessage> SendRequestAsync(HsmsMessage msg, int timeoutMs) { uint sys = (uint)Interlocked.Increment(ref _sysByteSeed); msg.Header.SystemBytes = sys; var tcs = new TaskCompletionSource<HsmsMessage>( TaskCreationOptions.RunContinuationsAsynchronously); _pending[sys] = tcs; try { SendRaw(msg); var completed = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); if (completed != tcs.Task) throw new TimeoutException($"事务 {sys} 超时"); return await tcs.Task; } finally { _pending.TryRemove(sys, out _); } } public void OnReplyReceived(HsmsMessage reply) { if (_pending.TryRemove(reply.Header.SystemBytes, out var tcs)) tcs.TrySetResult(reply); }

逻辑说明:Interlocked.Increment保证多线程下SystemBytes不重复,ConcurrentDictionary保证并发安全。TaskCreationOptions.RunContinuationsAsynchronously是为了避免回复线程被业务回调阻塞。参数上timeoutMs一般设 5000 到 10000,SECS 事务很少需要更长;如果对端是慢设备,可以按 Stream 分别设。

3.4 一个最小可跑的 S1F1 往返示例

把上面的部分拼起来,主动方连上、Select、发 S1F1、收 S1F2 的流程大致如下:

var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 5000); var session = new HsmsSession(client.GetStream()); session.Start(); // 启动接收循环 await session.SelectAsync(3000); // 发Select.req并等Select.rsp var s1f1 = SecsMessage.Build(1, 1, wBit: true, body: Array.Empty<byte>()); var s1f2 = await session.SendRequestAsync(s1f1, 5000); Console.WriteLine($"收到 S{s1f2.Header.Stream}F{s1f2.Header.HeaderByte3}");

逻辑说明:SelectAsync内部发SType=1并等SType=2,超时抛异常。SecsMessage.Build负责把 Stream、Function、W-bit 和消息体组装成HsmsMessage。参数上端口 5000 只是示例,实际按设备配置;wBit为 true 表示需要回复,S1F1 必须为 true。

4. SECS-II 消息体的编码与解析:别在 Item 上栽跟头

4.1 Item 头两个字节藏了格式和长度

SECS-II 消息体由 Item 组成,每个 Item 头 2 到 3 字节。第一个字节高 6 位是格式码,低 2 位是长度字节数;第二个字节开始是长度。格式码常见的有:

格式码类型说明
0o20Binary二进制
0o24Boolean布尔
0o30ASCII字符串
0o34I11字节有符号
0o36I22字节有符号
0o40I44字节有符号
0o44F44字节浮点
0o50U11字节无符号
0o52U22字节无符号
0o54U44字节无符号
0o60List列表

长度字节数低 2 位为 1、2、3,分别表示后续 1、2、3 字节是长度。写编码器时最容易错的是把长度字节数算错,导致对端解析越界。

4.2 用 C# 写一个够用的 Item 编码器

下面是一个只覆盖 ASCII、U4、List 三种常用类型的编码器,足够应付大部分 S1、S2、S6 消息:

public static byte[] EncodeAscii(string s) { var data = Encoding.ASCII.GetBytes(s); return BuildItem(0x30, data); } public static byte[] EncodeU4(uint value) { var data = new byte[] { (byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)(value & 0xFF) }; return BuildItem(0x54, data); } public static byte[] EncodeList(params byte[][] items) { var body = items.SelectMany(x => x).ToArray(); return BuildItem(0x60, body); } private static byte[] BuildItem(byte format, byte[] data) { int len = data.Length; byte lenBytes = len <= 0xFF ? (byte)1 : (len <= 0xFFFF ? (byte)2 : (byte)3); var head = new List<byte>(); head.Add((byte)(format | lenBytes)); if (lenBytes == 1) head.Add((byte)len); else if (lenBytes == 2) { head.Add((byte)(len >> 8)); head.Add((byte)len); } else { head.Add((byte)(len >> 16)); head.Add((byte)(len >> 8)); head.Add((byte)len); } head.AddRange(data); return head.ToArray(); }

逻辑说明:format传入的是格式码,比如 ASCII 是0x30,U4 是0x54,List 是0x60。lenBytes根据数据长度自动选 1、2、3,避免固定用 3 字节造成浪费。参数上注意 List 的长度是子 Item 字节数之和,不是子 Item 个数,这是新手最容易搞反的地方。

4.3 解析时怎么防止越界和死循环

解析比编码更容易出问题,因为对端数据不可信。建议每个解析函数都带一个offset引用或返回新偏移,并在读长度前检查剩余字节。下面是一个 ASCII Item 的解析片段:

public static string DecodeAscii(byte[] buf, ref int offset) { if (offset + 2 > buf.Length) throw new InvalidOperationException("Item头越界"); byte first = buf[offset]; int lenBytes = first & 0x03; if (lenBytes == 0) throw new InvalidOperationException("非法长度字节数"); if (offset + 1 + lenBytes > buf.Length) throw new InvalidOperationException("长度越界"); int len = 0; for (int i = 0; i < lenBytes; i++) len = (len << 8) | buf[offset + 1 + i]; int dataStart = offset + 1 + lenBytes; if (dataStart + len > buf.Length) throw new InvalidOperationException("数据越界"); var s = Encoding.ASCII.GetString(buf, dataStart, len); offset = dataStart + len; return s; }

逻辑说明:ref offset让调用方连续解析多个 Item。每一步都先检查边界,宁可抛异常也不要读越界内存。参数上lenBytes为 0 是非法值,直接拒绝;长度累加用移位而不是BitConverter,避免字节序混淆。

5. 避坑与排查:那些让 SECS 连接“看起来正常”的坑

5.1 现象:日志里 S1F1 发出去了,但永远等不到 S1F2

原因通常是SystemBytes没配对。回复消息的SystemBytes必须和请求完全一致,如果对端回的是 0 或者自增值,说明对端实现有问题,或者你发请求时SystemBytes写成了固定值导致多个事务撞车。解决:每次请求用Interlocked.Increment生成唯一值,收到回复先打印SystemBytes再查_pending。

5.2 现象:连接几分钟后自动断开,重连又正常

原因多半是心跳没发或者心跳超时设得太短。HSMS 的 Linktest 是双向的,主动方要发,被动方要回。如果只发不收,或者超时时间小于网络往返,就会误判断连。解决:心跳间隔设 10 到 30 秒,超时设间隔的 2 到 3 倍,并在收到 Linktest.req 时立刻回 Linktest.rsp。

5.3 现象:消息体解析出来是乱码或长度对不上

原因通常是 Item 长度字节数算错,或者把 List 的长度写成了子 Item 个数。另一个常见原因是 ASCII 编码用了 UTF-8,而 SECS 规定 ASCII。解决:编码统一用Encoding.ASCII,List 长度累加子 Item 的字节数组长度,解析时严格按lenBytes读长度。

5.4 现象:多线程发消息时偶发消息错乱

原因是没有对NetworkStream的写操作加锁。NetworkStream.Write不是线程安全的,两个线程同时写会把两条消息的字节交叉。解决:用一个SemaphoreSlim或lock包住SendRaw,保证一条消息的字节连续写入。

5.5 现象:Select 成功后发数据消息被 Reject

原因可能是SessionId不一致,或者PType没填 0。部分设备对SessionId校验严格,控制消息用0xFFFF,数据消息也必须用0xFFFF。解决:抓包对比 Select.req 和数据消息的SessionId、PType,确保一致。

6. 把实例代码用到真实项目里的三个进阶习惯

第一个习惯是给每条消息加可开关的十六进制日志。SECS 排错最终都要落到字节上,日志里打印SystemBytes、Stream、Function、SType和消息体前 64 字节,比任何断点都管用。我一般会写一个HsmsLogger,用ConditionalAttribute控制,产线默认关,调试时打开。

第二个习惯是把超时和重试分开配置。事务超时是等回复的时间,重试是超时后重新发请求。SECS 里不是所有消息都能重试,S1F1 可以,带写操作的消息重试可能造成重复执行。我的做法是给每个 Stream/Function 配一个RetryPolicy,默认不重试,只对幂等消息开重试。

第三个习惯是用一个假的 SECS 对端做回归测试。不需要真实设备,写一个FakeHsmsServer,收到 S1F1 回 S1F2,收到 S1F13 回 S1F14,收到 Linktest 回 Linktest.rsp。这样每次改分帧或事务代码,都能在本地跑一遍往返,避免上机台才发现问题。

下面是一个假对端的核心分发逻辑,可以直接嵌进单元测试:

private void Dispatch(HsmsMessage msg) { if (msg.Header.SType == 5) // Linktest.req { SendControl(6, msg.Header.SystemBytes); return; } if (msg.Header.SType != 0) return; // 其他控制消息忽略 if (msg.Header.Stream == 1 && msg.Header.HeaderByte3 == 1) { var reply = SecsMessage.Build(1, 2, wBit: false, body: Array.Empty<byte>()); reply.Header.SystemBytes = msg.Header.SystemBytes; SendRaw(reply); } }

逻辑说明:假对端只处理 Linktest 和 S1F1,回复时SystemBytes原样带回,W-bit置 false。参数上SType为 0 才是数据消息,其他控制消息直接忽略,避免假对端把 Select 流程搅乱。

最后说一个我自己的教训:早期写 SECS 时总想一步到位,把编码、事务、重连全塞在一个类里,结果每次改分帧都要动业务代码。后来拆成HsmsSession、SecsCodec、TransactionManager三层,每层只暴露必要接口,排错时能单独替换。SECS 这东西,玄学不多,血泪经验都在边界检查上,宁可多写几行if,也别让一个越界读把整条链路带崩。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询