从 Zig 调用 C 库的简单示例
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 第 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);
}上面的改动将我原本用 Zig 实现的 add 函数替换为一个包装器,用于调用 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。
随机一篇博客