ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从源码到真机:LineageOS编译刷入与增量构建实践

从源码到真机:LineageOS编译刷入与增量构建实践

1. 从零跑通一套自己编的系统镜像,到底值不值

把LineageOS 的代码下载、编译及真机运行这条链路完整走一遍,我前后折腾过七八次,最早那次是在一台只有 8G 内存的旧笔记本上,编译到一半被 OOM 杀掉,第二天起床看到终端里一行Killed,那种感觉现在还记得。所以这篇不打算写成"一键三连"的教程,而是把我这几年在源码下载、整包编译、真机刷入三个环节里踩过的坑、算过的账、验证过的参数全部摊开讲。适合谁看:手上有一台能跑 Linux 的机器、有一部愿意被反复刷机的备用机、想搞清楚一套完整系统镜像是怎么从一堆 Git 仓库变成一个能开机的 zip 的人。完全没碰过命令行的不建议从这篇入门,但如果你已经会用git、知道fastboot是什么、只是没走过完整流程,这篇能帮你少烧掉两三个通宵。

我自己的判断标准很朴素:如果你的目的只是"用上一个更干净的系统",直接下现成的 nightly 包刷进去就行,没必要自己编;但如果你要改框架层、要给自己机型补设备树、要给某个模块打补丁、要做定制 ROM 交付,那编译环境这一关迟早要过,早过比晚过好。因为一旦环境搭好,后面每次改动只需要增量编译十几分钟,而第一次全量编译省下的那些摸索时间,全都体现在后面几十次迭代里。

1.1 动手前先做三个判断,能省掉一半返工

第一个判断是机型有没有可用的维护分支。LineageOS 的代码组织方式是"一套公共框架 + 每个机型一份设备树 + 一份厂商固件提取物",公共部分由官方统一维护,机型相关的那部分决定了你这台机器能不能编出可用的镜像。判断方法很直接:去看官方支持的设备列表里有没有你这个代号,如果有,说明设备树、内核、固件提取脚本都是齐的,你只要照着走;如果没有,你就得自己找第三方维护的设备树,这时候要额外确认它对应的分支号和你同步下来的框架分支号是否一致——这两个版本对不上,最常见的表现是编译到system/sepolicy或者frameworks/base时冒出一堆找不到符号的错误,很难从报错本身反推原因。

第二个判断是厂商固件的获取方式。编译需要的图形驱动、相机 HAL、基带相关的二进制文件,都不在开源仓库里,得从你手机当前运行的原厂系统里提取,或者从原厂固件包里解出来。这一步是整个流程里最容易被忽略、也最容易卡住的环节:设备树里通常有一个extract-files.sh脚本,它会从指定的目录里按清单拷文件,目录结构不对就静默少拷,最后编出来的镜像能开机但相机打不开、指纹失效。我的习惯是把提取出来的文件列表和脚本里的清单做一次 diff,数量对不上就说明有问题。

第三个判断是内核是预编译还是现场编。有些机型的设备树直接引用仓库里已有的内核二进制,编译时只是把它打包进去;有些机型会把内核源码一起拉下来,在编译过程中现场构建。后者对环境的依赖明显更多,交叉编译工具链、dtc、libssl版本都会影响结果。看清楚是哪种,才知道自己该准备多少磁盘、要装哪些额外的包。

1.2 搞懂 repo 与 git 的分层,才知道磁盘该怎么规划

很多人第一次repo sync失败不是因为网络,是因为磁盘满了,而且是在已经下了 40G 之后才发现。要避免这个,得先明白目录结构。repo本身只是个 Python 脚本,它读一份 manifest 清单(一堆 XML),清单里写着"这个路径对应哪个仓库、哪个分支、哪个 revision",然后按照清单把几十上百个独立的 Git 仓库分别克隆到你当前目录下。

所以一个同步完成的源码树,体积由三部分组成:工作区里的实际文件、.repo/projects下的仓库元数据、.repo/project-objects下的对象库。后两者加起来往往和工作区差不多大甚至更大。再算上编译产物out/目录和编译缓存,一次完整流程的磁盘占用大致是这样的:

组成典型体积说明
工作区文件60~90 GB随分支和设备数量浮动
.repo元数据30~60 GB不开--depth时最大
out/编译产物40~70 GB含中间对象,全量编译后
ccache 缓存30~50 GB建议单独目录,可按需清理
原厂固件解包2~5 GB提取完可以删

结论很明确:准备一块至少 250G 的空闲分区,而且是 ext4 这类大小写敏感、支持软链接的文件系统。这一点特别重要——如果你图省事在 Windows 的某个盘上通过共享目录挂进 Linux 虚拟机,编译几乎一定会在创建符号链接或者处理大小写同名文件时失败,报错信息还特别误导人,看起来像是某个源文件缺失。我吃过这个亏,最后排查了半天才发现是文件系统的问题,跟代码一点关系都没有。

2. 环境准备:机器、依赖与缓存,一个都不能将就

环境准备这件事,说难不难,说简单也不简单。真正的门槛不在命令本身,而在于版本对齐。一套 Android 构建体系对 JDK 版本、Python 版本、编译器的要求是很挑的,装错版本之后的报错往往指向一个完全无关的文件。我的做法是先确认要编的分支对应哪个 Android 大版本,再按大版本倒推依赖,而不是先装一堆最新的包再去适配代码。

2.1 硬件配置怎么算,别凭感觉

内存是最容易出问题的地方。大型 C++ 模块在链接阶段会同时拉起多个编译任务,每个任务的内存占用不小。经验值是:每 2G 物理内存配一个并行任务,这是个相对保守的上限。比如 16G 内存的机器,-j取 6 左右比较合适;32G 可以取到 12;如果只有 8G,那基本只能-j2慢慢熬,而且最好开一块 16G 以上的 swap 兜底,虽然会慢,但至少不会在链接阶段被杀掉。

CPU 核心数决定理论并行上限,但对全量编译来说,内存往往是先撞墙的那个。我这里给自己定了个公式:

jobs = min(物理核心数, 内存GB / 2)

第一次编译建议在算出来的值上再减 2,因为编译过程中你还要开着编辑器、看日志、跑浏览器,留点余量能避免系统卡到无法响应。等第一次编完心里有数了,再往上调。

磁盘类型的影响也很明显。同样一份源码,放在机械硬盘上做增量编译,光是扫描文件变化就要花掉不少时间;换到固态盘之后,那些"只改了一个文件但要等十分钟"的情况会明显改善。如果预算有限只能二选一,我的建议是内存优先,因为 OOM 是硬失败,而磁盘慢只是不舒服。

2.2 系统依赖包与 JDK 版本对齐

系统我一般用 Debian 系的长期支持版本,不是因为别的,纯粹是软件包齐全、社区资料多。基础依赖装一遍就够了:

sudo apt update sudo apt install -y bc bison build-essential ccache curl flex \ g++-multilib gcc-multilib git git-lfs gnupg gperf imagemagick \ lib32readline-dev lib32z1-dev libelf-dev liblz4-tool libsdl1.2-dev \ libssl-dev libxml2 libxml2-utils lzop pngcrush rsync schedtool \ squashfs-tools xsltproc zip zlib1g-dev python3 python-is-python3

这份列表里几个包值得单独说。lib32z1-dev、gcc-multilib这些名字带 32 的,是因为构建过程中有些工具链组件是 32 位的,缺了会在编译早期就报"找不到某头文件",而且提示路径很怪,容易让人以为是自己代码的问题。libssl-dev影响的是内核和部分签名工具的编译,版本过新有时反而会报一堆废弃 API 的警告甚至错误,这时候与其去改代码,不如装一个兼容版本的开发包。git-lfs这两年越来越必要,有些预编译的二进制文件走大文件存储,不装的话拉下来的是指针文件,编译时才发现某个.so只有一百多字节。

JDK 版本这件事,踩坑率极高,我整理了一张对照表,照着选基本不会错:

分支对应的 Android 大版本需要的 JDK装错后的典型症状
较老分支(Android 9 及以前)JDK 8编译报Unsupported class file major version
中等分支(Android 10~12)JDK 11报javax.xml.bind之类的包找不到
较新分支(Android 13 及以后)JDK 17报IllegalAccessError或注解处理器崩溃

