跳转至

Kotlin 调用 C 语言是如何实现的?

Kotlin/Native 通过 C-interop 机制实现与 C 语言的互操作性,允许 Kotlin 代码直接调用 C 函数和使用 C 数据结构。C-interop 通过解析 C 语言头文件自动生成相应的 Kotlin 绑定代码,为 Kotlin 与 C 库的交互提供了无缝的桥梁。

我们就一起来看下,Kotlin 调用 C 语言具体是如何实现的。

接口定义

headers = hello.h

# (For cinterop tool) Path to search for static libraries.
# Must be relative to the project root.
libraryPaths = src/nativeInterop/cinterop

# (For final linker) Options to be embedded in the klib.
# Must be relative to the project root.
linkerOpts = -Lsrc/nativeInterop/cinterop

# The static library file to be included.
staticLibraries = libhello.a

# (For cinterop tool) Path to search for header files.
# Must be relative to the project root.
compilerOpts = -Isrc/nativeInterop/cinterop

如上所示,.def 文件定义了 C 接口,包括头文件、编译选项、链接选项等信息。C-interop 工具将解析这个文件,并生成相应的 Kotlin 代码和 KLIB 文件。

绑定与桥接生成

./kotlin-native/dist/bin/run_konan cinterop -def ./compilerTestData/cinterop/hello.def -o compilerTestData/cinterop/Hello -J"-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:50015"

当我们执行 C-interop 命令时,实际上是调用了以上命令,这个命令会执行以下操作:

参数解析

执行以上命令行时,会执行到 mainImpl 函数,该函数负责解析命令行参数,并根据参数调用相应的 C-interop 工具。

private fun mainImpl(args: Array<String>, runFromDaemon: Boolean, konancMain: (Array<String>) -> Unit) {
    val utilityName = args[0]
    val utilityArgs = args.drop(1).toTypedArray()
    when (utilityName) {
        "cinterop" -> {
            val konancArgs = invokeInterop("native", utilityArgs, runFromDaemon)
            konancArgs?.let { konancMain(it) }
        }
        // ...
    }
}

调用核心生成器

在解析完参数后,C-interop 工具会调用核心生成器来处理 C 库的绑定生成。这个过程主要由 processCLib 函数完成。

processCLib 的主要职责是:获取 C 库的完整定义,调用 LLVM 解析其头文件,生成所有必要的 Kotlin 和 C 的“胶水代码”(stubs),并将其编译打包,为最终生成 KLIB 做好准备。

获取 C 库定义

processCLib 函数中,首先会获取 C 库的定义, 这一步通过调用 LLVM 解析 .def 中指定的头文件来完成的。

// nativeIndex 是一个抽象类,它描述了来自C头文件的定义的IR
val (nativeIndex, compilation) = buildNativeIndexImpl(library, verbose, allowPrecompiledHeaders = nativeLibsDir != null)

构建 Kotlin IR 存根

这一步的主要任务是从 C AST 中提取所有声明信息,并将 C 声明转换为 Kotlin IR 存根。

class StubIrBuilder(private val context: StubIrContext) {
    fun build(): StubIrBuilderResult {
        // ...
        nativeIndex.functions.filter { it.name !in excludedFunctions }.forEach { generateStubsForFunction(it) }
        // ...
        return StubIrBuilderResult(
                stubs,
                buildingContext.declarationMapper,
                buildingContext.bridgeComponentsBuilder.build(),
                buildingContext.wrapperComponentsBuilder.build()
        )
    }    
}

// 生成类型映射和函数签名
fun mirror(declarationMapper: DeclarationMapper, type: Type): TypeMirror = when (type) {
    is PrimitiveType -> mirrorPrimitiveType(type, declarationMapper)
    is RecordType -> byRefTypeMirror(declarationMapper.getKotlinClassForPointed(type.decl).type)
    is EnumType -> // ...
    is PointerType -> // ...
    is ArrayType -> // ...
    is FunctionType -> byRefTypeMirror(KotlinTypes.cFunction.typeWith(getKotlinFunctionType(declarationMapper, type)))
    is Typedef -> // ...
    is ObjCPointer -> objCPointerMirror(declarationMapper, type)
    else -> TODO(type.toString())
}

这一步会处理基础类型(如整数、浮点数等)和复杂类型(如结构体、枚举等)的映射转换。它会生成相应的 Kotlin 类型,以便在 Kotlin 代码中使用 C 数据结构

这一步生成的内容实际上就是 CInterop 生成的 knm(kotlin native metadata) 文件,生成的内容就是函数声明,如下所示:

package hello

@kotlinx.cinterop.internal.CCall 
@kotlinx.cinterop.ExperimentalForeignApi 
public external fun hello_from_c(timestamp: kotlin.Long): kotlin.Unit { /* compiled code */ }

构建桥接代码

class StubIrDriver(
        private val context: StubIrContext,
        private val options: DriverOptions
) {
    fun run(): Result {
        // 生成 Kotlin IR 存根
        val builderResult = StubIrBuilder(context).build()
        // 构建桥接代码
        val bridgeBuilderResult = StubIrBridgeBuilder(context, builderResult).build()
        // 生成 C 存根文件
        outCFile.bufferedWriter().use {
            emitCFile(context, it, entryPoint, bridgeBuilderResult.nativeBridges)
        }
        // ...
    }
}

这一步的主要作用是生成 Kotlin 和 C 之间的桥接代码并写入 C 存根文件。它会创建函数调用包装器,生成属性访问器,并处理类型转换。

生成的内容如下所示,可以看到生成了hello_from_c 函数的调用包装器,后续 Kotlin 代码调用这个函数时,会通过这个包装器来调用 C 函数。

