Edit Page

与 C 代码交互

最终更新: 2024/03/21

C 库的导入是 实验性功能. cinterop 工具从 C 库生成的所有 Kotlin 声明都应该标注 @ExperimentalForeignApi 注解.

Kotlin/Native 自带的原生平台库 (例如 Foundation, UIKit, 和 POSIX), 只对一部分 API 需要使用者明确同意(Opt-in). 对于这样的情况, 你会在 IDE 中看到警告信息.

Kotlin/Native 遵循 Kotlin 的传统, 提供与既有的平台软件的优秀的互操作性. 对于原生程序来说, 最重要的互操作性对象就是与 C 语言库. 因此 Kotlin/Native 附带了 cinterop 工具, 可以用来快速生成与既有的外部库交互时所需要的一切.

与原生库交互时的工作流程如下:

  1. 创建一个 .def 文件, 描述需要绑定(binding)的内容.
  2. 使用 cinterop 工具生成绑定.
  3. 运行 Kotlin/Native 编译器, 编译应用程序, 产生最终的可执行文件.

互操作性工具会分析 C 语言头文件, 并产生一个 "自然的" 映射, 将数据类型, 函数, 常数, 引入到 Kotlin 语言的世界. 工具生成的桩代码(stub)可以导入 IDE, 用来帮助代码自动生成, 以及代码跳转.

此外还提供了与 Swift/Objective-C 语言的互操作功能, 详情请参见 与 Swift/Objective-C 的交互.

平台库

注意, 很多情况下不要用到自定义的互操作库创建机制(我们后文将会介绍), 因为对于平台上的标准绑定中的那些 API, 可以使用 平台库. 比如, Linux/macOS 平台上的 POSIX, Windows 平台上的 Win32, macOS/iOS 平台上的以及 Apple 框架, 都可以通过这种方式来使用.

一个简单的示例

首先我们安装 libgit2, 并为 git 库准备桩代码:


cd samples/gitchurn
../../dist/bin/cinterop -def src/nativeInterop/cinterop/libgit2.def \
 -compiler-option -I/usr/local/include -o libgit2

编译客户端代码:

../../dist/bin/kotlinc src/gitChurnMain/kotlin \
 -library libgit2 -o GitChurn

运行客户端代码:

./GitChurn.kexe ../..

为一个新库创建绑定

要对一个新的库创建绑定, 首先要创建一个 .def 文件. 它的结构只是一个简单的 property 文件, 大致是这个样子:

headers = png.h
headerFilter = png.h
package = png

然后运行 cinterop 文件, 参数大致如下 (注意, 对于主机上没有被包含到 sysroot 查找路径的那些库, 可能需要指定头文件):

cinterop -def png.def -compiler-option -I/usr/local/include -o png

这个命令将会生成一个编译后的库, 名为 png.klib, 以及 png-build/kotlin 目录, 其中包含这个库的 Kotlin 源代码.

如果需要修改针对某个平台的参数, 你可以使用 compilerOpts.osxcompilerOpts.linux 这样的格式, 来指定这个平台专用的命令行选项.

注意, 生成的绑定通常是平台专有的, 因此如果你需要针对多个平台进行开发, 那么需要重新生成这些绑定.

生成绑定后, IDE 可以使用其中的信息来查看原生库.

对于一个典型的带配置脚本的 Unix 库, 使用 --cflags 参数运行配置脚本的输出结果, 通常可以用做 compilerOpts, (但可能不使用完全相同的路径).

使用 --libs 参数运行配置脚本的输出结果, 编译时可以用作 kotlinc-linkedArgs 参数值(带引号括起).

选择库的头文件

使用 #include 指令将库的头文件导入 C 程序时, 这些头文件包含的所有其他头文件也会一起被导入. 因此, 在生成的 stub 代码内, 也会带有所有依赖到的其他头文件.

这种方式通常是正确的, 但对于某些类来说可能非常不方便. 因此可以在 .def 文件内指定需要导入哪些头文件. 如果直接依赖某个头文件的话, 也可以对它单独声明.

