简介:本资源是一套基于C#实现SECS/GEM通信协议的完整工程实践方案,面向半导体设备开发工程师、工厂自动化系统集成人员及工业通信协议学习者,解决设备端(Equip)与主机端(EAP Host)间标准SECS消息交互的落地难题。压缩包含1317个文件,总大小41.58MB,主体为280个C#源码文件(cs)、545个运行依赖DLL、16个可执行程序(exe)及配套配置文件(config)、SML协议定义文件(sml)和调试符号文件(pdb),支撑从编译构建到联调验证的全流程。已有76人下载学习,资源结构清晰,包含DevDeploy.bat部署脚本、Secs4Net核心类库项目(csproj)、SML协议解析模块及典型会话流程实现——如Socket连接后主动端发送Selected.rsp、被动端响应Select.rsp进入Selected状态,并完成S1F13/S1F14握手建立标准SECS/GEM连接。读者可直接编译运行双端程序,深入理解SECS消息封装、状态机管理与TCP层通信机制。 手里的SecsGem项目终于跑通了,整套代码包含Equip设备端和EAP Host主机端两个程序,压缩好放在一个zip里。这个项目不是简单的TCP长连接,而是把半导体制造场景里常用的SECS/GEM标准通信链路用C#重新实现了一遍。如果你在做上位机、设备自动化对接MES,或者准备接入EAP,这套代码的思路和踩坑记录应该能帮你少走不少弯路。
我会把开发过程中最关键的设计决策、消息编解码方式、设备端与主机端的交互逻辑,以及联调时遇到的那些怪问题全部拆开讲。不是贴一大堆完整源码完事,而是让你看懂每一层在干什么,为什么这么写,后面遇到问题能自己排查。
1. SecsGem到底在解决什么问题
1.1 半导体设备通信的链路组成
SecsGem不是单一协议,它是一整套由SEMI标准定义的通规则,最核心的几层包括:HSMS负责底层TCP/IP传输,SECS-II定义消息格式和内容,GEM则站在设备行为高度,规定设备在线状态、报警上报、事件收集、数据采集这些功能应该如何实现。很多新人一开始搞不清楚这三者的关系,我刚接触时也一样,花了很长时间才理清楚。
从实际开发角度来看,Equip设备端做的就是暴露一个TCP服务端,等待Host连接,然后按照SECS规定回复各种Primary/Secondary消息。EAP Host端则更像主动方,要向设备发起会话、订阅事件、下发参数、读取数据。这两端都要自己处理连接生命周期、消息ID对应、超时重传,还有SECS-II消息体的序列化和反序列化。
这套协议本身不涉及上层业务逻辑,它更像通用的工业级通信管道。设备端在这条管道上报告“我上电了”“我在生产”“有个报警发生了”,Host则通过标准命令查询设备状态、请求变量值、设置系统时间。不管设备是炉管、光刻机还是清洗机,只要按这套标准做,上层的EAP、MES就能无缝接入。
可能有人会问:为什么不用简单的自定义TCP协议?因为半导体制造系统对设备兼容性要求太高,一台设备可能在多个工厂流转,不同工厂会装不同MES/EAP。如果不遵循SEMI标准,每次对接都要定制开发,成本高而且很难维护。SecsGem的意义恰恰是标准化的设备自动化接口,这也是这个项目最值得复刻的地方。
1.2 为什么用C#而不是C++/Python写这套通信
半导体设备商传统的SECS/GEM实现大多用C/C++,因为设备端底层直接跑在嵌入式环境或者工控机硬实时系统上。但纯上位机层面的S/W,尤其是EAP Host端,C#反而很合适。C#的async/await让长连接处理非常顺手,TcpListener、TcpClient封装得足够好用,再配合内置的BinaryPrimitives做字节序转换,写HSMS解析器比C++省事太多。
用Python也能做,但落地到Windows工控机上部署、配置服务、对接数据库,Python环境相对啰嗦。C#可以编译成单文件exe,装个.NET Runtime就能跑,后续做可视化配置界面也方便。当然也有商业库比如SECSComm之类的可以直接调用,但版权和授权费用不便宜,而且封装太黑盒,出了问题不好定位。自己从协议层开始实现,虽然前期工作量多些,但后面排查问题会轻松很多,也更可控。
我并不是说商业库不好,实际上如果项目周期很紧、又需要严格符合GEM认证,商业库是更稳妥的选择。但若你是想深入理解这套协议,或者需要定制一些特殊消息,自己实现无疑是最好的方式。这个项目本质上也是出于这个动机:把协议吃透,再做一套可复用的两端代码。
2. 动手写之前,先把协议细节盘清楚
2.1 HSMS消息封装与四条关键字节序
HSMS是跑在TCP之上的一层封装,全称是High-Speed SECS Message Services。每条HSMS消息由Header和Data两部分组成,其中Header固定10字节,最后4字节如果是数据消息,表示SECS-II消息体的长度;如果是控制消息,则固定是0。前6个字节包含Session ID、Stream/Function、PType、SType,以及用于消息应答配对的System Bytes。
最关键的是这四个字节大小端,Header中的数据长度和系统字节都是大端序,即高字节在前。C#的BitConverter默认是系统小端序,直接转换会出问题,必须用BinaryPrimitives.ReadUInt32BigEndian或者手动移位。说实话,我见过太多人在这里栽跟头,现象就是S1F13回复超时,或者控制消息完全无法解析,最后发现系统字节高低位反了。
除了数据消息,HSMS还定义了Select Request/Response、Linktest Request/Response、Separate Response等控制消息。TCP连接建立成功后,Host不会立刻发SECS消息,而是先发Select Request(SType=1)建立会话,设备端应答Select Response(SType=2)之后,才允许传递数据消息。Linktest(SType=5)是双方互发的心跳,用来检测链路是否还活着。
这里要重点提一下T5、T6、T7、T8这几个定时器。T5是连接未建立时的重试时间,T6是控制消息响应超时,T7是连接建立超时,T8是接收数据间隔超时。项目中我会把T6设成5秒,T8设为10秒,通过配置文件可调。如果T6设太短,高负载下容易误判超时;设太长,链路故障后状态恢复又慢。这几个参数直接影响联调体验,建议根据现场网络状况做微调。
2.2 SECS-II消息体的编解码
SECS-II规定消息体格式,核心概念是Item。一条SECS-II消息体可以看作一棵ITEM树,根节点往往是一个List,下面可以套子List,也可以包含单个数据项。常用的格式码有:List(0x01)、ASCII(0x40或0x41)、Binary(0x20)、Boolean(0x29)、U1/U2/U4/U8(0x25/0x26/0x27/0x28)、I1/I2/I4/I8(0x31/0x32/0x33/0x34)、F4/F8(0x44/0x48)。每个Item由格式码、长度、数据组成,其中长度还可能使用两字节格式。
因为Item是嵌套结构,解码必须用递归。从根List开始,读取一个Item头,根据格式码判断是否要继续向下解析,如果是List就递归,否则读取具体长度和数据。编码时同理,递归构建字节序列。这个过程中最容易犯的错是List里元素个数与后续实际Item数不匹配,在解析时抛异常。我在编码器里加了一个Debug断言,以及错误消息打印,联调阶段非常实用。
还有一种常见问题就是ASCII编码,SECS-II协议里字符串没有强制统一编码,但实际中绝大多数设备用ASCII。C#里如果写成Encoding.UTF8.GetBytes,遇到中文或者特殊符号就可能导致字节数对不上,设备端解析时直接报错。项目里统一使用Encoding.ASCII,并规定所有字符串处理逻辑都走同一套工具方法,避免混用。
2.3 设备端和Host端各自的职责划分
在动手写代码前必须把两端角色理清楚。Equip设备端是TCP服务端,监听固定端口,维护与Host的连接。它要处理的业务消息包括S1F1(Are You There?)、S1F13(Establish Communications Request)、S2F17(Date and Time Request)、S6F11(Event Report Send)、S5F1(Alarm Report Send)等。简单说,设备端核心逻辑是“响应请求、上报状态、上报报警、上报事件”。
注意设备端也有主动发起消息的场景,例如上电后主动向Host发送S1F13不等Host先问,以及发生报警时主动发送S5F1。这种消息发送出去后,理论上Host要回S5F2确认。如果长期收不到确认,设备端是否重发,GEM标准里有具体规则,我们项目中简化成回调一个超时事件,由上层业务决定是否重发。
EAP Host端则要主动发起TCP连接,连接成功后发送Select Request建立会话,随后发送S1F13与设备确认通信状态,发送S2F17设置时间,发送S1F15/S1F16请求设备列表,发送S2F33读取变量,通过S2F41/S2F42管理事件报表。Host端要有一个消息分发器,收到设备端主动上报的消息后路由到事件处理器,例如收到S6F11就解析事件ID并触发业务回调。
还有一点容易被忽略:Host端同时管理多台设备时,每条连接都要单独维护会话状态。这个项目的Host端虽然只连一台设备,但代码里把设备连接抽象成了DeviceSession类,后续扩展多设备时只需要维护一个DeviceSession列表即可,不需要大改核心逻辑。
3. Equip设备端和EAP Host端的具体实现
3.1 开发环境与项目结构
开发环境方面,我用的是.NET 8.0,IDE用Visual Studio 2022。项目结构分两个独立工程,一个叫EquipServer,一个叫EapHost,另外还有一个类库SecsGem.Core用于共享协议编解码、HSMS消息模型、日志工具。这样拆的好处很明显:协议层代码只需写一次,设备端和Host端都引用同一个类库,避免两端解析逻辑不一致。
SecsGem.sln ├── SecsGem.Core │ ├── Hsms │ │ ├── HsmsMessage.cs │ │ ├── HsmsHeader.cs │ │ └── HsmsConnection.cs │ ├── Secs2 │ │ ├── SecsItem.cs │ │ ├── Secs2Encoder.cs │ │ └── Secs2Decoder.cs │ └── Common │ └── ByteHelper.cs ├── EquipServer │ ├── Program.cs │ └── DeviceService.cs └── EapHost ├── Program.cs └── HostService.cs这个项目结构参考了常见的服务端与客户端分离思路,如果只是临时调试,也可以把两端放在同一个控制台程序里用不同线程模拟,但可读性会差很多。特别是后续要添加GEM功能、数据库记录、界面展示,拆开工程会轻松得多。
3.2 设备端程序:从收连接到上报事件
设备端第一步是启动TcpListener监听9529端口。端口不是协议强制的,但我习惯用9529,因为SECS/GEM默认端口一般就是5000/9529,很多设备厂商使用9529。监听成功后,进入AcceptTcpClientAsync循环,每个客户端连接使用独立Task处理,保证并发安全。
private readonly TcpListener _listener; private readonly ConcurrentDictionary<int, HsmsConnection> _sessions = new(); public async Task StartAsync(int port) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); _logger.LogInformation("EquipServer listening on port {Port}", port); while (true) { TcpClient client = await _listener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleClientAsync(client)); } }对于每个新连接,要经历一次Select会话建立过程。整个流程是循环读取HSMS消息头,判断SType值。如果是SType=1,回复SType=2 Select Response,并将会话状态置为Selected;如果是SType=0,说明是数据消息,进入SECS-II消息分发器;如果是SType=5,回复Linktest Response;如果是SType=9,则关闭连接并清理会话。
private async Task HandleClientAsync(TcpClient client) { using var stream = client.GetStream(); while (client.Connected) { HsmsMessage msg = await HsmsReader.ReadMessageAsync(stream); switch (msg.SType) { case 0: // Data Message await ProcessSecsMessageAsync(msg); break; case 1: // Select Request await SendControlMessageAsync(stream, 2, msg); break; case 5: // Linktest Request await SendControlMessageAsync(stream, 6, msg); break; case 9: // Separate return; } } }数据消息分发要按Stream和Function来区分。我封装了一个字典,key为(SFN, SFN的组合),value是处理方法。例如收到(S1,F13),就调用HandleS1F13,组装S1F14回复。回复时必须把请求消息的SystemBytes原样返回,这样Host才能关联哪条回复对应哪个请求,这是整个协议最基础的对应关系。
S1F13回复内容按照GEM要求,必须包含MDLN(设备型号)、SOFTREV(软件版本)。代码中我定义了一个DeviceInfo类,用来表示设备ID、型号、软件版本。这些信息在S1F14中按设备信息列表格式返回,如果你省略了某个必填项,Host端解析S1F14就可能失败,这也是联调时经常遇到的问题。
事件上报相对独立。当设备内部EVENT发生变化时,例如一个批次完成,业务代码调用EventReporter上报S6F11。S6F11的Body结构是List,包含的是DataID、EventID,然后是多个Report集合。这里的EventID必须是Host之前通过S2F41/S2F42配置过的,否则设备上报的CEID Host端没有登记,会被当作未知事件丢弃。
3.3 主机端程序:连接管理、参数下发与报表收集
Host端的实现入口是主动连接设备IP和端口。连接建立成功后,不能立刻发数据消息,先发起Select Request,等待Select Response。这个步骤成功之后,才代表HSMS会话建立。随后项目里做了三件核心事情:发送S1F13建立通信,发送S2F17同步时间,发送S1F15/S1F16查询设备列表。
public async Task ConnectAndEstablishAsync(string ip, int port) { await _tcp.ConnectAsync(ip, port); _stream = _tcp.GetStream(); await SendHsmsControl(1); // Select Request HsmsMessage response = await ReceiveAsync(); if (response.SType != 2) throw new Exception("Select Response failed"); var s1f13Reply = await SendAndReceiveAsync( BuildS1F13Request() ); // 解析S1F14,取出MDLN和SOFTREV }这里SendAndReceiveAsync必须做一个请求消息的字典登记,等接收线程拿到消息后,通过SystemBytes找到对应的TaskCompletionSource并返回结果。这是实现异步请求应答模型最清爽的方式。我第一次实现时没有这么做,而是用一个全局变量存最近一次SystemBytes,结果并发请求一多立刻乱套。后来改成ConcurrentDictionary<int, TaskCompletionSource >才解决。
Host端接收线程是独立的,它负责解析所有收到的HSMS消息。如果是Primary消息,比如设备主动上报的S6F11、S5F1,就直接进入事件回调;如果是Secondary消息,则说明是对之前Primary请求的回复,通过SystemBytes匹配到对应的TaskCompletionSource,把结果交还给那个等待方。
我在项目里用一个简单的回调事件让上层业务感知设备状态变化。例如DeviceOnline、DeviceEventReceived、DeviceAlarmReceived。这样把通信层和业务层解耦,后续做WPF界面,或者接数据库记录,都只需要挂新事件即可,不需要改动通信逻辑。
再多提一句配置管理。EAP Host连接哪台设备、端口多少、心跳间隔多久、T6超时多少,我全放进appsettings.json。千万不要硬编码在代码里,否则现场联调时换台设备就要改代码重新编译,太痛苦了。设备端也要支持配置DeviceID、MDLN、SOFTREV,最好做成启动参数或者配置文件读取。
4. 联调实录:现象、排查与规避技巧
4.1 一次S1F13超时的定位过程
联调第一天,两边程序都能启动,TCP也连上了,但Host发送S1F13后迟迟等不到S1F14。抓包看到设备端其实已经回了包,但Host端一直抛超时。最开始怀疑是防火墙问题,后来看连接状态完全正常。于是我在解析消息处加了日志,把收到的HSMS消息前10字节全部打印成Hex,对比发现设备端回复的SystemBytes竟然不是Host请求里带的那个数。
问题出在设备端构造Secondary消息时,直接new了一个新的SystemBytes,而不是拷贝请求中的值。这个错误在代码走查时很难发现,因为两边SystemBytes刚好都是4字节整数,打印出来看着都类似,只有精确比对才知道不一致。修复方法很简单,所有回复消息的SystemBytes直接从原请求对象赋值。但为了以后防止再犯,我在SendAndReceiveAsync里加了一个校验,如果收到的系统字节与期望值不一致,记录一条Error日志并丢弃这条回复。
这类问题的定位思路值得记录一下:先确认TCP链路通不通,再确认HSMS控制消息是否正常,然后确认SECS-II消息体能不能正确解码,最后才去排查业务逻辑。一层一层剥洋葱,不然直接看Application层日志可能会被误导。
4.2 常见问题速查表
下面这张表是我在这个项目里实际遇到,以及行业内朋友经常碰到的问题,整理成速查表,按现象分类列出。排查的时候可以先对号入座。
| 故障现象 | 可能原因 | 解决方案 |
|---|---|---|
| TCP能连上,但Select Request后无响应 | 设备端没有处理SType=1控制消息 | 检查控制消息分支,必须在数据消息之前处理Select |
| S1F13回复超时 | SystemBytes未原样返回 | 回复消息必须携带请求消息的SystemBytes |
| 收到S1F14但解析失败 | List节点元素个数与实际数量不符 | 用解码器的Debug校验函数检查Item树 |
| 设备上报S6F11,Host端不触发事件 | EventID未在Host端注册 | 检查S2F33/S2F35配置流程,以及事件报表定义 |
| 通信一段时间后自动断开 | T8超时,即数据间隔超过设备容忍范围 | 加大T8存活时间,或定期互相发送Linktest |
| 消息能收能发,但设备显示数据乱码 | 字符串编码不一致 | 统一使用ASCII编码,禁止混用UTF8 |
| 多设备并发时回复串线 | 用单一变量存储请求ID,未按连接隔离 | 使用ConcurrentDictionary按连接或Session维护任务字典 |
| 消息发送延迟高 | 没有做消息队列,频繁创建Socket发送 | 复用连接,用Channel处理消息优先级 |
其中T8超时这个问题在仿真环境中最容易忽略,调试时由于停断点多,消息间隔时常超过设备容忍时间。最直观的表现就是程序停在断点继续运行时,连接已经被设备断开。这个不是代码Bug,但会让你误以为通信逻辑有问题,建议在调试模式下把设备端的T8设长一些,比如60秒。
4.3 几个新手最容易忽略的实现细节
第一个是字节读取的完整性。TCP是流式传输,一次Read调用不一定能拿到完整消息。比如你读取10字节Header,可能只读到了4字节,剩下的6字节要等下一次Read。代码里必须设置累计读取逻辑,直到填满Header长度,再根据长度字段读取Data部分。我封装了ReadExactlyAsync方法来循环读取固定字节数,避免因为拆包导致消息解析错乱。
第二个是日志的等级设计。Debug级记录每个收发消息的Hex和解析后的Item树,Info级记录会话建立、断开、关键流程,Error级记录异常堆栈。这个设计在联调时价值巨大。当别人拿着抓包工具问你“为什么我的消息没收到”,你直接打开Debug日志就能看到是在等哪个数据,对比一下就知道是对方没发还是自己解析错了。
第三个是状态机的实现。设备端至少要有Disconnected、Connected、Selected、Communicating四种状态。Host下发消息前判断当前是否处于Communicating状态,如果不是就报错提示“设备未就绪”。不要把所有状态都揉到if else里,最好是定义状态枚举,再通过状态转换方法统一控制。这套逻辑如果前期不做好,联调时各种脏数据会让人崩溃。
还有一个容易漏的是消息优先级。设备端可能同时要回S1F14的Secondary消息,又要主动上报S6F11事件。如果发送时不做队列,两个线程同时往同一个Socket写数据,会造成消息交错,接收方解析出来就是一坨乱码。我实现了一个简单的Channel 作为发送队列,单线程消费,按FIFO顺序发送,从根上避免并发写问题。
GEM标准里还有定时器处理、设备常量、数据变量、配方管理等功能,这些在这个项目中有些是简化实现的,有些是预留了接口。如果你要严格过GEM认证,不能这么简化,但作为一套可跑通的参考框架,目前的设计足以支持实际项目的二次开发。
最后再聊一点项目经验
这套代码写下来前后花了两周多,期间推翻过一次整体设计。第一次图省事,设备端和Host端各写各的解析器,结果联调时同一个消息两边的解析结果不一样,查了半天发现是长度字段的读取方式不同。后来把编解码逻辑统一放到SecsGem.Core里,这个问题才彻底解决。经验就是:协议解析这类通用逻辑,永远只写一遍,用共享库引用。
如果你准备基于这套代码做自己的项目,我的建议是先跑通Demo,再逐步加功能。第一件事是让设备端和Host端在自己电脑上完成一次S1F13通信,确认消息能通,然后再去接真实的模拟器或者设备。另外,调试时不要直接用生产参数,先把T6、T8调大一些,等到链路稳定了再收敛到标准值,这样能避免很多因网络波动造成的假故障。
最后补充一个小技巧:联调时最好用一段独立脚本模拟对端,只发送固定字节的消息,这样可以快速验证本端的解析逻辑。我调试的时候就是用Python写了个伪设备,每次触发S6F11上报时打印并保存报文,再拿这段报文去验证Host端的解析结果。这种“报文回放”的方式,比每次人工构造消息高效得多。
本文还有配套的精品资源,点击获取