// __attribute__((always_inline)): 强制内联优化,减少函数调用开销
__attribute__((always_inline))
// 函数名 hello_hello_from_c_wrapper0 由 Kotlin/Native 自动生成
// 参数 long long p0 对应 Kotlin 的 Long 类型
void hello_hello_from_c_wrapper0(long long p0) {
    hello_from_c(p0);
}

// 声明函数指针变量,用于存储包装器函数的地址
// __asm("knifunptr_hello0_hello_from_c") 指定汇编符号名,避免命名冲突
const void* knifunptr_hello0_hello_from_c __asm("knifunptr_hello0_hello_from_c");
// 将包装器函数的地址赋值给函数指针变量
// 这样 Kotlin 代码就可以通过这个函数指针安全地调用 C 函数
const void* knifunptr_hello0_hello_from_c = (const void*)&hello_hello_from_c_wrapper0;

原生代码编译

上面生成的 C 桥接代码,需要通过 LLVM 编译为 bc 文件。

when (flavor) {
    KotlinPlatform.NATIVE -> {
        // 编译为 LLVM 位码 (.bc) 文件
        val outLib = File(nativeLibsDir, "$libName.bc")
        val compilerCmd = arrayOf(compiler, *compilerArgs,
            "-emit-llvm", "-x", library.language.clangLanguageName, 
            "-c", "-", "-o", outLib.absolutePath)
        runCmd(compilerCmd, verbose, redirectInputFile = File(outCFile.absolutePath))
    }
}

生成 KLIB

最后一步是将生成的 Kotlin IR 存根和桥接代码序列化并写入 KLIB 文件。这一步会将库的元数据、IR Stub、manifest 文件等所有内容,按照 .klib 文件格式的标准目录结构,写入到由 -o 参数指定的输出目录中。

when (stubIrOutput) {
    is StubIrDriver.Result.Metadata -> {
        createInteropLibrary(
            metadata = stubIrOutput.metadata,           // Kotlin 元数据
            nativeBitcodeFiles = compiledFiles + nativeOutputPath,  // 原生位码
            target = tool.target,                       // 目标平台
            moduleName = moduleName,                    // 模块名
            outputPath = outputPath,                    // 输出路径
            manifest = def.manifestAddendProperties,    // 清单属性
            dependencies = stdlibDependency + imports.requiredLibraries,
            nopack = nopack,                           // 是否打包
            shortName = cinteropArguments.shortModuleName,
            staticLibraries = resolveLibraries(staticLibraries, libraryPaths)
        )
    }
}

编译器处理 @CCall 函数调用

上面生成的 KLIB 文件,包含了 Kotlin IR 存根和桥接代码。接下来,Kotlin 编译器会处理这个 KLIB 文件,将其与 Kotlin 代码进行链接。通过以下命令行执行 Kotlin 编译器:

./kotlin-native/dist/bin/run_konan konanc ./compilerTestData/cinterop/Main.kt -library ./compilerTestData/cinterop/Hello.klib -o ./compilerTestData/cinterop/Hello -J"-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:50015"

同时我们可以看到,上面生成的 knm 中的函数为hello_from_c,而生成的桥接代码中的函数是knifunptr_hello0_hello_from_c,因此 Kotlin 编译器还需要识别@kotlinx.cinterop.internal.CCall函数,并修改调用代码,将其替换为桥接函数。

// 处理 C 函数调用
if (function.annotations.hasAnnotation(RuntimeNames.cCall)) {
    return generateCCall(expression)、
}

generateCCall(expression: IrCall, builder: IrBuilderWithScope, isInvoke: Boolean,
                                       foreignExceptionMode: ForeignExceptionMode.Mode = ForeignExceptionMode.default): IrExpression {
    // ...
    if (isInvoke) {
        callBuilder.cBridgeBodyLines.add(0, "$targetFunctionVariable = ${targetPtrParameter!!};")
    } else {
        val cCallSymbolName = callee.getAnnotationArgumentValue<String>(RuntimeNames.cCall, "id")!!
        this.addC(listOf("extern const $targetFunctionVariable __asm(\"$cCallSymbolName\");")) // Exported from cinterop stubs.
    }
    return result
}

运行时处理

我们知道,Kotlin/Native 使用自动垃圾回收,而 C 语言使用手动内存管理,因此在 Kotlin 调用 C 函数时,可能导致了以下关键问题:

  • 生命周期不匹配:Kotlin 对象可能被 GC 回收,而 C 代码仍持有引用
  • 内存泄漏风险:C 代码分配的内存需要手动释放
  • 数据所有权不明确:谁负责释放内存?

Kotlin/Native 通过运行时库提供了 C-interop 的支持,处理这些问题。具体原理在介绍 Kotlin/Native 内存管理机制时再介绍。

总结

Kotlin/Native 与 C 语言的互操作性是其核心特性之一,它通过 cinterop 工具链实现。该过程首先通过一个 .def 配置文件来声明需要交互的 C 库头文件和链接信息。

cinterop 工具会解析这些头文件,并执行以下关键操作:

  1. 生成 Kotlin IR 存根:创建 C 函数和类型的 Kotlin 声明,并将其存储为 .klib 中的元数据。这使得 Kotlin 编译器能够识别这些外部符号。
  2. 生成 C 桥接代码:创建 C “胶水”函数(wrapper),用于安全地调用原始 C 函数。这些桥接代码随后被编译成原生 LLVM 位码。

最终,Kotlin 元数据和原生位码被一同打包进一个 .klib 文件。当编译器在用户代码中遇到对 C 函数的调用时(通过 @CCall 注解识别),它会链接到 .klib 中对应的桥接函数,从而完成整个调用链路。这个精巧的设计使得开发者可以在 Kotlin 中以类型安全的方式无缝调用底层 C 代码。


最后更新: August 10, 2025