先看结论与判断条件
- 兼容基线必须绑定旧版与候选 SO 的 SHA-256、ABI、ELF Class、Machine、SONAME 和构建身份,不能只比较文件名。
- 动态导出的名称相同并不足够,还要比较版本节点、默认版本、绑定、符号类型、可见性和定义状态,任何变化都需要所有者解释。
- 每个发布 ABI 有独立调用约定、寄存器和对齐规则,arm64-v8a 的静态通过不能替代其他已承诺 ABI 的产物与设备回归。
- C++ 符号携带名称修饰、模板、异常、RTTI 与运行库耦合;跨 SO 公共接口应优先采用稳定、显式且可测试的边界。
- 版本脚本可以收敛导出并维护版本节点,但不能隐藏运行时必需入口,也不能把 ABI 破坏自动变成兼容升级。
- 静态差异报告是发布阻断输入,最终结论仍需同一候选在目标设备上完成加载、符号解析、调用、异常与核心路径回归。
先把 SO 的公共 ABI 从实现细节中分离出来
SO 升级兼容的对象不是“库里所有符号”,而是调用方已经依赖的公共二进制契约。这个契约至少包含导出名称、符号版本、绑定、类型、可见性、参数与返回值的机器级传递方式、结构体布局、SONAME、依赖库和失败语义。若团队只保存一份头文件,实际动态符号与调用方依赖发生漂移时,头文件无法解释已经发布的二进制行为。
Android NDK symbol visibility 建议使用版本脚本和默认隐藏来收敛 Native 导出面,同时显式保留 JNI 或公共 API 入口。可核查事实是链接器能够控制导出;工程判断是所有保留入口都应进入 ABI 清单并拥有 owner、introducedVersion、allowedConsumers 和 removalPolicy。未进入清单的内部实现可以收敛,但不能因为名称看似内部就擅自删除已被外部模块调用的符号。
验收顺序应是旧版基线、候选静态对比、真实调用方回归,而不是先加固再从崩溃反推。本文没有具体旧版 SO、候选产物或设备回执,因此只给出契约与门禁方法,不声称任何库、SDK 或御盾项目已经兼容。保护变换也不是 ABI 修复器,基线中的错误导出和不稳定 C++ 边界必须先由代码所有者处理。
| 字段 | 回答的问题 | 破坏性变化示例 | 证据来源 |
|---|---|---|---|
| symbolName | 调用方解析什么名称 | 删除或重命名 | 动态符号表 |
| version/default | 名称绑定哪个版本节点 | 默认版本切换 | 版本定义与需求 |
| bind/type/visibility | 怎样导出和解析 | GLOBAL 变 LOCAL | dynsym |
| callingContract | 参数、返回值和布局怎样传递 | 结构体布局变化 | 头文件、编译器与测试 |
| SONAME/NEEDED | 装载身份与依赖是什么 | 依赖新增或 SONAME 漂移 | dynamic section |
基线必须绑定候选身份并逐 ABI 采集
比较前先固定 old 与 candidate 的文件摘要、构建版本、源码提交、NDK 与编译器信息、目标 ABI 和构建变体。两个都叫 libengine.so 的文件可能来自不同缓存或不同 product flavor,若没有 SHA-256,差异报告无法回答扫描的是哪一对产物。SONAME 也应单独保存,因为归档文件名相同不代表动态装载身份没有变化。
Android ABIs 说明不同 ABI 具有各自的调用约定、寄存器、对齐与设备支持范围。契约清单必须按 armeabi-v7a、arm64-v8a、x86 或 x86_64 分开,且只覆盖产品实际承诺的发布范围。不能把一个 ABI 的符号集合机械复制到另一个 ABI,因为条件编译、汇编实现、数据模型和构建脚本都可能让导出或类型发生差异。
GNU readelf 可以检查 ELF header、program header、dynamic section、符号、重定位和 unwind 信息。采集器应从 ELF 本身读取 Class、Machine、SONAME、NEEDED、动态符号和版本信息,同时保存工具版本与原始输出摘要。静态字段能够建立可复核基线,但它不执行加载器、JNI 注册或函数调用,因此报告状态应写成 static-contract-passed,而不是 runtime-compatible。
| 对象 | 必须相同或明确变化 | 阻断条件 | 允许继续的证据 |
|---|---|---|---|
| 构建变体 | release、flavor、配置 | 比较了错误变体 | 构建记录与产物摘要 |
| ABI | 同一目标架构逐对比较 | 跨 ABI 直接对比 | ELF Machine 与目录一致 |
| 文件身份 | SHA-256 唯一 | 摘要缺失或重复使用旧报告 | 不可变文件与哈希 |
| SONAME | 保持或经过迁移批准 | 无说明的装载身份变化 | 调用方和装载策略更新 |
| 工具链 | 版本与标志可追溯 | 来源无法还原 | 编译、链接和依赖清单 |
动态符号要比较名称、类型、绑定和可见性
只用 nm 提取一列名字会漏掉关键差异。一个符号可以保持同名,却从 GLOBAL 变成 LOCAL、从 DEFAULT 可见性变成 HIDDEN、从 FUNC 变成 OBJECT,或从已定义项变成 UND。调用方看到的解析结果会随这些字段改变,所以清单应使用统一键表示 name 与 version,并把 bind、type、visibility、section state 作为契约属性。
删除旧版公共导出通常应直接阻断;新增导出不一定破坏兼容,但可能扩大公共面或造成名称冲突,也需要审阅。属性变化必须由所有者分类:内部符号误入基线可以通过批准的 contract manifest 移除,外部 ABI 则需要保持、提供兼容包装或安排明确迁移。禁止用一张无限增长的 allowlist 把所有差异静默忽略。
弱符号、IFUNC、TLS 对象和编译器生成符号需要单独分类,不能与普通函数一概比较。文章中的示例脚本只接受 FUNC 与 OBJECT 等 readelf 可见类型并原样报告属性,它不推断参数签名,也不反编译函数体。若公共契约依赖结构体布局、枚举宽度或调用约定,还要用头文件 ABI 检查与真实调用测试补足。
| 差异 | 默认处置 | 理由 | 解除阻断条件 |
|---|---|---|---|
| 公共符号删除 | 阻断 | 既有调用方可能无法解析 | 兼容入口恢复或迁移完成 |
| 版本节点变化 | 阻断 | 名称可能绑定到不同实现契约 | 版本策略和调用方验证 |
| bind/type/visibility 变化 | 阻断 | 解析或使用方式发生变化 | 所有者逐项批准并回归 |
| 新增公共符号 | 审阅 | 可能扩大 ABI 面或冲突 | 进入契约或改为隐藏 |
| 内部符号删除 | 条件允许 | 不属于外部契约 | manifest 证明没有允许消费者 |
版本节点和默认版本决定同名符号怎样绑定
ELF 符号版本允许同名接口关联不同版本节点,调用方还可能要求某个具体节点。验收时应同时采集 version definition、version need 与动态符号上的版本标记,区分默认版本和非默认版本。只看到 libfoo.so 仍导出 api_open,并不能证明旧调用方请求的 api_open@LIB_1.0 仍可解析。
版本演进的稳健做法是保留旧节点的行为契约,为不兼容语义引入新节点,并明确哪个版本作为新链接的默认项。删除旧节点、把默认项直接切换到行为不兼容实现,或让候选新增未声明的 version need,都应先阻断。具体兼容策略取决于调用方分布和发布模型,不能用“版本号更大”替代二进制验证。
GNU ld options 包含版本脚本等链接控制能力,也说明 -z relro 与 -z now 等选项会改变生成段或符号解析时机。版本脚本负责选择和组织导出,安全链接标志负责其他链接属性,两者不能互相替代。启用 RELRO 或立即绑定并不会恢复被删除的版本节点,也不能保护业务算法或证明运行时调用成功。
| 候选变化 | 旧调用方风险 | 门禁 | 验证重点 |
|---|---|---|---|
| 保留旧节点并新增节点 | 通常可控 | 审阅 | 旧版和新版消费者分别调用 |
| 删除旧节点 | 明确解析失败风险 | 阻断 | 完成调用方迁移后再移除 |
| 切换默认版本 | 新链接行为改变 | 阻断 | 默认与显式版本均回归 |
| 新增 version need | 部署环境可能缺少节点 | 阻断 | minSdk 与目标设备解析 |
| 只改版本脚本未改源码 | 导出仍可能改变 | 完整对比 | dynsym 与 version-info |
调用约定与数据布局要在每个架构上单独验收
符号成功解析只说明加载器找到了一个地址,不说明调用方和被调用方对参数、返回值、寄存器、栈、对齐和结构体布局达成一致。公共函数若改变整数宽度、浮点传递、可变参数、结构体字段顺序或打包方式,名称完全不变也可能产生错误数据或崩溃。C 接口同样需要稳定头文件、显式类型和架构测试。
Android ABIs 的差异要求团队逐 ABI 构建调用方夹具。夹具应覆盖边界值、空值策略、结构体尺寸与对齐、回调、错误码和资源释放,并把旧版与候选结果绑定到同一输入。若某个 ABI 没有实际候选产物或设备覆盖,报告必须标记未验证,不能由其他 ABI 的通过结果推断。
静态工具可以记录 ELF Class、Machine 与符号大小,却无法从动态符号表完整恢复 C 或 C++ 函数签名。工程上可把公共头文件生成的 ABI 描述、编译器诊断和调用夹具加入证据包。本文示例只检查可见的 ELF 契约变化,发现 size 变化会提示审阅,但不把 size 相同写成参数布局兼容。
| 维度 | 静态输入 | 运行验证 | 失败信号 |
|---|---|---|---|
| 标量参数 | 固定宽度类型声明 | 边界值往返 | 截断或符号扩展变化 |
| 结构体 | sizeof、alignof、字段偏移 | 旧调用方传入候选 | 字段错位或越界 |
| 返回值 | 返回类型与所有权 | 成功和失败路径 | 寄存器或释放约定不一致 |
| 回调 | 函数指针签名 | 同步、异步和取消 | 栈破坏或生命周期错误 |
| 可变参数 | 调用规则与格式 | 真实格式组合 | 架构相关读取错误 |
C++ ABI 还受运行库、异常和 RTTI 约束
C++ 公共符号通常包含名称修饰,并可能暴露 std::string、容器、模板、虚表、type_info 或异常类型。这些对象的二进制契约受编译器、标准库、编译标志和类布局影响。两个版本的 mangled name 相同不能覆盖所有语义,名字变化也可能只是源码签名已经改变的直接证据,因此跨 SO 公共边界要比普通 C 函数更谨慎。
Android C++ library support 说明 libc++ 的链接方式以及异常、RTTI 等能力取决于构建系统和运行库选择。若多个 SO 交换 C++ 对象或传播异常,应保存每个模块的运行库链接方式、异常和 RTTI 标志,并验证 type_info 身份、捕获行为、析构与 unwind。只看到 libc++_shared.so 已被打包,不能证明跨模块异常链完整。
工程上更容易维护的边界是 C ABI 加显式句柄、固定宽度标量和由同一模块分配释放的缓冲区,并把 C++ 实现留在库内部。这不是所有产品的强制规则,但能减少工具链细节泄漏到消费者。已有 C++ ABI 不能粗暴替换,需先枚举真实消费者、保持旧包装层并安排迁移回归。
| 对象 | 耦合来源 | 需要记录 | 不能只看 |
|---|---|---|---|
| mangled function | 编译器与签名 | 完整符号和工具链 | 可读函数名 |
| STL 对象 | libc++ 与布局 | 运行库链接方式 | 头文件相同 |
| 异常 | type_info 与 unwind | 异常、RTTI 标志和设备回归 | 能够编译链接 |
| 虚表 | 类布局与继承 | 类 ABI 和构造析构路径 | 某个方法仍存在 |
| 跨模块内存 | 分配器与所有权 | 创建、释放和错误规则 | 指针类型相同 |
平台 Native API 需求必须服从 minSdk 与装载环境
候选新增 NEEDED 或未定义动态符号时,还要判断目标平台是否在 minSdk 范围内提供它。Android NDK stable APIs 说明高于 minSdk 的 Native API 不能直接静态调用,必要时可以通过 dlopen 与 dlsym 做版本化处理。静态对比因此要把新增未定义符号和依赖库列为阻断项,交由 API 可用性审查。
动态解析也不是无条件通行证。库所在 namespace、装载路径、设备系统版本和符号可见性都会影响实际结果,且 dlsym 失败需要明确降级分支。来源只支持版本化处理方法,不证明任意库在所有 namespace 都可达。验收应在最低支持 API、代表性中间版本和当前目标版本上触发真实解析与功能路径。
候选若通过版本脚本隐藏了 JNI 或供其他 SO 使用的入口,平台 API 检查也无法补救。依赖方向应分开记录:本库提供给消费者的 export contract,以及本库向系统或其他库提出的 import contract。前者看 removed exports 与版本节点,后者看新增 UND、NEEDED 和版本需求,两边任一无解释变化都应阻断。
| 变化 | 首要问题 | 静态处置 | 设备验证 |
|---|---|---|---|
| 新增 UND 符号 | minSdk 是否提供 | 阻断并核对 API | 最低支持版本解析 |
| 新增 NEEDED | 库是否可装载 | 阻断并核对 namespace | 真实安装与加载 |
| 新增 version need | 目标节点是否存在 | 阻断并采集需求 | 代表版本逐一解析 |
| 改用 dlopen/dlsym | 失败分支是否完整 | 审阅路径和错误处理 | 可用与不可用版本均测试 |
| 删除 UND 或 NEEDED | 行为是否改变 | 审阅 | 功能与错误路径回归 |
把静态差异、真实调用和发布证据合成一道门禁
下方脚本以 old SO、candidate SO 和契约 JSON 为输入,使用 readelf 读取 ELF 身份、dynamic section、动态符号与版本信息。它按 name 与 version 组成键,报告删除、新增、属性变化、SONAME、NEEDED、Class、Machine 和版本信息摘要变化。契约 JSON 只允许列出经过所有者批准的内部删除,输入不合法、工具失败或存在未批准破坏时明确返回非零。
脚本不会解析 C++ 参数签名、结构体布局、运行时 namespace 或函数行为,也不会执行候选。其通过状态只表示采集范围内没有未批准的静态变化。随后还要让真实旧调用方在目标 ABI 设备上加载候选,覆盖版本化与默认符号、JNI 入口、错误码、结构体、回调、C++ 异常和释放路径,并对比未加固基线。
发布证据应保存旧版与候选摘要、脚本与 readelf 版本、契约 manifest、原始输出摘要、设备矩阵、调用回执和批准记录。任何未覆盖 ABI、API 或消费者必须列为限制,不得折算成通过。需要提交实际 SO 符号版本兼容评估时,先准备这套不可变材料,再通过御盾中央平台提交申请。
| 状态 | 含义 | 允许动作 | 仍需证据 |
|---|---|---|---|
| identity-invalid | 候选或 ABI 无法确认 | 停止比较 | 正确产物与构建记录 |
| static-contract-blocked | 存在未批准破坏 | 修复或迁移 | 新静态差异报告 |
| static-contract-passed | 静态契约未见阻断 | 进入设备回归 | 真实加载与调用 |
| runtime-limited | 部分矩阵通过 | 保留限制 | 剩余 ABI、API、消费者 |
| release-review-ready | 证据链完整 | 提交批准 | 业务与发布责任人签署 |
- old 与 candidate 均绑定 SHA-256、构建变体和同一目标 ABI
- readelf 工具版本和原始输出摘要随报告保存
- 删除、版本节点和属性变化默认阻断,内部例外必须逐项批准
- 新增导出与 NEEDED 进入人工审阅,不因兼容性较低风险而忽略
- 静态通过后用真实旧调用方在目标设备执行加载与调用回归
- 未覆盖 ABI、API、厂商或消费者明确列为限制
#!/usr/bin/env python3
import hashlib
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
SYMBOL_RE = re.compile(
r"^\s*\d+:\s+([0-9a-fA-F]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$"
)
SONAME_RE = re.compile(r"\(SONAME\).*?\[(.+?)\]")
NEEDED_RE = re.compile(r"\(NEEDED\).*?\[(.+?)\]")
CLASS_RE = re.compile(r"^\s*Class:\s*(.+)$", re.MULTILINE)
MACHINE_RE = re.compile(r"^\s*Machine:\s*(.+)$", re.MULTILINE)
def fail(message, code=2):
print(message, file=sys.stderr)
raise SystemExit(code)
def sha256_file(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def run_readelf(readelf, args, path):
command = [readelf, *args, str(path)]
completed = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
if completed.returncode != 0:
fail("readelf failed for " + path.name + ": " + completed.stderr.strip(), 10)
return completed.stdout
def load_contract(path):
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
fail("cannot read contract: " + str(exc))
if not isinstance(value, dict):
fail("contract must be a JSON object")
allowed = value.get("allowedRemovedInternalSymbols", [])
if not isinstance(allowed, list) or any(not isinstance(item, str) or not item for item in allowed):
fail("allowedRemovedInternalSymbols must be an array of non-empty strings")
return set(allowed)
def split_name_and_version(raw_name):
clean = raw_name.split()[0]
if "@@" in clean:
name, version = clean.split("@@", 1)
return name, version, True
if "@" in clean:
name, version = clean.split("@", 1)
return name, version, False
return clean, "", False
def parse_symbols(text):
symbols = {}
for line in text.splitlines():
match = SYMBOL_RE.match(line)
if not match:
continue
value, size, kind, bind, visibility, ndx, raw_name = match.groups()
name, version, is_default = split_name_and_version(raw_name)
if not name or name == "0":
continue
key = name + "@" + version
record = {
"name": name,
"version": version,
"defaultVersion": is_default,
"value": value,
"size": int(size),
"type": kind,
"bind": bind,
"visibility": visibility,
"ndx": ndx,
}
if key in symbols and symbols[key] != record:
fail("duplicate dynamic symbol key: " + key, 11)
symbols[key] = record
if not symbols:
fail("no dynamic symbols were parsed", 12)
return symbols
def parse_identity(header_and_dynamic):
class_match = CLASS_RE.search(header_and_dynamic)
machine_match = MACHINE_RE.search(header_and_dynamic)
if not class_match or not machine_match:
fail("ELF Class or Machine is missing", 13)
soname_match = SONAME_RE.search(header_and_dynamic)
needed = sorted(set(NEEDED_RE.findall(header_and_dynamic)))
return {
"class": class_match.group(1).strip(),
"machine": machine_match.group(1).strip(),
"soname": soname_match.group(1).strip() if soname_match else "",
"needed": needed,
}
def inspect(readelf, path):
header_dynamic = run_readelf(readelf, ["--file-header", "--dynamic", "--wide"], path)
dynsym = run_readelf(readelf, ["--dyn-syms", "--wide"], path)
versions = run_readelf(readelf, ["--version-info", "--wide"], path)
return {
"path": path.name,
"sha256": sha256_file(path),
"identity": parse_identity(header_dynamic),
"symbols": parse_symbols(dynsym),
"versionInfoSha256": hashlib.sha256(versions.encode("utf-8")).hexdigest(),
}
def public_symbols(records):
return {
key: value
for key, value in records.items()
if value["ndx"] != "UND" and value["bind"] in {"GLOBAL", "WEAK"}
}
def compare(old, candidate, allowed_removed):
findings = []
for field in ("class", "machine", "soname"):
if old["identity"][field] != candidate["identity"][field]:
findings.append({"kind": "identity-changed", "field": field, "old": old["identity"][field], "new": candidate["identity"][field]})
old_needed = set(old["identity"]["needed"])
new_needed = set(candidate["identity"]["needed"])
for name in sorted(new_needed - old_needed):
findings.append({"kind": "needed-added", "name": name})
for name in sorted(old_needed - new_needed):
findings.append({"kind": "needed-removed", "name": name})
old_public = public_symbols(old["symbols"])
new_public = public_symbols(candidate["symbols"])
for key in sorted(old_public.keys() - new_public.keys()):
if key not in allowed_removed:
findings.append({"kind": "public-symbol-removed", "symbol": key})
for key in sorted(new_public.keys() - old_public.keys()):
findings.append({"kind": "public-symbol-added-review", "symbol": key})
compared_fields = ("type", "bind", "visibility", "ndx", "defaultVersion", "size")
for key in sorted(old_public.keys() & new_public.keys()):
changes = {
field: {"old": old_public[key][field], "new": new_public[key][field]}
for field in compared_fields
if old_public[key][field] != new_public[key][field]
}
if changes:
findings.append({"kind": "public-symbol-attributes-changed", "symbol": key, "changes": changes})
if old["versionInfoSha256"] != candidate["versionInfoSha256"]:
findings.append({"kind": "version-info-changed-review"})
return findings
def main():
if len(sys.argv) != 4:
print("Usage: compare_so_abi.py OLD_SO CANDIDATE_SO CONTRACT_JSON", file=sys.stderr)
raise SystemExit(2)
old_path = Path(sys.argv[1])
candidate_path = Path(sys.argv[2])
contract_path = Path(sys.argv[3])
if not old_path.is_file() or not candidate_path.is_file() or not contract_path.is_file():
print("all three input files must exist", file=sys.stderr)
raise SystemExit(2)
readelf = shutil.which("readelf")
if not readelf:
fail("readelf is not available", 3)
allowed_removed = load_contract(contract_path)
old = inspect(readelf, old_path)
candidate = inspect(readelf, candidate_path)
findings = compare(old, candidate, allowed_removed)
blocking = [item for item in findings if not item["kind"].endswith("-review")]
result = {
"status": "static-contract-blocked" if blocking else "static-contract-passed",
"old": {"sha256": old["sha256"], "identity": old["identity"]},
"candidate": {"sha256": candidate["sha256"], "identity": candidate["identity"]},
"findings": findings,
"boundary": "Static ELF comparison does not prove runtime ABI compatibility.",
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if blocking:
raise SystemExit(20)
if __name__ == "__main__":
main()事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 版本脚本和默认隐藏可以收敛 Native 导出面并保留明确入口。 | Android NDK symbol visibility 说明 Android NDK 的符号可见性控制。 | 减少导出不能隐藏运行时必需接口,也不等于 VMP 或 ABI 兼容通过。 |
| 链接器选项可以控制版本脚本、RELRO 与符号解析等链接行为。 | GNU ld options 描述 GNU 链接器的版本和 -z 相关选项。 | 链接标志不能恢复被删除接口,也不保护业务算法或运行时明文。 |
| ELF header、dynamic section、符号、重定位和 unwind 信息可以静态检查。 | GNU readelf 描述 readelf 支持检查的 ELF 信息。 | 静态字段存在不证明动态加载、参数布局、调用行为或异常传播成功。 |
| 高于 minSdk 的 Native API 不能被直接静态调用,必要时可做版本化动态解析。 | Android NDK stable APIs 说明 Native API 的版本可用性。 | API 可用性不证明动态链接路径在全部 namespace 与设备中可达。 |
| 每个 Android ABI 有独立调用约定、寄存器、对齐与设备支持范围。 | Android ABIs 说明 NDK 支持 ABI 的技术差异。 | 声明支持某 ABI 不证明对应产物已经构建、打包和运行验证。 |
| C++ 异常、RTTI 与 libc++ 链接方式取决于构建系统和运行库选择。 | Android C++ library support 说明 NDK 的 C++ 运行库与功能配置。 | 启用编译选项不证明跨 SO 的 type_info、析构与 unwind 完整。 |
| 公共符号删除、版本节点漂移及绑定、类型或可见性变化应默认阻断。 | 工程判断:这些变化使既有调用方的解析或调用契约无法得到唯一解释。 | 内部符号可在真实消费者清单和所有者批准后例外,不得用无限 allowlist 掩盖差异。 |
| 静态 ABI 差异通过后仍需真实旧调用方在目标 ABI 设备执行加载和调用回归。 | 工程判断:动态符号表无法完整证明调用约定、数据布局、namespace 和运行行为。 | 单一设备与 ABI 通过不能外推到完整 API、ABI、厂商和消费者矩阵。 |
工程常见问题
两个 SO 导出符号名称完全相同,是否说明 ABI 兼容?
不能。还要比较版本节点、默认版本、绑定、类型、可见性、SONAME、依赖、调用约定和数据布局,并让真实旧调用方在目标 ABI 设备执行。
新增动态导出为什么也需要审阅?
新增通常不会让旧调用方立即失效,但可能扩大公共 ABI、造成名称冲突或暴露内部实现。应确认它是有意契约,否则通过版本脚本或隐藏可见性收敛。
保留旧符号但切换默认版本是否安全?
不应默认安全。新链接调用方可能绑定到新的默认实现,显式要求旧节点的消费者行为又不同,需要分别验证默认与指定版本的解析和调用。
C++ mangled name 没变化能否证明参数和异常兼容?
不能。类布局、标准库、编译器标志、type_info、析构和 unwind 仍可能改变,跨 SO 的 C++ 对象与异常必须结合工具链记录和设备回归。
脚本显示 static-contract-passed 后可以直接发布吗?
不可以。该状态只覆盖 readelf 可见差异,还需验证目标 ABI 的装载、版本解析、调用约定、结构体、回调、JNI、异常和核心业务路径。
申请 SO 符号版本与 ABI 评估需要准备什么?
准备旧版和候选 SO、SHA-256、发布 ABI、工具链与版本脚本、公共契约 manifest、真实调用方、设备矩阵和运行回执,再通过御盾中央平台提交申请。