先看结论与判断条件
- errno 只在约定失败的 Native 调用之后有意义,必须立即复制,后续日志、清理或 JNI 调用都可能覆盖线程局部值。
- 错误契约需要包含 domain、nativeCode、operation、publicType、retryable 和 causePolicy,而不是只传一条本地化字符串。
- 同一 Native 错误只能映射到一个稳定 Java 失败类型;不同错误可以归并,但归并边界要由调用语义决定。
- JNI pending exception、Native 返回失败和清理失败是三个独立通道,桥接函数必须规定优先级,不能同时向 Java 暴露冲突终态。
- C++ 异常不应跨 JNI 边界逸出;捕获、分类与 Java 映射要在同一编译和运行库约束下回归。
- 加固、重链接、API level 或依赖变化后,要用同一 SO 候选验证成功、已知错误、未知错误、pending exception 和清理失败。
先分开 errno、返回值和 pending exception
Native 函数常用返回值表示成功或失败,并在约定的失败路径设置 errno;JNI 函数又可以通过 ThrowNew 或被调用的 Java 方法留下 pending exception。三者不是同一个信号。返回负值不一定使用 errno,errno 非零也不表示最近操作失败,而 pending exception 存在时 JNI 环境只允许有限操作。桥接代码必须先知道被调用 API 的精确契约。
稳定入口采用单一终态原则:成功时返回业务值且没有 pending exception;失败时要么抛出一个 Java 异常并返回约定占位值,要么返回结构化失败对象,不同时使用两套公开通道。内部可以保留 errno 和 Native 状态用于诊断,但 Java 调用方不应既检查 magic number 又猜测异常是否存在。
错误域用于避免数字碰撞。POSIX errno、自定义解析状态、库返回码和平台 API 状态都可能出现相同整数,却有完全不同语义。保存时至少记录 domain 与 code,再结合 operation 进入映射表。日志中的 strerror 文本可以辅助阅读,但受语言、平台和实现影响,不能充当稳定类型或分支条件。
| 通道 | 有效条件 | 公开用途 | 常见错误 |
|---|---|---|---|
| Native 返回值 | API 契约声明失败 | 触发内部映射 | 把任意负数当 errno |
| errno | 失败 API 明确设置 | 保留原始诊断码 | 成功后读取旧值 |
| pending exception | JNI 抛出或 Java 调用失败 | 成为 Java 终态 | 带异常继续普通 JNI 调用 |
| 清理失败 | 主操作后释放资源失败 | 按优先级附加或升级 | 覆盖主错误 |
| 日志文本 | 诊断展示 | 人工排查 | 作为机器协议 |
在失败点立即冻结原始错误上下文
errno 通常是线程局部状态,任何后续可能触达 C 运行库的操作都可能改变它。正确顺序是检查目标调用返回值,确认失败,然后立刻复制 errno 到局部不可变变量。格式化日志、构造 Java 字符串、释放另一个资源和查询系统状态都放在复制之后,否则最终记录可能属于清理函数而非原始操作。
冻结的 ErrorContext 应包含 domain、nativeCode、operation、phase 和 candidateId。operation 使用稳定标识,如 open_cache 或 parse_header,不把文件路径、账号或用户内容混入机器字段。phase 区分 validation、execution、cleanup 和 callback,便于同一个码在不同阶段采用不同公开语义,同时维持最小化日志。
原始码可以进入受控诊断回执,但 Java 公共 API 不宜把所有平台数值直接暴露给业务。不同系统版本或厂商实现可能产生不同底层码,业务若据此分支会形成脆弱依赖。映射层将其收敛为有限 publicType,并允许诊断层保留原码;两层分别服务稳定行为与根因分析。
| 字段 | 来源 | 稳定性 | 公开边界 |
|---|---|---|---|
| domain | 调用 API 家族 | 版本化稳定 | 可公开枚举 |
| nativeCode | 失败点立即复制 | 平台相关 | 受控诊断 |
| operation | 桥接入口定义 | 业务稳定 | 公开安全标识 |
| phase | 执行状态机 | 稳定枚举 | 可用于统计 |
| candidateId | 已装载 SO 身份 | 候选唯一 | 回执必填 |
| message | 本地化或库文本 | 不稳定 | 仅人工诊断 |
Java 失败类型应少而稳定,映射表应唯一
Java 层需要按可恢复动作而不是 Native 细节划分类别。例如 InvalidInput 表示调用前即可修正,NotFound 表示目标不存在,PermissionDenied 表示当前主体无权访问,ResourceExhausted 表示资源不足,Unavailable 表示暂时不可用,Internal 表示未分类内部错误。类别数量有限,便于 Kotlin when 和监控长期稳定。
不同 errno 可以归并到同一 publicType,但同一个 domain、operation、nativeCode 组合不能出现两个目标类型。若同一底层码在不同 operation 中语义不同,operation 必须成为映射键的一部分。映射表还要声明 retryable,因为同类错误在不同操作下重试策略可能不同,不能只看异常类名称决定自动重试。
未知错误是正常的演进边界,必须有 unknown fallback。回退通常映射到 Internal 或 NativeFailure,并保留受控原码与 operation;不能把未知值当成功,也不能用最接近的字符串猜类别。监控发现未知组合后,通过版本化映射表新增规则,再用目标候选回归,不在客户端热补一段字符串判断。
| publicType | 调用方动作 | 是否通常重试 | 不得包含 |
|---|---|---|---|
| InvalidInput | 修正请求 | 否 | Native 内存地址 |
| NotFound | 提示或重建资源 | 按业务决定 | 本地绝对路径 |
| PermissionDenied | 重新授权或终止 | 否 | 权限绕过建议 |
| ResourceExhausted | 释放资源或降级 | 受控重试 | 无限循环 |
| Unavailable | 稍后再试 | 受预算限制 | 底层实现文本 |
| Internal | 停止当前操作并记录 | 默认否 | 伪造根因 |
pending exception 存在时先收束 JNI 状态
JNI 调用 Java 方法、查找类、分配对象或创建字符串都可能留下 pending exception。异常存在后继续调用普通 JNI API,可能产生第二个失败或让本地引用清理路径偏离预期。每个可能抛出的 JNI 操作后应检查 ExceptionCheck,并立即进入统一出口,不再尝试构造另一个复杂异常对象。
若 pending exception 已经表达原始 Java 失败,桥接层通常保留它,只完成允许且必要的 Native 清理。若契约要求转换为公共异常,需要先提取受控分类信息,再按明确策略清除原异常并构造新异常;清除不是默认动作,因为会丢失 Java cause 和堆栈。任何转换都要防止异常构造本身再次失败。
ThrowNew 成功只是设置 pending exception,Native 函数仍必须返回到 Java。代码不应在抛出后继续业务计算,也不能把返回占位值误记录为成功。调用方看到异常时忽略返回值,因此文档与测试要明确每个 JNI 方法采用异常还是结果对象,不让不同入口形成互相矛盾的风格。
| 状态 | 允许动作 | 公开终态 | 禁止动作 |
|---|---|---|---|
| 无异常且成功 | 构造返回值 | 业务成功 | 读取无意义 errno |
| 无异常且 Native 失败 | 映射并抛出或返回失败 | 单一失败 | 同时使用两通道 |
| 已有 Java 异常 | 最小清理后返回 | 保留异常 | 继续普通 JNI 调用 |
| 策略化异常转换 | 提取分类并明确清除 | 新异常带 cause 策略 | 静默吞掉原异常 |
| 异常构造失败 | 保持已有异常或最小回退 | 明确内部失败 | 递归构造异常 |
清理失败不能随意覆盖主操作失败
资源清理也可能失败,例如 close、flush、unlock 或释放外部句柄。桥接函数若主操作已经失败,再用清理 errno 覆盖 ErrorContext,会让 Java 收到错误根因。应在进入 cleanup 前冻结 primaryError,清理错误保存为 secondaryError;公开终态默认以主错误为准,并把次错误放进受控诊断回执。
当主操作成功而必须持久化的 flush 或 close 失败时,清理错误可能升级为公开失败,因为业务结果未真正提交。是否升级取决于 operation 的事务语义,不由某个 errno 通用决定。映射规则可声明 cleanupPolicy 为 ignore_after_primary、attach_secondary 或 promote_on_success,测试覆盖每种状态组合。
清理过程中若已有 pending exception,要避免通过复杂 JNI 调用记录次错误。Native 侧可以写入固定大小的内部状态或交给外部采集,Java 异常返回后再由上层关联。日志调用同样可能失败或覆盖 errno,因此所有需要的原始码都在进入日志前保存,且日志不参与业务终态判断。
C++ 异常、运行库与 API level 另设边界
C++ 异常不能跨 extern C JNI 边界逸出。JNI 入口应在最外层捕获约定的业务异常、std::exception 和未知异常,转换成内部 ErrorContext,再走同一 Java 映射。捕获策略需要与项目编译选项、RTTI、libc++ 链接方式和跨 SO 边界一致,不能假设启用异常开关就保证 type_info 与 unwind 完整。
若 Native 调用的系统 API 高于 minSdk,装载阶段可能在业务错误映射之前失败。必要的版本化动态解析也要产生独立 domain,如 api_resolution,并把 symbol_missing 与 operation_error 分开。API 可用、动态链接 namespace 可达和调用结果正确是不同条件,任何一项失败都不能伪装成普通 errno。
加固、符号可见性、链接顺序或运行库变化可能改变异常路径和映射入口。回归不仅测试常见 errno,还要测试 C++ 业务异常、未知异常、符号不可用、已有 pending exception 和 cleanup 组合。没有同一候选的运行回执时,静态映射表通过只能证明配置完整,不能证明实际 unwind 和 JNI 状态稳定。
| 错误域 | 捕获位置 | 映射输入 | 主要限制 |
|---|---|---|---|
| posix_errno | 失败系统调用后 | operation 与保存 errno | 只在 API 契约设置时读取 |
| native_status | 库返回后 | 稳定状态码 | 不得与 errno 混用 |
| cpp_exception | JNI 最外层 catch | 异常类别与 phase | 不能跨 C 边界逸出 |
| jni_pending | 每个可抛 JNI 调用后 | ExceptionCheck 结果 | 限制后续 JNI 操作 |
| api_resolution | 动态解析阶段 | 符号与平台版本 | 可达性需实际验证 |
| cleanup | 统一出口 | 主次错误与策略 | 不得覆盖主错误 |
用映射表门禁检查唯一性、完整性和未知回退
错误映射适合写成公开安全 JSON。每条规则包含 domain、operation、nativeCode、publicType 和 retryable,不包含真实路径、用户数据或内部地址。顶层列出允许的 Java 类型和 unknownFallback。门禁验证映射键唯一、类型在 allowlist 内、retryable 是布尔值,并要求每个 domain 都存在明确通配回退。
下面的 Python 校验器从命令行读取映射文件。输入缺失时直接非零退出;随后拒绝空规则、重复键、非法 publicType、错误 retryable 类型和缺少 domain 回退。它不执行 JNI,不生成异常,也不暴露可运行攻击链,作用是让一次规则变更在进入候选前暴露协议冲突。
实际运行时仍要验证映射器读取的是相同版本配置,pending exception 分支没有绕过规则,Java 异常类能够由目标 ClassLoader 找到。映射表哈希、SO 候选、App 候选和测试回执要绑定。门禁通过不等于目标设备异常已被正确抛出,更不等于客户场景完成验收。
import json
import sys
from pathlib import Path
REQUIRED = {"domain", "operation", "nativeCode", "publicType", "retryable"}
def stop(message):
print(f"error map rejected: {message}", file=sys.stderr)
raise SystemExit(2)
def load_map(path_text):
path = Path(path_text)
if not path.is_file():
print("error map rejected: mapping file is missing", file=sys.stderr)
raise SystemExit(2)
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
stop(f"cannot parse mapping: {exc}")
def validate(data):
allowed = set(data.get("allowedPublicTypes", []))
rules = data.get("rules")
fallback = data.get("unknownFallback")
if not allowed or not isinstance(rules, list) or not rules:
stop("allowed types and non-empty rules are required")
if fallback not in allowed:
stop("unknown fallback is not an allowed public type")
keys = set()
domains = set()
wildcard_domains = set()
for index, rule in enumerate(rules):
if not REQUIRED.issubset(rule):
stop(f"rule {index} is missing required fields")
key = (rule["domain"], rule["operation"], str(rule["nativeCode"]))
if key in keys:
stop(f"rule {index} duplicates an existing mapping key")
keys.add(key)
domains.add(rule["domain"] )
if rule["nativeCode"] == "*":
wildcard_domains.add(rule["domain"] )
if rule["publicType"] not in allowed:
stop(f"rule {index} uses an unknown public type")
if type(rule["retryable"]) is not bool:
stop(f"rule {index} retryable must be boolean")
missing = domains.difference(wildcard_domains)
if missing:
stop(f"domains lack unknown fallback rules: {sorted(missing)}")
print(f"error map accepted: {len(rules)} rules")
if __name__ == "__main__":
if len(sys.argv) != 2:
stop("usage: validate_error_map.py mapping.json")
validate(load_map(sys.argv[1]))设备回归要验证终态,而不是只看日志
测试矩阵至少包含成功、每个已知错误域、未知码、pending Java 异常、异常构造失败、主失败加清理失败、仅清理失败和 C++ 未知异常。每个用例断言 Java 只观察一个终态,并核对 publicType、retryable、cause 策略和原始诊断码是否符合映射表,不能只搜索日志中某个单词。
instrumented test 可以从 Android 应用上下文触发 JNI 方法、检查异常类和生命周期重建后的行为。崩溃或进程退出样本还需使用 ApplicationExitInfo 与同候选时间窗关联。一个设备通过不代表所有 ABI、系统和厂商,测试回执必须携带候选 APK、SO、映射表与设备身份。
站内的 JNI 异常清理文章可继续检查 pending exception 下的本地引用与资源释放;准备评估商业 App 的 SO 加固和 JNI 错误契约时,可通过页面行动按钮进入御盾中央平台提交候选包、错误域和映射表。提交只启动评估,异常稳定性仍以同一候选的设备回执为准。
- 失败点立即复制 errno 和错误域。
- Java 公开失败类型保持有限且版本化。
- 已有 pending exception 时进入最小统一出口。
- 主错误与清理错误分别记录并按策略决定优先级。
- 每个错误域配置唯一映射和 unknown fallback。
- 候选 SO、映射表、符号和设备回执保持同一身份链。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 注册、线程、引用、异常与类加载边界会影响桥接稳定性。 | Android JNI tips 说明 JNI 线程、引用、异常和类查找等实践。 | 官方建议不证明加固后的目标 SO 保持相同 ABI 和异常行为。 |
| 错误转换回归要排除 API level、符号、运行库和依赖装载问题。 | Android NDK common problems 汇总这些常见 Native 兼容故障。 | 问题清单不能代替目标 ABI 的启动和异常路径执行回执。 |
| C++ 异常、RTTI 和 libc++ 行为依赖构建与运行库选择。 | Android C++ library support 说明 C++ 运行库与异常相关构建边界。 | 启用选项不证明跨 SO type_info 与 unwind 完整。 |
| 高于 minSdk 的 Native API 需要独立版本和解析边界。 | Android NDK stable APIs 说明 API level 与必要动态解析的要求。 | API 声明可用不证明动态链接 namespace 中实际可达。 |
| 异常导致的进程退出要与候选版本和时间窗关联。 | Android ApplicationExitInfo 提供退出原因及相应版本诊断数据入口。 | 退出记录不自动证明 errno 映射或 JNI 异常是根因。 |
| Java 可见异常类型和 JNI 终态应在 Android 设备环境验证。 | Android instrumented tests 说明设备端测试可访问应用上下文和平台 API。 | 单一设备通过不能覆盖全部 API、ABI、厂商和错误组合。 |
| 每个错误域必须有唯一映射与未知回退。 | 工程判断:不唯一或无回退会让调用方对同一失败产生冲突行为。 | 具体 publicType 与 retryable 仍需业务和风险评审。 |
| 当前不能声称目标 App 的 errno 与异常转换已经稳定。 | 项目证据尚未接入;需要候选 SO、映射表、设备矩阵和实际异常回执。 | 文章提供方法和代码门禁,不替代项目运行验证。 |
工程常见问题
errno 非零是否就表示刚才的 Native 调用失败?
不是。只有 API 契约说明失败且返回值进入失败分支时才读取 errno,并要立即复制;成功后看到的非零值可能是旧状态。
为什么不能把 strerror 文本直接传给 Java 判断?
文本可能因平台、语言和实现变化,无法形成稳定机器协议。Java 应按有限 publicType 分支,原始码和文本只用于受控诊断。
JNI 已有 pending exception 时还能抛出新的业务异常吗?
不能直接继续普通 JNI 调用。要么保留原异常并最小清理,要么按明确转换策略提取信息、清除并构造新异常,同时处理构造失败。
close 失败应该覆盖主操作错误吗?
通常不应。主错误先冻结,清理错误作为次错误;只有主操作成功且清理属于提交语义时,项目策略才可能把它升级为公开失败。
C++ exception 能否直接穿过 JNI 被 Java catch?
不应让 C++ 异常越过 extern C JNI 边界。入口最外层捕获并转换为内部错误,再生成 Java 契约允许的单一终态。
SO 加固后为什么要重跑错误映射测试?
加固和重链接可能改变符号、异常表、类查找和清理路径。候选身份变化后,旧异常回执不能证明新 SO 的实际行为。