4、FUSE与sdcardfs:FUSE用户态文件系统原理、sdcardfs内核模块、存储模拟与权限桥接

好,咱们今天聊点硬核的。FUSE 和 sdcardfs,这两个东西在 Android 存储体系里,就像一对「双胞胎兄弟」。一个在用户态,一个在内核态。说白了,它们共同解决了 Android 最头疼的问题:怎么让普通 App 安全地访问 SD 卡

我个人习惯把 FUSE 比作「翻译官」,把 sdcardfs 比作「守门员」。翻译官负责把 App 的文件请求转译给内核,守门员负责检查权限。嗯,这个比喻可能不太严谨,但你先记住这个感觉。

4.1 FUSE 用户态文件系统原理

FUSE,全称 Filesystem in Userspace。名字已经说得很清楚了——文件系统可以跑在用户空间。这在 Linux 世界里是个颠覆性的设计。

传统文件系统(比如 ext4、f2fs)都跑在内核态。你写一个文件系统,就得写内核模块。内核崩溃了,整个系统就挂了。而且调试起来极其痛苦,我当年第一次编译内核模块,光配置环境就折腾了两天。

FUSE 的思路完全不同。它把文件系统的逻辑搬到用户态,内核只提供一个「通道」。你的文件系统代码可以像普通程序一样写、一样调试。崩溃了?重启进程就行,不会蓝屏。

核心流程:

  1. App 调用 open()、read()、write() 等系统调用
  2. VFS(虚拟文件系统)层拦截请求,发现是 FUSE 挂载点
  3. VFS 把请求转发给 FUSE 内核模块
  4. FUSE 内核模块通过 /dev/fuse 设备,把请求发给用户态守护进程
  5. 用户态守护进程处理请求,返回结果
  6. 结果原路返回给 App

你想想看,这个过程其实挺绕的。从用户态到内核态,再回到用户态,再回到内核态。性能肯定有损耗。但换来的是开发效率和安全性,值不值?我个人觉得,在 Android 场景下,非常值。

我在项目中遇到过一个问题:某个第三方 App 疯狂扫描 SD 卡,导致 FUSE 守护进程 CPU 飙升到 100%。后来发现是 App 在遍历目录时,每次 stat() 都触发一次 FUSE 请求。优化方案是让守护进程做了一层缓存,减少重复查询。

4.2 sdcardfs 内核模块

sdcardfs 是 Google 在 Android 4.4 引入的,后来在 Android 8.0 成为默认方案。它本质上是一个内核态的文件系统,专门为 SD 卡访问场景设计。

为什么要搞一个 sdcardfs?原因很简单:FUSE 太慢了。

FUSE 每次操作都要经过用户态,延迟高、吞吐低。对于大量小文件的读写(比如相册缩略图),性能简直惨不忍睹。sdcardfs 直接跑在内核态,绕过了用户态转发,性能提升非常明显。

但 sdcardfs 不是完全替代 FUSE,而是在 FUSE 之上做了一层优化。它的核心工作有两个:

  • 权限桥接:把 App 的 UID/GID 映射到文件系统的实际权限
  • 存储模拟:让 App 以为自己在访问物理 SD 卡,实际上可能访问的是 emulated 存储

避坑指南:我曾经在适配 Android 10 时,发现某个设备上 sdcardfs 挂载失败。排查了半天,原来是内核配置里没打开 CONFIG_SDCARD_FS。嗯,这种低级错误,犯过一次就记住了。

4.3 存储模拟:从物理 SD 卡到 emulated 存储

早期的 Android 设备,SD 卡是真的插在卡槽里的。后来手机存储越来越大,物理 SD 卡逐渐被淘汰。但 App 的代码里还写着 /sdcard 路径,怎么办?

答案是:模拟

Android 把内部存储的一部分(通常是 /data/media 目录)挂载到 /sdcard。App 读写 /sdcard,实际上是在读写 /data/media。这个过程就是存储模拟。

