ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android 10开机前状态重置:启动落点、misc控制块与SELinux排错

Android 10开机前状态重置:启动落点、misc控制块与SELinux排错

"开机前重置一些状态"这八个字,第一次看到的时候我以为是半小时能收工的活:在 init.rc 里加两行删除命令,重启,验证,提交。真动起手来才发现,卡住整个事情的从来不是命令怎么写,而是那几行代码究竟跑在启动链路的哪一格、跑的时候目标路径到底存不存在、跑完之后 SELinux 认不认这个新上下文。写在 early-init 里,文件系统还没挂载;想清 persist 属性,属性服务在更早的时候就把旧值读进了内存;想清某个应用的数据,又撞上文件级加密,用户没解锁之前那个目录根本不可见。这篇文章就围绕 sp 平台(项目标题里的 sprd10.0 是团队内部沿用的版本串写法,下文统称 sp 平台,具体厂商和型号不展开)Android 10 基线下的"开机前重置状态"这件事,把启动窗口选择、状态分类、分区控制块写入、init.rc 落地、幂等设计和排错链路完整拆一遍。适合正在做定制固件、产线模式、返修整机处理或者升级兼容适配的同行看,也适合刚接手系统层、对 init 流程还比较陌生的同学按图索骥。

1. 先确定四个关键点:命令该落在启动链路的哪一格

1.1 从 Preloader 到 post-fs-data,每一格能碰到什么

Android 的启动不是一条直线,而是一串接力。粗粒度地拆,会经过 Preloader、二级引导(常见是 U-Boot 或 LK)、Kernel、init 第一阶段、init 第二阶段这几棒。每一棒手里能操作的东西完全不同,这也是"重置状态"这件事最容易翻车的地方——不是逻辑错,是放错了棒次。

Preloader 跑在 DRAM 刚初始化完的时候,能干的只有最基础的硬件初始化和加载下一级引导,这一层基本不适合做任何业务性质的清理。二级引导能读分区、能改分区内容,misc 分区里的控制块通常就是在这一层被解析的,所以想通过"写一条指令让下次开机进恢复模式执行清除"这条路,落点就在这里。

Kernel 起来之后,init 作为第一个用户态进程启动,它按 trigger 顺序推进:early-init→init→late-init,而late-init里又会依次触发early-fs→fs→post-fs→late-fs→post-fs-data→zygote-start→early-boot→boot。这个顺序不是随便排的,它精确对应"文件系统挂到哪一步了"。early-init阶段/data还没挂上,你在里面写rm /data/xxx只会得到一个"文件不存在"的静默失败;等到post-fs-data,/data可用了,但属性服务早在 init 启动初期就已经把persist.*的值从磁盘读进共享内存了,这时候你再去删属性文件,本次开机是无效的。

这里有个很实用的判断办法:在设备的 init.rc 里搜mount_all出现的位置,把自己要执行的命令放在它之后的第一个 trigger 里。mount_all在哪,文件系统就在哪可用,这比死记阶段名字靠谱得多。

1.2 判断你自己的设备该放在哪一格:一个三步的判定法

我一般用三个问题来定落点,顺序不能反。

第一个问题:要清的东西是分区里的字节,还是文件系统里的文件?如果是分区字节,比如某块保留区里的标志位,那落点在二级引导或者一个能拿到块设备节点的早期阶段;如果是文件系统里的文件,往下走。

第二个问题:这个文件在不在/data下、需不需要用户解锁才能看见?不需要解锁的(device encrypted 那部分,以及/cache、/metadata这类),落在post-fs-data之前或之中都可以;需要用户解锁才可见的(credential encrypted 那部分,也就是绝大多数应用的数据目录),必须等到用户解锁之后,通常是on user-unlocked或者在系统服务里做,init.rc 阶段想都别想。

