[Dart翻译]在Dart FFI中按值实现结构

深入了解API设计和本地调用惯例

原文地址:medium.com/dartlang/im…

原文作者:medium.com/@dacoharkes

发布时间:2021年6月9日 – 8分钟阅读

Dart 2.12版本中,我们扩展了C-interop功能,即Dart FFI,使其能够按值传递结构。这篇文章讲述了在Dart SDK中添加这一功能的过程。如果你对低级别的语言实现细节或对按值传递结构的平台惯例感兴趣,请继续阅读。

这篇文章讲述了开发API和确定ABI(应用二进制接口)的过程,以实现逐值结构功能。在两年的时间里,我们为这个功能(以及其他Dart FFI功能)工作,我们发现了许多需要改变API的限制。ABI之旅也同样有趣,说明你可以采取多种方法来解决一个棘手问题的细节。

C/C++中的按值传递和按引用传递

如果你不是每天都用C语言写代码,这里有一个简单的复习。假设我们在C语言中拥有以下结构和函数。

struct Coord {
  double x;
  double y;
  Coord* next;
};

Coord TranslateByValue(Coord coord) {
  coord.x = coord.x + 10.0;
  coord.y = coord.y + 10.0;
  return coord;
}

void TranslateByPointer(Coord* coord) {
  coord->x = coord->x + 10.0;
  coord->y = coord->y + 10.0;
}
复制代码

然后,我们可以在一些简单的C代码中使用这些函数。比方说,我们有一个局部变量c1。

Coord c1 = {10.0, 10.0, nullptr};
复制代码

如果我们将c1传递给TranslateByValue,那么参数是按值传递的,这使得被调用者有效地操作结构的一个副本。

Coord c2 = TranslateByValue(c1);
复制代码

这意味着c1保持不变。

然而,如果我们通过引用来传递c1,并附上一个指向包含c1的内存的指针,那么c1就会被就地改变。

TranslateByPointer(&c1);
复制代码

c1.x现在包含20.0。

API设计之旅

最初的Dart FFI原型已经支持向结构传递指针。然而,我们多次重新设计了API,以适应各种用例和限制。

最初的设计

我们最初的设计允许在内存中分配结构,将这些指针传递给C,并修改结构的字段。通过这种方法,Struct类扩展了Pointer类。

@struct
class Coordinate extends Pointer<Void> {
  @Double()
  double x;

  @Double()
  double y;

  @Pointer()
  Coordinate next;

  /// generated by @ffi.struct annotation
  external static int sizeOf();

  static Coordinate allocate({int count: 1}) =>
    allocate<Uint8>(count: count * sizeOf()).cast();
}
复制代码
final c = Coordinate.allocate()
  ..x = 10.0
  ..y = 10.0;
复制代码

Dart FFI用户编写了前面的片段,Dart FFI内部生成了sizeOf的实现以及x、y和next的getter和setter实现。

然而,两年前我们意识到这种设计有一个问题。通过让Coordinate扩展Pointer,我们无法区分Coordinate和Coordinate*。

区分Coordinate和Coordinate*的问题

我们在Dart FFI中引入了Struct,并让结构扩展这个类。

abstract class Struct<S extends NativeType> extends NativeType {
  final Pointer<S> addressOf;
}
复制代码

现在Dart中的Pointer<Coordinate>代表C语言中的Coordinate*,而Dart中的Coordinate代表C语言中的Coordinate。

这意味着下一个字段的类型是Pointer<Coordinate>,这使得@Pointer注解变得多余了。所以,我们摆脱了Pointer注解

class Coordinate extends Struct<Coordinate> {
  @Double()
  double x;

  @Double()
  double y;

  Pointer<Coordinate> next;
}
复制代码

因为我们现在将结构的指针表示为指针对象,我们开始在指针上使用分配工厂。

final c = Pointer<Coordinate>.allocate();
复制代码