切换 JDK 我用update-alternatives管理,比手动改环境变量可靠:

sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-17-openjdk-amd64/bin/java 1 sudo update-alternatives --config java

注意:改完 JDK 之后一定要新开一个终端再编译。已经source过环境脚本的终端里,JAVA_HOME还指着旧路径,表现出来就是"明明换了版本还是报同样的错",我第一次遇到时反复卸载重装了三次 JDK 才发现是这个原因。

2.3 ccache 与编译缓存的前置配置

ccache是这套流程里性价比最高的一个优化。它的原理是给编译器套一层壳,把源文件内容和编译参数的哈希值作为 key,命中就直接返回上次的目标文件。对于"只改了一个 Java 文件但要重新编一遍整个框架"这种场景,效果非常明显——我第一次配好之后,增量编译从四十多分钟降到十分钟出头。

配置要在source环境脚本之前做好,写进 shell 的启动文件里:

export USE_CCACHE=1 export CCACHE_EXEC=/usr/bin/ccache export CCACHE_DIR=/home/<用户名>/.ccache

然后设定容量并打开压缩:

ccache -M 50G ccache -o compression=true ccache -z

-M 50G是给缓存目录设上限,超过之后按最近最少使用淘汰。50G 对单机型开发够用了,如果你要同时维护两三个机型,建议开到 80G 以上,并且注意把它放在和源码同一块盘上,跨盘读取的收益会打折扣。-z是清零统计,方便你后面用ccache -s观察命中率——命中率长期低于 60% 通常意味着你的编译参数在频繁变化,比如每次都在不同的目录下编译,或者环境变量里有时间戳之类的东西。

3. 代码下载:从 repo init 到 sync 收尾

下载这一步看起来只是敲两行命令等它跑完,实际上信息量不小。用错参数可能导致后面所有环节都受影响,比如为了省空间用了浅克隆,结果某次想git log查一段历史发现查不到;或者同步过程中断了几次没处理好,最后得到一个半残的仓库树,repo status里一堆莫名其妙的改动。

3.1 repo 工具的落地细节

repo不是系统包管理器里的东西,得单独拿。它的本质是一个引导脚本,第一次运行时会把自己更新到最新,然后在当前目录下建.repo结构。我的习惯是放到用户目录下的一个固定位置并加进PATH:

mkdir -p ~/bin curl -o ~/bin/repo <repo 引导脚本地址> chmod a+x ~/bin/repo export PATH=~/bin:$PATH

引导脚本的地址以官方文档给出的为准,不要从各种来路不明的转载里复制,这类脚本被改动过的风险不小。装好之后跑一次repo --version,能打印出版本号就说明可用。如果提示找不到python,多半是系统里只有python3而没有建立兼容软链接,前面依赖列表里的python-is-python3就是干这个的。

还有一个容易被忽略的点:Git 身份配置。repo sync本身不需要,但你在设备树里做改动、跑repo upload或者打补丁时会需要。提前配好能避免中途被打断:

git config --global user.name "<你的代号>" git config --global user.email "<你的邮箱>" git config --global color.ui auto

提示:如果你所在的环境对上游仓库的访问速度不理想,可以在组织内部搭一份代码缓存服务,把repo init的地址指向内部缓存,同步速度会快很多。这件事最好在第一次同步之前就规划好,因为换地址重新同步等于从头再来一遍。

3.2 repo init 与分支选择的关键参数

初始化的核心是三个信息:manifest 仓库地址、分支、以及要不要带额外参数。命令长这样:

mkdir -p ~/android/lineage cd ~/android/lineage repo init -u <manifest 仓库地址> -b <分支名> --git-lfs

-b后面的分支名一定要和设备树支持的分支对上。常见写法是lineage-加数字版本号,比如lineage-21.0这种。如果设备树作者只维护到lineage-20.0,你硬要用lineage-21.0去喂,编译时会在BoardConfig相关的地方报一堆缺失变量。判断方法很简单:去设备树仓库看一眼它的分支列表和最近提交,别凭感觉。

