Android-Ext4文件系统问题排查

一次车机概率性 SELinux unlabeled 故障:用 ext4 原理把它挖到底

Android 车机中 /mnt/cert_package/pkiagent 目录概率性 变为 u:object_r:unlabeled:s0 的完整排查过程,包括对故障分区镜像的逐 inode 解析与时间线还原。


0. 故障现象:一条概率性的 AVC 拒绝

某车型 IVI 车机(Android 系统,分区 cert_package 位于 /dev/block/mmcblk0p6,挂载在 /mnt/cert_package,容量仅 1MB),现场偶发报错。抓到日志时能看到典型的 SELinux 拒绝:

ini 复制代码
avc: denied { search } for comm="diagApp" name="pkiagent" dev="mmcblk0p6" ino=15
     scontext=u:r:remote_diagnostic:s0 tcontext=u:object_r:unlabeled:s0
     tclass=dir permissive=0

业务侧的表象是远程诊断应用(diagApp)反复打印:

javascript 复制代码
E [oem_sslctx_config]: cannot open cert directory: /mnt/cert_package/pkiagent/crts

三个信息值得立刻圈出来:

  1. tcontext 是 u:object_r:unlabeled:s0------目标对象没有 SELinux 标签;
  2. ino=15、dev=mmcblk0p6------内核直接告诉了我们"出问题的对象"在哪个设备、哪个 inode;
  3. permissive=0------enforcing 模式,拒绝是实打实的,不是审计噪音。

而 file_contexts 里明明写得清清楚楚:

bash 复制代码
/mnt/cert_package(/.*)?                     u:object_r:mnt_cert_package:s0
/mnt/cert_package/pkiagent(/.*)?            u:object_r:pkiagent_root_file:s0
/mnt/cert_package/vin(/.*)?                 u:object_r:vin_data_file:s0
/mnt/cert_package/pkiagent/crts(/.*)?       u:object_r:pkiagent_crts_file:s0
/mnt/cert_package/pkiagent/pre_install(/.*)? u:object_r:pkiagent_pre_install_file:s0

定义都在,为什么运行时却是 unlabeled?而且还是概率性的? 这是本文要回答的核心问题。要讲清楚它,得先从 ext4 的磁盘结构说起------因为答案就藏在磁盘上的字节里。


1. 理论底座(一):ext4 的磁盘布局

1.1 从创建一个 ext4 说起

在 Linux 上创建并观察一个 ext4 文件系统只需要三条命令:

bash 复制代码
dd if=/dev/zero of=./ext4_image.img bs=1M count=1024   # 造一个 1GB 的"磁盘"
mkfs.ext4 ext4_image.img                                # 格式化为 ext4
mount -o loop ext4_image.img /mnt/ext4_test             # loop 设备挂载
dumpe2fs ext4_image.img                                 # 查看文件系统元信息

dumpe2fs 的输出(节选)就是整个文件系统的"身份证":

yaml 复制代码
Inode count:              65536
Block count:              262144
Block size:               4096
Blocks per group:         32768
Inodes per group:         8192
Inode size:               256
Journal inode:            8
Journal size:             32M

1.2 总体布局:块 → 块组

ext4 把分区切成固定大小的块(block) (通常 4096 字节),再把块组织成块组(block group) 。每个块组内部依次包含:

scss 复制代码
+-------------+-------------------+--------------+--------------+-------------+----------+
| Superblock  | Group Descriptors | Block Bitmap | Inode Bitmap | Inode Table | 用户数据  |
|  (超级块)   |    (组描述符表)    |  (块位图)     | (inode位图)   | (inode表)   |          |
+-------------+-------------------+--------------+--------------+-------------+----------+

几个设计要点:

  • 超级块 固定位于分区偏移 1024 字节处(对 4K 块来说在第 0 号块内),magic number 0xEF53(小端字节序 53 ef)写在超级块偏移 0x38 处;
  • 由于 sparse_super 特性,超级块与组描述符的备份只散布在部分块组(如 Group 1、3、5、7),主超级块损坏时可用于恢复;
  • 块位图 / inode 位图按位记录每个块、每个 inode 的占用状态;
  • 一个组描述符块里装的是所有块组的描述符,不只是自己的。

