☰
SECS-II消息解析与Stream/Function事务机制
2026/11/2 7:06:39 网站建设 项目流程

简介:《SEMI E5-1104》是2004年发布的SECS-II英文原版标准文档,面向半导体设备研发、控制系统维护及产线自动化集成工程师,用于解决设备与主机间消息交换、接口定义和集成调试中的规范依据问题。压缩包仅含1个PDF文件,大小约2.66MB,保留标准原版排版与高清文字,便于检索条款、对照实现和打印查阅。已有348人学习下载,说明其在SECS/GEM相关工程实践中具有一定参考需求。文档系统给出消息结构、流与函数、事务与对话协议、数据结构及计量单位,并细化设备状态、控制与诊断、物料状态、异常处理、数据采集、配方和工艺管理等消息用途,同时涉及T1-T4超时、错误恢复与自定义流/功能码。读者可据此理解主机与设备端如何落地SECS-II消息,排查通信语义、超时与数据格式问题,并作为设备集成和接口开发时的案头标准资料。

1. 一台刻蚀机和一台主机之间,到底约定了什么

半导体设备通信里有件反直觉的事:设备与主机之间不是靠一套 RPC 接口或厂商 SDK 对接的,而是靠一份几百页的“消息字典”——SEMI E5-1104 定义的 SECS-II。同样是“读设备状态”,不同厂商的设备可能给出完全不同的 Stream 和 Function 组合,主机却必须都能解析。SECS-II 解决的不是“字节怎么送到对面”,而是“送到对面的一串字节,凭什么能被另一套软件正确理解”。这也是很多人啃这份标准最难受的地方:它看着像产品清单,实际是一套自描述数据格式加一台事务状态机。搞懂这层,联调时那些“字段对上了但值不对”“回复收到了但配错了请求”的怪问题才有解释。

2. 消息自描述:Item 和 List 怎么做到不查文档也能解析

SECS-II 最核心的设计决定,是把消息体做成“自描述”的:每一个数据元素自带类型和长度,接收方不需要提前知道格式就能逐字节拆完。这一点直接决定了主机侧能不能写一套通用解析器去对接几十种设备,而不是每接一台设备就改一次代码。

2.1 一个字节里塞进了类型和长度宽度

每个 item 的开头是一个格式字节。高 6 位是格式码(format code),低 2 位告诉你后面的长度字段占几个字节。格式码用八进制看最直观:0o20是 ASCII,0o31是一字节有符号整数,0o44是四字节浮点,0o00是 List。低 2 位加一,就是长度字段的字节数,所以长度可以是 1、2、3 字节,取值上限 16777215,够描述一条超长配方。

这种“变长整数 + 定长类型码”的组合不新鲜,但在 1980 年代定下来并且沿用至今,说明它踩中的是真需求:解析器只要一个 switch 加一个递归函数就能写完,放到当年单片机级别的设备控制器里也跑得动。

# 解析 SECS-II item 头:高 6 位格式码,低 2 位 = 长度字段字节数 - 1 FORMAT_NAMES = { 0o00: "LIST", 0o10: "BINARY", 0o11: "BOOLEAN", 0o20: "ASCII", 0o21: "JIS8", 0o30: "I8", 0o31: "I1", 0o32: "I2", 0o34: "I4", 0o40: "F8", 0o44: "F4", 0o50: "U8", 0o51: "U1", 0o52: "U2", 0o54: "U4", } # 这些类型的 length 表示“元素个数”,而非字节数 NUMERIC = {0o30, 0o31, 0o32, 0o34, 0o40, 0o44, 0o50, 0o51, 0o52, 0o54} ELEM_SIZE = {0o30: 8, 0o31: 1, 0o32: 2, 0o34: 4, 0o40: 8, 0o44: 4, 0o50: 8, 0o51: 1, 0o52: 2, 0o54: 4} def parse_header(buf: bytes, pos: int): b = buf[pos] fmt = b >> 2 n_len = (b & 0x03) + 1 # 00->1 字节, 01->2 字节, 10->3 字节 if fmt == 0o00: # LIST 的长度字段固定 1 字节 n_len = 1 length = int.from_bytes(buf[pos + 1: pos + 1 + n_len], "big") return fmt, length, pos + 1 + n_len # 返回格式、长度、正文起点

