文章

Futhark——在Nim中自动化导入C库

您是否已经为您的项目找到了完美的C库?但在Nim中却找不到对应的封装库?不用再找了!Futhark的目标是让你可以简单地将C头文件直接导入Nim,并允许你像使用C语言一样使用它们,而无需任何手动干预。它仍处于 alpha状态,但已经可以封装许多复杂的头文件,而无需任何重写或预处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import futhark

# Tell futhark where to find the C libraries you will compile with, and what
# header files you wish to import.
importc:
  path "../stb"
  define STB_IMAGE_IMPLEMENTATION
  "stb_image.h"

# Tell Nim how to compile against the library. If you have a dynamic library
# this would simply be a `--passL:"-l<library name>`
static:
  writeFile("test.c", """
  #define STB_IMAGE_IMPLEMENTATION
  #include "../stb/stb_image.h"
  """)
{.compile: "test.c".}

# Use the library just like you would in C!
var width, height, channels: cint

var image = stbi_load("futhark.png", width.addr, height.addr, channels.addr, STBI_default.cint)
if image == nil:
  echo "Error in loading the image"
  quit 1

echo "Loaded image with a width of ", width, ", a height of ", height, " and ", channels, " channels"
stbi_image_free(image)

那么,现在所有的C语言包装器都过时了吗?

不尽然。Futhark只告诉你C头文件定义了什么,并允许你使用它们。这意味着界面仍然非常类似于C语言。很多优秀的Nim封装器都会使用C语言库,并将其封装成从Nim环境中使用起来更简单的东西。但Futhark绝对可以用来帮助封装C库。由于Futhark可以直接读取C语言文件,因此无论你在什么平台上,也无论你想传递什么定义,都能保证所有类型都与C语言对应的类型一致。这比手工编写代码有很大的优势。Futhark和Øpir还会缓存它们的结果,因此在初始编译之后,只需从缓存中抓取预生成的Nim文件,就能快速使用。当然,如果你希望用户无需亲自运行Øpir或Futhark,也可以对这两个文件进行编辑或将其原封不动地包含在项目中。

它是如何工作的?

基本上,Futhark 由两部分组成:一个名为Øpir的辅助程序(或opir,以确保它在任何地方都能正常工作)和一个名为futhark的模块,后者提供了一个importc宏。Øpir由libclang编译,并使用Clang来解析和理解C文件,然后创建一个大的JSON输出,其中包含了所有在头文件中定义的Nim友好类型。然后,宏读取该文件,并在生成所有Nim定义之前对类型和名称进行重写。

基本用法

使用Futhark需要知道的四个主要部分是sysPathpathcompilerArgs和头文件(上例中的"stb_image.h "部分)。

  • sysPath表示系统路径,将传递给Øpir以确保Clang知道在哪里可以找到所有定义。如果想自动生成这些路径,也可以用-d:sysPath:<path 1>:<path 2>来传递。
  • path表示库路径,这些路径也将传递给Øpir,但这些路径中的任何内容,如果被项目中的文件使用,也将被Futhark封装。
  • comppilerArgs指定了在解析C头文件时应传递给Clang的附加标志。
  • Futhark将生成这些文件中的所有定义,如果file.h包含了在path中找到的更多文件,这些文件也将被导入。

注意:sysPathpath之间的区别只是关于Futhark处理文件的方式。这种差异的存在是为了确保Futhark 不会导入Nim中已有的各种底层系统内容。sysPath的子路径可以毫无问题地与path一起传入。因此,sysPath "/usr/include"后跟path "/usr/include/X11"就可以了,Futhark 将只为明确提到的文件生成代码,以及任何它需要从/usr/include/X11中获取的文件。

名称编码和重写

与C语言不同,Nim不区分大小写和下划线,不允许使用____ 开头的标识符,也不允许标识符中包含多个连续的_。Nim还有一系列保留关键字,如procaddrtype,这些关键字不便用作名称。因此,Futhark将根据一些相当简单的规则来重命名这些关键字。

C中的名称Nim重命名规则
结构体类型struct_前缀
联合体型union_前缀
_前缀internal前缀
__前缀compiler前缀
名称中的__去掉所有下划线
保留关键字在名称后面添加procconststruct等。

