Zig 構建系統解析 - 第二部分

  • 原文鏈接: https://zig.news/xq/zig-build-explained-part-2-1850

  • API 適配到 Zig 0.11.0 版本

註釋

從現在起,我將只提供一個最精簡的 build.zig,用來說明解決一個問題所需的步驟。如果你想了解如何將所有這些文件粘合到一個構建文件中,請閱讀本系列第一篇文章 [1]。

在命令行上編譯 C 代碼

Zig 有兩種編譯 C 代碼的方法,而且這兩種很容易混淆。

使用 zig cc

Zig 提供了 LLVM c 編譯器 clang。第一種是 zig cc 或 zig c++,它是與 clang 接近 1:1 的前端。由於我們無法直接從 build.zig 訪問這些功能(而且我們也不需要!),所以我將在快速的介紹這個主題。

如前所述,zig cc 是暴露的 clang 前端。您可以直接將 CC 變量設置爲 zig cc,並使用 zig cc 代替 gcc 或 clang 來使用 Makefiles、CMake 或其他編譯系統,這樣您就可以在已有的項目中使用 Zig 的完整交叉編譯體驗。請注意,這只是理論上的說法,因爲很多編譯系統無法處理編譯器名稱中的空格。解決這一問題的辦法是使用一個簡單的封裝腳本或工具,將所有參數轉發給 zig cc。

假設我們有一個由 main.c 和 buffer.c 生成的項目,我們可以用下面的命令行來構建它:

zig cc -o example buffer.c main.c

這將爲我們創建一個名爲 example 的可執行文件(在 Windows 系統中,應使用 example.exe 代替 example)。與普通的 clang 不同,Zig 默認會插入一個 -fsanitize=undefined,它將捕捉你使用的未定義行爲。

如果不想使用,則必須通過 -fno-sanitize=undefined 或使用優化的發佈模式(如 -O2)。

使用 zig cc 進行交叉編譯與使用 Zig 本身一樣簡單:

zig cc -o example.exe -target x86_64-windows-gnu buffer.c main.c

如你所見,只需向 -target 傳遞目標三元組,就能調用交叉編譯。只需確保所有外部庫都已準備好進行交叉編譯即可!

使用 zig build-exe 和其他工具

使用 Zig 工具鏈構建 C 項目的另一種方法與構建 Zig 項目的方法相同:

zig build-exe -lc main.c buffer.c

這裏的主要區別在於,必須明確傳遞 -lc 才能鏈接到 libc,而且可執行文件的名稱將從傳遞的第一個文件中推導出。如果想使用不同的可執行文件名,可通過 --name example 再次獲取示例文件。

交叉編譯也是如此,只需通過 -target x86_64-windows-gnu 或其他目標三元組即可:

zig build-exe -lc -target x86_64-windows-gnu main.c buffer.c

你會發現,使用這條編譯命令,Zig 會自動在輸出文件中附加 .exe 擴展名,並生成 .pdb 調試數據庫。如果你在此處傳遞 --name example,輸出文件也會有正確的 .exe 擴展名,所以你不必考慮這個問題。

用 build.zig 創建 C 代碼

那麼,我們如何用 build.zig 來構建上面的兩個示例呢?

首先,我們需要創建一個新的編譯目標:

// demo2.1
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        // 這塊調試了很久。最後的結論是根本不要寫
        // .root_source_file = .{ .path = undefined },
        .target = target,
        .optimize = optimize,
    });
    // 這塊調試了很久。API變了不會寫,着了很久的文檔和看了很久的代碼
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("main.c"), .flags = &.{} });
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("buffer.c"), .flags = &.{} });
    //exe.linkLibC();
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

然後,我們通過 addCSourceFile 添加兩個 C 語言文件:

exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("main.c"), .flags = &.{} });
exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("buffer.c"), .flags = &.{} });

第一個參數 addCSourceFile 是要添加的 C 或 C++ 文件的名稱,第二個參數是該文件要使用的命令行選項列表。

請注意,我們向 addExecutable 傳遞的是空值,因爲我們沒有要編譯的 Zig 源文件。

現在,調用 zig build 可以正常運行,並在 zig-out/bin 中生成一個可執行文件。很好,我們用 Zig 構建了第一個小 C 項目!

如果你想跳過檢查 C 代碼中的未定義行爲,就必須在調用時添加選項:

exe.addCSourceFile(.{.file = std.build.LazyPath.relative("buffer.c"), .flags = &.{"-fno-sanitize=undefined"}});

使用外部庫

通常情況下,C 項目依賴於其他庫,這些庫通常預裝在 Unix 系統中,或通過軟件包管理器提供。

爲了演示這一點,我們創建一個小工具,通過 curl 庫下載文件,並將文件內容打印到標準輸出:

#include <stdio.h>
#include <curl/curl.h>
static size_t writeData(void *ptr, size_t size, size_t nmemb, FILE *stream) {
    size_t written;
    written = fwrite(ptr, size, nmemb, stream);
    return written;
}
int main(int argc, char ** argv)
{
    if(argc != 2)
        return 1;
    char const * url = argv[1];
    CURL * curl = curl_easy_init();
    if (curl == NULL)
        return 1;
    curl_easy_setopt(curl, CURLOPT_URL, url);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeData);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, stdout);
    CURLcode res = curl_easy_perform(curl);
    curl_easy_cleanup(curl);
    if(res != CURLE_OK)
        return 1;
    return 0;
}

要編譯這個程序,我們需要向編譯器提供正確的參數,包括包含路徑、庫和其他參數。幸運的是,我們可以使用 Zig 內置的 pkg-config 集成:

 // demo2.2
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "downloader",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("download.c"), .flags = &.{} });
    exe.linkSystemLibrary("curl");
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

讓我們創建程序,並通過 URL 調用它

zig build
./zig-out/bin/downloader  https://mq32.de/public/ziggy.txt

配置路徑

由於我們不能在交叉編譯項目中使用 pkg-config,或者我們想使用預編譯的專用庫(如 BASS 音頻庫),因此我們需要配置包含路徑和庫路徑。

這可以通過函數 addIncludePath 和 addLibraryPath 來完成:

//demo 2.3
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("bass-player.c"),
        .flags = &.{}
        });
    exe.linkLibC();
    // 還是一步步看源代碼,找新的函數,addIncludeDir,addLibDir ->new function
    exe.addIncludePath(std.build.LazyPath.relative("bass/linux"));
    exe.addLibraryPath(std.build.LazyPath.relative("bass/linux/x64"));
    exe.linkSystemLibrary("bass");
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

addIncludePath 和 addLibraryPath 都可以被多次調用,以向編譯器添加多個路徑。這些函數不僅會影響 C 代碼,還會影響 Zig 代碼,因此 @cImport 可以訪問包含路徑中的所有頭文件。

每個文件的包含路徑

因此,如果我們需要爲每個 C 文件設置不同的包含路徑,我們就需要用不同的方法來解決這個問題: 由於我們仍然可以通過 addCSourceFile 傳遞任何 C 編譯器標誌,因此我們也可以在這裏手動設置包含目錄。

//demo2.4
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("multi-main.c"), .flags = &.{} });
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("multi.c"), .flags = &.{ "-I", "inc1" } });
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("multi.c"), .flags = &.{ "-I", "inc2" } });
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

上面的示例非常簡單,所以你可能會想爲什麼需要這樣的東西。答案是,有些庫的頭文件名稱非常通用,如 api.h 或 buffer.h,而您希望使用兩個共享頭文件名稱的不同庫。

構建 C++ 項目

到目前爲止,我們只介紹了 C 文件,但構建 C++ 項目並不難。你仍然可以使用 addCSourceFile,但只需傳遞一個具有典型 C++ 文件擴展名的文件,如 cpp、cxx、c++ 或 cc:

//demo2.5
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("main.c"), .flags = &.{} });
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("buffer.cc"), .flags = &.{} });
    exe.linkLibCpp();
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

如你所見,我們還需要調用 linkLibCpp,它將鏈接 Zig 附帶的 c++ 標準庫。

這就是構建 C++ 文件所需的全部知識,沒有什麼更神奇的了。

指定語言版本

試想一下,如果你創建了一個龐大的項目,其中的 C 或 C++ 文件有新有舊,而且可能是用不同的語言標準編寫的。爲此,我們可以使用編譯器標誌來傳遞 -std=c90 或 -std=c++98:

//demo2.6
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("main.c"),
        .flags = &.{"-std=c90"}
        });
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("buffer.cc"),
        .flags = &.{"-std=c++17"}
        });
    exe.linkLibCpp();
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

條件編譯

與 Zig 相比,C 和 C++ 的條件編譯方式非常繁瑣。由於缺乏惰性求值的功能,有時必須根據目標環境來包含 / 排除文件。你還必須提供宏定義來啓用 / 禁用某些項目功能。

Zig 編譯系統可以輕鬆處理這兩種變體:

//demo2.7
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
     const use_platform_io = b.option(bool, "platform-io", "Uses the native api instead of the C wrapper") orelse true;
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("print-main.c"),
        .flags = &.{}
        });
    if (use_platform_io) {
        exe.defineCMacro("USE_PLATFORM_IO", null);
        if (exe.target.isWindows()) {
            exe.addCSourceFile(.{
            .file = std.build.LazyPath.relative("print-windows.c"),
            .flags = &.{}
            });
        } else {
            exe.addCSourceFile(.{
            .file = std.build.LazyPath.relative("print-unix.c"),
            .flags = &.{}
            });
        }
    }
    exe.linkLibC();
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

通過 defineCMacro,我們可以定義自己的宏,就像使用 -D 編譯器標誌傳遞宏一樣。第一個參數是宏名,第二個值是一個可選項,如果不爲空,將設置宏的值。

有條件地包含文件就像使用 if 一樣簡單,你可以這樣做。只要不根據你想在構建腳本中定義的任何約束條件調用 addCSourceFile 即可。只包含特定平臺的文件?看看上面的腳本就知道了。根據系統時間包含文件?也許這不是個好主意,但還是有可能的!

