文章

Nim中的动态链接库

这个问题多年来一直在不同的地方被问到,最近在论坛上也有多个不同的主题。鉴于我曾使用Nim动态库开发过商业软件,我想我应该谈谈我的看法。但由于有多个主题,而且这是一个很大的话题,所以我想最好还是在这里发帖。

问题

大家可能都知道,Nim是通过C语言编译的,这就意味着可以用它来编写动态链接库。无论是加载到现有程序中,还是编写一个可以加载动态库的Nim程序,问题都是一样的。我们如何才能做到这一点?我们可能很想简单地打开--app:lib编译开关,然后就大功告成了,但事情并没有那么简单。我们基本上有三个问题:

  • Nim和C差异很大,我们必须让它们能够互相交流
  • Nim有一个GC,因此我们需要想办法从动态链接库中控制它
  • 最后,我们需要实际编译动态链接库,并在可能的情况下加载它

问题 1:Nim/C接口

正如我所说,Nim和C语言截然不同,如果我们想创建一个动态链接库供第三方加载,就必须确保外部应用程序能够真正理解我们的Nim动态链接库在做什么。这实际上是一个很大的话题,我目前正在撰写另一篇文章,但有一些事情我们尤其需要牢记。首先,如果我们想使用加载程序中的过程,或者让加载程序调用我们的任何过程,那么签名必须完全匹配。这意味着参数、返回类型以及所谓的“调用约定 ”都必须匹配。参数和返回类型相当简单,C语言中的整数在Nim中会变成cint,C 语言中的字符串(char *)会变成cstring,C 语言中的浮点数会变成cfloat等。你可以说C语言中的整数在Nim中就是int,但这在技术上是不正确的,在某些平台上可能会出现问题,所以安全第一,使用正确的类型。如果你得到一个指向结构体的指针,你需要精确地重新创建该对象,然后使用ptr SomeObject,如果你得到一个指向对象的指针和一个长度,并打算将其作为数组遍历,你应该使用ptr UncheckedArray[SomeObject],因为这将告诉Nim它可以将指针作为数组处理,但不进行任何边界校验。这意味着你需要手动检查长度,否则就有可能越过数组末尾,进入潜在的未分配内存并导致崩溃。请记住,这些签名必须完全匹配!

现在是 “调用约定”,本质上只是一个花哨的术语,指的是如何生成底层机器代码。不同的编译器有不同的巧妙方法来做到这一点,而且必须完全匹配。如果C的源代码没有指定调用约定(可能没有),则应使用{.cdecl.},因为这本质上意味着“按C的源代码的做法来做”。这也是你可能在技术上弄错的情况,但在你的机器上仍然可以工作,因为你的编译器碰巧使用了你选择的调用约定。但你还是应该使用正确的调用约定。

由于C语言的类型系统相当薄弱,而且Nim完全信任你所说的来自C语言的签名,因此编写一个合适的封装器可能是一项艰苦的工作。如果你要封装任何琐碎的C代码,我非常推荐你使用Futhark,因为它能让这项工作变得简单得令人难以置信。不过这是我下一篇文章的主题,这里就不多说了。

问题 2:Nim有一个 GC

Nim默认为有垃圾回收的语言。这意味着与C语言不同,你不必担心调用mallocfree来获取和释放内存。相反,Nim 会在执行过程中精心选择时间点来释放任何未被使用的资源,甚至可能会停止一小会儿,以扫描相关的循环引用。Nim也有全局变量,它需要比典型的C全局变量更复杂的初始化。通常情况下,你不需要考虑这个问题,Nim会生成一个main函数,这是正常程序开始执行的地方,它会为我们处理所有这些设置。但在创建动态链接库时,情况就有些不同了。默认情况下,Nim会挂钩 Windows上的DllMain或 POSIX 上的\_\_attribute((constructor))__来创建挂钩,按照惯例,只要动态链接库被加载,这些挂钩就会被调用。这应该是可行,但许多动态链接库系统都会包含一些library_initlibrary_deinit过程,动态链接库的加载程序会调用这些过程,以便分配和适当释放资源(如果你正在创建一个类似的系统,你很可能也想在系统中添加这些过程)。确保初始化完成的推荐方法是在本地过程中调用初始化。要做到这一点,编译时使用--noMain会禁用默认的 main初始化钩子,然后自己调用一个名为NimMain的过程。这将设置垃圾回收器并初始化全局内存。请确保在代码中尽快调用该过程,如果在调用该过程之前意外使用了垃圾回收内存,将会导致问题。要在Nim代码中访问此过程,需要添加此定义:

1
proc NimMain() {.cdecl, importc.}

如果您将多个Nim编写的动态链接库加载到同一个程序中,或者如果您将一个Nim编写的动态链接库加载到一个Nim编写的程序中,并且您希望在它们之间传递Nim垃圾回收类型(包括引发异常),它们必须就哪个内存管理系统达成一致。这是通过一个名为nimrtl.(so|dll|dynlib)(Nim RunTime Library 的缩写)的额外动态库来实现的。要构建该库,你需要一份Nim库的副本,该库通常与Nim本身一起安装。要构建该库,请参阅Nim手册的这一部分,然后在构建程序或库时使用-d:useNimRtl链接。

如果只在Nim动态库/程序之间使用手动管理的类型进行通信,则不需要此步骤。在这种情况下,它们并不会真正意识到这些片段同普通的C代码有什么不同。

我还建议在编译时使用Nim 2.0中的新默认设置,即--gc:orc。只需将其添加到编译命令或nim.cfg文件中就可以了。如果您使用--gc:arc进行编译,从技术上讲,在NimMain 中无需为GC进行任何设置,但为了初始化全局变量(包括从导入的库中),您仍应确保调用它。

关于全局变量,如果动态链接库被卸载,加载器应该调用library_deinit或类似的函数。Nim 目前并不支持释放所有已分配内存的方法(只是假设进程退出时会自动释放)。因此,在这种情况下,清空所有全局内存并调用GC_FullCollect将是一个好主意。GitHub上的这个问题记录了这一疏忽,并正在跟踪修复工作。

问题 3:编译动态链接库

在过去的两节中,你已经看到了一些有助于解决这个问题的开关。此外,你还看到了一些用于生成正确的Nim代码并导出的实用程序。不过,我们的工作还没有完全结束。每一个你想调用的由动态链接库提供的过程都必须标上{.importc, dynlib.},而每一个你想让函数库加载器看到的过程都必须标上{.exportc, dynlib.}

编译时需要使用--app:lib--noMain(如果你打算自己调用NimMain,建议使用)以及-d:useNimRtl。你也可以像平常一样使用其他标志。这样,我们就拥有了一个完全由Nim编写、功能完备的动态链接库!最终的结果会是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
proc NimMain() {.cdecl, importc.}
 
proc library_init() {.exportc, dynlib, cdecl.} =
  NimMain()
  echo "Hello from our dynamic library!"
 
proc library_do_something(arg: cint): cint {.exportc, dynlib, cdecl.} =
  echo "We got the argument ", arg
  echo "Returning 0 to indicate that everything went fine!"
  return 0 # This will be automatically converted to a cint
 
proc library_deinit() {.exportc, dynlib, cdecl.} =
  echo "Nothing to do here since we don't have any global memory"
  GC_FullCollect()

它可以用类似这样的方式进行编译:

1
nim c -d:release -app:lib --noMain --gc:orc ourlibrary.nim
本文由作者按照 CC BY 4.0 进行授权