第三个问题:这次清理要不要"只发生一次"?如果要,就得引入标志位,标志位本身又需要一个不会被本次清理顺手删掉的存放位置——这是个递归问题,很多人就是在这里绕不出来,最后把标志和状态放在了同一个目录,清状态的时候把标志一起清了,于是每次开机都清一遍。

把这三个问题的答案写在一张纸上,落点基本就唯一了。我见过的大部分返工,都是跳过这三个问题直接开写。

2. 分清"状态"的四种形态,别把不可逆的东西当成可清项

2.1 属性、文件、分区字段、硬件寄存器:四类状态的重置代价

"状态"这个词太笼统了,实际项目里它至少包含四种形态,重置代价差着数量级。

形态典型例子重置方式生效时机风险等级
属性persist.sys.*、自定义persist.vendor.*setprop 覆盖或删持久化文件本次或下次开机低
文件目录下的标志文件、缓存、日志、小型配置rm / 重写视挂载阶段低到中
分区字段misc 控制块、引导计数、槽位元数据dd 写入指定偏移下次开机中到高
硬件相关校准数据、射频参数、唯一标识专用工具或产线流程需重新校准极高

属性和文件这两类,出问题最多是"没生效",可以反复试;分区字段出问题可能是"开不了机",但通常还能救回来;而校准数据、射频参数、设备唯一标识这一类,一旦覆盖,整机基本只能返产线重烧,普通开发者手上没有对应工装是做不回来的。

所以第一件事永远是分类,而不是动手。我在项目里习惯先把待清项列成表,逐条标注形态和风险等级,风险等级为"极高"的项直接画红线,不管需求方怎么催都不碰。

2.2 一眼看上去能清、实际上碰了会出事的几类东西

有几类东西特别容易被误判成"普通状态",这里单独点出来。

第一类是设备唯一标识相关的存储。它们的名字往往很朴素,看起来就是个普通的分区或文件,但里面存的是整机出厂时写入的唯一值,覆盖之后同一批机器会出现标识重复,后续所有依赖唯一标识的功能都会出问题。

第二类是传感器校准。有些方案会把校准结果放在一个可写分区里,测试的时候为了方便会去动它,动完之后指南针、重力感应就开始飘,而现场根本判断不出是软件问题还是硬件问题。

第三类是加密相关的元数据。文件级加密的密钥材料、密钥派生相关的文件,看起来只是/data下的一堆目录,但删错一个,用户数据就永久解不开了。清理加密元数据只有一种合法姿势,就是走完整的恢复出厂流程,让系统自己去处理密钥轮换。

第四类是引导计数和回滚保护相关的字段。有些平台用这些字段做启动失败自动回滚,你把它清了,等于把安全网拆了,某次刷了坏镜像之后设备就再也起不来。

提示:任何"清完之后没法靠重刷固件恢复"的项,都应当默认排除在自动清理流程之外,需要清的时候走人工工装流程并留记录。

3. misc 分区里的那块控制块:开机流程真正读的第一个"状态"

3.1 2048 字节的布局与各字段偏移

misc 分区是引导流程和 Android 之间的一条"便签纸"。引导程序每次启动都会读它,看里面有没有指令;Android 侧写完指令重启,引导程序读到就照做。Android 10 基线上常见的结构体是这样:

struct bootloader_message { char command[32]; /* 偏移 0,指令,比如 boot-recovery */ char status[32]; /* 偏移 32,状态回写 */ char recovery[768]; /* 偏移 64,传给恢复模式的参数 */ char stage[32]; /* 偏移 832 */ char reserved[1184]; /* 偏移 864 */ };

整块 2048 字节。command是"要做的事",recovery是"做的细节",两者配合。想触发一次带数据清除的恢复流程,需要把command写成boot-recovery,把recovery写成以recovery\n开头、后面跟具体参数的多行文本。清除数据用的是--wipe_data,清缓存是--wipe_cache,这两个参数的组合顺序会影响恢复模式里的实际行为,通常--wipe_data在前。

