先看结论与判断条件

  • 跨 SO 传递指针并不天然错误,真正风险是释放责任、分配器家族、对象布局、对齐、运行库和异常语义没有形成可验证契约。
  • 生产库分配的对象应由同一库导出的 destroy 释放,调用方不直接选择 free、delete、delete[] 或自定义池。
  • C++ STL 对象、异常和 RTTI 暴露在共享库 ABI 上会放大工具链与 libc++ 组合差异,稳定边界优先采用不透明句柄和 C ABI。
  • 接口要携带 ABI 版本、结构尺寸、能力位和所有者标识,升级时拒绝未知布局,不能根据头文件相似就强制转换。
  • 失败、取消、部分构造、回调和重复关闭路径必须统一进入幂等或明确报错的释放协议,避免异常分支绕过所有权。
  • 设备验收要覆盖每个交付 ABI、不同构建组合、重复释放、创建失败、进程退出和保护前后对照,并绑定最终 so 摘要。

先纠正跨 SO 一定不能 free 的误解

跨共享库分配和释放并不因为经过 SO 边界就必然崩溃。在同一进程、同一 C 分配器和正确函数族下,生产者调用 malloc、消费者调用匹配的 free 可能工作。但这种隐式假设很脆弱:生产库可能使用自定义池、对齐分配、new、new[] 或带头部的包装器,升级后也可能改变实现。调用方只拿到裸指针,无法从地址判断应由哪个释放函数处理。

更常见的崩溃来自函数族不匹配和生命周期错误。new 对应 delete,new[] 对应 delete[],malloc 或 calloc 对应 free,库自有池则必须使用同一池的释放接口。对象若已由回调、异常清理或超时路径释放,调用方再次释放会形成 double free;对象仍被异步线程使用时提前销毁,则形成 use-after-free。SO 边界只是让责任更难追踪。

本文只回答跨 SO 内存所有权为什么容易失控,以及怎样建立 create/destroy 契约,不重复完整 C++ 运行库一致性专题。没有最终二进制、目标 ABI 和设备回执时,不能宣称某个分配器组合安全或某次崩溃已经修复。接口审查、静态链接关系和运行时内存诊断属于不同证据层。

常见分配与释放家族
创建方式匹配释放跨库风险建议边界
malloc/callocfree包装器或自定义替换不可见生产库导出 destroy
new Tdelete析构与运行库语义暴露不透明句柄
new T[]delete[]数组元数据和析构数量生产库释放数组
aligned_alloc匹配的 free 约定对齐和平台实现差异记录对齐与所有者
自定义对象池同一池回收跨模块无法识别池身份只返回受控句柄
映射或外部缓冲专用 unmap/release长度和来源容易丢失显式释放函数

所有权协议要回答谁创建谁销毁

稳定接口需要把所有权写进函数语义。`create` 成功后由调用方持有一个不透明句柄,但句柄代表的内存仍由生产库管理;调用方完成使用时只能调用同一接口族的 `destroy`。若函数只借用缓冲区,应明确有效期覆盖当前调用、回调还是会话;若转移所有权,转移点和失败回滚必须唯一,不能让双方都认为对方负责。

生产库的 destroy 具有实现知识,可以选择正确分配器、执行析构、释放内部子对象并更新诊断状态。调用方不需要知道句柄是单个对象、数组、池节点还是引用计数包装。即使未来实现从 malloc 改为自定义池,只要 ABI 版本和销毁契约不变,调用方也无需改变释放方式。这比在文档里写“请使用 free”更能承受版本演进。

所有权还要覆盖空值和重复关闭。可以规定 destroy(null) 为无操作,或者把空值视为调用错误,但行为必须稳定。重复 destroy 对裸指针无法安全检测,因为第一次释放后读取元数据本身可能非法;更可靠的上层包装会在首次关闭后把句柄清零,并拒绝后续操作。Native 侧可以增加调试登记表,但生产正确性不能依赖已释放地址的查询。

  • 每个创建函数都有同一接口族的销毁函数
  • 借用、共享和转移所有权使用不同语义
  • 失败发生前后只有一个明确所有者
  • destroy 由生产库选择正确分配器和析构路径
  • 上层包装在关闭后清零句柄并拒绝复用
  • 空值与重复关闭行为写入版本化契约

C ABI 不透明句柄比跨库 C++ 对象稳定

直接在 SO 边界传递 std::string、std::vector、智能指针、带虚表对象或抛出 C++ 异常,会把布局、模板实例、allocator、RTTI、异常类型和 libc++ 组合一起暴露给调用方。生产库与消费库若由不同 NDK、编译选项或运行库方式构建,头文件相同也不保证二进制语义一致。Android C++ 库支持资料强调异常、RTTI 与 libc++ 链接方式取决于构建系统和运行库选择。