1.3 超级块:文件系统的"户口本"

超级块记录块大小、块/组/inode 计数、UUID、最后挂载点、各种时间戳,以及最重要的------三个 32 位的特性标志字:

字段 超级块内偏移 作用
s_feature_compat 0x5C 兼容特性(内核不认识也能挂载)
s_feature_incompat 0x60 不兼容特性(内核必须支持才能挂载)
s_feature_ro_compat 0x64 只读兼容特性

每个特性占一个 bit,例如:

  • compat 里 0x8 = ext_attr(支持扩展属性)、0x20 = dir_index、0x4 = has_journal(有日志!)
  • incompat 里 0x2 = filetype、0x40 = extents(extent 映射)
  • ro_compat 里 0x1 = sparse_super、0x2 = large_file、0x8 = huge_file、0x10 = uninit_bg、0x20 = dir_nlink、0x40 = extra_isize

dumpe2fs/tune2fs -l 输出里的 Filesystem features: 那一行,就是这三个字逐位翻译成人话。

1.4 Inode:文件的"实体"

每个文件/目录对应一个 inode,记录权限、uid/gid、三个时间戳(atime/mtime/ctime)、大小,以及 60 字节的 i_block 区域。i_block 的解释由 i_flags 决定:

  • 传统模式:直接块指针 + 一/二/三级间接块;
  • extent 模式 (i_flags & 0x80000,现代 ext4 默认):存放一棵 extent 树,每个 extent 形如"逻辑块 0 起、长度 0x196 个块、映射到物理块 0x84b5 起",大文件的映射既紧凑又减少碎片。

给定 inode 号,定位它的公式是:

css 复制代码
inode 所在偏移 = 组描述符表里查到的该组 inode table 起始块 × 块大小 + (inode号-1) × inode大小

另外 0~10 号 inode 是保留的:2 号永远是根目录,8 号被 journal 占用。

1.5 目录项:树是怎么串起来的

ext4 中"目录"本质上也是一个文件(inode 的 mode 标记为目录),其数据块内容是一串连续的目录项:

scss 复制代码
+----------------+--------+-----------+------------+----------------+
| inode 号 (4B)  | rec_len| name_len  | file_type  | 文件名(不满补齐)|
|                | (2B)   | (1B)      | (1B)       |                |
+----------------+--------+-----------+------------+----------------+

于是"按路径找文件"的过程就是:从 2 号 inode(根)读出目录项 → 找到下一级名字对应的 inode 号 → 读该 inode → 再往下走。删除文件则相反:释放数据块(块位图置闲)→ 释放 inode(inode 位图置闲)→ 摘除目录项。

创建文件的完整步骤(后面讲崩溃一致性时的主角):

  1. 查 inode 位图,找一个空闲 inode 并标记占用;
  2. 查块位图,分配数据块并标记占用;
  3. 更新 inode 表中的元数据(权限、时间、块指针......);
  4. 在父目录的数据块里追加目录项。

2. 理论底座(二):崩溃一致性------为什么"没有 journal"很危险

2.1 问题:四个步骤没法原子完成

磁盘以扇区为最小写入单位,上面"创建文件的四个步骤"是四次独立的落盘,中间任何时刻断电,磁盘上就会留下"半新半旧"的自相矛盾状态:

  • 第 1 步完成后断电:inode 位图标了占用,但没有对应文件 → inode 永久泄漏;
  • 第 4 步前断电:数据块、inode 都好了,但目录里没有它 → 文件"不存在";
  • 更隐蔽的:元数据在 page cache 里延迟批量落盘,崩溃窗口比想象的大得多。

这就是崩溃一致性(crash consistency) 问题:文件系统在崩溃、断电后,必须仍能恢复到一个"合法、可继续操作"的状态。

业界有四种主流解法,各有取舍,没有万能方案:

方案 核心思想 代表 代价
日志 Journal 先把变更顺序写进日志区,提交后再应用到最终位置 ext3/ext4、XFS 双写,IO 放大;日志区易磨损
写时复制 COW 修改写到新块,完成后才切换指针 Btrfs、ZFS 写放大、元数据负担重
Soft Updates 精确约束元数据写入顺序(如先写目录项再写 inode) FreeBSD UFS 实现复杂、内存开销大
日志结构 LFS 所有更新追加到连续日志,后台整理 F2FS、jffs2 随机读退化、碎片化

2.2 ext4 的日志:JBD2 与三种模式

ext4 的日志由 JBD2 子系统实现,遵循"先记日志、再执行、后清理":

scss 复制代码
事务开始 → 收集日志 → 提交日志(写commit标记) → 应用事务(写入最终位置) → 清理日志

崩溃后的恢复规则非常简单:挂载时扫描日志区,未提交的事务直接丢弃,已提交的事务重放。相比 fsck 全盘扫描比对位图,恢复时间从分钟级降到毫秒级。

按"往日志里记什么",ext4 有三种模式:

模式 记录内容 特点
writeback 仅元数据 性能最高;崩溃后可能"新元数据指向旧数据"
ordered(默认) 元数据入日志;数据不入日志但保证先于元数据落盘 性能与一致性的平衡点
journal 数据 + 元数据全入日志 一致性最强;所有数据写两遍

反过来说:如果格式化时没开 journal(compat 特性字里没有 0x4),这块 ext4 就退化成了"ext2 + extents + xattr" ------一致性完全指望下次开机 fsck,而 fsck 的修复是"以元数据自洽为目标"的外科手术,过程中丢掉部分元数据(比如 xattr)是合法的处置手段。记住这一点,后面全靠它。


3. SELinux 标签到底存在哪

Android 上一个问题是"标签(context)从哪来"。答案分两层,两层经常被混为一谈,而本案例恰恰败在混淆上:

第一层:磁盘上。 SELinux 标签以扩展属性(xattr)的形式挂在每个 inode 上,名字固定是 security.selinux,值形如 u:object_r:pkiagent_root_file:s0。内核在进程访问对象时实时读取这个 xattr;如果 inode 上没有这个 xattr,内核一律报告 u:object_r:unlabeled:s0。

xattr 的存放位置和 inode 大小有关:现代 ext4 的 inode 是 256 字节,放得下就内联在 inode 里(ibody);如果 inode 只有 128 字节(ext2 时代规格),所有 xattr 都放到独立的外部 xattr 块里,inode 用 i_file_acl 字段指向它。这个字段清零 = 标签 evaporate。

第二层:策略里。 file_contexts 定义的"路径 → 标签"映射,只在两种时刻生效 :制作镜像时(make_ext4fs/mke2fs 带 contexts)和执行 restorecon/restorecon_recursive 时。它不是"持续生效的规则",运行时内核压根不看 file_contexts。

两层的推论:

  • file_contexts 写得再全,只要磁盘上的 xattr 丢了、又没有 restorecon 兜底,对象就是 unlabeled;
  • 新建的文件按 SELinux 规则从父目录继承/转换 标签------包括把 unlabeled 原样传给子文件(表现为子文件的 xattr 值字面上就是 u:object_r:unlabeled:s0,这是 unlabeled 父目录的"传染性");
  • 判断"标签问题是出在挂载还是出在个别文件"有个简单判据:同一挂载下,别的对象有正确标签而个别对象 unlabeled ⇒ 一定是磁盘 xattr 缺失,跟挂载方式(context= 之类)无关。

4. 实战:把 1MB 的故障分区拆开看

4.1 手里的材料

  • cert_package_dump.bin:故障分区完整 dump(1MB);
  • 5 份 logcat(共约 100MB),每份里混着多个启动会话(这是车机日志的常态:RTC 未同步时时间戳会跳到 2000-01-01 或 2022-01-01,必须按会话切开看)。

4.2 第一步:file 和十六进制看气质