要注意的是,不同平台对这块区域的用法会有增减,有的方案会额外用stage字段做槽位相关的事情,有的会把控制块搬到别的位置。所以动手之前,先在设备上确认块设备节点:ls -l /dev/block/by-name/看有没有misc,有的话记下它的实际指向。分区名在不同平台上可能叫misc、miscdata或者别的,以实际列表为准。

3.2 用 dd 手写一条指令并在下次开机验证

确认了节点之后,写入本身很简单,关键是偏移和conv=notrunc:

# 只写 command 字段,不动其它区域 printf 'boot-recovery' | dd of=/dev/block/by-name/misc bs=1 seek=0 conv=notrunc # 写 recovery 字段,偏移 64 printf 'recovery\n--wipe_data\n--wipe_cache\n' \ | dd of=/dev/block/by-name/misc bs=1 seek=64 conv=notrunc # 回读确认 dd if=/dev/block/by-name/misc bs=1 count=128 2>/dev/null | hexdump -C

conv=notrunc不能省。省掉的话 dd 会先把文件截断成零长度,对整个块设备来说这意味着你后面一段数据全被清掉了,这时候设备还能不能起来就只能看运气。

回读那一步也别省。我在一个项目里遇到过写完立刻重启、结果什么也没发生的情况,回读才发现写进去的是boot-recover——少了一个y,因为当时用的是printf但字符串被 shell 变量截断了。这种低级错误靠肉眼核对代码是看不出来的,只能回读比对。

写完之后触发重启,引导程序读到boot-recovery就会进恢复模式并执行清除。清除完成后,恢复模式会把command字段清掉,避免下次开机又进一遍。如果清除完command还在,那就是恢复流程没有正常走到收尾,这时候开机就会陷入"每次都进恢复模式"的循环,需要手动把command字段写成全零救回来。

3.3 写完之后没生效,通常是哪三处出了问题

第一种,写错了块设备。有些设备上/dev/block/by-name/misc是个符号链接,指向/dev/block/mmcblk0pX之类,如果链接本身被改过或者指向了错误的槽位,你写的就不是引导读的那块。这种情况回读是正常的,但行为不对,非常迷惑。

第二种,槽位不对。A/B 方案的设备上,有些元数据是按槽位分开的,你当前跑在 A 槽,写进去的却是 B 槽的位置,重启后从 A 槽引导,自然读不到。判断当前槽位可以看ro.boot.slot_suffix这个属性。

第三种,写对了但时机不对。misc 里的指令是在引导阶段被读的,如果写入动作发生在引导已经开始之后(比如你写完之后做了一次软重启,某些方案下软重启不完整走引导流程),指令就不会被消费。稳妥的做法是写完做一次完整的断电重启,而不是reboot命令。

4. init.rc 里的清理动作:write、exec 与 restorecon 的正确组合

4.1 early-init 能写的节点与不能动的路径

early-init这个阶段最容易被误用,因为它名字里带"early",看起来什么都能干。实际上这个时候/data、/cache都还没挂上,你能操作的有效范围主要是两类:一类是 proc 和 sys 下的节点,另一类是那些在 fstab 里标记为 early mount 的分区。

写 sys 节点是early-init最正当的用法。比如某些硬件状态需要在系统起来之前复位,就往对应的 sysfs 节点写值:

on early-init write /sys/class/xxx/reset 1 write /sys/class/xxx/enable 0

这种写法干净、无副作用,也不会牵扯 SELinux 太多问题(前提是节点本身的 type 允许 init 域写入)。

而清理/data下的文件,无论怎么写都不会成功。我见过有人在early-init里写rm /data/system/xxx.xml,日志里连报错都没有,因为 init 的rm遇到不存在的路径是静默返回的。找这个问题的正确姿势不是看日志,而是看mount的输出确认/data到底在哪个 trigger 之后才出现。

4.2 exec 一个脚本:怎么让 init 真的把它跑起来