更稳妥的公共边界使用 `extern C` 风格函数、固定宽度整数、字节缓冲区、不透明 handle 和显式状态码。C ABI 只能减少名称修饰和 C++ 布局暴露,并不自动保证结构体兼容;结构若必须跨边界,应包含版本和 size,新增字段只追加,双方按较小已知尺寸访问。任何指针字段都要说明由谁分配、有效到何时、能否跨线程使用。

C ABI 内部仍可使用 C++ 实现,但异常不能穿过未约定边界。生产库应在边界捕获异常,清理部分构造状态,并转换为稳定错误码;消费库也不能把自己的 C++ 对象内存交给生产库按另一类型析构。RTTI 与动态类型判断若必须跨库,需要统一工具链和专项回归,通常不如显式 type tag 与版本字段可控。

跨 SO 接口类型的稳定性比较
接口类型暴露内容主要风险优先方案
裸 void*地址但无所有权释放函数和大小未知不透明句柄加 destroy
C 结构体固定字段布局size、对齐和版本漂移version 与 struct_size
std::string/vectorSTL 实例和 allocator运行库与布局耦合字节视图或复制接口
C++ 多态对象虚表、RTTI 和析构工具链与符号可见性内部隐藏实现
跨库异常type_info 与 unwind运行库组合和清理不一致边界内捕获转状态码
回调上下文用户指针和生命周期异步释放竞态显式 retain/release 协议

libc++ 组合和构建系统要统一取证

同一个 App 中多个使用 C++ 的共享库需要明确 libc++ 的链接方式和版本。不同库各自静态链接运行库,会把 allocator、异常、RTTI、iostream 和全局状态的边界变得复杂;使用共享运行库也仍需保证版本、打包和加载一致。这里的重点不是宣称某一种组合绝对安全,而是要求所有生产库、预编译 SDK 和最终 APK 有可追溯的运行库清单。

NDK 常见问题包括 STL、异常类型、缺失符号、API level 和错误依赖装载。内存崩溃可能由错误释放触发,也可能是跨库异常未正确展开、析构未执行或装载了非预期运行库。诊断时要同时采集崩溃线程、分配与释放模块、so build ID、DT_NEEDED、符号化堆栈和操作路径,不能看到 allocator 函数名就立即断言跨库 free 是唯一根因。

CMake toolchain 可以固定 ABI、平台版本和第三方库导入方式,但不覆盖预编译 so 的内部编译选项。非标准构建也必须显式固定 NDK toolchain、API level、ABI 和目标。发布台账应从最终 ELF 读取依赖与 build ID,再与构建配置交叉;源码 CMakeLists 或供应商说明不能替代最终产物事实。

  • 每个 C++ so 的 NDK、libc++ 链接方式和版本可追溯
  • 预编译 SDK 的内部构建未知项明确标记
  • 最终 ELF 依赖与构建配置交叉核对
  • 异常、RTTI、allocator 和全局状态边界分别评估
  • 崩溃归因包含分配模块与释放模块证据
  • 运行库一致不被当作所有权协议的替代

版本、尺寸和对齐必须进入接口契约

每个 Android ABI 都有独立调用约定、寄存器、对齐和设备支持范围。结构体在 32 位与 64 位环境中的指针宽度、自然对齐和 padding 可能不同,不能将一个 ABI 的 `sizeof` 记录复制到另一个 ABI。若 C ABI 暴露结构,使用固定宽度整数,显式传递 `struct_size` 和 `abi_version`,并为关键 offset 建立编译期与设备端测试。

生产库接收结构时,先检查版本和最小尺寸,再只读取双方都知道的字段。调用方接收输出时也要提供容量,生产库返回实际所需长度,避免无界写入。新增字段应追加而不是重排,语义变化应提升 ABI 版本。不能只比较头文件文本,因为编译器选项、packing pragma 和宏都可能改变最终布局。

对齐分配也需要完整元数据。若生产者要求某个对齐并返回缓冲区,destroy 必须知道原始分配方式;调用方不能把调整后的内部地址交给 free。切片或子指针只具有借用语义,销毁时归还原始 handle。把地址、容量、长度、对齐和所有者拆开传递时,任何一个字段漂移都会形成越界或错误释放,优先用不透明对象封装。

版本化 C ABI 的必要字段
字段用途拒绝条件验证
abi_version选择接口语义未知主版本双方版本矩阵
struct_size限制可访问字段小于最小布局sizeof 与 offset
owner_tag标识生产接口族与 destroy 不匹配创建销毁回执
capacity限制写入范围小于请求长度边界与零长度
alignment约束缓冲区地址不支持或无效值逐 ABI 地址检查
status跨边界错误语义未知状态码失败和异常映射

