☰
嵌入式开发者必读:UFS 3.1协议栈分层与调试实战
2026/10/2 1:29:50 网站建设 项目流程

做了这么多年嵌入式,我见过不少从SPI、IIC、UART甚至CAN协议入门的朋友,第一次翻开UFS协议规范时的表情——上千页英文文档,分了四五层协议,命令还要套一层SCSI的外壳,确实吓人。2022年我因为项目需要调UFS 3.1底层驱动,硬着头皮啃完了JEDEC的UFS 3.1规范、UFSHCI接口文档,以及MIPI联盟的M-PHY和UniPro相关材料,中间踩了不少坑,也终于把整个协议栈从"各个层认识我"到"我也认识各个层"梳理通了。这篇文章算是我沉淀下来的中文学习笔记整理,按1~4四个部分展开:UFS 3.1到底解决什么问题、协议栈怎么分层、核心命令怎么流转、初始化调试有哪些坑。适合刚接触存储协议、想在手机或嵌入式平台做底层开发,或者单纯好奇手机闪存读写原理的朋友。

1. 为什么UFS 3.1值得单独拉出来学:从eMMC到UFS的演进逻辑

1.1 eMMC与UFS是两套完全不同的交通规则

很多人的认知还停留在"eMMC比UFS慢"这个结论上,但真正做底层的人必须清楚:它们不是同一类东西的简单升级,而是彻底换了一套通信模型。

eMMC本质上是并行总线,8根数据线、1根时钟线、1根命令线,命令和数据走不同的物理通道,但同一时刻数据线只能朝一个方向传输,属于半双工。协议栈很薄,控制器把命令、地址、数据按固定时序放在线上就行。这种设计实现简单、成本低,但天花板也很明显——并行线的信号同步在高频下很难做,所以eMMC 5.1的实际顺序读速度跑到400MB/s上下基本就到头了。

UFS则完全不同。物理层是M-PHY,采用串行差分信号,一条lane就是一对差分线,UFS 3.1设备通常有2条lane,可以同时收发数据,全双工。协议栈是分层的,从底到顶依次是M-PHY、UniPro、UTP(UFS Transport Protocol),最上面承载SCSI命令集。用大白话说,eMMC像是老式电话交换机,接线员手动把一条线接到另一条线;UFS更像一套完整的邮政网络,有分拣中心、有运输干线、有签收确认,每封信还要贴标准面单。

这就解释了为什么同样叫"闪存存储协议",eMMC和UFS的调试难度完全不在一个量级。你拿着示波器看eMMC的时序能判断问题,但UFS出问题时,你往往要先搞清楚是物理层信号差、链路层重传多,还是协议层命令超时。

特性eMMC 5.1UFS 2.1UFS 3.0 / 3.1
物理层8-bit并行总线M-PHY HS-G3M-PHY HS-G4
总线模式半双工双lane全双工双lane全双工
理论带宽约400MB/s双lane约11.6Gbps双lane约23.2Gbps
命令集私有命令SCSI命令SCSI命令
命令队列最多32最多32最多32
数据保护简单CRC链路层重传+CRC链路层重传+CRC

1.2 UFS 3.1的版本亮点不只有"快"

UFS 3.0把带宽翻倍,UFS 3.1在这个基础上又补了几个关键特性,其中有两个在实际产品里感知特别明显。

第一个是WriteBooster,中文领域常叫写加速器。原理不复杂:设备内部拿出一部分SLC模式的NAND块专门做写缓冲,主机写数据时先以SLC速度快速写入这块缓冲区,等设备空闲了再把数据搬移到TLC/QLC大容量区域。因为SLC写入比TLC快得多,尤其对随机小写入,性能提升非常明显。但注意,缓冲区不是无限的,如果持续写入把Buffer写满,速度就会掉回TLC直写的水平。厂商跑分很好看,实际连续大文件拷贝时掉速,根源往往就在这里。

第二个是DeepSleep深度睡眠。UFS设备工作状态很耗电,以前Sleep模式下功耗还是偏高,DeepSleep直接把设备内部大部分电路关掉,功耗能压到非常低的水平。代价是唤醒时间变长,主机需要重新建立链路才能发命令。手机息屏待机、平板挂后台这种场景,DeepSleep省电效果很可观,但如果在唤醒机制设计不完善时强行进入,反而会导致系统卡顿或命令超时。

