资讯动态

SCSI数据结构详解:CDB、INQUIRY与Sense Data实战

发布时间:2026/10/17 9:39:05 来源:尧图企业网站定制
做存储驱动或者设备调试的人早晚都会跟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布局如下字节偏移位字段说明07:0Opcode0x2816DPO禁用预读15FUA强制写穿缓存13:0RDPROTECT保护信息相关2:531:0LBA起始逻辑块地址大端7:815:0Transfer Length传输块数0表示6553697:0Control通常为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字节常用字段如下偏移字段说明0Peripheral Qualifier3位设备是否可访问0Peripheral Device Type5位设备类型磁盘为0x001RMB1位介质是否可移动2Version规范版本3Response Data Format低4位一般等于24Additional Length额外数据的长度8:15Vendor Identification厂商名ASCII16:31Product Identification产品型号ASCII32:35Product 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设备标识页多路径绕不开的设计符VPDVital Product Data页是INQUIRY命令的扩展。所有VPD页里0x83设备标识页出镜率最高因为多路径软件、逻辑卷管理都要靠它生成稳定的设备标识。读取VPD页时INQUIRY CDB的EVPD置1Page 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-643表示NAA8表示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)的操作码是0x1AMODE 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处。模式页。模式页有SPF0和SPF1两种格式。SPF0时页的第1字节是页代码第2字节是页长度后面是参数SPF1时页结构要长一些页代码页后面还跟着子页代码页长度占2字节。模式页的Page Code字段决定了这页是什么内容。常见的有Page Code含义0x04磁盘设备参数页0x05可移动介质参数页0x08缓存模式页0x0A电源条件页0x02断开重连参数页读取模式页时CDB里的PC字段也很关键。PC0表示当前值PC1表示可更改值PC2表示默认值PC3表示已保存值。调试时如果发现改参数不生效先看看设备返回的是不是默认值页别拿默认值当成当前值去分析。2.5 Sense Data错误现场的最直接记录当设备执行命令失败时通常会返回Check Condition状态。主机需要再发送REQUEST SENSE命令把详细的Sense Data取回来。有些现代协议支持自动Sense在完成状态里直接携带Sense Data但解析逻辑是一样的。Sense Data有两种格式固定格式Fixed Format和描述符格式Descriptor Format。从响应码就能区分0x70和0x71是固定格式0x72和0x73是描述符格式。固定格式里关键字段的位置是固定的偏移字段说明0Response Code低7位0x702Sense Key低4位3:6Information出错信息通常是LBA7Additional Sense Length附加长度12ASC附加错误码13ASCQ附加错误限定码描述符格式的布局不太一样第2字节是Sense Key第3、4字节是ASC和ASCQ第5字节是Additional Sense Length从第6字节开始是Sense Descriptor列表。解析时千万别用固定格式的偏移去读描述符格式否则ASC读出来全是乱码。Sense Key决定了错误的大类。最常见的几个Sense Key含义常见处理0x00No Sense正常完成0x01Recovered Error已纠正但仍建议记录0x02Not Ready设备未就绪0x03Medium Error介质损坏0x04Hardware Error硬件故障0x05Illegal Request命令非法多因CDB填错0x06Unit Attention设备状态变化0x07Data Protect写保护或权限问题0x0BAborted Command命令被中止ASC和ASCQ是更细一层的错误分类常见值如下ASCASCQ含义0x000x00无附加信息0x040x02未就绪需要初始化命令0x050x20非法命令操作码0x110x00不可恢复读错误0x210x00LBA越界0x240x00CDB字段无效0x250x00LUN不支持0x290x00电源复位或总线复位0x2A0x00参数列表长度错误0x3F0x01固件被变更0x440x00内部故障这些码看熟之后很多问题一眼就知道方向。比如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: status0x%02x host0x%02x driver0x%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 ConditionSense解析才有价值。Linux里Host Status是一组固定宏最常见的几个宏值含义DID_OK0x00成功DID_NO_CONNECT0x01连接不上DID_BUS_BUSY0x02总线忙DID_TIME_OUT0x03命令超时DID_BAD_TARGET0x04目标设备不存在DID_NEXUS_FAILURE0x0A连接失败如果日志里只看到DID_TIME_OUT优先检查线缆、端口、HBA固件而不是反复翻Sense Key。4.5 VPD 0x83解析要留意关联类型VPD 0x83里同一个设备可能返回多个设计符有的关联类型是逻辑单元有的是目标端口有的是目标设备。多路径聚合时逻辑单元关联的NAA或EUI-64设计符通常是稳定的目标端口关联的设计符会随链路变化不适合做持久标识。还有一种情况很迷惑设备厂商返回了好几个designator其中一个是Vendor Specific另外一个是NAA。如果你不小心解析到了Vendor Specific那一段换一台同型号不同固件的设备字符串可能就变了。所以代码里要加判断优先选择Designator Type为3NAA或2EUI-64的条目并且关联类型要过滤成逻辑单元。最后分享一个个人习惯刚接触一套新存储设备时不要急着啃完整规范先打开sg3_utils的源码或者内核里现成的解析函数照着几个常用数据结构读两遍再配合真实设备的hexdump对照比自己从头翻标准快得多。SCSI数据结构虽然庞杂真正天天用的就那几个把它们的字节布局刻在脑子里以后看日志、写驱动都会轻松很多。

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

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

免费获取报价 →
↑