ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UFS 4.0带宽翻倍背后:链路层、UPIU与Task Tag并发调度

UFS 4.0带宽翻倍背后:链路层、UPIU与Task Tag并发调度

简介:《UFS 4.0 Specification》是面向存储协议研发、移动设备固件与硬件测试人员的标准原文资料,对应JEDEC于2022年8月发布的JESD220F版本,可用于查阅UFS 4.0在高速串行接口、双通道读写、低功耗管理及安全机制方面的规范定义。包内共1个PDF文件,大小约5.56MB,内容为完整英文标准文本,便于按章节检索协议结构、命令集与电气特性。已有788人学习下载,适合作为UFS 3.1向4.0迁移、控制器开发及兼容性验证时的案头参考。读者可从中获取每通道23.2Gbps带宽、DVFS、低功耗状态、加密认证等关键条款的原始表述,并对照JEDEC框架理解错误检测、互操作性要求与测试边界,减少二手资料偏差。

1. 46.4Gbps 的另一半:UFS 4.0 的提速为什么先发生在链路层,而不是颗粒上

把 UFS 4.0 规范翻到电气章节,会发现一个容易看漏的细节:宣传里的 46.4Gbps 是两条 lane 各自 23.2Gbps 单向跑出来的合计数——一条发、一条收,全双工。这意味着从上一代到这一代的带宽翻倍,主要功劳不在闪存颗粒变快,而在 SerDes 从 HS-G4 跳到 HS-G5,把单 lane 速率抬了一倍。链路快了,协议栈就必须跟着改:命令队列怎么排、数据能不能乱序回来、写缓存怎么兜底、电源状态怎么退出来,这些才是 UFS 4.0 规范里真正变厚的地方。搞懂这套机制的价值很直接:当你的设备跑不到标称带宽,八成不是颗粒的问题,而是协议栈某处把并行度卡死了,这份规范就是那本「卡在哪」的答案册。

2. 四层协议栈:一条 READ(10) 从 CDB 到 M-PHY burst 要过几道手

很多人对 UFS 的理解停在「它是 eMMC 的接班人,速度快、全双工」。这只说到结论。真正决定一颗 UFS 4.0 设备能不能跑出标称性能的,是它内部那套被切成四层的协议栈,以及每层之间用什么方式交接工作和控制流量。规范文档 JESD220F 的第五章把这张拓扑画得很清楚,但图里没有写「为什么非要这么切」。

2.1 UAP、UTP、UIC 三层的职责边界

从上往下看,UFS 应用层(UAP)里装着两样东西:一套 SCSI 命令集(READ/WRITE/UNMAP/SYNCHRONIZE CACHE 这些块设备语义),和一套 UFS 原生命令(Query、Descriptor 访问这类设备管理动作)。再往下是 UFS 传输协议层(UTP),它负责把上层的东西封成统一格式的信息单元,并管理事务的生命周期。最底下是互联层(UIC),由 UniPro 和 M-PHY 组成,干的是帧传输、流控、CRC 校验、重传和物理层 burst 这些活儿。

这中间有两个服务访问点值得单独说:UDM_SAP 给设备管理实体用,UIO_SAP 给 UIC 层的控制命令用。它们存在的意义是让「管理」和「数据」走两条不同的门,而不是把设备配置命令混进 SCSI 的 CDB 里假装自己是一条块设备命令。

为什么要切这么细?因为上下两层的节奏根本不一样。SCSI 是「命令-响应」语义,一条命令可能挂很久;UniPro 是 credit-based 的帧传输,收到一帧就得回 credit,否则对端就停发。这两种节奏硬接在一起必然出问题,所以中间必须有 UTP 这层翻译官:它把 CDB 封成定长头,把 credit 花在 DATA IN/DATA OUT 上,还要负责在超时、拒绝、任务中止这些异常路径上收拾局面。

2.2 UPIU:32 字节定长头把所有交互压成一种格式

