0%

【文章翻譯】Implementing structs by value in Dart FFI

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

在 Dart FFI 中實作傳值結構體

Dart 2.12 版本中,我們擴充了 C 語言互通功能 Dart FFI,使其能夠 傳值結構體。本文將討論將此功能加入 Dart SDK 的過程。如果您對低階語言實作細節或平台傳值結構體的慣例感興趣,請继续阅读。

本文將討論開發 API 和找出傳值結構體功能的 ABI(應用程式二進位制介面)。在我们開發此功能(以及其他 Dart FFI 功能)的兩年中,我們發現了許多需要變更 API 的限制。ABI 的過程同樣有趣,它說明了您可以採取多種方法來確定一個難題的細節。

C/C++ 中的傳值和傳址

如果您不是每天都撰寫 C 語言程式碼,這裡快速回顧一下。假設我們在 C 語言中有以下結構體和函數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Coord {
double x;
double y;
struct Coord* next;
};

struct Coord TranslateByValue(struct Coord c) {
c.x += 10.0;
return c;
}

void TranslateByPointer(struct Coord* c) {
c->x += 10.0;
}

然後,我們可以在一些簡單的 C 語言程式碼中使用這些函數。假設我們有一個局部變數 c1

1
Coord c1 = {10.0, 10.0, nullptr};

如果我們將 c1 傳遞給 TranslateByValue,則參數是傳值的,這使得被調用者實際上操作結構體的副本:

1
Coord c2 = TranslateByValue(c1);

這表示 c1 保持不變。

但是,如果我們使用指向包含 c1 的記憶體的指標傳址 c1,則 c1 將被就地修改:

1
TranslateByPointer(&c1);

c1.x 現在包含 20.0

API 設計之旅

最初的 Dart FFI 原型 已經支援傳遞結構體的指標。但是,我們多次重新設計了 API,以適應各種使用案例和限制。

初始設計

我們的初始設計允許在記憶體中配置結構體,將這些指標傳遞給 C 語言,並修改結構體的欄位。透過這種方法,Struct 類別擴充了 Pointer 類別:

1
2
3
4
5
6
7
8
9
10
11
class Coordinate extends Pointer {
@Double()
external double x;

@Double()
external double y;

external Pointer next;

static int get sizeOf => sizeOf<Coordinate>();
}

Dart FFI 使用者撰寫上述程式碼片段,Dart FFI 內部會產生 sizeOf 的實作以及 xynext 的 getter 和 setter 實作。

但是,兩年前我們意識到 這個設計存在一個問題。透過讓 Coordinate 擴充 Pointer,我們無法區分 CoordinateCoordinate*

區分 Coordinate 和 Coordinate*

我們 引入Struct 到 Dart FFI,並讓結構體擴充此類別:

1
2
3
4
5
6
7
8
9
class Coordinate extends Struct {
@Double()
external double x;

@Double()
external double y;

external Pointer<Coordinate> next;
}

現在,Dart 中的 Pointer<Coordinate> 代表 C 語言中的 Coordinate*,Dart 中的 Coordinate 代表 C 語言中的 Coordinate

這表示 next 欄位的類型為 Pointer<Coordinate>,這使得 @Pointer 註釋變得冗餘。因此,我們 擺脫了 Pointer 註釋

1
2
3
4
5
6
7
8
9
class Coordinate extends Struct {
@Double()
external double x;

@Double()
external double y;

external Pointer&lt;Coordinate&gt; next;
}

因為我們現在將結構體的指標表示為 Pointer 物件,所以我們開始在 Pointer 上使用 allocate 工廠:

1
final c = Pointer&lt;Coordinate&gt;.allocate();

為了存取 Pointer<Coordinate> 的欄位,我们需要一个 Coordinate 類型的物件,因為該物件具有欄位 xynext。為此,我們已經在 Pointer 上使用了 load 方法。

1
c.load&lt;Coordinate&gt;().x = 10.0;

當然,在調用 load 時必須寫 <Coordinate> 很冗長。(必須撰寫類型引數與從 Pointer<Uint8> 中載入 Dart int 相同。)我们需要在 load 上使用此類型引數的原因是向 Dart 類型系統指定此方法的返回類型。