--git-lfs建议一直带着,代价极小。--depth这个参数要慎用,它做浅克隆,能显著减少.repo的体积,但代价是丢掉历史,后续如果你想git log定位某个改动是什么时候引入的、或者想 cherry-pick 一个旧提交,就会很痛苦。我的做法是:空间够就全量,空间紧张才用--depth=1,并且明确知道自己在做什么。

初始化完成后,.repo/manifests下会出现一套 XML,你可以翻一翻default.xml了解这次到底会拉多少个仓库。有些人会在这里做本地清单定制,比如去掉某个不用的仓库或者替换成自己的 fork——这是进阶操作,第一次走流程不建议动。

3.3 repo sync 参数与断点续传的正确姿势

初始化之后就是同步,这一步最耗时,也最考验耐心:

repo sync -c -j8 --no-clone-bundle --force-sync

几个参数逐一说清楚。-c表示只拉当前分支,不拉所有分支,能省不少空间和带宽。-j8是并行仓库数,不是编译并行度,一般设成 4 到 8 之间,太大反而会因为磁盘随机读写成为瓶颈,而且出错时日志很难读。--no-clone-bundle跳过一种打包下载的优化,在某些网络环境下反而更可靠。--force-sync会强制覆盖本地改动,慎用——如果你在工作区里改过文件还没提交,这个参数会让改动直接消失。

同步中途断了怎么办?这是最常见的情况。直接重新执行同一条命令就行,repo会跳过已经完整的仓库。真正麻烦的是"看起来同步完了但某个仓库是半残的",典型症状是后面编译报某个头文件找不到,而那个文件在别的仓库里。处理办法是先跑一次:

repo sync -l --fail-fast

-l是只做本地校验,不下载,--fail-fast让它在第一个错误处停下。它会告诉你哪个仓库状态不对。定位到具体仓库之后,单独进去git fsck或者干脆删掉那个仓库的目录重新同步一次,比整个重来快得多。

3.4 专有固件与设备树的补齐

框架代码同步完之后,还差两块:设备树(包含板级配置和内核对接口)和厂商固件提取物。设备树通常已经在 manifest 里,同步时就一起下来了,位置在device/<厂商名>/<设备代号>/。固件提取物则不一定,有些机型会放在独立仓库里,需要你手动确认。

提取有两种路径。一种是从正在运行的原厂系统里提,手机连上电脑、开启调试、脚本通过adb pull把需要的文件拷出来;另一种是从原厂固件包里解,把厂商发布的完整固件包解开,得到各个分区的镜像,再挂载或者解包提取。第二种更干净,也更适合反复重建环境,我一般首选第二种。

设备树目录下通常有这么几个关键文件,值得花五分钟看一眼:

文件作用出问题时的表现
extract-files.sh从原厂固件提取专有文件漏拷导致相机/指纹/音频失效
<代号>.mk产品定义,决定打包哪些模块缺模块导致功能缺失
BoardConfig.mk板级编译配置,分区大小、内核参数分区大小不符导致刷入失败
lineage_<代号>.mk或类似继承关系入口编译时找不到目标产品
proprietary-files.txt提取清单和实际提取结果不一致时静默失败

提取命令大致是:

cd device/<厂商名>/<设备代号> ./extract-files.sh <原厂固件解包目录>

跑完之后一定要做一次核对:看proprietary-files.txt里列了多少条,实际提取出来的文件数量是不是大致对得上(有些条目是带通配符的,数量不会完全相等,但差距过大就是有问题)。我见过一次,脚本因为某个路径不存在,把整段目录都跳过了,全程没有任何报错,编出来的镜像是能开机的,只是插上耳机没声音。这种问题在真机上排查的成本极高。

4. 编译:从环境脚本到产出可刷入的包

编译这一步是整个流程里最"有反馈"的部分——它要么在几分钟内就报错,要么安静地跑上一两个小时然后给你一个 zip。区别就在于前面的准备工作做没做到位。

4.1 目标选择与常用命令的区别

进入源码根目录之后,第一件事是加载环境脚本:

source build/envsetup.sh

这一步会往当前 shell 里注入一堆函数,breakfast、brunch、lunch、m、mm、mma都是它带进来的。它们的关系值得理清,因为用错会浪费时间:

命令作用范围适用场景
lunch选择构建目标手动指定产品-版本组合
breakfast <代号>选目标并拉取设备树首次构建某机型
brunch <代号>选目标并直接开始整包编译日常全量/增量构建
m从当前目录开始编译在源码根目录用等于整包
mm只编当前目录下的模块改单个模块时最快
mma编当前目录及依赖模块间有依赖时用

我的日常习惯是:第一次用breakfast <代号>确认目标能正确识别,它会打印出产品名、变体(userdebug 之类)、以及一堆路径;确认没问题之后再用brunch <代号>开编。breakfast的好处是它会顺带检查设备树是否存在、依赖是否齐全,有问题当场就报,不用等编译到一半才发现。

如果你的设备树是自己维护的、不在官方清单里,breakfast可能会提示找不到设备,这时候需要先把设备树放到正确路径,或者用lunch手动指定完整的产品名,格式类似<产品名>-<变体>。

4.2 各个阶段的产出物分别是什么

编译过程分几个阶段,理解它们有助于你在出问题时快速定位。大致是:配置阶段生成构建规则、然后是大量的编译和链接、接着打包各个分区镜像、最后组装成一个可刷入的 zip。

编译完成后,产物在out/target/product/<设备代号>/下。我第一次编完时对着一堆文件不知道刷哪个,这里列一下主要的:

产物用途
lineage-<版本>-<日期>-<变体>-<代号>.zip完整刷机包,recovery 里安装
boot.img内核 + 引导,新版设备里 recovery 通常也在里面
dtbo.img设备树覆盖,部分平台需要单独刷
vbmeta.img校验相关分区,改动后需要处理校验标志
system.img/vendor.img只做单分区替换时用
recovery.img独立 recovery 分区机型才会单独产出
super_empty.img动态分区机型做分区重置时用

对于动态分区的机型(较新的设备基本都是),system、vendor、product这些被塞进一个super分区里,刷入方式跟老设备完全不同,这点在下一节会展开。

4.3 并行度、内存与缓存参数的实测取值

同样是brunch,参数不同结果差别很大。最直接影响的是并行度,可以在命令里显式指定:

brunch <代号> -j6

不指定的话它会自己去探测,探测逻辑有时候偏激进,在内存紧张的机器上直接把系统拖死。我建议第一次编译一定手动指定一个保守值,宁可慢二十分钟,也不要半夜被 OOM 打断。

另一个值得调的参数是输出日志的详细程度。默认输出已经够用,但如果编译失败,你会想看完整日志:

brunch <代号> -j6 2>&1 | tee build.log

tee的好处是既能在屏幕上看到进度,又能把完整输出存下来,事后用grep -n "error:" build.log精确定位。这个习惯我从第二次编译开始就一直保持着,救命过好几次——因为终端滚动缓冲区有限,报错信息往往滚上去就找不回来了。

再说 ccache 的效果验证。编译前后各跑一次:

ccache -s

关注cache hit (direct)和cache miss两个数。第二次全量编译(源码没变,只是清空了out/)时,直接命中率应该很高,编译时间会明显缩短。如果发现命中率极低,检查两件事:一是CCACHE_DIR是否指向了正确位置,二是编译时的工作目录是否每次都一样。有些人喜欢在不同路径下各存一份源码,结果缓存互相不认,等于白配。

4.4 编译报错的分类与快速定位

编译报错看着眼花,其实就那么几类。我在下面这张表里按"报错特征 → 大概率原因 → 处理方向"整理了一遍,实际用下来覆盖了八成以上的情况:

报错关键词大概率原因处理方向
error: unrecognized command-line option编译器版本与代码要求不匹配检查工具链版本,别用系统自带的默认编译器
Out of memory/Killed并行度过高或内存不足降-j,开 swap
Unsupported class file major versionJDK 版本不对切到对应版本的 JDK 并新开终端
cannot find -lXXX缺系统开发库补装对应的-dev包
No such file or directory: .../vendor/...固件提取不全重跑提取脚本并核对清单
undefined reference to模块间链接顺序或缺失依赖清掉对应模块的中间产物重编
ninja: build stopped: subcommand failed这是个汇总信息,真正错误在上面往上翻日志找第一条 error
sepolicy相关冲突设备树策略与框架策略不一致检查设备树对应的分支版本