使用 glob 过滤头文件

也可以使用 .def 文件内的过滤属性作为 glob 来过滤头文件. 这些属性值会被看作一个空格分隔的 glob 列表.

  • 要包含头文件中的声明, 请使用 headerFilter 属性. 如果包含的头文件与任何一个 glob 匹配, 那么头文件的声明就会被包含在绑定内容中.

    glob 应用于相对于恰当的包含路径元素的头文件路径, 例如, time.hcurl/curl.h. 因此, 如果通常使用 #include <SomeLibrary/Header.h> 指令来包含某个库, 那么应该使用下面的过滤设置来过滤头文件:

    headerFilter = SomeLibrary/**
    

    如果没有指定 headerFilter, 那么会包含所有的头文件. 但是, 我们鼓励使用 headerFilter, 并尽量精确的指定 glob. 这种情况下, 生成的库只包含必须的声明. 在你的开发环境中升级 Kotlin 或工具时, 可以避免很多问题的发生.

  • 要排除某个头文件, 请使用 excludeFilter 属性.

    这样可以删除多余的或有问题的头文件, 并优化编译过程, 因为指定的头文件中的声明不会被包含在绑定内容中.

    excludeFilter = SomeLibrary/time.h
    

如果同一个头文件由 headerFilter 指定为包含, 同时又由 excludeFilter 指定为排除, 那么后一个设定的优先级更高. 指定的头文件不会被包含在绑定内容中.

使用模块映射过滤头文件

有些库在它的头文件中带有 module.modulemapmodule.map 文件. 比如, macOS 和 iOS 系统库和框架就是这样. 模块映射文件(module map file) 描述头文件与模块之间的对应关系. 如果存在模块映射, 那么可以使用 .def 文件的实验性的 excludeDependentModules 选项, 将模块中没有直接使用的头文件过滤掉:

headers = OpenGL/gl.h OpenGL/glu.h GLUT/glut.h
compilerOpts = -framework OpenGL -framework GLUT
excludeDependentModules = true

如果同时使用 excludeDependentModulesheaderFilter, 那么最终起作用的将是二者的交集.

C 编译器与链接器选项

可以在定义文件中使用 compilerOptslinkerOpts 来分别指定 传递给 C 编译器 (用于分析头文件, 比如预处理定义信息) 和链接器 (用于链接最终的可执行代码) 的参数. 比如:

compilerOpts = -DFOO=bar
linkerOpts = -lpng

也可以指定某个目标平台独有的参数, 比如:

 compilerOpts = -DBAR=bar
 compilerOpts.linux_x64 = -DFOO=foo1
 compilerOpts.macos_x64 = -DFOO=foo2

通过这样的配置, C 头文件在 Linux 上的会使用 -DBAR=bar -DFOO=foo1 参数进行分析, macOS 上则会使用 -DBAR=bar -DFOO=foo2 参数进行分析. 注意, 定义文件的任何参数, 都可以包含共用的, 以及平台独有的两部分.

链接器错误

当一个 Kotlin 库依赖于一个 C 或 Objective-C 库时, 可能会发生链接器错误, 例如, 使用 CocoaPods 集成 时. 如果依赖的库在当前机器上没有安装, 在项目的构建脚本中也没有明确的配置, 那么就会发生 "Framework not found" 错误.

如果你是库的作者, 你可以通过自定义消息来帮助你的用户解决链接器错误. 方法是, 在你的 .def 文件中添加 userSetupHint=message 属性, 或者向 cinterop 传递 -Xuser-setup-hint 编译器选项.

添加自定义声明

在生成绑定之前, 有时会需要向库添加自定义的 C 声明(比如, 对 ). 你可以将它们直接包含在 .def 文件尾部, 放在一个分隔行 --- 之后, 而不需要为他们创建一个额外的头文件:

headers = errno.h

---

static inline int getErrno() {
    return errno;
}

注意, .def 文件的这部分内容会被当做头文件的一部分, 因此, 带函数体的函数应该声明为 static 函数. 这些声明的内容, 会在 headers 列表中的文件被引入之后, 再被解析.

将静态库包含到你的 klib 库中

有些时候, 发布你的程序时附带上所需要的静态库, 而不是假定它在用户的环境中已经存在了, 这样会更便利一些. 如果需要在 .klib 中包含静态库, 可以使用 staticLibrarylibraryPaths 语句. 比如:

headers = foo.h
staticLibraries = libfoo.a
libraryPaths = /opt/local/lib /usr/local/opt/curl/lib

如果指定了以上内容, 那么 cinterop 工具将会在 /opt/local/lib/usr/local/opt/curl/lib 目录中搜索 libfoo.a 文件, 如果找到这个文件, 就会把这个库包含到 klib 内.

使用这样的 klib, 库文件会就被自动链接到你的程序内.

绑定

基本的 interop 数据类型

C 中支持的所有数据类型, 都有对应的 Kotlin 类型:

  • 有符号整数, 无符号整数, 以及浮点类型, 会被映射为 Kotlin 中的同样类型, 并且长度相同.
  • 指针和数组映射为 CPointer<T>? 类型.
  • 枚举型映射为 Kotlin 的枚举型, 或整数型, 由 heuristic 以及 定义文件中的提示 决定.
  • 结构体(Struct)和联合体(Union)映射为通过点号访问的域的形式, 也就是 someStructInstance.field1 的形式.
  • typedef 映射为 typealias.

此外, 任何 C 类型都有对应的 Kotlin 类型来表达这个类型的左值(lvalue), 也就是, 在内存中分配的那个值, 而不是简单的不可变的自包含值. 你可以想想 C++ 的引用, 与这个概念类似. 对于结构体(Struct) (以及指向结构体的 typedef) 左值类型就是它的主要表达形式, 而且使用与结构体本身相同的名字, 对于 Kotlin 枚举类型, 左值类型名称是 ${type}Var, 对于 CPointer<T>, 左值类型名称是 CPointerVar<T>, 对于大多数其他类型, 左值类型名称是 ${type}Var.

对于兼有这两种表达形式的类型, 包含 "左值(lvalue)" 的那个类型, 带有一个可变的 .value 属性, 可以用来访问这个左值.

指针类型

CPointer<T> 的类型参数 T 必须是上面介绍的 "左值(lvalue)" 类型之一, 比如, C 类型 struct S* 会被映射为 CPointer<S>, int8_t* 会被映射为 CPointer<int_8tVar>, char** 会被映射为 CPointer<CPointerVar<ByteVar>>.

C 的空指针(null) 在 Kotlin 中表达为 null, 指针类型 CPointer<T> 是不可为空的, 而 CPointer<T>? 类型则是可为空的. 这种类型的值支持 Kotlin 的所有涉及 null 值处理的操作, 比如 ?:, ?., !! 等等:

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

由于数组也被映射为 CPointer<T>, 因此这个类型也支持 [] 操作, 可以使用下标来访问数组中的值:

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

CPointer<T>.pointed 属性返回这个指针指向的那个位置的类型 T 的左值. 相反的操作是 .ptr: 它接受一个左值, 返回一个指向它的指针.

void* 映射为 COpaquePointer – 这是一个特殊的指针类型, 它是任何其他指针类型的超类. 因此, 如果 C 函数接受 void* 类型参数, 那么绑定的 Kotlin 函数就可以接受任何 CPointer 类型参数.

可以使用 .reinterpret<T> 来对一个指针进行类型变换(包括 COpaquePointer), 例如:

val intPtr = bytePtr.reinterpret<IntVar>()

或者

val intPtr: CPointer<IntVar> = bytePtr.reinterpret()

和 C 一样, 这样的类型变换是不安全的, 可能导致应用程序发生潜在的内存错误.

对于 CPointer<T>?Long 也有不安全的类型变换方法, 由扩展函数 .toLong().toCPointer<T>() 实现:

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

注意, 如果结果类型可以通过上下文确定, 那么类型参数可以省略, 就象 Kotlin 中通常的类型系统一样.

内存分配

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

val byteVar = placement.alloc<ByteVar>()

或者

val bytePtr = placement.allocArray<ByteVar>(5)

内存最 "自然" 的位置就是在 nativeHeap 对象内. 这个操作就相当于使用 malloc 来分配原生内存, 另外还提供了 .free() 操作来释放已分配的内存:

val buffer = nativeHeap.allocArray<ByteVar>(size)
<使用 buffer>
nativeHeap.free(buffer)

然而, 分配的内存的生命周期通常会限定在一个指明的作用范围内. 可以使用 memScoped { ... } 来定义这样的作用范围. 在括号内部, 可以以隐含的接收者的形式访问到一个临时的内存分配位置, 因此可以使用 allocallocArray 来分配原生内存, 离开这个作用范围后, 已分配的这些内存会被自动释放.

比如, 如果一个 C 函数, 使用指针参数返回值, 可以用下面这种方式来使用这个函数:

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

向绑定传递指针

尽管 C 指针被映射为 CPointer<T> 类型, 但 C 函数的指针型参数会被映射为 CValuesRef<T> 类型. 如果向这样的参数传递 CPointer<T> 类型的值, 那么会原样传递给 C 函数. 但是, 也可以直接传递值的序列, 而不是传递指针. 这种情况下, 序列会以值的形式传递(by value), 也就是说, C 函数收到一个指针, 指向这个值序列的一个临时拷贝, 这个临时拷贝只在函数返回之前存在.

CValuesRef<T> 形式表达的指针型参数是为了用来支持 C 数组字面值, 而不必明确地进行内存分配操作. 为了构造一个不可变的自包含的 C 的值的序列, 可以使用下面这些方法:

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

比如:

C 代码:

void foo(int* elements, int count);
...
int elements[] = {1, 2, 3};
foo(elements, 3);

Kotlin 代码:

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

字符串

与其它指针不同, const char* 类型参数会被表达为 Kotlin 的 String 类型. 因此对于 C 中期望字符串的绑定, 可以传递 Kotlin 的任何字符串值.

还有一些工具, 可以用来在 Kotlin 和 C 的字符串之间进行手工转换:

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

要得到指针, .cstr 应该在原生内存中分配, 比如:

val cString = kotlinString.cstr.getPointer(nativeHeap)

在所有这些场合, C 字符串的编码都是 UTF-8.

要跳过字符串的自动转换, 并确保在绑定中使用原生的指针, 可以在 .def 文件中使用 noStringConversion 语句, 也就是:

noStringConversion = LoadCursorA LoadCursorW

通过这种方式, 任何 CPointer<ByteVar> 类型的值都可以传递给 const char* 类型的参数. 如果需要传递 Kotlin 字符串, 可以使用这样的代码:

memScoped {
    LoadCursorA(null, "cursor.bmp".cstr.ptr)   // 对这个函数的 ASCII 版
    LoadCursorW(null, "cursor.bmp".wcstr.ptr)  // 对这个函数的 Unicode 版
}

作用范围内的局部指针

memScoped { ... } 内有一个 CValues<T>.ptr 扩展属性, 使用它可以创建一个指向 CValues<T> 的 C 指针, 这个指针被限定在一个作用范围内. 通过它可以使用需要 C 指针的 API, 指针的生命周期限定在特定的 MemScope 内. 比如:

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 函数以传值的方式接受结构体(Struct)或联合体(Union) T 类型的参数, 或者以传值的方式返回结构体(Struct)或联合体(Union) T 类型的结果, 对应的参数类型或结果类型会被表达为 CValue<T>.

CValue<T> 是一个不透明(opaque)类型, 因此无法通过适当的 Kotlin 属性访问到 C 结构体的域. 如果 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 表达式. 因此, 如果要读取结构体中一个单独的域, 可以使用下面的代码:

    val fieldValue = structValue.useContents { field }
    

回调

如果要将一个 Kotlin 函数转换为一个指向 C 函数的指针, 可以使用 staticCFunction(::kotlinFunction). 也可以使用 Lambda 表达式来代替函数引用. 这里的函数或 Lambda 表达式不能捕获任何值.

向回调传递用户数据

C API 经常允许向回调传递一些用户数据. 这些数据通常由用户在设置回调时提供. 数据使用比如 void* 的形式, 传递给某些 C 函数 (或写入到结构体内). 但是, Kotlin 对象的引用无法直接传递给 C. 因此需要在设置回调之前包装这些数据, 然后在回调函数内部将它们解开, 这样才能通过 C 函数来再两段 Kotlin 代码之间传递数据. 这种数据包装可以使用 StableRef 类实现.

要封装一个 Kotlin 对象的引用, 可以使用以下代码:

val stableRef = StableRef.create(kotlinReference)
val voidPtr = stableRef.asCPointer()

这里的 voidPtr 是一个 COpaquePointer 类型, 因此可以传递给 C 函数.

要解开这个引用, 可以使用以下代码:

val stableRef = voidPtr.asStableRef<KotlinClass>()
val kotlinReference = stableRef.get()

这里的 kotlinReference 就是封装之前的 Kotlin 对象引用.

创建 StableRef 后, 最终需要使用 .dispose() 方法手动释放, 以避免内存泄漏:

stableRef.dispose()

释放后, 它就变得不可用了, 因此 voidPtr 也不能再次解开.

更多详情请参见 samples/libcurl.

每个展开为常数的 C 语言宏, 都会表达为一个 Kotlin 属性. 其他的宏都不支持. 但是, 可以将它们封装在支持的声明中, 这样就可以手动映射这些宏. 比如, 类似于函数的宏 FOO 可以映射为函数 foo, 方法是向库 添加自定义的声明:

headers = library/base.h

---

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

定义文件提示

.def 支持几种选项, 用来调整最终生成的绑定.

  • excludedFunctions 属性值是一个空格分隔的列表, 表示哪些函数应该忽略. 有时会需要这个功能, 因为 C 头文件中的一个函数声明, 并不保证它一定可以调用, 而且常常很难, 甚至不可能自动判断. 这个选项也可以用来绕过 interop 工具本身的 bug.

  • strictEnumsnonStrictEnums 属性值是空格分隔的列表, 分别表示哪些枚举类型需要生成为 Kotlin 枚举类型, 哪些需要生成为整数值. 如果一个枚举型在这两个属性中都没有包括, 那么就根据 heuristic 来生成.

  • noStringConversion 属性值是一个空格分隔的列表, 表示哪些函数的 const char* 参数应该不被自动转换为 Kotlin 的字符串类型.

可移植性

有时, C 库中的函数参数, 或结构体的域使用了依赖于平台的数据类型, 比如 longsize_t. Kotlin 本身没有提供隐含的整数类型转换, 也没有提供 C 风格的整数类型转换 (比如, (size_t) intValue), 因此, 在这种情况下, 为了让编写可以移植的代码变得容易一点, 提供了 convert 方法:

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

这里, type1type2 都必须是整数类型, 可以是有符号整数, 可以可以是无符号整数.

.convert<${type}> 的含义等同于 .toByte, .toShort, .toInt, .toLong, .toUByte, .toUShort, .toUInt.toULong 方法, 具体等于哪个, 取决于 type 的具体类型.

使用 convert 的示例如下:

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

而且, 这个函数的类型参数可以自动推定得到, 因此很多情况下可以省略.

对象固定

Kotlin 对象可以固定(pin), 也就是, 确保它们在内存中的位置不会变化, 直到解除固定(unpin)为止, 而且, 指向这些对象的内部数据的指针, 可以传递给 C 函数. 比如:

fun readData(fd: Int): String {
    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()` 函数调用得到的原生数据.
        }
    }
}

这里我们使用了服务函数 usePinned, 它会先固定一个对象, 然后执行一段代码, 最后无论是正常结束还是异常结束, 它都会将对象解除固定.