擴充方法來救援

Dart 2.7 引入了 擴充方法。透過擴充方法,我們可以在 Pointer<T> 中的類型引數 T 上進行 模式匹配

1
2
3
extension CoordinatePointer on Pointer&lt;Coordinate&gt; {
Coordinate get ref => load();
}

在類型引數上進行模式匹配使我們能夠 擺脫調用站點的冗長

1
c.ref.y = 10.0; // ref 被模式匹配為 Coordinate 類型。

我們還可以利用擴充方法模式匹配,使 Struct<S> 的類型引數變得冗餘,將使用者結構體的定義變更為:

1
2
3
4
5
6
7
8
9
class Coordinate extends Struct {
@Double()
external double x;

@Double()
external double y;

external Pointer&lt;Coordinate&gt; next;
}

之前,類型引數 <S> 限制了 Struct 欄位 Pointer<S> addressOf。相反,我們將欄位變更為擴充 getter:

1
2
3
extension&lt;T extends Struct&gt; on T {
Pointer&lt;T&gt; get addressOf =&gt; ...
}

停止洩漏後端儲存

從 C 語言向 Dart 返回傳值結構體時,我們不希望使用 malloc 分配 C 語言記憶體來儲存結構體,因為這樣速度會很慢,並且 會讓使用者承擔釋放記憶體的負擔。因此,我們將結構體複製到 TypedData 中,Coordinate 可以使用 PointerTypedData 作為後端儲存。

但是,在第一次重新設計中引入的 addressOf 的類型為 Pointer。此類型表示它始終由 C 語言記憶體支援,但現在已不再如此。

因此,我們 棄用了 addressOf

為了優化

最後一步是要求調用各種 Dart FFI 方法,包括與結構體相關的方法,都具有 編譯時常數類型引數

1
2
3
extension on Pointer&lt;T extends NativeType&gt; {
T load&lt;T extends NativeType&gt;() =&gt; ...
}

調用方法允許我們更好地優化程式碼,並且更符合 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 函數簽名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Struct3Bytes {
int8_t a;
int16_t b;
};

struct Struct3Bytes MyFunction(
struct Struct3Bytes,
struct Struct3Bytes,
struct Struct3Bytes,
struct Struct3Bytes,
struct Struct3Bytes,
struct Struct3Bytes,
struct Struct3Bytes,
struct Struct3Bytes);

各種 ABI 如何在 MyFunction 中傳遞這些結構體?

在 x64 上的 Linux 中,有 6 個 CPU 引數暫存器。結構體足夠小,可以放入單個暫存器中,因此前 6 個引數進入 6 個 CPU 引數暫存器,最後 2 個進入堆疊。堆疊引數以 8 位元組對齊。並且,返回值也適合放入 CPU 暫存器中(更大的範例)。

1
2
3
mov     rdi, rsi
mov rdx, rcx
...

那麼,在 Windows 上會發生什麼?

完全不同。Windows 只有 4 個引數暫存器。但是,第一個暫存器用於傳遞指向要將返回值寫入的記憶體位置的指標。並且,所有引數都透過指向副本的指標傳遞,因為結構體的大小為 3 位元組,這不是 2 的冪。

1
2
3
mov     qword ptr [rsp+32], r9  ; arg8
mov qword ptr [rsp+24], r8 ; arg7
...

讓我們看看另一個範例:Linux 和 Android 上的 ARM32。假設我們有以下結構體和 C 函數簽名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Struct16BytesHomogenousFloat {
float a0;
float a1;
float a2;
float a3;
};

struct Struct16BytesHomogenousFloat MyFunction(
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat,
struct Struct16BytesHomogenousFloat);

這些特定類型的結構體稱為同類複合類型,因為它們只包含相同的元素。並且,具有最多 4 個成員的同類浮點數的處理方式與普通結構體不同。在這種情況下,Linux 對結構體中的各個浮點數使用浮點暫存器

1
2
3
vmov            s1, r2
vmov s0, r1
...