有一条经验特别值得说:看到ninja: build stopped不要慌,这行只是结论,真正的错误在它上面几十行。我第一次遇到时盯着这行看了半天,以为是构建系统本身的问题,后来才学会往上翻。现在我的习惯是直接把日志丢给grep -n -B2 -A5 "error:",让工具帮我找。

还有一类错误是"编到 90% 才失败",通常发生在打包阶段,比如分区镜像超出大小限制。这类问题的原因是BoardConfig.mk里配置的分区大小和设备实际的分区表对不上,处理方式是参照原厂固件的分区信息调整配置,或者精简打包进去的模块。这时候out/里已经有一大堆中间产物,别急着make clean,改完配置直接重跑,增量编译几分钟就能到打包阶段。

5. 真机运行:解锁、刷入与首次开机

编出 zip 只是完成了一半,能不能刷进去、刷进去能不能开机、开机后各项功能是否正常,才是真正的考验。这一步的风险比前面都高,因为操作对象是实机,出问题可能变砖——虽然绝大多数情况都能救回来,但过程很折腾。

5.1 解锁与开发者选项的前置操作

刷任何非原厂镜像之前,必须先解锁引导程序。这件事各家厂商的流程不一样,有的需要在开发者选项里绑定账号并等待若干天,有的提供官方解锁工具。我不打算在这里写具体步骤,因为不同品牌差异太大,照着别人的教程走反而容易出错,正确做法是去查你这部机器对应型号的官方说明。

需要提醒的是解锁的副作用:它会清空设备上的全部数据,而且部分机型解锁后会失去某些厂商提供的服务(比如某些支付类应用的安全校验会不通过)。所以动手之前把重要数据备份好,这一步没有捷径。

解锁之后在开发者选项里打开调试模式,电脑上装好平台工具,用数据线连上,跑adb devices确认能看到设备。Linux 下如果显示no permissions,是 udev 规则没配:

sudo tee /etc/udev/rules.d/51-android.rules <<'EOF' SUBSYSTEM=="usb", ATTR{idVendor}=="<厂商ID>", MODE="0666", GROUP="plugdev" EOF sudo udevadm control --reload-rules sudo udevadm trigger

厂商 ID 用lsusb查,插拔对比一下就能找到。配好之后重插设备,adb devices应该显示device而不是unauthorized或空白。

5.2 分区概念与刷入顺序

老设备和新设备在分区布局上差别很大,这直接决定了刷入方式。老设备一般有独立的recovery分区,流程是"刷 recovery → 进 recovery → 安装 zip"。新设备大多把 recovery 合并进了boot分区(所谓 recovery-as-boot),并且引入了动态分区和校验机制,流程变成"刷 boot → 重启进 recovery → 安装 zip",还要额外处理vbmeta的校验标志。

先说vbmeta。这个分区存的是各分区的校验信息,刷了非官方签名的镜像之后,引导程序会拒绝启动或者强制回滚。处理方式是刷入时带上禁用校验的参数:

fastboot flash vbmeta vbmeta.img --disable-verity --disable-verification

这两个标志的含义分别是关掉数据分区完整性校验和关掉启动镜像校验。要注意的是,改完之后首次开机会触发一次数据分区格式化,所以别指望保留原来的用户数据。

刷入顺序我习惯这样:先boot,再dtbo(如果设备树里有这个分区),然后vbmeta,最后重启进 recovery 装 zip。

fastboot flash boot boot.img fastboot flash dtbo dtbo.img fastboot flash vbmeta vbmeta.img --disable-verity --disable-verification fastboot reboot recovery

注意:有些机型的boot分区写入后会立刻校验,如果vbmeta还没处理,可能直接进入引导失败循环。遇到这种情况不要慌,重新进引导模式(一般是长按音量键加电源键),把vbmeta补上就能恢复。我第一次遇到时以为机器废了,折腾了半小时才想起来这一步。

5.3 两种安装路径的取舍

装 zip 有两条路。第一条是recovery 里侧载:

adb sideload lineage-<版本>-<日期>-<变体>-<代号>.zip

