Using Zig to Call C Code: Strings

Michael Lynch

使用 Zig 呼叫 C 程式碼:字串

Zig 是一個全新的開源程式語言,設計目的是取代 C。我還是 Zig 的初學者,因此正嘗試透過使用 Zig 重寫現有 C 應用程式的部分功能來學習這個語言。

我在使用 Zig 時遇到的第一個挑戰之一就是理解字串。我找不到關於 Zig 字串在呼叫 C 程式碼時如何運作的詳細文件,因此我將分享我的發現,希望對其他想使用 Zig 呼叫 C 的人有所幫助。

C 字串簡要入門

在說明如何將 Zig 字串傳遞給 C 程式碼之前,我會先介紹一下 C 中字串的運作方式的背景知識。

在 C 語言中,字串的型別是 char*

char* example = "Hi, I'm a C string!";

C 字串只是一連串的字元。型別 char* 表示該變數儲存的是該序列中第一個字元的記憶體位址。

令人困擾的是,char* 型別並不會告訴 C 編譯器字串在何處結束。相反地,C 應用程式會以「空字元終止符(null terminator)」來表示字串的結尾,也就是字串最後一個字元之後、值為 0 的一個位元組。

如果我呼叫會回傳字串長度的 strlen,它會告訴我字串中的字元數量,不包含空字元終止符:

printf("strlen=%lu\n", strlen("hi"));
strlen=2

儘管 strlen 顯示字串長度為二,sizeof 函式卻告訴我字串以位元組為單位的大小是三:

printf("sizeof=%lu\n", sizeof("hi"));
sizeof=3

sizeof 對一個兩個字元的字串回傳 3 的原因,是 C 在字串結尾隱含地加入了一個空字元終止符。

我可以透過印出字串中每個字元的值來證明空字元終止符確實存在:

char s[] = "hi";
printf("%s=[%c, %c, %d]\n", s, s[0], s[1], s[2]);
hi=[h, i, 0]

C 和 C++ 中的字串極為脆弱,經常導致安全性漏洞和應用程式當機。由於編譯器不知道字串的長度,C 程式碼很容易意外覆寫屬於應用程式中其他變數的記憶體。

基本的 Zig 字串

Zig 被設計為 C 的現代替代品,因此它肩負著艱鉅的任務。它必須修正 C 的錯誤,同時還要讓與既有的 C 程式碼互通變得容易。

以下是一個簡單的 Zig 應用程式,用來示範字串的運作方式:

const std = @import("std");

pub fn main() !void {
    const s = "hello";
    std.debug.print("s is type {s}\n", .{@typeName(@TypeOf(s))});
}
$ zig run src/strings.zig
s is type *const [5:0]u8

在 Zig 中,字串 "hello" 的型別是 *const [5:0]u8

這個變數型別包含了大量資訊,因此我將從右至左來解釋。

u8 是 Zig 表示位元組的方式,也就是一個 8 位元的無號數值。

:0 表示這是一個以哨兵值 0 結尾的緩衝區(也就是以空字元結尾的緩衝區)。

const 表示這是一個常數變數,因此我在指派值之後就無法再變更它。

* 表示這個值是指向某個記憶體位址的指標。

這裡有兩個重要的重點:

  • Zig 知道字串變數的長度。
  • Zig 會在字串後面加上空字元終止符。

Zig 的空字元終止符是為了與 C 相容

在 C 中,空字元終止符實際上決定了字串的長度。如果你有一個五個字元的字串,並將第 3 個字元替換為空位元組,那麼每個 C 字串函式現在都會將該字串視為只有兩個字元長。

// Truncating a string in C.
char s[] = "hello";
s[2] = '\0';
printf("s=[%s]\n", s);
printf("len=%lu\n", strlen(s));
s=[he]
len=2

在 Zig 中,字串仍然有空字元終止符,但就我所知,空字元終止符對 Zig 的 API 來說是沒有意義的。當我印出字串或檢查其長度時,空字元不會造成任何差異。無論空字元出現在何處,Zig 都知道字串的真實長度。

// Trying to null-terminate a string in the middle in Zig.
const s = [_:0]u8{ 'h', 'e', 0, 'l', 'o' };
std.debug.print("s={s}\n", .{s});
std.debug.print("s.len={d}\n", .{s.len});
s=[helo]
s.len=5