由于这一点,再加上Nims风格敏感性,意味着某些标识符可能仍然会发生碰撞,因此名称会进一步附加种类,如果仍然发生碰撞,则会附加原始标识符的哈希值。这在实际项目中不会经常发生,主要是为了创建一个万无一失的重命名方案。请注意,struct和union类型也会获得一个前缀,这通常会通过C类型化struct struct_namestruct_name_t自动解决,但如果您需要使用struct struct_name类型,请记住在Nim中它将是struct_struct_name

如果要重命名一个对象或一个字段,可以使用rename指令。 只需将rename <from>, <to>和其他选项放在一起即可。<from>可以是一个对象名称(重命名之前),如字符串或标识符,或者是一个格式为<object>.<field>。既可以是两个标识符的原始C名称,也可以是整个字符串。<to>始终是一个标识符,并且是新名称。

如果要实现更复杂的重命名,可以使用renameCallback指令,并传递一个回调函数,该函数接收原始名称、一个表示标识符类型的字符串、一个表示该标识符出现在哪个对象或过程中的可选字符串,并返回一个新字符串。该回调函数将插入重命名逻辑中,并在应用所有其他规则之前调用原始C标识符。

重新定义类型

C语言倾向于使用大量的void指针、字符指针以及指向单个元素的指针,而这些元素本应是上述元素的集合。在Nim中,我们对类型的要求更为严格。为此,你可以使用retype指令。它的形式是retype <object>.<field>, <Nim type>,例如,要在Nim中将定义为some_element* some_field的C数组类型转换为可索引类型,可以使用retype some_object.some_field, ptr UncheckedArray[some_element]。对象和字段的名称都是重命名后的Nim标识符。

如果您需要重新定义整个对象,而不仅仅是特定的字段,Futhark 默认会在简单的when declared(SomeType)语句中保护每个类型和过程定义,这样如果您想覆盖定义,您可以在调用importc宏之前简单地定义您的类型,Futhark不会覆盖您的定义。不过,您需要确保该类型在大小和布局上与原始的C语言类型相匹配。

兼容性和可读性

默认情况下,Futhark会尽量确保与预封装库(如posix标准库模块)和其他用户代码的兼容性。正因为如此,Futhark生成的输出并不漂亮,到处都是when defined的语句和用于重命名的奇怪数字标识符。这些功能的目的是使Futhark更易于自动使用,但你可能并不需要它们。如果你想阅读生成的输出,为Futhark生成的模块编写文档,或者想获得更好的编辑器体验,你可能需要禁用其中的一些功能,以获得更漂亮、更易读的输出。

使用定义开关基本上可以控制三件事:

定义效果
nodeclguards禁用对象重命名/覆盖功能
noopaquetypes禁用用于未知对象的不透明类型
exportall导出所有字段(包括重命名的字段)

对象重命名/覆盖功能

在声明类型时,相同名称的早期声明不会被覆盖或冲突。利用这一功能,您可以在调用Futhark之前声明对象、函数、枚举等,这些声明将被使用,而不是自动生成的声明。这也是“重定义类型”部分末尾启用该功能的原因。禁用该功能将删除所有when declared的类型,但Futhark可能会尝试重新声明现有类型,包括内置类型和名称。

不透明类型功能

如果您的C头文件中没有完全声明一个类型,但您的项目仍然需要该类型,Futhark将为其生成一个type SomeType = distinct object的虚拟类型。由于大多数C语言库都是通过指针传递信息,因此这将确保一个ptr SomeType可以存在并被传递,而无需知道任何关于SomeType的信息。禁用此功能将删除这些定义,但可能意味着某些存储过程定义的参数或返回类型无效。该功能主要用于与nodeclguards结合使用,以及手动声明这些类型。

隐藏符号功能

为避免编辑器显示对象重命名/覆盖功能所使用的重命名标识符,这些标识符默认是隐藏的。不过,如果您想为Futhark生成的模块生成文档,这些字段将不可见,文档也大多无用。如果使用exportall,这些符号也将被导出,文档也将是可读的。如果您想导出文档,但又不能使用nodeclguards(它能使文档更加清晰可读),那么这个功能就非常有用了。