另外还有Host Performance Booster(HPB)。它的思路是让主机侧内存缓存设备内部L2P映射表的一部分,这样随机读时设备不需要每次查大表,减少读放大和延迟。HPB启动时先给主机发映射数据,之后主机维护本地缓存,设备发生映射更新时再同步。这个特性对容量越大的设备收益越明显,但会占用主机内存,实际手机产品上是否开启、开多大,都是要权衡的。

Performance Throttling Notification(PTN)则是散热相关的机制:设备检测到温度过高或功耗异常时,会主动给主机发通知,主机可以据此调整负载策略。这个特性在手机上尤其重要,长时间跑分或者快充时,存储芯片发热导致性能下降,有PTN之后系统能感知到原因而不仅仅是"跑分变低了"。

1.3 与SPI/IIC/CAN这些老朋友有什么不同

如果你是从MCU开发转过来的,之前调过SPI、IIC、UART、CAN,我很理解第一次看UFS协议时的错愕。SPI是一条主时钟加几条数据线,主从之间收发数据,逻辑简单到一眼能看穿;IIC更简单,两根线,靠地址寻址,一主多从,有ACK机制;CAN是差分总线,有报文ID、仲裁、错误帧,但本质还是"大家挂在一条总线上抢着发消息"。

UFS完全不是这种玩法。它没有"总线"概念,是严格的主从点对点连接,Host和Device之间通过两条lane相连。它的可靠性不靠物理层保证,而是靠UniPro层处理重传、流控、拆包打包。它的命令不叫"寄存器读写",而是完整的SCSI命令,一条命令有明确的命令描述块、传输长度、数据缓冲区,甚至支持乱序执行和多个命令并行。

更直白的对比是:SPI/IIC/CAN就像小区门口的保安,看见熟人直接放行;UFS像国际机场海关,每个旅客都要过安检、登记、查签证、托运行李,虽然流程复杂,但能承载的旅客量和安全性远超小区门口。

所以学UFS之前要先调整心态:不要指望看完某个命令的时序图就能跑起来,你需要建立的是"分层定位问题"的思维方式。这也是我后面三个部分要重点展开的内容。

2. UFS 3.1协议栈整体拆解:UTP、UniPro、M-PHY三层各管什么

2.1 一个写文件请求从应用层到NAND的完整旅程

先建立一个全局视图,我们用"手机里把一张照片写入存储"这个场景来走一遍。

应用层调用文件系统,文件系统把逻辑地址转换成块设备层的LBA(逻辑块地址),然后通过Linux块设备层下发一个写请求。这个请求到达UFS Host Controller(也就是SoC里的UFS控制器)后,控制器把它封装成一个UFS协议信息单元,即UPIU,这是UTP层的核心概念。UPIU里包含了这条命令的类型、目标逻辑单元号、起始LBA、传输长度、数据缓冲区地址等信息。

UPIU不会直接上物理线。它先被交给UniPro层处理,UniPro层会把UPIU当成上层负载,进行拆分打包,加上自己的头部信息做路由和序号管理,同时实现流控和重传机制。再往下,M-PHY层把这些数据包变成高速串行差分信号,通过两条lane发送给设备。

设备端收到信号后,按相反的顺序解包:M-PHY还原比特流,UniPro层检查完整性、做重组,最后把UPIU递给UFS Device的UTP层解析,识别出这是一条SCSI WRITE命令。设备内部的Flash Translation Layer(FTL)负责把LBA映射到具体物理页,最终把数据写入NAND。写完后设备再按原路返回一个Response UPIU,告诉主机"写完了"。

整个过程听起来很长,实际上因为协议栈每层都是硬件流水化处理,真正耗时主要在NAND擦写本身。协议栈的开销相对于NAND操作时间来说占比很小,SSD和UFS的优化重点从来都不是协议层,而是怎么减少NAND操作次数。

2.2 UTP层:UPIU的格式与类型

UTP层是UFS协议栈里最靠近主机的部分,它的工作就是定义"主机和设备的对话单元"——UPIU。所有主机发给设备的命令、设备返回的响应、数据搬运,全靠UPIU承载。