当清理逻辑复杂到几行 rc 写不下的时候,就需要exec一个脚本。Android 10 上 init 的exec语法大致是这样:

on post-fs-data exec - root system -- /system/bin/sh /system/bin/reset_state.sh

四个关键点逐一说。-之后的root system是用户和组,--之后才是真正的命令和参数。脚本本身必须放在一个 init 能访问、并且在系统分区里的位置,放在/data下是不行的,因为执行时机可能早于挂载。

脚本第一行要写#!/system/bin/sh,权限要设成可执行。更关键的是 SELinux 上下文:脚本文件自己的 type 必须是 init 域能够执行的类型,否则会得到一条avc: denied { execute },然后在 logcat 里一闪而过,很容易漏看。

脚本内部执行的动作同样受 SELinux 约束。在 init 域里 fork 出来的 shell 进程,其域可能是init本身,也可能是某个shell域,取决于策略配置。所以脚本里每一条对文件的写操作,都要确认目标路径的 type 对当前域开放写权限。

我的习惯是:脚本第一版只做一件事,就是把要清的目标路径挨个ls一遍并把结果打到内核日志里。先确认路径可见、权限够、上下文对,再往里加真正的删除动作。这个笨办法能省下大量来回刷机的时间。

4.3 文件创建后忘了 restorecon,后面全是 avc denied

清理完之后如果需要重建某些文件或目录(比如重建一个空的状态文件),一定要紧接着做restorecon。因为通过 init 或 shell 创建出来的文件,默认继承的是创建者的上下文,而不是该路径在策略文件里声明的预期上下文。

on post-fs-data mkdir /data/vendor/mystate 0770 system system restorecon_recursive /data/vendor/mystate

restorecon_recursive是 init 内建的命令,会按 file_contexts 里的规则把整个目录的上下文刷一遍。不做这一步,后续任何访问这个目录的进程都会撞上avc: denied,而这条拒绝日志往往在开机几万行日志的中间位置,第一次排查的人基本找不到。

排查 avc 拒绝有一个简单办法:adb shell dmesg | grep avc,或者adb logcat -b all | grep avc。dmesg 里的记录不受 logcat 缓冲区大小限制,更适合开机阶段的问题。如果嫌日志太多,可以临时把 SELinux 设成宽容模式(setenforce 0)对比一次行为,行为正常了才能确认问题出在策略上而不是逻辑上。

5. 让清理只发生一次:幂等标志与触发条件的几种设计

5.1 用版本号 + 标志文件做双保险

"只清一次"这件事,最朴素的做法是留个标志文件,但标志文件本身也会遇到被清掉的风险。我的做法是双保险:标志文件 + 版本号,两者都要满足条件才执行清理。

on post-fs-data exec - root system -- /system/bin/sh /system/bin/reset_state.sh
#!/system/bin/sh FLAG=/data/vendor/.state_reset_done VER_FILE=/data/vendor/.state_reset_ver CUR_VER=1 # 已经执行过且版本匹配,直接退出 if [ -f "$FLAG" ] && [ -f "$VER_FILE" ]; then LAST_VER=$(cat "$VER_FILE") if [ "$LAST_VER" = "$CUR_VER" ]; then exit 0 fi fi # 执行清理动作 rm -rf /data/vendor/mystate/* mkdir -p /data/vendor/mystate chown system:system /data/vendor/mystate restorecon -R /data/vendor/mystate # 写入标志 echo "$CUR_VER" > "$VER_FILE" touch "$FLAG" restorecon "$VER_FILE" "$FLAG"

注意标志文件和目标状态必须放在不同目录。这条建议看起来很傻,但我至少见过三次把它们放在同一个目录里、然后清理动作把标志一起删掉、于是每次开机都清一遍的案例。表现出来就是"用户设置的东西每次重启都丢失",而开发者在本地反复测试都复现不了,因为本地的目录结构跟定制版本不一样。

版本号这一层是给后续留的活口:将来如果又要清一次,把CUR_VER改成 2 就行,不需要新增文件,也不需要依赖用户手动触发。