ini 复制代码
$ file cert_package_dump.bin
Linux rev 1.0 ext2 filesystem data (mounted or unclean), UUID=5b13xxxx-xxxx-...

注意两点:file 认为它是 ext2 (file(1) 区分 ext2/ext3/ext4 的主要依据就是 has_journal 位);状态是 mounted or unclean。

直接读超级块(偏移 1024):

ini 复制代码
s_feature_compat    = 0x28   → ext_attr(0x8) + dir_index(0x20)     ;has_journal(0x4) = 0 ❌
s_feature_incompat  = 0x42   → filetype(0x2) + extents(0x40)
s_feature_ro_compat = 0x7b   → sparse_super+large_file+huge_file+uninit_bg+dir_nlink+extra_isize
s_journal_inum      = 0      (有日志的 ext4 这里是 8)
s_journal_dev/uuid  = 全零
8 号 inode           = mode/links/size 全 0(从未使用)

其他参数:块大小 4096、共 256 块、128 个 inode、单块组、inode 大小 128 字节 、last mounted /mnt/cert_package。

结论扑面而来:这是一颗"无日志 + 128 字节 inode"的 ext4------AOSP make_ext4fs 生成静态分区镜像的典型形态。它意味着:所有 SELinux 标签都住在外部 xattr 块里;一致性没有日志保护。

4.3 第二步:写个脚本,从 2 号 inode 走遍整棵树

没有现成的 Linux 环境,就用 Node.js 手写一个迷你 ext4 解析器(80 行核心逻辑,文件保存在 ext4_inspect.js):读超级块 → 找组描述符 → 定位 inode 表 → 沿目录项递归下钻 → 对每个 inode 解析 i_file_acl 指向的 xattr 块,取出 security.selinux。

输出是整个分区最诚实的画像:

路径 ino on-disk 标签 i_file_acl
/ 2 mnt_cert_package 14
/lost+found 11 mnt_cert_package 13
/misc、/misc/keystore、sqlite 文件 12,13,18,20 mnt_cert_package 15...
/evs、/evs/res/xcb、xcb_license 23,24,25,33 mnt_cert_package 15...
/pkiagent/crts + 全部证书文件 19,27,28,34~37 pkiagent_crts_file 18...
/pkiagent/pre_install 21 pkiagent_pre_install_file 22
/vin(空目录,0777) 14 无 xattr → unlabeled 0
/pkiagent 15 无 xattr → unlabeled 0
/pkiagent/pki_config.json 32 xattr 值 = u:object_r:unlabeled:s0(显式!) 62
/lost+found/#16、#17(空目录,0777) 16,17 无 xattr → unlabeled 0

三个关键观察:

  1. 同分区其他一切都有正确标签 ,唯独 vin、pkiagent 两个目录的 i_file_acl=0。结合第 3 节的判据:挂载没问题,是这两个 inode 的磁盘 xattr 没了(或从未有过) 。
  2. pki_config.json 是运行期在 unlabeled 目录下创建的,它的 xattr 被内核显式写成 unlabeled------完美复现"父目录无标签会传染子文件"的机制。
  3. 出问题的两个目录和 lost+found 里两个孤儿目录特征完全一致:空/浅目录、mode 0777、无 xattr。

顺带一个法证级细节:/pkiagent 的 ctime 停在 2022-01-01 而 mtime 是 2026-09-19------ctime < mtime 在正常内核行为下不可能发生 ,说明这些 inode 的元数据要么由恢复工具按原时间戳重写过,要么经历了系统时钟跳变。总之:这台设备的时间戳不能按字面相信。

4.4 第三步:日志重建时间线

把 5 份 logcat 按会话拆开(2000-01-01 / 08-06 / 09-05 / 09-20 四段),对齐每个会话里"cert 访问成功/失败 + AVC denial"的组合:

