这活儿一开始看起来很简单:在系统端给 Data 分区建一个 upgrade 目录,再实现一个 OTA 功能接口,最后把 SELinux 权限一放,完事。可我实际做下来,光是搞清升级包该放哪儿、由谁来建目录、哪个进程有权限读写,就反复折腾了两周。如果你也在做系统定制,或者要给上层应用提供“往指定分区目录里写升级文件”的能力,这篇文章应该能帮你少走不少弯路。
我的工作背景是负责某渠道定制平板的系统框架,这个项目代号就叫定制平板 X。平台本身有独立的 cache 分区,一开始团队里多数人觉得升级包放 cache 是理所应当的。但真正把 OTA 流程跑起来才发现,问题远比想象得多。下面我从存储选型、init.rc 创建目录、AIDL 接口设计、SELinux te 规则到联调踩坑,完整讲一遍。
1. 为什么要单独在 Data 分区建一个 upgrade 目录
1.1 升级包临时存放的四种选择,没有一个完全省心
系统做 OTA 升级,第一个绕不开的问题就是升级包放哪。传统 Android 设备常见选择有 cache 分区、/data/local/tmp、/sdcard 以及自建目录。我把这几种方案放一起对比过:
| 存放位置 | 优点 | 缺点 |
|---|---|---|
| cache 分区 | 系统标准路径,部分 OTA 流程原生支持 | 分区通常很小,差分包大一点的设备直接放不下;系统有清理 cache 的机制,升级包可能被清掉 |
| /data/local/tmp | 空间基本不受限,adb shell 容易访问 | 权限太开放,任何有 shell 权限的进程都能读写;目录不是为 OTA 设计的,容易被误清理 |
| /sdcard | 空间大,普通应用也能写 | FUSE 层慢,而且 /sdcard 属于共享存储,权限模型不可控,不适合放系统级升级包 |
| /data/upgrade | 空间充足,权限可控,系统进程可直接访问 | 需要自己创建目录,还要单独设计 SELinux 策略,前期工作量最大 |
从纯工程角度看,cache 分区是最“顺理成章”的候选。但我当时量了一下设备分区表,cache 总共只有 400MB,而全量包接近 1.2GB,差分包也常有 300~500MB,cache 根本扛不住。/sdcard 又存在用户可删除、FUSE 转发性能差的问题。所以最终放弃了传统路径,决定在 /data 下自己维护一个专属目录。
1.2 最终确定 /data/upgrade 的三个理由
选择 /data/upgrade 不是拍脑袋,主要基于三点。
第一,容量可控。Data 分区是用户数据分区,容量通常远大于 cache,设备上剩余空间充裕,全量包、差分包、升级日志都能放得下。空间不够时也能通过标准接口提前检测,避免升级过程中才报错。
第二,时序满足要求。/data 分区在系统启动早期就会被挂载,init 进入 post-fs-data 阶段后即可访问。升级包校验、日志收集这类工作可以在桌面解锁前完成,不会阻塞用户操作。我当时还专门确认过文件级加密(FBE)的行为:/data/upgrade 放在 /data 根目录下,属于 DE 存储,开机后、用户解锁前就能读写,这一点对 OTA 可靠性很关键。
第三,权限边界清晰。目录自建之后,owner 设为 system,SELinux context 单独定义,系统组件可以访问,普通应用默认碰不到。相比扔在 /data/local/tmp 里让人随意读写,安全性高很多。
1.3 目录、接口、权限三件事本来就是一条链
你可能觉得这三件事能分开做:先建目录,再写接口,最后授权。实际推进时它们强耦合,顺序错了会反复返工。
目录是为接口服务的使用方准备的。接口要往 upgrade 目录里写入升级包、读取状态、清理残留,那目录的 DAC 权限和 SELinux context 就必须和接口运行进程匹配。SELinux 授权又决定了哪些进程能访问目录,反过来影响接口能在哪个 domain 里跑。所以我建议一开始就把三者当成一个整体设计,而不是拆成三个独立任务。我们项目第一次就是这么拆的,结果接口写完了,目录权限没跟上,联调时所有写入都卡在 avc denied 上。
2. 创建 /data/upgrade 目录:时机、方式与 FBE 的配合
2.1 为什么不能想什么时候建就什么时候建
创建目录看起来就是一句 mkdir,但放在系统端就有严格时机要求。Data 分区挂载之前,路径根本不存在,写入会直接失败。如果等到 zygote 启动后再由某个应用去创建,又可能在开机早期产生竞态:升级服务先启动,目录还没建好,第一步就报错。
所以正确时机是 init 执行到on post-fs-data之后。这个阶段 Data 分区已经挂载、FBE 的 DE storage 已经可用、还没有进入用户解锁流程,是创建系统级目录的最佳窗口。目录在 init 阶段创建,后续服务启动时能直接依赖它存在,不用再处理竞态。
2.2 落地方式:init.rc、file_contexts、restorecon
具体落地我用了两套配置配合。第一套是 init.rc 脚本,在 post-fs-data 阶段建目录并设置 owner:
on post-fs-data mkdir /data/upgrade 0770 system system restorecon /data/upgrademkdir的 0770 权限意味着只有 root 和 system 组成员能写,普通应用从 DAC 层就被挡掉了。restorecon是第二个关键动作:它会根据 file_contexts 配置把目录的 SELinux context 强制改回来,避免被默认的 data_file 类型覆盖。
第二套配置是 file_contexts 文件:
/data/upgrade(/.*)? u:object_r:ota_data_file:s0这条规则的作用是:/data/upgrade 目录以及它下面所有子文件、子目录,启动时经过 restorecon 后都会统一设置为ota_data_file类型。没有这条规则,光在 init.rc 里 mkdir,目录 context 会是默认的data_file,后面接口进程即使有 DAC 权限,SELinux 依然会拦截。
2.3 第一次重启后目录权限/SELinux上下文被重置的完整排查
这里是我这次项目里第一次真正踩坑。最开始我在设备上手动执行了mkdir /data/upgrade,也 chown 成 system:system,当时 ls -Z 看 context 是期望的ota_data_file。结果设备一重启,目录还在,但ls -Zd /data/upgrade显示的变成了u:object_r:data_file:s0。
一开始我以为是 init.rc 没生效,反复改了 n 次启动脚本都没用。后来抓 boot log 才发现,系统启动时会对 /data 分区做一次递归 restorecon,把那些在 file_contexts 里没有明确匹配规则的新目录统一恢复成data_file。我手动设置的 context 属于运行时临时值,一重启就被冲掉了。
解决办法就是上面说的:必须在 file_contexts 里注册/data/upgrade(/.*)?规则,并且在 init.rc 的 post-fs-data 阶段显式执行一次restorecon。这样每次开机,init 都会把目录拉回ota_data_file,不会再被重置成默认类型。
2.4 开机首次进入时目录不存在的处理
FBE 设备上,/data/upgrade 位于 DE 存储,一般不会因为用户解锁而消失,但如果设备恢复出厂设置、userdata 被格式化,目录就会不存在。这时候不能只依赖 init.rc 里的 mkdir,因为 init.rc 只在开机时执行一次,后续运行阶段如果目录被异常删除,系统不会自动补建。
所以我在 OTA 服务启动时加了一个“确保目录存在”的步骤,用 Java 代码实现:
File upgradeDir = new File("/data/upgrade"); if (!upgradeDir.exists()) { if (!upgradeDir.mkdirs()) { throw new IOException("create /data/upgrade failed"); } // 重新设置 owner 和 SELinux context Os.chown("/data/upgrade", 1000, 1000); // system uid/gid selinux_android_restorecon("/data/upgrade", 0); }目录创建后必须重新 restorecon,否则在新环境下它还是默认 context。这里有个小细节:不要盲目调用File.mkdirs()就不管了,因为目录可能被恶意替换成符号链接。服务启动时最好校验一下路径不是 symlink,owner 是 system,再继续写入。
3. OTA 功能接口设计与 Binder 服务注册
3.1 选型:独立 Native 服务还是 system_server 里的 Java 服务
接口形态我纠结过一阵。OTA 功能如果做成独立 Native daemon,优点是不吃 system_server 的内存,升级过程中即使 system_server 有卡顿也不会影响底层逻辑;缺点是 AIDL 绑定、权限管理都要自己搞一套。如果做成 system_server 里的 Java Binder 服务,实现简单、容易被系统应用调用,但长时间运行的升级任务可能会阻塞主线程。
我们最后选了后者:Java 服务注册到 SystemServiceManager,同时把真正耗时的文件搬移、校验逻辑放到独立线程池。原因很简单:项目里的升级包搬运和状态查询都不是高频操作,Java 服务足够用,而且能直接复用安卓系统的权限框架,省掉很多底层的绑定细节。
3.2 AIDL 接口定义:最少需要这四个方法
接口设计不能太丰富,也不能太简陋。我最后定下来四个核心方法,再加一个回调:
interface IUpgradeService { boolean prepareUpgradePackage(in String srcPath, in String targetPath, in long packageSize, out int errorCode); boolean applyUpgrade(in String packagePath, int flags); int getUpgradeState(); boolean discardUpgradeData(); void registerCallback(IUpgradeCallback callback); }prepareUpgradePackage负责把上层下载好的临时包搬移到 /data/upgrade 指定位置,并做基础校验;applyUpgrade触发正式升级;getUpgradeState给 UI 层查询当前状态;discardUpgradeData用于清理残留。回调接口用于上报进度:
interface IUpgradeCallback { oneway void onProgress(int percent, int state); }为什么要把“搬移”和“触发升级”分开?因为升级包下载完成后,不能直接原地触发升级,需要先放进受控目录,确认签名和完整性,再执行 apply。两步分离后,任何一步失败都能单独重试,不会留下半残状态。
3.3 调用者校验与签名权限
接口一旦暴露给 Binder,就必须防住三方应用乱调用。我在两个层面做了校验。
第一层是权限声明。在系统 Manifest 里定义一个 signature|privileged 级别的权限:
<permission android:name="android.permission.MANAGE_OTA_UPGRADE" android:protectionLevel="signature|privileged" />客户端只有声明了该权限,才有资格调用接口。第二层是服务端强制检查:
int uid = Binder.getCallingUid(); if (uid != Process.SYSTEM_UID && uid != Process.SYSTEMUI_UID && !callerHasPermission("android.permission.MANAGE_OTA_UPGRADE")) { throw new SecurityException("caller not allowed"); }这里不建议只用权限检查:有些系统应用声明权限但调用逻辑不可控,所以我在核心操作里直接核对 UID,白名单之外的调用一律拒绝。配合 SELinux 的域隔离,普通第三方应用基本没有任何途径触达这个接口。
3.4 接口里不能只写文件:路径校验、临时文件与原子替换
接口实现里最容易翻车的是路径校验。上层传路径时极容易伪造一个包含..的路径,比如/data/upgrade/../../../data/system/foo。如果直接拿来写文件,就可能写到目录之外。
我采用的方案是统一走 canonical path 校验:
File canonical = new File(packagePath).getCanonicalFile(); if (!canonical.getPath().startsWith("/data/upgrade/")) { throw new IllegalArgumentException("illegal path"); }然后所有写入都经过临时文件再 rename。具体流程是:先把升级包写到.part后缀的临时文件,写入完成后调用 fsync 落盘,再 rename 成正式文件名。这样即使中途断电,也不会留下半截文件伪装成完整包。启动时扫描残留的.part文件并删除,是干净恢复的关键。
4. SELinux 权限授权的落地过程
4.1 avc denied 日志到底怎么读
SELinux 授权第一步不是写规则,而是学会读 avc denied 日志。我遇到过很多同事第一反应就是关 SELinux,或者随手加一个 allow,结果越改越乱。正确做法是抓日志、分析上下文、最小化授权。
典型日志长这样:
avc: denied { write } for pid=1234 comm="ota_updater" name="upgrade" dev="sda1" ino=123 scontext=u:r:ota_updater:s0 tcontext=u:object_r:data_file:s0 tclass=dir permissive=0重点看三处:scontext是调用进程的 domain,这里就是ota_updater;tcontext是被访问对象的类型,这里是data_file,说明目录没被正确设置成ota_data_file;tclass=dir表示目录对象,{ write }是缺的具体权限。
读懂之后,问题可能来自两个方面:要么目录 context 不对,要么 domain 缺 allow 规则。很多时候第一反应写 allow 规则,实际 directory 的 context 根本没设对。
4.2 新增 type、file_contexts 和 te allow 规则
目录类型我定义为ota_data_file,先声明类型:
type ota_data_file, file_type, data_file_type;file_contexts 里的规则前面已经写了。真正补 te 规则时,关键要搞清楚有哪些 domain 需要访问。我们最终涉及两个 domain:OTA 服务进程所在的 domain(假设叫 ota_updater)和系统应用 system_app。于是规则写成:
allow { ota_updater system_app } ota_data_file:dir create_dir_perms; allow { ota_updater system_app } ota_data_file:file create_file_perms;实际调试中还会需要 unlink、setattr、rename 这些权限,可以按日志逐步补充,但不要一口气放一个大范围权限。create_dir_perms和create_file_perms是常用宏,覆盖了创建目录、创建文件的基本操作,够用且不乱。
还要注意 Binder 通信的授权。如果 OTA 服务运行在 ota_updater domain,而另一个系统应用回调它在 system_app domain,两者之间需要允许 binder 调用的规则,否则接口调用能过去,回调却因为 policy 被拦截。
4.3 neverallow 把编译拦下来的那次教训
我最初的想法是让一个普通应用也能通过接口写入 upgrade 目录,方便 ODM 的测试工具使用。于是把 allow 规则写给了 untrusted_app 之类的 domain,结果编译 selinux_policy 时直接被 neverallow 规则拦下。
Android 基础策略里有大量 neverallow,比如普通应用不能直接写系统级 data_file,这是底线。我在那个节点意识到,方向错了:可以给普通应用提供单独的受控接口,但不能直接让它访问受保护的目录。最终我放弃了对 untrusted_app 开放,改为只允许系统应用和 ota_updater 访问。权限范围收窄后,neverallow 报错不再出现。
这也是一个很重要的经验:如果一开始就想让普通应用直接读写系统升级目录,整个设计就要推翻重来。真正的安全边界是“普通应用只能通过系统接口间接访问”,而不是直接对目录授予权限。
4.4 从 permissive 到 enforcing 的验证闭环
SELinux 授权容易陷入“permissive 下跑通就以为完成了”的错觉。我在 permissive 模式下调通所有接口后,特意把设备切回 enforcing,又跑了几轮完整升级流程,果然又暴露了两条漏掉的 avc denied。
正确验证方式是闭环:先在 permissive 下抓满所有 avc denied,逐一补规则;补完后切回 enforcing,再走一遍核心用例:下载、搬移、校验、触发升级、查询状态、清理残留。每一步都要确认 dmesg 里没有对应 deny。最后在关机重启状态下再跑一次升级,确保目录 context 恢复和接口调用顺序都没问题。
只有 setenforce 1 下完整跑通,才算真正完成了 SELinux 授权。permissive 模式只能作为排查工具,不能作为交付标准。
5. 联调阶段的三次翻车记录
5.1 第一次:目录权限开了,数据还是写不进去
所有配置都加好了,目录 ls -Z 也显示 ota_data_file,我以为没问题。结果联调时接口一跑,日志还是报 avc denied,而且 deny 里的 scontext 不是 system_app,而是u:r:upgrade_server:s0。
我当时很困惑:目录类型没问题,为什么会被拒?后来才想明白,接口服务进程根本不是跑在 system_app 域,而是因为它的可执行文件被标记成了另一个 domain upgrade_server。我在 te 文件里给 system_app 加了 allow,但没给 upgrade_server 加。这个教训直接点破一个常识:SELinux 授权一定要基于实际进程 domain,而不是“我想当然它是什么 domain”。
解决方式很简单,把允许规则补到 upgrade_server 上,或者把服务进程归入 system_app 域。我选择了前者,因为独立域更方便限制这个进程的权限。
5.2 第二次:接口能调通,回调却永远超时
这一轮问题最隐蔽。升级接口调用成功了,UI 上却一直卡在“准备中”,回调 onProgress 永远不触发。我抓了 binder 调用栈,发现回调对象注册成功,问题出在域间 binder 通信上。
具体来说,升级服务在 upgrade_server domain,回调对象运行在 system_app domain。从 upgrade_server 向 system_app 反向调用 Binder 时,SELinux 会检查 upgrade_server 是否有权限向 system_app 发起 binder call。权限没配,回调就石沉大海。dmesg 里果然有一条空 avc denied,之前没细看。
补上这条规则后,回调立刻通了:
allow upgrade_server system_app:binder { call transfer };这类问题很难直接用功能测试发现,因为接口本身返回成功,回调失败又不会抛异常。所以联调时一定要同步抓 dmesg,不能只看 UI 层的结果。
5.3 第三次:连续升级三次后磁盘被残留文件占满
功能全部调通后,我做了连续多次升级的稳定性测试。第一次和第二次都正常,第三次开始,prepare 阶段直接报“空间不足”。查了 df 才发现,每次升级包约 1.2GB,三次下来 /data 可用空间只剩下几十 MB。
根因是 applyUpgrade 成功之后,升级包文件一直没有被主动删除。设计上 discardUpgradeData 由上层负责调用,但 UI 层漏了逻辑。更麻烦的是,每次升级中断还会留下.part临时文件,日积月累也会占空间。
我后来在服务内部增加了一套自动清理逻辑:升级成功并确认签名后,只保留最新一份升级包,其余历史包和日志全部清理。启动时自动扫描.part文件,超过两次启动周期的一律删除。这样就算上层调用逻辑有遗漏,底层也不会把磁盘撑爆。
5.4 意外断电和空间不足这两个必须处理的边界
联调到后期,我专门做了断电测试:拷贝升级包到一半直接断电,重启后再进入升级流程。结果发现接口会拿着一个不完整的.part文件当成正式包去校验。这就是为什么前面强调必须“先写临时文件,再 rename,再 fsync”。没有 fsync,即使 rename 完成,数据也可能还在 page cache 里,断电一样丢数据。
空间不足同样不能只靠上层检测。我在 prepare 阶段用 statvfs 获取可用空间,必须满足“升级包大小 + 200MB 安全余量”才允许继续写入。如果空间不够,直接返回 errorCode,不在拷贝一半时才发现写满。
这些边界处理虽然不是主线需求,但 OTA 一旦部署到真实设备,可靠性比功能完整度更致命。
6. 做完这个项目后我留下的检查清单
6.1 改动落地前必查的 5 个点
这个项目做下来,我总结了一份自己的检查清单,后面接手这类需求可以直接套用。
- 目录创建时机是否正确:init.rc 的 mkdir 必须在 post-fs-data 之后,且需要配套 restorecon。
- file_contexts 是否覆盖目录和子文件:只写一条目录规则不够,要用
(/.*)?覆盖完整子树。 - te allow 规则是否匹配实际 domain:先查进程实际 domain,再写 allow,不要凭印象。
- 接口是否做了 canonical path 校验和临时文件写入:没有这两个,路径穿越和断电丢数据都是隐患。
- enforcing 模式完整流程是否无 avc denied:permissive 过了不算过,enforcing 过了才算完。
6.2 调试阶段最常用的命令
调试这类问题,命令不多,但很管用:
# 查看目录的 SELinux 上下文 adb shell ls -Zd /data/upgrade # 抓升级相关 avc denied adb shell dmesg | grep avc | grep upgrade # 临时切到 permissive 模式排查 adb shell setenforce 0 # 恢复 enforcing 模式 adb shell setenforce 1 # 确认升级服务状态和接口绑定 adb shell dumpsys upgrade如果是自己编译的 policy,可以用 audit2allow 快速生成候选规则,但不要直接照搬,要先确认目标 domain 和类型是否合理。
6.3 说给后来者的一句话
现在回头看,这个项目的 diff 其实很小:一段 init.rc、一条 file_contexts、几条 te allow、一个 Binder 服务。但每个小改动背后,都是对 Android 启动时序、Data 分区存储特性、SELinux 策略模型的一次完整理解。
如果让我重做一遍,我会在设计阶段就把 domain 划分、目录 context、接口权限一并定下来,而不是先写接口再补权限。遇到诡异问题先查 SELinux,但也不要什么都甩锅给 SELinux——我这次有两次返工,其实根子都在路径校验和文件生命周期管理上。
最后提醒一句:OTA 属于系统最底层的可靠性能力,测试用例一定要包含断电、空间不足、重复升级、异常清理这些场景。功能能跑通只是起点,能扛住真实环境才叫做完。