Using Zig to Call C Code: Strings

Michael Lynch

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

原文由 Michael Lynch 發布,訂閱此部落格

Zig 是一個旨在取代 C 的全新開源程式語言。我還是 Zig 新手,所以正嘗試透過用 Zig 重寫現有 C 應用程式的部分功能來學習這個語言。

我在 Zig 上遇到的第一個挑戰之一就是理解字串。我找不到關於在呼叫 C 程式碼時 Zig 字串如何運作的詳細文件,因此決定分享我的發現,希望能對其他想用 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 說這個字串的長度是 2,但 sizeof 卻告訴我這個字串的大小是 3 個位元組:

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 表示這是一個以哨兵值(sentinel)為 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 的位置:

哨兵結尾的 slice 允許存取索引為 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 的位元組數量。

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

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(¬NullTerminated) });
$ 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(¬NullTerminated) });
                                                                      ^~~~~~~~~~~~~~~~~~
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 的值一定是以空字元結尾。

我可以透過從現有陣列建立一個 slice,並錯誤地將其宣告為以空字元結尾來欺騙 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 的意思是,在這個 slice 的索引 2 位置,Zig 預期會找到一個 0 位元組(空字元結尾),但卻找到了字元 'l'(其數值為 108)。

如果我用 release 模式編譯這段程式碼,程式就會產生未定義行為

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

Zig 認為字串 s 有兩個字元("he"),因為它知道這個 slice 的長度。C 沒有長度資訊,所以它會去搜尋第一個空位元組,這會導致程式讀取到為陣列 a 配置的記憶體之外。取決於執行環境,這個程式可能會因為讀取超過已配置的記憶體而以存取違規(access violation)的方式當機。

在 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] 表示一個長度未知的空字元結尾 slice。這與 [:0] 不同,後者表示 Zig 知道該 slice 的長度,並可透過 .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 配置的,C 無法將緩衝區大小的資訊傳達給 Zig。

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

我成功地在 Zig 中呼叫了 C 的 strdup 函式,但我的解法有點雜亂。我的 Zig 包裝函式暴露了實作細節,因為它回傳了一個長度未知的 slice,而且呼叫者必須使用 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 slice。strdup 的呼叫者仍然有責任釋放這個 slice,但他們可以用標準的方式,透過 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。無論我在 slice 中儲存多少字元,大小都維持不變,但在我的 32 位元 ARM Raspberry Pi 上執行相同的程式碼時,大小卻會變成 size=8

我知道 s 是一個大小在編譯時期就為 Zig 所知的陣列,而 sCopy 則是一個直到執行時期 Zig 才知道其大小的 slice。即便如此,Zig 既然知道 slice 的長度,理應也知道它佔用了多少位元組,但我就是想不出該如何查詢這個資訊。

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

總結

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

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

延伸閱讀


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

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

留言