按功能划分,UFS里最常见的UPIU有4类:

  • Command UPIU:主机向设备发送命令,里面装载SCSI命令描述块(CDB),是请求的发起者;
  • Data In UPIU:设备向主机返回数据,比如读命令读出的NAND内容;
  • Data Out UPIU:主机向设备发送数据,比如写命令要写入NAND的内容;
  • Response UPIU:设备回复命令执行结果,包含状态码、Sense Key等信息。

以Command UPIU为例,它的头部包含传输类型(Transaction Type)、任务标签(Task Tag)、逻辑单元号(LUN)、期望数据传输长度、数据方向等字段。任务标签非常重要,因为UFS支持最多32个命令同时在设备内部执行,主机下发每条命令时分配一个Tag,后续唤醒、汇报完成状态都靠这个Tag区分。你甚至可以理解为快递单号——很多包裹同时在运输,每个包裹都有独立单号,签收时才不会搞混。

关于UPIU有个细节值得注意:一个UPIU不一定只装一次数据。对于大数据块读写,协议允许把数据拆成多个Data UPIU传输,而Command UPIU只是先告诉设备"准备收一批数据"。设备端通过header里的传输类型和期望长度字段判断当前UPIU属于哪个命令、是不是最后一个数据包。这个机制和TCP的拆包重组很像,理解起来并不难。

2.3 UniPro与M-PHY:链路层和物理层的工作逻辑

UniPro层在协议栈里很容易被忽略,但它恰恰是UFS可靠性的基石。它负责三件大事:把上层UPIU做分段和重组、做链路层的流控和确认重传、维护链路状态(包括速率协商和休眠唤醒)。

你可以把UniPro想象成快递公司的干线物流网络。UPIU是装满货的集装箱,UniPro负责给集装箱编组、决定走哪条线路、沿途每一站点都要签收确认,丢了货要重新发。正因为有这么一层确认重传机制,UFS才敢在物理层全速跑而不怕偶发干扰——偶尔一个bit翻转,链路层会自动重传,上层毫无感知。

UniPro层有个叫DME(Device Management Entity)的东西,专门负责链路配置管理。比如初始化时双方要协商好速率档位、lane数量,这些参数都是通过DME机制配置的。链路建立起来后,如果要进低功耗状态,也是DME下发指令。

M-PHY是物理层,定义了电气特性、信号编码、差分传输参数。M-PHY有两种工作模式:PWM模式和HS高速模式。PWM模式速率低但功耗小,主要用于链路初始化和低速唤醒场景;HS模式才是真正传数据的模式,HS-G3每条lane约5.8Gbps,HS-G4每条lane约11.6Gbps。UFS 3.1设备用两条lane,理论总带宽在23.2Gbps左右。

这里有一个M-PHY的经典设计逻辑:设备上电后,链路不会直接冲到HS-G4最高速率,而是先用PWM模式建立一个基础通路,确认双方都活着、能通信,再逐渐往高速档位切换。这个过程叫Link Startup,很像两个人先靠对讲机喊话确认身份,再切换到光纤专线传数据。调试中遇到的"速率上不去"问题,绝大多数就出在这个逐级换挡过程里。

3. 命令机制:SCSI命令集与UFS Query的"管理通道"

3.1 为什么UFS选择SCSI命令而不是自己发明一套

UFS协议栈的最上层是UCS(UFS Command Set),说白了就是SCSI命令集。很多人不理解:明明是自己设备,为什么不搞一套精简命令,非要套上古SCSI?

原因很简单:SCSI命令集已经被验证了三四十年,覆盖了块存储设备的几乎所有需求——读、写、容量查询、缓存管理、TRIM、电源管理、写保护,每一条命令的语义都很明确。UFS直接复用SCSI命令,意味着上层操作系统、文件系统、驱动生态都能平滑迁移,不需要重新发明轮子。尤其在Linux内核里,块设备层本来就是用SCSI命令和底层交互的,UFS驱动可以顺理成章地融入SCSI框架。

UFS里常用的SCSI命令有这些:

