简介:面向移动存储、嵌入式开发与芯片验证人员的UFS 4.0规范原文,即JEDEC于2022年8月发布的JESD220F标准文档,可用于系统梳理通用闪存从协议接口、数据传输到功耗管理的技术框架。压缩包仅含1个PDF文件,大小约5.56MB,保留标准正文、版权声明及版本修订信息,便于按章节检索关键定义与参数。内容围绕每通道23.2Gbps理论带宽、双通道操作、全双工串行接口、DVFS低功耗机制以及加密认证等安全特性展开,并覆盖智能手机、平板电脑和可穿戴设备等应用场景,有助于读者把握UFS 4.0相对UFS 3.1的升级重点。已有788人学习下载,适合作为存储驱动开发、性能优化和方案选型时查阅与对照的一手资料。
1. 23.2Gbps 这个数字,骗了不少人
UFS 4.0 规范里最容易被拿来做文章的数字,是每通道 23.2Gbps。很多人第一反应是“线速率翻了几倍,所以体验会快几倍”,但真把一份 UFS 4.0 规范文档从头翻到尾就会发现,线速率的提升只是结果,不是原因。真正让这套协议跟上一代拉开差距的,是它把“什么时候提速、什么时候降速、数据怎么插队、命令怎么并发”全部写进了可协商的状态机和数据结构里。换句话说,快是链路层和命令层配合出来的,不是把时钟调高调出来的。搞懂这一点,你再看 UPIU 头里的每一个 bit、看 Unipro 的流控信用、看那些一堆名字很像的复位类型,才不会觉得是在背单词。
2. 分层架构:一条 READ 命令要穿过四层才摸到闪存
主机侧发一条读请求,最终落到闪存阵列上,中间要过四道手。UFS 4.0 规范把这条路径切成应用层(UAP)、传输协议层(UTP)、互联层(UIC),再外加一个横跨各层的设备管理器。分层本身不稀奇,稀奇的是每一层之间的耦合被压到了最低:层与层之间只传递一种东西,就是 UPIU。理解了这个“单一货币”的设计,后面所有细节都能挂上去。
2.1 UAP 与 UTP:SCSI 命令和原生命令怎么挤进同一个通道
应用层负责的是“命令长什么样”。它下面有两套命令体系:一套是 SCSI 命令集,READ(10)、WRITE(16)、UNMAP、SYNCHRONIZE CACHE 这些,命令描述块 CDB 定长 16 字节;另一套是 UFS 原生命令 UNC,比如 NOP、QUERY、TASK MANAGEMENT,这些在 SCSI 里没有对应物。
传输协议层负责的是“命令怎么送出去”。它把 CDB 或者原生命令的参数塞进一个固定格式的信封,配上 LUN、任务标签、方向位和期望传输长度,然后交给下面的互联层。这一层还管着命令队列、任务管理、数据传输的分包节奏。规范里把 UTP 的职责描述得很克制,但实际实现里最容易出问题的恰恰是这里:任务标签的分配与回收、数据段长度和期望长度对不上、RESPONSE 里的 status 和 sense data 组合起来该怎么解释。
一个常见的实现思路是把 UTP 层做成一个状态机数组,每个任务标签对应一个槽位,槽位里记录当前处于“已发命令等响应”“等数据”“数据已收齐等响应”中的哪一态。这套状态机不难写,难写的是异常路径:设备回 REJECT UPIU、任务被 Task Management 中止、数据长度不匹配触发 CHECK CONDITION,每一条都要有明确的回收动作,否则标签泄漏之后队列深度会莫名其妙地掉下去。
2.2 UIC 层:UniPro 和 M-PHY 是两个不同的世界
互联层由两部分拼起来:上面是 UniPro,负责链路层的可靠性;下面是 M-PHY,负责电气层怎么把 bit 打成波形。规范里把它们分开写,是因为两者的失败模式完全不同。
UniPro 提供的是带重传的可靠链路:它有数据链路层的帧、有流控信用、有重传机制,还定义了网络层和传输层的地址映射。UFS 只是 UniPro 的一个“客户协议”,所以 UTP 层需要把 LUN 和任务映射到 UniPro 的端口(CPort)上去。这层抽象带来的好处是链路可以按需重传而不打扰上层,代价是多了一层缓冲和一套信用管理,调不好的时候表现为带宽上不去但又不报错。
M-PHY 则是纯粹的物理层:终端匹配、驱动电平、burst 的起止、gear 的切换、自适应均衡。它有一套自己的状态机,跟 UniPro 的链路状态不是一回事。联调时最典型的困惑就是“UniPro 说链路起来了,但数据就是过不去”,这时候要看的是 M-PHY 的 burst 是否真的建立、gear 是否协商到预期档位。
2.3 设备管理器与描述符:主机怎么“认识”一颗陌生的芯片
设备管理器是横切的一层,它不参与数据搬运,但它决定了主机能不能正确配置这颗芯片。它对外暴露三样东西:描述符(Descriptor)、标志(Flag)、属性(Attribute)。描述符是只读的静态信息,比如设备能力、几何结构、电源参数;标志是布尔开关,比如某个功能开没开;属性是可读可写的数值,比如队列深度、当前功耗模式。
主机访问这三样东西走的是 QUERY REQUEST UPIU,而不是 SCSI 命令。也就是说,在发第一条 READ 之前,主机已经通过一串 QUERY 把设备的底细摸了一遍。这套模型跟“寄存器映射”的思路不同:寄存器是地址加位域,描述符是带索引的结构体,读取时要用“描述符类型 + 索引”两个参数定位,返回的是变长结构。好处是扩展性强,新增能力只要加一个描述符类型;坏处是主机侧必须实现一套完整的解析逻辑,解析错了不会报错,只会读出一个看似合理但完全不对的值。
| 层 | 搬运的单位 | 典型失败表现 |
|---|---|---|
| UAP | CDB / 原生命令参数 | 命令被设备判为非法,回 CHECK CONDITION |
| UTP | UPIU | 任务标签泄漏,队列深度逐级下降 |
| UniPro | 帧与信用 | 带宽上不去但没有错误码 |
| M-PHY | burst 与 gear | 链路起不来,或起在低档位 |
3. UPIU:把命令、数据、状态装进同一种信封
UPIU 是这套协议里最值得细看的东西。它把“我要读”“这是你要的数据”“我收到了”“出错了”这些语义完全不同的事情,全部塞进同一个 32 字节的基本头里,靠一个 6 位的类型字段区分。这种设计的好处是设备侧只需要一个解析入口,坏处是这个头里每一个字段都要承载多重含义,读规范的时候一不留神就会把某个只在特定类型里有效的字段当成通用字段。
3.1 32 字节基本头里,每一位都有代价
基本头的布局是这样的:第一个字节的低 6 位是事务类型,高 2 位是标志;第二个字节里的若干位是方向位和任务属性的高位;然后是 1 字节的 LUN、1 字节的任务标签、1 字节的 Initiator ID 加上查询功能位、1 字节的响应码、1 字节的状态码、1 字节的端到端保护标志、1 字节的扩展头长度、1 字节的数据段长度,最后是 4 字节的期望传输长度和 4 字节的头部保护字段。
/* UFS 4.0 规范中 UPIU 基本头的 32 字节布局,按位域还原 */ struct ufs_upiu_header { uint8_t transaction_type : 6; /* bit5..0:0x01=COMMAND,0x02=RESPONSE,0x21=DATA IN */ uint8_t flags : 2; /* bit7..6:bit6 是方向位,bit5 是任务属性高位 */ uint8_t lun : 8; /* 逻辑单元号,8 位,并非 SCSI 的 64 位寻址 */ uint8_t task_tag : 8; /* 主机分配的票据,设备必须原样回填 */ uint8_t iid : 4; /* Initiator ID,单主机场景恒为 0 */ uint8_t reserved0 : 2; uint8_t query_function : 2; /* 仅 QUERY REQUEST 有效:读描述符/读属性/写描述符/写属性 */ uint8_t response : 8; /* 仅 RESPONSE UPIU 有意义:TARGET SUCCESS / TARGET FAILURE */ uint8_t status : 8; /* SCSI 状态:GOOD=0x00,CHECK CONDITION=0x02 */ uint8_t e2e : 8; /* 端到端保护信息标志 */ uint8_t total_ehs_len : 8; /* 扩展头长度,单位是 32 字节 */ uint8_t data_segment_len : 8; /* 本 UPIU 自带数据段长度,单位是 4 字节 */ uint32_t data_transfer_len; /* 本次事务期望传输的总字节数 */ uint32_t header_e2e; /* 头部保护字段,低 16 位承载 CRC */ } __attribute__((packed));几个参数必须掰开说。data_segment_len的单位是 4 字节,不是 1 字节,写驱动的人第一次看很容易直接当字节数用,结果算出来的偏移全部错位。data_transfer_len是整笔事务的总长度,而data_segment_len只是这一个 UPIU 里附带的那一段,两者在多包传输时并不相等。task_tag是主机分配的,设备只负责回填,所以主机侧必须维护一张从标签到上下文的映射表,标签用完不回收,队列深度就会假性耗尽。
query_function那两位只在 QUERY REQUEST 里有效,在 COMMAND UPIU 里它是保留位。这种“同一字段在不同类型下语义不同”的设计在规范里出现了不止一次,阅读时必须先把事务类型确定下来,再去看字段含义,否则会得出互相矛盾的结论。
3.2 一次读操作的完整时序:COMMAND 到 RESPONSE
以一条 READ(10) 为例,主机先发一个 COMMAND UPIU,带上 LUN、任务标签、CDB 和期望传输长度,此时方向位表示“设备要往主机送数据”。设备收到后如果可以直接服务,就开始回 DATA IN UPIU,每个包带一段数据。全部数据送完,设备再回一个 RESPONSE UPIU,里面填响应码和状态码,任务标签跟上一次一样。
写操作的时序长一点。主机发 COMMAND UPIU 之后,设备未必立刻要数据,它可能先回一个 READY TO TRANSFER UPIU,告诉主机“我现在要第 N 个偏移开始的第 M 个字节”。主机收到之后才开始发 DATA OUT UPIU。这个握手存在的意义是让设备掌握节奏:闪存写入的时序跟主机内存的读取速度不匹配,如果主机一口气灌过来,设备侧缓冲会溢出。用 RTT 把控制权交给设备,主机就只能按设备要多少给多少。
这里的坑是 RTT 里的偏移量语义。规范和 SCSI 的语义不完全一样,偏移指的是本次请求内部的偏移,不是整个传输的偏移,两者混用会导致写进去的数据错位。而且 RTT 可以在一笔事务里出现多次,也可以完全不出现,取决于设备实现。主机侧如果假设“写操作一定会有 RTT”,遇上不发的设备就会直接卡死。
3.3 乱序数据与命令队列:为什么 UFS 敢让数据“插队”
规范里明确写了数据乱序传输的规则。设备在响应一次多块写的时候,可以按任意顺序索要数据块,用 RTT 逐个指定。这条规则看起来只是给了设备一点灵活性,实际上是闪存介质特性的直接映射:不同物理页的写入耗时差异很大,设备如果能先拿已经准备好的那些块,整体吞吐就能上去。
命令队列是另一层并发。设备通过属性暴露自己的队列深度,主机可以同时挂起多个任务标签。更进一步,规范还定义了跨逻辑单元的优先级机制,允许主机告诉设备“这个 LUN 的请求优先处理”。这套东西组合起来,才让 UFS 在随机小 IO 场景下不至于退化成一条同步的总线。
代价是主机侧的实现复杂度直线上升。队列要管理,标签要复用,优先级要在提交时排序,任务管理命令要能在队列中间插进去取消某个任务。一个常见的实现思路是把队列做成两级:一级是待提交队列,按优先级排序;一级是已提交队列,按标签索引。提交时从前者取,响应回来时按标签回填后者。这套结构写起来不难,难的是各种边界:取消一个已经发了数据但还没收到 RESPONSE 的任务,设备回什么、主机怎么回收缓冲,规范给了框架但没给唯一答案。
import struct def parse_upiu(buf: bytes) -> dict: """解析 32 字节 UPIU 基本头,用于在抓包或日志里快速判断事务类型""" if len(buf) < 32: raise ValueError("基本头不足 32 字节,多半是 trace 被截断") b0, b1, lun, tag, iid_q, resp, status, e2e, ehs, dsl = struct.unpack("<10B", buf[:10]) dlen, = struct.unpack("<I", buf[10:14]) return { "type": b0 & 0x3F, # 低 6 位才是事务类型,高 2 位属于 flags "flags": (b0 >> 6) | ((b1 & 0x01) << 2), "lun": lun, "task_tag": tag, "query_func": iid_q & 0x03, # 只在 QUERY REQUEST 下有意义 "response": resp, "status": status, "data_segment_len": dsl * 4, # 单位是 4 字节,别直接当字节数 "expected_data_len": dlen, }这段解析代码的用处是在联调阶段快速判断“设备到底回了什么”。日志里如果出现type=0x02且status=0x02,那就是一条 CHECK CONDITION 的 RESPONSE,接下来要去读 sense data;如果出现大量task_tag相同但类型交替的包,说明主机侧标签复用出了问题。data_segment_len乘 4 这个细节,几乎每个第一次写解析脚本的人都会踩一次。
4. UniPro 与 M-PHY:链路为什么必须反复“起搏”
UFS 的物理链路不是一根一直在跑的线。它在两种模式之间来回切:低速的 PWM 模式用来做初始化和抗干扰场景,高速的 HS 模式用来搬数据,两者之间还有一段自适应过程。这套设计的动机很朴素:移动设备的功耗预算不允许链路一直全速待命,而闪存的访问又是突发的,所以链路必须能快速起、快速停,且每次起搏都要在几十微秒量级完成。
4.1 HS burst 与 gear:省电省出来的一套状态机
M-PHY 的状态机里有准备阶段、同步阶段、数据传输阶段和收尾阶段。HS burst 开始之前要先发一段准备序列,让接收端的均衡器收敛;然后是同步序列,用来对齐符号边界;再进入真正的数据段。gear 决定了这一轮 burst 跑多快,规范定义了从低速到高速若干档,两端的 gear 必须协商一致。lane 的数量也是可配的,单 lane 和双 lane 的带宽差一倍,但双 lane 对布线的对称性要求高得多。
这套机制的直接后果是:带宽不是恒定的,而是由“每次 burst 能传多少”和“burst 之间隔多久”共同决定。一条命令如果只有 4KB 数据,可能 burst 刚热身完就结束了,实际有效带宽远低于理论值。这也解释了为什么小文件随机读的性能测试结果波动很大:测的根本不是峰值带宽,而是起搏的开销。
自适应过程是另一个容易被忽略的点。规范里给了一段可调的均衡参数,用于补偿信道损耗。板级设计做得不好,自适应就会失败或者收敛到很差的值,表现出来是高速下误码率飙升,链路反复重传,最终降档运行。这时候看链路状态是正常的,看带宽却只有预期的一半。
4.2 流控与数据 pacing:接收端说“慢点”的两种方式
UniPro 的流控用的是信用机制。接收端告诉发送端“我还有多少个缓冲可用”,发送端每发一帧就扣一个信用,收到确认再还回来。这套机制的好处是不会丢帧,坏处是信用数量直接限制了在途数据量,链路延迟大的时候带宽上不去。
规范里还定义了数据 pacing 机制,让主机主动控制发送节奏。跟信用流控不同,pacing 是主机侧的主动行为,用来避免瞬时突发把设备侧缓冲打满。两者配合的逻辑是:信用保证不出错,pacing 保证不拥塞。实际调试时如果发现“带宽上不去但也没有重传”,优先查信用配置;如果发现“有重传且延迟抖动大”,优先查 pacing 和板级信号质量。
4.3 参考时钟:规范里那些“不起眼”的硬约束
参考时钟是整份规范里最枯燥、也最容易翻车的一节。规范对时钟的频率、抖动、占空比都给了明确指标,还专门附了测量方法。原因很简单:HS 模式下链路速率是参考时钟的倍数,时钟抖动会直接被放大成符号间干扰。很多“链路起不来”的问题,最后查出来是时钟源本身抖动超标,或者走线太长引入了额外噪声。
规范里还提到一种在低速模式下检测参考时钟是否可用的机制,用来在初始化阶段判断时钟质量。这个机制的存在说明设计者已经预见到一种情况:时钟“有”但“不好”,如果直接往上跑高速,链路会以难以定位的方式失败。先低速验证,再逐档提速,是这套协议默认的启动策略。
板级设计那一节给出的阻抗和电容建议,同样是硬约束而不是参考意见。电源去耦不到位会导致 burst 起始瞬间电压塌陷,表现出来是高速下偶发误码,而低速完全正常。这类问题在实验室里很难复现,一到量产批次就集中爆发,属于典型的血泪经验。
5. 排查:UFS 4.0 联调时最容易翻车的五个地方
下面这五条都是“表面上看起来像软件问题,根子上在别处”的典型。每条按现象、原因、解决三段写,顺序就是排查顺序。
5.1 枚举阶段卡在链路启动
现象:上电之后主机反复尝试建立链路,状态机停在启动阶段不动,偶尔重试几次能过去,但极其不稳定。
原因:链路启动阶段跑在最低档位,此时对时钟质量和终端匹配最敏感。参考时钟抖动超标、终端电阻值不对、差分对走线不对称,都会让同步序列对不齐。另一个常见原因是复位时序:复位释放得太早,设备内部还没准备好,主机就开始发启动序列。
解决:先用示波器量参考时钟的实际抖动,对照规范给的指标,不要只看频率对不对。再检查终端匹配和差分对阻抗,板级设计那一节给了允许的最大阻抗和典型电容值。最后核对复位时序,确保复位释放到链路启动之间有足够间隔。这三步做完还起不来,再去看设备侧的电源上电斜率是否满足要求。
5.2 跑分只有预期的一半
现象:顺序读写的带宽稳定在理论值的一半左右,不报错,不重传,就是慢。
原因:最常见的是 gear 没协商到最高档,或者只跑在单 lane 上。其次是数据包太小,起搏开销占比过高。还有一种隐蔽情况是主机侧的缓冲策略不对,一次只发一个命令,队列深度实际是 1,设备侧的并发能力完全没用上。
解决:先读设备属性,确认当前协商到的速率档位和 lane 数量,再跟设备声称支持的能力对比,两者不一致说明协商过程有问题,回头查信号质量。然后确认传输块大小是否够大,小包场景下带宽上不去是正常的。最后检查主机侧的队列配置,队列深度属性和实际提交的并发数要匹配得上。
5.3 受保护分区写入返回失败
现象:普通分区读写正常,往受保护分区写认证数据时返回失败,重试也没用,换一台设备又好了。
原因:受保护分区有一套独立的计数器和认证流程,写入请求要带正确的随机数和消息认证码,计数器不匹配就直接拒绝。另一个高频原因是分区容量没配置,或者配置了但主机侧的写入偏移算错了。规范里对这套流程的请求和响应格式定义得很细,字段错一个字节就会被判为非法。
解决:先把计数器读出来,跟主机侧记录的值比对,不一致就以设备侧为准重新同步。然后核对请求结构里的每一个字段,特别是长度字段的单位。写入之前确认分区已经配置且容量非零。这套流程没有“试出来”的余地,只能严格按规范的结构逐字段对。
5.4 掉电之后文件系统损坏
现象:正常关机没事,异常掉电之后挂载报错,日志里能看到最后一批写入丢了,甚至出现半写状态。
原因:主机的关机流程里没有下发断电通知,也没有刷缓存。UFS 设备内部有写缓存,还有专门用于加速的缓冲区域,这些内容在掉电时如果没有落盘就会丢失。更麻烦的是,设备可能已经向主机回了“写完成”,但数据还在设备缓冲里。
解决:关机流程里必须依次下发缓存同步命令和断电通知,等设备确认之后再切电源。对于不能保证正常关机的场景,要靠文件系统自身的日志机制兜底,同时在应用层避免“写完立刻断电”的操作序列。这条属于设计阶段就要定下来的事,事后补救代价很大。
5.5 长时间写入后速度断崖式下跌
现象:刚开始写入速度很快,跑了几十秒之后突然掉到一个很低的水平,过一会儿又恢复,反复循环。
原因:两种可能。一是温度升高触发了性能节流,设备主动降速保护介质;二是加速缓冲区域写满了,后续写入必须直接落到慢速介质上,同时后台的垃圾回收开始工作,争抢带宽。这两种情况在表现上很像,需要看设备上报的事件才能区分。
解决:先读温度相关的属性和事件通知,确认是不是节流触发。如果不是,再看缓冲区的占用情况,评估工作负载是否超出了缓冲能吸收的量。对于持续写入场景,主机侧要做速率整形,避免把设备逼到热节流和垃圾回收同时发生的状态。这类问题在短时间的跑分里根本看不出来,只有长时间压测才暴露。
6. 设计取舍:为什么规范把越来越多的决定权交给主机
翻完整份规范会发现一个趋势:UFS 4.0 定义的机制里,有相当一部分只规定了“接口和通知”,不规定“策略”。加速缓冲写满了怎么办、温度上来了降不降速、生产状态下写过的区域要不要标记、容量变化了主机怎么感知,这些都只给出可观测的信号,具体怎么做由主机决定。
这套思路的动机不难理解。设备侧的固件要对所有主机通用,它不知道对面是手机、平板还是车机,也不知道当前跑的是什么业务,贸然做策略性决策很可能帮倒忙。把决策权上移,设备只负责如实上报状态,主机根据自己的业务特点去权衡,理论上能得到更优的结果。
代价是主机固件变复杂了。每多一个可观测信号,主机侧就多一个状态机;每多一个可配置项,就多一处配置错误的可能。实际项目里最常见的不是“机制不好用”,而是“机制压根没被用起来”:缓冲开关没打开、节流事件没人监听、断电通知忘了发。规范给了工具,用不用是另一回事。
验证这类设计是否被正确实现,我的做法是构造三类负载:纯顺序大块写、随机小块写、长时间混合读写,每类跑够时间,同时记录设备的属性快照和事件上报。三类负载下的行为如果和规范描述的机制对得上,基本可以认为实现是完整的;如果某一类跑着跑着就“失联”了,问题往往不在数据通路上,而在状态管理上。
我印象最深的一次是排查一个“偶发掉速”,查了三天信号质量,最后发现是加速缓冲的开关从来没被打开过,设备一直在直写介质。从那以后我每次做 bring-up,都强制走一遍“时钟质量、链路档位、命令队列、电源与缓存策略”四项检查,每一项都要有实测数据,不看完不往下走。希望帮到你。
本文还有配套的精品资源,点击获取