ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SEMI E5-1104 SECS-II协议详解:消息编码与Python解析实战

SEMI E5-1104 SECS-II协议详解:消息编码与Python解析实战

简介:这份SEMI E5-1104标准文档是半导体设备通信SECS-II协议的核心英文原版,面向从事半导体制造设备与控制系统开发、维护的工程师,以及参与产线自动化集成项目的技术人员。它系统定义了智能设备与主机之间的消息交换规则,涵盖消息结构、流与函数、事务与对话协议、数据结构等内容,并逐一说明Stream 0至Stream 17的消息用途,涉及设备状态、控制与诊断、物料状态、异常处理、数据采集、工艺程序管理、晶圆映射等场景,同时给出T1至T4事务超时机制与错误恢复逻辑,便于开发者理解设备端与主机端的标准实现方式。资源包共1个PDF文件,约2.66MB,为高清英文原版,适合对照查阅条款与流函数定义。目前已有343人学习,可作为设备集成、协议对接与问题排查时的案头参考。

1. 半导体设备通信SECS协议文档:SEMI E5-1104到底在定义什么

如果你在半导体设备现场调过通信,大概率遇到过这种场景:设备端日志显示消息已发出,上位机却报“格式错误”,两边工程师对着各自的代码查了一整天,最后发现是某个数据项的格式码用错了——一个该用A(ASCII)的字段写成了B(Binary)。这类问题的根因,几乎都能追溯到同一份文档:SEMI E5-1104,也就是SECS-II的消息内容定义标准。

SECS-II全称Semiconductor Equipment Communication Standard II,是半导体设备与主机之间通信的消息层协议。它规定了消息的结构、数据项的编码方式、以及每条消息的语义。E5-1104是SEMI发布的原版标准文档,英文高清版,内容涵盖SECS-II消息的完整定义和应用规则。它解决的核心问题是:让不同厂商的设备和控制软件能用同一套“语言”对话。适合谁读?设备端固件工程师、EAP(Equipment Automation Program)开发者、CIM系统集成人员,以及需要对接SECS/GEM接口的任何从业者。

2. SECS-II消息结构拆解:从数据项到消息体的编码逻辑

2.1 数据项格式码:SECS-II的“字母表”

SECS-II的消息体由一个个数据项(Data Item)组成,每个数据项的第一个字节是格式码(Format Code),它决定了后续数据的类型和长度编码方式。E5-1104里定义的核心格式码如下表:

格式码类型含义长度字节数
0o00List列表(嵌套结构)1-3
0o20Binary二进制1-3
0o30Boolean布尔1-3
0o40ASCII字符串1-3
0o50JIS-8日文编码1-3
0o60I88字节有符号整数1-3
0o70I11字节有符号整数1-3
0o71I22字节有符号整数1-3
0o72I44字节有符号整数1-3
0o74F88字节浮点数1-3
0o76F44字节浮点数1-3
0o80U88字节无符号整数1-3
0o90U11字节无符号整数1-3
0o91U22字节无符号整数1-3
0o92U44字节无符号整数1-3

格式码的高6位表示类型,低2位表示长度字节数。比如0o41表示ASCII类型、1个长度字节;0o42表示ASCII类型、2个长度字节。这个设计让SECS-II在编码效率上做了优化——短字符串只占1字节长度描述,长字符串才用2或3字节。

实际编码时,一个ASCII字符串“Hello”的完整字节序列是:0x41 0x05 0x48 0x65 0x6C 0x6C 0x6F。第一个字节0x41是格式码,第二个字节0x05是长度,后面5个字节是内容。

2.2 消息头与消息体的组装规则

一条完整的SECS-II消息由消息头(10字节)和消息体组成。消息头包含:

  • 设备ID(2字节,高位在前)
  • 消息ID(W-Bit + Stream + Function,共3字节)
  • 系统字节(1字节,含R-Bit和等待位)
  • 保留字段(2字节)
  • 消息体长度(2字节)

消息体的组装遵循严格的嵌套规则。List类型可以包含其他数据项,形成树状结构。E5-1104里对List的嵌套层数没有硬性上限,但实际实现中一般不超过8层,否则解析栈容易溢出。

下面是一个Python示例,演示如何按E5-1104的规则编码一条S1F1(Are You There)消息:

import struct def encode_ascii(s): """编码ASCII数据项,自动选择长度字节数""" data = s.encode('ascii') length = len(data) if length <= 255: return bytes([0x41, length]) + data elif length <= 65535: return bytes([0x42]) + struct.pack('>H', length) + data else: return bytes([0x43]) + struct.pack('>I', length)[1:] + data def encode_list(items): """编码List数据项,items是已编码的字节列表""" body = b''.join(items) length = len(body) if length <= 255: return bytes([0x01, length]) + body elif length <= 65535: return bytes([0x02]) + struct.pack('>H', length) + body else: return bytes([0x03]) + struct.pack('>I', length)[1:] + body def build_secs_message(stream, function, w_bit, body_items): """组装完整SECS-II消息""" # 消息头:设备ID(2) + 消息ID(3) + 系统字节(1) + 保留(2) + 体长度(2) device_id = 0x0000 msg_id = ((w_bit & 0x01) << 15) | ((stream & 0x7F) << 8) | (function & 0xFF) system_byte = 0x00 body = encode_list(body_items) header = struct.pack('>H', device_id) header += struct.pack('>I', msg_id)[1:] # 取低3字节 header += bytes([system_byte]) header += b'\x00\x00' header += struct.pack('>H', len(body)) return header + body # 构建S1F1,W-Bit=1,消息体为空List msg = build_secs_message(1, 1, 1, []) print(msg.hex())

这段代码的关键点:msg_id的组装方式决定了W-Bit、Stream、Function三个字段的位布局。E5-1104规定消息ID占3字节,最高位是W-Bit,接着7位是Stream,低8位是Function。struct.pack('>I', msg_id)[1:]这个操作是取4字节大端整数的低3字节,等价于手动做位运算后拼接。

参数说明:w_bit为1表示需要回复,0表示不需要;stream范围0-127;function范围0-255。消息体长度字段是2字节无符号整数,所以单条SECS-II消息体最大65535字节。超过这个长度需要分片,但E5-1104本身不定义分片机制,那是上层协议的事。

2.3 常用消息的格式速查

E5-1104定义了数百条消息,但现场最常用的就那么几十条。下面列出高频消息的格式定义,方便快速对照:

消息方向消息体结构典型用途
S1F1双向空心跳检测
S1F2双向L[2]: A, A心跳回复(MDLN, SOFTREV)
S1F3主机→设备L[n]: U4...查询状态变量
S1F4设备→主机L[n]: L[2]: U4, A状态变量值
S1F13主机→设备L[2]: A, A建立通信
S1F14设备→主机L[2]: B, L[2]: A, A通信建立确认
S2F33主机→设备L[2]: U4, L[n]: L[2]: U4, L[m]: U4...定义报告
S2F34设备→主机L[1]: B定义报告确认
S2F41主机→设备L[2]: A, L[n]: L[2]: A, A远程命令
S2F42设备→主机L[1]: B命令确认
S5F1设备→主机L[3]: B, U4, A报警上报
S6F11设备→主机L[3]: U4, U4, L[n]: ...事件报告
S7F19主机→设备空查询工艺程序列表
S7F20设备→主机L[n]: A工艺程序列表

这张表建议打印出来贴在工位上。现场调试时,90%的通信问题都能通过对照这张表定位到具体字段。

3. 用Python实现SECS-II消息解析器:从字节流到结构化数据

3.1 解析器的核心逻辑与递归下降

解析SECS-II消息比编码要复杂,因为需要处理嵌套List和变长数据项。核心思路是递归下降:遇到List就递归解析其子项,遇到基本类型就按格式码读取对应字节数。