这段代码里最容易被忽略的是n_len那行:低 2 位的值加一才是字节数,不是值本身。很多自研解析器在这里少加一,遇到长度小于 256 的消息一切正常,一旦某条配方超过 255 个字符,长度字段自动变成 2 字节,解析器立刻错位——而错位之后读出来的偏偏还是“合法”的格式码,于是错误被吞掉,直到某个数值字段读出天文数字才暴露。

2.2 length 的语义分裂:元素个数还是字节数

这是 SECS-II 里最经典的一处不一致:数值类型的 length 是元素个数,文本和二进制类型的 length 是字节数。一条I4的 length 写 3,正文是 12 字节;一条ASCII的 length 写 3,正文就是 3 字节。标准这么定是为了让接收方算缓冲区时不必关心元素宽度——对 LIST 尤其重要,LIST 的 length 是子项个数,不是字节数,因为子项长度本身不固定。

于是完整的递归解析逻辑是这样:读到 LIST 头,拿到子项个数 n,然后循环 n 次调用自己;读到数值类型,按length × ELEM_SIZE切出正文。递归深度就是嵌套层数,标准里的消息最多嵌套四五层,正常实现用一个显式栈比递归更安全,避免畸形消息把栈打穿。

def parse_item(buf: bytes, pos: int): fmt, length, pos = parse_header(buf, pos) if fmt == 0o00: # LIST:递归 n 次 children = [] for _ in range(length): child, pos = parse_item(buf, pos) children.append(child) return {"fmt": "LIST", "items": children}, pos if fmt in NUMERIC: # 数值型:length 是元素个数 nbytes = length * ELEM_SIZE[fmt] else: # ASCII / BINARY / BOOLEAN:length 是字节数 nbytes = length raw = buf[pos: pos + nbytes] return {"fmt": FORMAT_NAMES.get(fmt, hex(fmt)), "len": length, "raw": raw}, pos + nbytes

2.3 为什么不用 JSON 或者现成的序列化格式

按现在的眼光,这套格式明显不如 JSON 好读。选它的理由在当年很硬:解析器要能在资源极其有限的设备控制器里跑,且必须能单遍流式解析,不能先缓冲整条消息再反序列化。自描述格式的好处是接收方对消息内容零先验——同一套解析代码能拆开任何一条 SECS-II 消息,语义解释交给上层的 Stream/Function 表。这就是“协议层不懂业务”的原始形态。

代价也很明显:可读性差、调试靠抓包工具、没有 schema 校验。标准里专门有一章 Data Item Dictionary 和一章 Variable Item Dictionary,就是拿人肉方式补上 schema 这一环——用文档换运行时开销。理解这个取舍,后面看 Stream/Function 的划分方式就顺了。

3. Stream 与 Function:奇偶配对是怎么变成一台状态机的

如果 Item 机制解决的是“怎么拆”,Stream/Function 解决的就是“拆出来的是什么事”。这两层严格分离,是 SECS-II 能在几十种设备上活下来的根本原因。

3.1 奇数主消息、偶数回复,编号加一就是配对

Function 的编号规则简单到有点粗暴:主消息(primary)一律用奇数,回复消息(secondary)的编号等于主消息编号加一。S1F1 的回复是 S1F2,S1F13 的回复是 S1F14。Function 0 在所有 Stream 里保留,专门用于中止事务。

这个设计的好处是配对零成本。发起方发出 S2F41 之后,收到任何 S2F42 都能立刻判定“这是我的回复”,不需要维护额外的关联表;回复方收到 S2F41,也知道自己必须回 S2F42。对比那些给请求和响应各起一套独立编号的协议,SECS-II 把关联关系编码进了编号本身,省掉了一张映射表和一个状态字段。

代价是表达力被锁死:一个主消息只能有一种回复编号,想表达“成功/失败/部分成功”只能靠回复消息体里的第一个字节(很多 Function 约定首项是返回码),而不能用不同的 Function 号区分。这也解释了为什么 SECS-II 消息体里到处是ACKC6这类一字节返回码。

顺带一个容易忽略的细节:请求回复的标志不是 Function 号,而是消息头里的 W-bit。主消息 W-bit 置 1 表示“我需要回复”,置 0 表示“单向通知,别回”。所以严格来说,“奇数必有回复”是错的——S1F1 可以不带 W-bit 发出去,对端不回也是合法的。

