简介:《半导体测试数据格式(STDF)V4规范使用手册》PDF版面向半导体测试工程师、ATE系统开发者与测试数据分析人员,系统说明STDF第4版如何以统一记录结构存储和传输不同厂商测试设备产生的数据,解决测试结果兼容性与可追溯性问题。全文围绕记录头、记录类型与子类型、数据类型编码、时间日期表示、可选字段及缺失或无效数据处理展开,并逐项梳理FAR、MIR、MRR、PIR、PRR、PTR、MPR、FTR等记录,以及ATR审计追踪、RDR重测数据、SDR站点描述等新增内容,同时给出文件命名、文件排序、维修信息存储和引脚映射的使用说明。包内为1个PDF文件,约550KB,目录按记录类型编排,便于按条目检索;末尾附STDF V3与V4在记录类型、数据类型、文件名字符和必需记录上的差异对照及术语表。已有669人学习,适合需要查阅字段定义、核对记录结构或迁移旧版数据的读者作为案头规范。
1. STDF V4 手册先摆正位置:它管的不是测试原理,是字节怎么排
产线传过来一个.std文件,记事本打开是一屏乱码,测试主管却要这颗料每个 pin 的 VIL/VIH 分布和 bin 分布。这时候能救场的就是 STDF 这份规范——半导体测试里 ATE 输出测试数据的通用记录格式,V4 版本把记录类型、数据类型码、可选字段的表达方式全部钉死到字节级。手册 60 多页,从文件属性记录 FAR 一路讲到通用数据记录 GDR,是写解析器、做测试数据分析时唯一该对着抄的底稿,不是科普读物。
适合三类人:刚接手 datalog 转换的测试工程师、要写内部报表工具的后端开发、以及被供应商.std文件卡住的数据分析同学。它回答的问题很窄——这堆字节该怎么读、哪些字段必须存在、哪些值代表"这项没测"。
2. 记录头解剖:4 个字节里藏着 REC_LEN、REC_TYP、REC_SUB 三段校验
STDF 文件没有文件级索引,没有 magic number,没有目录表,整个文件就是一条接一条的记录首尾相连。想从里面捞出东西,唯一的入口是每条记录开头那 4 个字节。这既是它简单的地方,也是所有解析器翻车的起点:一旦在某个位置把长度读错,后面几万条记录全部平移,报错点往往离真正出错的地方几百 KB 远。
2.1 一条记录的三段式:为什么 REC_LEN 不含头
每条 STDF 记录以一个固定 4 字节的记录头开始,字段顺序是 REC_LEN、REC_TYP、REC_SUB。REC_LEN 是两字节无符号整数,含义是"记录头之后还有多少字节",它自己这 4 个字节不算在内。这个约定和很多二进制格式不一样——不少格式的总长度字段是包含头的,照抄习惯就会每条记录多跳 4 个字节。
REC_TYP 和 REC_SUB 各一字节,组合起来唯一确定一种记录类型。REC_TYP 表示"这一组记录在讲什么层级的事",REC_SUB 表示组内的具体哪一种。手册里那张编码表值得打印出来贴在显示器边上:200 以下的编码由规范维护方保留,200 以上留给自定义应用扩展,所以看到 REC_TYP 在 180 附近,基本可以判断是某家设备厂商塞的私有数据,需要单独对待。
提示:解析器一定要对 REC_LEN 做上界检查。损坏文件里 REC_LEN 可能是 65535,直接按这个值切片再解码,很容易把内存吃光或者读出越界异常。
2.2 顺序扫描器:20 行把文件切成记录流
写解析器的第一层永远是"把字节流切成记录",不涉及任何字段语义。这一段用 Python 的标准库就够了,不需要第三方依赖。我一般会先写这个扫描器,跑一遍确认文件能完整走到末尾、没有残留字节,再去碰具体字段——先证明切分正确,再谈解码,顺序反了会浪费大量时间。
import struct from pathlib import Path # STDF 记录头固定 4 字节:REC_LEN(U*2) + REC_TYP(U*1) + REC_SUB(U*1) # 默认按小端解释;真实端序由第一条 FAR 记录里的 CPU_TYPE 决定 HDR = struct.Struct("<HBB") def walk_records(path): data = Path(path).read_bytes() off = 0 while off + 4 <= len(data): rec_len, rec_typ, rec_sub = HDR.unpack_from(data, off) body = data[off + 4: off + 4 + rec_len] if len(body) != rec_len: # 长度字段声称的字节数超过文件剩余长度,说明文件被截断或偏移已错 raise ValueError(f"截断记录 @0x{off:X}: 期望 {rec_len}, 实得 {len(body)}") yield off, rec_typ, rec_sub, body off += 4 + rec_len if off != len(data): # 尾部有零头字节,通常是最后一次写入被中断 raise ValueError(f"尾部残留 {len(data) - off} 字节") for off, typ, sub, body in walk_records("lot_a.std"): print(f"0x{off:08X} TYP={typ:<3} SUB={sub:<3} LEN={len(body)}")HDR用的是<HBB:<表示小端,H是两字节无符号,两个B是一字节无符号。unpack_from配合偏移量比切片再 unpack 快得多,处理几十 MB 的文件时差别明显。off这个绝对偏移我建议一律保留并写进后续的输出结构里——将来某条数据对不上,能凭偏移直接跳到那一行去看原始字节,这是排查时唯一的后悔药。
2.3 类型码分组记忆:0/1/2/5/10/15/20/50 八组怎么用
硬背二十多个记录缩写没什么意义,按 REC_TYP 分组记要快得多,因为分组本身就对应了数据的层级结构:文件级、lot 级、wafer 级、part 级、测试项级、程序段级、通用数据级。理解了这个嵌套关系,后面写聚合逻辑时才知道该在哪一层收口。
| REC_TYP | 层级含义 | 代表记录(REC_SUB) |
|---|---|---|
| 0 | 文件自身属性 | FAR(10)、ATR(20) |
| 1 | 整个 lot 的汇总数据 | MIR(10)、MRR(20)、PCR(30)、HBR(40)、SBR(50)、PMR(60)、PGR(62)、PLR(63)、RDR(70)、SDR(80) |
| 2 | 晶圆级数据 | WIR(10)、WRR(20)、WCR(30) |
| 5 | 单颗零件数据 | PIR(10)、PRR(20) |
| 10 | 测试程序中某一项的汇总 | TSR(30) |
| 15 | 一次测试执行的结果 | PTR(10)、MPR(15)、FTR(20) |
| 20 | 程序段边界标记 | BPS(10)、EPS(20) |
| 50 | 通用/文本 | GDR(10)、DTR(30) |
这张表能解释一个常见疑惑:为什么 PTR 里的测试结果不能直接按出现顺序累加。PTR 属于 15 组,是"每次执行"的粒度,同一条测试项在并行测试的多个 site 上会重复出现;而 TSR 属于 10 组,是对整份程序的执行统计。两者数量对不上是正常的,对得上反而是巧合——具体怎么对账,后面第 6 章会给出守恒关系。
另外,FAR 是文件的第一条记录这一点是硬约定。它不是可选的装饰,因为端序、版本号都写在里面,解析器必须先把 FAR 读出来,才能决定后面几十万条记录怎么解释。跳过 FAR 直接按小端硬解,在一部分机器产出的文件上能跑通,在另一部分上会静默出错——这是最坑的一类问题,因为它不抛异常。
3. 数据类型码翻译:12 种码如何落成 struct 格式与端序开关
切分对了,下一步是把每种字段的值读出来。STDF 用一套短码描述数据类型,比如U*2是两字节无符号、R*4是四字节浮点、C*n是带长度前缀的字符串。这套码本身很清晰,真正的难点在三个地方:三种字符串的差异、端序由文件自己声明、位域字段的还原。逐个说。
3.1 C12、Cn、C*f:三种字符串的读法差异
字符串是 STDF 里最容易读错的一类,因为它有三种编码方式,长得像但语义完全不同。C*12是定长字符串,占满固定字节数,内容不足时右边补空格,读出来必须rstrip掉尾随空格,但不该strip左侧——有些字段确实以空格开头。C*n是自描述变长字符串,第一个字节是无符号长度,紧跟内容,最大 255 字节;长度字节为 0 表示这个字段没有值。C*f最特殊,它自己的长度不写在字段里,而是存在记录中另一个字段里,读取时必须由调用方把长度传进来。
第三种在实际解析中最容易出事。因为它打破了"字段自带边界"的假设,一旦你用一个通用的字段读取函数去顺序推,就会把长度字段当成内容读进来,然后整条记录的偏移全错。我的做法是在字段描述表里给C*f单独打标记,强制调用方传参,谁忘了传就直接抛异常,而不是默默读错。
import struct # 定长数值类型:字符 + 字节数。端序前缀在调用时拼接 FIXED = { "U*1": ("B", 1), "U*2": ("H", 2), "U*4": ("I", 4), "I*1": ("b", 1), "I*2": ("h", 2), "I*4": ("i", 4), "R*4": ("f", 4), "R*8": ("d", 8), } def read_value(buf, pos, code, endian="<", ext_len=None): if code == "C*n": # 首字节是长度 n = buf[pos] return buf[pos + 1:pos + 1 + n].decode("latin-1"), pos + 1 + n if code == "C*f": # 长度来自记录里的另一个字段 if ext_len is None: raise ValueError("C*f 必须由调用方提供长度") return buf[pos:pos + ext_len].decode("latin-1"), pos + ext_len if code.startswith("C*"): # C*12 这类定长,右侧空格填充 n = int(code[2:]) return buf[pos:pos + n].decode("latin-1").rstrip(" "), pos + n ch, size = FIXED[code] return struct.unpack_from(endian + ch, buf, pos)[0], pos + sizelatin-1是我惯用的兜底编码:它能把 0x00–0xFF 的每个字节一一映射成字符,遇到非法字节也不会抛解码异常。STDF 的字符集约定本来就窄,用 UTF-8 去解老文件,偶尔会因为某个 lot id 里混进一个高位字节就整个批次导入失败,得不偿失。真正要展示给人看的时候,再统一做一次清洗。
3.2 U/I/R 定长数值与 CPU_TYPE 决定的字节序
定长数值只有八种,一一对应到 C 语言的基本类型:无符号单/双/四字节、有符号单/双/四字节、四字节浮点、八字节浮点。R*4就是 IEEE 754 单精度,R*8是双精度,手册里明确写了和 C 语言类型说明符的对应关系,没什么歧义。
真正的坑在字节序。规范没有规定文件必须用小端,而是让写入方用自己的原生表示,把处理器类型写进 FAR 记录的 CPU_TYPE 字段;读取方读到 FAR 之后,判断写入方的表示方法和自己是否一致,不一致就在读取时逐条转换。这个设计的意图很清楚:让测试机写数据时不做任何转换,性能不受影响。
后果是同一份代码要能处理两种端序。我在分派函数里留了一个endian参数,默认<,一旦 FAR 的 CPU_TYPE 表明写入方是大端机器,就把后续所有调用的参数切成>。这个开关必须放在解析器的最外层统一管理,不能让每个字段各自猜——一旦有一处漏改,读出来的数值就是错的,而且不报错。
注意:CPU_TYPE 只出现在 FAR 里,而 FAR 是文件第一条记录。所以"先读 FAR 拿端序,再回头重读全文件"是正确姿势,别嫌麻烦跳过。
3.3 Bn、Dn、N*1:位域与半字节怎么还原成 pin 列表
这三类在数字测试数据里高频出现,尤其是功能测试的失败引脚信息。B*n是字节计数的位串,第一个字节表示后面有几个字节的位数据;D*n是位数计数的位串,前两个字节表示总位数,最大 65535;N*1是半字节打包,一个字节塞两个值,低 4 位是第一个,高 4 位是第二个,位数是奇数时高 4 位补零。
它们的共同约定是:第一个数据项放在最低位。这条规则和很多人默认的"从高位开始"相反,是位域读错的头号原因。如果拿到的失败引脚集合看起来整体偏移了几位、或者把相邻两个 pin 混在一起,先怀疑这条规则。
import struct def read_dn(buf, pos): # D*n:前 2 字节是位数,数据项从第 3 字节的最低位开始,高位补零 nbits = struct.unpack_from("<H", buf, pos)[0] nbytes = (nbits + 7) // 8 payload = buf[pos + 2:pos + 2 + nbytes] hits = [i for i in range(nbits) if (payload[i >> 3] >> (i & 7)) & 1] return hits, pos + 2 + nbytes def read_bn(buf, pos): # B*n:首字节是字节数,同样从最低位开始编号 nbytes = buf[pos] payload = buf[pos + 1:pos + 1 + nbytes] hits = [i for i in range(nbytes * 8) if (payload[i >> 3] >> (i & 7)) & 1] return hits, pos + 1 + nbytes这两个函数的返回值是"位序号列表",也就是失败位的位置集合,不是 pin 编号。要变成人能看的 pin 名,还得去查 PMR(引脚映射记录)——把 PMR 里的序号和物理引脚名、逻辑引脚名对起来。这一段对应手册里"Using the Pin Mapping Records"那一节,讲的就是从失败位序号反查到引脚名的完整链路,做失效分析时绕不过去。
i >> 3得到字节下标,i & 7得到字节内偏移,这种位运算写法比转成整数再移位更直观,也不受大整数实现细节影响。返回的pos一定要正确推进,位串字段后面往往还跟着别的字段,位置算错整条记录就废了。
4. 可选字段与缺失值:Optional Data 位图读错,后面全盘皆错
STDF 最复杂的设计不在数据结构,而在"如何表达没有数据"。测试数据天生残缺:这颗料某个测试项跳过了、那台机器没装某个传感器、某次重测没有记录坐标。规范用了几套不同机制来表达这些情况,混用之后,解析器和数据分析两边对同一个字段的理解就会分叉。
4.1 缺失与无效的四种表达:0 长度、空格、标志位、二进制 0
第一种是变长字符串用长度 0 表示缺失。第二种是定长字符串整体填空格,读出来 rstp 之后就是空串。第三种是针对数值字段的哨兵值,比如定义 -1 表示没有数据,这要求分析代码在算均值之前先把这个值剔掉,否则会把分布整体拉偏。第四种是二进制 0,主要用在时间日期字段上,表示该时间点没有记录。
这四种机制里,前两种在数据清洗时会被自动"吞掉",后两种必须显式处理。我在做 datalog 转表的时候会专门加一列标记字段的有效性,把哨兵值和正常值分开存,宁可多一列,也不要让脏值混进统计口径。血泪经验是:一颗料的某个参数全部是 -1,被当成真实值算出来的 CPK 会非常好看,然后被拿去汇报。
4.2 Optional Data 位图:位从低位数起,置 1 表示这一项别信
有些记录里,可选字段的取值范围是全值域,包括 -1,所以没法用哨兵值区分。这种情况下规范在记录里放一个 Optional Data 字节,每一位对应后面一个可选字段:位置 1 表示该字段的数据无效、应该忽略。位图的第 0 位对应紧跟其后的第一个可选字段,依次往高位排。
这里有两个必须同时满足的细节:位序从低位开始,以及置 1 只表示"值不可信"而不是"字段不存在"。字段本身仍然老老实实占着字节,你把pos推进过去才行。很多新手看到位被置 1 就直接跳过不读,结果后面的字段全部错位,读出一堆脏字符串和天文数字。
import struct # 以参数测试记录尾部的可选字段为例,顺序与手册字段表保持一致 OPT_TAIL = ["RES_SCAL", "LLM_SCAL", "HLM_SCAL", "LO_LIMIT", "HI_LIMIT", "UNITS", "C_RESFMT", "C_LLMFMT", "C_HLMFMT", "LO_SPEC", "HI_SPEC"] def read_optional(buf, pos, codes, endian="<"): flags = buf[pos] # 位图字节在前,之后才是各字段的数据 pos += 1 out = {} for i, code in enumerate(codes): invalid = (flags >> i) & 1 # 第 i 位对应第 i 个可选字段 if pos >= len(buf): # 尾部省略:字段整体不存在 out[code] = None continue val, pos = read_value(buf, pos, code, endian) out[code] = None if invalid else val # 无效也要读,读完照常推进 return out, pos函数里最容易被改坏的是pos的推进位置。注意read_value无论字段是否有效都必须调用,因为字节流不会因为值无效就消失。out[code] = None if invalid else val只是把结果置空,不影响读取动作。这个顺序一旦写反,前面几条记录可能碰巧正常,遇到第一条带无效标志的记录就开始错位。
4.3 尾部省略规则:哪些字段可以整体不写
除了位图,规范还允许在记录末尾整体省略可选字段来节省空间,但限制很严:只有在某字段及其后所有字段都是可选字段且都没有有效数据时,才能从该字段处截断。记录中间绝不能省略字段,否则偏移量无法推算。
这带来一个实现上的麻烦:读取一条记录时,你不知道尾部到底有没有被截断,只能边读边判断pos有没有到达len(body)。我的习惯是在每个字段读取前加一次边界检查,一旦pos >= len(body)就把剩余字段全部填 None 并结束,而不是让它抛 IndexError。抛异常看起来更"严格",但产线上经常有半截文件,一抛异常整批数据都进不了库。
提示:解析日志里把"因尾部截断而缺失"和"因位图标记而无效"分开统计。这两类在数据质量报告里含义完全不同,混在一起就查不出是设备少写了字段,还是测试项本身没测。
5. 解析翻车的五类现场:从长度错位到 V3/V4 混读的排查顺序
解析器写完之后,真正的工作才开始。下面这几类问题我在不同项目里都遇到过,顺序大致按出现频率排。排查时建议从第一条开始逐项排除,因为后面的现象很多是前面问题的次生结果。
5.1 现象:第二条记录开始整体错位,报错位置离起点很远
原因通常是把 REC_LEN 当成"包含记录头"的长度,导致每条记录多跳 4 个字节;或者端序读反,把一个本该是 30 的长度读成 7680。还有一种更隐蔽的情况:REC_LEN 读对了,但某条记录的字段解码多消费或少消费了字节,错误累积到几十条之后才在某个长度检查处爆出来。
解决方式是给扫描器加自校验:每读完一条记录,检查off + 4 + rec_len <= len(data),并且统计所有记录的4 + rec_len之和是否等于文件大小。只要这两条成立,切分层面就是对的。之后再逐条比对记录类型的出现顺序是否符合 FAR → MIR → ... → MRR 的大致层级,如果 TYP 值出现明显不该出现的组合,说明字段层出错了,跟切分层无关。
5.2 现象:PTR 的 RESULT 变成 1e38 量级或者一个极小的负数
两个原因。一是把R*4当成定点整数读,IEEE 754 的位模式按整数解释出来就是天文数字。二是字段偏移错了:参数测试记录里 RESULT 之前有若干个一字节的标志字段,少跳一个字节,四字节浮点就从错误的位置开始读,读出来的东西毫无意义但不报错。
解决方式是严格按手册字段表的顺序推进,不要凭记忆跳过任何字段,哪怕那个字段你根本不用。定位手段是拿一条已知结果的记录,把 RESULT 那 4 个字节单独抠出来,用struct.unpack('<f', ...)手工解一次,和解析器输出比对。两者一致说明解码没问题,不一致就往回退一个字段找偏移。
5.3 现象:时间戳整体偏移几个小时,跨时区导入时更乱
规范把时间字段定义成四字节无符号整数,表示从 1970 年 1 月 1 日午夜起算的秒数,但这个基准是"本地时区"的,不是 UTC。写入方按自己的本地时区算,读取方如果按自己的时区去解释,两台机器不在同一时区就会整体偏移。另一个常见错误是把它当有符号整数读,1970 年附近没问题,一旦遇到高位被置上的值就变成负数。
from datetime import datetime, timezone, timedelta def stdf_time(v, tz_hours=None): if v == 0: # 二进制 0 表示该时间点没有记录 return None if tz_hours is None: return datetime.fromtimestamp(v) # 按读取方本地时区解释 tz = timezone(timedelta(hours=tz_hours)) return datetime.fromtimestamp(v, tz=tz) # 已知写侧时区时显式指定解决方式是让时区成为显式参数,绝不依赖运行环境的默认设置。导入历史数据时,把写侧时区连同文件一起登记,后续所有转换都按这个参数走。偏移量是整小时这一点本身就是个好线索——看到差 8 小时、差 5 小时,基本可以直接判定是时区问题而不是数据损坏。
5.4 现象:可选字段读出脏字符串或负数,但记录长度是对的
位图位序搞反是最常见的原因。规范规定第 0 位对应第一个可选字段,如果按最高位当第一个去解,后面所有字段的有效性判断全部错位:本该忽略的脏值被当成真值,本该采信的数据被丢掉。另一种情况是尾部已经被截断,代码还在按完整字段表硬读,读到了缓冲区边界外的内容。
解决方式很直接:打印位图字节的二进制形式,对照手册字段表逐个核对,同时打印每个字段读取前后的pos,看它是否与记录长度吻合。这一步做完,问题基本就定位了。我一般会在解析器里留一个调试开关,打开后把每条记录的所有字段名、值、偏移都打出来,只在单个文件上跑,不用于批量。
5.5 现象:老文件解析到一半抛异常,或者字符串字段全是乱码
多半是拿 V4 的规则去解 V3 的文件。两版之间记录类型有增删,V4 新增了审计追踪、重测数据、晶圆配置等记录;数据类型的表达也有变化,V3 里不少字符串是定长写法,V4 大量改用长度前缀的变长写法;文件名的允许字符集和必需记录清单同样有调整。手册里专门有一章讲两版差异,做兼容前必须完整过一遍。
解决方式是在解析入口先读第一条记录的 FAR,拿到版本号再决定用哪套字段表。如果文件连 FAR 都没有,那它本身就不是合规文件,别硬解。另一个实操经验是:V3 文件在 V4 解析器上往往能读出头几条记录,然后在某个变长字符串处崩掉,这种"读一半才炸"的表现本身就是版本不匹配的信号。
6. 用守恒关系验收解析器:记录计数、Bin 汇总与测试项对账
解析器能跑通不代表结果可信。我的习惯是给它配一套自动验收,每次改动代码或者接入新机台的数据,都先跑一遍守恒检查。这些关系不需要知道具体工艺参数,纯粹靠文件内部的逻辑约束就能查出大部分错误。
6.1 四条守恒关系当验收标准
下面这几条覆盖了从字节层到业务层的三个维度,任何一条不成立,都说明解析或聚合环节有问题。
| 校验项 | 期望关系 | 不成立时的典型含义 |
|---|---|---|
| 字节守恒 | 所有记录的 (4 + REC_LEN) 之和 == 文件字节数 | 切分错误、文件截断 |
| 零件计数 | 零件结果记录条数 == 零件计数记录里的总颗数 | 并行 site 处理遗漏、跳过空记录 |
| Bin 汇总 | 硬件 bin 与软件 bin 的计数之和 == 总颗数 | bin 字段偏移错误、可选字段误读 |
| 测试项对账 | 某测试项的汇总执行次数 == 该测试项的执行记录条数 | 测试号关联错误、并行数据重复计入 |
第三行那条尤其有用。bin 计数分布在硬件 bin 和软件 bin 两类记录里,如果解析时把 bin 编号和计数对调了位置,总和立刻对不上,而且不报任何异常。我见过一次整整排查两天的问题,最后就是 bin 计数字段和 bin 编号字段顺序搞反了,靠这条守恒关系十分钟就能定位。
6.2 抽样回读:拿 hexdump 对一条参数测试记录
自动化检查只能证明"总量对得上",不能证明"每条记录都对"。所以我还会做一次抽样回读:随机挑一条参数测试记录,把它在文件里的绝对偏移记下来,用十六进制工具跳到那个位置,手工核对前几个字段。这一步看起来原始,但它能验证字段顺序这个最容易出错的地方。
# 从解析日志里拿到某条记录的偏移,比如 0x1A24C,直接看这 64 字节 xxd -s 0x1A24C -l 64 lot_a.std # 输出前 4 字节是记录头:长度、类型、子类型,后面紧跟字段数据 # 把长度字段按小端手工解出来,和解析器打印的 REC_LEN 比对对照的时候重点看三处:记录头的长度是否与解析器一致;紧跟头部的第一个定长字段值是否合理,比如测试号通常不会大得离谱;字符串字段开头那个长度字节是否等于后面可见字符的数量。三处都对上,这条记录基本可信。
那以后我每次接新机台的数据,都强制先跑一遍守恒检查再抽样回读一条,两关都过了才让它进数据库。这个习惯帮我挡掉过好几次"总量看着对、单条全错"的情况——尤其是并行测试的 site 数据,数量凑巧吻合的概率并不低,光看总数根本发现不了。希望帮到你。
本文还有配套的精品资源,点击获取