具体实现上,sdcardfs 做了这么几件事:

App 看到的路径 实际物理路径 说明
/sdcard/DCIM /data/media/0/DCIM 主用户的外部存储
/sdcard/Android/data /data/media/0/Android/data App 私有目录,Android 11 后限制访问
/sdcard/Download /data/media/0/Download 公共下载目录

你可能会问:为什么不直接让 App 访问 /data/media?因为权限。App 的 UID 是 100xx,而 /data/media 的 owner 是 media_rw(UID 1023)。sdcardfs 在这里做了权限桥接,让 App 看起来有权限,实际上内核在背后做了 UID 映射。

4.4 权限桥接:sdcardfs 如何管理多用户

Android 支持多用户(比如访客模式、工作资料)。每个用户都有自己的 /sdcard 目录。sdcardfs 通过派生挂载来实现隔离。

举个例子:

  • 用户 0(主人):/data/media/0 → /mnt/user/0/emulated
  • 用户 10(访客):/data/media/10 → /mnt/user/10/emulated

每个用户只能看到自己的目录。sdcardfs 在挂载时,会根据当前进程的 UID 判断属于哪个用户,然后映射到对应的物理路径。

我记得有一次,测试同事反馈说「访客模式下能看到主人的照片」。我第一反应是 sdcardfs 的 UID 映射出了问题。后来发现是 App 用了 FileProvider 跨用户共享文件,跟 sdcardfs 没关系。嗯,有时候 bug 不在你负责的模块,但背锅的总是你。

4.5 FUSE 与 sdcardfs 的协作关系

在 Android 10 及之后版本,两者的协作模式是这样的:

  1. sdcardfs 作为底层:负责实际的权限检查和路径映射
  2. FUSE 作为上层:处理一些 sdcardfs 无法覆盖的场景,比如动态权限变更

说白了,sdcardfs 处理 90% 的常规操作(读、写、创建文件),FUSE 处理剩下的 10%(比如 App 突然被授予了新的权限,需要动态更新文件访问范围)。

注意:在 Android 11 之后,Google 强制要求所有设备使用 sdcardfs。如果你在 AOSP 编译时发现 sdcardfs 没生效,检查一下 BoardConfig.mk 里有没有设置 TARGET_USE_SDCARDFS := true。这个坑我踩过,编译出来的系统开机后 /sdcard 挂载失败,logcat 里报错说「No such file or directory」。

4.6 实战:如何调试 FUSE 和 sdcardfs

调试这两个东西,我常用的方法:

  • 看挂载点mount | grep sdcardfsmount | grep fuse
  • 看进程ps -A | grep fuse 找到 FUSE 守护进程
  • 看日志logcat -s FUSEdmesg | grep sdcardfs
  • 看权限ls -lZ /sdcard 查看 SELinux 上下文

我曾经遇到一个 case:某个 App 在 Android 12 上无法写入 /sdcard/Download。用 strace 跟踪发现,open() 返回了 EACCES。进一步排查,是 sdcardfs 的权限缓存没刷新。解决方案是让 App 重新挂载存储卷,或者重启设备。嗯,这种问题通常不是代码 bug,而是状态不一致。

4.7 总结与思考

FUSE 和 sdcardfs,一个灵活但慢,一个快但死板。Android 选择了两者结合,说白了就是「既要又要」。作为系统工程师,你得理解它们的边界在哪里。

我个人建议:

  • 如果你在开发新的存储方案,优先考虑 sdcardfs
  • 如果遇到权限相关的诡异问题,先怀疑 FUSE 守护进程
  • 如果性能瓶颈在文件读写,检查是否走了 FUSE 路径

最后说一句:存储适配没有银弹。每个厂商的 kernel 版本、patch 情况都不一样。我见过某厂商魔改 sdcardfs 导致文件空洞无法读取的 case,也见过 FUSE 守护进程内存泄漏导致系统卡死的 case。嗯,这就是 AOSP 适配的日常。