命令功能对应场景
INQUIRY查询设备基本信息识别UFS设备型号、厂商
READ CAPACITY读取设备总容量初始化和分区工具
READ(10) / READ(16)读取数据正常读操作
WRITE(10) / WRITE(16)写入数据正常写操作
UNMAP解除逻辑块映射TRIM、删除文件后回收空间
SYNCHRONIZE CACHE强制脏数据落盘fsync、系统关机前落盘
TEST UNIT READY查询设备是否就绪启动阶段轮询

我在实际调试中观察到一个有意思的现象:很多人遇到UFS读写失败,第一反应是查PHY、查信号,但有一类问题是SCSI层返回了Check Condition,Sense Key是Write Error。这时候问题可能在NAND坏块管理、设备内部FTL,甚至缓存同步策略上。协议栈的好处就在这里,每一层都有明确的错误反馈渠道,你得学会从上往下逐层看状态。

3.2 Query命令:描述符、属性、旗标的三级管理机制

除了SCSI命令,UFS协议还定义了一套独立的"管理通道"——Query Request / Query Response UPIU。这套机制用来读设备信息、配置设备参数,相当于设备的"控制面板"。

Query机制里最重要的三个对象是描述符(Descriptor)、属性(Attribute)和旗标(Flag)。

描述符是结构化的数据块,相当于设备的"简历"和"体检报告"。比如Device Descriptor记录设备厂商、协议版本、支持的lane数;Geometry Descriptor记录设备的物理结构信息,包括有多少个逻辑单元、WriteBooster缓冲容量多大、每个LUN的块大小;Unit Descriptor描述每个逻辑单元的能力和配置,包括是否支持写保护、当前写保护状态等。初始化时,主机需要读这些描述符来决定怎么配置设备。

属性是单值的状态项,相当于设备当前的"仪表盘读数"。常见的有:设备当前电源模式(Active、Sleep还是DeepSleep)、Boot LUN是否使能、WriteBooster缓冲剩余空间等。属性分两种,一种只读(状态),一种可写(配置)。主机设置某些配置时,就是往对应属性里写值。

旗标是最简单的布尔量,相当于设备的"开关状态"。比如永久写保护使能标志、上电时写保护标志,还有Power On WP等。旗标的值要么是0要么是1,语义单一明确,适合表达二值化状态。

这三类信息都通过Query命令访问,对应的操作码有READ DESCRIPTOR、WRITE DESCRIPTOR、READ ATTRIBUTE、WRITE ATTRIBUTE、READ FLAG、SET FLAG、CLEAR FLAG等。实际操作中,我建议把这三个对象分开记忆:描述符描述"设备是谁、有什么能力",属性描述"设备当前怎么样",旗标描述"某个开关是否打开"。搞清楚这个分类,后面看UFS驱动代码时会轻松很多。

3.3 命令生命周期:读一次数据要过几道关

把前面所有机制串起来,我们完整走一遍"主机读LBA 1000开始的4KB数据"这条命令。

第一步,操作系统块设备层发来读请求,UFS Host Controller的驱动把这个请求构造成SCSI READ(10)命令,写入控制器的命令队列寄存器。控制器生成一个Command UPIU,分配一个空闲的任务标签,然后通过UniPro/M-PHY发送给设备。

第二步,设备收到Command UPIU后,解析出SCSI命令,把它交给内部的命令处理器。设备内部的FTL根据LBA找到对应的物理页地址,开始读NAND。这个过程可能是几个页并行读,也可能触发映射表查询,耗时取决于NAND介质和FTL优化程度。

第三步,设备把读出来的4KB数据打包成Data In UPIU返回给主机。如果数据量大于单个UPIU承载能力,会被拆成多个Data In UPIU连续发送。

第四步,数据传完后,设备发送Response UPIU,携带状态码表示命令执行成功。主机收到Response后释放命令队列深度,唤醒等待该命令的进程。如果没有出错,应用层就能拿到完整数据了。

整个过程里,主机和设备的每一次交互都对应一种UPIU类型,链路层还会对每个UPIU做确认和潜在重传。我在调试时习惯把一次完整的读操作想象成一次网购:下单(Command)、发货(Data In)、签收(Response),中间任何一个环节断了,问题都不在生产端,而在链路协调上。