异常、回调和并发路径决定真实所有权

正常路径往往不是错误释放的来源,异常和取消才会暴露双重责任。create 完成一半后失败,生产库必须销毁已构造成员并返回空 handle;调用方只有在收到成功状态和非空 handle 后才取得关闭责任。若业务函数返回错误但对象仍有效,契约要明确是否可以重试或必须销毁,不能让每个调用者自行猜测。

异步回调会延长对象寿命。调用方发起操作后立即 close,而生产库线程仍持有指针,会造成 use-after-free;生产库在回调完成后自行销毁,调用方又 close,则造成 double free。可以采用显式 retain/release、操作完成回调后才能 close,或取消并等待的协议。选择哪种都要形成状态机,并在超时、取消、进程退出和回调重入下测试。

多线程 close 需要同步。Java 或 Kotlin 包装层可以用原子 handle 把首次 close 交换为零,后续 close 成为无操作或明确错误;Native destroy 仍应假设调用方遵守契约,不能读取已释放对象判断是否重复。调试构建可以启用内存工具或所有者登记,生产版本则以最小开销保留错误码和 build ID。本文不提供特定工具检测率或性能数字。

  • 部分构造失败只由生产库清理内部成员
  • 成功状态与非空 handle 同时转移关闭责任
  • 异步操作定义 close、cancel 和等待顺序
  • 回调重入和多线程 close 使用明确状态机
  • 上层包装通过原子清零避免重复调用 destroy
  • 调试登记不依赖读取已释放内存判断状态

用版本化句柄包装器验证 create 与 destroy

下面的 Java 示例展示 JNI 侧公开契约的调用方包装。Native 生产库提供带 ABI 版本的 create、inspect 和 destroy,Java 只保存 long 句柄并通过 AutoCloseable 归还,绝不调用另一套释放函数。代码用原子变量保证首次 close 取走句柄,所有操作先检查未关闭状态;主函数从直接输入读取版本与容量,构造失败、版本错误、重复使用和关闭后访问都有可达失败路径。

对应 Native 实现应通过 C ABI 导出固定宽度参数与不透明 handle,在 create 内捕获 C++ 异常,在 destroy 内使用原分配器执行析构。Java 声明本身不能证明 Native 遵守这些规则,因此发布测试还要用受控故障注入检查部分构造、错误版本和异步取消。示例不包含真实业务对象、地址日志、凭据或可利用的内存操作。

wrapper 的 ABI_VERSION 与 Native 支持版本必须由发布流程生成或校验,不能由调用者随意修改。生产代码通常不需要 main,它仅为公开示例提供独立契约测试入口。若跨 SO 的 Native 调用方不经过 Java,也应复用同一 create/destroy/version 协议,而不是直接 delete 不透明指针。

用版本化句柄确保对象由生产库销毁
import java.util.concurrent.atomic.AtomicLong;

public final class NativeOwnedBuffer implements AutoCloseable {
  private static final int ABI_VERSION = 1;
  private final AtomicLong handle;

  private static native long nativeCreate(int abiVersion, int capacity);
  private static native int nativeInspect(long handle, int abiVersion);
  private static native void nativeDestroy(long handle, int abiVersion);

  public NativeOwnedBuffer(int capacity) {
    if (capacity <= 0) {
      throw new IllegalArgumentException("capacity must be positive");
    }
    long created = nativeCreate(ABI_VERSION, capacity);
    if (created == 0L) {
      throw new IllegalStateException("native create failed");
    }
    this.handle = new AtomicLong(created);
  }

  public int inspect() {
    long current = handle.get();
    if (current == 0L) {
      throw new IllegalStateException("buffer is already closed");
    }
    int status = nativeInspect(current, ABI_VERSION);
    if (status < 0) {
      throw new IllegalStateException("native inspect rejected the ABI contract");
    }
    return status;
  }

  @Override
  public void close() {
    long owned = handle.getAndSet(0L);
    if (owned != 0L) {
      nativeDestroy(owned, ABI_VERSION);
    }
  }

  public static void main(String[] args) {
    if (args.length != 2) {
      throw new IllegalArgumentException("usage: abi-version capacity");
    }
    if (Integer.parseInt(args[0]) != ABI_VERSION) {
      throw new IllegalArgumentException("unsupported ABI version input");
    }
    int capacity = Integer.parseInt(args[1]);
    try (NativeOwnedBuffer buffer = new NativeOwnedBuffer(capacity)) {
      buffer.inspect();
    }
  }
}

在每个 ABI 上验证错误路径和升级组合

