AOSP 14 OTA 升级指导(三):三条升级链路——Java API、自研 HAL 与 TR069

0 阅读23分钟

系列第 3 / 6 篇 · 承接第 2 篇《OTA 包的构成与生成》

系列目录(规划):

  1. 总览:A/B 无缝升级架构与核心组件
  2. OTA 包的构成与生成:payload.bin / metadata / otacert 与 ota_from_target_files
  3. 升级链路:Java UpdateEngine、自研 HAL 与 TR069 / MQTT 三条路怎么选(本篇)
  4. update_engine 内部机制:Action 流水线、下载、校验与 postinstall
  5. 切槽、回滚保护与升级后首启:bootctl HAL、update_verifier、checkpoint
  6. 非 A/B 设备对照:传统 Recovery 升级、sideload 与现场刷机(附 A9 设备移植对照)

说明:文中产品名、设备名、厂商名、内部模块名一律用中性占位符 (base / OtaApp / oemupdater / opsd / device_x),不影响任何结论。


0. 本篇要解决什么问题

第 2 篇把一个 OTA zip 拆到了字节级。但包是死的,真正决定"升级能不能成"的是调用链。上手时通常是这三个问题:

  1. 谁下载? 是我的 App 下、还是 update_engine 自己下、还是运维守护进程下?
  2. 谁解析 zip? 谁算出 payload.bin 的 offset / size,谁读 payload_properties.txt?
  3. 谁判定成功? onPayloadApplicationCompleted(0) 就算成功?还是要重启后才知道?

同一个 Android 14 产品里,这三件事往往有 三套彼此独立的实现(App 一套、HAL 一套、运维守护一套), 口径不同、包目录不同、成功判据不同,最后表现为"同一台设备,App 升级能成,运维平台下发就失败"。

本篇把三条链路拆到"谁在做什么"的粒度,并在每一条上都标出真实会把人卡住的那个点。


1. 一张图看清三条链路