另外,UFS支持命令队列,最多32条命令同时挂在设备端执行。也就是说上面的生命周期在设备端是并发的,多条读命令可能同时在不同的NAND plane上执行,最后按任务标签分别返回。这也是UFS并发性能远好于eMMC的原因,但代价是调试时面对的命令交织情况更复杂。

4. 初始化流程、调试方法与实战避坑

4.1 从设备上电到Ready:初始化步骤清单

拿到一颗UFS 3.1器件,第一次接上主控,老老实实走一遍初始化流程比什么都管用。完整的初始化可以分成几个阶段,我按实际操作顺序列一下:

  1. 上电,RESET信号拉低再释放,保证设备完成内部复位;
  2. M-PHY进行Link Startup,双向握手。先以PWM模式低速建立链路,然后逐级往更高速度档切换;
  3. UniPro层通过DME完成链路层配置,协商好版本、重传参数、流控窗口大小;
  4. 主机发送第一个UTP命令,通常是Test Unit Ready或者直接发Inquiry,确认设备已经能响应命令;
  5. 读取Device Descriptor,拿到厂商ID、协议版本号、支持的最大速率;
  6. 读取Geometry Descriptor和Unit Descriptor,获知设备容量、逻辑单元数量、每单元大小;
  7. 配置电源参数,确认设备进入Active模式并可正常收发数据;
  8. 如有需要,读Boot LUN或对指定LUN执行写保护配置;
  9. 下发标准SCSI命令集合(比如READ CAPACITY、INQUIRY给操作系统用),初始化完成。

如果是Linux环境,ufshcd驱动大体上也是按这个顺序做的,只不过驱动代码把很多配置封装到了函数里。我建议初学时就照着这个顺序读spec,每个步骤对应到spec哪一章,脑子里会清楚很多。

伪代码大概是这样:

power_on_and_release_reset() mphy_link_startup() // PWM G1 -> 协商到HS-G4 unipro_dme_configure() // 配置重传、流控、版本 send_utp_command(INQUIRY) // 探测设备响应 device_desc = read_device_descriptor() geometry_desc = read_geometry_descriptor() for each unit in geometry_desc.units: read_unit_descriptor(unit.lun) configure_power_mode(ACTIVE) notify_block_layer(device_ready)

4.2 踩坑复盘:UFS速率协商失败时的排查思路

我调试UFS 3.1时遇到最多的问题,就是设备识别不稳定或者速率只能跑在低速档。这种现象最开始很让人头疼,因为链路层报错并不直接告诉你是哪根线的问题。复盘一次典型的排查过程,按这个顺序能少走弯路。

第一个场景:上电后设备完全响应,但链路一直停在PWM模式,协商到HS模式失败。我先用示波器抓REF_CLK和M-PHY数据线的差分信号。看波形后发现REF_CLK幅度偏小,查硬件文档才意识到该平台的参考时钟触发条件比较严格,PCB走线过长导致信号衰减。这时候问题在硬件,软件怎么调都不行。

第二个场景:低速正常,高速档位偶尔成功偶尔失败,出现"时而飞起、时而龟速"。用寄存器看M-PHY的PCS错误计数,发现Error Counter在HS-G4切换时剧增。这不是信号质量问题,而是UniPro协议层配置的流控窗口和设备端能力不匹配。把DME里协商的接收能力参数改成设备端报告的数值后,问题消失。软件层一个配置项,影响链路稳定性,不看spec根本想不到。

第三个场景:系统休眠唤醒后,设备命令超时。排查确认设备进了DeepSleep,唤醒路径上主机没有先把链路重新Link Startup,导致第一批命令发到了还没醒过来的设备。解决办法是在唤醒流程里先做链路重训练,再发命令。这属于电源状态机设计问题,时序图里看着简单,真实现调起来最费时间。

我把这些问题的排查方式总结成一个表:

现象优先确认可能根因处理方向
链路完全起不来电源、复位、REF_CLK波形硬件信号问题示波器量波形,调整走线或驱动强度
低速正常高速不稳定M-PHY错误计数、UniPro重传统计信号质量或流控配置不匹配检查PCB阻抗,核对DME协商参数
唤醒后命令超时设备电源模式、唤醒时序链路未重训练补Link Startup流程,调整休眠策略
读写命令报Check ConditionUTP错误寄存器、Sense Key设备内部FTL或坏块问题用厂商调试工具读NAND状态