内联功能

在动态库中使用Futhark时,封装内联函数是没有意义的。不过,如果您直接针对某些C代码编译代码,这些函数可能会对您有用。在这种情况下,您可以通过-d:generateInline来生成内联函数的函数定义。

ANSI C之前的函数声明

也称为K&R风格函数。根据定义,C代码如:

1
int* myfunc();

是C语言的函数声明,其中表示myfunc返回一个指向整数的指针,并且可以接受任意数量的参数。最后一部分是一个历史问题,你可以在这里阅读更多相关信息,但只需指出这在很多C语言库中被误用,意思是函数不需要参数。由于这一点相当隐晦,而且如果 varargs pragma附加到不带参数的函数上,Nim将创建糟糕的C代码,因此默认情况下会忽略这一点。不过,如果你出于某种原因需要这样做,可以在编译时添加-d:preAnsiFuncDecl

更深入的控制

如果遇到不容易解决的问题,还有最后一种修改方法,那就是Øpir钩子。由于Øpir会将C导入转换为JSON格式,因此你可以注册钩子,在Futhark消耗JSON之前运行这些钩子。这些都是简单的过程,接收一个JsonNode并返回一个JsonNode。有了它,你就能改变JSON的方方面面,甚至添加或删除定义。回调是一个列表,因此执行某些常用转换的模块(例如将名称相似的常量合并为一个枚举)可以很容易地添加到回调列表中。要添加这些回调,只需在importc块中添加outputPath <procedure name>

析构

如果您使用的是C库,您可能会想要封装析构函数调用。Futhark使所有C对象{.pure,inheritable.},这意味着您可以非常容易地使用习以为常的Nim来实现析构函数。

MiniAudio绑定的用例如下:

1
2
3
4
5
6
7
type TAudioEngine = object of maEngine # Creates a new object in this scope
                                       # maEngine is a type wrapped by Futhark
                                       # TAudioEngine can now have a destructor
                                       # attached to it

proc `=destroy`(engine: var TAudioEngine) = # Define a destructor as normal
  maEngineUninit(engine.addr)

动态库和执行头

如果您要制作一个动态或静态库,以便与其他程序一起加载或链接,您通常会得到一个头文件来执行。该文件通常包括您可以从主程序中调用的类型和函数,以及您的应用程序为正确加载和运行而必须执行的程序。Futhark通常会导入所有的头文件,前提是在编译时这些头文件可以从C语言中获取,即添加importc语义。但为了支持这种情况,你也可以让它使用exportc语义创建前向声明。这样,Nim就能知道在你的应用程序中必须存在一个给定过程的实现,因此,如果你缺少一个实现,编译就会失败。它还能确保你的签名被正确导出,并与预期的C头相匹配。要做到这一点,只需像这样定义要转发声明的存储过程以及其他选项即可:

1
2
importc:
  forward "proc_to_forward"

如果使用--app:lib 进行联编,Futark会自动在此声明中添加dynlib实用程序,但如果需要添加更多实用程序,可以像这样在名称后列出:

1
2
3
4
5
6
macro customPragma(msg: static[string], body: untyped): untyped =
  echo "Saying: ", msg
  return body

importc:
  forward "proc_to_forward", customPragma("Hello world"), used

如果你想查看Futhark为你的转发声明生成了哪些代码,以及你需要匹配的签名(甚至是参数名),你可以通过-d:echoForwards在编译时将它们写入终端。

注意:由于前向声明中包含了将其作为C兼容符号传递的所有实用程序,因此您实际上并不需要将这些实用程序附加到您的实现中,这将使您的实现更加简洁。当然,如果你愿意,也可以用 camelCase 来编写存储过程,它仍然会与前向声明相匹配。

分享包装库

