做UFS开发这几年,我发现自己跟同事聊得最多的不是顺序读能跑到多少MB/s,而是一个看起来特别基础的话题:逻辑单元(Logical Unit)到底该怎么管。有一次调试,主机侧往LUN 0发了一个READ(16),设备直接返回CHECK CONDITION,日志里只剩一行看不懂的Sense Key。排查了半天才发现,那颗UFS的LUN 0被配置成了Boot LU,而我在Query Request里读回来的Unit Descriptor显示它的bBootLunID等于1——这颗芯片根本没有我想象中那种“默认LUN 0就是用户数据区”的布局。
这个经历让我意识到,UFS逻辑单元管理不是一个“看一遍规范就会”的知识点,它牵扯到协议层的地址映射、配置描述符的时序约束、厂商私有实现、甚至是维修工具在读全镜像时对LU寻址的理解差异。这篇东西我会从概念讲到底层配置,再穿插实际调试中踩过的坑,尽量做到既有体系、又能直接拿来用。
1. 逻辑单元不是“分区”,是UFS的骨架
1.1 从LUN字段讲起:为什么不能想当然发命令
UFS主机和设备之间是靠UPIU(UFS Protocol Information Unit)通信的,每个命令类UPIU的头部都有一个LUN字段。这个字段只有4个bit,理论上能表示0到15一共16个普通LUN地址。很多资料里说UFS支持最多8个LU(对应LUN 0到7),原因是早期的Device Descriptor里bNumberLU字段能表达的数和实际配置描述符数组长度受到约束,JEDEC规范里对配置描述符中的Unit Descriptor数量有明确上限,常见实现是8个,一些厂商扩展到更多。
但问题在于,LUN字段不只是普通地址。JEDEC为一些特殊功能定义了Well-Known LUN,它们用的是0x01、0xB0、0xC4、0xD0这类高地址。也就是说,你在初始化时如果只按LUN 0、1、2这种“顺序思维”去下发命令,很容易访问到完全不是你想操作的那个实体。
| 地址 | 类型 | 用途 |
|---|---|---|
| 0x00 - 0x07 | 普通LU | 常规数据LU,对应配置描述符里的Unit Descriptor |
| 0x01 | Well-Known LUN | Report LUNs,用来枚举当前使能的LU列表 |
| 0xB0 | Well-Known LUN | Boot LU,访问引导代码的入口 |
| 0xC4 | Well-Known LUN | RPMB,安全认证区域 |
| 0xD0 | Well-Known LUN | UFS Device,用于设备级控制和状态查询 |
调试的时候最容易犯的错就是把Report LUNs当成一个普通LU去读,或者把RPMB的0xC4当成普通分区地址去格式化。轻则命令失败,重则把不该碰的认证区写坏。
1.2 LU与LUN映射关系:设备内部如何找到目标
主机侧看到的是LUN,设备内部真正干活的是LU。硬件上,每个LU有独立的逻辑块寻址空间、独立的写保护属性和独立的坏块管理策略。所谓“独立”,不只是概念上的隔离,而是Flash Translation Layer(FTL)里每个LU有自己的一套逻辑到物理映射表,一个LU的GC(垃圾回收)不会直接去回收另一个LU的物理块。
设备收到UPIU后,会先解析LUN字段,然后通过内部的LU索引表找到对应的Unit Descriptor和FTL上下文。这个索引表在设备出厂或配置描述符变更时构建。所以“配置描述符改完后必须复位”不是厂商刁难人,而是因为索引表已经实例化,不重新初始化就不会按新配置重建。
给一个生活化类比:UFS芯片像一个小区,LU是里面的楼栋,LUN是门牌号,FTL映射表是每栋楼的住户登记册。你改了好几栋楼的用途,但物业的系统不重启刷新,管家还是按旧册子找房。这种情况下你命令发得再对,设备也不知道你找的是哪一户。
2. LU的“户口登记”:配置描述符与Provisioning
2.1 一个LU的完整“身份信息”需要哪些字段
UFS把LU配置放在Configuration Descriptor里,具体是其中的Unit Descriptor数组。主机可以通过Query Request去读写这些描述符。我这里挑几个实际开发中最常碰到的字段列一下,顺便说清楚它们各自管什么。
| 字段 | 含义 | 实操注意点 |
|---|---|---|
| bLUEnable | 该LU是否使能 | 置0后该LU不向主机暴露,但配置描述符里位置还在 |
| bBootLunID | 指定它是哪个Boot LU | 只能取0、1、2,且要与物理位置匹配,否则引导失败 |
| bLUWriteProtect | 写保护等级 | 0不保护,1是永久保护,2是上电保护,3是Set Session后保护 |
| dLUNum | 该Unit Descriptor对应的LUN编号 | 并不是数组下标就一定等于LUN号,需要以这个字段为准 |
| qLogicalBlockSize | 逻辑块大小 | 常见512B或4096B,修改前必须确认文件系统兼容性 |
| qLogicalBlockCount | 逻辑块总数 | 决定了LU容量,改小后可用空间直接缩水 |
| bBootLunID | Boot LU优先级 | 多个Boot LU时,引导部件按这个ID决定读取顺序 |
配置描述符的结构是“一个配置描述符头 + 若干个Unit Descriptor”。修改时先要把bConfigDescriptorUpdate置1,然后写整段描述符,写完以后再发命令让设备复位。这里的坑是:有些UFS设备支持在运行状态下直接更新配置并立即生效,有些则必须power cycle。你不能把某一颗芯片的“性格”套到所有芯片上,量产前一定要把目标厂商的datasheet翻清楚。
2.2 为什么改动LU配置后经常不生效
我遇到过不止一次“配置明明写进去了,重启后就是没变”的情况。排查下来通常是这三个原因之一:
- 没有做Device Reset或完整掉电。部分设备只响应软复位,你发的是UFS Reset,实际上只是链路复位,设备固件并没有重新解析配置描述符。
- 修改的字段超出操作权限。例如出厂已经设置了Permanent Write Protection,你再改bLUWriteProtect就无效,甚至整个配置描述符写入都会被拒。
- 没有先备份原配置描述符。设备对配置有校验和机制,如果你改动后长度对不上,或某个字段值非法,配置会被整体丢弃,设备继续跑默认配置。
第二种情况尤其常见。厂商为了防止量产后的误操作,会把一部分LU的写保护做成永久锁定。你回读描述符时看到bLUWriteProtect是1,还想改成0,那基本是白费力气。这时正确的思路是把数据写到别的没保护的LU,而不是去跟永久保护硬刚。
2.3 特殊LU:Boot LU、RPMB LU和Well-Known LU的边界
普通LU的事好查,特殊LU才是翻车重灾区。
Boot LU通常是UFS最前面的几个LU之一,要通过bBootLunID标记为0、1或2,引导代码放在这个LU的前几个block里。需要注意的是,Boot LU有一个“Boot Well-Known LUN”的访问入口(0xB0),也有普通LUN形式(比如LUN 0)。上电早期主机用Boot Well-Known LUN读取引导镜像,加载完再切成普通LUN访问整个LU。如果你把Boot LU配置在很靠后的位置,或者没有把bBootLunID设置成有效值,那SoC根本找不到引导代码,无限重启就是这么来的。
RPMB则是另一个极端,它独立迷你又安全。地址0xC4访问RPMB时,所有读写都要附带MAC校验,密钥存在芯片内部OTP区域。主机要先把密钥写入RPMB的Write Counter区域,之后每次读写都要用密钥算HMAC才能通过认证。普通LUN管理对它不起作用,它不参与容量统计,也不会出现在Report LUNs的正常LU列表里。最要命的是它的写计数器只能增加不能减少,如果你把RPMB写爆了,这颗芯片的RPMB区域基本就废了,只能换片。
3. 多LU方案设计:容量、性能与可靠性的平衡
3.1 典型产品里的LU划分实例
别看手机上显示一个“内部存储”,背后往往是好几个LU在协同工作。一个典型的嵌入式系统可能是这样划分的:
| LU | 存放内容 | 典型属性 | 为什么这么分 |
|---|---|---|---|
| LU 0 | Bootloader、TEE镜像 | bBootLunID=0,可启动 | SoC上电后最先访问,独立LU可以单独保护 |
| LU 1 | 系统分区、kernel、rootfs | 只读或半保护 | 系统坏了直接OTA整LU重刷,不影响用户数据 |
| LU 2 | 用户数据区 | 可读写,支持Trim | 用户频繁写删,独立LU避免GC干扰系统区 |
| LU 3 | 日志、cache等热数据 | 可精简配置或独立分区 | 写入频率高、生命周期短,方便单独回收 |
这样划分的核心逻辑是隔离故障域和隔离GC压力。用户频繁写删造成的碎片和垃圾回收,如果和系统分区混在一起,最直接的后果就是系统启动变慢、应用随机读写掉速。分LU后,每个LU的FTL映射表独立,GC只在用户数据LU内发生,系统分区的读性能基本不受到干扰。
3.2 WriteBooster与LU的绑定门道
UFS 3.1以后引入WriteBooster这个特性,本质是划出一块SLC缓存来加速写入。但WriteBooster Buffer不是凭空存在的,它通常映射到一个专门的LU上,或者是某一个LU内部预留的隐藏区域。厂商在配置描述符里会用类似dWriteBoosterBufferType、dWriteBoosterBufferSize这类字段来指定缓冲区的类型和大小,再通过设置让它与某个目标LU绑定。
很多人以为只要把WriteBooster开关打开,写性能就会立刻上去。实际调试中我发现,如果绑定的LU没有实际挂载文件系统,或者绑定的LU容量太小,WriteBooster会被频繁刷写,性能增益非常有限。还有一个细节是,WriteBooster Buffer在被主机访问到之前,要先通过一条专门命令激活。类似地,如果缓冲区本身被配置成只读,或者分配给它的物理块数量不足,控制器会退回普通TLC直写模式,这时你读WriteBooster状态寄存器是“已使能”,实测速度却很难看。
所以真正靠谱的做法是:配置完WriteBooster相关字段后,跑一轮全盘顺序写和随机写,把稳态写入速度拉出来看,而不是只看刚开始那几百MB的突发成绩。突发快不代表一切,稳态才决定体验。
3.3 Thin Provisioning:省容量,但别踩中隐藏的雷
Thin Provisioning(精简配置)是UFS支持的一种LU类型,简单说就是LU声明了一个很大的逻辑容量,但物理块是按需分配的。写入时才真正分配物理页,删除时通过UNMAP把块释放回FTL资源池。对日志类、缓存类数据,这个特性很实用,因为很多日志文件写一次就没用了,精简配置能让物理空间利用率大幅提升。
但坑也很明确。第一,如果文件系统不支持discard或没有周期性drop,那些“逻辑上删除”的块永远不会触发UNMAP,精简池会被耗尽,表现出来就是明明LU还剩几十GB逻辑空间,写入却直接失败。第二,Thin Provisioning的LU在掉电后恢复逻辑比较复杂,如果设备固件在崩溃恢复时没有处理好映射关系,可能出现逻辑块能读但物理页已经回收的尴尬状态。第三,生产环境里不要把Thin Provisioning用于系统关键分区,它更适合“丢了不心疼”的数据。
我个人的习惯是:日志LU可以用Thin Provisioning,用户不可再生数据一律用固定容量,宁可最开始多预留一点,也不冒写穿物理资源池的风险。
4. “UFS有没有Trim命令”——从UNMAP到Discard的实现真相
4.1 先纠正一个概念:UFS是怎么做Trim的
很多人从eMMC时代转过来,习惯性地问“UFS的TRIM命令是哪个opcode”。严格说,UFS协议层没有一个叫“TRIM”的命令,它继承了SCSI命令集,使用UNMAP命令来实现trim。到了UFS 4.0阶段,JEDEC又加入了更专门的Discard特性,在FTL里明确标记一个LBA范围的数据为无效,让垃圾回收知道哪些页可以直接回收。
手机系统层面,最常见的触发方式是文件系统周期性执行fstrim。运行fstrim -v /data,最终就是往底层块设备发一个discard请求。UFS驱动收到后把它翻译成UNMAP,再封装成UPIU发给UFS设备。所以下次有人问“UFS支持Trim吗”,答案不是简单“有”或“没有”,而是“它走UNMAP,UFS 4.0又进一步做成了显式Discard”。
4.2 一条fstrim命令的完整旅程
我想用一个更直观的方式把整条链路捋一遍:
- 用户在命令行执行
fstrim -v /data - VFS层遍历指定文件系统的所有空闲块区间
- 文件系统通过blkdev_issue_discard把这些区间打包成block层的discard bio
- UFS驱动识别到REQ_OP_DISCARD后,将bio的LBA和长度转换为UNMAP CDB
- CDB被塞进UPIU发给UFS设备
- UFS设备把对应逻辑块标记为可回收状态
- 设备FTL在随后的垃圾回收中优先回收这些物理块
全链路看起来很长,但数据面并不搬移用户内容,只传递“这些块没用了”的元信息,所以fstrim执行一般不会太久(除非驱动没实现discard,或者设备对UNMAP响应异常慢)。
那为什么Trim对UFS这么重要?因为闪存不能原地覆盖。写入新数据前,必须先擦除物理块。如果FTL不知道哪些页是“死”的,垃圾回收就只能把整块有效页搬走、无效页丢弃。有了Trim,FTL能精确识别死页,减少搬移量,降低写放大,同时让空闲块保持在健康水位。
提示:如果你的产品使用的是UFS 3.1及以下,又没有在挂载参数里开启discard,也不定期执行fstrim,那么删除大量文件之后,短时间内随机写入性能下降是非常正常的,不是“设备坏了”。
4.3 排查Trim不生效的三个方向
我自己遇到过“fstrim跑完一点效果都没有”的情况,最后是三板斧解决的:
| 排查方向 | 具体做法 | 典型原因 |
|---|---|---|
| 文件系统 | 查看mount参数是否带discard,或者确认有没有定时fstrim服务 | 挂载时没有discard,块层根本不会生成discard bio |
| 驱动层 | 抓trace看UFS驱动是否处理了REQ_OP_DISCARD并发出UNMAP | 旧内核或厂商裁剪驱动可能没实现discard转发 |
| 设备端 | 查询设备是否宣称支持UNMAP,响应是否带check condition | 部分低端UFS芯片的UNMAP实现有bug,需要固件修复 |
举个例子,某次抓trace发现驱动层确实收到了discard bio,但cdb却没发出去。查代码发现厂商把block层请求的discard粒度设成了对齐到erase block size,而文件系统传下来的bio sector数不足一个对齐单位,直接return 0。这种“假成功”最容易迷惑人,看起来操作正常,实际上一个UNMAP都没发出。解决方法是把设备max_discard_sectors和discard_granularity暴露给块层,让上层能正确合并请求。
5. 实践中最容易出问题的LU管理场景
5.1 新UFS上电后,读出来的容量总是不对
经常有人问我:标称128GB的UFS,上电后Linux只能看到110GB,剩下的空间去哪了?这不只是GB和GiB的进制换算问题。真正要做的是把每个使能LU的qLogicalBlockCount乘以qLogicalBlockSize,把所有LU容量加起来,再对比设备宣称的物理容量。两边差别通常来自这几个地方:
- WriteBooster Buffer占用的SLC区域,它可能不暴露给主机。
- 厂商保留块,用于替换出厂坏块和运行时坏块增长。
- 被
bLUEnable=0隐藏起来的LU,配置描述符里占着位置但不响应访问。 - RPMB和其他系统保留区域。
所以“容量对不上”不必惊慌,但在量产测试里要记录下来形成基线。如果你的产品对可用容量有最低要求,选型阶段就要把这些隐藏开销算进去,而不是光看标称值。
5.2 维修场景里“读UFS”的本质:直接对话逻辑单元
手机维修圈子里经常出现“蛋蛋读UFS”这种工具,它能直接读取UFS芯片内的分区镜像,用来备份字库、修复变砖手机。抛开工具界面花哨的部分,它做的事本质上就是在协议层绕过操作系统,直接对UFS的各个逻辑单元发起读请求,再按LUN顺序把数据拼成完整镜像。
这些工具面对的其实就是逻辑单元管理问题:哪个分区落在哪个LU的哪个偏移,哪些区域是Boot LU、哪些是用户数据LU、哪些是RPMB。维修人员口中的“全字库”备份,通常是把Boot LU和系统LU完整读出,其中包含的正是UFS的配置描述符、引导代码、文件系统等多层面的内容。
这里必须提醒一句:RPMB在维修工具里通常只能读计数器或做有限操作,普通接口无法直接读写RPMB数据,因为它有独立的认证体系。如果你手里的工具声称能绕过RPMB认证直接读内容,要么是针对特定芯片漏洞,要么就是唬人。维修操作最怕的就是往RPMB里写了一堆错误数据,这个区域一旦故障,常规手段难以恢复。
5.3 把普通LU硬改成Boot LU的教训
有一次我为了省事,把用户数据LU直接改成了Boot LU,想着“反正bBootLunID设成1就能从它启动”。结果上电后SoC根本没找到引导镜像,代码卡死在连接引导阶段。原因不复杂:Boot LU不仅在逻辑上要被标记成可引导,它的物理摆放位置还有讲究,通常必须占用起始几个LU,而且引导代码要放在LU的特定偏移处。SoC的Boot ROM只会按固定顺序去探测Boot LU,你随便指定一个后面的LU,它根本不会去。
后来我的做法是:Boot LU保持出厂默认,绝不到量产后再去动。如果非要在新设计里自研分区布局,也要先确认SoC的Boot ROM支持从哪个LUN引导、对Boot LU有什么约束,再让UFS厂商配合把配置描述符一次性生成好,尽量避免通过软件在量产阶段去改。
还有一点值得强调:在生产测试过程中修改配置描述符,务必保留一份出厂时的完整配置描述符镜像。一旦后续操作把配置搞坏,可以用烧录器或厂商工具把原配置刷回去。我见过有人把整颗芯片的配置描述符清零后没有备份,结果只能回炉重写文件系统,数据和引导代码全丢,代价非常大。
如果让我总结一条最宝贵的经验,就是:拿到一颗UFS芯片,第一件事不是跑benchmark,而是把Device Descriptor和Configuration Descriptor完整读一遍,存档留底。每次改动配置之前先备份,改完以后不要急着复位,回读验证一遍字段没问题再进行power cycle。这套习惯救过我很多次,比任何调试技巧都实在。