4. ABI基础与二进制兼容原理

在 Android 内核模块接口(KMI)工程中,源码兼容并不等于二进制兼容。一个模块即使能在新内核上重新编译通过,也不代表旧模块可以直接加载运行。ABI(Application Binary Interface,应用二进制接口)描述的是编译产物之间在机器码层面的契约,它规定了函数如何被调用、数据如何布局、符号如何解析、动态链接如何完成。理解 ABI,是判断 KMI 能否稳定、模块能否跨版本复用的基础。

一、ABI 的定义与构成要素

ABI 是二进制层面的接口规范,它比 API 更底层。API 关注“有哪些函数、参数是什么”,ABI 关注“这些函数在寄存器、栈、内存中如何传递,结构体每个字段落在哪个偏移,符号以什么名字和版本导出”。在 Android 内核场景中,ABI 主要由以下要素共同决定:

  • 调用约定:参数传递顺序、寄存器分配、返回值位置、栈对齐与调用者/被调用者保存规则。
  • 数据结构布局:结构体字段顺序、对齐、填充、位域、联合体以及指针宽度。
  • 符号与版本:导出符号名、符号类型、CRC 校验、版本节点与命名空间。
  • 动态链接机制:重定位、GOT/PLT、模块加载时的符号解析与依赖顺序。
  • 编译与目标属性:目标架构、浮点 ABI、代码模型、编译器选项与 LTO 等。

这些要素中任意一项发生变化,都可能破坏二进制兼容性。因此 KMI 工程的核心任务,是把这些要素稳定下来,形成可验证的契约。

二、调用约定与数据结构布局

以 ARM64 为例,函数调用时前 8 个整型参数依次放入 x0~x7,浮点参数放入 v0~v7,超出部分通过栈传递,返回值放在 x0 或 v0。若模块与内核对该约定理解不一致,参数就会错位,表现为随机崩溃或数据错乱。调用约定通常由架构和编译器共同固定,相对稳定,但编译器选项(如是否启用 SCS、是否使用不同浮点 ABI)仍可能改变行为。

数据结构布局是更常见的破坏源。结构体字段的增删、顺序调整、类型宽度变化、对齐属性变化,都会改变字段偏移和整体大小。例如内核中一个导出结构体在版本 A 中为:

struct kmi_ops {
    int (*init)(void *ctx);
    void (*release)(void *ctx);
    u32 flags;
};

若在版本 B 中把 flags 移到 init 之前,虽然源码层面仍可编译,但旧模块按旧偏移写入 flags 时,实际会覆盖函数指针,导致调用跳转到非法地址。这类问题在二进制兼容判定中必须通过布局比对来发现。

ABI 兼容的第一原则:导出给模块使用的结构体,字段只能追加,不能插入、删除或改变已有字段的类型与顺序;同时要显式控制对齐和填充。

三、符号版本、CRC 与动态链接

Linux 内核通过 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL 导出符号,并为每个符号生成 CRC 校验值。模块加载时,内核会比对模块记录的符号 CRC 与当前内核的 CRC,不一致则拒绝加载。这实际上是一种粗粒度的 ABI 校验:只要函数签名或相关类型布局变化,CRC 就可能改变。

符号版本进一步细化了这种约束。通过版本节点和命名空间,可以控制哪些符号对哪些模块可见,避免不同供应商模块之间的符号冲突。动态链接阶段,模块的未解析符号会按依赖顺序在内核符号表中查找,重定位完成后才允许执行初始化函数。

# 查看内核导出符号及其 CRC
cat /proc/kallsyms | grep kmi_ops
# 模块加载时的典型报错
kmi_demo: disagrees about version of symbol kmi_register
kmi_demo: Unknown symbol kmi_register (err -22)

当出现 “disagrees about version of symbol” 时,通常说明模块编译时使用的头文件与目标内核的符号 CRC 不一致,属于典型的 ABI 不匹配。

四、兼容性判定方法与流程

判断两个内核版本之间是否保持 KMI 二进制兼容,不能只看源码 diff,而应建立可重复的判定流程:

  1. 提取基线 ABI:从参考内核中导出符号列表、符号 CRC、导出结构体布局和调用约定相关属性。
  2. 提取目标 ABI:对目标内核执行同样的提取,生成可比对的数据集。
  3. 逐项比对:符号集合是否只增不减;已有符号 CRC 是否一致;结构体大小、字段偏移、对齐是否一致。
  4. 动态验证:用基线模块直接在目标内核上加载,观察符号解析、重定位和初始化是否成功。
  5. 回归确认:对关键路径执行功能测试,确认没有因隐式布局变化导致的行为差异。

下表给出常见 ABI 变化类型及其兼容性判定结论,可作为评审清单使用:

变化类型示例二进制兼容处理建议
新增导出符号新增 kmi_query兼容可直接合入
删除导出符号移除 kmi_register不兼容保留旧符号或走版本升级
函数签名变化参数由 2 个变 3 个不兼容新增函数,旧函数保留
结构体尾部追加字段末尾增加 u64 cookie通常兼容确认大小与对齐无副作用
结构体中间插入字段在首字段后插入新字段不兼容禁止,改用预留字段
字段类型变宽u32 改为 u64不兼容新增字段并保留旧字段
对齐属性变化增加 __aligned(16)不兼容评估后统一基线
CRC 变化符号 CRC 与基线不同不兼容定位头文件或类型差异

五、典型场景与误区复盘

某项目在升级内核小版本后,旧显示模块无法加载,日志提示符号 CRC 不一致。排查发现,内核侧在一个导出结构体中把 bool 字段改成了 u8,源码语义几乎相同,但字段大小和对齐发生变化,导致 CRC 改变。修复方式不是强行忽略 CRC,而是恢复原布局,把新语义放入新增字段。

常见误区包括:

  • 认为“源码能编译”就等于“二进制兼容”,忽略布局与 CRC。
  • 只关注函数符号,忽略导出结构体和全局变量的布局。
  • 依赖编译器默认对齐,未使用显式填充和预留字段。
  • 在未验证的情况下关闭 CRC 校验,掩盖真实的不兼容问题。
工程实践中,应把 ABI 基线数据纳入版本管理,每次内核变更都自动生成符号与布局报告,并在 CI 中执行模块加载冒烟测试,让不兼容在合入前暴露。

ABI 是 KMI 稳定性的底层语言。只有把调用约定、数据布局、符号版本和动态链接统一纳入可度量、可回归的工程流程,才能真正实现“一次编译、多版本加载”的二进制兼容目标。

ABI 二进制兼容判定:从源码到加载 源码与头文件 函数声明 / 结构体定义 EXPORT_SYMBOL 编译与链接 调用约定 / 对齐 / 重定位 生成符号 CRC 模块 .ko 未解析符号 + CRC 记录 依赖版本信息 目标内核 符号表 CRC 基线 加载时 ABI 校验 ① 符号是否存在 ② CRC 是否一致 ③ 结构体大小与偏移 ④ 重定位是否成功 ⑤ 初始化函数能否执行 兼容 符号只增不减 CRC 一致 布局未变 → 直接加载 不兼容 符号缺失 / CRC 不符 字段偏移改变 拒绝加载 → 修复基线 全部通过 任一失败 核心结论:ABI 兼容 = 调用约定一致 + 数据布局稳定 + 符号与 CRC 匹配 + 动态链接可解析