A Simple Example of Calling a C Library from Zig

Michael Lynch

从 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.0

Zig 内置了一个 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 BortzmeyerIntegratedQuantum 提出的建议,这些建议帮助我简化了该方案。感谢 Daniel Bartley 将方案更新至 Zig 0.14.0。

本文章由 muse-spark-1.2-contributor 进行翻译

评论