5.2 把异常重启计数作为触发条件

有些场景下"清一次"的语义不是"新版本第一次开机清",而是"连续异常重启 N 次之后清"。这种需求在稳定性兜底里很常见:某个状态被写坏导致系统起不来、无限重启,希望系统能自己恢复。

实现上需要在引导阶段维护一个计数,每次正常启动完成后清零,每次启动时加一,超过阈值就触发清理。计数放在哪里是个设计点:放在/data下不行,因为系统起不来的时候/data可能还没挂载;放在 misc 的reserved区域或者单独一块保留区比较合适,但要注意兼顾前面说的风险等级评估。

/* 思路示意:从 misc 的保留区读计数 */ struct boot_count_meta { uint32_t magic; /* 用于识别这块区域是否已被初始化 */ uint32_t count; uint32_t reserved[2]; };

magic字段很重要。首次上电时保留区大概率是全0xFF或者全0,如果不做魔数校验,你会把一片随机值当成计数读进来,导致第一次开机就误触发。这是分区字段类操作的通病,凡是从裸分区读结构化数据,第一件事都是校验魔数。

5.3 开关要不要留:出厂固件里的应急后门设计

量产出货的固件里,我倾向于给清理逻辑留一个属性开关,比如persist.vendor.state.reset,默认为 0,设为 1 的时候强制执行一次。理由很实际:设备到用户手里之后出现状态相关的疑难问题,现场支持人员没有工程机、也没法刷机,但能通过某种受控方式改一个属性重启一次,比寄回来省事得多。

这个开关的设计有几条约束。属性名要足够独特,不要跟已有命名撞车;开关只对白名单内的清理项生效,不能一个开关把所有东西都清了;触发之后要自动复位成 0,避免残留;最重要的是,对所有高风险项(校准、唯一标识)必须硬编码排除,不能因为一个属性就放行。

6. 一次完整的排错复盘:改了 init.rc 但开机后状态还在

6.1 现象与第一层排除

某次做定制版本,需求是在出厂前清掉测试阶段留下的一组状态文件。改完 init.rc,编译,刷机,重启,进系统一看——状态文件原封不动。整个改动不超过十行,理论上没有任何出错空间。

第一层排除做的是"确认改动真的进了镜像"。这一步听起来多余,实际上很有必要,因为定制版本的编译产物路径经常有多套,一不小心刷的是旧的。验证方法很直接:adb shell cat /system/etc/init/hw/init.rc | grep reset_state,或者看我加的那条exec在不在。确认在。

第二层排除做的是"确认脚本被执行"。这一步我用了一个取巧的办法:在脚本第一行加一句写内核日志的动作,重启后dmesg | grep reset_state看有没有打出来。结果没有。到这里问题范围就从"清理逻辑不对"缩小到了"脚本压根没跑"。

6.2 用日志把执行链一环一环接回去

脚本没跑,可能的原因有三层:rc 里的exec语法有问题、脚本文件不存在或不可执行、SELinux 拦住了执行。逐层验证。

先看 rc 语法。把exec那一行从post-fs-data挪到别的 trigger 下试一次,还是没跑,说明不是 trigger 名字写错。再看文件:

adb shell ls -lZ /system/bin/reset_state.sh

ls -lZ会带出 SELinux 上下文。这一看就发现了问题:脚本文件的上下文是u:object_r:system_file:s0,而我期望的是u:object_r:init_exec:s0或者该平台对应的可执行类型。system_file这个 type 对 init 域是没有执行权限的。

到这里其实还没完,因为还要确认脚本是怎么被编进镜像的。检查编译配置发现,脚本被放进了/system/bin目录,但没有在对应的文件上下文配置里声明,所以默认继承了目录的上下文。

6.3 定位结论与修复动作

