先看结论与判断条件
- 分别记录 linker 映射、ELF 构造、Java 显式加载、JNI_OnLoad、JNI 注册和业务 ready,禁止用一个 loaded 布尔值代表全部阶段。
- DT_NEEDED 说明静态依赖关系,但不能直接推导所有库都会执行 JNI_OnLoad;需要显式声明哪些库由 VM 加载并期望回调。
- 初始化契约以稳定库 ID、事件、前置依赖、线程、幂等、失败传播和重试政策表达,不依赖偶然的源码或日志顺序。
- 加固前后使用同一 APK、ABI、进程入口和设备条件采集事件,比较缺失、逆序、重复、失败和未就绪调用,而不是只比较启动是否崩溃。
- linker namespace、缺失符号、STL 与异常是初始化失败的相邻原因,应分层诊断,但本文不重复完整依赖图和 namespace 专项。
- 静态依赖与运行事件都通过后,仍要覆盖冷启动、并发首次调用、进程恢复、错误注入和目标 API/ABI 设备,结论绑定真实候选。
初始化顺序至少包含五种不同事件
多个 Native 库启动时,经常把 System.loadLibrary 调用顺序当成全部顺序。实际需要区分 linker 映射依赖、ELF 初始化函数、Java 或 Kotlin 的显式加载、VM 对目标库调用 JNI_OnLoad、Native 注册完成,以及业务组件可以安全调用的 ready 事件。它们发生在相关但不相同的边界,日志里出现库名不代表所有阶段完成。
例如主库的 DT_NEEDED 可以让依赖库由 linker 映射并执行其 ELF 初始化,但这不等于依赖库一定获得独立 JNI_OnLoad 回调。JNI_OnLoad 与 VM 装载入口、JavaVM 和类加载环境有关。若依赖库必须注册 JNI,就应有明确显式加载或由主库统一注册的契约,不能依赖某次设备上的偶然回调顺序。
业务 ready 应是项目定义的最终门槛:必要依赖已映射、所需构造完成、JNI 方法注册成功、配置和线程前置满足、失败状态可见。上层只能在 ready 后调用核心接口;加载失败或注册失败时保持失败关闭,不把 loaded=true 提前写入缓存。这样加固导致某一阶段变化时,可以指出具体边界。
| 事件 | 执行者 | 表示什么 | 不能表示 |
|---|---|---|---|
| mapped | 动态 linker | 库进入进程映射 | ELF 构造成功 |
| constructor_complete | ELF 初始化流程 | 本库构造完成 | JNI 已注册 |
| load_success | 显式加载调用者 | System.loadLibrary 或等价入口返回 | 业务状态就绪 |
| jni_onload_success | VM 与目标库 | JNI_OnLoad 成功返回 | 所有业务依赖已初始化 |
| native_ready | 项目初始化器 | 声明的业务前置完成 | 未测试线程都安全 |
| load_failure | 任一加载阶段 | 当前候选未完成契约 | 可继续调用核心能力 |
静态图只描述依赖输入,不等于运行时间线
GNU readelf 可以读取 dynamic section、符号、重定位和其他 ELF 信息。用它从最终 SO 提取 DT_NEEDED,可以建立库级静态边:某库声明需要哪些依赖。该图适合发现缺库、循环设计、第三方来源和加固前后依赖变化,但静态字段不能告诉测试人员某个业务入口何时调用 System.loadLibrary,也不能证明构造和 JNI 回调成功。
静态图必须来自最终 APK 或设备 split 中的每个 ABI SO,记录 SO SHA-256、APK SHA-256、Build ID、Machine、库名、DT_NEEDED、运行路径和来源。中间 build 目录可能包含未 strip、未加固或未打包版本,不能替代最终候选。更完整的库级依赖建模可参考同站 [DT_NEEDED 依赖图](/zh-cn/articles/elf-dt-needed-dependency-graph/),本页只把图作为顺序契约输入。
运行时间线由设备事件产生。至少记录 sequence、单调时钟、进程、线程、libraryId、event、result 和 candidateId。多线程并发时,时间戳相近不等于存在先后保证;只有同步、依赖或显式契约才能建立 happens-before。报告把“观察到先发生”和“必须先发生”分开,避免一次日志顺序被永久固化为不必要约束。
| 字段 | 静态图 | 运行时间线 | 用途 |
|---|---|---|---|
| candidateId | APK 与 SO 摘要 | 同一候选标识 | 防止证据串包 |
| libraryId | 稳定库身份 | 事件所属库 | 连接两类证据 |
| needed | DT_NEEDED 边 | 映射现场可观测 | 解释 linker 依赖 |
| event | 不适用 | mapped、JNI 或 ready | 确定具体阶段 |
| sequence/thread | 不适用 | 顺序与执行线程 | 识别并发和逆序 |
| source | 构建与供应方 | 设备与测试入口 | 限定证据来源 |
初始化契约写必须先后,而不是写理想日志
契约由若干必要关系组成,每条关系写 beforeEvent、afterEvent、reason、owner、failurePolicy 和 testEntry。例如 support:mapped 必须先于 engine:load_success,engine:jni_onload_success 必须先于 engine:native_ready。只有业务或平台要求的关系进入契约;没有同步依据的并行构造只记录观察,不强行排序。
每个库还声明 explicitLoad、jniOnLoadExpected 和 readyOwner。显式加载库若期望 JNI_OnLoad,运行时间线必须出现成功事件;仅作为 DT_NEEDED 依赖且不拥有 JNI 注册的库可以不期待 JNI_OnLoad。这个字段避免测试器误把所有依赖都要求同一种回调,也能发现某库从显式加载改成依赖加载后丢失注册。
失败政策必须具体。某依赖失败是否阻断整个 Native 子系统,是否允许非相关功能继续,能否重试,重试是否需要新进程,用户看到什么,以及日志怎样关联,都要写清。初始化不是普通幂等函数时,重复调用可能造成二次注册、重复线程或状态污染;契约为每个阶段标注 once、idempotent 或 retry-new-process,测试据此判定。
| 字段 | 问题 | 示例值 | 缺失后果 |
|---|---|---|---|
| before/after | 哪两个事件必须排序 | mapped 到 load_success | 无法判定逆序 |
| reason | 为何需要顺序 | 依赖符号或注册表 | 偶然日志变契约 |
| jniOnLoadExpected | VM 是否应调用回调 | true 或 false | 依赖库被误判 |
| threadPolicy | 在哪个线程初始化 | loader、main 或 worker | 线程差异被忽略 |
| retryPolicy | 失败后怎样再次尝试 | never 或 new-process | 重复初始化污染 |
| testEntry | 如何从真实业务触发 | 启动、SDK 或功能页 | 只有人工日志 |
JNI_OnLoad 只承担小而确定的注册工作
Android JNI tips 讨论 JNI 注册、线程、引用、异常和类加载边界。JNI_OnLoad 常用于取得 JavaVM、执行 RegisterNatives 或缓存经过审阅的 ID,但不适合塞入长耗时网络、复杂业务、不可控锁等待或依赖 UI 的工作。回调返回失败时,加载结果必须向上层传播,不能记录警告后继续调用未注册方法。
类查找与类加载器需要明确。由 Java 或 Kotlin 触发加载时,JNI_OnLoad 所处上下文与任意 Native 工作线程不同;后台线程直接 FindClass 可能使用不同类加载边界。契约记录注册类、签名、加载入口和线程,设备测试从正式入口触发。缓存引用还要区分局部与全局生命周期,避免初始化顺序问题被引用失效伪装。
异常同样是初始化结果。每次 JNI 调用后按项目契约检查异常,注册失败保留首个错误并停止后续依赖初始化。清理只能释放当前阶段已拥有的资源,不删除其他库的全局状态。JNI 建议能指导设计,但不能证明加固后 ABI、寄存器、异常或线程行为已经保持,仍需同候选运行回归。
| 职责 | 适合程度 | 关键断言 | 失败动作 |
|---|---|---|---|
| 保存 JavaVM | 常见 | 指针有效且只初始化一次 | 返回失败 |
| RegisterNatives | 常见 | 类、签名和方法完整 | 停止依赖 ready |
| 缓存全局引用 | 逐项评审 | 创建、所有权和释放明确 | 清理当前所有权 |
| 启动后台线程 | 谨慎 | 线程与退出契约独立 | 不得半初始化继续 |
| 读取远端配置 | 不适合 | 移出装载临界路径 | 由业务初始化负责 |
| 调用其他未就绪库 | 禁止隐式 | 必须有显式 before/after | 阻断并修契约 |
namespace、符号和运行库错误要从顺序问题中拆开
AOSP linker namespace 文档说明 Android 动态链接器按调用者 namespace 处理 DT_NEEDED、dlopen、搜索路径和允许库。库未映射可能是 namespace 不可达、路径错误或依赖缺失,不一定是 Java 加载顺序错。顺序回归记录调用者、目标库名、namespace 现场和 linker 错误,把可达性问题转交专项,不通过多调用几次 loadLibrary 掩盖。
Android NDK common problems 汇总 API level、缺失符号、STL、异常类型和错误依赖装载等故障。某库在 JNI_OnLoad 前失败,先看 linker 首个错误和缺失符号;构造完成后崩溃,再看构造依赖与线程;JNI 注册失败则看类、签名和异常。使用最早失败阶段路由,比把所有 Native 启动问题称为初始化顺序更准确。
加固前后对比时保持 APK、ABI、设备 API、安装集合和入口一致。若调整加载顺序后现象消失,要恢复原顺序复现或补同步与依赖证据,证明因果。永久添加 sleep、重复 loadLibrary、捕获所有 Throwable 或忽略 JNI_OnLoad 返回,会让问题暂时不可见,却破坏错误传播和可维护性。
| 最早失败 | 优先检查 | 顺序证据 | 错误修复 |
|---|---|---|---|
| 找不到目标库 | 打包、路径和 namespace | 显式 load 入口 | 重复 loadLibrary |
| 缺少依赖或符号 | DT_NEEDED、API 和提供者 | mapped 前置是否满足 | 改变 Java 调用延时 |
| ELF 构造崩溃 | 构造依赖、锁和全局状态 | constructor 事件 | 吞掉退出 |
| JNI_OnLoad 失败 | 类、签名、异常与线程 | 显式加载和回调 | 继续标记 ready |
| ready 前业务调用 | 门控与并发入口 | before/after 契约 | 加固定 sleep |
| 重试后异常 | 幂等与部分清理 | retryPolicy | 无限循环重试 |
用静态图和设备事件生成顺序差异表
自动门禁接收静态图和运行事件两个 JSON。静态图列出 libraryId、needed、jniOnLoadExpected 与 orderContracts;运行事件列出 sequence、libraryId 和 event。脚本先验证库身份、依赖引用、事件类型和序号唯一,再检查每条 DT_NEEDED 依赖是否在依赖者 load_success 前出现 mapped,并检查显式 before/after 契约。
下面 Python 示例不加载任何 SO、不修改产物,也不包含真实库名。它不会从 DT_NEEDED 推导依赖库一定执行 JNI_OnLoad;只有 jniOnLoadExpected=true 才要求该事件。它还阻断运行日志中的未知库、重复关键事件、load_failure 和逆序契约,最后输出 Markdown 差异表,便于把静态关系、运行序号与状态放进发布审阅。
事件采集器应在测试构建中使用稳定、低侵入的标记,不能把敏感符号、密钥或用户数据写入日志。sequence 由单一进程内记录器分配,跨进程分别建表。若事件丢失,结论是证据不足而不是默认成功。真实项目还要绑定候选摘要、进程、线程和单调时间,并保留原始日志。
- 静态图和运行事件使用同一候选 ID
- 每个 libraryId 与 sequence 必须唯一
- DT_NEEDED 只检查映射前置,不推导 JNI 回调
- JNI_OnLoad 仅按显式 expected 字段要求
- 缺失、逆序、重复和 load_failure 均阻断
- 差异表与原始事件日志一起归档
from pathlib import Path
import json
import sys
if len(sys.argv) != 3:
raise SystemExit("usage: compare_init_order.py static-graph.json runtime-events.json")
graph_file = Path(sys.argv[1])
events_file = Path(sys.argv[2])
for required in (graph_file, events_file):
if not required.is_file():
raise SystemExit(f"missing input file: {required}")
graph = json.loads(graph_file.read_text(encoding="utf-8"))
runtime = json.loads(events_file.read_text(encoding="utf-8"))
libraries = graph.get("libraries")
events = runtime.get("events")
if not isinstance(libraries, list) or not isinstance(events, list):
raise SystemExit("libraries and events arrays are required")
known_events = {
"mapped", "constructor_complete", "load_success",
"jni_onload_success", "native_ready", "load_failure"
}
lib_by_id = {}
failures = []
for item in libraries:
library_id = item.get("libraryId")
if not library_id or library_id in lib_by_id:
failures.append(f"invalid or duplicate libraryId: {library_id!r}")
else:
lib_by_id[library_id] = item
positions = {}
seen_sequences = set()
for item in events:
sequence = item.get("sequence")
library_id = item.get("libraryId")
event = item.get("event")
if not isinstance(sequence, int) or sequence in seen_sequences:
failures.append(f"invalid or duplicate sequence: {sequence!r}")
continue
seen_sequences.add(sequence)
if library_id not in lib_by_id or event not in known_events:
failures.append(f"unknown runtime event: {library_id}:{event}")
continue
key = (library_id, event)
if key in positions:
failures.append(f"duplicate critical event: {library_id}:{event}")
else:
positions[key] = sequence
if event == "load_failure":
failures.append(f"runtime load failure recorded for {library_id}")
rows = []
def compare(before, after, basis):
before_seq = positions.get(tuple(before))
after_seq = positions.get(tuple(after))
status = "pass"
if before_seq is None or after_seq is None:
status = "missing"
elif before_seq >= after_seq:
status = "reversed"
rows.append((basis, ":".join(before), before_seq, ":".join(after), after_seq, status))
if status != "pass":
failures.append(f"{basis}: {status}")
for library_id, item in lib_by_id.items():
for dependency in item.get("needed", []):
if dependency not in lib_by_id:
failures.append(f"{library_id}: unknown dependency {dependency}")
continue
compare((dependency, "mapped"), (library_id, "load_success"), "DT_NEEDED")
if item.get("jniOnLoadExpected") is True:
compare((library_id, "load_success"), (library_id, "jni_onload_success"), "JNI_OnLoad")
for contract in graph.get("orderContracts", []):
compare(contract.get("before", []), contract.get("after", []), contract.get("reason", "contract"))
print("| basis | before | before_seq | after | after_seq | status |")
print("|---|---|---:|---|---:|---|")
for row in rows:
print(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} | {row[5]} |")
if failures:
for failure in failures:
print(failure, file=sys.stderr)
raise SystemExit(2)冷启动、并发和进程恢复要分别回归
Android instrumented tests 运行在设备或模拟器上,适合从 Application、Activity、Service、Worker 或 SDK 真实入口触发 Native 初始化。冷启动验证第一次完整时间线,热路径验证已就绪状态不重复构造或注册,并发首次调用验证只有一个所有者执行初始化、其他调用者等待或得到明确失败。直接调用测试辅助初始化函数不能替代正式入口。
进程死亡后所有进程内 Native 状态都会重建,磁盘或服务端标记却可能保留。测试杀进程后从通知、深链、后台任务和普通启动恢复,确认每个进程重新形成自己的事件序列,旧进程的 ready 不被复用。多进程应用为每个进程维护独立契约;主进程通过不代表 remote service 进程。
失败注入使用公开安全条件,例如让测试适配器返回注册失败、缺少可选能力或拒绝业务配置,不删除系统库、不篡改设备。验证失败传播、部分资源清理、用户回退和下一次允许的重试。单一设备通过不能代表全部 API、ABI 和厂商,矩阵按实际 minSdk、目标 ABI 和加载入口扩展。
| 场景 | 起始状态 | 关键断言 | 主要风险 |
|---|---|---|---|
| 冷启动 | 新进程无 Native 状态 | 完整事件按契约到 ready | 缺失或逆序 |
| 重复进入 | 已 ready | 不重复非幂等初始化 | 二次注册和线程 |
| 并发首次调用 | 多个入口同时触发 | 单一所有者与明确等待 | 竞态和半初始化 |
| 失败注入 | 指定阶段返回失败 | 停止依赖调用并回退 | 失败后继续执行 |
| 进程恢复 | 旧进程已退出 | 新进程重新建立状态 | 复用旧 ready |
| 多进程 | 各进程独立加载 | 每个进程有完整回执 | 主进程结果外推 |
退出证据和首个失败阶段决定发布结论
Android ApplicationExitInfo 可以提供进程退出原因等信息,并在支持版本和条件下提供 ANR trace 或 Native tombstone 相关输入。退出记录要按包版本、进程、时间窗和用户路径与初始化事件关联,不能看到一次 Native crash 就自动归因到 JNI_OnLoad。低版本或没有可用 trace 时明确证据缺口,结合日志和同候选复现。
定位按最早失败阶段进行:未 mapped 查打包与 namespace,构造前缺符号查依赖和 API,constructor 失败查全局状态与锁,JNI_OnLoad 失败查类、签名、异常和线程,ready 前业务调用查门控与并发。修复一次只改变一个变量并生成新候选;如果顺序调整有效,恢复旧变量复现或补同步证据。
放行结论应限定为指定 APK 和 SO 摘要在列出的 API、ABI、进程与入口中,静态依赖和运行事件满足声明契约,并且失败和恢复路径完成回归。不能写成初始化永久无竞态、所有设备兼容或加固不影响任何库。准备评估时,可整理最终 APK、DT_NEEDED 图、显式加载清单、JNI_OnLoad 注册、事件日志、退出证据和设备矩阵,再通过御盾中央平台提交申请。
| 证据 | 绑定字段 | 支持的判断 | 不能外推 |
|---|---|---|---|
| 最终候选 | APK、SO、ABI 和 Build ID | 检查对象唯一 | 同名文件相同 |
| 静态图 | readelf 与最终 SO | DT_NEEDED 关系 | 运行时间线 |
| 顺序契约 | 版本、所有者和入口 | 必要先后关系 | 所有事件都串行 |
| 运行事件 | 设备、进程和候选 | 列出阶段实际顺序 | 未测设备结果 |
| 退出证据 | 版本、时间窗和路径 | 首个失败辅助定位 | 单条记录给出根因 |
| 放行记录 | 通过、失败和未执行 | 当前矩阵结论 | 永久兼容或零风险 |
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 注册、线程、引用、异常和类加载边界会影响 Native 桥接稳定性。 | Android JNI tips 描述相关实践与限制。 | JNI 建议不能证明某个加固 SO 保持 ABI、线程和异常语义。 |
| readelf 可以检查 ELF dynamic section、符号、重定位和其他结构。 | GNU readelf 描述对应选项和输出。 | DT_NEEDED 与静态字段不能证明动态加载、构造和 JNI_OnLoad 成功。 |
| Android linker 会按调用者 namespace 处理 DT_NEEDED、dlopen、搜索路径和允许库。 | AOSP linker namespace 描述 namespace 解析与隔离模型。 | VNDK 文档不能替代普通 App 进程、目标设备和实际候选的装载测试。 |
| Native 常见故障包括 API level、缺失符号、STL、异常类型和错误依赖装载。 | Android NDK common problems 汇总相关问题与诊断方向。 | 问题清单只能路由首个失败,不能替代目标 ABI 的运行回归。 |
| 依赖 Android linker、JNI、组件和系统 API 的语义适合设备端测试。 | Android instrumented tests 描述在设备或模拟器上访问 Android 框架的测试。 | 单一设备通过不能代表全部 API、ABI、进程和厂商环境。 |
| ApplicationExitInfo 可以提供进程退出原因,并在支持条件下提供相关 trace 输入。 | Android ApplicationExitInfo API 描述退出信息、trace 与 Native tombstone 相关能力。 | 退出记录仍需按候选版本、进程、时间窗和用户路径关联,不能单独证明根因。 |
| 初始化契约应分开 DT_NEEDED 映射、构造、显式加载、JNI_OnLoad 和业务 ready。 | 工程判断:这些事件由不同执行者触发,失败语义和可观测证据不同。 | 具体 before/after 关系必须来自项目依赖、同步和业务要求,不能从一次日志猜测。 |
| 静态依赖、顺序契约、运行事件和退出证据必须绑定同一候选身份。 | 工程判断:只有同候选证据才能排除重构建、ABI 替换和旧日志造成的漂移。 | 证据绑定完整仍不能外推未执行设备或证明初始化永远没有竞态。 |
工程常见问题
System.loadLibrary 的代码顺序就是 Native 初始化顺序吗?
不是全部。还要区分 linker 映射、ELF 构造、JNI_OnLoad、注册完成和业务 ready,并考虑依赖与并发。
DT_NEEDED 依赖库是否一定会执行 JNI_OnLoad?
不能这样推导。DT_NEEDED 描述 linker 依赖;是否期望 JNI_OnLoad 要按 VM 的显式加载入口和项目注册契约单独声明。
为什么加载成功后还要定义 native_ready?
load 返回或 JNI_OnLoad 成功不一定代表配置、依赖和业务初始化完成。ready 提供上层可调用的唯一门槛。
添加 sleep 能否修复初始化逆序?
不能作为正式修复。sleep 没有建立依赖和同步保证,应明确 before/after 契约、所有者、失败传播和真实同步。
一次 Native 崩溃能否说明 JNI_OnLoad 有问题?
不能。要把退出记录与同候选事件序列关联,先确定最早失败在映射、构造、注册、ready 还是业务调用。
申请 Native 初始化顺序评估要准备什么?
准备最终 APK 与 SO、DT_NEEDED 静态图、显式加载清单、JNI_OnLoad 注册、顺序契约、设备事件和退出证据,再通过御盾中央平台提交申请。