import struct class SecsItem: def __init__(self, fmt_code, value): self.fmt_code = fmt_code self.value = value def __repr__(self): return f"SecsItem(fmt=0o{self.fmt_code:o}, value={self.value!r})" def parse_item(data, offset): """解析单个数据项,返回(SecsItem, 新偏移量)""" if offset >= len(data): raise ValueError("数据不完整") fmt_byte = data[offset] fmt_code = fmt_byte >> 2 # 高6位是格式码 len_bytes = (fmt_byte & 0x03) # 低2位是长度字节数 if len_bytes == 0: raise ValueError(f"非法长度字节数: 0, 格式码=0o{fmt_code:o}") offset += 1 # 读取长度 if len_bytes == 1: length = data[offset] offset += 1 elif len_bytes == 2: length = struct.unpack('>H', data[offset:offset+2])[0] offset += 2 else: length = struct.unpack('>I', b'\x00' + data[offset:offset+3])[0] offset += 3 if fmt_code == 0o00: # List items = [] end = offset + length while offset < end: item, offset = parse_item(data, offset) items.append(item) return SecsItem(fmt_code, items), offset # 基本类型 raw = data[offset:offset+length] offset += length if fmt_code == 0o20: # Binary value = raw elif fmt_code == 0o30: # Boolean value = [b != 0 for b in raw] elif fmt_code == 0o40: # ASCII value = raw.decode('ascii') elif fmt_code == 0o70: # I1 value = list(struct.unpack(f'>{length}b', raw)) elif fmt_code == 0o71: # I2 value = list(struct.unpack(f'>{length//2}h', raw)) elif fmt_code == 0o72: # I4 value = list(struct.unpack(f'>{length//4}i', raw)) elif fmt_code == 0o90: # U1 value = list(raw) elif fmt_code == 0o91: # U2 value = list(struct.unpack(f'>{length//2}H', raw)) elif fmt_code == 0o92: # U4 value = list(struct.unpack(f'>{length//4}I', raw)) elif fmt_code == 0o74: # F8 value = list(struct.unpack(f'>{length//8}d', raw)) elif fmt_code == 0o76: # F4 value = list(struct.unpack(f'>{length//4}f', raw)) else: value = raw # 未知类型保留原始字节 return SecsItem(fmt_code, value), offset def parse_secs_message(data): """解析完整SECS-II消息""" if len(data) < 10: raise ValueError("消息头不完整") device_id = struct.unpack('>H', data[0:2])[0] msg_id = struct.unpack('>I', b'\x00' + data[2:5])[0] w_bit = (msg_id >> 15) & 0x01 stream = (msg_id >> 8) & 0x7F function = msg_id & 0xFF system_byte = data[5] body_length = struct.unpack('>H', data[8:10])[0] body_data = data[10:10+body_length] items = [] offset = 0 while offset < len(body_data): item, offset = parse_item(body_data, offset) items.append(item) return { 'device_id': device_id, 'w_bit': w_bit, 'stream': stream, 'function': function, 'system_byte': system_byte, 'body': items }

这段解析器的关键设计:parse_item返回新偏移量而不是修改全局状态,这样递归调用时不会互相干扰。长度字节数的处理是容易翻车的地方——E5-1104规定长度字节数可以是1、2、3,但值为0是非法组合,因为格式码低2位为0意味着没有长度字段,这在标准里是不允许的。

参数说明:fmt_code是格式码的高6位,通过fmt_byte >> 2提取;len_bytes是低2位,决定后续读几个字节的长度。对于3字节长度,代码里用b'\x00' + data[offset:offset+3]补成4字节再解包,这是处理24位整数的常用技巧。

3.2 解析结果验证与常见格式错误

解析器写完后,需要用真实报文验证。一个典型的S1F2回复报文(十六进制)如下:

00 00 00 81 02 00 00 00 00 0E 01 0C 40 06 4D 4F 44 45 4C 31 40 04 31 2E 30 30

逐段拆解:前10字节是消息头,00 00是设备ID,00 81 02是消息ID(W-Bit=0, Stream=1, Function=2),00是系统字节,00 00是保留字段,00 0E是消息体长度14字节。消息体从01 0C开始:01是List格式码(长度1字节),0C是长度12;接着40 06是ASCII格式码、长度6,内容MODEL1;然后40 04是ASCII、长度4,内容1.00。

用上面的解析器跑这段数据,应该得到:

{ 'device_id': 0, 'w_bit': 0, 'stream': 1, 'function': 2, 'system_byte': 0, 'body': [SecsItem(fmt=0o00, value=[ SecsItem(fmt=0o40, value='MODEL1'), SecsItem(fmt=0o40, value='1.00') ])] }

常见格式错误有三种:一是长度字段与实际内容不匹配,比如声明长度6但只给了5个字节;二是List的嵌套长度计算错误,子项总长度与List声明的长度不一致;三是格式码与数据类型不匹配,比如用0o70(I1)去编码一个需要4字节的整数。这三种错误在解析时都会抛出异常或产生错误结果,排查时优先检查长度字段。

3.3 与设备联调时的日志抓取方法