如果你已经用Futhark构建了封装器,并用Nim界面对其进行了扩展,现在是时候分享它们了。本节将介绍如何发布封装程序的最佳实践。由于Øpir工具需要安装Clang,因此安装Futhark可能有点麻烦。除此之外,Futhark显然还需要访问C头文件,而根据系统的不同,头文件可能安装在不同的位置。因此,您可能不希望将Futhark作为绑定的依赖项。为了解决这个问题,Futhark有一个outputPath参数,可以添加到importc块中。该路径是已完成绑定的存储位置,也是Futhark查找现有绑定以避免重建绑定的位置。这意味着如果将outputPath路径设置为一个文件,那么当你在importc代码块中进行修改时,就需要使用-d:futharkRebuild来更新文件。如果将outputPath路径设置为文件夹,那么futhark将把附加哈希值的文件存储在该文件夹中,而不是存储在nimcache文件夹中,缓存将照常工作。通过使用这一功能,您就可以为您的项目设置一个本地路径,并将生成的Futhark文件捡取到您的版本控制系统中。但这只是完成了一半的工作,因为要感知缓存文件,还需要安装Futhark。解决这个问题的推荐方法是使用when defined(useFuthark)开关来检查用户是想直接使用Futhark还是使用已发布的封装器。建议使用确切的名称useFuthark,这样用户就可以在整个项目中打开 Futhark(以防导入了其他也使用Futhark的库)。如果想让用户选择只为自己的项目打开Futhark,建议使用附加开关useFutharkFor<project name>。

完整的样本应该是这样的:

1
2
3
4
5
6
7
8
9
when defined(useFuthark) or defined(useFutharkForExample):
  import futhark, os

  importc:
    outputPath currentSourcePath.parentDir / "generated.nim"
    path "<path to library>"
    "libfile.h"
else:
  include "generated.nim"

请记住,当您的软件包安装完成后,生成的 Futhark 输出将通过这段代码放到软件包的文件夹中。如果 “generated.nim "部分被省略,那么该文件就会如上所述被命名为futhark_<hash>.nim,这意味着您的include可以使用软件包安装中指定的文件,而使用useFuthark的用户则会根据其哈希值生成一个文件(如果哈希值匹配,则重用您的文件)。

但为什么不使用c2nim或nimterop呢?

c2nim和nimterop过去都曾在封装头文件时失败过。c2nim试图自己读取和理解C文件,这看起来似乎很简单,但C是出了名的难解析,而且c2nim在宏和其他稍微复杂的事情上也会失败。理论上,它能解析所有C语法,但C语义仍由nimterop来实现。这意味着它不能自动执行宏或类似“IFDEF”的功能。

另一方面,Futhark使用的是clang,它不仅能很好地理解C语言语法,还能很好地理解C语言语义。这意味着它能解析所有宏和IFDEF语句,并给出所有内容的定义。这意味着实际尝试理解C语言的工作要少得多,这意味着所有这些工作都可以花在高质量的Nim翻译上。

听起来不错,有什么好处?

Futhark目前处于测试阶段,运行非常良好,但偶尔也会遇到错误或故障。它目前还不支持C++,也不理解函数式宏等东西。它还可能会在尚未遇到过的奇怪C语言上出错,不过随着人们的使用,这种情况越来越少。我希望随着时间的推移,所有这些缺点都能得到修正。

安装

要安装Futhark,首先需要安装clang。在 Linux上安装clang非常简单,只需从软件包管理器中抓取即可(例如,sudo apt install clang libclang-dev)。要在Windows上安装clang,您需要安装LLVM(您可能需要抓取LLVM-15.0.7-win64.exe版本)。要在macOS上安装clang,在终端运行xcode-select --install即可。Opir会自动检测到它。如果你对 Windows和macOS的检测工作原理感到好奇,请查看opir.nims

如果系统路径中已安装Clang,现在只需运行:

1
nimble install futhark

否则,你需要告诉Opir如何链接libclang。方法是将libclang.liblibclang.dll复制到Futhark项目目录,或者使用passLlibclang.lib(或Linux机器上的libclang.so)所在的文件夹传递给链接器:

1
2
nimble install --passL:"-L<path to lib directory containing libclang.so file>" futhark
#e.g.: nimble install --passL:"-L/usr/lib/llvm-6.0/lib" futhark

待处理事项

  • 正确处理C宏(这本身就很难,因为C宏是无类型的)
  • 找到不要求C编译器包含路径的方法
本文由作者按照 CC BY 4.0 进行授权