做存储驱动或者设备调试的人,早晚都会跟SCSI数据结构正面撞上。不管是上报一个IO错误、读取设备信息,还是排查为什么磁盘容量对不上,底层跑的都是那些看起来不起眼的CDB、Sense Data、Inquiry数据块。很多人一开始觉得这些东西又古老又难啃,其实它们就像快递单和回执单,格式是固定的,字段是精确定义的,搞明白之后排查问题会顺手很多,写驱动时也能少踩一半的坑。
这篇内容围绕常用的SCSI数据结构展开,包括CDB命令描述块、INQUIRY标准数据、VPD页、Mode Sense参数区、Sense Data错误现场,以及在内核驱动和用户态程序里怎么把它们用起来。适合刚接触存储开发的人,也适合那些偶尔要翻SCSI日志、需要跟设备厂商扯皮“到底谁填错了字段”的老手。
这类结构最大的好处是不依赖具体厂商。SCSI规范把布局写得很死,设备再不一样,返回数据的前几个字节也必须按规矩来。正因为这样,解析代码一旦写对,几乎能通吃各家的盘、阵列和磁带机。下文会按“先认识位置,再拆字节,最后上手用”的顺序来讲,代码以Linux内核和用户态SG_IO为主,我会把容易踩坑的偏移和字节序都标出来,这些细节大多数手册不会专门强调。
1. 先画地图:SCSI数据结构在IO路径里的位置
1.1 一次命令从发出到返回,到底经过了什么
SCSI命令不是凭空出现在设备面前的。在Linux里,一个块设备请求最终会变成一个struct scsi_cmnd,这个结构体里挂着CDB数组、数据缓冲区指针、sense缓冲区和完成回调。命令向下经过SCSI中间层、HBA驱动,最后通过HBA把CDB放到物理总线上;设备执行完再把数据或状态返回。
理解这条路径,你才能明白几种数据结构各自的角色:
cmnd[]是CDB数组,里面放的就是SCSI命令本身,比如READ(10)、INQUIRY、MODE SENSE。sdb相关的数据缓冲用来承载命令的输入或输出数据,比如读回来的扇区、设备返回的Inquiry数据。sense_buffer存放错误发生后的附加信息,也就是Sense Data。命令成功时这段缓冲区可能没被更新,所以解析前要先看状态。scsi_cmnd本身还带scsi_done回调,告诉上层命令已经完成。完成不代表成功,必须再检查状态。
有一件事特别值得记住:SCSI target不替主机维护“上一笔命令”的上下文,每次命令都独立执行。这和TCP会话完全不同。所以CDB里必须自带目标地址、长度、保护信息等,所有字段都直接决定设备行为。这也是为什么CDB里一个bit理解错,整个命令就会偏离预期。
1.2 把数据结构分成三类,定位问题会快很多
我习惯把SCSI数据结构分成三块:命令面、数据面、错误面。
- 命令面:CDB数组,定义“做什么”,比如读、写、查询、模式选择。
- 数据面:命令带回来的结果数据,比如INQUIRY标准数据、VPD页、Mode Sense返回的块描述符和模式页、Read Capacity返回的容量。
- 错误面:Sense Data、Host Status、Driver Status,定义“出了什么问题、发生在哪一层”。
分区的好处是排查时有路线。命令失败不一定要先看Sense,如果Host Status是非0,问题可能出在HBA或总线上,Sense里的值往往是上一次的残留。很多人一看到报错就去翻Sense Key,结果翻半天发现设备根本没执行命令,白白浪费时间。
2. 常用数据结构逐个拆开看
2.1 CDB命令描述块:一切命令的起点
CDB是SCSI通信里最基础的结构,没有之一。它是一段定长字节流,格式由操作码(Opcode)决定,常见的有6字节、10字节、12字节、16字节四类。操作码相同的情况下,后续字段布局也完全一致,所以解析CDB首先看第一个字节。
以最常用的READ(10)为例,操作码是0x28,它的CDB布局如下:
| 字节偏移 | 位 | 字段 | 说明 |
|---|---|---|---|
| 0 | 7:0 | Opcode | 0x28 |
| 1 | 6 | DPO | 禁用预读 |
| 1 | 5 | FUA | 强制写穿缓存 |
| 1 | 3:0 | RDPROTECT | 保护信息相关 |
| 2:5 | 31:0 | LBA | 起始逻辑块地址,大端 |
| 7:8 | 15:0 | Transfer Length | 传输块数,0表示65536 |
| 9 | 7:0 | Control | 通常为0 |
这里的LBA字段是4字节大端序,Transfer Length是2字节大端序。很多人第一次写CDB时会在字节序上翻车——在x86机器上直接memcpy一个u32进去,结果设备收到的是字节颠倒的地址,随即返回Illegal Request。
工程上我更建议用数组而不是位域来定义CDB结构:
struct read10_cdb { uint8_t opcode; /* 0x28 */ uint8_t flags; /* DPO/FUA等 */ uint8_t lba[4]; /* 大端序逻辑块地址 */ uint8_t reserved; /* 字节6 */ uint8_t transfer_length[2];/* 大端序块数 */ uint8_t control; /* 字节9 */ };使用数组可以绕开C语言位域的字节序问题。填充时手工把LBA拆成4个字节放进去,虽然多写几行,但跨平台行为完全一致。
2.2 INQUIRY标准数据:设备身份的第一手资料
INQUIRY命令用来获取设备的基本信息。它的CDB是6字节格式,操作码为0x12。如果EVPD位是0,返回的是标准INQUIRY数据;如果EVPD位是1,返回的是指定页的VPD数据。字节3:4是分配长度(Allocation Length),也就是告诉设备“你最多能回我多少字节”。
标准INQUIRY数据至少要读36字节,常用字段如下:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | Peripheral Qualifier | 3位,设备是否可访问 |
| 0 | Peripheral Device Type | 5位,设备类型,磁盘为0x00 |
| 1 | RMB | 1位,介质是否可移动 |
| 2 | Version | 规范版本 |
| 3 | Response Data Format | 低4位,一般等于2 |
| 4 | Additional Length | 额外数据的长度 |
| 8:15 | Vendor Identification | 厂商名,ASCII |
| 16:31 | Product Identification | 产品型号,ASCII |
| 32:35 | Product Revision Level | 固件版本,ASCII |
有个细节一定要记牢:Vendor Identification和Product Identification字段是ASCII空格填充,不是C语言字符串,末尾没有\0。直接printf("%s")会把后面的空格全打出来,甚至带出一串乱码。实际解析时要先把字段拷贝出来,再手动去掉尾部空格。
设备类型也很容易看错。0x00是直接访问块设备,普通磁盘和SSD都是这个;0x05是CD/DVD设备;0x07是光存储设备;0x1F是未知或未支持设备。上层驱动就是靠这个字段决定绑定到sd、sr还是st驱动。
2.3 VPD 0x83设备标识页:多路径绕不开的设计符
VPD(Vital Product Data)页是INQUIRY命令的扩展。所有VPD页里,0x83设备标识页出镜率最高,因为多路径软件、逻辑卷管理都要靠它生成稳定的设备标识。
读取VPD页时,INQUIRY CDB的EVPD置1,Page Code填0x83,分配长度给大一点,比如255或者更大。返回数据的前4个字节分别是:Page Code、保留字节、两个字节的Page Length。从page[4]开始是一个或多个Identification Descriptor,每个描述符的头部是4字节,后面跟着设计符数据。
描述符头部的解析按SPC-4规范的习惯来:第1个字节高4位是协议标识,低4位是编码集(Code Set);第2字节包含PIV标志、关联类型和设计符类型;第3、4字节是设计符长度。一个简化的遍历逻辑可以这么写:
unsigned char *p = page + 4; unsigned char *end = page + 4 + page_len; while (p + 4 <= end) { unsigned char code_set = p[0] & 0x0f; unsigned char assoc = (p[1] >> 4) & 0x03; unsigned char type = p[1] & 0x0f; unsigned int dlen = (p[2] << 8) | p[3]; if (p + 4 + dlen > end) break; /* 处理设计符数据,p + 4 是起点,长度是 dlen */ p += 4 + dlen; }设计符类型里,2表示EUI-64,3表示NAA,8表示UUID。多路径识别时优先选NAA或EUI-64的设计符,Vendor Specific类型最好别用,因为各家厂商格式不通用,换了设备就识别不出来。
这里有个非常容易踩的坑:page_len表示的是从第4字节开始的描述符总长度,而不是整个VPD页的长度。解析循环如果拿page_len去当缓冲区的总长度用,数据多的时候会越界,数据少的时候又可能漏解析。正确做法是先用设备返回的Additional Length判断缓冲区大小,再用Page Length控制描述符循环。
2.4 Mode Sense数据:设备的参数快照
Mode Sense命令用来读取设备的模式和参数。这里有个概念先厘清:MODE SENSE(6)的操作码是0x1A,MODE SENSE(10)的操作码是0x5A。括号里的数字是CDB长度,不是返回数据的固定长度。
返回数据由三部分组成:
- 模式参数头。MODE SENSE(6)对应的头是4字节,包含Mode Data Length、Medium Type、Device-Specific Parameter、Block Descriptor Length。MODE SENSE(10)对应的头是8字节,区别是Mode Data Length占2字节,Block Descriptor Length也占2字节。
- 块描述符。长度由模式参数头里的Block Descriptor Length字段决定,常见的是0或8。对于直接访问盘,8字节的块描述符里,最后2字节存放逻辑块长度。在MODE SENSE(6)返回数据里,头占4字节,块描述符从偏移4开始,所以块长度通常就在偏移10和11处。
- 模式页。模式页有SPF=0和SPF=1两种格式。SPF=0时,页的第1字节是页代码,第2字节是页长度,后面是参数;SPF=1时,页结构要长一些,页代码页后面还跟着子页代码,页长度占2字节。
模式页的Page Code字段决定了这页是什么内容。常见的有:
| Page Code | 含义 |
|---|---|
| 0x04 | 磁盘设备参数页 |
| 0x05 | 可移动介质参数页 |
| 0x08 | 缓存模式页 |
| 0x0A | 电源条件页 |
| 0x02 | 断开重连参数页 |
读取模式页时,CDB里的PC字段也很关键。PC=0表示当前值,PC=1表示可更改值,PC=2表示默认值,PC=3表示已保存值。调试时如果发现改参数不生效,先看看设备返回的是不是默认值页,别拿默认值当成当前值去分析。
2.5 Sense Data:错误现场的最直接记录
当设备执行命令失败时,通常会返回Check Condition状态。主机需要再发送REQUEST SENSE命令,把详细的Sense Data取回来。有些现代协议支持自动Sense,在完成状态里直接携带Sense Data,但解析逻辑是一样的。
Sense Data有两种格式:固定格式(Fixed Format)和描述符格式(Descriptor Format)。从响应码就能区分,0x70和0x71是固定格式,0x72和0x73是描述符格式。
固定格式里,关键字段的位置是固定的:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | Response Code | 低7位,0x70 |
| 2 | Sense Key | 低4位 |
| 3:6 | Information | 出错信息,通常是LBA |
| 7 | Additional Sense Length | 附加长度 |
| 12 | ASC | 附加错误码 |
| 13 | ASCQ | 附加错误限定码 |
描述符格式的布局不太一样,第2字节是Sense Key,第3、4字节是ASC和ASCQ,第5字节是Additional Sense Length,从第6字节开始是Sense Descriptor列表。解析时千万别用固定格式的偏移去读描述符格式,否则ASC读出来全是乱码。
Sense Key决定了错误的大类。最常见的几个:
| Sense Key | 含义 | 常见处理 |
|---|---|---|
| 0x00 | No Sense | 正常完成 |
| 0x01 | Recovered Error | 已纠正,但仍建议记录 |
| 0x02 | Not Ready | 设备未就绪 |
| 0x03 | Medium Error | 介质损坏 |
| 0x04 | Hardware Error | 硬件故障 |
| 0x05 | Illegal Request | 命令非法,多因CDB填错 |
| 0x06 | Unit Attention | 设备状态变化 |
| 0x07 | Data Protect | 写保护或权限问题 |
| 0x0B | Aborted Command | 命令被中止 |
ASC和ASCQ是更细一层的错误分类,常见值如下:
| ASC | ASCQ | 含义 |
|---|---|---|
| 0x00 | 0x00 | 无附加信息 |
| 0x04 | 0x02 | 未就绪,需要初始化命令 |
| 0x05 | 0x20 | 非法命令操作码 |
| 0x11 | 0x00 | 不可恢复读错误 |
| 0x21 | 0x00 | LBA越界 |
| 0x24 | 0x00 | CDB字段无效 |
| 0x25 | 0x00 | LUN不支持 |
| 0x29 | 0x00 | 电源复位或总线复位 |
| 0x2A | 0x00 | 参数列表长度错误 |
| 0x3F | 0x01 | 固件被变更 |
| 0x44 | 0x00 | 内部故障 |
这些码看熟之后,很多问题一眼就知道方向。比如Sense Key是0x05、ASC是0x24,几乎可以断定是CDB里的某个字段填错了,优先检查字节序和字段偏移,而不是去怀疑盘坏了。
3. 代码实操:内核侧、用户态SG_IO和现成工具
3.1 内核侧用scsi_cmnd下发CDB
在内核驱动里,最常用的方式是构造scsi_cmnd后调用中间的执行接口。以较新的内核接口scsi_execute_cmd为例,先构造一个READ(10)命令:
#include <scsi/scsi_device.h> #include <scsi/scsi_cmnd.h> #include <scsi/scsi_eh.h> static int test_read10(struct scsi_device *sdev) { struct scsi_exec_args args = {0}; unsigned char cdb[10] = {0x28, 0, 0, 0, 0x10, 0, 0, 0, 0x08, 0}; unsigned char sense[32] = {0}; char buf[4096] = {0}; int ret; args.cdb = cdb; args.cdb_len = sizeof(cdb); args.sense = sense; args.sense_len = sizeof(sense); args.buf = buf; args.buf_len = sizeof(buf); args.timeout = 5 * HZ; args.req_flags = BLK_MQ_REQ_PREEMPT; ret = scsi_execute_cmd(sdev, &args); if (ret < 0) { pr_err("read10 failed, ret=%d\n", ret); return ret; } /* 到这里命令已经完成,但要看args.sense和状态判断成败 */ return 0; }scsi_execute_cmd这个接口把不少细节封装好了,但CDB里的LBA仍然要自己填。buf存放读回来的数据,sense存放设备返回的Sense Data。内核版本不同,接口名称会有差异,老内核可能是scsi_execute_req或scsi_execute,参数个数和结构体字段不完全一样,移植代码时留意一下。
有个内核编程上容易犯的错误:把数据缓冲放在栈上直接传给scsi_execute_cmd。命令走异步路径时,DMA映射和回调时序复杂,栈上小数组很容易出问题。建议用kmalloc或kzalloc分配数据缓冲。
3.2 用户态用SG_IO直接对话设备
用户态调试SCSI命令,最灵活的方式是用SG_IO ioctl。它允许你绕过文件系统,直接把CDB发给设备,再把设备的返回数据拿回来。一个读取INQUIRY标准数据的完整例子:
#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <scsi/sg.h> #define INQ_LEN 96 #define SENSE_LEN 32 static void trim_space(char *s, int len) { int i = len - 1; while (i >= 0 && (s[i] == ' ' || s[i] == '\0')) s[i--] = '\0'; } int main(int argc, char **argv) { int fd; unsigned char cdb[6] = {0x12, 0x00, 0x00, 0x00, INQ_LEN, 0x00}; unsigned char sense[SENSE_LEN] = {0}; unsigned char data[INQ_LEN] = {0}; struct sg_io_hdr hdr; if (argc < 2) { fprintf(stderr, "Usage: %s /dev/sdX\n", argv[0]); return 1; } fd = open(argv[1], O_RDWR); if (fd < 0) { perror("open"); return 1; } memset(&hdr, 0, sizeof(hdr)); hdr.interface_id = 'S'; hdr.dxfer_direction = SG_DXFER_FROM_DEV; hdr.cmd_len = sizeof(cdb); hdr.mx_sb_len = sizeof(sense); hdr.dxfer_len = sizeof(data); hdr.dxferp = data; hdr.cmdp = cdb; hdr.sbp = sense; hdr.timeout = 5000; if (ioctl(fd, SG_IO, &hdr) < 0) { perror("SG_IO"); close(fd); return 1; } if (hdr.status || hdr.host_status || hdr.driver_status) { printf("cmd failed: status=0x%02x host=0x%02x driver=0x%02x\n", hdr.status, hdr.host_status, hdr.driver_status); close(fd); return 1; } char vendor[9] = {0}, product[17] = {0}, rev[5] = {0}; memcpy(vendor, data + 8, 8); memcpy(product, data + 16, 16); memcpy(rev, data + 32, 4); trim_space(vendor, 8); trim_space(product, 16); trim_space(rev, 4); printf("Vendor: %s\n", vendor); printf("Product: %s\n", product); printf("Revision: %s\n", rev); close(fd); return 0; }这个例子里有几个值得注意的地方。dxfer_direction如果是SG_DXFER_FROM_DEV,表示数据从设备流向主机,适合INQUIRY、READ这类命令;如果是写命令,要用SG_DXFER_TO_DEV。dxfer_len要和数据缓冲大小一致,别只填CDB里的分配长度而不给缓冲区留够空间。
返回之后最好先看hdr.status、hdr.host_status和hdr.driver_status。hdr.status等于0x02代表Check Condition,此时hdr.sb_len_wr会写入Sense数据长度,再解析sense里的内容才有意义。如果Host Status非0,比如DID_TIME_OUT,那就别管Sense了,先看传输层为什么超时。
3.3 sg3_utils工具是最好的对照实验台
不想写代码时,sg3_utils是验证SCSI行为最快的工具。常用命令:
# 查看标准INQUIRY数据 sg_inq /dev/sda # 查看VPD 0x83设备标识页 sg_vpd -p 0x83 /dev/sda # 读取缓存模式页 sg_mode_sense -p 8 /dev/sda # 读取容量 sg_readcap -l /dev/sda # 解析一段十六进制Sense Data sg_decode_sense 70 00 05 00 00 00 00 0a 00 00 00 00 24 00 00 00sg_inq和sg_vpd会直接把厂商、产品、序列号、设备标识打出来,是快速确认盘是否正常的好帮手。真正调试时,我习惯先用这些工具确认设备行为,再写代码去复现,避免一开始就被自己的代码带偏。
特别注意sg_decode_sense这个命令。线上环境经常看到类似Sense: 70 00 05 00 00 00 00 0a 00 00 00 00 24 00 00 00的日志,把十六进制串丢给它就能解释成人话,非常实用。
4. 高频雷区和排查套路
4.1 字节序和位域:解析前先确认自己站在哪一边
SCSI协议里所有多字节字段都是大端序。LBA、Transfer Length、Allocation Length、Page Length,清一色高位在前。在x86这种小端机器上解析,必须做转换。内核里可以用get_unaligned_be32、be32_to_cpu这类接口,用户态就自己移位或者ntohl。
不要图省事直接用C位域去映射SCSI结构。GCC的位域分配规则和SCSI的bit位置并不总是一一对应,跨编译器和跨架构更是无法保证。我见过有人把Sense数据定义成一大串位域结构体,在x86上跑得好好的,换到ARM上解析结果全错。最稳妥的方式是纯字节数组加掩码,或者用__be16、__be32类型标注清楚。
4.2 Transfer Length和Allocation Length真的不是一回事
这两类长度经常被混用,但含义完全不同。
Transfer Length是READ、WRITE这类命令里的字段,表示要传输多少个逻辑块。Allocation Length是INQUIRY、MODE SENSE这类命令里的字段,表示设备最多可以返回多少字节。前者是主机对设备下达的传输数量,后者是主机给设备划定的返回上限。
分配长度填小了不会报错,但数据会被截断。比如INQUIRY标准数据里有个Additional Length字段,它表示“我本来还想给你多少字节”。如果缓冲区只给了36字节,而Additional Length显示159,那就说明后面还有VPD相关数据没拿全。这时候不是命令失败,而是数据不完整,要适当加大分配长度重新读取。
4.3 Sense缓冲区残留问题:一个能骗过所有人的假故障
Sense缓冲区的复用是个老生常谈但永远有人踩的坑。在驱动里反复使用同一个sense_buffer时,如果上一次命令失败了,缓冲区里存了Sense数据;下一次命令成功了,这个缓冲区并不会自动清零。上层如果在命令成功后仍然去读Sense缓冲区,拿到的全是上一次的旧数据,于是系统会“凭空”报出一个错误。
我的习惯是两条:
- 每发一条命令之前,把Sense缓冲区全部清零。
- 只有状态是Check Condition或任务失败时,才去解析Sense缓冲区。
另外要注意响应码。如果sense[0]的低4位既不是0x0也不是0x2,那这个字段就不是标准的Sense响应码,说明缓冲区内容可能被覆盖或根本没更新,再往下解析没有意义。
4.4 出错时先分清是设备层还是传输层
很多新手一看到命令失败就直奔Sense Data,但这个习惯会耽误事。SCSI错误有层次:
- Host Status:由HBA和驱动报告,表示命令在传输过程中是否出现了超时、总线忙、连接失败等问题。
- Driver Status:由SCSI驱动层报告,表示驱动自身认为命令状态是否有效。
- Device Status:设备返回的状态字节,
0x02才是Check Condition,此时Sense Data才有意义。
排查顺序应该是:先看Host Status,再看Device Status,最后才解析Sense。比如Host Status等于DID_TIME_OUT或DID_BUS_BUSY时,命令可能根本没到设备,Sense里自然是空的或者旧数据。只有确认命令被设备执行并返回了Check Condition,Sense解析才有价值。
Linux里Host Status是一组固定宏,最常见的几个:
| 宏 | 值 | 含义 |
|---|---|---|
| DID_OK | 0x00 | 成功 |
| DID_NO_CONNECT | 0x01 | 连接不上 |
| DID_BUS_BUSY | 0x02 | 总线忙 |
| DID_TIME_OUT | 0x03 | 命令超时 |
| DID_BAD_TARGET | 0x04 | 目标设备不存在 |
| DID_NEXUS_FAILURE | 0x0A | 连接失败 |
如果日志里只看到DID_TIME_OUT,优先检查线缆、端口、HBA固件,而不是反复翻Sense Key。
4.5 VPD 0x83解析要留意关联类型
VPD 0x83里同一个设备可能返回多个设计符,有的关联类型是逻辑单元,有的是目标端口,有的是目标设备。多路径聚合时,逻辑单元关联的NAA或EUI-64设计符通常是稳定的,目标端口关联的设计符会随链路变化,不适合做持久标识。
还有一种情况很迷惑:设备厂商返回了好几个designator,其中一个是Vendor Specific,另外一个是NAA。如果你不小心解析到了Vendor Specific那一段,换一台同型号不同固件的设备,字符串可能就变了。所以代码里要加判断,优先选择Designator Type为3(NAA)或2(EUI-64)的条目,并且关联类型要过滤成逻辑单元。
最后分享一个个人习惯:刚接触一套新存储设备时,不要急着啃完整规范,先打开sg3_utils的源码或者内核里现成的解析函数,照着几个常用数据结构读两遍,再配合真实设备的hexdump对照,比自己从头翻标准快得多。SCSI数据结构虽然庞杂,真正天天用的就那几个,把它们的字节布局刻在脑子里,以后看日志、写驱动都会轻松很多。