LLVM 项目博客

LLVM 项目新闻和来自战壕的详细信息

DragonFFI: 使用 Clang/LLVM 的 C 语言 FFI/JIT


引言

一个 外部函数接口 是“一种机制,允许用一种编程语言编写的程序调用用另一种语言编写的例程或使用其服务”。
在 DragonFFI 的情况下,我们公开了一个库,允许从任何语言调用 C 函数并使用 C 结构体。基本上,我们想要能够做到这一点,比如在 Python 中
import pydffi
CU = pydffi.FFI().cdef("int puts(const char* s);");
CU.funcs.puts("hello world!")
或者,以更高级的方式,例如直接从 Python 使用 libarchive
import pydffi
pydffi.dlopen("/path/to/libarchive.so")
CU = pydffi.FFI().cdef("#include <archive.h>")
a = funcs.archive_read_new()
assert a
...
这篇博文介绍了相关工作、它们的缺点,以及如何使用 Clang/LLVM 来规避这些缺点,DragonFFI 的内部工作原理以及更进一步的想法。
该项目的代码可在 GitHub 上获取:https://github.com/aguinet/dragonffi。Linux/OSX x86/x64 平台提供 Python 2/3 轮子。Windows x64 平台提供 Python 3.6 轮子。在所有这些架构上,只需使用
$ pip install pydffi
并尽情体验它:)

请参阅以下内容以了解更多信息。

相关工作

libffi是提供 C 语言 FFI 的参考库。cffi 是围绕该库的 Python 绑定,它还使用PyCParser以便能够轻松地声明接口和类型。这两个库都有局限性,其中包括
  • libffi不支持 Linux x64 下的 Microsoft x64 ABI。添加新的 ABI 并不像看起来那么简单(手工编写 ABI,确保 ABI 正确等等),而编译器已经投入了大量精力来确保这些 ABI 的正确性。
  • PyCParser仅支持非常有限的 C 子集(没有包含文件、函数属性等等)。
此外,在 2014 年,来自 Apple 的 Jordan Rose 和 John McCall 在圣何塞的 LLVM 开发者会议上做了一个关于如何使用 Clang 来实现 C 互操作性的 演讲。这个演讲还展示了各种 ABI 问题,并且是 DragonFFI 起始阶段的灵感来源。

在某种程度上相关的是,曾在lldb工作的 Sean Callanan 在 2017 年圣何塞的 LLVM 开发者会议上做了一个关于如何使用 Clang/LLVM 的部分来实现某种eval()用于 C++ 的 演讲。我们可以从这个演讲中学到,像lldb 这样的调试器也必须能够调用任意 C 函数,并且使用调试信息和其他方法来解决这个问题(我们也这样做,见下文:)。

DragonFFI 基于 Clang/LLVM,并因此能够解决这些问题
  • 它使用 Clang 解析头文件,允许直接使用 C 库头文件,无需修改;
  • 它支持与 Clang/LLVM 一样多的调用约定和函数属性;
  • 作为额外的好处,Clang 和 LLVM 允许对 C 函数进行即时编译,而无需依赖系统上是否存在编译器(不过,你仍然需要系统 libc 的头文件,或者 Windows 下的 MSVCRT 头文件);
  • 这是一种用 Clang 和 LLVM 玩得开心的好方法!:)
让我们深入研究!

为 C 创建一个 FFI 库

支持 C ABI

C 函数总是针对特定的 C ABI 进行编译。C ABI 并非由官方 C 标准定义,而是与系统/架构相关的。这些 ABI 定义了许多内容,实现起来可能非常容易出错。

为了了解 ABI 如何变得复杂,让我们编译这段 C 代码

typedef struct {
short a;
int b;
} A;

void print_A(A s) {
printf("%d %d\n", s.a, s.b);
}

针对 Linux x64 编译后,它会生成以下 LLVM IR

target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"

@.str = private unnamed_addr constant [7 x i8] c"%d %d\0A\00", align 1

define void @print_A(i64) local_unnamed_addr {
%2 = trunc i64 %0 to i32
%3 = lshr i64 %0, 32
%4 = trunc i64 %3 to i32
%5 = shl i32 %2, 16
%6 = ashr exact i32 %5, 16
%7 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0), i32 %6, i32 %4)
ret void
}