实际联调时,光有解析器不够,还需要抓到原始字节流。常见做法是在通信层加一个透传日志钩子,把收发的原始字节按十六进制打印出来,同时用解析器转成可读格式。

import logging logger = logging.getLogger('secs_trace') def trace_message(direction, raw_data): """记录SECS消息的原始字节和解析结果""" hex_str = ' '.join(f'{b:02X}' for b in raw_data) logger.info(f"[{direction}] RAW: {hex_str}") try: parsed = parse_secs_message(raw_data) logger.info(f"[{direction}] PARSED: S{parsed['stream']}F{parsed['function']} " f"W={parsed['w_bit']} BODY={parsed['body']}") except Exception as e: logger.error(f"[{direction}] PARSE FAILED: {e}")

这个钩子挂在socket的send和recv之后即可。注意日志里不要只打解析结果,原始十六进制才是排查问题的最终依据——解析器本身可能有bug,但原始字节不会骗人。

4. 避坑指南:SECS-II对接中最容易翻车的五个地方

4.1 长度字段的“差一字节”问题

现象:设备端发送的消息,上位机解析时报“数据不完整”或“长度超出预期”。抓包看原始字节,发现List声明的长度比实际子项总长度少1或多1。

原因:E5-1104规定List的长度字段是消息体的字节数,不包括格式码和长度字段本身。很多开发者第一次实现时会把格式码和长度字段也算进去,导致长度偏大。另一种情况是子项编码时多算了填充字节。

解决:编码完成后,先计算所有子项编码后的总字节数,再把这个数写入List的长度字段。建议在编码函数里加断言:assert len(body) == declared_length,上线前跑一遍全量消息类型。

4.2 ASCII与JIS-8的编码混淆

现象:包含日文或特殊字符的字段,在设备端显示正常,到上位机变成乱码。

原因:E5-1104定义了ASCII(0o40)和JIS-8(0o50)两种文本格式码。有些设备厂商在实现时把所有文本都按JIS-8编码,但格式码仍标0o40,导致上位机按ASCII解码时出错。

解决:对接前先确认设备端使用的文本编码。如果设备文档没有明确说明,发一条包含已知字符的测试消息,抓原始字节对比。ASCII的字符范围是0x00-0x7F,JIS-8会用到0x80以上的字节。发现格式码与内容不匹配时,要么改设备端编码,要么在上位机做兼容处理。

4.3 W-Bit与回复超时的配合

现象:主机发送S1F1后等待S1F2回复,但设备端没有回复,主机一直阻塞直到超时。

原因:W-Bit(Wait-Bit)设置为1表示需要回复,但设备端可能因为消息处理队列满、或者该消息被配置为不需要回复而丢弃。另一种情况是设备端回复了,但主机的T3定时器设置太短,回复还没到就超时了。

解决:E5-1104本身不定义超时时间,T3是SEMI E37(HSMS)里的概念,默认45秒。如果现场网络延迟大,适当调大T3。同时检查设备端的消息处理逻辑,确认W-Bit=1的消息一定会触发回复。调试阶段可以把T3临时调到120秒,排除超时因素后再逐步收紧。

4.4 List嵌套层数过深导致解析栈溢出

现象:解析某些复杂消息时程序崩溃,报“RecursionError”或栈溢出。

原因:SECS-II的List可以无限嵌套,但递归解析器的调用栈深度有限。某些设备在S6F11事件报告里嵌套了十几层List,Python默认递归限制是1000层,虽然理论上够用,但如果每层解析都占用较多栈帧,实际可能提前溢出。

解决:两种方案。一是把递归解析改成迭代加显式栈,彻底消除递归深度限制;二是提高Python的递归限制:sys.setrecursionlimit(5000)。推荐第一种,因为迭代版本更可控。另外在解析前先检查List嵌套深度,超过阈值(比如16层)直接报错,避免恶意或异常报文拖垮解析器。

4.5 消息体长度超过65535字节的处理

现象:发送大消息(比如完整的工艺程序列表)时,消息体长度字段溢出,接收端解析出错误的长度。

原因:E5-1104的消息头里,消息体长度字段是2字节无符号整数,最大65535。超过这个长度的消息体无法用单条SECS-II消息承载。