为了访问Pointer<Coordinate>的字段,我们需要一个Coordinate类型的对象,因为该对象有x、y和next字段。为此,我们已经有了Pointer的load方法。

c.load<Coordinate>().x = 10.0;
复制代码

当然,在调用load时要写<Coordinate>是很冗长的。(对于从Pointer<Uint8>中加载一个Dart int来说,必须写一个类型参数也是如此)。我们之所以在加载时需要这个类型参数是为了向Dart类型系统指定这个方法的返回类型。

扩展方法的拯救

Dart 2.7引入了扩展方法。通过扩展方法,我们可以对Pointer<T>中的类型参数T进行模式匹配

extension StructPointer<T extends Struct> on Pointer<T> {
  external T get ref;
}
复制代码

对类型参数进行模式匹配使我们能够摆脱调用站点上的繁琐

c.ref.y = 10.0; // ref is pattern matched to be of type Coordinate.
复制代码

我们还可以使用扩展方法模式匹配来使Struct<S>的类型参数变得多余,将用户结构的定义改为。

class Coordinate extends Struct {
  @Double()
  double x;

  @Double()
  double y;

  Pointer<Coordinate> next;
}
复制代码

之前,类型参数<S>约束了Struct字段Pointer<S> addressOf。相反,我们将该字段改为扩展getter。

extension StructPointer<T extends Struct> on Pointer<T> {
  external Pointer<T> get addressOf;
}
复制代码

停止泄漏后备存储

当从C语言向Dart返回一个结构的值时,我们不想用C语言的内存来保存该结构,因为这样做会很慢,并且会给用户带来释放内存的负担。因此,结构被复制到TypedData中,坐标可以有一个指针或一个TypedData作为支持存储。

然而,在第一次重新设计中引入的addressOf,其类型是Pointer。这种类型表达了它总是由C语言内存支持的,但这不再是真的了。

所以,我们废弃了addressOf。

用于优化

最后一步是要求各种Dart FFI方法的调用,包括与结构相关的方法,都要有编译时常量类型参数

extension StructPointer<T extends Struct> on Pointer<T> {
  /// Must invoke with a constant [T].
  external T get ref;
}
复制代码

方法的调用使我们能够更好地优化代码,并且与C语言的语义更加一致。

请注意,这最后一个变化在Dart 2.12中触发了弃用通知,而这个变化在Dart 2.13中被强制执行。

ABI发现之旅

现在API已经到位,下一个问题是:当传递或返回值时,C希望这些结构在哪里?这就是所谓的应用二进制接口(ABI)。

文档

最自然的事情就是寻找文档了。ARM提供了Arm架构的程序调用标准 – ABI 2019Q1ARM 64位架构的程序调用标准(AArch64)。然而,x86和x64的官方文档从互联网上掉了下来,导致人们在搜索这些信息时,只能求助于非官方的镜像或反向工程

快速浏览一下文档,可以看到按值传递结构的各种位置。

  • 在多个CPU和FPU寄存器中。
  • 在堆栈中。
  • 一个指向副本的指针。该副本在调用者的堆栈框架上)。
  • 部分在CPU寄存器中,部分在堆栈中。

当在堆栈上传递时,还有一些问题,即所需的对齐方式是什么,所有未使用的CPU和FPU寄存器是否被封锁或回填。

当按值返回一个结构时,结构可以在两个位置传回。

  • 在多个CPU和FPU寄存器中。
  • 被调用者写入一个内存位置,在这种情况下,调用者传入一个指向该内存位置的指针。(这个保留的内存也在调用者的堆栈框架上)。

当一个指向结果位置的指针被传入时,还有一个问题是这是否与正常的CPU参数寄存器相冲突。

重构Dart FFI编译

这一初步调查足以让我们意识到,我们必须重新设计Dart FFI编译器管道的一部分。我们曾经重复使用Location类型,它最初是用来将Dart代码编译成汇编的。