① Java API(App)
   OtaApp ──applyPayload(url,off,size,headers)──▶ update_engine ──▶ 写 target slot
          ↑ 自己下载 zip 到 /data/ota_package        (file:// 时引擎不下载)
          ↑ 自己解 zip 拿 offset/size/headers

② 自研 HAL(vendor AIDL,给 CLI / 守护 / 产测)
   client ──IUpdater.applyUpdateFromUrl(url)──▶ oemupdater 服务
                                                  ├─ libcurl 下载整包到固定目录
                                                  ├─ 解析 zip → offset/size/headers
                                                  └─ applyPayloadFd(fd, …) ──▶ update_engine

③ TR069 / MQTT(无人值守运维)
   平台下发 SET_UPGRADE(url + md5 + 限速 + task_id)──▶ opsd
                                                          ├─ 下载 → md5 校验 → 应用
                                                          ├─ 状态机持久化 / 忙检查
                                                          └─ 重启后读 rollback-happened 判成败
维度① Java UpdateEngine② 自研 HAL oemupdater③ 运维守护 opsd
典型使用方系统更新 AppCLI / 产测 / 其他守护进程远程运维平台
谁下载App(或让引擎按 Range 拉)服务(libcurl → 固定目录)守护进程(自带下载器)
是否整包落盘看实现(file:// 必须落;http(s):// 可不落)必须必须
谁解析 zip 取 offset/headersApp服务守护进程
交给引擎的接口applyPayload(url,…)applyPayloadFd(fd,…)applyPayloadFd(fd,…)
进度来源UpdateEngineCallback.onStatusUpdate引擎回调 → 转发 IUpdaterCallback本地日志 + 状态机上报
成功判据回调码 0(52 视场景)同左,0/52 都算成功重启后看 rollback-happened
断电/崩溃恢复无无有(任务持久化)
限速 / 包级 MD5无无有
工程完整度中(够用)低(够调试)高(唯一能无人值守)

2. 公共底座:update_engine 的 Binder 契约

三条链路最后都落到同一个地方。这一节讲清"接口长什么样、什么时候会拒绝你", 后面三条链路就只是"谁在调它"的区别。

2.1 两个服务名,别调错

服务名实现给谁用
android.os.UpdateEngineServicesystem/update_engine/aosp/binder_service_android.h:43Java android.os.UpdateEngine
android.os.UpdateEngineStableServicesystem/update_engine/aosp/binder_service_stable_android.h:44-45稳定 AIDL(C++ 侧走这个)
// frameworks/base/core/java/android/os/UpdateEngine.java:56
private static final String UPDATE_ENGINE_SERVICE = "android.os.UpdateEngineService";
// frameworks/base/core/java/android/os/UpdateEngine.java:249-250
mUpdateEngine = IUpdateEngine.Stub.asInterface(
        ServiceManager.getService(UPDATE_ENGINE_SERVICE));

C++ 侧调 android.os.UpdateEngineService 也能通,但 AOSP 的约定是"新代码走 stable": stable 服务的 AIDL 有版本冻结(aidl_api),不会随主服务接口演进被改坏。

2.2 服务默认是 disabled 的

# system/update_engine/update_engine.rc:1
service update_engine /system/bin/update_engine --logtostderr --logtofile --foreground
    ...
    disabled            # :6
    ...
on property:ro.boot.slot_suffix=*   # :8
    enable update_engine

含义很重要:只有 A/B 设备(ro.boot.slot_suffix 非空)才会把 update_engine 拉起来。 在非 A/B 设备上你写什么 applyPayload 都会得到"服务不存在 / 连接失败"——那不是权限问题,是服务根本没启动。 (这也是第 1 篇"先判断升级形态"的原因。)

2.3 applyPayload(url,…) 与 applyPayloadFd(fd,…) 的本质区别

applyPayload(url, offset, size, headers)applyPayloadFd(fd, offset, size, headers)
声明system/update_engine/aosp/binder_service_android.cc:101同文件 :117
谁搬字节update_engine(libcurl_http_fetcher)调用方(文件已经在本地)
下载器能力Range 分段、断点、限速、超时(见 §4.3)不涉及
典型坑服务器不支持 Range / 走了会改包的代理文件权限、路径必须在引擎可读的目录
空实现system/update_engine/aosp/update_attempter_android.cc:215同文件 :377

同一个引擎、同一套校验、同一个写入路径,只有"数据从哪来"不同。 所以排障时第一步永远是分清"这次是 URL 模式还是 fd 模式",两者的日志完全不同:

  • URL 模式失败先看网络:DownloadTransferError、HTTP 码、Range 响应头;
  • fd 模式失败先看文件:open 失败、pread 短读、PayloadSizeMismatch。

2.4 回调必须能收到:bind 的时机

// frameworks/base/core/java/android/os/UpdateEngine.java:261
public boolean bind(final UpdateEngineCallback callback, final Handler handler)

服务端在 bind 时做了一件很实用的补偿:

// system/update_engine/aosp/binder_service_android.cc:64-71
if (last_status_ != -1) {
  auto status = callback->onStatusUpdate(last_status_, last_progress_);
  ...
}

也就是"晚绑也能拿到最后一次状态"。但只补状态、不补完成:onPayloadApplicationComplete 只发一次, 绑晚了就永远收不到。所以三条链路里凡是"先 apply 再绑回调"的写法,都会表现成 "升级其实跑完了,但界面一直转圈"。

正确的顺序只有一个:

bind(callback)  →  applyPayload(...)  →  在回调里等 0/52 或错误码  →  unbind()

2.5 状态码与错误码:把两张表背下来

onStatusUpdate(int status, float percent) 的 status(UpdateEngine.java:187):

值常量含义
0IDLE空闲(:191)
3DOWNLOADING下载/写入中(:206),percent 在这段才有意义
4VERIFYING校验中
6UPDATED_NEED_REBOOT应用完成,等重启(:222)
8ATTEMPTING_ROLLBACK首启失败,正在回滚(:233)
9DISABLED引擎被禁用(:238),通常是服务没起来

onPayloadApplicationComplete(int errorCode) 常用的几个(system/update_engine/common/error_code.h 与 Java 常量一一对应):

码常量人话
0kSuccess(:26)应用成功且(默认)已把新槽设为下次启动
9DOWNLOAD_TRANSFER_ERROR取包失败(网络 / Range / 代理)
10PAYLOAD_HASH_MISMATCH_ERRORFILE_HASH 对不上
12DOWNLOAD_PAYLOAD_VERIFICATION_ERROR签名验证失败(otacert 不匹配)
51PAYLOAD_TIMESTAMP_ERROR时间戳比当前版本旧,拒绝降级
52kUpdatedButNotActive(:78)应用成功,但没切槽(用 SWITCH_SLOT_ON_REBOOT=0)
60NOT_ENOUGH_SPACE空间不足
61DEVICE_CORRUPTED设备状态坏了,后续升级都应拒绝

52 是这一整套里最容易出事的一个码:它不是失败,但很多实现写的是 if (code == 0), 于是两段式升级永远被判成"升级包异常"。§3.5 有真实例子。

2.6 同一时刻只允许一个 apply

// system/update_engine/aosp/update_attempter_android.cc:221-228
if (status_ == UpdateStatus::UPDATED_NEED_REBOOT) {
  return LogAndSetError(error, FROM_HERE, "An update already applied, waiting for reboot");
}
if (processor_->IsRunning()) {
  return LogAndSetError(error, FROM_HERE, "Already processing an update, cancel it first.");
}

引擎不排队、也不覆盖,直接报错。"防重复触发"必须由调用方自己做——三条链路都各自实现了一遍: App 用 AtomicBoolean + 锁,HAL 用互斥,守护进程用 sys.upgrade.*.status 属性。谁漏了, 用户连点两下升级按钮就会看到"升级失败",而实际第一次正在正常跑。


3. 链路 ①:Java android.os.UpdateEngine(App 里最常见)

3.1 准入门槛有三道

门槛具体不满足的表现
API 可见性UpdateEngine 是 @SystemApi(UpdateEngine.java:52-53),非 SDK API普通 App 编译期就找不到这个类
签名/位置必须是 platform 签名并装在 priv-app能编过,运行时 SecurityException
SELinux调用方域要有 update_engine_service:service_manager findbind 返回 false,日志 avc: denied … scontext=u:r:xxx_app

AOSP 里这条 allow 是按域逐个批的,不是给所有 App:

# system/sepolicy/private/system_server.te:954
allow system_server update_engine_service:service_manager find;
# system/sepolicy/private/gmscore_app.te:19
allow gmscore_app update_engine_service:service_manager find;

服务端的类型和注册(对照看,才知道自己要补哪一半):

# system/sepolicy/public/service.te:49
type update_engine_service,     service_manager_type;
# system/sepolicy/public/update_engine.te:37
add_service(update_engine, update_engine_service)

车机产品里给 platform_app 开这个口的写法可以直接照抄:

# packages/services/Car/car_product/sepolicy/private/platform_app.te:5
    update_engine_service

3.2 一次完整调用(照这个骨架抄)

// 1) 选包:本地 zip(也可直接给 http(s) URL)
//    前置自检:zip 里必须同时有 payload.bin 与 payload_properties.txt,否则直接判"包异常"
File zip = new File("/data/ota_package/update.zip");

// 2) 取 offset / size / headers
long offset  = payloadOffset(zip);       // 见 §3.3
long size    = payloadSize(zip);         // ZipEntry.getSize(),注意 ZIP64 时为 -1
String[] hdr = readProperties(zip);      // payload_properties.txt 的每行原样

// 3) 先 bind,再 apply(顺序不能反,原因见 §2.4)
UpdateEngine engine = new UpdateEngine();
engine.bind(new UpdateEngineCallback() {
    @Override public void onStatusUpdate(int status, float percent) {
        // status=3 时 percent 才有意义;也可用 6 作为"可以提示重启"的信号
    }
    @Override public void onPayloadApplicationComplete(int errorCode) {
        boolean ok = (errorCode == UpdateEngine.ErrorCodeConstants.SUCCESS
                   || errorCode == UpdateEngine.ErrorCodeConstants.UPDATED_BUT_NOT_ACTIVE);
        // … 见 §3.5:52 必须算成功
    }
});

// 4) 应用
engine.applyPayload("file://" + zip.getAbsolutePath(), offset, size, hdr);

headers 就是第 2 篇里那四个头(FILE_HASH / FILE_SIZE / METADATA_HASH / METADATA_SIZE), 原样传,不要自己拼、不要漏行。缺任何一个,引擎会走不同分支: FILE_SIZE 缺失时它用 size 参数兜底(update_attempter_android.cc:246-251), 但 FILE_HASH 缺失就等于放弃整包校验——线上绝对不要这么干。

3.3 深坑 1:payload.bin 的偏移怎么算(附实测)

线上常见写法是"从头逐字节扫 0x04034b50(local file header 签名),命中后比对文件名":

// 反面教材:能跑,但最坏情况会退化
for (long pos = 0; pos < fileLength - 4; pos++) {
    raf.seek(pos);                                   // 每字节一次系统调用
    if (Integer.reverseBytes(raf.readInt()) == 0x04034b50) {
        raf.skipBytes(22);
        int nameLen = Short.reverseBytes(raf.readShort()) & 0xffff;
        int extraLen = Short.reverseBytes(raf.readShort()) & 0xffff;
        ...
        if ("payload.bin".equals(name)) return pos + 30 + nameLen + extraLen;
    }
}

我在一台普通 PC 上用一个 1.4 GB 的合成 OTA 包实测了几种做法(包内 5 个条目,payload.bin 在第 3 个):

做法耗时说明
逐字节 seek + readInt(命中在 2255 字节)4 ms包结构正常时其实很快
逐字节 seek + readInt(最坏:扫满 1.4 GB)20 MB 用 32.6 s,折算 ≈ 38 分钟每字节一次 seek+read,十亿次系统调用
1 MB 缓冲块扫描整包 1.4 GB1.0 s同样能找签名,吞吐约 1.4 GB/s
EOCD → 中央目录 → 本地头偏移0.2 ms只读尾部 64 KB + 中央目录

结论不是"逐字节一定慢",而是:它的成本完全取决于 payload.bin 排在第几个条目。 一旦包内顺序变了(增量包、后处理重打包、payload.bin 落在尾部),或者包里干脆没有 payload.bin(包损坏/传了一半), 就会退化成分钟级扫描——而设备上 eMMC 的随机读比 PC 慢得多,用户在界面上看到的就是"点了升级,转圈十几分钟没反应"。

正确做法是走 ZIP 的中央目录(ZipEntry 不暴露这个偏移,得自己读):

// 推荐:EOCD(0x06054b50) → 中央目录(0x02014b50) → 本地头(0x04034b50)
// CD 记录里 +42 处是"本地头相对偏移",再去本地头读 nameLen/extraLen,即得数据起点
static long payloadDataOffset(RandomAccessFile raf) throws IOException {
    long fileLen = raf.length();
    int tail = (int) Math.min(fileLen, 66 * 1024);
    byte[] buf = new byte[tail];
    raf.seek(fileLen - tail);
    raf.readFully(buf);
    ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
    int eocd = -1;
    for (int i = tail - 22; i >= 0; i--) {
        if (bb.getInt(i) == 0x06054b50) { eocd = i; break; }
    }
    if (eocd < 0) throw new IOException("EOCD not found");
    int entries  = bb.getShort(eocd + 10) & 0xffff;
    long cdOff   = bb.getInt(eocd + 16) & 0xffffffffL;
    long cdSize  = bb.getInt(eocd + 12) & 0xffffffffL;
    byte[] cd = new byte[(int) cdSize];
    raf.seek(cdOff);
    raf.readFully(cd);
    ByteBuffer cdb = ByteBuffer.wrap(cd).order(ByteOrder.LITTLE_ENDIAN);
    for (int p = 0, i = 0; i < entries && p + 46 <= cd.length; i++) {
        int nameLen    = cdb.getShort(p + 28) & 0xffff;
        int extraLen   = cdb.getShort(p + 30) & 0xffff;
        int commentLen = cdb.getShort(p + 32) & 0xffff;
        long lho       = cdb.getInt(p + 42) & 0xffffffffL;
        String name    = new String(cd, p + 46, nameLen, StandardCharsets.UTF_8);
        if ("payload.bin".equals(name)) {
            raf.seek(lho + 26);
            int ln = Short.reverseBytes(raf.readShort()) & 0xffff;
            int le = Short.reverseBytes(raf.readShort()) & 0xffff;
            return lho + 30 + ln + le;
        }
        p += 46 + nameLen + extraLen + commentLen;
    }
    throw new IOException("payload.bin not found");
}

这段代码我在同一个 1.4 GB 包上跑过:偏移与逐字节法完全一致(2255), 从偏移处读出的 4 字节正是 CrAU,耗时 0.2 ms。 已知边界:> 4 GB 的包会用 ZIP64 的 EOCD(0x06064b50),需要再走一层 ZIP64 定位器—— 1.4–2 GB 级别的 OTA 不会遇到,但做通用工具时要补。

3.4 深坑 2:file:// 的路径、权限与 SELinux

file:// 模式下读文件的是 update_engine(root),不是你的 App,所以三件事要同时成立:

条件说明常见错法
路径在引擎可读的位置项目惯例是 /data/ota_package/update.zip直接把 /sdcard/xxx.zip 交给引擎
文件权限可读chmod 644(App 私有目录默认 600)复制完忘了改权限 → open 失败
SELinux 标签正确/data/ota_package 是引擎域允许的标签放 /data/local/tmp → avc: denied

/sdcard 是 FUSE 视图,update_engine 看不到;真要复用用户选的包,得先映射回真实路径再复制:

// /sdcard/x.zip  →  /data/media/0/x.zip
static String toRealPath(String p) {
    if (p.startsWith("/sdcard/"))                return "/data/media/0/" + p.substring(8);
    if (p.startsWith("/storage/emulated/0/"))    return "/data/media/0/" + p.substring(20);
    if (p.startsWith("/storage/self/primary/"))  return "/data/media/0/" + p.substring(22);
    return p;
}

这个"/sdcard → /data/media/0"的映射不通,症状是升级永远卡在 0%,但没有任何错误日志—— 因为引擎根本没打开文件(fd 模式)或 curl 打不开 file://(URL 模式)。排障时先 ls -lZ 看标签,再 cat 一下确认能读。

3.5 深坑 3:52 不是失败,以及两段式升级

真实实现里出现过这样的分支(简化):

@Override public void onPayloadApplicationComplete(int errorCode) {
    if (errorCode == 0) {
        // 成功 → 3 秒后重启
    } else {
        // 一律当失败:"升级包异常"
    }
}

这段代码平时没问题——因为默认 SWITCH_SLOT_ON_REBOOT=1,成功就是 0。 但只要有人为了"装完先不重启、等用户确认"而传了 SWITCH_SLOT_ON_REBOOT=0,引擎就返回 52, 于是"升级其实已经成功写入新槽,界面却报升级包异常,用户重试又提示已在等待重启"。修法只有一行:

boolean ok = (errorCode == UpdateEngine.ErrorCodeConstants.SUCCESS
           || errorCode == UpdateEngine.ErrorCodeConstants.UPDATED_BUT_NOT_ACTIVE);

SWITCH_SLOT_ON_REBOOT=0 的完整语义(UpdateEngine.java:340-346)值得记住:

第一次 applyPayload(带 SWITCH_SLOT_ON_REBOOT=0):只写数据,不切槽 → 返回 52
第二次 applyPayload(同一个包,带 SWITCH_SLOT_ON_REBOOT=1):只做"校验 + 切槽"这点最小工作 → 返回 0

这套两段式非常适合"下载安装放在后台、重启时机交给用户"的产品:安装阶段不掉电风险窗口很短, 真正切槽只花几秒。代价就是调用方必须正确处理 52 和"第二次 apply"。

3.6 深坑 4:空间预检别省

applyPayload 不是"先检查空间再返回",而是写到一半才发现不够,此时错误码可能是 60(NOT_ENOUGH_SPACE), 也可能只是一个笼统的 FILESYSTEM_COPIER_ERROR。稳妥做法是先用官方接口问一句:

// frameworks/base/core/java/android/os/UpdateEngine.java:578
public AllocateSpaceResult allocateSpace(...)

工程上的经验值是"包大小 + 200 MB":/data 可用空间小于这个数就别开始, 否则用户会经历"下载 1.4 GB → 应用到 60% → 失败 → 空间更少了"。 (另外别忘了 payload.bin 要被写进目标槽,/data 不够时 snapshot 也会失败,第 5 篇细讲。)

3.7 重启由谁做

应用完成 ≠ 设备在新系统上跑。重启这一步必须由调用方做:

PowerManager pm = (PowerManager) ctx.getSystemService(Context.POWER_SERVICE);
pm.reboot("ota");        // 需要 android.permission.REBOOT(signature|privileged)

reboot() 最终落到属性 sys.powerctl,由 init 抢先处理:

// system/core/init/init.cpp:370
if (name == "sys.powerctl") { … }

切槽不在这里发生:写 misc/bootloader 控制块的是引擎在 apply 阶段做完了的, 重启只是让 bootloader 按新标志位加载另一个槽(第 5 篇细讲 cutover 与回滚)。

3.8 这条链路适合谁

  • 适合:有 UI 的系统更新 App、需要在应用内展示进度/让用户确认重启的产品。
  • 不适合:无人值守运维(App 可能没在跑)、产测/脚本(不该为升级装一个 App)。

4. 链路 ②:自研 HAL(vendor AIDL)

4.1 为什么还要再写一个 HAL

因为 UpdateEngine 是 @SystemApi:只有 platform 签名的特权 App 能用,C++ 守护进程和 CLI 用不了。 而现场需要的往往是"一个命令就能升级":

updater.cli --apply_url http://10.0.0.2/update.zip --reboot

于是厂商侧通常在 vendor 里放一个 AIDL 服务,把"下载 + 解析 + 调引擎"包起来。 接口设计的典型形态:

方法语义
applyUpdateFromFile(path, reboot)设备本地文件;不下载
applyUpdateFromUrl(url, reboot)服务自己下载后应用;reboot=true 时成功后自动重启
applyUpdateFromUrlWithCallback(url, cb)同上,但进度回传、不负责重启(职责分离)
wipe(flags, reboot, reason)非升级:写擦除标记,重启进 recovery 擦数据

结果枚举也建议固定下来,避免调用方去猜错误码:

OK / DOWNLOAD_FAILED / INVALID_PACKAGE / ENGINE_FAILED / ENGINE_TIMEOUT

4.2 服务内部的六步

applyUpdateFromUrlWithCallback(url, callback)
 ① 校验 URL scheme(只允许 http/https,防止 file:// 被滥用)
 ② 准备缓存目录(固定路径,例如 /data/ota_update/),清理旧包
 ③ 下载整包(自研 libcurl 封装,参数见下表)
 ④ 解析 zip:payload.bin 偏移/大小 + payload_properties.txt 头
 ⑤ 先 bind(UpdateEngineStableService) 回调,再 applyPayloadFd(fd, offset, size, headers)
 ⑥ 阻塞等 onPayloadApplicationComplete,超时上限(例如 7200 s)→ 返回结果

第 ⑤ 步的顺序是硬要求(§2.4),第 ⑥ 步的"阻塞等待"是 HAL 与 App 的分水岭: HAL 必须把异步引擎回调转成同步返回值,否则调用方拿不到结果。

4.3 下载器参数:抄这张表就够了

项建议值为什么
连接超时30 s内网 30 s 连不上,基本就是地址/路由错
重试次数10(区分可重试/不可重试)4xx 立刻失败,5xx/超时才重试
卡死检测180 s 无字节增长即中止比"总超时"更能反映真实卡死
断点续传写 .tmp + RESUME_FROM_LARGE,成功后 rename1.4 GB 包重下代价太大
重定向最多 5 跳太多跳通常意味着配错
TLS 校验生产必须开见过为省事关掉校验,结果中间人改包也能"升级成功"
日志脱敏URL 去掉 query/fragment签名 URL 会带 token

update_engine 自己的下载器也是这套参数(libcurl_http_fetcher.cc): CURLOPT_RANGE(:229)、低速阈值与时长(:243/:246)、连接超时(:249)、跟随重定向(:254)、 以及 scheme 白名单(HTTP :290、HTTPS :308、FILE :329)——厂商侧照抄这套数值最省事。

4.4 这条链路的两个硬伤

  1. 必须先整包落盘:1.4–4 GB 的包先写一份到 /data,再交给引擎就地读。 等于同一份数据在 /data 里占一份、目标槽再写一份。/data 紧张的产品要专门预留。
  2. 没有状态持久化:进程重启/断电后,调用方无法查询"上次升到哪了"。 要恢复能力,就得往链路 ③ 的任务模型靠。

4.5 SELinux 接线:这一步最容易翻车

AIDL 服务写完、能编译、updater.cli 在本机 setenforce 0 下也跑通了,装到 enforcing 的机器上就是不通。 原因是一条链上缺了任意一环:

新服务名 com.example.updater.IUpdater/default
        │
        ├─ ① service_contexts 里没有映射   → 落到兜底标签 default_android_service
        │        ↓
        │      system/sepolicy/public/domain.te:508
        │        neverallow * default_android_service:service_manager *;
        │        ↓
        │      addService 被拒(服务起不来,日志 avc denied add)
        │
        ├─ ② 没有专属 type(service.te 里没声明)
        └─ ③ 调用方域没有 find 权限(客户端 bind 被拒)

对照 AOSP 里 update_engine 自己是怎么做的,就知道该补哪三处:

环节AOSP 里的范本
声明服务类型system/sepolicy/public/service.te:49
服务域注册自己system/sepolicy/public/update_engine.te:37(add_service)
调用方获得 findsystem/sepolicy/private/system_server.te:954、system/sepolicy/private/gmscore_app.te:19

补全的最小集合(厂商 sepolicy 里):

# 1) 声明类型
type oemupdater_service, service_manager_type;
# 2) 服务域注册
add_service(oemupdater, oemupdater_service)
# 3) 调用方 find
allow system_app oemupdater_service:service_manager find;
# 4) 服务自己的数据目录(type_transition + allow,否则下载落盘就失败)

第 4 条经常被漏:服务能起来、能调引擎,但下载写 /data/ota_update/ 时被拒, 表现为"下载进度 0% 然后 ENGINE_FAILED"。这类问题的定位方法只有一个:adb shell dmesg | grep avc, 把每条 denied 按"服务域 / 调用方域 / 数据目录"三堆归好,一次性补完,别一条一条试。


5. 链路 ③:TR069 / MQTT 运维守护

5.1 命令模型

平台下发一条升级命令,字段通常长这样:

字段作用
upg_url包地址;守护进程还要做域名白名单校验,避免被下发任意地址
md5包级 MD5(与 payload 内的 sha256 是两回事,管的是"传输有没有坏")
traffic限速(kbps),避免升级把用户带宽打满
task_id任务标识,用于状态上报与去重

5.2 状态机:这条链路的核心价值

Idle
 │ SET_UPGRADE(upg_url + md5 + traffic + task_id)
 ▼
Accepted ──(URL 不在白名单 / 参数非法)──────────────▶ Failed
 │
 ▼
Downloading ──(HTTP 错误 / 卡死 180s / 无空间)──────▶ Failed
 │  (写 .tmp,按 RESUME_FROM_LARGE 续传,成功后 rename)
 ▼
Downloaded
 │
 ▼
Verifying(MD5 比对平台下发值)
 │
 ├─(不匹配)─────────────────────────────────────────▶ Failed
 ▼
Applying(applyPayloadFd,阻塞等回调 0 / 52)
 │
 ├─(其它错误码 / 超时)──────────────────────────────▶ Failed
 ▼
PendingReboot(落盘 task_id + 包路径)
 │
 ▼  设备重启
读 /data/misc/update_engine/prefs/rollback-happened
 │
 ├─存在(= 新槽首启失败已回滚)─────────────────────▶ Failed
 ▼不存在
Success(清 pending,上报 task_id)

三个"只有它具备"的能力:

  1. 任务可恢复:状态和 task_id 落在 /data 下的固定目录,进程被杀后重启还能接着报进度, 平台侧不会看到"任务消失"。
  2. 限速:traffic 字段直接进 curl 限速参数,运维时段可控。
  3. 忙检查:升级前先看 sys.upgrade.*.status,有任务在跑就拒绝新命令(对应 §2.6 的引擎限制)。

5.3 判定"升级成功"的正确姿势

这是三条链路里唯一做对了的地方:应用成功只是"写完了",升级成功要等重启后看有没有回滚。

# /data/misc/update_engine/prefs/ 下的 prefs 文件
# 常量名:system/update_engine/common/constants.h:100   kPrefsRollbackHappened = "rollback-happened"
adb shell ls -l /data/misc/update_engine/prefs/ | grep rollback
  • 文件存在(值为 1)→ 新槽首启失败、已回滚到旧槽 → 上报 失败;
  • 文件不存在 → 新槽正常起来 → 上报 成功,并清掉 pending 任务。

App 那条链路(§3)如果只看 errorCode == 0 就上报成功,遇到"首启失败自动回滚"会误报成功—— 用户第二天发现系统还是老版本,运维平台显示 100% 成功。这是现场最常见的"升级率虚高"来源。

5.4 一个结构性问题:三套实现互相踩

同一个产品里同时存在 ② 与 ③ 时,通常会有两套下载器 + 两套包目录 + 两套校验口径:

② HAL③ 守护进程
包目录/data/ota_update//data/system/<daemon>/upgrade/
校验无 md5(只靠 payload 内 hash)md5 + payload 内 hash
互斥自己的锁sys.upgrade.*.status

两边同时触发 → 引擎报 Already processing an update,两边都认为失败 → 重试风暴。 要么归一成一套,要么至少共用同一个互斥标志(属性或文件锁),并且让 App 也遵守它。


6. 选型:一张决策表

场景选理由
电视/车机上的"系统更新"入口,用户手动点① Java API有 UI、进度直观、可让用户决定何时重启
产测 / 维修 / 现场脚本② HAL + CLI一条命令搞定,不依赖 App 是否存在
运营商/运维平台批量升级③ 守护进程有状态、有限速、能上报、能无人值守
需要"装完不重启,用户确认后再切槽"① + SWITCH_SLOT_ON_REBOOT=0官方支持的两段式
需要在升级前判断"这包能不能装"① / ② 的 verifyPayloadApplicablebinder_service_android.cc:178,只读 metadata,不下整包

决策树(从"谁触发"出发):

谁触发升级?
├─ 用户(有界面)──────────────▶ ① Java API(App 自己下载 → applyPayload)
├─ 脚本/产测(有人在场)────────▶ ② HAL + CLI(服务下载 → applyPayloadFd)
└─ 平台(无人值守)
   ├─ 要进度上报/限速/断点恢复 ─▶ ③ 守护进程(FSM + 持久化)
   └─ 只要"能升就行" ──────────▶ ② HAL(少写一套状态机)

7. 不写一行代码也要能升级:update_engine_client

三条链路都是"壳",引擎本身带了个调试客户端,现场排障非常有用(已编入 /system/bin):

# 1) 先问一句:这个包能不能装到这台设备上(只读 metadata,不下载 payload)
adb shell update_engine_client --verify \
    --metadata=/data/ota_package/metadata

# 2) 应用(headers 一行一个,直接来自 payload_properties.txt)
adb shell update_engine_client --update \
    --payload=file:///data/ota_package/update.zip \
    --offset=2255 --size=1437799148 \
    --headers="FILE_HASH=…
FILE_SIZE=1437799148
METADATA_HASH=…
METADATA_SIZE=70604" \
    --follow          # 跟随状态直到结束,退出码 0=成功

# 3) 其它动作
adb shell update_engine_client --suspend / --resume / --cancel / --reset_status
adb shell update_engine_client --switch_slot=true      # 只做切槽那一步

参数定义在 system/update_engine/aosp/update_engine_client_android.cc:129-168 (--payload 默认值甚至就是 http://127.0.0.1:8080/payload,方便本地起个 http 服务喂包)。

配合 bootctl 看/改槽位(注意命令名是 get-suffix,不是 get-slot-suffix):

adb shell bootctl hal-info                    # system/extras/bootctl/bootctl.cpp:36 起有完整用法
adb shell bootctl get-current-slot
adb shell bootctl get-active-boot-slot        # 下次启动会加载哪个槽
adb shell bootctl get-suffix 0
adb shell bootctl mark-boot-successful        # :43 把当前槽标记为 GOOD
adb shell bootctl set-active-boot-slot 1      # :45 下次启动切到 slot 1

现场最有用的一条:升级"成功"了但重启后还是老版本 → 先 get-active-boot-slot, 再看 ro.boot.slot_suffix,两个不一致就是"切槽没生效",与包无关。


8. 链路层故障对照表

现象大概率原因查什么
bind() 返回 false,无任何异常SELinux 没给 update_engine_service finddmesg | grep avc 看调用方域
服务不存在 / 连接失败非 A/B 设备,update_engine 是 disabled 且没被 slot_suffix 唤起getprop ro.boot.slot_suffix、ps -A | grep update_engine
进度永远 0%,无报错file:// 路径引擎读不到(/sdcard 未映射、权限 600、标签不对)ls -lZ、run-as/root cat 一下
界面一直转圈,实际已完成先 applyPayload 后 bind,漏掉了完成回调调整顺序(§2.4)
报"升级包异常",但包没问题把 52(UPDATED_BUT_NOT_ACTIVE) 当失败回调码打印出来看是不是 52
Already processing an update重复触发 / 另一条链路正在跑查互斥标志、sys.upgrade.*.status
DOWNLOAD_TRANSFER_ERROR(9)服务器不支持 Range、代理改写、URL 带签名过期curl -r 0-99 -I 看是否 206
PAYLOAD_HASH_MISMATCH(10)下载被截断 / 二次压缩 / 换了包没换 headers重算 FILE_HASH
DOWNLOAD_PAYLOAD_VERIFICATION_ERROR(12)otacert 与 payload 签名不匹配(拿错签名的包)包内 META-INF/com/android/otacert
PAYLOAD_TIMESTAMP_ERROR(51)降级被拒包 post-timestamp vs ro.build.date.utc
NOT_ENOUGH_SPACE(60)/data 或 snapshot 空间不足df /data,按"包大小+200MB"预检
上报成功但版本没变只看 errorCode,没看 rollback-happened重启后读 prefs 目录

9. 本篇小结

  1. 三条链路共用同一个引擎契约:两个服务名、applyPayload 与 applyPayloadFd 的分工、 先 bind 后 apply、状态/错误码两张表、同一时刻只允许一个 apply。
  2. Java API 的六个坑:@SystemApi 准入、payload.bin 偏移(逐字节扫最坏 38 分钟,EOCD 只要 0.2 ms)、 file:// 的路径与标签、52 不是失败、空间预检、重启自己做。
  3. 自研 HAL 的价值是"给非 App 用",代价是整包落盘 + 无状态; 它最容易死在 SELinux 接线上(default_android_service + neverallow)。
  4. 运维守护是唯一能无人值守的,因为它把"应用成功"和"升级成功"分开了: 后者必须重启后读 rollback-happened。
  5. 成功判据统一成一句话:引擎回调 0 或 52 只代表"写完了",设备上真的跑新版本, 要等重启后 update_verifier 校验通过、bootctl mark-boot-successful 落定。

下一篇预告

第 4 篇《update_engine 内部机制》会把 applyPayload 之后发生的事拆开: InstallPlan 怎么生成、DownloadAction → VerifyPayloadApplicable → PartitionWriter 这条 Action 流水线、 增量包为什么必须先校验源分区、payload_consumer 里 REPLACE/REPLACE_BZ/REPLACE_XZ/SOURCE_COPY/BSDIFF/ZUCCHINI 各走哪条路径、 postinstall 与 otapreopt_slot 什么时候跑,以及"同一个 1.4 GB 包,增量 vs 全量在设备上耗时差多少"。