4. C指针与内存模型映射

在 Kotlin/Native 与 C 互操作中,指针不是“可选细节”,而是决定程序能否正确读写原生内存的核心。Kotlin 侧并不直接暴露 C 的 T*,而是用一组类型来映射:CPointer<T> 表示“指向已知类型 T 的指针”,COpaquePointer 表示“指向未知或不完整类型的指针”,CValuesRef<T> 表示“可接受数组或指针的引用参数”。理解这三者的语义差异,以及它们与 Kotlin/Native 内存管理器、原生分配器之间的关系,是写出安全互操作代码的前提。

一、三种指针类型的语义与选择

CPointer<T> 是带类型的指针。当 C 函数签名是 int32_t* 时,Kotlin 侧对应 CPointer<IntVar>;当签名是 struct Point* 时,对应 CPointer<Point>。它支持 pointed 解引用、plus(index) 指针偏移,以及 readValues/writeValues 批量读写。类型信息让编译器能计算步长,例如 CPointer<IntVar> 每次偏移 4 字节,而 CPointer<Point> 偏移 sizeof(Point)。

COpaquePointer 是不透明指针,等价于 C 的 void*。它不能直接解引用,必须先 reinterpret() 成具体类型的 CPointer。典型场景是 C 库返回 void* 句柄,例如 sqlite3*、FILE* 或自定义上下文对象。使用不透明指针时,类型安全由开发者自己保证,转换错误不会在编译期报错,只会在运行时表现为数据错乱或崩溃。

CValuesRef<T> 用于 C 函数参数声明为 const T* 或 T[] 的位置。它允许传入 CPointer<T>,也允许传入 Kotlin 数组,由互操作层自动完成“数组到原生内存”的临时转换。例如 C 函数 void sum(const int32_t* arr, int n),Kotlin 侧参数类型是 CValuesRef<IntVar>,可以直接传 intArrayOf(1,2,3)。这种转换是临时的,仅在调用期间有效,函数返回后底层内存可能被回收。

Kotlin/Native 指针类型与原生内存交互模型 Kotlin 侧类型 CPointer<T> 已知类型 · 可解引用/偏移 COpaquePointer void* · 需 reinterpret CValuesRef<T> 数组/指针 · 临时转换 C 侧类型 T* int32_t* / struct* void* 不透明句柄 const T* / T[] 只读数组参数 直接映射 reinterpret 转换 自动临时转换 Kotlin/Native 内存管理器 nativeHeap 分配器 allocate / free 自动内存管理 GC 追踪 Kotlin 对象 CValuesRef 临时内存 调用期间有效 手动释放 free / dispose 关键规则 • CPointer 解引用前必须确保内存有效;COpaquePointer 转换错误不会编译报错 • CValuesRef 传入的数组仅在调用期间有效,不可保存指针供后续使用 • nativeHeap.alloc 分配的内存必须手动 free,否则造成原生内存泄漏

二、原生内存分配与释放

Kotlin/Native 提供 kotlinx.cinterop.nativeHeap 作为原生堆分配器。分配使用 nativeHeap.alloc<T>(),释放使用 nativeHeap.free(ptr)。分配返回的是 CPointer<T>,可以直接读写。例如分配一个 IntVar:

val p: CPointer<IntVar> = nativeHeap.alloc<IntVar>()
p.value = 42
println(p.pointed.value)  // 42
nativeHeap.free(p)

对于数组,使用 allocArray<T>(size),它返回 CPointer<T>,可通过 ptr[0]、ptr[1] 访问元素。释放时同样调用 nativeHeap.free(ptr)。需要特别注意的是:nativeHeap 分配的内存不受 Kotlin GC 管理,即使 CPointer 变量离开作用域,内存也不会自动回收。忘记 free 就是原生内存泄漏,长时间运行会耗尽进程地址空间。

另一种常见方式是使用 memScoped { }。在 memScoped 块内,alloc<T>() 和 allocArray<T>(n) 分配的内存会在块结束时自动释放。这适合生命周期短、作用域明确的临时缓冲区。例如把 Kotlin 字符串转成 C 字符串并传给 C 函数:

memScoped {
    val cStr = "hello".cstr.ptr
    cFunc(cStr)
}  // cStr 内存在此自动释放

如果 C 函数需要长期持有指针(例如注册回调上下文),就不能用 memScoped,必须用 nativeHeap.alloc 并自行管理释放时机,否则块结束后指针悬空,后续访问是未定义行为。

三、与 Kotlin/Native 内存管理器的交互

Kotlin/Native 的内存管理器负责 Kotlin 对象的分配与回收,但它不追踪通过 nativeHeap 或 C malloc 分配的原生内存。两者是并行的:Kotlin 对象由 GC 管理,原生内存由开发者手动管理。当把 Kotlin 对象地址传给 C 时,必须防止对象被 GC 提前回收。常见做法是使用 StableRef 或 staticCFunction 配合全局引用,确保回调期间对象存活。

此外,CValuesRef 的临时转换内存由互操作层在调用期间分配,调用返回后即失效。如果 C 函数把该指针保存到全局变量,后续再使用就是悬空指针。正确做法是:若 C 侧需要长期保存,先在 Kotlin 侧用 nativeHeap.allocArray 分配持久内存,把数据拷贝进去,再把该指针传给 C,并在确认 C 不再使用后手动 free。

易错点:不要把 memScoped 内分配的指针返回或保存到块外;不要把 CValuesRef 自动转换的指针缓存到 C 全局变量;不要对同一块 nativeHeap 内存重复 free,否则会造成双重释放崩溃。

四、实践判断清单

场景推荐方式释放责任
临时缓冲区,作用域内使用memScoped { alloc }块结束自动释放
需要跨函数/跨回调存活nativeHeap.alloc手动 free
只读数组参数传给 CCValuesRef 直接传数组调用期间有效,无需手动释放
C 返回 void* 句柄COpaquePointer + reinterpret按 C 库约定释放
Kotlin 对象地址传给 C 回调StableRef 保持存活回调结束后 dispose

掌握这些映射关系后,实际编码时可以按“先判断生命周期,再选分配方式,最后确认释放路径”的顺序推进。指针类型的选择不是风格问题,而是正确性问题:CPointer 用于已知类型,COpaquePointer 用于不透明句柄,CValuesRef 用于只读数组参数;原生内存要么交给 memScoped 自动管理,要么用 nativeHeap 手动管理并确保每条分配路径都有对应的释放路径。