目录

Kotlin Native 解析

最近,终于跑通了 kotlin-native 在鸿蒙上的 demo!

前置知识

kotlin multiplatform

kotlin multiplatform 是 jetbrains 推出来的跨端方案,渲染通过 compose(skia),逻辑通过 kotlin。

逻辑这块在 iOS/linux 等平台依靠的是 kotlin-native 编译器,将 kotlin 代码直接编译成了对应平台的二进制,而在 jvm 平台则直接转为 java bytecode

以下文章,只是分析 kotlin/native 这一分支的实现原理

这个图,简洁的展示了其中的原理

/img/in-post/kn_llvm.png

思考 kotlin/native 相关的几个问题

  1. kotlin 代码怎么直接就变成了二进制了?
  2. kotlin 代码怎么直接调用到对应平台的 c 函数的,比如 linux epoll 之类的函数的?(理论上这是隔离的两个世界)
  3. kotlin 运行在 jvm 环境的时候,有垃圾回收器帮我们处理内存问题,那现在变成二进制没有了 jvm,内存问题怎么办?

交叉编译

我们经常在 macOS 上进行 android 开发,我们使用 android studio 配置好 ndk,就可以将 c 代码编译成 linux 平台的二进制 elf。这个平时感受不到,但是细思是不是有点奇怪呢?为啥 macOS 上可以编译出来 linux 平台的东西?

这就是交叉编译器的功劳了

同时,牵扯出来另一个问题,一个程序运行到底需要什么?

同时,牵扯出来另另一个问题,我们凭什么可以直接调用 malloc/epoll?这些代码我们工程中并没有,只是导了个头文件

同时,牵扯出来另另另一个问题,libc 又是什么?bionic-libc glibc musl-libc 又是什么?linker 又做了什么?

llvm/clang

kotlin-native 编译器,大量依赖了 llvm 相关功能

llvm 是一个编译器框架,clang 是 llvm 工程搞出来的 c/c++ 编译前端

llvm 有自己的 ir,也有自己的 ir 解释器,iOS 有的热修复通过下发 ir 来实现

llvm 本身支持交叉编译,通过 ir 可以转换为对应平台的二进制

.bc = bitcode 是 ir 的二进制存储格式

kotlin-native 跨平台有一个很关键的步骤,就是将 kotlin 代码转换为 bitcode,然后使用 llvm link,再使用 clang 编译为对应平台二进制。

原理

klib & cinterop

kotlin-native 可以让我们在 kotlin 中调用到 platform 的 api,比如 iOS NSLog,linux epoll 等等

但是,我们仅仅是在写 kotlin 代码,这是如何做到的?

如果是 jvm 环境,倒是可以用 jni 做绑定,但是 kotlin-native 并不是 jvm 环境,可能是 iOS/Linux/MacOS 等等,又何谈 jni 呢?除非自带个 jre?那包体太大

其实,说到这里,如果不考虑包体和启动的问题的话,我们完全可以直接各个平台起个 java 进程,当然这种启动和执行可能性能较差。但是,是不是也可以直接使用 graalvm aot 产生的二进制呢?岂不是类似?

所以,搞了 klib 和 cinterop

先说结论,cinterop 会把 c lib(c++不可以) 的 .h 头文件,使用 clang 做解析,然后转为 kotlin 供给 kotlin 调用

简单版本的原理 debug 走过一遍 https://njuptrain.site/posts/Kotlin-Native-Cinterop/

💡注意💡
因为 cinterop 只会处理标准c头文件。所以,如果你想使用 kotlin 直接调用如 libmmkv.so(c++ lib),是不可以的。需要增加一层 c wrapper

llvm api

cinterop 在解析头文件时,使用了很多 llvm/clang 的 api(还没去研究)

看一些 api 说明,大概就是将 .h 做翻译,是不是常量?是不是一个方法?等等

/img/in-post/kni.png

收集到这些基础的描述信息以后,使用 kotlin metadata 生成一一对应的 kotlin 代码 /img/in-post/stubir.png

kotlin -> .so

在开发 kmp lib 的时候,我们都是书写的 kotlin 代码,最终又是怎么能够运行到对应平台的呢?

其实是依靠 kotlin 编译器,处理 kotlin 源文件,转化为 kotlin ir,再一一映射为 llvm ir,最终交叉编译为目标平台二进制

生成 bitcode

如何生成 bitcode?

kotlin 代码会通过 kotlin 前端编译器,转化为 kotlin ir,kotlin ir 最终序列化,存储在 klib 中

/img/in-post/runfrontend.png

/img/in-post/psiToIr.png

/img/in-post/toKlib.png