# 收到一条 primary 消息后,怎么判断该不该回、回哪个 Function def route(sh: int, sf: int, wbit: int, body: dict): # Function 0:中止事务,任何时候都不产生回复 if sf == 0: return abort_transaction(body) # 只有奇数 Function 才可能是 primary if sf % 2 == 0: raise ProtocolError(f"S{sh}F{sf} 不是合法的 primary") handler = HANDLERS.get((sh, sf)) if handler is None: # 未识别的 Stream/Function,标准要求回 S9 系列错误 return error_reply(9, 5 if stream_known(sh) else 3) result = handler(body) if not wbit: return None # 不要求回复,处理完就结束 return build_secondary(sh, sf + 1, result)

HANDLERS这张表的键是(stream, function)二元组,实际工程里通常还会叠一层设备型号——因为标准只规定消息形状,具体某台设备支持哪些 Function 由设备自己声明。这一点标准写得很直白:设备负责定义自己实现的消息子集,主机负责适配。所以真实项目里那张表从来不是标准给的,是设备手册里抄出来的。

3.2 Stream 0 和 Function 0:协议自己的控制通道

Stream 0 是唯一不承载业务语义的 Stream,里面装的都是“关于通信本身”的消息:S0F0 中止事务,S0F1 abort 的确认,S0F2 给 abort 的回复,S0F3 是通信延迟的握手,S0F5 用于请求通信延迟,等等。Function 0 又是所有 Stream 通用的中止码。

这相当于在数据通道里留了一条带外控制通道。当主机发了一条 S6F11 却迟迟等不到回复,它可以发 S0F0 把这条事务强行掐掉,然后继续干别的,而不用重启整个连接。没有这个机制,一次超时就得断链重连,产线上的设备经不起这种折腾。

S9 系列则是错误上报通道:S9F1 未识别 device ID,S9F3 未识别 Stream,S9F5 未识别 Function,S9F7 数据非法,S9F9 事务超时,S9F11 数据过长。这六个错误码几乎覆盖了联调阶段 80% 的异常,抓包时看到 S9Fx 基本就能定位到是哪一层出了问题——是设备不认这个 Stream,还是数据格式被判定非法,还是干脆超时了。

3.3 Stream 1 到 17 按“活动”分类,而不是按设备类型

标准把消息按活动类型切分,而不是按设备种类:Stream 1 设备状态、Stream 2 设备控制与诊断、Stream 3 材料状态、Stream 4 材料控制、Stream 5 异常处理、Stream 6 数据采集、Stream 7 工艺程序管理、Stream 8 控制程序传输、Stream 9 系统错误、Stream 10 终端服务、Stream 12 晶圆映射、Stream 13 数据集传输、Stream 14 对象服务、Stream 15 配方管理、Stream 16 工艺管理、Stream 17 设备控制与诊断的扩展。

按活动切的好处是同一套语义能横跨不同设备。一台清洗机和一台沉积设备都需要“报告材料位置”,都用 S3 系列;都需要“开始处理”,都用 S2F41。主机的业务代码因此可以复用,只有参数细节不同。如果按设备类型切 Stream,每来一种新设备就得新开一段编号,主机侧永远在打补丁。

值得注意的是 Stream 11(主机文件服务)在后续版本里被删掉了。标准做过减法这件事挺有意思:文件传输后来被 S7 的工艺程序管理和外部通用文件通道替代,留着 Stream 11 只会让实现者犹豫要不要支持。删掉比留着更清晰——这也是读旧版规范时容易踩的坑,某些老设备的文档里还写着 S11,新版标准里根本查不到。

4. 事务与超时:T3、T4 与多开事务在约束什么

消息格式解决的是静态结构,事务协议解决的是动态时序。SECS-II 这一层的心智模型其实很小:一对 primary/secondary 构成一个事务,发起方叫 originator,接收并解释的叫 interpreter,事务有超时,可以有多个同时在飞。

4.1 消息头:10 个字节里装下了整个事务上下文

每条 SECS-II 消息前面有固定 10 字节的消息头:前 2 字节是 device ID(0 到 32767,最高位是 R-bit,指示消息方向),第 3 字节的高位是 W-bit、低 7 位是 Stream,第 4 字节是 Function,第 5、6 字节是 PType 和 SType(SECS-II 里都固定为 0),最后 4 字节是 System bytes。