然而,在Dart ABI中,我们从不使用非字对齐的堆栈位置或同时使用两个以上的寄存器。一个试图扩展Location类型以支持这些额外位置的实验最终导致了巨大的复杂差异,因为Location在Dart虚拟机中被大量使用。

因此,相反,我们替换了Dart FFI的编译管道

探索本地ABI

让我们来探索一下ABI。

假设我们有以下的结构和C函数签名。

struct Struct3Bytes {
  uint8_t a0;
  uint8_t a1;
  uint8_t a2;
};

Struct3Bytes MyFunction(Struct3Bytes, Struct3Bytes, Struct3Bytes,
                        Struct3Bytes, Struct3Bytes, Struct3Bytes,
                        Struct3Bytes, Struct3Bytes);
复制代码

各种ABI是如何在MyFunction中传递这些结构的?

在x64的Linux中,有6个CPU参数寄存器。该结构小到可以装入一个寄存器,所以前6个参数进入6个CPU参数寄存器,最后2个参数进入堆栈。堆栈参数被对齐为8字节。而且,返回值也适合放在一个CPU寄存器中(更大的例子)。

rdi int64 Compound(size: 3)
rsi int64 Compound(size: 3)
rdx int64 Compound(size: 3)
rcx int64 Compound(size: 3)
r8 int64 Compound(size: 3)
r9 int64 Compound(size: 3)
S+0 Compound(size: 3)
S+8 Compound(size: 3)
=>
rax int64 Compound(size: 3)
复制代码

那么,在Windows上会发生什么?

完全不同。Windows只有4个参数寄存器。然而,第一个寄存器被用来传递指向内存位置的指针,以写入返回值。而且,所有的参数都是通过指针传递给一个副本,因为结构体的大小是3字节,不是2的幂。

Locations on Windows
Pointer(rdx int64) Compound(size: 3)
Pointer(r8 int64) Compound(size: 3)
Pointer(r9 int64) Compound(size: 3)
Pointer(S+0 int64) Compound(size: 3)
Pointer(S+8 int64) Compound(size: 3)
Pointer(S+16 int64) Compound(size: 3)
Pointer(S+24 int64) Compound(size: 3)
Pointer(S+32 int64) Compound(size: 3)
=>
Pointer(rcx int64, ret:rax int64) Compound(size: 3)
复制代码

让我们看看另一个例子。Linux和Android上的ARM32。假设我们有以下结构和C函数签名。

struct Struct16Bytes {
  float a0;
  float a1;
  float a2;
  float a3;
};

Struct16Bytes MyFunction2(Struct16Bytes, float, Struct16Bytes);
复制代码

这些特定类型的结构被称为同质组合,因为它们只包含相同的元素。而且,最多有4个成员的同质浮动体的处理方式与普通结构不同。在这种情况下,Linux对结构中的各个浮点使用浮点寄存器

Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
s4 float
Multiple(s5 float, s6 float, s7 float, s8 float) Compound(size: 16)
=>
Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
复制代码

在Android上,使用SoftFP而不是HardFP。这意味着,浮点是以整数寄存器而非浮点寄存器传递的。此外,我们还为结果传入了一个指针。这导致了一种奇怪的情况,即第一个参数部分在整数寄存器中传递,部分在栈中传递。

M(r1 int32, r2 int32, r3 int32, S+0 int32) Compound(size: 16)
S+4 float
M(S+8 int32, S+12 int32, S+16 int32, S+20 int32) Compound(size: 16)
=>
P(r0 uint32) Compound(size: 16)
复制代码

任何一个环节出错都可能导致运行时的分段故障。因此,在每个硬件和操作系统的组合上,正确掌握ABI的所有角落情况是最重要的。

通过godbolt.org探索

由于文档非常简略,我们通过编译器探索器godbolt.org找出了许多角落的情况。编译器探索器并排显示C代码和编译的汇编。

image.png

前面的截图显示,在Windows x86上sizeof(Struct3Bytes)是3字节,因为3被移到了返回寄存器eax中。