调试UFS,我的体会是:一定不要只看一个层次的表象。物理层问题会表现为协议层超时,协议层配置问题会表现为链路频繁重建,链路异常又会表现为上层命令排队堵塞。逐层排查、逐层确认,是唯一可靠的方式。

4.3 WriteBooster与DeepSleep必须知道的几个细节

WriteBooster是这个时代手机存储性能的关键,但很多人理解有偏差。首先它并不是默认开启的功能。设备端必须有WriteBooster Buffer LUN,主机要通过属性查询确认支持状态,还需要正确配置写入策略。其次,WriteBooster的收益集中在随机写入和突发写入,顺序大文件写入如果缓冲区很快填满,性能会大幅回落。实测表现就是:跑分软件连续写几个G,前面数据非常好看,后面突然掉到TLC直写水平。厂商宣传的"写入速度翻倍"严格说是"峰值写入速度翻倍"。

如果你在做存储性能测试,要留意WriteBooster的buffer耗尽行为和搬移策略。设备在空闲时会自动把Buffer里的有效数据搬移到TLC区域,腾出空间给后续写入。这个搬移过程会占用设备内部带宽,如果此时你发起下一轮测试,可能观测到延迟抖动。做自动化测试的时候,最好在每轮写入后留足够空闲时间,让设备完成搬移再测下一轮,不然数据很难复现。

DeepSleep的坑主要在唤醒路径。设备进入DeepSleep后,主机和它之间的链路实际上已经断开,所有队列里的命令都会超时。所以正确的休眠流程应该是:主机先确保所有命令完成、数据落盘,然后通知设备进入DeepSleep,最后再让平台进入低功耗状态。唤醒时,主机要先重新建立链路(做Link Startup),等设备确认退出DeepSleep,才能恢复IO。顺序反了,轻则唤醒慢,重则数据损坏。移动平台上的睡眠调试,用逻辑分析仪抓唤醒时序是最直观的。

4.4 学UFS 3.1推荐看哪些文档、用什么工具

最后聊一下学习工具。UFS 3.1协议本身由JEDEC发布,核心规范是JESD220E,主机控制器接口规范是JESD223E,MIPI联盟定义了M-PHY和UniPro。这几份文档加起来确实吓人,但不需要从头读到尾。我的建议是先从JESD223E开始,搞懂Host Controller一侧接口的作用,再读JESD220E里的UTP章节和Query命令部分,最后回来看M-PHY和UniPro的链路机制。顺序很重要,先宏观再微观,不然很容易陷在某个字段细节里出不来。

工具方面,有条件的话最好准备以下三样:

  • 逻辑分析仪或示波器,看M-PHY的差分信号、Link Startup时序;
  • 支持UFS协议解码的协议分析仪(不少厂商仿真器带这个功能),能直接看到UPIU内容、重传统计和命令失败原因;
  • UFS设备厂商的调试工具,读取设备内部FTL状态、NAND错误计数、WriteBooster搬迁进度,这些信息在标准协议层完全暴露不出来。

如果你在Linux环境调试,ufshcd驱动本身是个很好的教材。它的OPS结构体、UPIU收发路径、错误处理函数注释都写得比较清楚,配合内核的tracepoint,可以观测每条命令的生命周期。手边没有实际硬件的话,用QEMU跑Linux内核,观察UFS驱动的模拟初始化流程,也能建立基本概念,比裸看spec要快得多。

我在实际项目里还有一个习惯:把每一类UPIU的收发点在内核代码里打trace标记,然后把业务流程和spec对照着看。很多协议细节,比如"到底是Command UPIU先到还是Link Startup先完成",只看文档容易迷糊,在真实trace里一眼就能看出来。建议你也试试这个方法。

最后说回学习心态。UFS 3.1的难度不在某个具体命令,而在"多层协作"这件事本身。我的经验是:第一次接触时别追求背下所有字段,先跑通一次最简单的读操作,把每一层发生什么串成一条线,再回头查细节。这样比从头死磕规范效率高很多。后面有时间我再整理UFS 4.0和MIPI底层链路的笔记,到时候继续分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询