System bytes 就是事务 ID,由发起方生成,回复方必须原样带回。这条规则看着平淡,实际是整个异步通信的支点:设备可以在收到 S6F11 之后很久才回 S6F12,只要 System bytes 对得上,主机就能认领。没有它,主机并发发十条消息就只能靠“先来先回”假设,任何乱序都会崩。

device ID 和 System bytes 一起构成了事务的全局唯一标识——同一个主机端口下挂多台设备时,光靠 System bytes 区分不开,必须(deviceID, systemBytes)一起看。

4.2 T3 和 T4 为什么必须分开

超时定时器有四个:T1 字符间超时、T2 协议超时、T3 回复超时、T4 块间超时。常见实现里 T1 在几百毫秒量级,T2 十几秒,T3 和 T4 各几十秒(不同设备配置不同,以设备文档为准)。

T3 管的是“我发了请求,迟迟等不到第一个回复块”。T4 管的是“回复来了第一块,但后续块迟迟不到”。这两件事的故障定位完全不同:T3 超时说明对端没接住或者处理卡住,问题在处理逻辑;T4 超时说明对端已经在发了但中间断了,问题在线路或分块逻辑。如果合并成一个超时,联调时你只知道“超时了”,根本分不清该查设备软件还是查网线。

T1 和 T2 更靠底层:T1 是字节流级别,一个字节到下一个字节之间的间隔;T2 是块级别,接收方回了控制帧之后等下一块的时间。标准明确说这些是消息传输协议(也就是 SECS-I)的职责,SECS-II 只是“期待被通知超时了”。这层职责划分很关键——SECS-II 不定义超时怎么测,只定义超时之后怎么办。

4.3 一个独立的小状态机

把上面这些串起来,一条消息的生命周期其实是个五行状态机:

class Transaction: def __init__(self, sysbytes, t3=45.0, t4=45.0): self.sysbytes = sysbytes self.state = "WAIT_EOT" # 等第一个回复块 self.deadline = now() + t3 self.t3, self.t4 = t3, t4 self.blocks = [] def on_block(self, block, is_last): if self.state == "WAIT_EOT": self.state = "IN_REPLY" # 每收到一块就重置块间超时,这是 T4 的语义 self.deadline = now() + self.t4 self.blocks.append(block) if is_last: self.state = "DONE" return assemble(self.blocks) def on_timeout(self): if self.state == "WAIT_EOT": return fail("T3: 对端未回复") # 处理侧问题 if self.state == "IN_REPLY": return fail("T4: 回复分块中断") # 传输侧问题

deadline在第一块到达时从 T3 换成了 T4,这个切换点就是两种故障的分界线。写实现时最容易犯的错是全程用同一个超时值,表面能跑,出问题时日志里全是“timeout”,排查成本翻倍。

4.4 多开事务和单块 244 字节

标准允许但不要求支持多个并发打开的事务。这条“允许但不要求”很要命:主机侧代码通常假定可以并发,设备侧实现可能只支持一个。于是就有了那种经典现象——主机连发三条消息,第一条回了,后两条全超时,因为设备把它们排队了但没实现队列。

单块消息 244 字节这个上限来自 SECS-I:一个块的数据区最多 254 字节,扣掉 10 字节消息头,剩 244。超过就得拆成多块,每块带块号和 E-bit 标志最后一块。标准从 1988 年修订起明确规定,新应用不得对接收块的尺寸施加额外限制——这条是给历史包袱兜底的,早期有些实现只认固定块大小,新设备不该再这么干。

5. 避坑与排查:SECS-II 对接里最容易翻车的几处

5.1 长度字段按元素还是按字节,两边理解不一致

现象:小消息一切正常,某条包含长字符串或大数组的消息一发就报 S9F7(数据非法)或者解析出乱码。

原因:一边按字节数填 length,一边按元素个数读。ASCII 类型下两者恰好相等,所以短消息测不出来;一旦用了 I4 数组或多字节字符集,长度差 4 倍,正文直接错位。

解决:把类型和 length 语义的对应关系写成一张表放进公共库,编码和解码共用同一份定义,任何新增类型先过一遍这张表。抓包时优先核对第一个出错 item 的格式字节和 length,别从头逐字节看。

