先看结论与判断条件
- 跨 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/calloc | free | 包装器或自定义替换不可见 | 生产库导出 destroy |
| new T | delete | 析构与运行库语义暴露 | 不透明句柄 |
| 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 与版本字段可控。
| 接口类型 | 暴露内容 | 主要风险 | 优先方案 |
|---|---|---|---|
| 裸 void* | 地址但无所有权 | 释放函数和大小未知 | 不透明句柄加 destroy |
| C 结构体 | 固定字段布局 | size、对齐和版本漂移 | version 与 struct_size |
| std::string/vector | STL 实例和 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。把地址、容量、长度、对齐和所有者拆开传递时,任何一个字段漂移都会形成越界或错误释放,优先用不透明对象封装。
| 字段 | 用途 | 拒绝条件 | 验证 |
|---|---|---|---|
| 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 和设备回执。