这条路的好处是 recovery 会自己做校验、自己处理分区映射,出问题时会明确告诉你哪一步失败。缺点是依赖 recovery 本身能用——如果 recovery 起不来,这条路就断了。

第二条是fastboot 直接刷各个分区:

fastboot flash system system.img fastboot flash vendor vendor.img fastboot flash product product.img

这条路适合调试阶段,比如你只想替换system看看某个改动效果,不想走完整安装流程。缺点是动态分区机型不能这么直接刷,得先处理super分区的逻辑分区映射,操作复杂度明显上升。我的建议是日常用第一条,调试用第二条,并且第二条只在对自己机型分区布局足够熟悉的前提下用。

5.4 首次开机的观察与日志抓取

首次开机动辄十几分钟,屏幕上一直转圈,这时候最想知道的是"它到底在干什么"。答案是看日志。设备起来一点之后(哪怕还在开机动画),adb通常已经可用了:

adb logcat -v time > boot.log

如果adb连不上,说明系统还没起到那个阶段,这时候看内核日志:

adb shell dmesg

连 shell 都进不去的话,就只能靠 recovery 里的日志或者串口了。我在排查一次开机循环时,最后是靠 recovery 里的/proc/last_kmsg找到线索的——内核在挂载某个分区时失败了,原因是我刷的镜像分区大小和实际不符。

开机之后有几项检查我每次都做,花不了几分钟但能省掉很多后续麻烦:

  • 相机能不能打开,前后摄都试一遍
  • 指纹/人脸能不能录入
  • 扬声器、听筒、麦克风是否正常
  • Wi-Fi 和移动数据能不能连上
  • 蓝牙能不能配对
  • 充电是否正常,电量读数有没有跳变
  • 传感器(自动亮度、方向)是否响应

这些项目里任何一项异常,基本都能追溯到固件提取不全或者设备树配置有偏差。趁刚编完记忆还热的时候排查,效率最高。

5.5 回滚方案必须提前准备

刷机之前,原厂固件包一定要先下好放在本地。这是唯一可靠的退路。真出了刷不回去的情况,靠的就是它。

回滚的通用思路是进入引导模式,把原厂固件包里的各个分区镜像按原厂的方式刷回去,或者用厂商提供的完整刷机工具。不同品牌流程不一样,但核心逻辑一样:把设备恢复到出厂状态,再重新开始。

我的习惯是在第一次刷自编镜像之前,先完整走一遍"刷原厂 → 确认能开机 → 再刷自编"的流程。这一步看起来多余,实际上是验证你的回滚路径是通的。等到真出问题的时候,你不需要现学怎么救砖。

6. 问题速查表与增量编译的实战技巧

前面几节讲的都是"正常路径",这一节讲的是"不正常的时候怎么办"。这部分内容在官方文档里基本找不到,都是从一次次失败里攒出来的。

6.1 高频问题速查

现象排查方向我的处理办法
编译到一半Killed内存不足降并行度,加 swap,关掉其他占内存的程序
同步报RPC failed单仓库体积过大或连接中断单独进该仓库git fetch后回根目录再 sync
开机卡在第一屏boot 镜像与设备不匹配确认设备代号和镜像代号一致,检查 vbmeta
开机动画无限循环系统起来了但某个服务崩溃抓 logcat 找第一条 FATAL,通常是固件缺失
刷入报partition not found分区名与设备不符查设备实际分区表,别照抄别人命令
相机黑屏HAL 相关固件缺失核对 proprietary-files 清单,重提
Wi-Fi 打不开无线固件或 MAC 配置缺失检查 vendor 分区里的固件文件
adb devices空白udev 规则或数据线问题换线、换口、重配规则
增量编译没反应构建系统认为无变化确认改动文件确实被纳入构建,必要时清对应模块

这张表里最值得展开的是"开机动画无限循环"。这个现象特别迷惑,因为设备看起来在工作,只是进不去桌面。真正的原因往往是某个系统服务在启动时崩了,导致整个启动流程反复重试。定位方法是在开机动画阶段就开始抓logcat,然后搜FATAL EXCEPTION或者E AndroidRuntime。我遇到过一次,原因是某个 HAL 服务找不到对应的.so,而这个.so在固件清单里被注释掉了,脚本跳过了它,全程静默。

