7.3.3.1 与 C 互操作

原文链接: https://kotlinlang.org/docs/native-c-interop.html

7.3.3.1 与 C 互操作

注意: C 库导入处于 Beta 阶段。由 cinterop 工具从 C 库生成的所有 Kotlin 声明都应带有 @ExperimentalForeignApi 注解。随 Kotlin/Native 提供的原生平台库(例如 Foundation、UIKit 和 POSIX)只对部分 API 要求选择启用。

本文介绍 Kotlin 与 C 互操作的总体情况。Kotlin/Native 附带了 cinterop 工具,你可以用它快速生成与外部 C 库交互所需的一切。

该工具会分析 C 头文件,并把 C 类型、函数和字符串直接映射为 Kotlin。生成的桩随后可以导入 IDE,以启用代码补全和导航。

提示: Kotlin 还提供与 Objective-C 的互操作。Objective-C 库同样通过 cinterop 工具导入。更多细节请参阅 Swift/Objective-C 互操作。

设置你的项目

下面是使用需要消费 C 库的项目时的一般工作流:

  1. 创建并配置一个定义文件。它描述 cinterop 工具应把哪些内容包含到 Kotlin 绑定中。
  2. 配置你的 Gradle 构建文件,把 cinterop 纳入构建过程。
  3. 编译并运行项目,以生成最终可执行文件。

注意: 想获得动手实践体验,请完成使用 C 互操作创建应用教程。

在许多情况下,无需为 C 库配置自定义互操作。你可以改用平台上可用的、称为平台库的标准化绑定 API。例如,Linux/macOS 平台上的 POSIX、Windows 平台上的 Win32,或 macOS/iOS 上的 Apple 框架都可以这样使用。

绑定

基本互操作类型

所有受支持的 C 类型在 Kotlin 中都有对应的表示:

  • 有符号、无符号整数类型和浮点类型会映射到宽度相同的 Kotlin 对应类型。
  • 指针和数组会映射为 CPointer<T>?。
  • 枚举可以映射为 Kotlin 枚举或整数值,取决于启发式规则和定义文件设置。
  • 结构体和联合体会映射为可通过点号表示法访问字段的类型,即 someStructInstance.field1。
  • typedef 表示为 typealias。

此外,任何 C 类型都有表示该类型左值(lvalue)的 Kotlin 类型,即位于内存中的值,而不是简单的不可变自包含值。可以把它类比为 C++ 的引用。对于结构体(以及指向结构体的 typedef),这种表示是主要形式,并且与结构体本身同名。对于 Kotlin 枚举,其名称是 ${type}.Var;对于 CPointer<T>,是 CPointerVar<T>;对于大多数其他类型,是 ${type}Var。

对于同时具有两种表示的类型,左值形式带有一个可变的 .value 属性用于访问该值。

指针类型

CPointer<T> 的类型实参 T 必须是上述左值类型之一。例如,C 类型 struct S* 映射为 CPointer<S>,int8_t* 映射为 CPointer<int_8tVar>,char** 映射为 CPointer<CPointerVar<ByteVar>>。

C 的空指针表示为 Kotlin 的 null,指针类型 CPointer<T> 不可为空,而 CPointer<T>? 可以为空。该类型的值支持 Kotlin 中所有与处理 null 相关的操作,例如 ?:、?.、!! 等等:

1
val path = getenv("PATH")?.toKString() ?: ""

由于数组也映射为 CPointer<T>,它支持 [] 运算符来按索引访问值:

1
2
3
4
5
6
7
8
import kotlinx.cinterop.*

@OptIn(ExperimentalForeignApi::class)
fun shift(ptr: CPointer<ByteVar>, length: Int) {
    for (index in 0 .. length - 2) {
        ptr[index] = ptr[index + 1]
    }
}

CPointer<T> 的 .pointed 属性返回该指针所指向的、类型为 T 的左值。反向操作是 .ptr,它接收左值并返回指向它的指针。

void* 映射为 COpaquePointer —— 一种特殊的指针类型,它是所有其他指针类型的父类型。因此如果 C 函数接受 void*,Kotlin 绑定可以接受任何 CPointer。

指针(包括 COpaquePointer)的转换可以用 .reinterpret<T> 完成,例如:

1
2
3
4
import kotlinx.cinterop.*

@OptIn(ExperimentalForeignApi::class)
val intPtr = bytePtr.reinterpret<IntVar>()

或者:

1
2
3
4
import kotlinx.cinterop.*

@OptIn(ExperimentalForeignApi::class)
val intPtr: CPointer<IntVar> = bytePtr.reinterpret()

与 C 中一样,这些 .reinterpret 转换是不安全的,可能导致应用中难以察觉的内存问题。

此外,还提供了 CPointer<T>? 与 Long 之间的不安全转换,由 .toLong() 和 .toCPointer<T>() 扩展方法提供:

1
2
val longValue = ptr.toLong()
val originalPtr = longValue.toCPointer<T>()

提示: 如果结果的类型可以从上下文中得知,得益于类型推断,你可以省略类型实参。

内存分配

原生内存可以使用 NativePlacement 接口分配,例如:

1
2
3
4
5
6
@file:OptIn(ExperimentalForeignApi::class)
import kotlinx.cinterop.*

val placement: NativePlacement = // 关于 placement 的示例见下文
val byteVar = placement.alloc<ByteVar>()
val bytePtr = placement.allocArray<ByteVar>(5)

最合乎逻辑的 placement 是对象 nativeHeap。它对应于用 malloc 分配原生内存,并提供一个额外的 .free() 操作用于释放已分配的内存:

1
2
3
4
5
6
7
8
@file:OptIn(ExperimentalForeignApi::class)
import kotlinx.cinterop.*

fun main() {
    val size: Long = 0
    val buffer = nativeHeap.allocArray<ByteVar>(size)
    nativeHeap.free(buffer)
}

nativeHeap 要求手动释放内存。不过,把内存的生命周期绑定到词法作用域通常很有用。如果这类内存能被自动释放会很有帮助。

为解决这个问题,你可以使用 memScoped { }。在花括号内部,临时 placement 作为隐式接收者可用,因此可以用 alloc 和 allocArray 分配原生内存,并且分配的内存在离开作用域后会被自动释放。

例如,一个通过指针参数返回值的 C 函数可以这样使用:

1
2
3
4
5
6
7
8
9
@file:OptIn(ExperimentalForeignApi::class)
import kotlinx.cinterop.*
import platform.posix.*

val fileSize = memScoped {
    val statBuf = alloc<stat>()
    val error = stat("/", statBuf.ptr)
    statBuf.st_size
}

向绑定传递指针

虽然 C 指针映射为 CPointer<T> 类型,但 C 函数中指针类型的参数映射为 CValuesRef<T>。当把 CPointer<T> 作为这类参数的值传入时,它会按原样传给 C 函数。不过,也可以传值序列来代替指针。在这种情况下,该序列会“按值”传递,即 C 函数收到的是指向该序列临时副本的指针,该副本只在函数返回前有效。

指针参数采用 CValuesRef<T> 表示,是为了支持无需显式分配原生内存的 C 数组字面量。为构造不可变的自包含 C 值序列,提供了以下方法:

  • ${type}Array.toCValues(),其中 type 是 Kotlin 原始类型
  • Array<CPointer<T>?>.toCValues()、List<CPointer<T>?>.toCValues()
  • cValuesOf(vararg elements: ${type}),其中 type 是原始类型或指针

例如:

1
2
3
4
5
// C:
void foo(int* elements, int count);
...
int elements[] = {1, 2, 3};
foo(elements, 3);
1
2
3
// Kotlin:

foo(cValuesOf(1, 2, 3), 3)

字符串

与其他指针不同,const char* 类型的参数表示为 Kotlin String。因此可以把任何 Kotlin 字符串传给期望 C 字符串的绑定。

还有一些工具可用于在 Kotlin 与 C 字符串之间手动转换:

  • fun CPointer<ByteVar>.toKString(): String
  • val String.cstr: CValuesRef<ByteVar>。

要获取指针,.cstr 需要在原生内存中分配,例如:

1
val cString = kotlinString.cstr.getPointer(nativeHeap)

在所有情况下,C 字符串都应编码为 UTF-8。

要跳过自动转换并确保绑定中使用原始指针,请把 noStringConversion 属性添加到 .def 文件中:

1
noStringConversion = LoadCursorA LoadCursorW

这样,任何 CPointer<ByteVar> 类型的值都可以作为 const char* 类型的实参传入。如果需要传入 Kotlin 字符串,可以使用这样的代码:

1
2
3
4
5
6
7
import kotlinx.cinterop.*

@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
memScoped {
    LoadCursorA(null, "cursor.bmp".cstr.ptr) // 用于 ASCII 或 UTF-8 版本
    LoadCursorW(null, "cursor.bmp".wcstr.ptr) // 用于 UTF-16 版本
}

作用域局部指针

可以使用 CValues<T>.ptr 扩展属性(在 memScoped {} 下可用)为 CValues<T> 实例创建作用域稳定的 C 表示指针。它让你可以使用那些要求 C 指针、且生命周期限定于某个 MemScope 的 API。例如:

1
2
3
4
5
6
7
8
9
import kotlinx.cinterop.*

@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
memScoped {
    items = arrayOfNulls<CPointer<ITEM>?>(6)
    arrayOf("one", "two").forEachIndexed { index, value -> items[index] = value.cstr.ptr }
    menu = new_menu("Menu".cstr.ptr, items.toCValues().ptr)
    // ...
}

在这个例子中,传给 C API new_menu() 的所有值的生命周期都属于它所在的最内层 memScope。一旦控制流离开 memScoped 作用域,C 指针就会失效。

按值传递和接收结构体

当 C 函数按值接收或返回结构体/联合体 T 时,相应的参数类型或返回类型表示为 CValue<T>。

CValue<T> 是不透明类型,因此无法用相应的 Kotlin 属性访问结构体字段。如果某个 API 把结构体当作不透明句柄使用,这样是可以的。不过,如果需要访问字段,可以使用以下转换方法:

  • fun T.readValue(): CValue<T> 把(左值)T 转换为 CValue<T>。因此要构造 CValue<T>,可以先分配、填充 T,再把它转换为 CValue<T>。
  • CValue<T>.useContents(block: T.() -> R): R 会临时把 CValue<T> 存入内存,然后以这个放置好的值 T 作为接收者运行传入的 lambda。因此要读取单个字段,可以使用以下代码:
1
  val fieldValue = structValue.useContents { field }

回调

要把 Kotlin 函数转换为指向 C 函数的指针,你可以使用 staticCFunction(::kotlinFunction)。也可以用 lambda 代替函数引用。该函数或 lambda 不能捕获任何值。

向回调传递用户数据

很多 C API 允许向回调传递一些用户数据。这类数据通常由用户在配置回调时提供,并以 void* 的形式传给某个 C 函数(或写入结构体)。然而,对 Kotlin 对象的引用不能直接传给 C。因此它们需要在配置回调前进行包装,并在回调内部解包,才能安全地从 Kotlin 经由 C 世界回到 Kotlin。这种包装可以用 StableRef 类完成。

要包装引用:

1
2
3
4
5
import kotlinx.cinterop.*

@OptIn(ExperimentalForeignApi::class)
val stableRef = StableRef.create(kotlinReference)
val voidPtr = stableRef.asCPointer()

这里 voidPtr 是一个 COpaquePointer,可以传给 C 函数。

要解包引用:

1
2
3
@OptIn(ExperimentalForeignApi::class)
val stableRef = voidPtr.asStableRef<KotlinClass>()
val kotlinReference = stableRef.get()

这里 kotlinReference 就是原先被包装的引用。

创建出来的 StableRef 最终需要手动调用 .dispose() 方法释放,以防止内存泄漏:

1
stableRef.dispose()

之后它就失效了,因此 voidPtr 不能再被解包。

宏

每个展开为常量的 C 宏都会表示为 Kotlin 属性。

当编译器能够推断类型时,支持不带参数的宏:

1
2
int foo(int);
#define FOO foo(42)

在这种情况下,FOO 在 Kotlin 中可用。

要支持其他宏,你可以通过用受支持的声明包装它们来手动暴露。例如,函数式宏 FOO 可以通过向库添加自定义声明而暴露为函数 foo():

1
2
3
4
5
6
7
headers = library/base.h

---

static inline int foo(int arg) {
    return FOO(arg);
}

可移植性