5.2 多块消息拼包顺序错乱

现象:偶发性的数据错乱,重发一次又好了,日志里看不出规律。

原因:多块消息靠块号排序,E-bit 标志结束。有些实现在高并发下把不同事务的块混进同一个缓冲区,或者收到重复块时直接追加,导致拼出来的正文多一段或少一段。

解决:拼包缓冲区必须以(deviceID, systemBytes)为键隔离,块号做去重,收到 E-bit 之前不允许提前交付。同时在拼包入口打一条带块号的日志——这条日志在排查乱序问题时几乎一锤定音。

5.3 把 W-bit 当成 Function 号的一部分

现象:设备对某些请求不回复,主机一直等到 T3 超时;但同样的 Function 号换个场景又能正常回。

原因:误以为“奇数 Function 就必须回”,忽略了 W-bit。某些设备实现严格按 W-bit 判断,主机发送时没置位,设备就当作单向通知处理。

解决:发送侧永远显式设置 W-bit,接收侧以 W-bit 为准而不是以 Function 奇偶为准。抓包时先看消息头第 3 字节的最高位,这一位比 Function 号更能解释“为什么不回”。

5.4 数值类型和单位没对齐

现象:温度、压力这类字段数值差几个数量级,但字段类型和长度全对。

原因:标准第 12 章定义了计量单位标识符,但没有强制某字段必须用哪个单位;设备文档写摄氏度,主机按开尔文处理,数据本身完全合法。

解决:把单位当成接口契约的一部分写进对接文档,每条数值字段标注单位标识符,联调第一步就拿已知物理量做对拍。浮点数统一按四字节单精度处理,别在编码侧悄悄用双精度。

5.5 通信超时之后的重试把问题放大

现象:偶发超时演变成大面积超时,设备侧日志显示收到大量重复请求。

原因:T3 超时后主机立即重发,但设备其实已经处理完了,只是回复在路上丢了。重发导致同一操作执行两次,对材料搬运这类有副作用的操作是灾难。

解决:重试前先发 S0F0 中止原事务,或者用 System bytes 做幂等判断;对非幂等操作一律不自动重试,交给上层决策。这条不是标准要求,是血泪经验。

6. 设计取舍:为什么把“语义”和“传输”拆成两层

回头看 SECS-II 最值得借鉴的一点,是它从头到尾没打算自己解决传输问题。标准开篇就写明与 SECS-I 完全兼容,同时允许换用其他传输协议;第 6 章讨论的全是“应用层对传输层的要求”,而不是传输本身怎么实现。这个分层在 1980 年代是务实,放到今天是范式。

层次负责什么不负责什么
传输层分块、块号、E-bit、T1/T2 超时、字节流可靠性消息语义、事务配对
SECS-IIItem/List 结构、Stream/Function 语义、事务与 T3/T4物理链路、重传策略
设备实现声明支持的 Function 子集、字段单位与取值范围协议帧格式

这张表解释了很多“标准里为什么没写”的疑惑。标准不规定重传策略,因为那是传输层的事;不规定某台设备必须支持哪些 Function,因为那是设备能力声明的事;甚至连超时值都只给语义不给数字,因为不同产线的节拍差得太远。它把可变的部分全部推到边界外,只锁死中间那层不可能变的东西——数据怎么拆、事务怎么配、错误怎么报。

Stream 11 被删除这件事,恰好说明这套结构是可维护的。一个 Stream 的生命周期可以结束,因为它的职责能被别的机制吸收,而不会牵动整个编号体系。对比那些一旦发布就永远不能删枚举值的协议,这个设计留了后路。

如果要从这份标准里带走一个思维习惯,我会说是“先分清哪层该懂语义”。后来很多工业协议出问题,根子都在把业务判断塞进了协议层——协议一旦开始理解业务,就再也改不动了。

我第一次做设备对接时,图省事把 Stream/Function 的路由表直接写在解析器里,解析和分发混成一个函数。前三个月很顺,接第二家设备时整个文件重写了一遍。从那以后我每次起新项目,都强制先把「字节怎么拆」和「拆出来是什么意思」拆成两个模块,中间只留一个纯数据结构的边界——这条规矩救过我很多次。希望帮到你。

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

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

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

立即咨询