解决:E5-1104本身不定义分片机制,实际做法是把大消息拆成多条小消息,用上层协议(如GEM的工艺程序管理)的分段传输功能。如果设备端不支持分段,只能压缩消息体或减少单次传输的数据量。编码时加检查:if len(body) > 65535: raise ValueError("消息体超长,需要分片")。

5. 从E5-1104到GEM:消息定义如何支撑上层应用

5.1 E5-1104与E30/E37的关系

E5-1104定义的是消息内容,但消息怎么传输、连接怎么建立,是另外的标准。SEMI E37(HSMS)定义了基于TCP/IP的传输层,E30(GEM)定义了设备行为模型。三者的关系是:E37负责把字节流从A送到B,E5-1104负责定义字节流里每个字节的含义,E30负责定义设备在什么状态下该发什么消息。

实际开发中,通信库通常把这三层都封装好了。但排查问题时,需要清楚问题出在哪一层。比如连接建立失败,查E37;消息解析失败,查E5-1104;设备行为不符合预期(比如该上报的事件没上报),查E30。

5.2 用E5-1104定义自定义消息

标准消息不够用时,可以定义自定义消息。E5-1104允许在Stream 64-127范围内定义厂商私有消息。定义时需要注意:消息体的数据项格式必须符合E5-1104的编码规则,否则其他厂商的设备无法解析。

一个自定义消息的示例:S64F1,用于查询设备自定义状态。

# 自定义S64F1:查询自定义状态 # 消息体:L[1]: U4(状态ID) custom_query = build_secs_message(64, 1, 1, [ encode_list([encode_u4(1001)]) # 状态ID=1001 ]) # 自定义S64F2:回复自定义状态 # 消息体:L[2]: U4, A(状态ID, 状态值) custom_reply = build_secs_message(64, 2, 0, [ encode_list([ encode_u4(1001), encode_ascii("RUNNING") ]) ])

自定义消息的关键是双方约定好消息体和语义。建议在项目初期就把自定义消息的格式写成文档,和E5-1104的格式定义放在一起,避免后期扯皮。

5.3 用最小系统验证消息定义

学E5-1104最有效的方法不是通读文档,而是搭一个最小验证系统:一个模拟设备端,一个模拟主机端,中间用socket连接,跑几条基本消息。

import socket import threading def mock_device(port): """模拟设备端:收到S1F1回复S1F2""" srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(('127.0.0.1', port)) srv.listen(1) conn, _ = srv.accept() while True: data = conn.recv(4096) if not data: break msg = parse_secs_message(data) if msg['stream'] == 1 and msg['function'] == 1: # 回复S1F2 reply = build_secs_message(1, 2, 0, [ encode_list([ encode_ascii("MOCK-EQ"), encode_ascii("1.0.0") ]) ]) conn.send(reply) # 启动模拟设备 t = threading.Thread(target=mock_device, args=(5000,), daemon=True) t.start() # 主机端发送S1F1 host = socket.socket(socket.AF_INET, socket.SOCK_STREAM) host.connect(('127.0.0.1', 5000)) s1f1 = build_secs_message(1, 1, 1, []) host.send(s1f1) resp = host.recv(4096) print(parse_secs_message(resp))

这个最小系统跑通后,再逐步加入S1F3/S1F4、S2F33/S2F34、S6F11等消息,每加一条就验证一次。这种增量验证的方式比一次性实现所有消息再联调要高效得多。

5.4 一个容易忽略的细节:消息ID的位序

最后说一个我踩过的坑。E5-1104里消息ID是3字节,但不同文档对这三个字节的位序描述不一致。有的文档写“Byte 0是高位”,有的写“Byte 2是高位”。实际抓包验证后确认:消息ID按大端序传输,第一个字节是最高字节。W-Bit在最高字节的最高位,Stream在最高字节的低7位,Function在第二个字节。

这个细节在实现编码器时如果搞反了,消息会发到错误的Stream/Function上,设备端要么不回复,要么回复错误的消息。验证方法很简单:发一条S1F1,抓包看第二个字节是不是0x81(W-Bit=1, Stream=1)。如果是0x01,说明位序搞反了。

我在第一次实现SECS-II编解码器时,因为没仔细核对位序,调试了整整两天。后来养成的习惯是:每实现一条新消息,先用已知正确的报文做单元测试,确认编码结果与预期字节完全一致后再接入通信层。这个习惯帮我省下了大量联调时间。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进