当我们稍微改变结构时,我们可以检查大小是否仍然是3。

typedef struct {
  int16_t a0;
  int8_t a1;
} Struct3Bytes;
复制代码

大小不是3:mov eax, 4.因为int16必须是2字节对齐的,结构必须是2字节对齐的。这意味着在分配这些结构的数组时,每个结构后都有一个1字节的填充,以确保下一个结构是2字节对齐的。因此,在本地ABI中,该结构为4字节。

通过生成的测试进行探索

不幸的是,编译器探索器并不支持MacOS和iOS。因此,为了使手动探索更有效(并为该功能提供一个漂亮而庞大的测试套件),我们写了一个测试生成器。

主要的想法是以这样的方式生成测试,如果它们崩溃了,就可以用GDB来查看问题所在。

当遇到分段故障时,使其更容易看出问题所在的一种方法是使所有参数具有可预测的、容易识别的值。例如,下面的测试使用了连续的整数,因此这些整数值可以在寄存器和堆栈中很容易被发现。

void testPassStruct3BytesHomogeneousUint8x10() {
  final a0Pointer = calloc<Struct3BytesHomogeneousUint8>();
  final Struct3BytesHomogeneousUint8 a0 = a0Pointer.ref;
  final a1Pointer = calloc<Struct3BytesHomogeneousUint8>();
  // ...
  a0.a0 = 1;
  a0.a1 = 2;
  a0.a2 = 3;
  a1.a0 = 4; 
  // ...

  final result = passStruct3BytesHomogeneousUint8x10(
      a0, a1, a2, a3, a4, a5, a6, a7, a8, a9);
  print("result = $result");
  Expect.equals(465, result);

  calloc.free(a0Pointer);
  calloc.free(a1Pointer);
  // ...
}
复制代码

另一种使查找问题更容易的方法是在各处添加打印。例如,如果我们在从Dart到C的转换过程中没有遇到分段故障,但是我们设法把所有的参数都弄乱了,那么打印参数就有帮助。

int64_t
PassStruct3BytesHomogeneousUint8x10(Struct3BytesHomogeneousUint8 a0,
                                    Struct3BytesHomogeneousUint8 a1,
                                    // ...
                                   ) {
  std::cout << "PassStruct3BytesHomogeneousUint8x10"
            << "((" << static_cast<int>(a0.a0) << ", "
            << static_cast<int>(a0.a1) << ", " << static_cast<int>(a0.a2)
            << "), (" << static_cast<int>(a1.a0) << ", ") <<  // ...

  int64_t result = 0;
  result += a0.a0;
  result += a0.a1;
  result += a0.a2;
  result += a1.a0;
  // ...

  std::cout << "result = " << result << "\n";
  return result;
}
复制代码

添加一个测试就像在配置文件中添加一个函数类型一样简单。快速添加测试的能力导致了一个巨大的测试套件

果然,这个测试套件在原生ABI中又抓到了一个奇怪的案例–这次是在iOS-ARM64上。在ARM64的iOS上,堆栈中的非结构参数并没有按照字的大小对齐,而是按照它们自己的大小对齐。结构是按字大小对齐的,但如果结构是一个只有浮点的同质结构,那么它就按浮点的大小对齐

总结

我们的API设计和ABI发现之旅到此结束。通过良好的测试套件和彻底的代码审查,我们于2020年12月在Dart FFI的主分支上登陆了对按值传递结构的支持,并且在Dart 2.12中可以使用! 如果你对使用Dart FFI感兴趣,你可以从dart.dev上的C互操作文档开始。如果你对API设计和ABI发现有任何问题或意见,欢迎在下面留言。我们很乐意听到你的意见!

感谢Dart语言团队和Dart虚拟机团队(其他成员)对这项Dart FFI功能的贡献,感谢Kathy Walrath和Michael Thomsen对这篇博文的塑造


www.deepl.com 翻译

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享