简介:JESD220E是JEDEC固态技术协会于2020年1月发布的UFS 3.1官方协议标准文档,面向移动存储固件开发、驱动调试与存储系统设计人员,帮助读者系统掌握这一eMMC进阶版高速存储接口的完整规范。UFS 3.1采用全双工运行与多通道架构,弥补了eMMC半双工读写的性能瓶颈,并引入命令队列、动态电源管理、CRC与ECC错误校验及多种低功耗状态,理论最大传输速率可达11.6Gbps,总带宽约23.2Gbps,同时支持HS-G4接口、3D NAND与设备身份验证加密。资源包为1个PDF文件,大小约4.36MB,即JESD220E标准正文,内容涵盖协议架构、命令集、传输层与电气特性等章节,适合作为查阅与对照开发的权威依据。目前已有5381人学习下载,便于快速定位关键条款、理解性能一致性机制与安全特性,为存储方案选型与固件实现提供参考。
1. JESD220E 与 UFS 3.1:一块闪存为什么需要 1200 页的协议文档
如果你拆过一台近三年的中高端手机,大概率会在主板上看到一颗 BGA 封装、引脚密到肉眼数不清的存储芯片,旁边丝印着 UFS 字样。它替代了早年 eMMC 的位置,成了移动端事实上的主存储标准。而 JESD220E,就是这套标准里 UFS 3.1 那一版的协议规范文档编号。很多人第一次打开它的时候是懵的:一千多页,前两百页在讲术语和参考模型,中间几百页在讲命令描述符和属性,最后还有大段大段的时序图和电气参数。它不像一份教程,更像一份法律条文——每一条都在定义“谁在什么状态下必须做什么”。
这份文档解决的核心问题只有一个:让主控(Host)和存储设备(Device)在互不信任的前提下,还能高效、可靠、可恢复地交换数据。UFS 3.1 相比 3.0 主要加了三样东西:Write Booster、DeepSleep、Performance Throttling Notification。这三个特性在文档里各自占了一整章,也是实际项目里最容易被问到的部分。适合读这篇文章的人,是那些需要做 UFS 驱动移植、存储性能调优、或者单纯想搞明白手机里那块闪存到底怎么被管起来的工程师。接下来的内容会从协议分层讲到具体命令,再到实际调试时怎么抓包、怎么改参数、怎么避开那些文档里写得含糊但实际会翻车的地方。
2. 从 JESD220E 的参考模型拆出 UFS 3.1 的通信骨架
2.1 为什么 UFS 不是简单的“块设备”
如果你之前只接触过 eMMC 或者 SD 卡,第一反应会把 UFS 当成一个更快的块设备。但 JESD220E 的参考模型里,UFS 是一个带命令队列、支持多逻辑单元、有独立物理层和链路层的完整协议栈。它和主机之间的接口叫 UFS InterConnect,简称 UIC。UIC 下面又分 M-PHY 和 UniPro 两层。M-PHY 负责电气信号,UniPro 负责链路管理、错误恢复和流量控制。再往上才是 UFS 传输协议层,处理命令、描述符和属性。
这个分层带来的直接后果是:你不能像操作 eMMC 那样直接发一个 CMD17 读块。UFS 里所有操作都封装成 UPIU(UFS Protocol Information Unit)。UPIU 是一个固定格式的数据包,有头部、有载荷、有事务类型。读一个块,实际流程是:主机发 COMMAND UPIU,设备回 RESPONSE UPIU,然后设备发 DATA IN UPIU,主机回 RESPONSE UPIU。每一步都有超时和重试机制。所以调试 UFS 的时候,逻辑分析仪抓到的不是简单的时钟和数据线,而是一串串 UPIU 包。理解 UPIU 的结构,是读懂 JESD220E 后面所有命令章节的前提。
2.2 UPIU 的头部字段与事务类型
UPIU 头部固定 12 字节,后面跟可变长度的额外头部和载荷。前 12 字节里最关键的是 Transaction Type 和 Flags。Transaction Type 决定了这个包是命令、响应、数据还是任务管理。Flags 里有一位叫 R/W,区分读和写。还有一位叫 Task Tag,用来匹配请求和响应。设备端最多支持 32 个 Task Tag,也就是命令队列深度。UFS 3.1 要求至少支持 32 个,实际芯片有的做到 64 个。
下面这段代码是一个简化的 UPIU 头部解析逻辑,用 Python 写,方便你在抓包后快速看字段。实际驱动里是 C 结构体,但字段偏移是一样的。
import struct # UPIU 基础头部 12 字节,按 JESD220E 定义 # byte 0: Transaction Type # byte 1: Flags # byte 2: LUN # byte 3: Task Tag # byte 4: IID (Initiator ID) / Command Set Type # byte 5: Reserved # byte 6-7: Query Function / Response # byte 8-9: Status / Data Segment Length # byte 10-11: Residual Transfer Count / Reserved def parse_upiu_header(raw: bytes): if len(raw) < 12: raise ValueError("UPIU header must be at least 12 bytes") trans_type = raw[0] flags = raw[1] lun = raw[2] task_tag = raw[3] iid_cmdset = raw[4] query_func = struct.unpack(">H", raw[6:8])[0] status = raw[8] data_len = struct.unpack(">H", raw[9:11])[0] residual = struct.unpack(">H", raw[10:12])[0] # 提取关键标志位 rw = (flags >> 6) & 0x01 # 1 表示读,0 表示写 return { "trans_type": hex(trans_type), "rw": "READ" if rw else "WRITE", "lun": lun, "task_tag": task_tag, "cmd_set": (iid_cmdset >> 4) & 0x0F, "query_func": hex(query_func), "status": hex(status), "data_seg_len": data_len, "residual": residual, } # 示例:一个 COMMAND UPIU 的头部 sample = bytes([ 0x01, # Transaction Type = COMMAND 0x40, # Flags: R/W = 1 (读) 0x00, # LUN 0 0x05, # Task Tag 5 0x00, # IID 0, Command Set 0 (SCSI) 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]) print(parse_upiu_header(sample))这段代码的逻辑很直接:按字节偏移把头部字段拆出来,重点看 Transaction Type 和 Flags。参数说明上,Task Tag 是匹配请求和响应的关键,如果你在日志里看到 Task Tag 对不上,说明设备回错了包或者主机发重了。Data Segment Length 在 COMMAND UPIU 里通常为 0,在 DATA IN/OUT UPIU 里才表示实际数据长度。Residual Transfer Count 只在读操作出错时才有意义,表示还有多少字节没传完。实际调试时,我一般会先把抓到的 UPIU 按 Task Tag 分组,再看每个组的 Transaction Type 序列是否完整。如果少了 RESPONSE UPIU,大概率是链路层超时或者设备端固件卡住了。
2.3 描述符、属性与标志:UFS 的“配置数据库”
JESD220E 里有一类东西叫 Descriptor,翻译成描述符。它不是数据,而是设备的配置信息。比如 Device Descriptor 里有厂商 ID、产品型号、支持的 UFS 版本。Unit Descriptor 里有每个 LUN 的逻辑块大小、可写状态。Configuration Descriptor 里有当前生效的配置。这些描述符在设备初始化的时候由主机读取,然后主机根据读到的内容决定怎么配。
和描述符并列的还有 Attribute 和 Flag。Attribute 是可读可写的配置项,比如 bRefClkFreq 决定参考时钟频率,bMaxNumOfRTT 决定最大重传次数。Flag 是布尔量,比如 fDeviceInit 用来触发设备初始化,fWriteBoosterEn 用来开写加速。这三类东西的访问方式不一样:描述符用 QUERY REQUEST UPIU 读,属性用 QUERY REQUEST 读写,标志用 SET FLAG 和 CLEAR FLAG。很多驱动移植的坑就出在这里——把描述符当属性读,或者忘了在改属性之前先检查设备是否支持。
下面这张表列出了 UFS 3.1 里最常用的几个配置项和它们的访问方式,实际写初始化代码的时候可以对着查。
| 名称 | 类型 | 访问方式 | 典型值/说明 |
|---|---|---|---|
| bDeviceInit | Flag | SET FLAG | 置 1 触发设备初始化,完成后设备自动清零 |
| bRefClkFreq | Attribute | QUERY WRITE | 0=19.2MHz, 1=26MHz, 2=38.4MHz, 3=52MHz |
| bMaxNumOfRTT | Attribute | QUERY WRITE | 最大重传次数,默认 2,可调到 4 提高可靠性 |
| fWriteBoosterEn | Flag | SET/CLEAR FLAG | 开启 Write Booster 功能 |
| bWriteBoosterBufferType | Attribute | QUERY WRITE | 0=共享缓冲,1=专用缓冲 |
| dNumWriteBoosterBufferAllocUnits | Attribute | QUERY WRITE | 分配多少写加速缓冲单元 |
| wExceptionEventStatus | Attribute | QUERY READ | 读异常事件状态,排查错误时必看 |
这张表里的每一项在 JESD220E 里都有独立的章节号,查的时候直接搜属性名就能定位。实际项目里,我习惯在初始化阶段把 bRefClkFreq 和 bMaxNumOfRTT 先读出来打印,确认设备当前配置和硬件设计一致。如果 bRefClkFreq 读出来是 0 但硬件给的是 26MHz,那后面所有时序都会错,表现就是随机读写失败。
3. 用 QUERY 和命令 UPIU 跑通一次完整的读写流程
3.1 初始化:从复位到可读写状态
UFS 设备上电后不是立刻就能读写的。JESD220E 定义了一个明确的状态机:上电复位、链路启动、设备初始化、配置、最后才进入传输状态。链路启动由 UniPro 层负责,主机通过 M-PHY 的 PWM 模式开始,协商到 HS 模式。这一步如果失败,后面什么都做不了。链路起来之后,主机发 SET FLAG 把 fDeviceInit 置 1,设备开始内部初始化,完成后把 fDeviceInit 清 0。主机轮询这个标志,直到读到 0 才算初始化完成。
初始化完成后,主机要读 Device Descriptor 和 Unit Descriptor,确认设备容量和 LUN 配置。然后根据需求写 Configuration Descriptor,设置 Number of LUNs、LUN 使能、启动 LUN 等。最后写 bRefClkFreq 和 bMaxNumOfRTT 这些属性。这一套流程在 JESD220E 的 10.1 节到 10.4 节有详细描述,但文档里是状态图,实际代码要自己实现轮询和超时。
下面是一个用 bash 和 debugfs 在 Linux 环境下查看 UFS 设备描述符的例子。不同平台路径可能不一样,但思路通用。
# 假设 UFS 主机控制器已经枚举成功,设备节点在 /sys/class/ufs/ # 查看设备描述符 cat /sys/class/ufs/ufs_device/device_descriptor # 查看所有 LUN 的 Unit Descriptor for lun in /sys/class/ufs/ufs_device/unit_descriptor/*; do echo "=== $lun ===" cat "$lun" done # 查看当前生效的属性 cat /sys/class/ufs/ufs_device/attributes/bRefClkFreq cat /sys/class/ufs/ufs_device/attributes/bMaxNumOfRTT # 手动触发一次设备初始化(调试用,正常流程由驱动做) echo 1 > /sys/class/ufs/ufs_device/flags/fDeviceInit # 轮询直到读到 0 while [ "$(cat /sys/class/ufs/ufs_device/flags/fDeviceInit)" != "0" ]; do sleep 0.01 done echo "Device init done"这段脚本的逻辑是:先读描述符确认设备基本信息,再读属性确认时钟和重传配置,最后手动触发一次初始化并轮询完成标志。参数说明上,fDeviceInit 的轮询间隔建议 10ms,超时设 1 秒。如果超过 1 秒还没清零,说明设备端初始化失败,常见原因是供电不稳或者参考时钟不对。实际调试时,我一般会在这个阶段用示波器量一下 REF_CLK 的频率和占空比,确认硬件没问题再查软件。
3.2 读写命令的 UPIU 交互序列
初始化完成后,读写操作就进入标准流程。以读一个块为例,主机先发 COMMAND UPIU,里面包含 SCSI 读命令(0x28)或者 UFS 原生命令。设备收到后回 RESPONSE UPIU,表示命令已接受。然后设备发 DATA IN UPIU,把数据传给主机。主机收到数据后回 RESPONSE UPIU 确认。如果是写操作,则是主机发 COMMAND UPIU,设备回 RESPONSE UPIU,主机发 DATA OUT UPIU,设备回 RESPONSE UPIU。
这里的关键是 Task Tag 的匹配。主机发 COMMAND 时带一个 Tag,设备回 RESPONSE 和 DATA 时必须带同一个 Tag。如果 Tag 对不上,主机会认为响应丢失,触发重传。重传次数超过 bMaxNumOfRTT 就会报错。JESD220E 里规定,设备必须在收到命令后的某个时间内响应,这个时间叫 bMaxNumOfRTT 相关的超时。实际芯片手册里会给出具体纳秒数,但协议文档只给范围。
下面这段 Python 代码模拟了一个简化的读写状态机,用来理解 UPIU 的交互顺序。它不是真实驱动,但能帮你在写代码前把流程理清楚。
from enum import Enum, auto class UpiuState(Enum): IDLE = auto() CMD_SENT = auto() RESP_WAIT = auto() DATA_XFER = auto() DONE = auto() ERROR = auto() class UfsTransaction: def __init__(self, task_tag: int, is_read: bool): self.task_tag = task_tag self.is_read = is_read self.state = UpiuState.IDLE self.retry = 0 self.max_retry = 4 # 对应 bMaxNumOfRTT def send_command(self): # 构造 COMMAND UPIU,Transaction Type = 0x01 print(f"[Tag {self.task_tag}] Send COMMAND UPIU, R/W={'READ' if self.is_read else 'WRITE'}") self.state = UpiuState.CMD_SENT def on_response(self, resp_tag: int, status: int): if resp_tag != self.task_tag: print(f"[Tag {self.task_tag}] Tag mismatch: got {resp_tag}, retry") self.retry += 1 if self.retry > self.max_retry: self.state = UpiuState.ERROR return if status != 0: print(f"[Tag {self.task_tag}] Response error status={status}") self.state = UpiuState.ERROR return if self.is_read: self.state = UpiuState.DATA_XFER print(f"[Tag {self.task_tag}] Wait DATA IN UPIU") else: self.state = UpiuState.DATA_XFER print(f"[Tag {self.task_tag}] Send DATA OUT UPIU") def on_data_done(self): if self.state == UpiuState.DATA_XFER: self.state = UpiuState.DONE print(f"[Tag {self.task_tag}] Transaction done") # 模拟一次读操作 tx = UfsTransaction(task_tag=3, is_read=True) tx.send_command() tx.on_response(resp_tag=3, status=0) tx.on_data_done() # 模拟一次 Tag 不匹配 tx2 = UfsTransaction(task_tag=7, is_read=False) tx2.send_command() tx2.on_response(resp_tag=8, status=0) # 错误的 Tag tx2.on_response(resp_tag=7, status=0) # 重传后正确 tx2.on_data_done()这段代码的重点是 on_response 里的 Tag 匹配逻辑。参数说明上,max_retry 对应 bMaxNumOfRTT,默认 2,我一般会调到 4 来提高在高负载下的稳定性。status 字段在 RESPONSE UPIU 里表示命令执行结果,0 表示成功,非 0 要看具体错误码。实际调试时,如果看到大量 Tag mismatch,先查主机的 Task Tag 分配逻辑是不是有并发冲突,再查链路层有没有丢包。UFS 3.1 支持命令队列,多个 Tag 同时活跃是正常的,但每个 Tag 必须唯一。
3.3 Write Booster 的配置与生效条件
Write Booster 是 UFS 3.1 最实用的新特性之一。它的原理是在设备内部划一块 SLC 缓冲,写数据先写到这块缓冲里,等空闲时再搬到大容量 TLC 区域。这样突发写入的延迟会低很多。但 JESD220E 里对 Write Booster 的配置有严格顺序:先读 bWriteBoosterBufferType 确认设备支持哪种缓冲类型,再读 dNumWriteBoosterBufferAllocUnits 确认可分配单元数,然后写 bWriteBoosterBufferType 和 dNumWriteBoosterBufferAllocUnits,最后 SET FLAG 把 fWriteBoosterEn 置 1。
顺序错了会怎样?如果先置 fWriteBoosterEn 再配缓冲大小,设备会返回错误,因为缓冲还没分配。如果分配的单元数超过设备支持的上限,也会报错。实际项目里,我见过有人直接抄了别的平台的配置值,结果设备不支持那么大的缓冲,初始化直接失败。所以这一步一定要先读再写。
下面是一个配置 Write Booster 的伪代码流程,用 C 风格写,方便对应到驱动代码。
// 假设已经实现了 query_read_attr 和 query_write_attr // 以及 set_flag 和 clear_flag int configure_write_booster(void) { uint8_t buf_type; uint32_t alloc_units; uint32_t max_units; // 1. 读设备支持的缓冲类型 if (query_read_attr(ATTR_WRITE_BOOSTER_BUFFER_TYPE, &buf_type) != 0) { return -1; } // buf_type: 0=共享, 1=专用 if (buf_type > 1) { return -1; // 设备不支持 } // 2. 读最大可分配单元数 if (query_read_attr(ATTR_NUM_WRITE_BOOSTER_BUFFER_ALLOC_UNITS, &max_units) != 0) { return -1; } // 3. 分配缓冲,这里取最大值的一半,留余量 alloc_units = max_units / 2; if (query_write_attr(ATTR_WRITE_BOOSTER_BUFFER_TYPE, buf_type) != 0) { return -1; } if (query_write_attr(ATTR_NUM_WRITE_BOOSTER_BUFFER_ALLOC_UNITS, alloc_units) != 0) { return -1; } // 4. 使能 Write Booster if (set_flag(FLAG_WRITE_BOOSTER_EN) != 0) { return -1; } // 5. 可选:设置刷新策略 // bWriteBoosterBufferFlushEn 控制是否自动刷新 // bWriteBoosterBufferFlushDuringHibernate 控制休眠时是否刷新 query_write_attr(ATTR_WRITE_BOOSTER_BUFFER_FLUSH_EN, 1); return 0; }这段代码的关键在步骤 3 和 4 的顺序。参数说明上,alloc_units 的单位是 Allocation Unit,具体大小在 Device Descriptor 里定义,通常是 512 字节的倍数。取最大值的一半是经验做法,留出空间给设备内部管理。如果分配太多,设备可能没有足够空间做垃圾回收,反而导致性能下降。实际测试时,我会用 fio 跑随机写,对比开和关 Write Booster 的 IOPS 和延迟。通常开启后随机写 IOPS 能提升 30% 到 50%,但持续写入超过缓冲大小后,性能会掉回 TLC 的水平。所以这个特性适合突发写,不适合持续大文件写入。
4. 调试 UFS 3.1 时最容易翻车的五个地方
4.1 参考时钟频率配错导致链路起不来
现象:设备上电后,主机控制器报链路启动超时,M-PHY 一直停在 PWM 模式,进不了 HS 模式。用示波器量 REF_CLK,发现频率是 26MHz,但驱动里配的是 19.2MHz。
原因:JESD220E 规定 bRefClkFreq 必须和实际硬件提供的参考时钟一致。很多开发板默认焊的是 26MHz 晶振,但参考代码里写的是 19.2MHz。驱动初始化时如果先写 bRefClkFreq 再启动链路,写进去的值和实际时钟不匹配,UniPro 层协商就会失败。
解决:先读 bRefClkFreq 的当前值,和硬件原理图核对。如果不一致,在链路启动之前就写正确的值。注意,写 bRefClkFreq 需要设备处于初始化状态,不能在链路还没起来的时候写。正确顺序是:上电、链路启动到 PWM 模式、发 SET FLAG fDeviceInit、轮询完成、读 bRefClkFreq、如果不对就写新值、然后重新启动链路到 HS 模式。
4.2 Task Tag 耗尽导致命令队列卡死
现象:系统跑了一段时间后,UFS 读写全部超时,日志里大量 “task tag not available” 或者 “command queue full”。重启后恢复,但跑一段时间又出现。
原因:主机驱动分配 Task Tag 后,如果某个命令因为错误没有正常完成,Tag 没有被释放。UFS 3.1 最多 32 个 Tag,泄漏几个之后就没有可用 Tag 了。常见触发场景是设备返回错误响应,但驱动只处理了成功路径,忘了在错误路径里释放 Tag。
解决:在驱动的错误处理分支里,确保每个已分配的 Tag 都被释放。可以用一个位图来管理 Tag 分配,每次分配和释放都检查位图。另外,bMaxNumOfRTT 不要设太小,否则重传次数不够,命令更容易失败。我一般会把 bMaxNumOfRTT 从默认的 2 调到 4,给重传留更多机会。
4.3 Write Booster 缓冲分配后没有刷新导致数据丢失
现象:开了 Write Booster 之后,突然断电,重新上电发现最近写入的数据丢了。关掉 Write Booster 就不会丢。
原因:Write Booster 的缓冲是易失的,数据先写到 SLC 缓冲,还没搬到大容量区域时如果断电,缓冲里的数据就没了。JESD220E 里定义了 bWriteBoosterBufferFlushEn 和 bWriteBoosterBufferFlushDuringHibernate 两个属性来控制刷新行为。如果没开自动刷新,也没有在断电前手动刷新,就会丢数据。
解决:对于需要掉电保护的场景,要么不开 Write Booster,要么把 bWriteBoosterBufferFlushEn 置 1,并且在系统关机流程里发一次刷新命令。刷新命令是 SET FLAG fWriteBoosterBufferFlush,设备收到后会把缓冲里的数据全部搬走。这个操作可能需要几百毫秒,关机流程里要留够时间。实际项目里,我一般会在文件系统 sync 之后、断电之前,再发一次刷新,确认设备返回完成。
4.4 描述符读取长度不对导致解析错位
现象:读 Device Descriptor 时,返回的数据长度比预期短,后面的字段全是 0 或者乱码。驱动解析出来的厂商 ID 和产品型号明显不对。
原因:JESD220E 里每个描述符都有固定的长度,比如 Device Descriptor 是 0x59 字节。但 QUERY REQUEST UPIU 里的 Data Segment Length 如果设错了,设备只会返回部分数据。有些驱动为了省事,只读前 16 字节,结果后面的字段没读到。
解决:读描述符之前,先查 JESD220E 里对应的长度定义,把 Data Segment Length 设对。Device Descriptor 是 0x59,Unit Descriptor 是 0x2D,Configuration Descriptor 是 0x90。读的时候一次性读完,不要分多次。如果设备返回的长度和预期不符,检查 QUERY REQUEST 里的 Query Function 和 Length 字段。实际调试时,我会把读到的原始字节打印出来,对照文档的字段偏移表逐字节核对。
4.5 高温降频触发 Performance Throttling 导致性能骤降
现象:手机跑大型游戏或者长时间录像时,UFS 读写性能突然掉到原来的一半甚至更低。温度降下来后又恢复。
原因:UFS 3.1 新增了 Performance Throttling Notification 特性。设备温度超过阈值时,会主动通知主机自己降频了。如果主机驱动没有处理这个通知,上层应用会以为存储变慢了,但不知道为什么。JESD220E 里定义了 wExceptionEventStatus 属性,其中有一位就是 Performance Throttling。
解决:在驱动里注册异常事件处理,定期读 wExceptionEventStatus。如果发现 Performance Throttling 位被置 1,说明设备在降频。这时候可以通知上层做降级处理,比如降低写入速度、暂停后台任务。同时检查散热设计,看是不是设备周围太热了。实际项目里,我一般会在温升测试阶段专门跑这个场景,确认降频阈值和恢复时间符合预期。
5. 用 fio 和 trace 验证 UFS 3.1 的真实性能边界
5.1 搭建一个可复现的测试环境
要验证 UFS 3.1 的性能,不能只靠跑分软件。我一般会用一个 Linux 开发板,把 UFS 设备挂到块设备节点上,然后用 fio 做可控的读写测试。关键是控制变量:固定 CPU 频率、关掉后台任务、确保散热良好。测试之前先读一遍设备描述符,确认 LUN 配置和 Write Booster 状态。
下面是一个 fio 配置,用来测随机读和随机写的 IOPS。这个配置在 UFS 3.1 上跑,能看出 Write Booster 开和关的差异。
; fio 配置文件:ufs_rand_test.fio [global] ioengine=libaio direct=1 runtime=60 time_based=1 group_reporting=1 filename=/dev/ufs_block0 bs=4k iodepth=32 numjobs=4 [rand_read] rw=randread stonewall [rand_write] rw=randwrite stonewall这个配置里,iodepth=32 对应 UFS 3.1 的命令队列深度,numjobs=4 模拟多线程并发。bs=4k 是随机读写的典型块大小。direct=1 绕过页缓存,测的是真实设备性能。跑的时候用fio ufs_rand_test.fio,输出里重点看 iops 和 clat 的 99 分位。如果开了 Write Booster,randwrite 的 iops 应该明显高于关闭时。但注意,跑完 60 秒后,Write Booster 缓冲可能已经满了,后面的写入会掉速。所以我会把 runtime 设长一点,比如 300 秒,观察性能拐点。
5.2 用 ftrace 看 UPIU 的延迟分布
fio 只能看到块设备层的性能,看不到 UPIU 层的延迟。要深入分析,可以用 ftrace 抓 UFS 驱动的函数调用。Linux 内核里 UFS 驱动有 tracepoint,可以记录每个 UPIU 的发送和完成时间。
# 开启 UFS tracepoint echo 1 > /sys/kernel/debug/tracing/events/ufs/enable # 开始抓取 echo 1 > /sys/kernel/debug/tracing/tracing_on # 跑一次 fio 测试 fio ufs_rand_test.fio # 停止抓取 echo 0 > /sys/kernel/debug/tracing/tracing_on # 查看结果,按 Task Tag 分组统计延迟 cat /sys/kernel/debug/tracing/trace | \ awk '/ufs_cmd/ {print $NF}' | \ sort | uniq -c | sort -rn | head -20这段脚本的逻辑是:开启 UFS 的 tracepoint,跑测试,然后从 trace 输出里提取命令信息。参数说明上,tracepoint 的格式每个内核版本可能略有不同,但一般会包含 Task Tag、UPIU 类型和时间戳。实际分析时,我会把 trace 导出到文件,用脚本算每个 Task Tag 从 COMMAND 到 RESPONSE 的时间差。如果某个 Tag 的延迟明显高于其他,说明那个命令遇到了重传或者设备内部处理慢。这个信息比块设备层的延迟更有价值,因为它能定位到具体是哪个 LUN、哪个操作类型出了问题。
5.3 一个容易被忽略的细节:LUN 配置影响并发性能
UFS 3.1 支持多个 LUN,每个 LUN 可以独立配置。很多项目里,所有数据都放在 LUN 0,结果并发读写的时候,LUN 0 成了瓶颈。JESD220E 允许把不同的 LUN 分配给不同的用途,比如 LUN 0 给系统,LUN 1 给数据,LUN 2 给缓存。这样并发访问时,设备内部可以并行处理不同 LUN 的命令。
配置 LUN 需要在初始化阶段写 Configuration Descriptor。具体字段包括 Number of LUNs、LUN Enable、Boot LUN ID 等。写完之后,主机需要重新枚举 LUN,块设备层会看到多个块设备节点。实际测试时,我会把随机写放到 LUN 1,随机读放到 LUN 2,对比单 LUN 和双 LUN 的并发性能。通常双 LUN 能提升 20% 到 30% 的吞吐量,因为设备内部的闪存通道可以并行。
这个细节在 JESD220E 里没有直接说“这样配性能更好”,它只定义了字段和合法值。怎么配是工程经验。我自己的习惯是:系统盘和数据盘分开,缓存盘单独一个 LUN,并且把 Write Booster 配到缓存 LUN 上。这样系统盘的读写不会被缓存刷新影响,数据盘的持续写入也不会把缓存冲爆。
5.4 验证 Write Booster 是否真的生效
开了 Write Booster 之后,怎么确认它真的在工作?不能只看 fio 的 IOPS,因为 IOPS 高也可能是缓存命中。我一般会读两个属性:bWriteBoosterBufferLifeTimeEst 和 dNumWriteBoosterBufferAllocUnits。前者表示缓冲的剩余寿命,后者表示已分配的单元数。如果 Write Booster 在工作,写入数据后,LifeTimeEst 会下降,AllocUnits 会变化。
另外,可以发一次 fWriteBoosterBufferFlush,然后观察 flush 前后的性能差异。flush 之前,随机写 IOPS 高;flush 之后,如果继续写,IOPS 会掉到 TLC 水平,因为缓冲被清空了。这个对比能直接证明 Write Booster 在起作用。
最后说一个我自己的教训:早期做 UFS 调优的时候,我只看跑分,不看温度。结果在高温环境下,Write Booster 因为降频提前关闭,性能比不开还差。后来我养成了一个习惯:任何性能测试都同时记录温度,用sensors或者热成像仪都行。温度超过 60 度的时候,所有 UFS 性能数据都要打问号。这个习惯帮我避开了很多次误判。希望帮到你。
本文还有配套的精品资源,点击获取