29、系统更新与Recovery:Recovery模式、update-engine、脚本编写
系统更新,说白了就是给设备“换血”。嵌入式设备不像手机那样天天推送更新,但一旦需要OTA(Over-The-Air)升级,整个流程的稳定性就至关重要。我做过不少嵌入式项目,最怕的就是升级到一半断电——设备变砖,那可就麻烦了。
今天我们来聊聊Android系统更新的核心机制:Recovery模式、update-engine,以及升级脚本怎么写。这些东西,你平时可能接触不多,但一旦要量产,就绕不开它们。
一、Recovery模式:系统的“安全屋”
Recovery模式,你可以把它理解成Android的“安全模式”升级版。正常系统启动不了时,Recovery就是最后的救命稻草。
Recovery模式的核心作用:
- 执行系统更新(OTA包安装)
- 恢复出厂设置(wipe data/factory reset)
- 运行诊断脚本
- 挂载分区并手动操作
我记得有一次,客户的设备在OTA升级后无法开机,原因就是系统分区写坏了。还好Recovery模式还在,我们通过adb sideload重新推送了完整包,才救回来。嗯,这里要注意:Recovery分区本身必须是只读的,否则连它也坏了,那就只能拆机用烧录器了。
重要:Recovery模式下的操作,通常通过按键组合触发(比如音量上+电源键)。在嵌入式设备中,我建议保留一个物理按键或GPIO触发方式,方便调试。
二、update-engine:系统更新的“引擎”
update-engine是Android 8.0之后引入的A/B系统更新的核心组件。说白了,它负责管理“当前槽位”和“备用槽位”的切换。
A/B系统更新原理:
- 系统有两个槽位:slot A 和 slot B
- 当前运行在 slot A,更新包写入 slot B
- 更新完成后,重启切换到 slot B
- 如果 slot B 启动失败,自动回滚到 slot A
你想想看,这种设计的好处是什么?——永不砖机。即使更新过程中断电,也只是备用槽位没写完,当前系统依然完好。
我在项目中遇到过一个问题:某款嵌入式设备存储空间只有4GB,A/B系统需要双倍分区空间,导致用户可用空间严重不足。后来我们改用了“非A/B”模式,但增加了升级前的完整性校验和断电保护机制。说白了,技术选型要结合实际硬件条件。
三、update-engine的工作流程
update-engine的工作流程,我画了一张图,方便你理解:
流程看起来简单,但实际坑不少。比如:
- 校验失败怎么办?——直接丢弃包,不执行更新
- 写入过程中断电?——备用槽不完整,下次启动还是当前槽
- 启动后系统不稳定?——可以设置“启动计数”,多次失败后自动回滚
我的习惯:在update-engine的回调接口中,加入日志上报功能。这样即使设备在客户现场升级失败,我们也能远程拿到日志,快速定位问题。
四、升级脚本编写:update-engine的“灵魂”
升级脚本,就是告诉update-engine“怎么更新”的指令集。它通常写在OTA包中的 META-INF/com/google/android/updater-script 文件里。
脚本语言是Edify,一种简单的声明式语言。我刚开始接触时觉得它语法奇怪,但用多了就发现,它其实很严谨。
常见的脚本命令:
| 命令 | 作用 | 示例 |
|---|---|---|
mount |
挂载分区 | mount("ext4", "/dev/block/mmcblk0p5", "/system") |
package_extract_file |
从OTA包中提取文件 | package_extract_file("system/build.prop", "/system/build.prop") |
set_progress |
设置进度条 | set_progress(0.5) |
delete |
删除文件 | delete("/system/app/Bluetooth.apk") |
apply_patch |
应用增量补丁 | apply_patch("/system/lib/libc.so", ...) |
来看一个实际的脚本片段:
# 挂载系统分区
mount("ext4", "/dev/block/mmcblk0p5", "/system");
# 显示进度
ui_print("正在更新系统文件...");
set_progress(0.1);
# 提取新文件
package_extract_file("system/build.prop", "/system/build.prop");
package_extract_file("system/lib/libutils.so", "/system/lib/libutils.so");
# 删除旧文件
delete("/system/app/OldApp.apk");
# 应用补丁
apply_patch("/system/lib/libc.so", "-",
1234567890, 987654321,
1234567890, sha1("new_libc.so"),
sha1("old_libc.so"),
package_extract_file("patch/libc.so.patch"));
set_progress(0.8);
ui_print("更新完成,正在重启...");
这里有个细节:apply_patch 的参数很复杂,但它的核心逻辑是“只更新变化的部分”。对于大文件(比如系统库),增量更新能节省大量带宽和时间。
我曾经踩过的坑:在某个项目中,我写脚本时忘了 mount 分区,结果 package_extract_file 直接报错。更惨的是,这个错误在测试环境没复现(因为测试机分区已经挂载了),到了客户现场才暴露。所以,脚本一定要在干净的分区状态下测试。
五、实战:编写一个完整的升级脚本
假设我们要更新一个嵌入式设备,包含系统分区和vendor分区。脚本大致如下:
# 检查设备型号
assert(getprop("ro.product.device") == "my_embedded_device" ||
abort("设备型号不匹配!"));
# 挂载分区
mount("ext4", "/dev/block/mmcblk0p5", "/system");
mount("ext4", "/dev/block/mmcblk0p6", "/vendor");
# 更新系统文件
ui_print("正在更新系统分区...");
package_extract_dir("system", "/system");
# 更新vendor分区
ui_print("正在更新vendor分区...");
package_extract_dir("vendor", "/vendor");
# 设置权限
set_perm(0, 0, 0644, "/system/build.prop");
set_perm_recursive(0, 0, 0755, 0644, "/system/app");
# 更新bootloader(可选)
if getprop("ro.bootloader") == "v1.0" then
ui_print("正在更新bootloader...");
package_extract_file("bootloader.img", "/dev/block/mmcblk0boot0");
endif;
# 完成
set_progress(1.0);
ui_print("更新完成!");
你可能会问:为什么要有 assert 检查设备型号?——因为OTA包可能被误刷到不同型号的设备上,导致变砖。我见过有人把手机ROM刷到平板上,结果屏幕驱动不匹配,直接黑屏。
六、调试与测试:别让脚本坑了你
脚本写完了,怎么测?我建议按以下步骤来:
- 模拟测试:在PC上用
update_engine_sideload工具模拟执行,检查语法错误 - 真机测试:找一台开发机,刷入测试包,观察日志输出
- 异常测试:模拟断电、网络中断、存储空间不足等场景
- 回滚测试:确保A/B系统能正确回滚
我的习惯:在脚本中加入 ui_print 输出关键步骤的日志。这样即使升级失败,也能从Recovery模式的日志中看到“卡在哪一步”。
好了,关于系统更新与Recovery,今天就聊到这里。这些内容看起来偏底层,但做嵌入式Android开发,迟早要面对。你想想看,设备卖出去之后,总不能派人去现场刷机吧?OTA升级是唯一的选择,而Recovery模式和update-engine就是保证升级成功的关键。
下次遇到设备变砖,别慌——先试试能不能进Recovery模式。能进,就有救。
公众号:蓝海资料掘金营,微信deep3321