之后,通过 kotlin 后端编译器,将 kotlin ir 优化为 lower ir,lower ir 再一一映射到 llvm ir,生成 bitcode

这里其实也是比较复杂的,没有看的比较详细。
其中包含:
1.konanIrLinker 处理 klib dependency 的反序列化
2.形成 irModule。两个 module,最终合并为 out.bc
3.生成 c api header

/img/in-post/runbackend.png

/img/in-post/afterlowering.png

生成 target binary

在上面的 runAfterLowerings 的 compileAndLink 最终生成对应平台二进制

其中,上面的 compileModule 方法特别重要
compileModule 内部的 runBackendCodegen 会根据 kotlin ir 使用 llvm 生成对应 llvm ir

/img/in-post/compileModule.png

/img/in-post/backendCodegen.png

runAfterLowerings 的最后一行 compileAndLink,使用 clang 将 compileModule 产生的 bitcode 编译为 .o,然后使用 llvm 链接生成对应平台的二进制

/img/in-post/compileAndLink.png

以鸿蒙平台为例:

编译命令 /img/in-post/ohos_1.png

链接命令
/img/in-post/ohos_2.png

platform libs

由上面所说,我们已经可以将 kotlin 代码转为 bitcode 进而转换为二进制了,但是,还有一个问题!怎么调用平台特殊能力?怎么调用通用的底层能力?

比如,iOS/android 可能有个特殊的辅助能力?怎么在 kotlin 中直接调用?
比如,假设我们想用 kotlin 从零写一个跨端的网络库,posix 标准的 api 又怎么在 kotlin 中调用?

这些基础的 platform lib,kotlin-native 编译器已经帮我准备好了,就是 platformLibs 模块

/img/in-post/platform_libs.png

如果要增加平台能力,则需要增加对应的 def 文件声明,然后 cinterop 会帮忙处理 def 文件,生成对应的 klib

原理就是 cinterop 的原理

def 文件是 cinterop 生成 klib 必需的文件
主要作用是:
1.配置需要导入哪些 c lib 的方法到 kotlin 中
2.配置导入到 kotlin 中以后的包名
3.配置 c lib 的编译参数和链接参数
4.也可以在 def 文件写一些 c 代码做一些辅助功能

一个导入 鸿蒙 napi 到 kotlin 中的例子

// kotlin 中使用的包名
package = platform.napi
// 导入 napi_api.h 必要的所有头文件
headers = napi/native_api.h node_api.h node_api_types.h js_native_api_types.h js_native_api.h
// napi 所需要的 so
linkerOpts = -lace_napi.z
// 依赖哪个模块
depends = posix

有上述的 def 文件,cinterop 就可以帮我们生成一个 napi 的 klib,我们在 kotlin 中可以使用鸿蒙的 napi c 方法

fun register() {
    memScoped {
        val module = alloc<napi_module>().apply {
            this.nm_version = 1
            this.nm_flags = 0u
            this.nm_filename = null
            this.nm_register_func = staticCFunction(::Init)
            this.nm_modname = "hello".cstr.ptr
        }
        // napi module 注册方法
        // 现在可以直接在 kotlin 中调用
        napi_module_register(module.ptr)
    }
}

kotlin runtime

因为 kotlin 代码本身是运行在 jvm 环境下,jvm 有 garbage collector,所以我们不用显式的去 free 某个 object

但是,当 kotlin 代码转为运行在对应平台的二进制时,如果没有显式的 free,那就会有内存问题。所以 kotlin runtime 就是为了处理这个事情。

他有自己的 内存分配器 和 garbage collector,这部分操作应该是在 kotlin ir 转为 llvm ir 后,使用 llvm ir 插桩实现的

这样,我们写代码仍然没有管内存的释放,但是最终代码会被插桩,内存的分配都会使用 kotlin runtime 的内存分配器,后台有垃圾回收线程,辅助处理内存问题

llvm ir 插桩这块比较复杂,相当于 kotlin runtime 自己实现了一个简易垃圾回收器,自己也要定义 safe point,用于内存检测

在 kotlin-native 源码中,kotlin runtime 是一块 c++ 代码,这部分代码在编译 kotlin 编译器时,已经转换为了 runtime.bc。当我们写 kotlin 代码,使用 kotlin-native 将它编译成为二进制时,编译器会把我们的 kotlin 代码生成的 bitcode 和 runtime bitcode 最终合并为 out.bc。之后就是上面的 kotlin -> so 的过程了

这里显然也有一个问题,如果我们用 kotlin 写了多个跨端库,岂不是每个库中都存在 runtime 这块冗余的代码了?gc 线程会有多个么?

/img/in-post/runtime_multi.png

目前看,似乎会