资讯动态

Java实现SECS/GEM报文解析:格式码、长度编码与嵌套List详解

发布时间:2026/10/13 22:46:54 来源:尧图企业网站定制
1. 为什么半导体设备通信绕不开SECS/GEM第一次接触SECS/GEM的兄弟大概率是被项目逼的。设备端的PLC工程师丢过来一份协议文档说“你们上位机按这个解析就行”然后你打开一看满屏的S1F1、S6F11、L、A、B、U1、U2、U4头都大了。这不是普通的TCP粘包拆包问题而是一套有三十多年历史、专门为晶圆厂设备通信设计的完整协议体系。SECS全称SEMI Equipment Communications StandardGEM是Generic Equipment Model。简单说SECS定义了“怎么传”GEM定义了“传什么”。在半导体、面板、光伏这些自动化程度极高的产线上一台设备要跟MES或者EAP系统对话基本都得走这套协议。你做的Java程序通常扮演Host端设备端可能是C、C#甚至嵌入式C写的双方通过TCP/IP把一个个二进制报文丢来丢去。报文解析是整个通信链路里最底层、也最容易出问题的一环。你可能会想不就是读字节吗但实际做下来你会发现SECS-II的报文结构是自描述的、嵌套的、变长的一个字节的高6位表示格式码低2位表示长度字节数后面跟着长度再后面才是数据。如果长度字节数是3那长度就是3个字节拼出来的整数。这种设计在当年带宽金贵的年代很聪明但对写解析器的人来说就是个精细活。这篇文章适合谁看如果你正在用Java做设备通信、MES对接、EAP开发或者单纯对二进制协议解析感兴趣那接下来的内容应该能帮你少走不少弯路。我会从报文结构讲起把格式码、长度编码、嵌套列表这些核心概念拆开揉碎然后给出完整的Java实现思路和代码最后分享几个我在实际项目里踩过的坑。2. SECS-II报文结构深度拆解2.1 报文头与消息体的分工一个完整的SECS报文分两大部分10字节的Message Header和变长的Message Body。Header是定长的Body是自描述的。很多人一开始会混淆这两者的边界导致解析偏移量算错。Header的10个字节定义如下字节位置长度含义说明02Session ID会话标识通常Host端为0xFFFF21Header Byte 2包含Wait Bit和Stream31Header Byte 3包含Function41PType协议类型0表示SECS-II51SType会话类型0表示Data Message64System Bytes系统字节用于匹配请求和响应Header Byte 2的最高位是Wait BitW-Bit表示对方是否需要回复。低7位是Stream号。Header Byte 3就是Function号。System Bytes是4字节的无符号整数Host端发起请求时生成设备端回复时原样带回这样就能把请求和响应配对。注意Session ID在GEM标准里Host端固定用0xFFFF设备端用0x0000到0x7FFF。但有些老设备不遵守所以解析时不要硬编码校验否则会莫名其妙丢包。2.2 格式码一个字节里的信息密度Message Body由一个个Item组成每个Item的头部至少2个字节。第一个字节的高6位是格式码Format Code低2位是长度字节数Number of Length Bytes。格式码决定了数据类型常见的有0o00八进制List列表可以嵌套0o20Binary二进制0o21Boolean布尔0o24ASCII字符串0o30I11字节有符号整数0o32I22字节有符号整数0o34I44字节有符号整数0o40F44字节浮点数0o44F88字节浮点数0o50U11字节无符号整数0o52U22字节无符号整数0o54U44字节无符号整数0o60U88字节无符号整数低2位的值n表示后面跟着n个字节来表示长度。n的范围是0到3。当n0时长度就是0表示空Item。当n1时长度是1个字节最大255。当n2时长度是2个字节最大65535。当n3时长度是3个字节最大16777215。这里有个容易搞错的地方长度字节数n表示的是“长度字段占几个字节”而不是“数据占几个字节”。数据实际占用的字节数由长度字段的值决定。对于ASCII和Binary长度就是字节数对于I2、U2长度是元素个数实际字节数要乘以2对于I4、U4、F4长度是元素个数实际字节数要乘以4。2.3 长度编码的三种情况与计算长度编码是解析器里最容易写错的地方。我见过不少实现只处理了n1的情况结果遇到长字符串就崩了。假设第一个字节是0x81二进制是10000001。高6位是0o40对应F4格式低2位是1表示长度占1个字节。下一个字节是0x03表示有3个F4元素实际数据是12字节。假设第一个字节是0xA5二进制是10100101。高6位是0o44对应F8格式低2位是1长度占1字节。下一个字节0x02表示2个F8元素实际数据16字节。假设第一个字节是0x01二进制是00000001。高6位是0o00对应List低2位是1长度占1字节。下一个字节0x03表示这个List里有3个Item。注意List的长度是子Item的个数不是字节数。这是List和其他类型最大的区别。当n2时长度是两个字节拼成的无符号整数高字节在前。当n3时是三个字节拼成的无符号整数。Java里没有无符号类型所以要用int或者long来承载并且用位运算拼接避免符号扩展问题。private int parseLength(byte[] data, int offset, int numLengthBytes) { int length 0; for (int i 0; i numLengthBytes; i) { length (length 8) | (data[offset i] 0xFF); } return length; }这段代码里 0xFF是关键。Java的byte是有符号的如果不做与运算0x80会被当成-128拼出来的长度就全错了。2.4 嵌套List与递归解析List是SECS-II里唯一可以嵌套的结构。一个List里可以包含任意多个Item每个Item又可以是List。这种设计让SECS-II可以表达非常复杂的数据结构比如一个设备状态报告里面包含多个参数每个参数又有自己的ID、名称、值。解析List必须用递归。遇到List格式码时先读出子Item个数然后循环调用解析方法直到解析完指定个数。这里有个细节List的长度字段表示的是直接子Item的个数不包括子Item内部嵌套的Item。所以递归的终止条件是“解析完当前List的直接子Item个数”而不是“解析完所有字节”。private SecsItem parseItem(byte[] data, int[] offset) { int firstByte data[offset[0]] 0xFF; int formatCode (firstByte 2) 0x3F; int numLengthBytes firstByte 0x03; offset[0]; int length parseLength(data, offset[0], numLengthBytes); offset[0] numLengthBytes; if (formatCode 0x00) { // List ListSecsItem children new ArrayList(); for (int i 0; i length; i) { children.add(parseItem(data, offset)); } return new SecsItem(formatCode, children); } else { byte[] value Arrays.copyOfRange(data, offset[0], offset[0] getByteLength(formatCode, length)); offset[0] value.length; return new SecsItem(formatCode, value); } }用int[] offset而不是int offset是因为Java是值传递需要用一个容器来在递归中共享偏移量。这是个小技巧但很实用。3. Java实现的核心类设计3.1 数据模型SecsItem与SecsMessage设计数据模型时我倾向于把Item和Message分开。SecsItem表示一个数据项SecsMessage表示一个完整报文。这样职责清晰也方便后续扩展。SecsItem的核心字段int formatCode格式码ListSecsItem children如果是List存子Itembyte[] rawValue如果是其他类型存原始字节Object parsedValue解析后的值比如String、Integer、FloatSecsMessage的核心字段int sessionIdint streamint functionboolean waitBitint pTypeint sTypelong systemBytesSecsItem rootItemSystem Bytes用long而不是int因为4字节无符号整数的最大值是4294967295超过了int的范围。虽然实际项目中很少用到这么大的值但用long更安全。3.2 格式码枚举与类型映射把格式码定义成枚举代码可读性会好很多。每个枚举值包含格式码、名称、每个元素的字节数。public enum SecsFormat { LIST(0x00, List, 0), BINARY(0x20, Binary, 1), BOOLEAN(0x21, Boolean, 1), ASCII(0x24, ASCII, 1), I1(0x30, I1, 1), I2(0x32, I2, 2), I4(0x34, I4, 4), F4(0x40, F4, 4), F8(0x44, F8, 8), U1(0x50, U1, 1), U2(0x52, U2, 2), U4(0x54, U4, 4), U8(0x60, U8, 8); private final int code; private final String name; private final int elementSize; // 构造函数和getter省略 }elementSize表示每个元素占几个字节。对于ASCII和BinaryelementSize是1长度就是字节数。对于I2、U2elementSize是2实际字节数等于长度乘以2。这个字段在计算数据长度时非常关键。3.3 解析器的入口与偏移量管理解析器的入口方法接收一个完整的字节数组返回SecsMessage。第一步是校验长度至少10字节。然后解析Header再解析Body。public SecsMessage parse(byte[] data) { if (data null || data.length 10) { throw new SecsParseException(报文长度不足10字节); } SecsMessage message new SecsMessage(); message.setSessionId(((data[0] 0xFF) 8) | (data[1] 0xFF)); int headerByte2 data[2] 0xFF; message.setWaitBit((headerByte2 0x80) ! 0); message.setStream(headerByte2 0x7F); message.setFunction(data[3] 0xFF); message.setPType(data[4] 0xFF); message.setSType(data[5] 0xFF); long systemBytes 0; for (int i 6; i 10; i) { systemBytes (systemBytes 8) | (data[i] 0xFF); } message.setSystemBytes(systemBytes); if (data.length 10) { int[] offset new int[]{10}; message.setRootItem(parseItem(data, offset)); } return message; }偏移量从10开始因为Header固定10字节。如果报文只有Header没有Body比如S1F1这种请求rootItem就是null。3.4 值转换从字节到Java对象解析出原始字节后还需要转换成Java对象才能用。ASCII直接new String注意指定字符集。SECS-II标准里ASCII是7位字符但实际项目里经常有中文所以用ISO-8859-1或者UTF-8要看设备端实现。我一般先用ISO-8859-1遇到乱码再换。整数类型要注意字节序。SECS-II是大端序高字节在前。Java的ByteBuffer可以指定ByteOrder.BIG_ENDIAN但手动拼也很简单。private Object convertValue(SecsFormat format, byte[] rawValue) { switch (format) { case ASCII: return new String(rawValue, StandardCharsets.ISO_8859_1); case BINARY: return rawValue; case BOOLEAN: return rawValue[0] ! 0; case U1: return rawValue[0] 0xFF; case U2: return ((rawValue[0] 0xFF) 8) | (rawValue[1] 0xFF); case U4: return ((long)(rawValue[0] 0xFF) 24) | ((rawValue[1] 0xFF) 16) | ((rawValue[2] 0xFF) 8) | (rawValue[3] 0xFF); case I1: return (int) rawValue[0]; case I2: return (short)(((rawValue[0] 0xFF) 8) | (rawValue[1] 0xFF)); case I4: return ((rawValue[0] 0xFF) 24) | ((rawValue[1] 0xFF) 16) | ((rawValue[2] 0xFF) 8) | (rawValue[3] 0xFF); case F4: return Float.intBitsToFloat((int) convertValue(SecsFormat.I4, rawValue)); case F8: long longBits 0; for (byte b : rawValue) { longBits (longBits 8) | (b 0xFF); } return Double.longBitsToDouble(longBits); default: return rawValue; } }U4用long承载是因为Java的int放不下0xFFFFFFFF。F4和F8用intBitsToFloat和longBitsToDouble这是标准做法。4. 完整解析流程与实操演示4.1 从字节流到SecsMessage的完整链路实际项目里数据是从Socket读进来的。TCP是流式协议一次read可能读到半个报文也可能读到两个半报文。所以需要一个缓冲区来累积数据然后根据报文长度切分。SECS报文的长度怎么判断Header里没有总长度字段。但Body的第一个Item头部包含了长度信息可以据此算出Body长度再加上10字节Header就是总长度。如果Body为空总长度就是10。public ListSecsMessage decode(byte[] buffer, int length) { ListSecsMessage messages new ArrayList(); int offset 0; while (offset length) { if (length - offset 10) { break; // 不够Header } int bodyLength calculateBodyLength(buffer, offset 10, length - offset - 10); if (bodyLength 0) { break; // Body不完整 } int totalLength 10 bodyLength; if (length - offset totalLength) { break; } byte[] messageBytes Arrays.copyOfRange(buffer, offset, offset totalLength); messages.add(parse(messageBytes)); offset totalLength; } return messages; }calculateBodyLength需要预解析Item头部算出整个Body占多少字节。这个方法要处理嵌套List所以也是递归的。但和完整解析不同它只需要计算长度不需要构造对象所以可以写得更轻量。4.2 一个真实报文的解析过程假设收到这样一段字节十六进制FF FF 80 0B 00 00 00 00 00 01 01 02 41 01 03 41 01 04逐字节解析字节0-1FF FFSession ID 65535字节280二进制10000000Wait Bit 1Stream 0字节30BFunction 11字节400PType 0字节500SType 0字节6-900 00 00 01System Bytes 1字节1001二进制00000001格式码0o00List长度字节数1字节1102List有2个直接子Item字节1241二进制01000001格式码0o20Binary长度字节数1字节1301长度1字节1403数据0x03字节1541Binary长度1字节1601长度1字节1704数据0x04所以这是一个S0F11报文Body是一个List包含两个Binary Item值分别是0x03和0x04。S0F11通常是设备端上报的Collection Event具体含义要看设备端的GEM手册。4.3 编码反向操作的注意事项解析做完了通常还要能编码。Host端发请求给设备也得按同样格式拼字节。编码是解析的逆过程但有几个地方容易出错。List的长度是子Item个数不是字节数。所以编码List时要先编码所有子Item统计个数再写List头部。这意味着需要先递归编码子Item拿到字节数组再拼头部。长度字段的字节数选择也有讲究。如果长度小于256用1字节小于65536用2字节否则用3字节。不要固定用3字节因为有些设备端解析器只认最小字节数。private byte[] encodeLength(int length) { if (length 0x100) { return new byte[]{(byte) length}; } else if (length 0x10000) { return new byte[]{(byte)(length 8), (byte) length}; } else { return new byte[]{(byte)(length 16), (byte)(length 8), (byte) length}; } }编码时还要注意格式码要左移2位再或上长度字节数。比如ASCII格式码0o24长度字节数1第一个字节就是(0x24 2) | 1 0x91。5. 常见问题与排查技巧实录5.1 解析偏移量错位的典型场景偏移量错位是最常见的问题表现是解析出来的数据全是乱码或者直接数组越界。根据我的经验90%的偏移量问题出在三个地方。第一长度字节数没算对。低2位的值直接就是长度字节数不需要任何转换。我见过有人把它当成“长度值的位数”然后去做十进制转换那就全错了。第二List的长度理解错了。List的长度是子Item个数不是字节数。如果你按字节数去循环就会多读或者少读。第三多字节整数拼接时没做与0xFF。Java的byte是有符号的0x80会变成-128拼出来的数就是负数。这个坑我踩过不止一次后来养成了习惯凡是byte转int先与0xFF。5.2 粘包与半包的处理策略TCP粘包半包是网络编程的老问题SECS也不例外。我的做法是维护一个ByteArrayOutputStream或者自定义的环形缓冲区。每次read到数据就追加然后尝试解码。解码时如果发现数据不够一个完整报文就保留在缓冲区里等下次数据到了再试。判断“数据够不够”需要预解析。预解析只读头部不构造对象所以很快。如果预解析发现Body不完整就返回-1表示需要更多数据。注意缓冲区要有上限防止恶意或者异常情况下无限增长。我一般设10MB超过就清空并记录告警。5.3 字符集乱码的排查思路ASCII Item的字符集问题很让人头疼。标准说ASCII是7位但实际设备可能发8位甚至发GBK编码的中文。排查时先用十六进制打印原始字节看看是不是合法的ASCII。如果高位有值那肯定不是纯ASCII。我的经验是先按ISO-8859-1解这样每个字节都能映射到一个字符不会丢数据。然后看业务上是否合理。如果是中文再尝试GBK或者UTF-8。有些设备端可以配置字符集最好在对接阶段就确认清楚。5.4 常见问题速查表现象可能原因排查方法解决方案数组越界长度字段解析错误打印第一个字节的格式码和长度字节数检查低2位处理逻辑数据乱码字符集不匹配十六进制打印原始字节尝试ISO-8859-1、GBK、UTF-8解析结果为空List长度为0检查List的长度字段确认设备端是否真的发了空List数值为负数有符号扩展检查是否与0xFF所有byte转int都加 0xFF报文不完整粘包半包打印缓冲区长度和预解析结果实现缓冲区累积逻辑System Bytes不匹配请求响应配对错误打印请求和响应的System Bytes用Map维护待响应请求5.5 性能优化的几个实用技巧SECS报文通常不大但高频场景下解析性能也很重要。我做过一个测试用递归解析一个包含1000个Item的List耗时大概2毫秒。如果每秒要处理几百个报文这个开销就不能忽略了。优化思路有几个。第一避免频繁创建小对象比如byte[]。可以用ByteBuffer或者直接操作原始数组。第二预解析和完整解析可以合并避免重复遍历。第三对于固定格式的报文可以缓存解析结果但要注意System Bytes不同不能直接复用。不过说实话大多数SECS场景下网络延迟才是瓶颈解析性能不是关键。除非你做的是高频数据采集否则不用过度优化。代码可读性和可维护性更重要。6. 工程化落地的几点经验6.1 日志与抓包排查问题的左右手SECS通信出问题时光看代码是看不出来的。必须把原始字节流打出来。我的做法是在Socket的InputStream和OutputStream上各包一层把所有读写的字节以十六进制记录到日志。格式大概是时间戳、方向、长度、十六进制内容。有了原始字节再对照SECS-II标准手册基本能定位到问题。如果设备端有日志两边一对很快就能找到差异。我遇到过设备端把Wait Bit设反的情况就是靠抓包发现的。6.2 单元测试用真实报文做回归解析器的单元测试很重要。我一般会收集一批真实的报文样本包括各种格式码、各种长度、嵌套List、空List、边界值。每个样本对应一个测试用例断言解析结果。边界值特别重要。比如长度刚好255、256、65535、65536的报文这些是长度字节数切换的临界点。还有空List、空ASCII、单元素List这些特殊情况。把这些都覆盖到解析器的稳定性就有保障了。6.3 与设备端联调的注意事项联调阶段最容易扯皮。设备端说发了Host端说没收到。这时候抓包就是唯一真相。我建议在联调前就约定好双方都开日志都记录原始字节出问题先对字节。另外GEM标准里有很多可选功能比如事件报告、变量查询、过程程序管理。不是所有设备都支持全部功能。联调前先确认设备支持哪些Stream和Function避免做无用功。还有一点设备端的响应超时时间要设置合理。太短了设备还没处理完就超时太长了出问题发现得慢。我一般设5秒特殊场景再调整。6.4 代码结构建议最后说下代码结构。我习惯把SECS相关代码放在一个独立的模块里不跟业务逻辑混在一起。模块内部再分层底层是字节编解码中间是Item和Message模型上层是会话管理和超时重发。会话管理这块核心是维护一个待响应请求的Mapkey是System Bytesvalue是请求上下文和超时时间。收到响应时根据System Bytes找到对应的请求完成回调。超时的话触发重发或者报错。这套结构我在几个项目里用过扩展性和可维护性都不错。新加一个Stream Function的处理只需要在业务层注册一个处理器底层完全不用动。解析SECS/GEM报文这件事说难不难说简单也不简单。核心就是理解格式码、长度编码、嵌套List这三个概念然后小心处理偏移量和字节序。真正花时间的往往是跟设备端联调时遇到的各种“不标准”实现。所以除了写好自己的代码抓包工具和日志系统一定要提前准备好。我在实际项目里的体会是前期在解析器上多花一天时间做单元测试和边界覆盖后期联调能省三天扯皮时间。这个投入产出比怎么算都划算。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