編譯大型項目

由於大多數 C(更糟糕的是 C++)項目都有大量文件(SDL2 有 411 個 C 文件和 40 個 C++ 文件),我們必須找到一種更簡單的方法來編譯它們。調用 addCSourceFile 400 次並不能很好地擴展。

因此,我們可以做的第一個優化就是將 c 和 c++ 標誌放入各自的變量中:

//demo2.8
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    const flags = .{
        "-Wall",
        "-Wextra",
        "-Werror=return-type",
    };
    const cflags = flags ++ .{"-std=c99"};
    const cppflags = cflags ++ .{
        "-std=c++17",
        "-stdlib=libc++",
        "-fno-exceptions",
    };
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("main.c"),
        .flags = &cflags,
    });
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("buffer.cc"),
        .flags = &cppflags,
    });
    exe.linkLibC();
    exe.linkLibCpp();
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

這樣就可以在項目的不同組件和不同語言之間輕鬆共享標誌。

addCSourceFile 還有一個變種,叫做 addCSourceFiles。它使用的不是文件名,而是可編譯的所有源文件的文件名片段。這樣,我們就可以收集某個文件夾中的所有文件:

//demo2.9
const std = @import("std");
pub fn build(b: *std.build.Builder) !void {
    var sources = std.ArrayList([]const u8).init(b.allocator);
    // Search for all C/C++ files in `src` and add them
    {
        var dir = try std.fs.cwd().openIterableDir(".", .{ .access_sub_paths = true });
        var walker = try dir.walk(b.allocator);
        defer walker.deinit();
        const allowed_exts = [_][]const u8{ ".c", ".cpp", ".cxx", ".c++", ".cc" };
        while (try walker.next()) |entry| {
            const ext = std.fs.path.extension(entry.basename);
            const include_file = for (allowed_exts) |e| {
                if (std.mem.eql(u8, ext, e))
                    break true;
            } else false;
            if (include_file) {
                // we have to clone the path as walker.next() or walker.deinit() will override/kill it
                try sources.append(b.dupe(entry.path));
            }
        }
    }
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFiles(sources.items, &.{});
    exe.linkLibC();
    exe.linkLibCpp();
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

正如您所看到的,我們可以輕鬆搜索某個文件夾中的所有文件,匹配文件名並將它們添加到源代碼集合中。然後,我們只需爲每個文件集調用一次 addCSourceFiles,就可以大展身手了。

你可以制定很好的規則來匹配 exe.target 和文件夾名稱,以便只包含通用文件和適合你的平臺的文件。不過,這項工作留給讀者自己去完成。

注意:其他構建系統會考慮文件名,而 Zig 系統不會!例如,在一個 qmake 項目中不能有兩個名爲 data.c 的文件!Zig 並不在乎,你可以添加任意多的同名文件,只要確保它們在不同的文件夾中就可以了 😏。

編譯 Objective C

我完全忘了!Zig 不僅支持編譯 C 和 C++,還支持通過 clang 編譯 Objective C!

雖然不支持 C 或 C++,但至少在 macOS 上,你已經可以編譯 Objective C 程序並添加框架了:

//demo2.10
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("main.m"),
        .flags = &.{},
    });
    exe.linkFramework("Foundation");
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

在這裏,鏈接 libc 是隱式的,因爲添加框架會自動強制鏈接 libc。是不是很酷?

混合使用 C 和 Zig 源代碼

現在,是最後一章: 混合 C 代碼和 Zig 代碼!

爲此,我們只需將 addExecutable 中的第二個參數設置爲文件名,然後點擊編譯!

//demo2.11
const std = @import("std");
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "example",
        .root_source_file = .{ .path = "main.zig" },
        .target = target,
        .optimize = optimize,
    });
    exe.addCSourceFile(.{
        .file = std.build.LazyPath.relative("buffer.c"),
        .flags = &.{},
    });
    exe.linkLibC();
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

這就是需要做的一切!是這樣嗎?

實際上,有一種情況現在還沒有得到很好的支持: 您應用程序的入口點現在必須在 Zig 代碼中,因爲根文件必須導出一個 pub fn main(...) ....。 因此,如果你想將 C 項目中的代碼移植到 Zig 中,你必須將 argc 和 argv 轉發到你的 C 代碼中,並將 C 代碼中的 main 重命名爲其他函數(例如 oldMain),然後在 Zig 中調用它。如果需要 argc 和 argv,可以通過 std.process.argsAlloc 獲取。或者更好: 在 Zig 中重寫你的入口點,然後從你的項目中移除一些 C 語言!

結論

假設你只編譯一個輸出文件,那麼現在你應該可以將幾乎所有的 C/C++ 項目移植到 build.zig。

如果你需要一個以上的構建工件,例如共享庫和可執行文件,你應該閱讀下一篇文章,它將介紹如何在一個 build.zig 中組合多個項目,以創建便捷的構建體驗。

敬請期待!

參考資料

[1] 

第一篇文章: https://zigcc.github.io/post/2023/12/24/zig-build-explained-part1/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7tCo_2bGFcifR_O8dwrJGg