系列第 3 / 6 篇 · 承接第 2 篇《OTA 包的构成与生成》
系列目录(规划):
- 总览:A/B 无缝升级架构与核心组件
- OTA 包的构成与生成:payload.bin / metadata / otacert 与
ota_from_target_files- 升级链路:Java
UpdateEngine、自研 HAL 与 TR069 / MQTT 三条路怎么选(本篇)- update_engine 内部机制:Action 流水线、下载、校验与 postinstall
- 切槽、回滚保护与升级后首启:bootctl HAL、update_verifier、checkpoint
- 非 A/B 设备对照:传统 Recovery 升级、sideload 与现场刷机(附 A9 设备移植对照)
说明:文中产品名、设备名、厂商名、内部模块名一律用中性占位符 (
base/OtaApp/oemupdater/opsd/device_x),不影响任何结论。
0. 本篇要解决什么问题
第 2 篇把一个 OTA zip 拆到了字节级。但包是死的,真正决定"升级能不能成"的是调用链。上手时通常是这三个问题:
- 谁下载? 是我的 App 下、还是 update_engine 自己下、还是运维守护进程下?
- 谁解析 zip? 谁算出
payload.bin的offset/size,谁读payload_properties.txt? - 谁判定成功?
onPayloadApplicationCompleted(0)就算成功?还是要重启后才知道?
同一个 Android 14 产品里,这三件事往往有 三套彼此独立的实现(App 一套、HAL 一套、运维守护一套), 口径不同、包目录不同、成功判据不同,最后表现为"同一台设备,App 升级能成,运维平台下发就失败"。
本篇把三条链路拆到"谁在做什么"的粒度,并在每一条上都标出真实会把人卡住的那个点。
1. 一张图看清三条链路
arduino
① 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 |
|---|---|---|---|
| 典型使用方 | 系统更新 App | CLI / 产测 / 其他守护进程 | 远程运维平台 |
| 谁下载 | App(或让引擎按 Range 拉) | 服务(libcurl → 固定目录) | 守护进程(自带下载器) |
| 是否整包落盘 | 看实现(file:// 必须落;http(s):// 可不落) |
必须 | 必须 |
| 谁解析 zip 取 offset/headers | App | 服务 | 守护进程 |
| 交给引擎的接口 | applyPayload(url,...) |
applyPayloadFd(fd,...) |
applyPayloadFd(fd,...) |
| 进度来源 | UpdateEngineCallback.onStatusUpdate |
引擎回调 → 转发 IUpdaterCallback |
本地日志 + 状态机上报 |
| 成功判据 | 回调码 0(52 视场景) |
同左,0/52 都算成功 |
重启后 看 rollback-happened |
| 断电/崩溃恢复 | 无 | 无 | 有(任务持久化) |
| 限速 / 包级 MD5 | 无 | 无 | 有 |
| 工程完整度 | 中(够用) | 低(够调试) | 高(唯一能无人值守) |
2. 公共底座:update_engine 的 Binder 契约
三条链路最后都落到同一个地方。这一节讲清"接口长什么样、什么时候会拒绝你", 后面三条链路就只是"谁在调它"的区别。
2.1 两个服务名,别调错
| 服务名 | 实现 | 给谁用 |
|---|---|---|
android.os.UpdateEngineService |
system/update_engine/aosp/binder_service_android.h:43 |
Java android.os.UpdateEngine |
android.os.UpdateEngineStableService |
system/update_engine/aosp/binder_service_stable_android.h:44-45 |
稳定 AIDL(C++ 侧走这个) |
java
// frameworks/base/core/java/android/os/UpdateEngine.java:56
private static final String UPDATE_ENGINE_SERVICE = "android.os.UpdateEngineService";
java
// 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 的
bash
# 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 的时机
java
// frameworks/base/core/java/android/os/UpdateEngine.java:261
public boolean bind(final UpdateEngineCallback callback, final Handler handler)
服务端在 bind 时做了一件很实用的补偿:
cpp
// system/update_engine/aosp/binder_service_android.cc:64-71
if (last_status_ != -1) {
auto status = callback->onStatusUpdate(last_status_, last_progress_);
...
}
也就是"晚绑也能拿到最后一次状态"。但只补状态、不补完成 :onPayloadApplicationComplete 只发一次, 绑晚了就永远收不到。所以三条链路里凡是"先 apply 再绑回调"的写法,都会表现成 "升级其实跑完了,但界面一直转圈"。
正确的顺序只有一个:
scss
bind(callback) → applyPayload(...) → 在回调里等 0/52 或错误码 → unbind()
2.5 状态码与错误码:把两张表背下来
onStatusUpdate(int status, float percent) 的 status(UpdateEngine.java:187):
| 值 | 常量 | 含义 |
|---|---|---|
| 0 | IDLE |
空闲(:191) |
| 3 | DOWNLOADING |
下载/写入中(:206),percent 在这段才有意义 |
| 4 | VERIFYING |
校验中 |
| 6 | UPDATED_NEED_REBOOT |
应用完成,等重启(:222) |
| 8 | ATTEMPTING_ROLLBACK |
首启失败,正在回滚(:233) |
| 9 | DISABLED |
引擎被禁用(:238),通常是服务没起来 |
onPayloadApplicationComplete(int errorCode) 常用的几个(system/update_engine/common/error_code.h 与 Java 常量一一对应):
| 码 | 常量 | 人话 |
|---|---|---|
| 0 | kSuccess(:26) |
应用成功且(默认)已把新槽设为下次启动 |
| 9 | DOWNLOAD_TRANSFER_ERROR |
取包失败(网络 / Range / 代理) |
| 10 | PAYLOAD_HASH_MISMATCH_ERROR |
FILE_HASH 对不上 |
| 12 | DOWNLOAD_PAYLOAD_VERIFICATION_ERROR |
签名验证失败(otacert 不匹配) |
| 51 | PAYLOAD_TIMESTAMP_ERROR |
时间戳比当前版本旧,拒绝降级 |
| 52 | kUpdatedButNotActive(:78) |
应用成功,但没切槽 (用 SWITCH_SLOT_ON_REBOOT=0) |
| 60 | NOT_ENOUGH_SPACE |
空间不足 |
| 61 | DEVICE_CORRUPTED |
设备状态坏了,后续升级都应拒绝 |
52 是这一整套里最容易出事的一个码:它不是失败 ,但很多实现写的是
if (code == 0), 于是两段式升级永远被判成"升级包异常"。§3.5 有真实例子。
2.6 同一时刻只允许一个 apply
cpp
// 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 find |
bind 返回 false,日志 avc: denied ... scontext=u:r:xxx_app |
AOSP 里这条 allow 是按域逐个批的,不是给所有 App:
bash
# 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;
服务端的类型和注册(对照看,才知道自己要补哪一半):
bash
# 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 开这个口的写法可以直接照抄:
bash
# packages/services/Car/car_product/sepolicy/private/platform_app.te:5
update_engine_service
3.2 一次完整调用(照这个骨架抄)
java
// 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 签名),命中后比对文件名":
java
// 反面教材:能跑,但最坏情况会退化
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 GB | 1.0 s | 同样能找签名,吞吐约 1.4 GB/s |
EOCD → 中央目录 → 本地头偏移 |
0.2 ms | 只读尾部 64 KB + 中央目录 |
结论不是"逐字节一定慢",而是:它的成本完全取决于 payload.bin 排在第几个条目 。 一旦包内顺序变了(增量包、后处理重打包、payload.bin 落在尾部),或者包里干脆没有 payload.bin(包损坏/传了一半), 就会退化成分钟级扫描------而设备上 eMMC 的随机读比 PC 慢得多,用户在界面上看到的就是"点了升级,转圈十几分钟没反应"。
正确做法是走 ZIP 的中央目录 (ZipEntry 不暴露这个偏移,得自己读):
java
// 推荐: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 看不到;真要复用用户选的包,得先映射回真实路径再复制:
java
// /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 不是失败,以及两段式升级
真实实现里出现过这样的分支(简化):
java
@Override public void onPayloadApplicationComplete(int errorCode) {
if (errorCode == 0) {
// 成功 → 3 秒后重启
} else {
// 一律当失败:"升级包异常"
}
}
这段代码平时没问题------因为默认 SWITCH_SLOT_ON_REBOOT=1,成功就是 0。 但只要有人为了"装完先不重启、等用户确认"而传了 SWITCH_SLOT_ON_REBOOT=0,引擎就返回 52, 于是"升级其实已经成功写入新槽,界面却报升级包异常,用户重试又提示已在等待重启"。修法只有一行:
java
boolean ok = (errorCode == UpdateEngine.ErrorCodeConstants.SUCCESS
|| errorCode == UpdateEngine.ErrorCodeConstants.UPDATED_BUT_NOT_ACTIVE);
SWITCH_SLOT_ON_REBOOT=0 的完整语义(UpdateEngine.java:340-346)值得记住:
ini
第一次 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。稳妥做法是先用官方接口问一句:
java
// 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 重启由谁做
应用完成 ≠ 设备在新系统上跑。重启这一步必须由调用方做:
java
PowerManager pm = (PowerManager) ctx.getSystemService(Context.POWER_SERVICE);
pm.reboot("ota"); // 需要 android.permission.REBOOT(signature|privileged)
reboot() 最终落到属性 sys.powerctl,由 init 抢先处理:
cpp
// 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 用不了。 而现场需要的往往是"一个命令就能升级":
bash
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 服务内部的六步
scss
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,成功后 rename |
1.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.4--4 GB 的包先写一份到
/data,再交给引擎就地读。 等于同一份数据在/data里占一份、目标槽再写一份。/data紧张的产品要专门预留。 - 没有状态持久化:进程重启/断电后,调用方无法查询"上次升到哪了"。 要恢复能力,就得往链路 ③ 的任务模型靠。
4.5 SELinux 接线:这一步最容易翻车
AIDL 服务写完、能编译、updater.cli 在本机 setenforce 0 下也跑通了,装到 enforcing 的机器上就是不通。 原因是一条链上缺了任意一环:
bash
新服务名 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) |
| 调用方获得 find | system/sepolicy/private/system_server.te:954、system/sepolicy/private/gmscore_app.te:19 |
补全的最小集合(厂商 sepolicy 里):
ini
# 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 状态机:这条链路的核心价值
scss
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)
三个"只有它具备"的能力:
- 任务可恢复 :状态和
task_id落在/data下的固定目录,进程被杀后重启还能接着报进度, 平台侧不会看到"任务消失"。 - 限速 :
traffic字段直接进 curl 限速参数,运维时段可控。 - 忙检查 :升级前先看
sys.upgrade.*.status,有任务在跑就拒绝新命令(对应 §2.6 的引擎限制)。
5.3 判定"升级成功"的正确姿势
这是三条链路里唯一做对了的地方:应用成功只是"写完了",升级成功要等重启后看有没有回滚。
bash
# /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 |
官方支持的两段式 |
| 需要在升级前判断"这包能不能装" | ① / ② 的 verifyPayloadApplicable |
binder_service_android.cc:178,只读 metadata,不下整包 |
决策树(从"谁触发"出发):
objectivec
谁触发升级?
├─ 用户(有界面)──────────────▶ ① Java API(App 自己下载 → applyPayload)
├─ 脚本/产测(有人在场)────────▶ ② HAL + CLI(服务下载 → applyPayloadFd)
└─ 平台(无人值守)
├─ 要进度上报/限速/断点恢复 ─▶ ③ 守护进程(FSM + 持久化)
└─ 只要"能升就行" ──────────▶ ② HAL(少写一套状态机)
7. 不写一行代码也要能升级:update_engine_client
三条链路都是"壳",引擎本身带了个调试客户端,现场排障非常有用(已编入 /system/bin):
bash
# 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):
bash
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 find |
`dmesg |
| 服务不存在 / 连接失败 | 非 A/B 设备,update_engine 是 disabled 且没被 slot_suffix 唤起 |
getprop ro.boot.slot_suffix、`ps -A |
| 进度永远 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. 本篇小结
- 三条链路共用同一个引擎契约 :两个服务名、
applyPayload与applyPayloadFd的分工、 先bind后apply、状态/错误码两张表、同一时刻只允许一个 apply。 - Java API 的六个坑 :
@SystemApi准入、payload.bin偏移(逐字节扫最坏 38 分钟,EOCD 只要 0.2 ms )、file://的路径与标签、52不是失败、空间预检、重启自己做。 - 自研 HAL 的价值是"给非 App 用" ,代价是整包落盘 + 无状态; 它最容易死在 SELinux 接线上(
default_android_service+neverallow)。 - 运维守护是唯一能无人值守的 ,因为它把"应用成功"和"升级成功"分开了: 后者必须重启后读
rollback-happened。 - 成功判据统一成一句话 :引擎回调
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 全量在设备上耗时差多少"。