從 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.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 重寫。接下來,我要來研究如何將我用 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。
隨機一篇部落格