会话(本地时间) 标签状态 证据
2000-01-01 08:00(RTC 重置 = 此前有硬掉电) 正常 59 次 found final matching cert、0 失败、无任何 mmcblk0p6 的 AVC
08-06 20:43 开机 vin+pkiagent 已 unlabeled 22 条 denial(ino=14/15);根目录 /mnt/cert_package(ino=2)tcontext 正常是 mnt_cert_package------同挂载、同时刻,个别对象坏
08-06 22:27:28 分区被重新 mkfs + 重灌 tune2fs 的 Filesystem created: Aug 6 22:27:28 2026;而镜像里 inode 时间戳停在 2022-01-01 → 内容是从金片/母本镜像按原时间戳恢复的
09-05 18:18/18:21 持续失败 diagApp 反复被拒;同会话有权限的域(pkiagent_app 走 crts)能成功------不是标签恢复
09-19 14:04 持续 pki_config.json 写入
09-20 09:13 / 14:26 两次开机 持续 denial 照旧,permissive=0
09-28(dump 时刻) 持续 ino 14/15 无 xattr;fs state "not clean"

4.5 第四步:设备端 tune2fs 复核

在 DUT 上用 tune2fs -l /dev/block/by-name/cert_package 复核(没有 dumpe2fs 时的替代):

yaml 复制代码
Filesystem features: ext_attr dir_index filetype extent sparse_super large_file
                     huge_file uninit_bg dir_nlink extra_isize     ← 没有 has_journal
Filesystem state:    not clean
Filesystem created:  Thu Aug  6 22:27:28 2026
Last mount time:     Sat Jan  1 08:00:04 2022     ← 挂载时系统时钟还是默认 epoch
Mount count:         1
Inode size:          128

与离线解析完全互相印证。


5. 根因:三层叠加

把所有证据叠起来,故障是三层因素共同作用的结果:

第一层(直接原因):磁盘上 i_file_acl=0。 /vin、/pkiagent 的 inode 没有 security.selinux xattr,内核报 unlabeled。file_contexts 定义得再好也没用------它只在 restorecon/制镜像时生效,运行时内核只认磁盘 xattr。

第二层(标签是怎么没的):两条路径都成立,且都指向"没有日志"。

  • 路径 A(旧文件系统,8/6 20:43 之前):无日志 ext4 在车机硬掉电(日志里 RTC 跳回 2000-01-01 的会话就是掉电现场)后,元数据多步写不原子,e2fsck 修复时把损坏/引用异常的 xattr 块清掉(i_file_acl 置零)------修复以元数据自洽为目标,丢 xattr 是合法处置。哪个 inode 中招是随机的,这就是"概率性"的来源之一;
  • 路径 B(现行文件系统,8/6 22:27 之后):分区被重新格式化并从金片镜像恢复,而金片/恢复流程本身就没给 vin、pkiagent 写标签(其余目录全部有正确标签),重刷等于把问题原样刷了回去。

第三层(为什么永远不自愈):缺兜底。 这台设备的启动流程里没有任何针对 /mnt/cert_package 的 restorecon_recursive。标签一旦丢失,就带着 unlabeled 一直跑,每次开机重复同样的 denial;运行期在 unlabeled 目录下新建的文件还会被显式写成 unlabeled,问题自我延续。

至于"file_contexts 明明有定义"的疑问,一句话回答:定义只是"翻译词典",得有人(restorecon)去翻它;没人翻,磁盘上缺的那一页就永远是空的。


6. 修复与预防

按"止血 → 根治 → 加固"三层:

  1. 存量止血(现场已坏设备,root 执行一次即可):

    bash 复制代码
    restorecon -R /mnt/cert_package
    # 或最小化处理:
    chcon u:object_r:mnt_cert_package:s0 /mnt/cert_package/pkiagent /mnt/cert_package/vin

    注意把 /vin 一起修------它和 pkiagent 是同一故障(carservice_app 的 denial)。

  2. 根治自愈(init rc,分区挂载后):

    bash 复制代码
    restorecon_recursive /mnt/cert_package

    每靴执行,无论标签是被 fsck 清的还是镜像漏刷的,开机即修复。file_contexts 已经是对的,缺的只是这一行。

  3. 切断源头:排查重灌/镜像的生成脚本,给所有目录写标签;同时给分区补日志:

    bash 复制代码
    # 需先卸载分区
    tune2fs -O has_journal /dev/block/by-name/cert_package

    1MB 分区开日志的代价可以忽略,却能把"掉电 → fsck → 丢 xattr"整条链掐断。

  4. 监控 :enforcing 模式下 denial 本身就是最好的探测器,可对 tcontext=unlabeled 的 audit 做聚合告警。


