先看结论与判断条件
- 任何可能抛出 Java 异常的 JNI 调用后都要立即检查 pending 状态,返回值在检查前不能当作有效业务数据。
- 异常处理策略只能明确选择原样传播,或捕获、ExceptionClear 后转换;盲目清理会丢失 Java 类型、消息和堆栈。
- pending exception 存在时不能继续任意 JNI 调用,清理局部引用与继续业务处理要遵循受限契约。
- Java 异常、JNI 错误码、C++ 异常和进程崩溃是不同通道,跨边界前必须转换为单一、可审计的结果。
- VMP 候选应位于异常已规范化之后的稳定 Native 决策核心,JNIEnv、jobject、局部引用和桥接查找逻辑通常排除。
- 发布验证必须覆盖抛出、传播、清理、转换、重复调用、线程附着、进程恢复和符号化,单次成功调用不能放行。
pending exception 会改变后续 JNI 调用的前提
Native 代码通过 CallObjectMethod、CallVoidMethod、NewObject、FindClass、GetMethodID 等 JNI 接口进入 Java 运行时,这些操作可能因为 Java 方法主动抛出、类加载失败、内存不足或签名不匹配而留下 pending exception。此时 C++ 语句仍可能继续执行,JNI 返回值也可能是 null 或零值,但它们不能被解释为普通业务结果。第一责任是检查异常状态,而不是继续解引用或写入状态。
立即处理的原因不是代码风格,而是 JNI 环境已经处于异常路径。pending exception 存在时,Native 端能够安全执行的 JNI 操作受限制;继续查类、调方法、创建对象或读取字段,可能触发 CheckJNI 报告、产生二次故障,或让真正的 Java 异常被后续崩溃掩盖。错误表面最终可能出现在完全不同的地址,使堆栈看起来像随机 Native 问题。
每个边界调用应采用固定节奏:执行一个可能抛出的 JNI 操作,立刻调用 ExceptionCheck 或等价检查,若无异常再验证返回值,若有异常则进入传播或转换分支。不能先执行一批调用后统一检查,因为无法知道第一项失败后哪些返回值已经失效,也无法保证后续调用在 pending 状态下合法。
| JNI 操作 | 潜在失败 | 立即检查 | 检查前禁止 |
|---|---|---|---|
| FindClass | 类加载异常或类不存在 | ExceptionCheck 加 null | 继续 GetMethodID |
| GetMethodID | 签名或方法不存在 | ExceptionCheck 加 methodID | 继续 CallMethod |
| NewObject | 构造函数异常或分配失败 | ExceptionCheck 加 jobject | 保存或解引用对象 |
| CallObjectMethod | Java 方法抛出 | ExceptionCheck 加返回引用 | 读取字段或转换字符串 |
| CallVoidMethod | Java 方法抛出 | ExceptionCheck | 推进业务成功状态 |
| GetStringUTFChars | 转换或分配失败 | ExceptionCheck 加指针 | 读取或释放无效指针 |
先决定传播还是转换,不能边走边猜
原样传播适合 Java 调用者已经定义异常契约的场景。Native 端检测到 pending exception 后停止普通 JNI 工作,只做必要的本地资源清理,然后直接返回,让 JVM 将原异常交给 Java 层。这样可以保留异常类型、消息、cause 和 Java 堆栈,也避免创建一个语义重复的新异常。调用者必须明确 native 方法可能抛出哪些异常,并在测试中覆盖。
清理并转换适合跨模块只允许结构化错误码的场景。Native 端先用 ExceptionOccurred 捕获 throwable 引用,在允许范围内保存必要分类,再调用 ExceptionClear,随后转换为应用定义的错误对象或状态。清理之后不能假装 Java 调用成功,原返回值必须丢弃。若要保留 cause,应在安全路径构造新异常或交给 Java 辅助方法,且任何再次抛出都要检查。
最危险的写法是发现异常后无条件 ExceptionClear,再继续原业务分支。这会把类加载失败、权限异常、参数错误和业务拒绝全部压成 null 或默认值,既丢失诊断证据,也可能把失败当作允许。另一种错误是同时保留 pending exception 又调用 ThrowNew,造成异常覆盖或未定义的错误路径。每个 wrapper 必须只有一种明确出口。
| 策略 | 处理顺序 | 适用条件 | 禁止行为 |
|---|---|---|---|
| 原样传播 | 检查、清理 Native 资源、返回 | Java 调用者理解原异常 | 清理异常后伪装传播 |
| 清理并转换 | 捕获、分类、清理、构造错误 | 接口只接受规范错误 | 继续使用原返回值 |
| 转换为新 Java 异常 | 捕获、清理、ThrowNew、返回 | 公开异常类型稳定 | 抛出后继续 JNI 调用 |
| 返回错误码 | 捕获、清理、记录分类、返回 | Java 层有强制检查契约 | 错误码与成功值冲突 |
| 终止当前操作 | 检查、释放本地资源、回退 | 非关键可恢复路径 | 推进部分业务状态 |
| 盲目 ExceptionClear | 无分类直接清理 | 不应采用 | 隐藏根因并继续执行 |
异常状态下的清理必须保持最小化
异常路径仍然要释放 Native RAII 资源,例如互斥锁、文件描述符和堆内存,但 JNI 清理要遵循 pending 状态限制。局部引用通常会在 native 方法返回时释放,显式删除引用是否必要取决于生命周期和数量。不要为了追求“清理完整”而在异常状态下调用新的 Java 方法、读取更多对象或格式化复杂消息,这些动作可能再次依赖失败的运行时路径。
wrapper 应把需要清理的 Native 资源放在 RAII 对象中,使传播异常时直接 return 也能释放。JNI 局部引用可使用小型作用域包装器,但包装器析构只能执行允许且可预测的引用删除,不应隐藏 FindClass、CallMethod 或日志回调。异常分支越短,越容易证明没有在 pending 状态下继续普通调用。
日志也要克制。ExceptionDescribe 适合受控调试,不应成为生产错误处理的默认动作,因为它可能输出内部类名、消息或业务数据。生产回执保存边界名称、操作类别、错误分类、候选摘要和设备信息即可,不复制完整 throwable 内容到普通日志。若需要详细堆栈,应在受控诊断通道关联同一事件 ID。
Java 异常与 C++ 异常是两条不同通道
Java pending exception 保存在 JNI 环境的运行时状态中,C++ exception 则通过编译器、unwind 信息和运行库传播。Native wrapper 不能用 catch(...) 假定捕获 Java 异常,也不能让未处理的 C++ 异常穿过 JNI 导出函数进入 Java。边界前必须分别处理:JNI 调用后检查 pending,Native 调用用明确的 C++ 异常或错误码契约,最终只向 Java 暴露已声明结果。
Android C++ library support 说明 C++ 异常、RTTI 与 libc++ 链接方式依赖构建系统和运行库选择。即使编译打开异常选项,跨 SO 的 type_info、unwind 和运行库组合仍需核对。本文不展开 C++ 运行库一致性,只强调 Java pending exception 不会因为启用了 C++ exceptions 自动安全传播,两者要在 wrapper 中明确汇合。
推荐的内部结果可以是 Result<Value, NativeError> 或固定错误枚举,包含 category、operation 与 retryable,但不携带 jobject、JNIEnv 指针和局部引用。JNI 外层把 Java 异常转换为该结果,或选择原样传播;Native 核心只处理纯数据。这样单元测试可以脱离 JVM 覆盖决策,设备测试则专门验证 wrapper、类加载、线程和异常映射。
| 通道 | 表示形式 | 边界动作 | 诊断证据 |
|---|---|---|---|
| Java 异常 | pending throwable | 传播或清理后转换 | Java 类型与边界分类 |
| JNI 查找失败 | null 加 pending 状态 | 立即检查并停止 | 类、方法和签名标识 |
| C++ 异常 | throw 与 unwind | 在 JNI 导出边界捕获 | Native 类型与构建身份 |
| Native 错误码 | Result 或枚举 | 显式映射给 Java | 操作和错误类别 |
| 进程信号 | tombstone 与地址 | 崩溃诊断流程 | ABI、符号与候选 |
| 业务拒绝 | 服务端或状态机结果 | 保持拒绝语义 | 主体、对象和原因码 |
SO/VMP 边界应放在异常规范化之后
JNI 导出函数、类与方法查找、jobject 解包、局部引用管理和 ExceptionCheck 分支属于桥接层。它们与 Android 运行时、类加载器、方法签名和线程附着高度耦合,发生问题时需要可读堆栈和精确诊断,通常不宜粗放进入 SO/VMP。保护这些胶水不会让异常契约更可信,反而可能改变控制流布局并增加排查难度。
更适合保护的是异常已经转换为纯数据之后的 Native 决策核心,例如许可证状态组合、风险评分、离线计数和商业规则。核心函数不接收 JNIEnv、jobject、jclass 或局部引用,不直接调用 Java,不负责线程附着,也不自行清理 pending exception。它的输入输出可以在主机单元测试和设备测试中复用,保护前后容易比较。
若业务要求保护整个 native 方法,应先用 wrapper 把 JNI 操作集中到薄边界,再让 protectedCore 接受复制后的值。任何 wrapper 变化都要重新验证异常检查顺序、返回值失效、局部引用和 Java 观察到的异常类型。没有真实候选证据时,只能给出方法候选和测试计划,不能声称变换保持 ABI 或异常语义。
| 对象 | 耦合 | VMP 建议 | 验证重点 |
|---|---|---|---|
| JNI 导出入口 | 签名、注册和运行时 | 通常排除 | 符号与调用约定 |
| FindClass 与 GetMethodID | 类加载器和签名 | 排除 | 查找失败与 pending |
| CallMethod 包装器 | Java 异常和引用 | 通常排除 | 立即检查与传播 |
| 线程附着 | JavaVM 与线程生命周期 | 排除 | attach、detach 和退出 |
| 纯数据校验器 | 低运行时耦合 | 敏感时可保护 | 边界值与错误枚举 |
| Native 决策核心 | 业务状态与规则 | 稳定敏感逻辑可保护 | 保护前后语义一致 |
常见错误会把第一现场推迟成崩溃
Android NDK common problems 汇总的故障包括 API level、缺失符号、STL、异常类型和依赖装载等问题。它们可能在 JNI 边界表现为类或方法查找失败、Native 装载失败或异常类型错配。故障清单只能帮助分类,不能代替目标 ABI 的真实启动与异常回归;尤其不能看到一个 null 就立即归因于 SO/VMP。
错误代码常把 CallObjectMethod 返回 null 当作合法“没有数据”,随后继续 GetObjectClass 或 GetStringUTFChars。若 null 实际伴随 pending exception,后续崩溃会遮住最初 Java 原因。另一个模式是先 ExceptionCheck,再记录日志时调用 Java logger,导致异常状态下继续普通 JNI 调用。日志应使用不依赖 Java 的最小 Native 通道,或在清理并分类后交给上层。
静态审查应搜索所有可能抛出异常的 JNI 调用,要求相邻检查和明确出口;动态回归则让 Java helper 主动抛出不同异常,确认 Native 不使用返回值、不推进状态并保持预期类型。两者都通过才能说明当前候选覆盖了已知契约,但仍不代表所有系统异常、内存压力和设备实现都已验证。
用事件序列检查器审计 wrapper 异常契约
下面的 Python 脚本读取 JNI wrapper 在测试模式输出的结构化事件序列。每个可能抛出异常的 jni-call 后必须紧跟 exception-check;pending=false 才能继续普通调用,pending=true 时只能 capture、describe、clear 或 propagate。若选择 clear,下一步必须 translate-error,随后只能清理 Native 资源并 return-error。事件只记录操作类别和错误分类,不保存 Java 对象、消息或用户数据。
状态机拒绝未检查的连续调用、异常存在时的普通 JNI 操作、清理后继续成功路径、转换后再次调用 Java 和缺少终止出口。这样可以把 wrapper 的控制流约束变成 CI 可读取的回执。实际 Native 包装器仍需在每次 JNI 调用后生成对应事件,并保证埋点本身不调用 Java 或改变异常状态。生产构建可以关闭详细事件,只保留同候选测试回执。
脚本通过不证明 C++ 源码所有路径都被执行,也不证明事件未被漏报。项目应结合代码审查、分支覆盖和主动抛出测试验证埋点完整性。它也不会检查 JNI 允许操作的完整规范列表,而是采用更严格的项目契约:异常出现后只允许最短传播或清理转换路径。严格子集更易审计,但需要在设计时确认不会遗漏必要清理。
from pathlib import Path
import json
import sys
if len(sys.argv) != 2:
raise SystemExit(2)
trace_path = Path(sys.argv[1])
if not trace_path.is_file():
raise SystemExit(2)
try:
events = json.loads(trace_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
raise SystemExit(2)
if not isinstance(events, list) or not events:
raise SystemExit(2)
allowed_pending = {"exception-capture", "exception-describe", "exception-clear", "propagate", "native-cleanup"}
allowed_translated = {"native-cleanup", "return-error"}
state = "ready"
issues = []
terminal = False
for index, event in enumerate(events):
if not isinstance(event, dict) or not isinstance(event.get("op"), str):
raise SystemExit(2)
op = event["op"]
if terminal:
issues.append({"index": index, "issue": "event-after-terminal"})
continue
if state == "need-check":
if op != "exception-check" or not isinstance(event.get("pending"), bool):
issues.append({"index": index, "issue": "jni-call-not-immediately-checked"})
state = "invalid"
elif event["pending"]:
state = "pending"
else:
state = "ready"
continue
if state == "pending":
if op not in allowed_pending:
issues.append({"index": index, "issue": "ordinary-operation-while-exception-pending"})
state = "invalid"
elif op == "exception-clear":
state = "cleared"
elif op == "propagate":
terminal = True
continue
if state == "cleared":
if op != "translate-error" or not isinstance(event.get("category"), str) or not event["category"]:
issues.append({"index": index, "issue": "cleared-exception-not-translated"})
state = "invalid"
else:
state = "translated"
continue
if state == "translated":
if op not in allowed_translated:
issues.append({"index": index, "issue": "jni-operation-after-error-translation"})
state = "invalid"
elif op == "return-error":
terminal = True
continue
if state == "ready" and op == "jni-call":
state = "need-check"
elif state == "ready" and op not in {"native-work", "native-cleanup", "return-success"}:
issues.append({"index": index, "issue": "unexpected-ready-operation"})
elif state == "ready" and op == "return-success":
terminal = True
if state in {"need-check", "pending", "cleared", "translated"} and not terminal:
issues.append({"index": len(events), "issue": "unterminated-exception-contract"})
print(json.dumps({"eventCount": len(events), "terminal": terminal, "issues": issues}, ensure_ascii=False, indent=2))
if issues or not terminal:
raise SystemExit(3)设备回归要主动制造每条异常路径
Android instrumented tests 适合验证依赖真实 Android 运行时、组件和系统 API 的语义。测试应用可以提供 Java helper,分别在构造函数、实例方法、静态方法和字符串转换前后主动抛出异常,Native wrapper 调用后检查 Java 层观察到的类型、错误码和业务状态。单元测试只能覆盖纯 Native 结果,不能替代 JVM pending 状态。
矩阵还应覆盖方法不存在、类加载器不同、null 参数、线程未附着、重复调用、局部引用压力和进程恢复。不同 API、ABI 与厂商系统选择代表设备,记录候选摘要、SO build ID、异常入口和结果。一个设备通过只能支持该组合,不能直接推广到完整矩阵;blocked 用例也不应停止其他独立路径继续取证。
保护前后比较必须使用同一输入和异常触发器。确认 pending 检查点没有移动,原样传播仍保留异常类型,转换路径仍返回同一 category,业务状态没有部分提交,Native 清理没有泄漏或二次调用。若 SO/VMP 改变控制流或符号布局,回归报告要绑定变换后的最终 SO,不得用未保护库的结果代替。
| 用例 | 触发位置 | 核心断言 | 失败后定位 |
|---|---|---|---|
| Java 方法主动抛出 | CallMethod 内部 | 立即检查并保持出口 | wrapper 检查顺序 |
| 类或方法不存在 | FindClass 或 GetMethodID | null 不进入后续调用 | 签名与类加载器 |
| 清理并转换 | pending=true 分支 | clear 后只返回规范错误 | 异常分类映射 |
| 原样传播 | Java 契约异常 | 原类型到达调用者 | 清理与返回路径 |
| 线程附着异常 | 工作线程调用 | JNIEnv 不跨线程复用 | attach 与 detach |
| 保护后回归 | 最终 SO 候选 | 结果、异常和状态一致 | 候选与符号身份 |
崩溃诊断要绑定同一构建的符号
若异常契约失效最终导致 Native 崩溃,ndk-stack 可使用未剥离符号目录与同一构建的地址信息还原堆栈。地址、ABI、build ID、最终 SO 摘要和符号包摘要必须对应,重新构建的近似符号不能替代。符号化成功只把地址转换为函数与行号,不证明第一处 pending exception 已被定位。
Debug Android native code 说明 Native 调试和 tombstone 还原依赖正确符号、架构与构建产物。调试回执应关联 Java 异常日志、wrapper 事件 ID、tombstone、设备和候选,按时间寻找第一项异常检查缺口。公开文章和普通日志不应包含可运行攻击链、私有地址、完整用户数据或敏感符号包。
准备接入评估时,可在御盾中央平台提交最终 APK、SO 摘要与 build ID、未剥离符号回执、JNI 接口清单、异常策略、VMP 方法清单和设备矩阵。登录、注册、申请、价格与控制台动作统一由中央平台承接。没有同候选的真实异常回归和符号证据时,应保持待验证,不写兼容通过、性能收益或攻击阻断结论。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 注册、线程、引用、异常和类加载边界会直接影响 Native 桥接稳定性。 | Android JNI tips 描述 JNI 线程、引用、异常检查与类加载等实践。 | JNI 建议不证明某个 SO/VMP 变换保持 ABI、异常语义或设备兼容。 |
| NDK 兼容故障常涉及 API level、缺失符号、STL、异常类型和依赖装载。 | Android NDK common problems 汇总常见 NDK 构建与运行故障类别。 | 故障清单只能帮助分类,不能替代目标 ABI 上的真实启动和异常回归。 |
| C++ 异常、RTTI 与 libc++ 链接方式取决于构建系统和运行库选择。 | Android C++ library support 描述异常、RTTI 与 C++ 运行库配置。 | 启用编译选项不证明跨 SO 的 type_info、unwind 和异常传播完整。 |
| 依赖真实 Android 运行时与系统 API 的 JNI 异常语义适合在设备端验证。 | Android instrumented tests 描述在 Android 设备环境执行测试的用途。 | 单一设备通过不能代表完整 API、ABI、厂商和线程矩阵。 |
| Native 地址还原需要未剥离符号目录与同一构建的地址信息。 | ndk-stack 描述使用符号目录和崩溃输出还原 Native 堆栈。 | 符号化成功不证明第一现场或根因已经定位。 |
| Native 调试与 tombstone 分析依赖正确符号、架构和构建产物。 | Debug Android native code 描述 Native 调试、符号与设备诊断入口。 | 调试能力不构成生产兼容通过,也不应扩展为公开攻击复现链。 |
| pending exception 出现后必须停止普通 JNI 工作并选择传播或清理转换。 | 工程判断:继续使用失效返回值和推进状态会掩盖原始 Java 异常并制造二次故障。 | 允许的最小清理操作与异常类型仍需依据实际 wrapper 和 JNI 规范逐项核对。 |
| SO/VMP 应优先保护异常规范化后的纯 Native 决策核心。 | 工程判断:JNIEnv、局部引用、查找、线程和异常检查胶水耦合运行时且需要高可诊断性。 | 是否适合保护以及保护后语义是否一致,需要真实候选与设备证据确认。 |
工程常见问题
JNI 调用返回 null 时为什么不能直接当作没有数据?
null 可能同时伴随 pending exception。必须先 ExceptionCheck,再根据无异常的 null 契约处理;否则后续 JNI 调用或解引用会遮住真正的 Java 异常。
发现 pending exception 后可以直接 ExceptionClear 吗?
不能无条件清理。应先决定原样传播还是捕获后转换,保存必要分类并丢弃原返回值。盲目清理会丢失异常类型、消息和堆栈,还可能把失败当作成功。
C++ catch 能否捕获 Java pending exception?
不能把两者视为同一通道。Java 异常保存在 JNI 环境状态,C++ exception 依赖 unwind 和运行库。wrapper 必须分别处理并在 JNI 导出边界汇合。
JNI wrapper 是否适合整体做 SO/VMP?
通常不宜粗放保护。类与方法查找、引用、线程和 ExceptionCheck 需要高可诊断性。先提取纯数据决策核心,再对稳定敏感函数评估保护。
代码审查确认每处都调用 ExceptionCheck 后还要真机测试吗?
需要。静态审查无法证明所有分支被执行,也不能覆盖类加载器、线程附着、运行时异常类型和保护后控制流。设备测试应主动抛出并核对每条出口。
申请 JNI 异常契约评估需要准备什么?
准备最终 APK、SO 摘要与 build ID、JNI 接口和调用清单、异常传播与转换规则、VMP 方法清单、符号回执和设备测试矩阵,再通过御盾中央平台提交。