UPIU(UFS Protocol Information Unit)是整个协议栈的通用货币。不管是读数据、写数据、查描述符还是中止任务,全都用它承载。规范把基本头定死成 32 字节,多字节字段一律高位在前,这个「定长 + 大端」的选择看着朴素,实际上是整条链路上一切解析代码能写简单的前提。

/* UPIU 基本头:固定 32 字节,多字节字段一律 MSB first */ struct upiu_header { uint8_t trans_type; /* 0x01=COMMAND 0x02=RESPONSE 0x04=DATA IN ... */ uint8_t flags; /* bit0=R 读, bit6=W 写, 其余按事务类型复用 */ uint8_t lun; /* 目标逻辑单元;0xC4 是 RPMB 那个 well-known LU */ uint8_t task_tag; /* 0..31,主机分配的并发槽位,重组数据的唯一键 */ uint8_t iid_cmdset; /* 高 4 位 Initiator ID,低 4 位命令集类型 */ uint8_t query_tm_fn; /* Query 功能码 / Task Management 功能码 */ uint8_t response; /* RESPONSE 专用:TARGET_SUCCESS / TARGET_FAILURE */ uint8_t status; /* SCSI 状态:GOOD / CHECK CONDITION */ uint8_t ehs_len; /* 扩展头长度,单位是 4 字节,0 表示没有 */ uint8_t dev_info; /* 设备异常时的提示位,正常为 0 */ uint8_t data_seg_len[2]; /* 紧跟其后的数据段长度(字节) */ uint8_t residual[4]; /* RESPONSE 专用:还剩多少字节没传完 */ uint8_t cdb[16]; /* COMMAND 专用:CDB 直接塞进头里,省一次拷贝 */ /* 到这儿正好 32 字节,后面才是 EHS 和 data segment */ };

这段结构里有三处设计值得停一下。第一,COMMAND UPIU 把 16 字节 CDB 直接放在头的 16~31 字节位置,设备收到定长头就拿到了完整命令,不用再等一段变长数据,这在高并发下省下的是实打实的延迟。第二,LUN 放在头里而不是 CDB 里,意味着调度器只看头就能判断这条命令属于哪个逻辑单元——这正是后面跨 LU 优先级调度能成立的前提。第三,residual 字段是错误恢复的关键:读命令失败时,主机靠它知道到底写进去/读出来多少字节,而不是整条命令重来。

规范定义的 UPIU 类型不多,但每一类都对应一个明确的决策点:

事务类型方向作用
COMMAND主机到设备携带 CDB,发起一次 SCSI 或原生命令
RESPONSE设备到主机回状态、residual 和 sense data
DATA IN / DATA OUT双向承载实际载荷,长度写在头里
READY TO TRANSFER设备到主机设备说「这个 tag 的写数据我可以收了」
TASK MANAGEMENT REQUEST/RESPONSE双向中止任务、清队列、查状态
QUERY REQUEST/RESPONSE双向读写描述符、属性、标志位
REJECT / NOP双向协议层纠错与链路保活

2.3 Query 通道:为什么设备几何信息不走 SCSI

如果只用 SCSI,主机在拿到「这盘多大、有几个 LU、支持什么特性」之前是没法正常读写的。UFS 的解法是把这件事挪出 SCSI,用独立的 Query 事务完成。Query 请求的功能码很有限:读/写描述符、读/写属性、读/置位/清位/翻转标志位、空操作。但这几样东西正好构成了设备配置的全部接口面。

三类对象的语义差别要分清。描述符是结构化只读数据,按 IDN 加 index 访问,规范里定义了设备描述符、配置描述符、单元描述符、RPMB 单元描述符、几何描述符、电源参数描述符、字符串描述符、健康描述符这几类;属性是可读写的单值,比如当前电源模式、参考时钟频率、允许的在飞 RTT 上限;标志位是布尔开关,可以单独置位、清位甚至翻转,比如设备初始化完成位、后台操作使能位、写缓存使能位。分这三类而不是一股脑塞进寄存器,好处是每类的访问语义都能被静态检查,驱动程序不容易写错。