7. 方法论沉淀:这类问题怎么查

复盘整个过程,可复用的套路有五条:

  1. AVC 日志是最好的路标 :name=、ino=、dev= 三个字段把问题对象精确定位到"哪块盘哪个 inode",比任何猜测都硬;
  2. "同挂载下个别对象 unlabeled" 判据:邻居有标签而它没有 ⇒ 一定是磁盘 xattr 缺失,不要在挂载选项上浪费时间;
  3. 拿 dump 离线解析 ,别依赖设备上有没有工具:超级块在 1024 偏移、magic 0xEF53、三个特性字 + i_file_acl 就能回答 90% 的问题。没有 e2fsprogs 时,dd skip=1116 count=1 | od -tx1 读出 28 而不是 2c,就足以证明没有 journal;
  4. 车机日志必须按会话切开读:RTC 未同步时时间戳会在 2000/2022/2026 之间跳,且单个文件里混着多次开机的 buffer;inode 时间戳出现 ctime < mtime 这种"不可能状态",本身就是时钟跳变或元数据被工具重写的证据;
  5. file_contexts ≠ 运行时标签:验证"定义有没有被应用",看的不是 contexts 文件,而是磁盘 xattr 和"有没有东西定期去 apply 它"。

8. 结语

这个案例的"戏剧性"在于:表象是一次概率性的 SELinux 拒绝,往下一层是磁盘上消失的 60 字节 xattr,再往下一层是"无日志 ext4 在反复硬掉电 + fsck 修复"和"镜像本身漏标"两条合流的伏笔,而让它从偶发变成永久故障的最后一击,是启动流程里缺席的那一行 restorecon_recursive。

ext4 的超级块、位图、inode、extent、目录项这些结构,平时藏在 dumpe2fs 的输出和内核源码里,显得抽象;但当它以 1MB 分区的形态躺在你面前、每个字节都能和一条 AVC 日志对上号时,"文件系统"这门课就突然具体起来了。崩溃一致性那篇文章里说得很对------四种方案各有局限,没有万无一失;对工程实践而言,真正万无一失的是假设坏事情一定会发生,然后给系统留一条自愈的路。

相关推荐
Escalating_xu1 小时前
【C 语言】深入理解指针(3):字符指针、数组指针、二维数组传参、函数指针与转移表
java·c语言·开发语言
不正经的码狗1 小时前
Java 开发环境搭建与 Eclipse 使用速通教程
java·开发语言·eclipse
用户8181870627461 小时前
第37章 Java应用在K8s里的经典坑:容器内存/CPU limit与JVM参数不匹配导致的OOMKilled
java·后端
马剑威(威哥爱编程)2 小时前
【AI全栈后端12-03】Spring Boot 用多模型路由把智能客服成本降下来
java·人工智能·spring boot
霸道流氓气质2 小时前
AI模型幻觉检测与抑制完全指南:从规则引擎到RAG对比的Java生产级实战
java·开发语言·人工智能
niucloud-admin2 小时前
JAVA V6 多商户商城 开发文档——手机端前端
java·开发语言·前端
云安全助手2 小时前
自建接入VS聚合平台:企业 AI 调用的选型思路与迁移成本拆解
java·大数据·数据库·人工智能·ai大模型
樱花落木兰2 小时前
SpringBoot + ECharts 后台数据统计报表模块实战
java·javascript·spring boot·ai·log4j·github·echarts
霸道流氓气质2 小时前
Dify 可视化 LLM 应用开发平台完全指南:从Workflow编排到Java生产级集成实战
java·开发语言