最近帮一个客户调试UFS3.1量产问题,卡在写入性能上整整三天。逻辑分析仪抓出来的波形看起来没问题,链路训练也过了,读写命令都能正常收发,可顺序写就是上不去标称值。后来翻协议文档才发现,问题出在Write Booster的配置上——那个SLC缓存区域的阈值设得太保守,触发回写太频繁,性能全耗在搬运上了。这种坑,光靠经验猜还真猜不出来,必须回到协议本身找答案。
这套UFS3.1协议中文学习讲解已经做到第5期了。前几期我们聊过UFS的基本概念、接口演进、器件初始化和链路层的东西,本期我想换个视角,不按协议文档的章节顺序走,而是顺着一条真实的读写数据通路,把命令怎么发、数据怎么搬、队列怎么调度、性能怎么挖这些实务问题串起来讲一遍。适合正在做UFS驱动、存储固件或者存储测试的朋友参考,也适合想从整体上理解UFS协议是怎么工作的同学。我尽量少贴大段英文规范原文,多用流程图式的描述和实际调试经验来说事。
1. 先画一张全景图:一次读操作到底经过哪些层
很多刚接触UFS协议的朋友最容易犯的错,就是一头扎进某个协议层的细节里出不来,比如盯着M-PHY的电气参数看半天,或者死抠UniPro的流控字段,却不知道这一层在整个数据通路里扮演什么角色。我这里建议先建立整体感。
一条完整的UFS 3.1数据通路,从主机端往下数大概是这样的:应用层(文件系统/块设备层)发起的读写请求,先经过UFS驱动框架,转成标准的SCSI命令;这些命令被包进UPIU(UFS Protocol Information Unit),交给UTP层;UTP层通过UR(Transfer Request)描述符把命令和数据地址告诉控制器;控制器再通过UniPro协议栈把UPIU打包成M-PHY物理层可以传输的串行信号;最后是M-PHY在差分线对上做高速串行传输。
往回走的方向也一样:设备端的存储介质(闪存)把读取的数据返回给设备控制器,设备控制器把数据封装成数据UPIU,再经过UniPro的链路层和传输层,回到主机内存。
这个分层模型,用个不太严谨但好理解的类比:UFS协议像一套快递系统。SCSI命令是包裹上的面单,写着“我要读哪个逻辑块”;UPIU是标准快递箱,什么样的内容都按这个箱子封装;UniPro是运输网络,负责在链路层做路由和可靠投递;M-PHY是拉货的重卡,决定运输速度上限。
为什么要分层?因为每一层解决不同的问题。M-PHY只关心信号怎么在物理线上传得又快又稳,不关心传的是什么内容;UniPro负责把不可靠的物理信号变成可靠的字节流,做的是纠错和重传;UTP关心的是主机的命令队列怎么组织、数据DMA到哪块内存;而UFS应用层(UFS Application Layer,UAL)则负责把SCSI命令映射成UFS设备能执行的操作。有了这种分层,任何一个环节升级都只影响相邻层,协议演进才可能向前兼容。
UFS3.1协议里的关键特性,比如HS-G4高速模式、Write Booster、DeepSleep省电、HPB主机性能增强,本质上都是落在不同协议层上的优化手段。有的是改物理层速率,有的是改命令调度策略,有的是改设备端缓存管理,理解这一点,你就知道排查问题的时候该去哪一层找原因。
2. UPIU格式与命令下发:主机到底给设备发了什么
2.1 一个标准的命令UPIU长什么样
命令下发是UFS通信的第一步。主机想读某个LBA(逻辑块地址),先要构造一个命令UPIU。这个UPIU,整个UFS协议里最核心的报文结构,没有之一。不管你是做驱动还是做固件,早晚要跟它打交道。
一个命令UPIU分为几段。最前面是基本的Header字段,包括:
Transaction Type:标识这是一个命令UPIU(type=0x01),还是数据UPIU(0x02)、响应UPIU(0x03)等。这一项相当于快递箱上的“货物类型”标签。Flags:携带一些特殊标志位,比如是否要求设备在部分完成时返回状态。LUN(Logical Unit Number):目标逻辑单元号。UFS设备可以分成多个LU,比如把系统分区、用户数据分区、缓存分区放在不同LUN上。Task Tag:任务标签,8位,用于关联命令和对应的响应。这相当于快递单号,主机和设备靠它对得上号。Expected Data Transfer Length:期望传输的字节数,告诉设备这次操作涉及多少数据。
紧随Header之后的是CDB(Command Descriptor Block),也就是SCSI命令本身。以最常见的READ(10)命令为例,CDB里包含操作码0x28、LBA起始地址、传输块数等信息。UFS设备内部实际上跑着一套SCSI命令处理器,发送给UFS设备的命令本质上就是SCSI命令,这是很多做嵌入式开发的朋友一开始容易忽略的——UFS不是一个全新的命令体系,它在命令层跟经典的SCSI是兼容的。
一个命令UPIU里还可能有额外的Segment,比如写命令要携带将要写入的数据。但这里有个细节:UFS协议里,写数据不一定要跟命令UPIU一起走。设备可以通过数据UPIU单独接收写入的数据,命令UPIU和后续的数据UPIU用Task Tag关联。对驱动来说,使用哪种方式通常由实现决定,但绝大多数情况下,写命令伴随的数据是通过独立的数据UPIU分段传输的。
2.2 响应UPIU与命令完成
当设备执行完命令,会返回一个响应UPIU。响应UPIU里最重要的字段是Response(0x01代表成功,非0值代表任务管理和错误信息)和Sense Data(感知数据,即SCSI的状态信息)。
这里要单独提醒一个坑:响应UPIU返回成功,并不代表数据一定正确落盘了。在开了Write Booster或设备有写缓存的情况下,命令完成可能意味着数据进了SLC缓存,而不是真正写入了TLC/QLC主存储区。这在后面第4节我会专门展开讲。
响应UPIU还包含一个Status字段,用来标识SCSI命令的执行状态(Good、Check Condition、Busy等)。如果设备返回Check Condition,主机就要再发一个REQUEST SENSE命令去获取详细的错误信息。这种“二次确认”的交互逻辑跟SCSI一模一样,习惯了SATA/NVMe的朋友可能需要适应一下。
我再补充一个实操中经常遇到的现象:设备返回Busy状态时,驱动不能立刻重发命令,必须等设备通过Unit Attention机制通知主机“我准备好了”。很多初学UFS的朋友一看到Busy就直接重试,结果加重设备负载,反而拖慢恢复。正确做法是读取设备的状态寄存器,确认设备退出了忙状态再重发。
3. 队列调度与数据传输:SQ/CQ和PRD的配合
3.1 命令队列的入口:Submit Queue和Completion Queue
如果只有一个命令在跑,UFS的性能优势根本发挥不出来。UFS3.1引入的是多命令并行机制,主机可以同时下发最多32个命令(UFS 3.1规格里队列深度为32),设备端异步处理。
这就要用到队列机制。协议里定义了两类关键队列:Submit Queue(SQ,提交队列)和Completion Queue(CQ,完成队列)。SQ是主机用来存放待执行命令UR描述符的队列,CQ是设备用来存放命令完成信息的队列。两个队列都不是设备内部的概念,而是通过主机内存中的环形缓冲区实现的。
驱动的工作流程大致是:
把命令UR写入主机的SQ中,SQ的具体地址通过UFS控制器寄存器配置。
写Doorbell寄存器(门铃寄存器),相当于告诉设备“我有新命令了,快来看看”。
设备收到门铃,从SQ中取出命令,开始执行。
设备执行完,把完成信息写入CQ,并更新CQ的门铃或者直接产生中断。
宿主收到中断后,从CQ中读出完成信息,释放对应的命令槽位。
这里有几个设计细节值得说道。Doorbell寄存器是整个队列机制的核心,主机每写一次Doorbell,就表示SQ中新增了多少条待处理命令。设备侧通过CQ的Doorbell来判断主机是否已经取走了完成记录。两边各管各的,谁都不直接写对方的队列内存,这避免了主机和设备同时访问同一块内存的竞争问题。
另一个细节是UFS的命令完成不一定每次都触发中断。UFS支持中断聚合(Interrupt Aggregation)机制,也就是多个命令完成之后才产生一次中断,减少CPU被打断的次数。这个机制在NVMe协议里叫Interrupt Coalescing,思路一样。对高IOPS场景来说,中断聚合能明显降低CPU占用,但副作用是单命令延迟会变高,所以实时性敏感的应用要谨慎开启。
3.2 PRD表:数据缓冲区怎么描述
命令队列解决的是“命令怎么排队”,但还没解决“数据怎么搬运”。一次读命令,设备要往主机内存的哪个地址塞数据?一次写命令,设备从哪个地址取数据?这个地址信息就靠PRD(Physical Region Descriptor)来描述。
每个PRD描述一段物理连续的内存区域,里面包含起始物理地址和数据长度。一次读或写操作的数据缓冲区,经常不是一段连续的物理内存,可能散在多处——比如文件系统页缓存里的一堆离散页。驱动需要组织一份PRD表(PRD List),把所有这些不连续的内存段首尾相接描述出来。设备拿到这个列表之后,就能通过DMA直接读写这些内存区域,不需要主机先拷贝合并。
PRD表在数据UPIU里通过数据段描述符引用。构造PRD表时最容易出的问题有两个:
地址没按对齐要求处理。UFS设备做DMA传输通常有对齐要求,比如32字节对齐。如果PRD里的起始地址不对齐,设备可能直接报错,或者在某些实现下性能骤降。
PRD表跨越了内存页边界但物理地址不连续。宿主驱动需要确保PRD表本身在物理内存中是连续的,否则设备访问PRD表时可能读到错误数据。很多UFS控制器的PRD表大小有上限(例如最多支持某个数量的PRD条目),大块数据传输时还要考虑拆分成多个传输请求。
3.3 流控机制:别把设备“灌满”了才想起刹车
队列深度32,数据通路可以并行,那是不是主机可以把一大坨数据疯狂往设备里灌?当然不是。
UFS协议在UTP层有流控机制,关键是设备端每个LUN都有收发缓冲区大小的限制。设备通过READY TO TRANSFER UPIU(RTT)来控制主机的数据发送节奏。以写操作为例,主机不能发了命令UPIU之后就自动把大数据UPIU连续发给设备,必须等设备返回RTT,告诉主机“我这边的数据缓冲区准备好了,你可以发这么多数据”。这个机制很像网络协议里的滑动窗口。
初调UFS驱动的人经常遇到的怪现象是:写性能远低于预期,命令队列明明深,但设备老是发RTT催数据。仔细观察会发现,要么是PRD表组织得太碎,导致设备每接收一小段就要处理一次;要么是主机端响应RTT的速度太慢,DMA启动有较大延迟。前者看驱动的缓冲区分配策略,后者就要优化DMA描述符的预置逻辑,把下一段数据的DMA提前准备好,等RTT一到立刻启动搬运。
读方向同理。设备返回的数据UPIU不能无限发,主机端必须有足够的缓冲区来接收。如果主机的接收缓冲区不足,协议里设计了OVERFLOW标志,设备发现宿主缓冲区容纳不下时,要么报错,要么按宿主的实际能力缩小传输长度。这块在使用小的数据缓冲池做读测试时特别容易踩到——明明读命令没问题,数据就是不对,抓包发现是宿主端缓冲区长度字段搞错了。
4. Write Booster和性能优化:把3.1版本吃透的关键
4.1 SLC缓存不是什么高深魔法
UFS3.1在性能上最核心的卖点就是Write Booster。本质很简单:TLC或QLC闪存直接写入慢,但SLC写入快。Write Booster就是把一部分闪存空间配置成SLC模式的伪缓存区,先把数据快速接住,再在后台慢慢搬到TLC区。
理解Write Booster的运作,需要明白几个关键配置项:
WriteBoosterBufferSize:SLC缓存区的大小,以LUN为单位配置。太小区间,大流量写入很快就把缓存打满,性能断崖;太大则浪费了主存储区空间,用户显性容量变小。WriteBoosterBufferLifeTime:SLC缓存区的擦写寿命占比。SLC模式擦写寿命比TLC模式高很多,但既然这块区域用来频繁写入,就需要监控它的损耗程度。WriteBoosterBufferFlushThreshold:触发后台回写的阈值。当SLC缓存区的数据占用超过这个百分比,设备启动将数据搬移到TLC区。这个阈值设得太小,回写频繁,性能波动大;设得太大,缓存容易写满,写满之后主机会被强制等待回写完成,这也就是用户感知到的“掉速”。AvailableSLCMemory:当前SLC缓存区剩余可写空间。这个值是可以实时读取的,做性能监控和状态排查很有用。
这些参数都通过Mode Select/Mode Sense命令读写,具体字段在UFS 3.1规格书的Write Booster章节里有详细定义。驱动在初始化的时候要主动查询设备支持不支持Write Booster,支持的话再配置使能。
4.2 回写带来的性能锯齿和排查方式
开了Write Booster,顺序写的性能曲线往往是锯齿状——前段飙高速,中段稳定,后段突然掉下来,过一阵子又恢复。这是SLC缓存满了之后,设备边接收边回写的典型表现。
真正的性能问题排查不能只看平均速度,要看瞬时速度和缓存水位。我常用的办法:测试顺序写的时候,周期性读AvailableSLCMemory,把性能曲线和缓存水位画在同一个时间轴上。如果掉速的时刻刚好是缓存水位冲到阈值附近,几乎可以断定是回写策略太激进;如果掉速时缓存还很空,那问题可能出在设备固件的垃圾回收策略上。
还有一个容易忽略的配置:当系统有多个LUN时,Write Booster是per-LUN配置的。比如数据分区开了Write Booster,系统分区没有,主机往不同分区写入时走的路径完全不同,性能特征也完全不同。性能测试之前先确认目标分区到底有没有启用Write Booster,这是最基础但最常踩的坑。
另外提醒一点:Write Booster的SLC缓存不是永久保存数据的地方。断电时,SLC缓存里的数据需要保证一致性,设备需要有能力在下次上电时做恢复。UFS3.1协议里有专门的掉电处理机制,固件侧要保证回写过程中突然掉电不会丢数据。做硬件测试的朋友可以关注一下掉电测试场景,观察设备对突然掉电的处理是否符合预期。
4.3 HPB功能:让随机读少走弯路
再看一眼UFS3.1另一个协议特性HPB(Host Performance Booster)。它解决的是UFS 3.1时代随机读性能不够的问题。UFS设备里的闪存映射表(L2P表)太大,设备内部SRAM装不下,就只能把部分映射表放在闪存里。一次随机读如果映射表不在SRAM,设备就要先进闪存读映射条目,再做真正的数据读,多了一次访问,延迟和IOPS都被拖累。
HPB的思路是:主机内存大,把一部分映射表信息缓存在主机侧,设备做随机读时可以直接从主机拿映射信息,省掉闪存读表的开销。协议通过专用的HPB命令和管理机制实现这一交互。
但HPB不是免费的:驱动要实现映射条目的缓存管理,还得维持缓存与设备实际映射的一致性。设备端映射表更新(比如垃圾回收导致物理地址变化)时,需要主动通知主机使相关缓存失效。任何一致性漏洞都会导致读回错误数据。所以HPB用起来比Write Booster复杂得多,目前不少方案的默认策略是保守地不开HPB。
如果你在做UFS性能优化,我建议先跑满Write Booster的收益,理顺队列深度和中断聚合的配置,再考虑HPB这种高阶特性。一上来把特性全打开,出了问题根本不知道是哪一层在拖后腿。
5. 常见问题与排查技巧实录
5.1 初始化阶段的链路训练失败
现象:UFS设备枚举不到,或者能识别厂商信息但是读写命令发不出去。
排查链路从物理层开始确认。M-PHY在速率协商阶段如果失败,表现为链路训练超时。问题通常出在:参考时钟频率不对、PCB差分走线阻抗不对、供电电压不稳。
再往上就查UniPro层。协议规定设备在链路启动后会进入配置阶段,主机需要发送特定的配置序列(比如DME_SET指令)来协商分频参数、流量控制参数。我见过最多的情况是主机配置的UniPro参数超出设备支持范围,比如把TxFcDepth设得太大。解决办法很简单:先把参数设为规格书里的默认值,一条条往上加,看哪项导致失败。
5.2 读写命令超时与中断丢失
Q:命令发出去很久没有完成中断,超时了怎么办?
A:首先确认Doorbell寄存器状态。如果Doorbell里还有未清的命令,说明命令还没被设备取走,大概率是设备端异常,需要复位设备。如果Doorbell已经清掉了但CQ没有完成记录,那就查中断有没有丢。很多控制器支持中断状态寄存器,驱动在超时处理里先读它,能直接区分“设备没收命令”和“设备收了命令但中断丢了”两种情况。
排查中断丢失时最容易忽略的是CQ的环形缓冲区头尾指针管理。须知,完成队列的Head/Tail指针由不同侧维护,驱动在处理完CQ条目后必须更新Doorbell,把指针推进的位置告诉设备。如果驱动漏更新Doorbell,设备可能认为CQ已满,不再提交新的完成信息,表现就是越收越慢,最后完全卡死。
5.3 眼看一堆协议热搜词,UFS的调试工具怎么选
这一阵子总看到大家在聊UART、SPI、CAN、MQTT这些协议,其实它们跟UFS不是一回事:UART/SPI是芯片间低速互联,CAN是车上设备通信的现场总线,MQTT是物联网应用层的消息协议。UFS是嵌入式存储的主机-设备接口协议,调试工具也完全不同。
做UFS协议分析,手头至少要有逻辑分析仪或者存储协议分析仪,推荐支持M-PHY和UniPro解码的型号。它能抓物理层波形,还能把UPIU层的信息解析出来,让你直接看到命令和响应交互过程。抓下来的转储文件重点看几个方面:有没有异常的RTT节奏、有没有高频率的重传、数据UPIU的长度分布是否合理、响应UPIU里是否频繁出现Sense Key=Aborted Command。
如果没有协议分析仪,可以退而求其次,在驱动里加打印。UFS驱动框架一般都提供了跟踪点,能记录命令的提交和完成时间戳。把这些时间戳整理出来,画出每个命令的生命周期,也能定位大部分问题。再不行,就靠设备端的寄存器读取来辅助判断,比如读设备健康状态、错误历史寄存器。但寄存器信息量有限,到了疑难杂症阶段,协议分析仪真的是必需品。
6. 这套协议,还有哪些可以往外延伸的方向
写到本期,UFS3.1的几个重点核心点基本过了一遍:分层的协议栈、命令UPIU的结构、SQ/CQ队列的调度、PRD描述的数据通路、Write Booster和HPB的优化逻辑、以及用协议分析仪做排障的思路。
如果顺着这个脉络继续往下走,后边还能展开的内容其实不少。UFS4.0已经支持了更大的带宽和LPB(Logical Block Provisioning)等新特性,对比着看更容易理解协议演进的逻辑;UFS与NVMe在命令模型上的差异,也值得做一篇专题;还有UFS在车载存储和AI终端里的应用场景,这些已经不只是手机的问题了。
我个人在多次调试里的体会是:读UFS协议文档,不需要逐页背诵,而是要“以问题为导向”去查、去对应。先弄明白数据从哪里来、到哪里去、经过什么环节,遇到性能瓶颈就知道去查哪一段,遇到兼容性问题就知道去查哪个字段。这套思路不管协议未来怎么演进,都能用得上。希望这一期对你有点帮助。