Kotlin Native 解析

最近,终于跑通了 kotlin-native 在鸿蒙上的 demo!
前置知识
kotlin multiplatform
kotlin multiplatform 是 jetbrains 推出来的跨端方案,渲染通过 compose(skia),逻辑通过 kotlin。
逻辑这块在 iOS/linux 等平台依靠的是 kotlin-native
编译器,将 kotlin 代码直接编译成了对应平台的二进制,而在 jvm 平台则直接转为 java bytecode
以下文章,只是分析 kotlin/native
这一分支的实现原理
这个图,简洁的展示了其中的原理
思考 kotlin/native 相关的几个问题
- kotlin 代码怎么直接就变成了二进制了?
- kotlin 代码怎么直接调用到对应平台的 c 函数的,比如 linux epoll 之类的函数的?(理论上这是隔离的两个世界)
- 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 做翻译,是不是常量?是不是一个方法?等等
收集到这些基础的描述信息以后,使用 kotlin metadata 生成一一对应的 kotlin 代码
kotlin -> .so
在开发 kmp lib 的时候,我们都是书写的 kotlin 代码,最终又是怎么能够运行到对应平台的呢?
其实是依靠 kotlin 编译器,处理 kotlin 源文件,转化为 kotlin ir,再一一映射为 llvm ir,最终交叉编译为目标平台二进制
生成 bitcode
如何生成 bitcode?
kotlin 代码会通过 kotlin 前端编译器,转化为 kotlin ir,kotlin ir 最终序列化,存储在 klib 中
之后,通过 kotlin 后端编译器,将 kotlin ir 优化为 lower ir,lower ir 再一一映射到 llvm ir,生成 bitcode
这里其实也是比较复杂的,没有看的比较详细。
其中包含:
1.konanIrLinker 处理 klib dependency 的反序列化
2.形成 irModule。两个 module,最终合并为 out.bc
3.生成 c api header
生成 target binary
在上面的 runAfterLowerings 的 compileAndLink 最终生成对应平台二进制
其中,上面的 compileModule 方法特别重要
compileModule 内部的 runBackendCodegen 会根据 kotlin ir 使用 llvm 生成对应 llvm ir
runAfterLowerings 的最后一行 compileAndLink,使用 clang 将 compileModule 产生的 bitcode 编译为 .o,然后使用 llvm 链接生成对应平台的二进制
以鸿蒙平台为例:
编译命令
链接命令
platform libs
由上面所说,我们已经可以将 kotlin 代码转为 bitcode 进而转换为二进制了,但是,还有一个问题!怎么调用平台特殊能力?怎么调用通用的底层能力?
比如,iOS/android 可能有个特殊的辅助能力?怎么在 kotlin 中直接调用?
比如,假设我们想用 kotlin 从零写一个跨端的网络库,posix 标准的 api 又怎么在 kotlin 中调用?
这些基础的 platform lib,kotlin-native 编译器已经帮我准备好了,就是 platformLibs 模块
如果要增加平台能力,则需要增加对应的 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 线程会有多个么?
目前看,似乎会