有时 C 库会有平台相关类型的函数参数或结构体字段,例如 long 或 size_t。Kotlin 本身既不提供隐式整数转换,也不提供 C 风格的整数转换(例如 (size_t) intValue),因此为了让这类情况下编写可移植代码更容易,提供了 convert 方法:

1
fun ${type1}.convert<${type2}>(): ${type2}

这里 type1 和 type2 都必须是有符号或无符号的整数类型。

.convert<${type}> 的语义与 .toByte、.toShort、.toInt、.toLong、.toUByte、.toUShort、.toUInt 或 .toULong 之一相同,具体取决于 type。

使用 convert 的示例:

1
2
3
4
5
6
7
import kotlinx.cinterop.*
import platform.posix.*

@OptIn(ExperimentalForeignApi::class)
fun zeroMemory(buffer: COpaquePointer, size: Int) {
    memset(buffer, 0, size.convert<size_t>())
}

此外,类型参数可以自动推断,因此在某些情况下可以省略。

对象固定

Kotlin 对象可以被固定(pin),即在取消固定之前它们在内存中的位置保证稳定,并且可以指向这些对象内部数据的指针传给 C 函数。

你可以采用两种方式:

  • 使用 .usePinned() 扩展函数,它固定一个对象、执行一个代码块,并在正常路径和异常路径上都取消固定:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  import kotlinx.cinterop.*
  import platform.posix.*

  @OptIn(ExperimentalForeignApi::class)
  fun readData(fd: Int) {
      val buffer = ByteArray(1024)
      buffer.usePinned { pinned ->
          while (true) {
              val length = recv(fd, pinned.addressOf(0), buffer.size.convert(), 0).toInt()
              if (length <= 0) {
                  break
              }
              // 现在 `buffer` 中包含从 `recv()` 调用得到的原始数据。
          }
      }
  }

这里 pinned 是一个特殊类型 Pinned<T> 的对象。它提供了一些有用的扩展,例如 .addressOf(),可用来获取已固定数组主体的地址。

  • 使用 .refTo() 扩展函数,它在底层具有类似的功能,但在某些情况下可能帮助你减少样板代码:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  import kotlinx.cinterop.*
  import platform.posix.*

  @OptIn(ExperimentalForeignApi::class)
  fun readData(fd: Int) {
      val buffer = ByteArray(1024)
      while (true) {
          val length = recv(fd, buffer.refTo(0), buffer.size.convert(), 0).toInt()

          if (length <= 0) {
              break
          }
          // 现在 `buffer` 中包含从 `recv()` 调用得到的原始数据。
      }
  }

这里 buffer.refTo(0) 的类型是 CValuesRef,它会在进入 recv() 函数之前固定该数组、把其第零个元素的地址传给该函数,并在退出后取消固定。

前向声明

要导入前向声明,请使用 cnames 包。例如,要导入在包为 library.package 的 C 库中声明的 cstructName 前向声明,请使用特殊的前向声明包:import cnames.structs.cstructName。

考虑两个 cinterop 库:一个包含结构体的前向声明,另一个在另一个包中包含实际实现:

1
2
3
4
5
6
7
8
// 第一个 C 库
#include <stdio.h>

struct ForwardDeclaredStruct;

void consumeStruct(struct ForwardDeclaredStruct* s) {
    printf("Struct consumed\n");
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 第二个 C 库
// 头文件:
#include <stdlib.h>

struct ForwardDeclaredStruct {
    int data;
};

// 实现:
struct ForwardDeclaredStruct* produceStruct() {
    struct ForwardDeclaredStruct* s = malloc(sizeof(struct ForwardDeclaredStruct));
    s->data = 42;
    return s;
}

要在两个库之间传递对象,请在 Kotlin 代码中使用显式的 as 转换:

1
2
3
4
// Kotlin 代码:
fun test() {
    consumeStruct(produceStruct() as CPointer<cnames.structs.ForwardDeclaredStruct>)
}

接下来做什么

通过完成以下教程,了解类型、函数和字符串如何在 Kotlin 与 C 之间映射:

第一步 从 C 映射原始数据类型 第二步 从 C 映射结构体和联合体类型 第三步 从 C 映射函数指针 第四步 从 C 映射字符串

开始