A Simple Example of Calling a C Library from Zig

Michael Lynch

從 Zig 呼叫 C 函式庫的簡單範例

Zig 是一個全新、獨立開發的低階程式語言。它是 C 語言的現代化重新詮釋,試圖保留 C 語言所有的效能優勢,同時善用過去 30 年來在工具與語言設計上的進步。

由於 Zig 的設計目標是取代 C,因此其核心功能之一就是讓你能從 Zig 應用程式中呼叫 C 函式庫。我找不到任何展示 Zig 的 C interop(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 interop 的底層機制,但沒有提供任何完整的範例。

上述兩篇「擴充 C 專案」教學的主要限制之一,是它們假設你已經知道如何將複雜的 Makefile 移植到 Zig 建置系統。兩篇教學都像是說:「嘿,看看這個令人困惑的 100 行 Makefile。瞧,現在它變成了一個同樣令人困惑的 100 行 build.zig 檔案!」卻沒有真正解釋是如何做到的(除非你去看這支90 分鐘的影片)。

身為一個完全的 Zig 新手,我不想學習如何將大型 Makefile 轉換為 Zig 建置系統。相反地,我想嘗試一個簡單的範例,只用 Zig 來建置 C 應用程式的一部分,而不是將整個應用程式移植到 Zig 原生的建置系統。

建立一個簡單的 C 應用程式

在其他 Zig + C 範例中讓我卡關的地方是,C 程式碼過於複雜,以至於掩蓋了從 Zig 呼叫 C 程式碼的基本機制。

為了讓 Zig 的 C interop 功能更簡單易懂,我決定建立一個簡單的 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;
}

好的,如果一切正常,我應該能夠使用 gcc 這個標準的 C 編譯器來編譯這個應用程式:

$ 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 重寫。接下來,我要來研究如何將我用 Zig 實作的 add 替換為原生的 C 實作。

此階段的完整範例已在 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 arithmetic 函式庫,但我還沒有實際呼叫這個函式庫。為了完成這個範例,我需要在 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 程式碼中加入一個錯誤:

// arithemtic.c

int add(int x, int y) {
  return x + y - 1; // Intentionally return incorrect results.
}

如果我的 Zig 應用程式真的有呼叫 C,那麼我的 Zig 單元測試應該會失敗,因為底層的 C 程式碼現在是錯誤的。

我執行了單元測試來看看會發生什麼事:

$ 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 函式中的錯誤。

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。

原文由 Michael Lynch 發布

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