在 Android 上,使用 SoftFP 而不是 HardFP。這表示浮點數在整數暫存器中傳遞,而不是在浮點暫存器中傳遞。此外,我們正在傳遞一個指向結果的指標。這導致了一種 奇怪的情況,其中第一個引數部分在整數暫存器中傳遞,部分在堆疊上传遞。

1
2
3
str             r2, [r0, #4]
str r1, [r0]
...

如果其中任何一個出錯,都可能導致執行階段出現區段錯誤。因此,在每個硬體和作業系統組合上正確處理 ABI 的所有邊角案例至關重要。

透過 godbolt.org 探索

由於文件非常簡潔,我們透過編譯器資源管理器 godbolt.org 找出許多邊角案例。編譯器資源管理器 並排顯示 C 程式碼和編譯後的組合語言:

A screenshot of godbolt.com showing that the assembly code for sizeof(Struct3Bytes) is returning 3 in the return register.

上述螢幕截圖顯示,在 Windows x86 上,sizeof(Struct3Bytes) 為 3 位元組,因為 3 被移入返回暫存器 eax 中。

當我們 稍微變更 結構體時,我們可以檢查大小是否仍然為 3:

1
2
3
4
struct Struct3Bytes {
uint8_t a;
int16_t b;
};

大小不是 3:mov eax, 4。由於 int16 必須為 2 位元組對齊,因此結構體必須為 2 位元組對齊。這表示,在配置這些結構體的陣列時,每個結構體之後有一個 1 位元組的填補,以確保下一個結構體為 2 位元組對齊。因此,此結構體在本機 ABI 中為 4 位元組。

透過產生的測試探索

不幸的是,編譯器資源管理器不支援 MacOS 和 iOS。因此,為了使手動探索更有效率(並且為此功能提供一個良好且龐大的測試套件),我們編寫了一個測試產生器。

主要思想是以這樣的方式產生測試:如果測試崩潰,則可以使用 GDB 來查看問題所在。

使發現 segmentation fault 時更容易看到問題的一種方法是使所有引數都具有可預測且易於識別的值。例如,以下測試使用連續的整數,以便可以在暫存器和堆疊上輕鬆發現這些整數值:

1
2
3
4
5
6
7
8
9
struct Struct8Bytes {
int16_t a;
int16_t b;
int32_t c;
};

int main() {
struct Struct8Bytes s = {1, 2, 3};
}

另一種簡化尋找問題的方法是在各處加入列印語句。例如,如果我們在從 Dart 轉換到 C 語言的過程中沒有遇到 segmentation fault,但我們設法損壞了所有引數,則列印引數會有所幫助:

1
2
3
4
5
6
7
8
struct Struct1ByteInt8 {
int8_t a0;
};

void MyFunction(struct Struct1ByteInt8 a0, int8_t a1) {
printf("%" PRId8 "\n", a0.a0);
printf("%" PRId8 "\n", a1);
}

加入測試就像在 組態檔案 中加入函數類型一樣簡單。快速加入測試的能力導致了一個 龐大的測試套件

果然,這個測試套件在本機 ABI 中發現了另一個奇怪的案例 - 這次是在 iOS-ARM64 上。在 ARM64 上的 iOS 上,堆疊上的非結構體引數不是以字大小對齊,而是以其自身的大小對齊。結構體以字大小對齊,除非結構體是只包含浮點數的同類結構體,則 它以浮點數的大小對齊

總結

到此結束了我們對 API 設計和 ABI 探索的旅程。透過良好的測試套件和全面的程式碼審查,我們在 2020 年 12 月在 master 分支上 加入了對在 Dart FFI 中傳遞傳值結構體的支援,並且它在 Dart 2.12 中可用!如果您有興趣使用 Dart FFI,則可以從 dart.dev 上的 C 語言互通文件 開始。如果您對 API 設計和 ABI 探索有任何問題或意見,請在下方留言。我們很想聽到您的聲音!

感謝 Dart 語言團隊和(其餘的)Dart 虛擬機器團隊對此 Dart FFI 功能的貢獻,也感謝 Kathy Walrath 和 Michael Thomsen 對此部落格文章的塑造!


在 Dart FFI 中實作傳值結構體 最初發佈在 Dart 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。