这里发生的事情被称为结构体强制转换。为了优化一些函数调用,一些 ABI 会通过寄存器传递结构体值。例如,一个llvm::ArrayRef对象,它本质上是一个包含指针和大小的结构体(见 https://github.com/llvm-mirror/llvm/blob/release_60/include/llvm/ADT/ArrayRef.h#L51),会通过寄存器传递(虽然这种优化不受任何标准保证)。

重要的是要理解,ABI 是复杂的东西,我们不想自己重新做一遍这项工作,尤其是当 LLVM/Clang 已经知道如何做的时候。

寻找合适的类型抽象

我们想列出解析的 C 文件中使用的所有类型。为了实现这一目标,需要各种信息,包括
  • 函数类型及其调用约定
  • 对于结构体:字段偏移量和名称
  • 对于联合体/枚举:字段名称(和值)
一方面,我们在上一节中看到,LLVM IR 对于此目的而言过于底层(如底层虚拟机)。另一方面,Clang 的 AST 又过于高层。实际上,让我们打印上面代码的 Clang AST
[...]
|-RecordDecl 0x5561d7f9fc20 <a.c:1:9, line:4:1> line:1:9 struct definition
| |-FieldDecl 0x5561d7ff4750 <line:2:3, col:9> col:9 referenced a 'short'
| `-FieldDecl 0x5561d7ff47b0 <line:3:3, col:7> col:7 referenced b 'int'
我们可以看到,关于结构体布局(填充等等)没有信息。关于标准 C 类型的大小也没有信息。由于所有这些都取决于使用的后端,因此这些信息不在 AST 中并不奇怪。

合适的抽象似乎是 Clang 生成的 LLVM 元数据,用于生成 DWARF 或 PDB 结构体。它们提供了结构体字段偏移量/名称、各种基本类型描述以及函数调用约定。这正是我们需要的!对于上面的示例,它会生成以下内容(在 LLVM IR 级别,带有一些内联注释)

target triple = "x86_64-pc-linux-gnu"
%struct.A = type { i16, i32 }
@.str = private unnamed_addr constant [7 x i8] c"%d %d\0A\00", align 1

define void @print_A(i64) local_unnamed_addr !dbg !7 {
%2 = trunc i64 %0 to i32
%3 = lshr i64 %0, 32
%4 = trunc i64 %3 to i32
tail call void @llvm.dbg.value(metadata i32 %4, i64 0, metadata !18, metadata !19), !dbg !20
tail call void @llvm.dbg.declare(metadata %struct.A* undef, metadata !18, metadata !21), !dbg !20
%5 = shl i32 %2, 16, !dbg !22
%6 = ashr exact i32 %5, 16, !dbg !22
%7 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([...] @.str, i64 0, i64 0), i32 %6, i32 %4), !dbg !23
ret void, !dbg !24
}

[...]
; DISubprogram defines (in our case) a C function, with its full type
!7 = distinct !DISubprogram(name: "print_A", scope: !1, file: !1, line: 6, type: !8, [...], variables: !17)
; This defines the type of our subprogram
!8 = !DISubroutineType(types: !9)
; We have the "original" types used for print_A, with the first one being the
; return type (null => void), and the other ones the arguments (in !10)
!9 = !{null, !10}
!10 = !DIDerivedType(tag: DW_TAG_typedef, name: "A", file: !1, line: 4, baseType: !11)
; This defines our structure, with its various fields
!11 = distinct !DICompositeType(tag: DW_TAG_structure_type, file: !1, line: 1, size: 64, elements: !12)
!12 = !{!13, !15}
; We have here the size and name of the member "a". Offset is 0 (default value)
!13 = !DIDerivedType(tag: DW_TAG_member, name: "a", scope: !11, file: !1, line: 2, baseType: !14, size: 16)
!14 = !DIBasicType(name: "short", size: 16, encoding: DW_ATE_signed)
; We have here the size, offset and name of the member "b"
!15 = !DIDerivedType(tag: DW_TAG_member, name: "b", scope: !11, file: !1, line: 3, baseType: !16, size: 32, offset: 32)
!16 = !DIBasicType(name: "int", size: 32, encoding: DW_ATE_signed)
[...]

内部工作原理

DragonFFI 首先解析 Clang 在其生成的 LLVM IR 中包含的调试信息,并创建一个自定义类型系统来表示解析的 C 文件的各种函数类型、结构体、枚举和类型定义。这个自定义类型系统是出于两个原因创建的
  • 创建一个类型系统,只从元数据树中收集必要的信息(我们不需要全部调试信息)
  • 使 DragonFFI 库的公共头文件摆脱任何 LLVM 头文件(这样就无需使用库的全部 LLVM 头文件)
一旦我们有了这个类型系统,用于调用 C 函数的 DragonFFI API 就是这个样子

DFFI FFI([...]);
// This will declare puts as a function that returns int and takes a const
// char* as an argument. We could also create this function type by hand.
CompilationUnit CU = FFI.cdef("int puts(const char* s);", [...]);
NativeFunc F = CU.getFunction("puts");
const char* s = "hello world!";
void* Args[] = {&s};
int Ret;
F.call(&Ret, Args);

因此,基本上,一个指向返回数据的指针和一个void*数组被传递给 DragonFFI。每个void*value 是一个指向必须传递给底层函数的数据的指针。因此,拼图中最后一块缺失的部分是获取这个void*的数组(以及指向返回数据的指针)并调用puts的代码,因此像这样的函数

void call_puts(void* Ret, void** Args) {
*((int*)Ret) = puts((const char*) Args[0]);
}

我们称这些为“函数包装器”(多么原始!:)。这种签名的一个优点是,它是一个通用签名,可以在 DragonFFI 的实现中使用。假设我们能够在运行时编译这个函数,那么我们就可以像下面这样简单地调用它

typedef void(*puts_call_ty)(void*, void**);
puts_call_ty Wrapper = /* pointer to the compiled wrapper function */;
Wrapper(Ret, Args);

生成和编译像这样的函数是 Clang/LLVM 可以做到的。作为记录,这也是libffi的主要工作,通过手工生成必要的汇编代码。我们通过为每种不同的函数类型生成包装器来优化 DragonFFI 中这些包装器的数量。因此,针对puts实际生成的包装器实际上是这个样子

void __dffi_wrapper_0(int32_t( __attribute__((cdecl)) *__FPtr)(char *), int32_t *__Ret, void** __Args) {
*__Ret = (__FPtr)(*((char **)__Args[0]));
}

目前,所有必要的包装器在使用DFFI::cdefDFFI::compileAPI 时生成。唯一一个在即时调用时生成包装器的情况(当调用CompilationUnit::getFunction时)是用于可变参数。一个可能的改进是让用户选择是否希望针对每个声明的函数在即时调用时生成包装器。

Clang 的问题

Clang 存在一个主要问题,我们需要对其进行 hack 才能实现DFFI::cdef的功能:Clang 不会发出未使用的声明(即使使用-g -femit-all-decls).

以下是用这段 C 代码生成的示例

typedef struct {
short a;
int b;
} A;

void print_A(A s);
$ clang -S -emit-llvm -g -femit-all-decls -o - a.c |grep print_A |wc -l
0

生成的 LLVM IR 中不包含名为print_A的函数!我们目前使用的 hack 会解析 clang AST 并生成类似于以下内容的临时函数

void __dffi_force_decl_print_A(A s) { }

这会迫使 LLVM 生成一个名为__dffi_force_decl_print_A的空函数,并具有正确的参数(以及相关的调试信息)。

这就是为什么 DragonFFI 提供另一个 API,DFFI::compile。这个 API 不会强制在 LLVM IR 中包含仅声明的函数,并且只会公开最终自然出现在 LLVM IR 中的函数(经过优化之后)。

如果有人有更好的方法来解决这个问题,请告诉我们!

Python 绑定

Python 绑定是第一个被写出来的,仅仅是因为这是我最熟悉的“高级”语言。Python 提供了自己的挑战,但我们会留待另一篇博文来讨论。这些 Python 绑定是使用 pybind11 构建的,并提供了自己的 C 类型集。可以在 这里这里 找到许多关于可以实现什么的示例。

项目状态

DragonFFI 目前支持 Linux、OSX 和 Windows 操作系统,运行在 Intel 32 位和 64 位 CPU 上。Travis 用于持续集成,所有更改都会在所有这些平台上进行验证,然后再集成。

当 0.3 版本发布时,该项目将从 alpha 质量提升至 beta 质量(这将带来 Travis 和 Appveyor CI 集成以及对可变参数函数的支持)。一旦以下事情发生,该项目将被认为是稳定的
  • 存在用户和开发者文档!
  • 支持另一种外语(JS?Ruby?)
  • DragonFFI 主库 API 被认为是稳定的
  • 添加了一组数量可观的测试
  • 文件中的所有内容TODO都已完成:)

未来的各种想法

以下是我们对未来的各种有趣想法。我们还不知道它们何时会实现,但我们认为其中一些想法会非常不错。

解析嵌入式 DWARF 信息

由于 DragonFFI 的入口点是 DWARF 信息,我们可以想象解析来自嵌入 DWARF 信息的共享库的这些调试信息(或将它们提供在单独的文件中)。主要优点是,执行 FFI 所需的所有信息都包含在一个文件中,不再需要头文件。主要缺点是调试信息往往会占用大量空间(例如,DWARF 信息对于libarchive3.32 在发布模式下编译时,占用 1.8Mb,而原始二进制代码大小为 735Kb),这引出了下一个想法。

轻量级调试信息?

DWARF 标准允许定义大量信息,但我们并不需要所有的信息。我们可以考虑只嵌入必要的 DWARF 对象,即只包含调用共享库导出函数所需的类型。这里有一个实验的例子:https://github.com/aguinet/llvm-lightdwarf。这是一个 LLVM 优化流程,插入在优化流程的末尾,并解析元数据以仅保留与 DragonFFI 相关的元数据。更准确地说,它只保留与**导出**和**可见**函数相关的矮人元数据,以及相关的类型。它还保留全局变量的调试信息,即使这些信息在 DragonFFI 中尚未得到支持。它也做了一些非传统的事情,比如用 "_" 替换所有文件和目录以节省空间。有趣的事实是,为了做到这一点,它借鉴了来自 LLVM 位码“混淆器”的一些代码,该代码包含在最近的 Apple clang 版本中,用于对与 tvOS/iOS 应用程序一起发送的 LLVM 位码中的某些信息进行匿名化 (有关更多信息,请参见 http://lists.llvm.org/pipermail/llvm-dev/2016-February/095588.html)。

少说多做,让我们看看一些初步的结果 (在 Linux x64 上)
  • 在 libarchive 3.3.2 上,DWARF 从 1.8Mb 降至 536Kb,原始二进制代码大小为 735Kb
  • 在 zlib 1.2.11 上,DWARF 从 162Kb 降至 61Kb,原始二进制代码大小为 99Kb
在 LLVM 传递库的 README 中提供了用于重现此操作的说明。
我们可以得出结论,定义这种“轻量级”DWARF 格式可能是一个好主意。另一个可以做的事情是定义一个新的二进制格式,这样会更节省空间,但这样做也有缺点。
  • 如今,调试信息在所有平台上都得到了很好的支持:存在用于解析它们、从二进制文件中嵌入/提取它们的工具,等等。
  • 我们已经有了 DWARD 和 PDB:https://xkcd.com/927/
然而,尝试这样做仍然是一个不错的实验,可以找出节省的空间并看看是否值得!

最后说明一下,这两个想法也将有利于libffi,因为我们可以处理这些格式并创建libffi类型!

从最终语言 (如 Python) 到本机函数代码的 JIT 代码

嵌入一个完整的可工作的 C 编译器的优势在于,我们可以将来自最终语言粘合层的代码 JIT 到最终的 C 函数调用,从而限制这种粘合层代码的性能影响。
实际上,当从 Python 发出调用时,会发生以下情况
  • 根据函数类型,将参数从 Python 转换为 C
  • 从 DragonFFI 收集函数指针和包装器
  • 进行最终调用
所有这些过程基本上都涉及对被调用函数参数类型的循环,其中包含一个大型开关语句。这个循环生成void*表示 C 参数的值数组,然后将其传递给包装器。我们可以为函数类型 JIT 该循环的专用版本,内联已经编译的包装器,并在生成的 IR 之上应用经典优化,并直接从 Python 到 C 获得专门用于给定函数类型的直接转换代码。

我们正在探索的一个想法是将 easy::jit (你好,Quarkslab 的同事们!) 与 LLPE 相结合来实现这个目标。

减小 DragonFFI 库大小

DragonFFI 共享库静态地嵌入 LLVM 和 Clang 的已编译版本。最终共享库的大小约为 55Mb (剥离,在 Linux x64 下)。与 libffi 的 39Kb (同样剥离,Linux x64) 相比,这确实非常大!

以下是一些尝试减少这种占用的想法
  • 使用 (Thin) LTO 编译 DragonFFI、Clang 和 LLVM,并为 Clang 和 LLVM 设置隐藏可见性。这可能会导致从 Clang/LLVM 中删除 DragonFFI 未使用的代码。
  • 使 DragonFFI 更加模块化:- 仅包含 CodeGen 中处理 ABI 的部分的核心模块。如果类型和函数原型是“手动”定义的 (没有DFFI::cdef),这基本上是唯一需要的部分 (当然还有 LLVM) - 包含完整 clang 编译器的可选模块 (以提供DFFI::cdefDFFI::compileAPI)
即使有了这一切,似乎也很难匹配到 39Kb 的libffi,即使我们删除了cdef/编译API 从 DragonFFI 中删除。一如既往,为你的需求选择合适的工具 :)

结论

编写 DragonFFI 的第一个工作版本是一个有趣的实验,让我发现了 Clang/LLVM 的新部分:) 目前的目标是尝试实现第一个稳定版本 (见上文),并尝试各种提到的想法。

这是一条漫长的道路,所以请随时来#dragonffi在 FreeNode 上提出任何你可能有的问题/建议,(包括) 或者如果你想贡献!

致谢

感谢 Serge «sans paille» Guelton 就 Python 绑定进行的讨论,以及帮助我找到项目的名称 :) (这是最困难的任务之一)。也要感谢他、Fernand Lone-SangKévin Szkudlapski 对这篇博文的审阅!