简介:一份面向半导体设备自动化开发者的 C++ 版 SECS/GEM 协议实现源码包,覆盖 SECS1、SECS2、HSMS 与 GEM 设备模型,可用来构建设备仿真器、主机系统或设备控制器,解决晶圆制造场景中设备与上位机之间的通信难题。压缩包共 110 个文件,其中 57 个 h 头文件与 52 个 cpp 源文件构成完整工程骨架,另有 1 个接口文件,整体约 190KB,代码结构紧凑,便于直接阅读与二次开发。源码模块涵盖 SECS 消息编解码、Socket/串口通信、事务管理、设备模型、事件分发与错误处理等关键环节,分层清晰、模块化程度高,能够帮助开发者快速理解标准协议的交互流程、状态同步机制以及设备与主机的适配方法,并可作为进一步开发量产系统的基础。已有 6967 人学习下载,适合需要掌握 SECS/GEM 通信细节的嵌入式工程师、MES 开发人员及相关专业学生。 做半导体设备软件这行的朋友,对GEM、SECS1、SECS2、HSMS这几个词肯定不陌生。这四层协议是设备与工厂MES系统之间通信的事实标准,也是设备自动化(Equipment Automation)最底层的通信底座。我这次自己动手用C++完整实现了一套兼容这几份SEMI标准的协议栈源代码,涵盖串口版SECS-I、TCP版HSMS-SS、SECS-II消息编解码,以及GEM层基本状态模型和常用消息处理。这篇文章把整套协议栈的整体设计、核心实现和实际调试中踩过的坑都整理出来,给准备自研设备端通信软件,或者需要和半导体机台做联调的工程师参考。
1. 项目概况:这套协议栈到底在做什么
1.1 为什么放弃商业SDK,选择自己写
市面上一提到SECS/GEM通信,绝大多数设备厂商第一反应就是买商业协议栈,比如Cimetrix的Connectivity系列、国内的某些封装驱动。商业方案确实省事,拿过来配置一下设备模型就能跑,但它有几个问题在设备端场景里很别扭:授权费按机台数量收,量一大成本完全不低;整个协议栈是个黑盒,出了问题只能提工单等支持;最致命的是不好定制,比如设备内部固件要直接集成通信模块、需要在收到配方后立刻做一层业务校验、要对接自研的调度系统,这时候被商业库牵着走特别难受。
开源方案又是另一个极端。Python、Java的实现偏主机端和上位机工具,真正适合嵌到设备控制器里的C++实现很少,而且大多只覆盖其中一两层协议,要么没有GEM状态模型,要么SECS-II的数据类型支持不全。所以从半导体设备软件工程师的角度看,自己按SEMI标准文档实现一套C++协议栈,其实是一件性价比很高的事:协议本身有公开规范,核心难点不在“懂不懂协议”,而在工程化过程中的细节取舍。
1.2 分层架构:把SECS1/HSMS统一到一条传输接口上
这套协议栈在架构上严格按四层拆分:传输层(SECS-I / HSMS)、消息层(SECS-II)、应用层(GEM)、业务接口层。这样一个设计背后有个很实际的原因——同一套上层业务逻辑,要能跑在两种完全不同的物理传输上。
老设备、8寸线、某些专用模块还在用RS-232串口跑SECS-I;新设备尤其是新建产线,基本全部走以太网用HSMS。如果业务层直接绑定传输方式,那两套逻辑就要维护两遍,C++代码里复制粘贴改改的后果谁都懂。所以我把传输层抽象成ITransport接口,SECS-I和HSMS各自实现同一套send/receive/open/close语义,SECS-II消息编解码完全复用。上层业务面对的是“消息”,而不是“串口还是socket”。
1.3 为什么最终选了C++
语言选型其实没什么悬念。设备控制器常见的是Windows工控机或者Linux嵌入式环境,C++在这两种平台上都能做到一套核心代码、少量平台适配;内存管理可控,协议栈作为常驻服务可以长时间稳定运行;而且和底层设备交互(串口、网卡、共享内存、OPC UA等)C/C++是最顺手的。再加上模板和类型系统的支持,编解码层的代码可以写得既安全又高效,这一点在后面的实现细节里会体现。
2. 传输层核心设计:SECS-I与HSMS双栈实现
2.1 SECS-I:串口半双工下的块传输机制
SECS-I在物理上就是一根RS-232串口线,半双工、波特率通常9600。协议将一条完整消息拆成一个个Block(块),每个Block头部10字节,数据部分最多244字节,最后一个块用E位(Endbit)标记结束。发送方每发一块,必须等接收方的ACK/NACK确认;只有收到NACK才需要重传同一块。
实现的时候有几个细节特别要命。第一是字节填充:串口链路上出现控制字符(比如DLE、STX、ETX)时必须做转义,否则接收方会把数据里的字节误判成帧边界。这一块我建议单独封装一个FrameCodec,不要揉进传输层主逻辑里,否则后续排查字节错位问题会非常痛苦。第二是一组定时器,T1等块确认、T2块间延迟、T3消息间等待、T4互锁超时,每个定时器对应独立状态,用统一的定时器轮询线程管理。第三是Block编号,我见过不少设备在块数超过0x7FFF时的处理完全不对,标准里这块有明确的回绕规则,但实测很多上位机实现是兼容的,自己设备端做严谨些没有坏处。
2.2 HSMS:TCP全双工、心跳与连接模式
HSMS基于TCP/IP,全双工、不需要拆块,一条SECS-II消息可以整包直接承载。HSMS消息头固定16字节,关键字段包括SessionID(2字节)、SType(消息类型,0为数据消息,1到7为控制消息)、PType(SECS-II消息固定为0)、Stream/Function/W-bit组合、以及4字节SystemBytes。控制消息里最常用的就是Select.Request/Select.Response(建立会话)、Linktest.Request/Linktest.Response(链路保活)、Separate.Request(断开会话)。
连接模式分主动和被动。我需要强调,主动/被动的叫法容易混淆:主动方(Active)是发起TCP连接的一方,被动方(Passive)是监听等待连接的一方。真正会导致联调失败的点是“谁来发起Select握手”,SEMI标准里规定主动方必然发起Select,被动方配合响应。实测最典型的报错场景是:设备侧把自己配置成被动方,但Host也配置成被动方,两边都在等,网络层通但应用层就是建立不了会话。这个在排障时是首先要确认的。
T7/T8心跳参数也值得留意。T7是空闲多久主动发Linktest,T8是发了Linktest后多久收不到响应就判定连接断开。有些Host实现的心跳间隔短,设备端默认T7调得长,会出现Host认为链路断了、设备还浑然不觉的情况。我建议设备端T7初始值就按SEMI E37默认来,不做特殊优化,等联调时再根据Host侧要求调整。
2.3 传输层抽象:ITransport接口如何设计
看一段简化的接口定义:
class ITransport { public: virtual ~ITransport() = default; virtual bool open(const TransportConfig& cfg) = 0; virtual void close() = 0; virtual bool isConnected() const = 0; virtual bool sendBlock(const uint8_t* data, size_t len) = 0; virtual void setReceiveCallback(std::function<void(const uint8_t*, size_t)> cb) = 0; };SECS-I的sendBlock负责组帧、填充、加块头、等确认;HSMS的sendBlock就是往TCP缓冲区写数据。上层拿到的是完整的SECS-II消息字节流,完全不关心它底下的帧格式差异。这个抽象是整个协议栈能保持单一代码链路的核心,我强烈建议做传输层时先把这套接口定死,不要边写边改,否则串口和TCP两个实现会越写越不一样。
3. SECS-II消息编解码与SML解析
3.1 数据类型与编码规则
SECS-II是消息内容的编码标准,它定义了一个层级化的Item树结构,每个Item由一个Format Byte开头,后跟长度信息,再跟数据内容。我实现的类型覆盖是这样的:
| Format | 类型 | 说明 |
|---|---|---|
| 0x00 | List | 子项列表,递归结构 |
| 0x20 | Binary | 无符号字节数组 |
| 0x30 | Boolean | 布尔值数组 |
| 0x40 | ASCII | 字符串 |
| 0x50 | JIS-8 | 日文字符集字符串 |
| 0x60 / 0x70 / 0x80 / 0x90 | I8 / I1 / I2 / I4 | 有符号整数 |
| 0xA0 / 0xB0 | F8 / F4 | 浮点数 |
| 0xC0 / 0xD0 / 0xE0 / 0xF0 | U8 / U1 / U2 / U4 | 无符号整数 |
长度编码的规则是:如果长度小于128字节,直接用Format Byte后的一个字节表示;如果超过127字节,用0x81(后跟2字节长度)、0x82(后跟3字节长度)、0x83(后跟4字节长度)这种带偏移的形式。这个规则看着简单,但实测极容易在“高位字节顺序”上出问题,所有长度字段都是大端字节序,和网络字节序一致。
3.2 DataItem树与编解码器
我用一个DataItem类表达所有类型,List表示子节点集合,其他类型直接在原始字节缓冲区上做解释:
class DataItem { public: DataType type() const { return type_; } const std::vector<DataItem>& children() const { return children_; } std::vector<uint8_t> encode() const; static DataItem decode(const uint8_t* data, size_t len, size_t& consumed); private: DataType type_; std::vector<uint8_t> raw_; std::vector<DataItem> children_; };编解码器我写成递归函数,encode把Item树序列化成字节流,decode从字节流还原成Item树。整个回路可以直接用“先encode再decode必须还原出同构树”来做单元测试。这一步是在工程里最有价值的地方:协议栈所有层都依赖这段编解码逻辑,如果这里有BUG,上层GEM的状态机再对也是白搭。
3.3 SML解析:运行时解析还是代码生成
SEMI官方标准的SML(Statement Message List)描述文件是人类可读的消息结构定义,形如S1F1 W、S1F2 .这类。实际项目中,最舒服的开发方式是用SML定义好消息格式,然后让工具自动生成C++结构体或者运行时解析表。
我最终选择了运行时解析,因为设备端消息类型不会固定,Host侧随时可能要求新增一个自定义Stream消息,代码生成虽然效率高、类型安全更强,但每次改消息定义都需要重新编译部署,对设备现场运维不友好。运行时解析则在启动时加载SML文件,把消息模板注册到消息分发器里,新增消息只需要改配置文件。代价是调用点需要做一层动态类型检查,这在C++里用std::any或者std::variant可以控制得很干净。
4. GEM应用层:状态模型与标准消息处理
4.1 状态模型:两个维度要分开看
GEM标准定义的设备状态,我一开始也理解混了。它其实是两个正交的维度:
控制状态(Control State)是OFFLINE、LOCAL、REMOTE三态。设备在OFFLINE时不接受远程操作;LOCAL是本地操作;REMOTE是允许Host远程控制。通信状态(Communication State)是COMMUNICATING和NOT COMMUNICATING两态。注意一个设备可以同时处于REMOTE和NOT COMMUNICATING,这表示它允许远程控制但TCP链路断了,报警照样要本地显示。
我在实现里建了两个独立状态机,用事件驱动的方式切换,比如收到S1F1/S1F2建立通信时通信状态切到COMMUNICATING,收到S1F13/S1F14时控制状态从OFFLINE/LOCAL切到REMOTE。两个状态机之间不要用全局变量强耦合,否则后维护阶段每个状态迁移都要瞻前顾后。
4.2 标准消息分发与处理框架
消息分发我用了一个非常朴素的注册表:
using MessageHandler = std::function<void(const Secs2Message& msg)>; class GemDispatcher { public: void registerHandler(int stream, int function, MessageHandler handler); void dispatch(const Secs2Message& msg); private: std::map<std::pair<int, int>, MessageHandler> handlers_; };收到任何消息先查注册表,再回调到具体处理函数。GEM常用的标准消息包括S1F1/F2(通信建立)、S1F3/F4(状态数据查询)、S2F13/F14(设备变量)、S5F1/F2(报警上报)、S6F11/F12(事件上报)等。这个注册表模式的工作量集中在一开始的消息类别枚举,后面新增消息就是写一个handler函数然后注册,扩展性很好。
4.3 事件、报警与数据收集
GEM层除了被动的消息回复,还有主动上报能力。设备侧在某个状态变化时(比如腔体温度越限、配方加载完成)会主动发S6F11事件报告,Host收到后回S6F12。事件ID(CEID)需要和定义的事件报告表对上,而事件报告表是通过S2F33/S2F35由Host动态配置的。这部分实现有个典型的坑:设备端如果没实现S2F33/F35,Host会认为设备不支持GEM高级功能而拒绝整线认证。
所以我实现的GEM层把“动态事件收集”做成了默认开启:设备内置一个报告定义表,Host通过S2F33下发定义报告、S2F35链接事件报告、S2F37启停数据收集,设备端把Host的操作映射到内部表里,后续事件上报从表里取配置序列化数据。这块逻辑是纯数据驱动的,不涉及业务逻辑,写起来稳,测试也好测。
5. C++工程落地:从协议栈到可运行Demo
5.1 工程结构与关键类
整个工程我按库和示例程序拆开,目录结构如下:
secs-gem/ ├── include/ # 对外头文件 │ ├── transport/ # ITransport, Secs1Transport, HsmsSession │ ├── secs2/ # DataItem, Secs2Codec, SmlParser │ └── gem/ # GemStateMachine, GemDispatcher, EventManager ├── src/ ├── tests/ # 单测与消息编解码回环测试 ├── examples/ # 设备端Demo和Host端Demo └── sml/ # 标准消息的SML定义文件关键类除了前面说到的ITransport、DataItem、GemDispatcher,还有一个Secs2Message承载完整消息(10字节消息头 + Item树),以及HsmsSession管理TCP连接建立、Select握手、心跳和断线重连。设备端Demo的设计是一个简单的“温控设备”,支持Host查询温度、上报温度越限报警,这个Demo只用了不到800行业务代码,完整演示了从TCP建链到事件上报整条链路。
5.2 线程模型与生命周期
协议栈线程模型要简单清晰。我用了三个线程:发送线程(负责消息出站和重传)、接收线程(负责读串口或socket,解帧后交给分发器)、定时器线程(处理T3/T5/T7/T8等全部超时计时)。业务回调不在接收线程里执行,而是投递到业务线程的事件队列里,防止业务处理阻塞协议栈接收。这个设计花了些功夫但非常值,现场跑半年没出现过卡死或消息丢失。
连接断开的生命周期管理也要提前规划。HSMS断线后不能立即释放对象,因为可能还有未完成的消息事务在等回复。我以HsmsSession为所有权单元,断线后先进入“关闭中”状态,等未确认事务超时后再真正释放资源,这样上层拿到的session指针一直是有效的,只是isConnected()返回false。
5.3 编译、移植与联调
代码在Windows(MSVC)和Linux(GCC/Clang)双平台编译,串口部分用了一个轻量的平台层封装,Windows下走CreateFile/ReadFile,Linux下走open/tcsetattr,socket部分用标准BSD socket,两边差异小。编译建议打开-Wall -Wextra -Werror,协议栈代码对警告零容忍能省掉大量隐晦bug。
联调环节,我先写了一个自己的Host模拟器,直接构造SECS-II消息字节流来测设备端;再用Python的开源库secsgem作为第三方Host做过一轮互操作测试,它支持HSMS主动和被动模式,用来验证我这边的消息编码是不是标准。最后找了一台支持GEM的老型号温控器,用真实设备跑通了完整的S1F13建立连接、S2F13查询变量、S6F11上报事件流程。三次测试暴露的问题形态完全不同,自测偏逻辑,第三方库偏标准兼容,真机偏边界条件,三个环节都不能省。
6. 常见问题与排查技巧实录
6.1 连接建立类问题
最常遇到的就是前面说的主动/被动模式不匹配,表现为设备侧TCP已经ESTABLISHED,但双方一直不发Select,或者设备发了Select之后等不到Select.Response。排查第一步永远是看抓包里的SType和系统字节,确认Hex Data里的SessionID、SType、PType是否符合预期。我一开始用自己写的日志函数打印字节流调试,后来发现Wireshark对HSMS协议有解析支持,能直接把16字节头字段解出来,定位效率提升非常明显。
另一个高频问题是T6超时。T6是控制消息(比如Select)的超时计时器,如果一方迟迟不响应,主动方会重试并最终断开。很多团队的调试日志只记“T6 timeout”就没了,然后无从下手。我建议日志里必须把超时的消息头完整打出来,包括SessionID、SType、SystemBytes,第一时间就知道是哪条控制消息没人理。
6.2 消息编解码与数据不匹配问题
字节序问题是最阴险的。HSMS的SessionID和SystemBytes、SECS-II的长度字段都是大端,一旦某个地方用了小端编码,消息发出去逻辑完全错乱但TCP层完全正常。我排查过一个诡异现场:Host收到设备上报S6F11后回复S6F12,但设备侧一直认为SystemBytes对不上,后来发现是我的SystemBytes生成时用了本机小端序写入,自作聪明导致全链路对不上。解决方案是编码器里统一封装一个writeU32BE函数,禁止任何地方直接memcpy一个整数到字节流。
分块重组成问题也是常见来源。设备端如果消息过大(比如配方S7F1下载几MB内容),HSMS虽然不用拆块,但TCP分包是不可避免的,接收方必须处理“一条消息分多次到达”和“多条消息一次性到达”两种情况。我的做法是接收循环里维护一个累积缓冲区,先解析16字节头拿到消息总长度,再等缓冲区够长再整体消费,严格避免在循环里直接按recv返回值当作消息边界。
6.3 调试经验与工具链
日志是协议栈调试的生命线。我最终采用的日志格式是:每条入站/出站消息一行,包含时间戳(精确到毫秒)、方向(RX/TX)、消息名(如S6F11)、SystemBytes、以及编码后的关键字段摘要。配合一个脚本按SystemBytes排序,基本能肉眼追踪一条事务的完整生命周期。加日志的原则是输出关键头和业务摘要,不要输出完整Item树,否则生产环境下日志量大到没法看。
单元测试我额外建议做三件小事:一是编解码回环测试,随机生成Item树,编解码后必须还原;二是状态机迁移测试,把GEM的每一个合法/非法迁移都写成测试用例;三是连接断开恢复测试,模拟TCP半开连接、断电重连、对端主动关闭等场景。这三类测试在接入新Host时非常有价值,几乎能挡住70%以上的现场兼容性问题。
最后再分享一个我自己的体会:协议栈实现本身难度没那么高,真正花时间的是把各种Host的兼容性边界摸清楚。不同上位机对GEM细节的理解有差异,有的Host建链后马上发S1F3不等你S1F1完整确认,有的Host对S6F11的回复只给S6F12不带CEID,这些都不是标准文档里能学到的。如果你也在做这套东西,拿标准实现当基线,再准备一套能灵活调参数的测试环境,会比咬着文档死磕有效得多。
本文还有配套的精品资源,点击获取