依赖真实 Android 运行时、ABI、动态链接和线程语义的所有权问题应通过设备端 instrumented test 验证。矩阵至少覆盖每个交付 ABI、最低支持系统、正常 create/destroy、create 失败、重复 close、关闭后访问、异步操作中取消、回调后销毁、库版本升级和进程退出。测试必须绑定生产库与调用库的精确 build ID 和摘要。

错误释放崩溃需要符号化分配与释放两侧的堆栈,并记录 handle 创建版本、所有者、操作状态和线程。单一 allocator 帧不能直接证明根因,内存可能更早被越界写破坏。对照实验应使用同一业务输入,只改变库版本、运行库组合或所有权路径;修复后重跑原失败、压力和异常用例。单一设备通过不能代表全部 ABI 和厂商矩阵。

完整发布材料包括 C ABI 头文件、版本与 size 规则、create/destroy 映射、运行库清单、toolchain、最终 ELF 依赖、调用图、设备矩阵和失败回执。需要评估实际 SO 时,可通过御盾中央平台提交脱敏的接口声明、分配释放堆栈、build ID、目标 ABI 和最终候选摘要,先确定所有者,再检查运行库与异常边界。

  • 每个交付 ABI 都验证 create、destroy 和结构布局
  • 创建失败、重复关闭和关闭后访问具有明确结果
  • 异步取消、回调与多线程 close 进入设备回归
  • 调用库和生产库 build ID 与摘要完整绑定
  • 崩溃分析同时保留分配与释放侧符号化堆栈
  • 修复回归覆盖版本升级、异常和压力路径

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
C++ 异常、RTTI 与 libc++ 链接方式取决于构建系统和运行库选择。Android C++ library support 说明 NDK C++ 运行库、异常和 RTTI 支持。启用编译选项不能证明跨 SO 的 type_info、unwind 和 allocator 契约完整。
NDK 兼容故障常见于 API level、缺失符号、STL、异常类型和错误依赖装载。Android NDK common problems 汇总常见 Native 构建与运行问题。故障清单不能替代目标 ABI 的实际启动、所有权和异常回归。
每个 Android ABI 有独立调用约定、寄存器、对齐和设备支持范围。Android ABIs 说明各 ABI 的体系结构与调用约定。声明支持 ABI 不等于对应产物已构建、打包并通过所有权测试。
CMake toolchain 用于固定 ABI、平台版本和第三方库导入方式。Android NDK CMake 说明 Android CMake toolchain 和库导入配置。CMake 配置不覆盖预编译库内部运行库、异常和分配器选项。
非标准构建必须显式固定 NDK toolchain、API level、ABI 与编译目标。NDK with other build systems 说明非 CMake 构建的工具链与目标配置。构建参数正确不能证明第三方预编译库与目标运行时兼容。
依赖真实 Android 运行时、组件和系统 API 的行为应通过设备端测试验证。Android instrumented tests 说明 instrumented test 适用于真实 Android 环境。单一设备通过不能代表完整 API、ABI 和厂商矩阵。
跨 SO 对象应由生产库导出的 destroy 使用原分配器销毁。工程判断:让实现所有者收口析构与释放,可以隔离函数族、自定义池和版本变化。该契约不能防止越界写、错误借用寿命或调用方绕过接口直接释放。
版本化不透明句柄比公开 C++ STL 对象更适合长期共享库 ABI。工程判断:隐藏布局、allocator、RTTI 和异常语义可以缩小工具链耦合面。C ABI 仍需固定尺寸、对齐、线程、错误码和升级规则,并经过逐 ABI 验证。

工程常见问题

在一个 SO 中 malloc、另一个 SO 中 free 一定会崩溃吗?

不一定,但前提是同一兼容分配器和正确函数族。接口难以长期保证该假设,生产库导出 destroy 更稳妥。

为什么 new[] 不能用 delete 释放?

数组分配与单对象分配的析构和元数据契约不同,必须使用匹配的 delete[],跨库时最好由创建库内部处理。

全部库都使用 libc++_shared 就不需要所有权协议吗?

仍然需要。运行库一致不能防止 new/free 混用、自定义池、重复释放、异步寿命和版本布局错误。

可以直接跨 SO 返回 std::string 或 std::vector 吗?

这会暴露 STL 布局、allocator 和运行库 ABI。长期接口优先用字节视图、复制函数或不透明句柄。

destroy 设计成可重复调用就能解决 double free 吗?

对已释放裸地址再次读取通常也不安全。上层应原子清零句柄,只让首次 close 调用 destroy。

准备跨 SO 内存所有权排查需要哪些材料?

准备 C ABI、create/destroy 实现、分配与释放堆栈、运行库清单、build ID、结构布局、线程状态、目标 ABI 和设备回执。

想用自己的 App 验证?

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

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