理解 Zig 中的空字元終止

len 欄位不包含空字元

當你在 Zig 中宣告字串時,它有一個 len 屬性來回報字串的長度。有趣的是,這個長度不包含空字元終止字元:

const s = "hi";
std.debug.print("s.len={d}\n", .{s.len});
s.len=2

我知道字串的長度實際上是三,因為它還必須儲存空位元組。我可以透過檢查字串以位元組為單位的大小來證明這一點:

const s = "hi";
// Dereference the string s with s.*. Otherwise, Zig would report the size of
// the pointer.
std.debug.print("@sizeOf={d}\n", .{@sizeOf(@TypeOf(s.*))});
@sizeOf=3

真正的陣列邊界在哪裡?

在大多數語言中,如果陣列的大小是 N,你只能讀取到陣列中索引 N - 1 的位置。舉例來說,在大小為 2 的陣列中,你可以讀取位置 0,也可以讀取位置 1,但讀取位置 2 則會導致執行階段當機或產生未定義行為。

Zig 的一般陣列行為與其他語言類似,但 Zig 的哨兵終止陣列允許你讀取超出陣列預期結尾的一個位置。這是因為,儘管 .len 聲稱的長度如此,實際上還有一個額外的位置用來存放哨兵值:

var s = "hi";
std.debug.print("s[{d}]={d}\n", .{ s.len, s[s.len] });
s[2]=0

起初,我不太確定自己是否正確理解了這個行為。或許我實際上是讀取了超出陣列邊界的範圍,只是記憶體中的下一個位元組剛好是 0

並非如此,Zig 文件確認了你可以在長度為 N 的哨兵終止陣列中讀取索引 N 的位置:

哨兵終止切片允許存取索引為 len 的元素。

https://ziglang.org/documentation/0.11.0/#Sentinel-Terminated-Slices

我也可以嘗試讀取索引 N + 1 的位置,看看 Zig 會拒絕編譯這段程式碼:

var s = "hi";
std.debug.print("s[{d}]={d}\n", .{ s.len + 1, s[s.len + 1] });
src/corrupt-string.zig:5:59: error: index 3 outside array of length 2 +1 (sentinel)
    std.debug.print("s[{d}]={d}\n", .{ s.len + 1, s[s.len + 1] });
                                                    ~~~~~~^~~

編譯器訊息明確指出該變數的長度為 2 + 1,因此它為空字元終止符保留了一個額外的位置。

你無法覆寫空字元終止符

Zig 文件聲稱 [:0] 語法表示 Zig 「保證在以長度為索引的元素位置上有一個哨兵值。」

我決定透過覆寫以空字元結尾的緩衝區中的空字元來測試這一點:

var s = [_:0]u8{ 'h', 'e', 'l', 'l', 'o' };
s[s.len] = 'A';

上述程式碼可以正常編譯並執行,沒有出現錯誤。

這令人驚訝。Zig 不是應該要保證空字元終止的特性嗎?

結果發現,Zig 是透過忽略覆寫空字元終止字元的指派操作來保證空字元終止的。因此,即使我的程式碼指示 Zig 覆寫空字元終止字元,Zig 也會直接忽略該指派,從而保留空字元終止的特性。

var s = [_:0]u8{ 'h', 'e', 'l', 'l', 'o' };
s[s.len] = 'A';  // This line has no effect.
std.debug.print("s[{d}]={d}\n", .{ s.len, s[s.len] });
s[5]=0

將 Zig 字串傳遞給 C 程式碼

既然我已經理解 Zig 字串的運作方式,我就可以示範如何利用 Zig 的空字元終止保證,更安全地呼叫 C 程式碼。

想像一下,我想從 Zig 程式碼中呼叫一個接受字串參數的 C 函式。作為範例,我將示範如何呼叫 C 標準函式庫中的 strlen 函式

// Returns the length of the C string str.
size_t strlen(const char* str);

strlen 是一個簡單的函式。它會遍歷字串尋找第一個 0 位元組,並回傳不是 0 的位元組數量。

要在 Zig 中呼叫 C 標準函式庫於其 string.h 標頭檔中所宣告的函式,我會像這樣匯入該標頭檔:

const cString = @cImport({
    @cInclude("string.h");
});

這樣我就可以在 Zig 中像這樣呼叫 strlen 函式:

cString.strlen("hello!");

了解這一點後,讓我為 C 的 strlen 函式建立一個 Zig 原生的包裝函式:

fn strlen(str: [:0]const u8) usize {
    return cString.strlen(str);
}

我將參數定義為 [:0]const u8,以確保任何 Zig 呼叫者傳入的都是以空字元結尾的字串。這提供了一定程度的安全性,是 C 所無法做到的,因為 Zig 會強制要求空字元終止,而 C 無法保證任何字串是以空字元結尾的。

以下是一個完整的 Zig 程式來示範此行為:

// src/wrap-strlen.zig

const std = @import("std");

const cString = @cImport({
    @cInclude("string.h");
});

fn strlen(str: [:0]const u8) usize {
    return cString.strlen(str);
}

pub fn main() !void {
    const s = "hi";
    std.debug.print("strlen({s})={d}\n", .{ s, strlen(s) });
}

我可以使用 zig run 來編譯並執行這個範例。因為我匯入了來自 C 標準函式庫的標頭檔 string.h,所以需要傳遞 --library c 來告訴 Zig 編譯器連結至 libc。

$ zig run src/wrap-strlen.zig --library c
strlen(hi)=2

太好了,結果如我所預期。我正在使用 C 的 strlen 函式來計算我從 Zig 建立的字串長度。

如果我嘗試傳入一個不是以空字元結尾的字串,會發生什麼事?

const notNullTerminated = [_]u8{ 'h', 'i' };
std.debug.print("strlen({s})={d}\n", .{ notNullTerminated, strlen(&notNullTerminated) });
$ zig run src/wrap-strlen-badcall.zig --library c
src/wrap-strlen-badcall.zig:13:71: error: expected type '[:0]const u8', found '*const [2]u8'
    std.debug.print("strlen({s})={d}\n", .{ notNullTerminated, strlen(&notNullTerminated) });
                                                                      ^~~~~~~~~~~~~~~~~~
