论如何维护好一个自定义 GKI
注:本文中所提及的GKI,皆指GKI 2.0。至于GKI 1.0,我不熟,就不班门弄斧了,感兴趣的话请阅读Google官方的文档。
什么是GKI? #
如果你跟我一样开发过4.4~4.19的Android内核,你肯定会很快说出Anykernel3包中应该包含哪几样东西:内核Image、dtb、dtbo。(1)
而如果你看过那些5.10及以上版本的Android内核安装包,你会注意到Anykernel3包中只有一个内核Image,而dtb、dtbo都不见了。
为什么要这样?很简单,dtb和dtbo不需要替换,用原有的就行。
这也就是GKI的目标…的一部分。
GKI,即通用内核镜像(Generic Kernel Image),和Google之前搞的GSI(通用系统镜像)一样,是为了解决Android的碎片化问题而提出的。
关于Android内核的碎片化,Google官方文档中的这张图就很形象生动:
![/images/e45_p1.png](/images/e45_p1.png)
ACK(Android Common Kernel,Android通用内核)从Linux LTS内核中派生,之后厂商(vendor,指高通、联发科这些)再从ACK派生出自己的内核仓库以对soc进行一些定制,而产品制造商(product,指小米、三星这些)再基于厂商的内核仓库进行派生以对产品进行一些定制。在这一次次的派生中,碎片化就产生了。
GKI的目标有两个:
- 实现内核通刷,字面意思(一个内核Image,高通机型可以刷,联发科机型也可以刷)。
- 将厂商和供应商的专属驱动,以及对Android内核的修改,以内核模块的形式从内核镜像中剥离出来。
目标1的结果就是:只要你的设备的内核和内核模块符合GKI的规范(没有经过厂商魔改),你就可以在解锁bootloader后随意更新GKI,即使设备出厂的内核版本是v5.10.149,你也可以自己更新到v5.10.209。
目标2的结果就是:内核模块由厂商和供应商负责更新,而GKI可以保证无论GKI怎么更新都可以顺利加载厂商和供应商的内核模块,且保证这些内核模块正常工作。
注释:
- (1) 在Header v2版本之前,dtb是附加在kernel后面的,因此你会看到那些老旧机型的内核安装包里边的内核是以
Image*-dtb
的形式存在的。自Header v2之后,dtb在boot镜像中有一块自己的地址,因此不应该再采用Image*-dtb
这种形式,否则dtb并不会被替换,且bootloader不会识别附加在Image*-dtb
后面的dtb。
为了实现GKI,Google付出了哪些努力? #
前面提到,为了实现GKI,就需要保证GKI的更新不会影响到没被更新的内核模块的加载(毕竟,用户可以自己更新GKI,但用户没法更新厂商和供应商内核模块,想要更新只能看厂商脸色了)。
对于采用android12-5.10
内核的设备来说:内核模块位于两个不同的位置:vendor_boot分区的ramdisk的/lib/modules
中,以及vendor_dlkm分区的/lib/modules
中。
为什么要这样设计?这就说来话长了:目前几乎所有的新机型都采用了动态分区的设计(2),而内核镜像本身没有挂载super设备下的逻辑分区的能力,只能让init来。vendor_boot分区不是逻辑分区而是传统的物理分区,因此设备启动第一阶段先挂载vendor_boot并加载其中的内核模块,确保设备有基本的工作能力(因此vendor_boot分区中的内核模块都是非常基础的内核模块,比如pci、ufs、watchdog、cpufreq这些,而像是音频驱动、WiFi驱动、触屏驱动这些内核模块往往是位于vendor_dlkm分区)。之后init挂载super设备下的逻辑分区,而vendor_dlkm也是逻辑分区,因此第二阶段再加载vendor_dlkm分区中的内核模块。现在,让我们假设这样一个场景:有个用户把vendor_dlkm分区搞崩了导致设备开不了机需要救砖,而fastboot并没有刷写super设备下的逻辑分区的能力(要刷就只能把super设备整个刷了),但是因为vendor_boot分区没崩,因此该用户仍然可以把设备启动到recovery模式(如果有的话)或者fastbootd模式,这两种模式下是支持挂载逻辑分区的,之后用户就可以愉快地救砖了。让我们假设另一个场景:有个用户把vendor_boot分区搞崩了导致设备开不了机需要救砖,此时recovery模式和fastbootd模式是肯定进不去了,那怎么办?很简单,vendor_boot分区是物理分区而不是逻辑分区,因此fastboot支持刷写vendor_boot分区,在fastboot模式下救砖就行了。
那么,内核模块是如何跟GKI联系起来呢?
在Linux内核中,可以通过EXPORT_SYMBOL
和EXPORT_SYMBOL_GPL
宏将一些符号暴露出来供内核模块调用,这些符号可以是指向一个函数,也可以是指向一个变量。
那么,Google是如何确保这些符号的稳定呢?
在编译GKI时,编译程序会为这些符号计算CRC,并保存到内核镜像中,同时在编译生成的内核模块中也存一份。通过Linux内核的modversions校验功能,在加载内核模块前,首先检查内核模块使用的符号的CRC跟内核中保存的该符号的CRC是否一致,如果一致则允许加载,反之则拒绝加载。
CRC的计算结果受到很多因素影响,比如:函数的名字、参数的类型、函数返回值的类型,等等。
需要注意的是,很多内核模块之间也是存在依赖关系的。比如模块A导出了一个符号,模块B需要用,那么模块B在加载时同样也会和模块A比较该符号的CRC,如果不一致则模块B将不能加载成功。(3)
Linux内核有大约三万多个导出符号,难不成Google要维护三万多个符号的稳定性?并不是,Google会根据厂商和供应商的需要,仅仅维护它们需要的符号的稳定性,这些符号也就称之为KMI。(4)
随着Android系统的更新,KMI也会有不同的tag,比如:SM8450的内核基于android12-5.10
,而SM8550的内核基于android13-5.15
。不同tag的KMI并不相互兼容,因此SM8450刷android13-5.10
的GKI是肯定开不了机的。
KMI往往会在对应的Android系统版本发布之后冻结。KMI冻结之后,厂商和供应商仍然可以根据需要申请向KMI添加新的符号,但不能删除KMI中已有的符号(这是肯定的,假设某个内核模块调用了符号x,但某一次GKI更新把符号x从KMI里删了,那你猜会不会炸),同时,Google会负责维护KMI确保这些符号的稳定(说直白点就是确保这些符号的CRC值永久不变)。
前面也提到了,很多因素都会影响一个符号的CRC值,甚至是修改C结构体(struct)的成员(增加/删除成员、修改成员的名字、修改成员的变量类型)同样会影响CRC值的计算结果。
为什么会这样?通过IDA对任意一个内核模块进行逆向分析,会发现生成的汇编代码是通过成员在结构体中的偏移量来访问该成员的(而不是根据该成员的名字或者类型)。
![/images/e45_p2.png](/images/e45_p2.png)
如果你是内核开发者,此时如果你在该结构体里边添加了一个新成员,那么这个新成员之后的所有成员的偏移量肯定是变了,你说会不会炸?就算运气好没有炸那也肯定是不稳定的;再者,你这样一改之后这个结构体的大小肯定也变了,如果该结构体是其他结构体的成员,那么其他结构体内的成员的偏移量也变了,结果自然是跟着一起炸。
那么,GKI是如何维护KMI的稳定性呢?很简单,上游的哪个提交影响了KMI中某个符号的CRC,就revert掉它,比如 Revert “bpf: Defer the free of inner map when necessary”,在该提交的原始提交中,很明显修改结构体bpf_map
的成员是不被允许的,因此只能revert。
但是,revert掉会影响KMI的提交并不是唯一的解决方法。根据我的观察,还有以下几种方法:
- 拓展结构体法,比如:ANDROID: binder: fix KMI-break due to alloc->lock
__GENKSYMS__
欺骗法(在确保结构体大小和成员的偏移量不变的前提下允许修改结构体),比如:ANDROID: GKI: fix crc issue in include/net/addrconf.h- 见缝插针法(在确保结构体大小和成员的偏移量不变的前提下允许向结构体添加新成员),比如:Reapply “perf: Disallow mis-matched inherited group reads”
- 填坑法(利用早前在结构体末尾预留的空位),比如:ANDROID: GKI: Fix abi break in struct scsi_cmd
- 糊弄过去法(也称自欺欺人法),比如:ANDROID: struct io_uring ABI preservation hack for 5.10.162 changes
另外,也不是说GKI里的每一个结构体都是不能动的,例如:内核模块自己定义的结构体。
那么,哪些结构体会影响到KMI符号的CRC值计算呢?Google自己有一套维护KMI稳定的工具,其中有一个dump_abi
工具,它会分析vmlinux文件,然后生成一个xml文件,该文件包含了KMI中所有符号的CRC值,以及与这些符号相关的结构体的信息(包括但不限于:结构体的名字、对应的源代码文件路径、行号、结构体成员的偏移量,唯一的id)。还有一个diff_abi
工具,通过比较两个不同的xml文件,生成一份详尽的报告,提示你哪些符号的CRC值发生了变化、内核Image缺少了哪些KMI的符号、哪些结构体成员发生了变化,等等。
需要注意的是,使用不同版本的编译工具链也有可能会影响到KMI的稳定(但一般来说不会影响到符号CRC的计算),但是众所周知用新版本的编译工具链能带来很多好处,因此,请开发者自行抉择。
最后,还有一个问题,在GKI之前,厂商会对内核进行一定程度的魔改优化,那么GKI岂不是没有魔改的空间了?
并不是。Google早就考虑到这一点了,因此在GKI中提供很多供厂商hook的接口,厂商只需编写一个内核模块利用这些接口就可以了。比如小米的binder_prio模块,通过利用Google在binder驱动中安排的hook点,binder_prio可以针对性地为MIUI/HyperOS桌面和SystemUI、surfaceflinger进程提高在binder中的优先级(priority),实现专属优化效果。
和KMI一样,厂商可以跟Google申请在GKI中添加更多的hook接口。
好了,以上是我对GKI的片面理解。接下来我会谈一谈如何维护好一个自定义GKI。
注释:
- (2) 什么是动态分区?简单来说,就是从闪存芯片中划出来一块容量很大(对于A-Only设备,大概6GB以上,对于VAB设备,大概得8GB以上)的物理分区,称之为super设备,而那些系统分区(system、vendor、product、odm、system_ext等等)都是这个super设备下的逻辑分区,这些逻辑分区可以按照厂商的需要自行调整分区大小,或是增删逻辑分区。这样设计有利于设备的长期维护。
- (3) 模块A肯定是会先于模块B加载的,depmod会根据模块间的依赖关系生成
modules.dep
和modules.softdep
文件,系统在加载某个模块时将通过这两个文件解析出应该提前加载哪些模块。 - (4) 在libxzr的博文中有提到:GKI内核的模块被分为了两类,一类是只能使用KMI列出的接口的“供应商模块”,另一类是可使用的接口不受限制的“GKI模块”。但就我个人的观察,Google预编译的GKI默认都会修剪掉非KMI接口(当然也有未修剪的版本,不过根据Google的说法这仅供开发人员调试所用),理论上不应该存在能够调用非KMI接口的“GKI模块”。因此,可以认为目前所有搭载GKI的设备的内核模块都是只能使用KMI列出的接口的“供应商模块”。
一个自定义GKI开发者的自我修养 #
首先,问你一个问题:你是否想要被KMI的条条框框所束缚?
要知道,很多更加先进的特性(比如:mglru、BBRv2、eevdf,等等),都因为KMI的限制没法backport(backport之后会导致很多KMI符号的CRC值发生变化,进而导致内核拒绝加载系统中预编译的内核模块)。
如果你说:劳资才不管什么KMI,劳资才不管什么稳定性,什么CRC校验不通过,我要强制加载内核模块!
那么,pick 这个补丁,之后放飞自我吧。
如果你不想走这条路,那就接着往下看。
如果你想像编译传统内核那样编译GKI(即:不使用Google提供的编译环境repo),那么为了符合Google的规范,你得确保:
- 你编译生成的内核Image导出的所有符号的CRC值需要和
android/abi_gki_aarch64.xml
中定义的相同; - 你编译生成的内核Image需要包含
android/abi_gki_aarch64.xml
中提及的所有符号。
Google提供的编译环境和编译脚本会在编译时自动检查这些,如果你不想用,也可以试试我编写的 KMI_function_symbols_test.py。
如果你不仅想编译GKI还想同时编译内核模块,那还有一点需要注意,Google提供的编译脚本在编译生成内核Image时,会把不在KMI列表中的所有符号都“修剪”掉。这意味着:你需要检查你编译得到的内核模块是否调用了不在KMI列表中的符号,利用Google提供的compare_to_symbol_list
工具可以做到这一点。(5)
举个例子:arm64: Kconfig: Enable GENERIC_FIND_FIRST_BIT 这个提交,看似人畜无害,实际上在合并之后,内核Image会额外导出一个函数符号find_first_bit
(在不修剪非KMI符号的前提下)。如果你还同时编译了内核模块,那么有的内核模块就会调用这个函数符号,比如mac80211.ko
,如果你尝试在其他GKI上加载你新编译的mac80211.ko
模块,那肯定会因为内核缺少find_first_bit
符号而加载失败。
因此,如果你打算像我一样在编译GKI的同时编译内核模块,请老老实实跟Google一样给内核Image“修剪”掉非KMI符号,同时用compare_to_symbol_list
工具进行检查。
还有,有些时候有的结构体发生了变化,但并不会导致任何KMI符号的CRC值发生变化。这种情况只有dump_abi
和diff_abi
才能捕捉到。
说了这么多,你可能会感觉到:好麻烦啊,能不能简单点?
OK,最简单的方法就是:用 Google提供的编译环境和编译脚本。
注释:
- (5)
compare_to_symbol_list
工具通过将./out/Module.symvers
与包含所有KMI符号列表的文件进行比较来判断是否有内核模块调用了不在KMI列表中的符号。./out/Module.symvers
文件包含了编译生成的所有内核模块所需要的符号,如果你只编译内核Image,那么这个文件将不会生成。
“返璞归真”的non-GKI? #
non-GKI这个词是我自己发明的,嘿嘿…
并不是所有内核开发者都喜欢GKI这一套,因此,这些开发者选择走回老路:把所有设备需要的内核模块全都编译进内核镜像,比如 Sultan Kernel、arter97 Kernel。
显而易见,能这样做的前提是:厂商高度开源该机型的内核。如果该机型还有一大堆厂商不愿开源的内核模块,那肯定是没法这样搞。
在我看来,GKI是未来,从此以后,从传统内核镜像中剥离出来的内核模块一定会和供应商的那堆不开源的elf文件一样成为厂商和供应商的秘密,厂商和供应商的开源积极性将会越来越低(高通从msm-5.10开始就不再在clo开源dtb和dtbo了)。内核开发者只需专注于GKI本身,至于内核模块、dtb、dtbo这些,说实话,老老实实用预编译的就行了。
综上所述,我不看好non-GKI。