使用 Zig 调用 C 代码:字符串
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 应用程序通过“空终止符”,即字符串最后一个字符之后一个值为 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 的字节数量。
要调用 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 的值一定是以空字符结尾的。
我可以通过从现有数组创建一个切片并错误地声明它是以空字符结尾的来欺骗 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)
输出相当直观。s 和 sCopy 是不同的变量类型,尽管它们包含相同的数据。这是因为 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 树莓派上运行相同代码时,它会减小到 size=8。
我知道 s 是一个大小在编译时就已知的数组,而 sCopy 是一个大小直到运行时才知道的切片。即便如此,Zig 知道切片的长度,因此应该知道它占用多少字节,但我不知道该如何查询该信息。
更新:reddit 上的 /u/paulstelian97 解释说切片是“胖指针”,其中包含内存地址和长度。现在我明白为什么它是普通指针大小的两倍了,但我仍然不知道如何让 Zig 给出切片以字节为单位的大小。
总结
Zig 使得向 C 代码传入字符串以及从 C 接收字符串输出变得容易。
Zig 的类型系统比 C 更强,这使得开发者能够为 C 库编写 Zig 原生的包装,从而提供比 C 中更强大的防止内存损坏的检查。
延伸阅读
随机一篇博客