src/wrap-strlen-badcall.zig:13:71: note: destination pointer requires '0' sentinel
src/wrap-strlen-badcall.zig:7:16: note: parameter type declared here
fn strlen(str: [:0]const u8) usize {
               ^~~~~~~~~~~~

太好了!

Zig 在編譯時期就捕捉到了這個錯誤,因為它發現 strlen 需要一個以空字元結尾的字串([:0]u8),但 notNullTerminated 的型別是 const [2]u8,因此缺少了空字元終止的特性。

空字元終止並非嚴格的保證

儘管 Zig 編譯器讓呼叫 C 字串函式變得更安全,它仍然無法完全保證型別為 [:0]u8 的字串一定是以空字元結尾的。

我可以透過從現有陣列建立切片,並錯誤地宣告它是以空字元結尾的,來欺騙 Zig:

var a = [_]u8{ 'h', 'e', 'l', 'l', 'o' };
// This is a lie, as this slice isn't really null-terminated.
const s = a[0..2 :0];
std.debug.print("strlen({s})={d}\n", .{ s, strlen(s) });

如果我使用預設設定編譯這段程式碼,Zig 會加入除錯檢查,並在執行時期引發 panic:

$ zig run src/wrap-strlen-evil.zig --library c
thread 43926 panic: sentinel mismatch: expected 0, found 108
/home/mike/ustreamer/src/wrap-strlen-evil.zig:13:16: 0x21fefc in main (wrap-strlen-evil)
    const s = a[0..2 :0];
               ^
/nix/store/bg6hyfzr1wzk795ii48mc1v15bswcvp3-zig-0.11.0/lib/zig/std/start.zig:574:37: 0x220477 in main (wrap-strlen-evil)
            const result = root.main() catch |err| {
                                    ^
???:?:?: 0x7ffff7dffacd in ??? (libc.so.6)
Unwind information for `libc.so.6:0x7ffff7dffacd` was not available, trace may be incomplete

錯誤訊息 sentinel mismatch: expected 0, found 108 表示在切片中索引 2 的位置,Zig 預期會找到一個 0 位元組(空字元終止符),但卻找到了字元 'l'(以 108 表示)。

如果我在發行模式下編譯程式碼,該程式將會產生未定義行為

$ zig run src/wrap-strlen-evil.zig --library c -O ReleaseFast
strlen(he)=5

Zig 將字串 s 視為有兩個字元("he"),因為它知道切片的長度。C 沒有長度資訊,因此它會搜尋第一個空位元組,這會導致程式讀取超出為陣列 a 所配置的記憶體。視執行環境而定,這個程式可能會因為讀取超出已配置的記憶體而以存取違規的方式當機。

在 Zig 中接收 C 字串

好的,我已經示範了如何為接受字串作為輸入參數的 C 函式撰寫 Zig 包裝函式。

那麼,我該如何處理會產生 C 字串作為輸出的 C 函式呢?

作為範例,我將示範如何為 C 的 strdup 函式建立 Zig 包裝函式:

// Returns a pointer to a null-terminated byte string, which is a duplicate of
// the string pointed to by str1. The returned pointer must be passed to free to
// avoid a memory leak.
char* strdup(const char* str1);

如註解所述,strdup 接受一個字串,為該字串的複本配置記憶體,然後將字串內容複製到新配置的緩衝區中。

用於 C 的 strdup 函式的 Zig 包裝函式可能看起來像這樣:

fn strdup(str: [:0]const u8) ![* :0]u8 { ... }

該函式接受一個以空字元結尾的字串作為輸入。回傳值是 ![* :0]u8,其組成如下:

u8 表示一個無號位元組。

[*:0] 表示一個長度未知的空字元終止切片。這與 [:0] 不同,後者表示 Zig 知道切片的長度,並可透過 .len 屬性取得。對於型別為 [*:0] 的情況,Zig 不知道其長度,只知道它以空位元組結尾。

! 表示這個函式可能會回傳錯誤。之所以可能發生錯誤,是因為底層的 C strdup 函式可能會失敗。

既然我已經解釋了這個函式的輸入與輸出,讓我來嘗試實作它:

fn strdup(str: [:0]const u8) ![* :0]u8 {
    return cString.strdup(str) orelse error.OutOfMemory;
}

以下是一個從 Zig 呼叫 strdup 包裝函式的完整範例:

const std = @import("std");

const cString = @cImport({
    @cInclude("string.h");
});

fn strdup(str: [:0]const u8) ![* :0]u8 {
    return cString.strdup(str) orelse error.OutOfMemory;
}

pub fn main() !void {
    const s = "hi";
    std.debug.print("s    = [{s}] (type={}, size={d}, len={d})\n", .{ s, @TypeOf(s), @sizeOf(@TypeOf(s.*)), s.len });
    const sCopy = try strdup(s);
    defer std.c.free(sCopy);
    std.debug.print("sCopy= [{s}] (type={}, size={d}, len={d})\n", .{ sCopy, @TypeOf(sCopy), @sizeOf(@TypeOf(sCopy)), std.mem.len(sCopy) });
}

以下是輸出結果:

$ zig run src/wrap-strdup.zig --library c
s    = [hi] (type=*const [2:0]u8, size=3, len=2)
sCopy= [hi] (type=[*:0]u8, size=8, len=2)

輸出結果相當直觀。儘管 ssCopy 包含相同的資料,它們卻是不同的變數型別。這是因為 sCopy 代表由 C 所配置的記憶體,而 s 則代表由 Zig 所配置和管理的記憶體。

Zig 回報 sCopy 的大小為 8,因為這是在 64 位元系統上指標的大小。Zig 無法告訴我記憶體緩衝區的大小,因為它是由 C 所配置的,且無法將緩衝區大小資訊傳達給 Zig。

使用 Zig 管理的緩衝區來改進包裝函式

我已經能夠從 Zig 呼叫 C 的 strdup 函式,但我的解決方案有點雜亂。我的 Zig 包裝函式暴露了其內部實作細節,因為它回傳的是一個長度未知的切片,而且呼叫者必須使用 std.c.free 來釋放緩衝區,而不是使用 Zig 原生的記憶體配置器。

如果我想將 C 的實作細節抽象化,讓它看起來像一個普通的原生 Zig 函式,該怎麼做?

以下是我對 strdup 包裝函式的修改版本,它會配置一個 Zig 原生的緩衝區,並將結果字串複製到新的緩衝區中:

fn strdup(allocator: std.mem.Allocator, str: [:0]const u8) ![:0]u8 {
    const cCopy: [* :0]u8 = cString.strdup(str) orelse return error.OutOfMemory;
    defer std.c.free(cCopy);

    // Create a Zig slice of the C buffer that's length-aware.
    const zCopy: [:0]u8 = std.mem.span(cCopy);

    // Allocate a new null-terminated Zig-managed slice and copy in the contents
    // of zCopy.
    return allocator.dupeZ(u8, zCopy);
}

原始實作與這個修改版本之間有幾個變更。

首先,這個函式接受一個 std.mem.Allocator,這是一個用於配置記憶體的 Zig 物件。在 Zig 中,你不會像在 C 中那樣,隨時透過呼叫全域記憶體配置函式來配置記憶體。為了避免隱藏的記憶體配置,函式會接受 std.mem.Allocator 型別,這讓呼叫者能清楚知道該函式可能會配置記憶體。

接下來,新的 strdup 實作不再回傳 C 的記憶體緩衝區並要求呼叫者以非標準的方式負責釋放,而是回傳一個普通的 Zig 切片。strdup 的呼叫者仍然有責任釋放該切片,但他們可以用標準的方式,透過 allocator.free 來完成。

這個修改版本的缺點是它比前一個版本慢。我配置並複製了兩次記憶體:一次在 C 中,另一次在 Zig 中。如果這是對效能要求極高的程式碼,或許我會為了提升速度而讓呼叫者直接處理 C 陣列,但一般來說,我更傾向於讓 Zig 來管理我的記憶體。

以下是使用新包裝函式實作的完整範例:

const std = @import("std");

const cString = @cImport({
    @cInclude("string.h");
});

fn strdup(allocator: std.mem.Allocator, str: [:0]const u8) ![:0]u8 {
    const cCopy: [* :0]u8 = cString.strdup(str) orelse return error.OutOfMemory;
    defer std.c.free(cCopy);
    const zCopy: [:0]u8 = std.mem.span(cCopy);
    return allocator.dupeZ(u8, zCopy);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    defer _ = gpa.deinit();

    const s = "hi";
    std.debug.print("s    = [{s}] (type={}, size={d}, len={d})\n", .{ s, @TypeOf(s), @sizeOf(@TypeOf(s.*)), s.len });
    const sCopy = try strdup(allocator, s);
    defer allocator.free(sCopy);
    std.debug.print("sCopy= [{s}] (type={}, size={d}, len={d})\n", .{ sCopy, @TypeOf(sCopy), @sizeOf(@TypeOf(sCopy)), sCopy.len });
}
$ zig run src/wrap-strdup2.zig --library c
s    = [hi] (type=*const [2:0]u8, size=3, len=2)
sCopy= [hi] (type=[:0]u8, size=16, len=2)

我無法理解為什麼 sCopy 的大小是 16。無論我在切片中儲存多少字元,其大小都保持不變,但如果我在 32 位元的 ARM Raspberry Pi 上執行相同的程式碼,大小則會變為 size=8

我知道 s 是一個大小在編譯時期就為 Zig 所知的陣列,而 sCopy 則是一個直到執行時期 Zig 才知道大小的切片。儘管如此,Zig 知道切片的長度,因此應該知道它佔用了多少位元組,但我卻想不出如何查詢該資訊。

更新:Reddit 上的 /u/paulstelian97 解釋說,切片是「fat pointers(胖指標)」,其中包含一個記憶體位址和一個長度。現在我明白為什麼它的大小是一般指標的兩倍,但我仍然不知道如何讓 Zig 告訴我切片以位元組為單位的大小。

總結

Zig 讓將字串傳入 C 程式碼以及從 C 接收字串輸出變得很容易。

Zig 的型別系統比 C 更強大,這讓開發者能夠為 C 函式庫撰寫 Zig 原生的包裝函式,從而提供比 C 更健全的記憶體毀損檢查。

延伸閱讀


感謝 efjimm 提供建議,幫助我簡化了這個解決方案。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