先看结论与判断条件

  • 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 文本可以辅助阅读,但受语言、平台和实现影响,不能充当稳定类型或分支条件。

JNI 边界的三个失败通道
通道有效条件公开用途常见错误
Native 返回值API 契约声明失败触发内部映射把任意负数当 errno
errno失败 API 明确设置保留原始诊断码成功后读取旧值
pending exceptionJNI 抛出或 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,并允许诊断层保留原码;两层分别服务稳定行为与根因分析。

ErrorContext 的机器字段
字段来源稳定性公开边界
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;不能把未知值当成功,也不能用最接近的字符串猜类别。监控发现未知组合后,通过版本化映射表新增规则,再用目标候选回归,不在客户端热补一段字符串判断。

有限 Java 失败类型的用途
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 方法采用异常还是结果对象,不让不同入口形成互相矛盾的风格。

pending exception 的处理决策
状态允许动作公开终态禁止动作
无异常且成功构造返回值业务成功读取无意义 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_exceptionJNI 最外层 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 候选和测试回执要绑定。门禁通过不等于目标设备异常已被正确抛出,更不等于客户场景完成验收。

JNI 错误映射表校验器
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 的实际行为。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: SO 加固与 Native 兼容性检查清单