上电后的握手序列,是最能体现这套设计的场景:

  1. 上电或复位后,主机反复发 Query 请求读设备描述符,直到设备不再返回忙——这一步没有捷径,只能重试。
  2. 读到设备描述符后,读配置描述符,确认设备使能了哪些特性、boot 逻辑单元怎么配的。
  3. 如果要改逻辑单元划分,写单元描述符,然后置位设备初始化标志。
  4. 轮询这个标志位,直到设备把它清掉——这一步等价于「配置已生效,我重新枚举完了」。
  5. 重新读一遍描述符,用新值刷新主机侧缓存。
  6. 再进入正常的 SCSI 读写流程。

提示:第 4 步是整个流程里最容易出错的一环。标志位没清就往下走,读到的是旧配置,后面所有容量和 LU 计算全是错的,而且错误往往延后几百毫秒才暴露出来。

3. 队列与乱序:UFS 4.0 靠什么把双工链路喂饱

链路快了不等于吞吐上去了。一条 23.2Gbps 的 lane,如果主机同一时刻只有一条命令在飞,那绝大部分时间链路是空的——NAND 的读延迟是几十微秒量级,而链路传 4KB 只要一两微秒。差距就在这里产生,UFS 用来填这个差距的两件武器是命令队列和乱序数据传输。

3.1 Task Tag:并发槽位是 5 bit 的硬上限

规范给每个逻辑单元分配了 32 个 Task Tag,也就是 32 个并发槽位。这个数字不是随便定的:UPIU 头里 Task Tag 占一个字节但只用低 5 位,留出的高位是给未来扩展的余地;而 32 这个量级刚好能覆盖多 plane、多 die 的并行编程深度,再大对设备端的调度器反而是负担。

真正容易出问题的是主机侧的生命周期管理。Tag 不是「发命令时随便取一个数」,而是一份必须严格配对的资源:分配、绑定缓冲区、提交、等待、回收,任何一步漏掉,这个槽位就永久消失了。

/* 主机侧发起一条读命令的骨架,寄存器操作省略 */ int ufs_read(uint8_t lun, uint64_t lba, void *buf, size_t len) { int tag = alloc_task_tag(lun); /* 每个 LU 独立的 32 个槽位 */ if (tag < 0) return -EBUSY; /* 槽位耗尽只能排队等,不能抢别人的 */ struct upiu_header *cmd = build_command_upiu(lun, tag, READ_10, lba, len); bind_buffer(lun, tag, buf, len); /* 缓冲区必须和 tag 一一绑定 */ enqueue_request(lun, tag, cmd); /* 挂到该 LU 的请求链表上 */ ring_doorbell(lun, tag); /* 通知控制器:这个 tag 有效 */ wait_for_completion(tag); /* RESPONSE UPIU 回来才能回收 */ return release_task_tag(lun, tag); /* 失败路径也必须走到这里 */ }

这段骨架里有两个隐藏约定。第一,bind_buffer之后缓冲区不能被上层挪用,因为乱序返回时数据可能分好几批落到同一块内存的不同偏移上。第二,ring_doorbell这一步严格来说不属于 UFS 规范,而属于主机控制器接口的范畴——规范只定义 UPIU 长什么样、Task Tag 怎么用,至于主机怎么把请求递给控制器,那是另一份文档的事。这个留白是有意的:它让同一套协议能适配不同的主机控制器实现,代价是驱动开发者要在两份文档之间来回对照。

3.2 乱序数据与 RTT:把等待时间叠起来

顺序返回数据的模型很好理解:主机按提交顺序收数据。但它在真实 NAND 上效率极低,因为不同 tag 对应的页可能分布在不同 die 上,完成时间

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进