6.2 增量编译怎么用才划算

第一次全量编译之后,后面绝大多数改动都可以增量编。改一个 Java 文件,理论上只要几十秒到几分钟。但增量编译有个坑:它依赖中间产物的依赖图,如果依赖图本身出错了,增量结果就是错的。表现出来是"改了没生效"或者"改完编译通过但运行行为没变"。

遇到这种情况,不要怀疑自己的改动,先清掉相关模块的中间产物再编。比如改的是设置应用:

rm -rf out/target/common/obj/APPS/SettingsIntermediates brunch <代号> -j6

只清目标模块,比make clean快得多。make clean会把整个out/清空,后面又要等一次接近全量的编译。

另一个技巧是善用mm。在源码里改完某个模块,cd到那个模块目录,跑mm,它会只编这一个模块,速度极快,适合快速验证语法层面没问题。验证通过之后再回根目录跑一次brunch做完整打包。这种"先局部后整体"的习惯,能让你在改代码阶段不至于每次都等半小时。

6.3 我个人踩过的几个坑

先说一个最亏的:用错设备代号。有次我手上的机器有两个非常接近的型号,代号只差一个字母,我从网上找的设备树是另一个型号的,编译全程没报错,刷进去也能开机,但屏幕触控完全没反应。后来才发现触摸驱动在设备树里是按型号区分的。教训是:设备代号一定要从设备自己的系统信息里读,不要凭型号名猜。

再说一个关于out/目录的:不要在多份源码之间共享out/。有些人为了省磁盘,把out做成软链接指到公共目录,想着不同分支共用编译产物。这在极少数情况下能跑通,但绝大多数时候会带来莫名其妙的错误,因为不同分支的中间产物格式可能不兼容。省那点空间不值当。

第三个是提取固件时的路径问题。extract-files.sh一般接受一个参数,指向原厂固件解包后的根目录或者某个特定子目录,不同设备树的要求不一样。我曾经因为这个参数给错,脚本从一个空目录里提取,一路跑完没有任何报错,最后编出来的镜像缺了一大半专有文件。现在我每次提取完都会看一眼输出目录的文件数量和总大小,跟上次对比,差异过大就停下来查。

最后一个,关于编译时间的心理预期。第一次全量编译在一台中等配置的机器上跑两三个小时是正常的,不要因为等太久以为卡死了就中断。判断是否还在干活有个简单办法:看out/目录的大小是不是持续在涨,或者用top看有没有编译器进程在跑。中断一次重来,损失的时间比耐心等下去多得多。

6.4 把这套流程固化成可复用的脚本

走到第三四次的时候,我强烈建议把这套流程写成脚本。不是为了偷懒,是为了保证每次行为一致。手工敲命令最容易出的问题就是漏了某个参数,而漏参数导致的失败往往在很后面才暴露。

我的做法是拆成三个脚本:01-sync.sh负责初始化和同步,02-build.sh负责加载环境和编译,03-flash.sh负责刷入。每个脚本开头都做一次前置检查,比如检查磁盘剩余空间、检查 JDK 版本、检查设备是否连接。脚本里所有路径用变量代替,方便换机器时改一处就行。

#!/bin/bash set -e SRC_ROOT="$HOME/android/lineage" DEVICE="<设备代号>" JOBS=6 cd "$SRC_ROOT" source build/envsetup.sh breakfast "$DEVICE" brunch "$DEVICE" -j"$JOBS" 2>&1 | tee "build-$(date +%Y%m%d-%H%M).log"

set -e是关键,任何一步失败立刻停,不会带着错误状态继续往下跑。日志文件名带时间戳,方便回溯哪次构建出了什么问题。这套东西攒下来之后,我从"想起来要编一次"到"手里有可刷的包",中间需要的人工干预基本只剩等待。

如果你打算长期维护自己机型的镜像,还可以进一步把设备树和固件提取物单独做成一个仓库,每次同步完框架代码就把它叠上去,这样即使换了机器重建环境,也能在半天内恢复到可用状态。这件事的价值在第一次重装系统时就会体现出来——那种"所有东西都要重新摸一遍"的痛苦,经历一次就够了。

RELATED READING

延伸阅读

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