从 Zig 调用 C 库的简单示例
原文由 Michael Lynch 于 发布,订阅该博客
Zig 是一门全新、独立开发的底层编程语言。它是对 C 的现代化重构,试图在保留 C 全部性能优势的同时,吸纳过去 30 年来工具链和语言设计上的进步。
由于 Zig 的设计目标是取代 C,其一等特性之一就是允许在 Zig 应用中直接调用 C 库。我找不到任何展示 Zig C 互操作功能的简单示例,于是决定自己写一个。
关于在 Zig 中调用 C 的现有资料
我找到了几篇介绍如何在 Zig 中调用 C 代码的文章。它们都提供了有用的信息,但要么过于抽象,要么描述的场景比我想实现的要复杂得多:
- “C/C++/Zig” 作者 Loris Cro
- 这是一篇很棒的教程,但内容比较复杂。它不仅仅是调用 C 库,而是在研究如何用 Zig 构建一个庞大的 C 应用,然后编写一个既能调用原有 C 代码、又能被 C 代码调用的新函数。
- 我从这篇教程中学到了很多,但在更简单的场景下如何用 Zig 调用 C,却很难从这个系列中找到答案。
- 这篇教程还是基于 Zig 0.8.1 编写的,其中的代码在 Zig 0.14.0 上已无法编译。
- “Extending a C Project with Zig” (2023)
- 这是一篇较新的文章,因此在当前版本的 Zig 上仍可编译。
- 与上面的教程类似,本文讨论的是如何编译一个大型复杂的 C 应用,因此我很难理解如何将这些经验应用到更简单的场景中。
- ziglearn Chapter 4 - Working with C
- 本文介绍了 Zig 与 C 互操作的底层机制,但没有提供任何完整的示例。
上述两篇“扩展 C 项目”教程的一个主要局限在于,它们默认你已经知道如何将复杂的 Makefile 移植到 Zig 构建系统中。它们的做法都是:“看,这个让人困惑的 100 行 Makefile。变一下,现在成了一个同样让人困惑的 100 行 build.zig 文件!”至于怎么变,却没有真正解释(除非你去看这个90 分钟的视频)。
作为一个完全的 Zig 新手,我并不想学习如何将大型 Makefile 转换为 Zig 构建系统。相反,我想尝试一个简单的例子,只用 Zig 来构建 C 应用的一部分,而不是将整个应用都移植到 Zig 的原生构建系统上。
创建一个简单的 C 应用
在其他 Zig + C 示例中让我感到困惑的是,C 代码过于复杂,以至于掩盖了从 Zig 调用 C 代码的基本原理。
为了更清晰地展示 Zig 的 C 互操作功能,我决定创建一个简单的 C 应用和库。
这是我的第一个 C 头文件:
// arithmetic.h
int add(int x, int y);这是它的实现:
// arithmetic.c
#include "arithmetic.h"
int add(int x, int y) {
return x + y;
}我没有做任何花哨的操作,目标就是尽可能保持简单。
最后,我来创建一个测试程序来调用 add 函数:
// main.c
#include <stdio.h>
#include "arithmetic.h"
int main(void)
{
int x = 5;
int y = 16;
int z = add(x, y);
printf("%d + %d = %d\n", x, y, z);
return 0;
}如果一切正常,我应该可以用标准 C 编译器 gcc 来编译这个应用:
$ gcc arithmetic.c main.c -o ./bin/example
$ ./bin/example
5 + 16 = 21很好,一切正常!
这一阶段的完整示例已发布在 GitHub 上。
将编译器切换为 Zig
到目前为止,这还是一个纯 C 项目,我还没有用到 Zig。
接下来,我来安装 Zig。安装 Zig 有几种方式,但我用的是 Nix,因为它是我最近最喜欢的包管理器。我只是用 Nix 来安装,所以如果你还没有加入 Nix 的阵营,完全可以用其他方式安装 Zig 0.14.0。
我在项目中添加了如下 flake.nix 文件,它会将 Zig 0.14.0 拉取到我的环境中:
{
description = "Dev environment for zig-c-simple";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
# 0.14.0
zig-nixpkgs.url = "github:NixOS/nixpkgs/f6db44a8daa59c40ae41ba6e5823ec77fe0d2124";
};
outputs = { self, flake-utils, zig-nixpkgs }@inputs :
flake-utils.lib.eachDefaultSystem (system:
let
zig-nixpkgs = inputs.zig-nixpkgs.legacyPackages.${system};
in
{
devShells.default = zig-nixpkgs.mkShell {
packages = [
zig-nixpkgs.zig
];
shellHook = ''
echo "zig" "$(zig version)";
'';
};
});
}接下来,我运行 nix develop,就可以看到 Zig 0.14.0 已经在我的项目环境中可用了:
$ nix develop
zig 0.14.0Zig 内置了一个 C 编译器,可以作为 gcc 的直接替代品。我来重新执行之前的编译,只不过不再调用 gcc,而是调用 zig cc:
$ zig cc arithmetic.c main.c -o ./bin/example
$ ./bin/example
5 + 16 = 21不错,一切依然正常,现在我已经在用 Zig 进行编译了。不过我还没有编写任何 Zig 代码,接下来就做这件事。
这一阶段的完整示例已发布在 GitHub 上。
创建等效的 Zig 应用
为了创建 Zig 应用,我会使用 zig init-exe,它会生成一个 Zig 可执行程序的样板代码:
$ zig init-exe
info: Created build.zig
info: Created src/main.zig我将 src/main.zig 替换为以下内容,这样就创建了一个与上文中的 main.c等效的 Zig 应用。
// src/main.zig
const std = @import("std");
fn add(x: i32, y: i32) i32 {
// TODO: Instead of reimplementing this in Zig, call the C version.
return x + y;
}
pub fn main() !void {
const x: i32 = 5;
const y: i32 = 16;
var z: i32 = add(x, y);
const stdout_file = std.io.getStdOut().writer();
var bw = std.io.bufferedWriter(stdout_file);
const stdout = bw.writer();
try stdout.print("{d} + {d} = {d}\n", .{ x, y, z });
try bw.flush();
}
test "test add" {
try std.testing.expectEqual(@as(i32, 21), add(5, 16));
}运行后,我得到了与 C 版本相同的输出:
$ zig build run
5 + 16 = 21不错,但我的目标是从 Zig 调用 C 代码,而不仅仅是用 Zig 重写一遍。接下来,我要研究如何用原生的 C 实现来替换 Zig 版的 add 函数。
这一阶段的完整示例已发布在 GitHub 上。
将 Zig 应用链接到原生 C 库
好了,到目前为止都还是基础的“hello, world!”级别的内容。现在,我们来到了之前一直困扰我的部分:从 Zig 调用原生 C 代码。
首先,我来重新组织文件,将 Zig 代码和 C 代码分开。这是新的目录结构:
c-src/
arithmetic.c
arithmetic.h
main.c
src/
main.zig
build.zig接着,我调整 build.zig,让 Zig 应用能够访问我的 C 源文件:
const exe = b.addExecutable(.{
.name = "zig-c-simple",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.addIncludePath(b.path("c-src")); // Look for C source files对于 Zig 的单元测试构建目标,我也做同样的修改:
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
unit_tests.addIncludePath(b.path("c-src")); // Look for C source files现在我已经调整了 Zig 的构建配置,使其可以访问 C 语言的算术库,但还没有真正调用该库。要完成这个示例,我需要对 src/main.zig 文件做如下修改:
// src/main.zig
const arithmetic = @cImport({
@cInclude("arithmetic.c");
});
fn add(x: i32, y: i32) i32 {
return arithmetic.add(x, y);
}上述修改将 add 函数的 Zig 原生实现替换成了一个包装器,用于调用 arithmetic.c 文件中原生的 C 语言 add 函数。
现在到了见分晓的时刻。一切能否如预期般编译和运行?
$ zig build run
5 + 16 = 21太好了,成功了!
我再来试试单元测试:
$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 1/1 tests passed
test success
└─ run test 1 passed 1ms MaxRSS:1M
└─ zig test Debug native success 2s MaxRSS:201M单元测试也通过了。一切看起来都很顺利!
Zig 真的在调用 C 吗?
我曾尝试从其他编程语言调用 C 代码,从来没有这么轻松过。我担心自己是不是哪里弄错了,Zig 并没有真正调用我的 C 代码,于是我故意在 C 代码中引入了一个 bug:
// arithemtic.c
int add(int x, int y) {
return x + y - 1; // Intentionally return incorrect results.
}如果我的 Zig 应用真的在调用 C,那么由于底层 C 代码现在是错误的,Zig 单元测试就应该失败。
我运行了单元测试,看看会发生什么:
$ zig build test --summary all
test
└─ run test 0/1 passed, 1 failed
error: 'main.test.test add' failed: expected 21, found 20
/nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/lib/zig/std/testing.zig:103:17: 0x1048bad in expectEqualInner__anon_421 (test)
return error.TestExpectedEqual;
^
/tmp/tmp.LCH7Soiq5V/src/main.zig:25:5: 0x1048c7f in test.test add (test)
try std.testing.expectEqual(@as(i32, 21), add(5, 16));
^
error: while executing test 'main.test.test add', the following test command failed:
/tmp/tmp.LCH7Soiq5V/.zig-cache/o/ab02faa31a7c5067027f9f3a2e4ce1f9/test --seed=0x4b485ae6 --cache-dir=/tmp/tmp.LCH7Soiq5V/.zig-cache --listen=-
Build Summary: 1/3 steps succeeded; 1 failed; 0/1 tests passed; 1 failed
test transitive failure
└─ run test 0/1 passed, 1 failed
└─ zig test Debug native success 1s MaxRSS:257M
error: the following build command failed with exit code 1:
/tmp/tmp.LCH7Soiq5V/.zig-cache/o/159f82a7dcc12c245254f0919e2ecdf2/build /nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/bin/zig /nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/lib/zig /tmp/tmp.LCH7Soiq5V /tmp/tmp.LCH7Soiq5V/.zig-cache /home/mike/.cache/zig --seed 0x4b485ae6 -Zabdc51211068b123 test --summary all很好!测试如预期失败了,报错为 expected 21, found 20。单元测试正确地发现了我引入到 C 语言 add 函数中的 bug。
Zig 会跟随头文件的引用吗?
这个方案中另一个出乎意料好用的地方是,我可以通过 .h 文件来引用函数。我已经很久没有做 C/C++ 开发了,但我记得通过 .c 文件导入是不行的,所以在 Zig 中如此简单让我感到很惊讶。
为了验证 Zig 是否在作弊,我在 arithmetic.h 头文件中添加了一个新函数和一个预处理宏:
// arithmetic.h
#define INCREMENT_AMOUNT 1
int increment(int x);然后我在 arithmetic.c 中添加这个新函数的定义:
// arithemtic.c
int increment(int x) {
return x + INCREMENT_AMOUNT;
}最后,我在 src/main.zig 文件中为这个新函数添加一个简单的单元测试:
test "test increment" {
try std.testing.expectEqual(@as(i32, 6), arithmetic.increment(5));
}如果 Zig 忽略了 C 头文件中的 #include 指令,那么这次应该会出现编译错误,或者测试不再通过。来运行一下新测试:
$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 2/2 tests passed
test success
└─ run test 2 passed 830us MaxRSS:1M
└─ zig test Debug native success 2s MaxRSS:201M测试通过了!这表明 Zig 具备一个很方便的特性:会跟随 C 源代码中的 #include 引用,这让从 Zig 调用 C 代码比我用过的任何其他语言都要轻松。
总结
本文展示了我能想到的、用于演示如何从 Zig 调用 C 代码的最简单示例。
利用这种方法,你可以将 C 库的一部分移植到 Zig 构建系统,然后用 Zig 来调用该库。
源代码
完整源代码已发布在 GitHub 上。我将其按项目的不同阶段进行了拆分:
感谢 Stéphane Bortzmeyer 和 IntegratedQuantum 提出的建议,这些建议帮助我简化了该方案。感谢 Daniel Bartley 将方案更新至 Zig 0.14.0。
随机一篇博客
评论
登录后参与讨论