日期:2026-08-15
设备:小米 13(HyperOS / Android 17 beta),KernelSU root,数据分区 f2fs
结果:约 110.7GB、242,600 个文件、30,100 个目录,100% 恢复,零覆盖、零删除
一、现象
系统升级到 Android 17 后,/storage/emulated/0(内部存储)里几乎所有旧文件”消失”,只剩Android、DCIM、Download、MIUI、Pictures 等升级后应用重新生成的空目录。
而所有旧文件其实都在:
/storage/emulated/uncasefolded/0/
uncasefolded 是 vold 的内部目录(media_rw 权限),普通应用和文件管理器看不到它,
所以看起来像”文件全没了”。
二、根因
1. 大小写不敏感存储(casefold)迁移
Android 16/17 起,内置存储改为大小写不敏感(目录带 F casefold 标志,可用lsattr 查看)。系统迁移时会把无法共存的大小写冲突文件搬进/storage/emulated/uncasefolded/。
本次升级中迁移异常(疑似小米 beta 的迁移中断/有 bug),把主用户几乎全部数据
搬进了 uncasefolded/0/ 却没有搬回。vold 迁移进程事后处于空闲状态,不会自动恢复,
需要手动搬回。
2. f2fs 项目配额(projid)——恢复时的拦路虎
手动搬回时,rename 报 EXDEV (errno 18, Cross-device link):
- 升级后应用新建的
Android/data/<包名>目录带应用专属 project ID(如20262),
旧数据的 projid 是0; - f2fs 内核禁止跨 projid 的 rename,root 也无法豁免;
- 注意:
toybox mv遇到 EXDEV 会静默退化为”复制+删除”,容易误以为 rename 成功。
3. 与 Magisk / KernelSU 模块无关
排查确认 /proc/mounts 挂载表干净,无模块干预痕迹。EXDEV 是文件系统层的
项目配额限制,Mountify 等挂载类模块不影响 rename 语义。
三、诊断命令
# 1. 看两侧内容
su -c 'ls -la /data/media/0/ /data/media/uncasefolded/0/'
# 2. 看目录的 projid 和 casefold 标志(F)
su -c 'toybox lsattr -p -d /data/media/0/Android/data/某包名 /data/media/uncasefolded/0/Android/data/某包名'
# 3. 确认没有可疑挂载点(排除模块/挂载因素)
su -c 'grep -E "uncasefolded|Android/data" /proc/mounts'
# 4. 测试跨 projid rename(预期 FAIL errno=18)
su -c 'touch /data/media/uncasefolded/0/.t; mv /data/media/uncasefolded/0/.t /data/media/0/Android/data/某包名/files/.t'
# (mv 可能"成功",那是因为它自动退化成复制;用 strace/python 测试才是真相)
# 5. 验证"临时清零 projid 后可以 rename"(关键技巧)
su -c 'toybox chattr -p 0 /data/media/0/Android/data/某包名/files
mv /data/media/uncasefolded/0/.t /data/media/0/Android/data/某包名/files/.t
toybox chattr -p 原值 /data/media/0/Android/data/某包名/files'
四、恢复方案设计(数据安全第一)
- 先做全量清单:两侧目录的「相对路径 + 类型 + 大小」落盘留档,事后逐条核对;
- 只做原子无覆盖搬移:用
renameat2(RENAME_NOREPLACE),目标已存在时内核拒绝,
杜绝覆盖;绝不使用会覆盖的mv/cp; - 大小写冲突改名保留双方:从 uncasefolded 搬入的冲突项改名
xxx (uncasefolded)(重名再加数字),两边都保留; - EXDEV 处理:临时把源/目标父目录 projid 清零 → rename → 结束后恢复原值;
待恢复列表持久化到pending_projid.tsv,中途断电/被杀,下次运行会先恢复; - 兜底:真·跨挂载点(如
.siexternal)无法 rename 时,文件走
「复制→校验大小→fsync→无覆盖改名→删源」,目录走「重建→递归」; - 结束逐文件校验:每个源文件都能按映射表解析到目标路径且大小一致、
uncasefolded剩余文件数为 0。
同分区搬移全部是 rename,瞬间完成、不占额外空间,权限/时间戳/SELinux 上下文
原样保留。
五、完整脚本 restore_merge.py
脚本本体约 14KB,作为附件提供,正文不再贴全文:
restore_merge.py子命令:manifest 生成两侧清单、merge 合并、verify 校验、chattr 补 casefold 标志;
具体用法见下一节。
六、使用步骤
# 0. 前提:root(su),python3
# 把脚本放到 root 可执行的位置
su -c 'cp restore_merge.py /data/local/tmp/ && chmod 700 /data/local/tmp/restore_merge.py'
# 1. 生成两侧清单(必须在 merge 之前做,用于校验)
su -c 'python3 /data/local/tmp/restore_merge.py manifest'
# 2. 执行合并(可重复执行,幂等;中断后重跑即可)
su -c 'python3 /data/local/tmp/restore_merge.py merge'
# 3. 校验(期望:src 剩余文件数=0、problems=0、原dst文件消失=0)
su -c 'python3 /data/local/tmp/restore_merge.py verify'
# 4. (可选)给能加 F 标志的目录补 casefold 标志
su -c 'python3 /data/local/tmp/restore_merge.py chattr'
Termux 里 python3 的完整路径是
/data/data/com.termux/files/usr/bin/python3;
其他环境直接用python3。
七、本机实测结果
| 项目 | 结果 |
|---|---|
| 源文件总数 | 242,600(约 110.7GB) |
| 搬回并校验通过 | 242,600 / 242,600 ✅ |
| uncasefolded 剩余文件 | 0(只剩 4.2MB 空目录壳) |
| 原有新文件丢失 | 0 |
| 大小写冲突改名保留 | 1,542 处 → xxx (uncasefolded) |
| 临时清零的 projid | 172 个目录,全部恢复原值 |
| 迁移期间应用新写入的文件 | 43 个(EXTRA,属正常) |
verify 报告中唯一一条 SIZE-MISMATCH 是相册应用正在使用的 SQLite -wal
日志文件被应用正常截断所致,并非数据丢失。
八、收尾
# 1. 重启一次手机,让媒体库重新扫描索引(相册/音乐等应用重新"看到"文件)
# 2. 查看所有因大小写冲突而改名的文件(双方都保留,自行取舍合并)
find /sdcard -name "* (uncasefolded*" -print
# 3. 确认一切正常后,删除 uncasefolded 空壳(建议先观察几天)
su -c 'rm -rf /data/media/uncasefolded'
九、注意事项
- 恢复全程只做 rename(同分区搬移),不复制、不占额外空间、瞬间完成,
权限/时间戳/SELinux 上下文原样保留; - 升级后应用新建的文件和目录不会被覆盖,原样保留;
- 冲突改名的文件双方都在,没有任何数据被删除;
- 大量旧目录无法加
F(casefold)标志,因为内核要求空目录才能启用;
这只影响大小写查找语义,不影响正常访问; merge中途被杀/断电:直接重跑即可(幂等,且会先恢复上次未还原的 projid);- 执行前建议先看
vold是否还在做迁移(su -c 'top -b -n1 | grep vold'),
确认空闲再动手。
十、进阶:为旧目录启用 casefold(大小写不敏感)
为什么恢复后很多旧目录加不上 F 标志
F 标志 = 目录级大小写不敏感。内核只允许「空目录」或「升级后新建的目录」
直接 chattr +F;旧目录里的目录项(dentry)是启用 casefold 之前创建的旧格式,
直接翻转会报 Directory not empty,即使目录里只有文件。
实测还发现两个关键点:
- 危险:目录里若已有大小写重名文件(如
A.txt/a.txt),直接chattr +F
会让其中一个文件”隐身”(无法再访问)!所以必须先查重名,有重名的目录整体跳过。 - 跨目录 rename 的 EXDEV:内核检查「源 inode 的 projid == 目标父目录的 projid」。
应用创建的文件本身带应用 projid(如 30463),搬到 projid=0 的暂存目录会被拒。
解法:临时清零源 inode 的 projid,rename 后恢复(文件和目录都可chattr -p)。
转换方案
对每个无 F 的旧目录:搬空到同分区暂存目录 → chattr +F → 搬回
(搬回时内核按新格式重建目录项)。每目录独立事务,搬出失败自动回滚;
崩溃/中断后下次运行自动从日志配对找回滞留的暂存目录。
完整脚本 normalize_casefold.py
脚本本体约 10KB,作为附件提供:
normalize_casefold.py用法:su -c 'python3 /data/local/tmp/normalize_casefold.py'
实测结果
- 第一轮:转换成功 20,684 个目录,失败 274 个(均为应用文件带 projid 的 EXDEV,
数据完好、自动回滚);中断一次后自动找回 130 个滞留暂存目录,零丢失 - 第二轮(修复源 inode projid 清零后):144 个全部成功,失败 0
- 最终仅剩 5 个目录未转换(内含大小写重名文件,为防”隐身”按设计跳过):
cn.kaiheila/files、com.zongheng.reader/files、com.tencent.hunyuan.app.chat/files、com.tencent.lolm/files、com.tencent.qqmusic/files - 若想转换这 5 个目录,需要先手动改掉其中的大小写重名文件(双方都在,可自行取舍)
常用命令
# 查看目录是否已启用 casefold(含 F 即已启用)
su -c 'toybox lsattr -d /sdcard/某目录'
# 查看跳过的重名目录里有哪些冲突对
su -c 'ls /data/media/0/Android/data/cn.kaiheila/files | sort -f | awk "{n=tolower(\$0); if(seen[n]++) print}"'
十一、为什么 Android 16 没事、升级 17 才出事(根因溯源)
结论先行
Android 16 上小米通过内置的 casefolding_remover 服务关闭了大小写不敏感存储(casefold),
所以一切正常;Android 17 开始该特性被强制启用,升级时系统执行了启用迁移(数据搬进uncasefolded),随后小米的兼容服务尝试把它”关回去”,但禁用失败(Disabling failed),
数据卡在 uncasefolded 里没有搬回。
证据
本机系统属性(su -c getprop):
| 属性 | 值 | 含义 |
|---|---|---|
external_storage.casefold.enabled |
1 |
casefold 被启用 |
ro.casefolding.adjusted |
1 |
启用迁移已执行 |
persist.sys.casefolding.status |
Disabling failed |
小米随后尝试”禁用”casefold,失败 |
init.svc.casefolding_remover |
stopped |
服务跑完即停(oneshot),没有恢复动作 |
服务本体 /system/bin/casefolding_remover(Rust 实现,对应 AOSP 标准接口android.os.casefoldingremover.ICasefoldingRemover),从二进制字符串可以看到它的
状态机与错误路径:
Enabled / Enabling / Disabling / Disabled
Enabling failed / Disabling failed
Failed to remove case
Attempting to move folder to dest (does not change casefolding)
persist.sys.casefolding.status
external_storage.casefold.enabled
persist.sys.casefold.enabled.override
时间线还原
- Android 16:casefold 被 Xiaomi 关闭(兼容自家应用),存储保持传统大小写敏感
- 升级 Android 17:casefold 强制启用,系统执行启用流程——把
/data/media/0数据
整体搬进uncasefolded暂存、给目录打F标志 casefolding_remover按小米策略尝试禁用 casefold(改回旧状态),
中途失败(很可能败在本文第四/十章记录的同一个坑:f2fs 内核拒绝跨 projid
的 rename,它的搬回操作没有兜底)- 失败后系统彻底放弃:
uncasefolded/0的 mtime 停在升级当天 00:29,
00:34 起应用开始重建空目录,vold 全程空闲——与观察完全吻合
与 root / Magisk 模块无关
挂载表干净、无模块干预痕迹;这是小米官方「启用→禁用」流程失败与 Android 17
强制启用 casefold 的组合结果。root 只是让我们得以手工完成系统没做完的迁移。
后续影响与建议
casefolding_remover每次开机都会再跑一次(init 启动项)。它目前仍会失败;
即使未来某次”成功”,也只会处理已空的uncasefolded壳、最多去掉部分F标志,
不会丢数据,无需干预- 当前状态(casefold 启用)与恢复后的文件系统状态一致,保持不动即可
- 若小米后续推送修复,
persist.sys.casefolding.status会变为正常值,可留意观察