修复分两步。第一步,在文件上下文的配置里给这个脚本单独声明一个 type,让它跟着 init 相关域走;第二步,在策略里补上 init 域对该 type 的执行权限。策略文件里大致是这么几条:

# file_contexts 片段 /system/bin/reset_state\.sh u:object_r:state_reset_exec:s0 # 类型定义 type state_reset_exec, file_type, exec_type, vendor_file_type; # 域转换与权限 init_domain(state_reset_exec) allow init state_reset_exec:file { execute execute_no_trans };

补完之后重新编译刷机,脚本跑起来了,但清理还是没成功——这是第三层:脚本跑起来了,里面的rm却因为目标路径的 type 对 init 域没有写权限而被拒。这次的日志在dmesg里找到了,一条avc: denied { write },目标正是/data/vendor/mystate。

最后把这条写权限也补上,整套流程才通。整个过程从"十行改动的简单需求"变成"改了三个配置文件、跨了两轮刷机",这就是为什么我在第 1 节里反复强调落点和上下文要先想清楚——真正耗时间的从来不是业务逻辑,而是这条链路上的每一道门。

7. 出货前的那张自检表:验证、灰度与回滚

7.1 验证清单

清理类改动最容易出现的问题是"看起来生效了,实际只生效了一半"。我一般用下面这张表逐项过:

检查项验证方法关注点
清理动作是否执行脚本内打日志 +dmesg比对时序是否早于目标路径可用
清理是否只执行一次连续重启三次,观察标志文件标志与状态是否放在同目录
状态是否真的消失逐项读取,不能只看表面目录有些状态有内存缓存
重启后是否恢复正常完整断电重启而非软重启软重启可能跳过引导阶段
是否有 avc 拒绝dmesg | grep avc全量扫描开机阶段日志容易被刷掉
加密场景是否正常未解锁状态下观察行为CE 存储路径在解锁前不可见
高风险项是否被排除逐条核对白名单校准、唯一标识不能进自动流程

其中"连续重启三次"这一项我建议一定要做,成本很低但能抓住绝大多数幂等设计错误。很多问题在第一次开机时完全看不出来,第二次开机才暴露,因为第一次的开机流程和后续开机走的路径不完全一样。

还有一个细节:验证"状态是否真的消失"的时候,不要只看文件在不在,有些状态会被加载进共享内存,文件删了但内存里的值还在,表现出来就是"文件没了但行为没变"。这种情况需要同时确认重启后属性值和实际行为。

7.2 回滚路径怎么提前埋好

清理类改动一旦出问题,往往表现为"设备能开机但某些功能异常",比完全开不了机更难定位。所以在提交这种改动之前,我要求自己能答出三个问题:怎么关掉它、怎么恢复被清掉的东西、怎么在不刷机的前提下验证。

第一个问题的答案通常是属性开关。前面说的persist.vendor.state.reset那种设计,反过来也能当总闸用,设成 0 就彻底不执行清理。这个属性本身不能放在被清理的范围内,否则开关自己会丢。

第二个问题最容易被忽略。如果清理动作删掉的东西里有系统后续需要重建的,光删不建会让系统进入一个半残状态。所以清理脚本里每删一项,都要想清楚"系统会不会自己重建",不会的话就必须在脚本里显式重建,包括目录、权限和 SELinux 上下文三件套。

第三个问题的答案是日志。在脚本里保留一份轻量的执行记录,写到一个不会被清掉的位置,出问题时能一眼看出清理是在哪一步停住的。这份日志不需要复杂,几个时间戳加几行结果就够了,但它能省下的排查时间远超写它的成本。

我在实际项目里踩过最深的一个坑,是把清理逻辑和标志位放在了同一个/data/vendor子目录里,本地测试永远是清一次就停,到了产线批量刷机却变成每次开机都清,最后追下来是产线流程里有一个环节会额外重建这个目录,把标志一起清掉了。从那之后我给自己定了条死规矩:标志位永远单独放,且永远不放在任何会被清理脚本扫到的路径下。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进