zig 构建系统解析 - 第三部分

从现在起,我将只提供一个最精简的 build.zig,用来说明解决一个问题所需的步骤。如果你想了解如何将所有这些文件粘合到一个构建文件中,请阅读本系列第一篇文章

复合项目

有很多简单的项目只包含一个可执行文件。但是,一旦开始编写库,就必须对其进行测试,通常会编写一个或多个示例应用程序。当人们开始使用外部软件包、C 语言库、生成代码等时,复杂性也会随之上升。

本文试图涵盖所有这些用例,并将解释如何使用 build.zig 来编写多个程序和库。

软件包

译者:此处代码和说明,需要 zig build-exe –pkg-begin,但是在 0.11 已经失效。所以删除。

但 Zig 也知道库这个词。但我们不是已经讨论过外部库了吗?

在 Zig 的世界里,库是一个预编译的静态或动态库,就像在 C/C++ 的世界里一样。库通常包含头文件(.h 或 .zig)和二进制文件(通常为 .a、.lib、.so 或 .dll)。

这种库的常见例子是 zlib 或 SDL。

与软件包相反,链接库的方式有两种

  • (静态库)在命令行中传递文件名
  • (动态库)使用 -L 将库的文件夹添加到搜索路径中,然后使用 -l 进行实际链接。

在 Zig 中,我们需要导入库的头文件,如果头文件在 Zig 中,则使用包,如果是 C 语言头文件,则使用 @cImport。

工具

如果我们的项目越来越多,那么在构建过程中就需要使用工具。这些工具通常会完成以下任务:

生成一些代码(如解析器生成器、序列化器或库头文件) 捆绑应用程序(例如生成 APK、捆绑应用程序……)。 创建资产包 … 有了 Zig,我们不仅能在构建过程中利用现有工具,还能为当前主机编译我们自己(甚至外部)的工具并运行它们。

但我们如何在 build.zig 中完成这些工作呢?

添加软件包

添加软件包通常使用 LibExeObjStep 上的 addPackage 函数。该函数使用一个 std.build.Pkg 结构来描述软件包的外观:

1
2
3
4
5
pub const Module = struct {
    builder: *Build,
    source_file: LazyPath,
    dependencies: std.StringArrayHashMap(*Module),
};

我们可以看到,它有 2 个成员:

source_file 是定义软件包根文件的 FileSource。这通常只是指向文件的路径,如 vendor/zig-args/args.zig dependencies 是该软件包所需的可选软件包片段。如果我们使用更复杂的软件包,这通常是必需的。

这是个人建议:我通常会在 build.zig 的顶部创建一个名为 pkgs 的结构/名称空间,看起来有点像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const args = b.createModule(.{
    .source_file = .{ .path = "libs/args/args.zig" },
    .dependencies = &.{},
});

const interface = b.createModule(.{
    .source_file = .{ .path = "libs/interface.zig/interface.zig" },
    .dependencies = &.{},
});

const lola = b.createModule(.{
    .source_file = .{ .path = "src/library/main.zig" },
    .dependencies = &.{},
});
const pkgs = .{
    .args = args,

    .interface = interface,

    .lola = lola,
};

随后通过编译步骤 exe,把模块加入进来。函数 addModule 的第一个参数 name 是模块名称

1
2
exe.addModule("lola",pkgs.lola);
exe.addModule("args",pkgs.args);

添加库

添加库相对容易,但我们需要配置更多的路径。

注:在上一篇文章中,我们已经介绍了大部分内容,但现在还是让我们快速复习一遍:

假设我们要将 libcurl 链接到我们的项目,因为我们要下载一些文件。

系统库

对于 unixoid 系统,我们通常可以使用系统软件包管理器来链接系统库。方法是调用 linkSystemLibrary,它会使用 pkg-config 自行找出所有路径:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
//demo 3.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 = "example",
        .root_source_file = .{ .path = "main.zig" },
        .target = target,
        .optimize = optimize,
    });
    exe.linkLibC();
    exe.linkSystemLibrary("curl");
    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);
}

对于 Linux 系统,这是链接外部库的首选方式。

本地库

不过,您也可以链接您作为二进制文件提供商的库。为此,我们需要调用几个函数。首先,让我们来看看这样一个库是什么样子的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
./vendor/libcurl
include
│ └── curl
│ ├── curl.h
│ ├── curlver.h
│ ├── easy.h
│ ├── mprintf.h
│ ├─── multi.h
│ ├── options.h
│ ├── stdcheaders.h
│ ├── system.h
│ ├── typecheck-gcc.h
│ └── urlapi.h
├── lib
│ ├── libcurl.a
│ ├── libcurl.so
│ └── ...
├─── bin
│ └── ...
└──share
    └── ...

我们可以看到,vendor/libcurl/include 路径包含我们的头文件,vendor/libcurl/lib 文件夹包含一个静态库(libcurl.a)和一个共享/动态库(libcurl.so)。

动态链接

要链接 libcurl,我们需要先添加 include 路径,然后向 zig 提供库的前缀和库名:(todo 代码有待验证,因为 curl 可能需要自己编译自己生成 static lib)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
//demo 3.3
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "test",
        .root_source_file = .{ .path = "main.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(exe);
    exe.linkLibC();
    exe.addIncludePath(.{ .path = "vendor/libcurl/include" });
    exe.addLibraryPath(.{ .path = "vendor/libcurl/lib" });
    exe.linkSystemLibraryName("curl");
}

addIncludePath 将文件夹添加到搜索路径中,这样 Zig 就能找到 curl/curl.h 文件。注意,我们也可以在这里传递 “vendor/libcurl/include/curl”,但你通常应该检查一下你的库到底想要什么。

addLibraryPath 对库文件也有同样的作用。这意味着 Zig 现在也会搜索 “vendor/libcurl/lib “文件夹中的库。

最后,linkSystemLibrary 会告诉 Zig 搜索名为 “curl “的库。如果你留心观察,就会发现上面列表中的文件名是 libcurl.so,而不是 curl.so。在 unixoid 系统中,库文件的前缀通常是 lib,这样就不会将其传递给系统。在 Windows 系统中,库文件的名字应该是 curl.lib 或类似的名字。

静态链接

当我们要静态链接一个库时,我们必须采取一些不同的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
pub fn build(b: *std.build.Builder) void {
     const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "test",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(exe);
    exe.linkLibC();
    exe.addIncludeDir("vendor/libcurl/include")
    exe.addObjectFile("vendor/libcurl/lib/libcurl.a")
    exe.addIncludePath(.{ .path = "vendor/libcurl/include" });
    exe.addLibraryPath(.{ .path = "vendor/libcurl/lib" });
}

对 addIncludeDir 的调用没有改变,但我们突然不再调用带 link 的函数了?你可能已经知道了: 静态库实际上就是对象文件的集合。在 Windows 上,这一点也很相似,据说 MSVC 也使用了相同的工具集。

因此,静态库就像对象文件一样,通过 addObjectFile 传递给链接器,并由其解包。

注意:大多数静态库都有一些传递依赖关系。在我编译 libcurl 的例子中,就有 nghttp2、zstd、z 和 pthread,我们需要再次手动链接它们:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 示例片段
pub fn build(b: *std.build.Builder) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "test",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(exe);
    exe.linkLibC();
    exe.addIncludePath(.{ .path = "vendor/libcurl/include" });
    exe.addLibraryPath(.{ .path = "vendor/libcurl/lib" });
    exe.linkSystemLibrary("nghttp2")
    exe.linkSystemLibrary("zstd")
    exe.linkSystemLibrary("z")
    exe.linkSystemLibrary("pthread")
}

我们可以继续静态链接越来越多的库,并拉入完整的依赖关系树。

通过源代码链接库

不过,我们还有一种与 Zig 工具链截然不同的链接库方式:

我们可以自己编译它们!

这样做的好处是,我们可以更容易地交叉编译我们的程序。为此,我们需要将库的构建文件转换成我们的 build.zig。这通常需要对 build.zig 和你的库所使用的构建系统都有很好的了解。但让我们假设这个库是超级简单的,只是由一堆 C 文件组成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 示例片段
pub fn build(b: *std.build.Builder) void {
    const cflags = .{}

    const curl = b.addSharedLibrary("curl", null, .unversioned)
    exe.addCSourceFile(.{
            .file = std.build.LazyPath.relative("vendor/libcurl/src/tool_main.c"),
            .flags = &cflags,
            });
    exe.addCSourceFile(.{
            .file = std.build.LazyPath.relative("vendor/libcurl/src/tool_msgs.c"),
            .flags = &cflags,
            });
    exe.addCSourceFile(.{
            .file = std.build.LazyPath.relative("vendor/libcurl/src/tool_dirhie.c"),
            .flags = &cflags,
            });
    exe.addCSourceFile(.{
            .file = std.build.LazyPath.relative("vendor/libcurl/src/tool_doswin.c"),
            .flags = &cflags,
            });
    const target = b.standardTargetOptions(.{});
    exe.linkLibC()
    exe.addIncludePath(.{ .path = "vendor/libcurl/include" });
    exe.linkLibrary(curl)
    b.installArtifact(exe);

}

这样,我们就可以使用 addSharedLibrary 和 addStaticLibrary 向 LibExeObjStep 添加库。

这一点尤其方便,因为我们可以使用 setTarget 和 setBuildMode 从任何地方编译到任何地方。

使用工具

在工作流程中使用工具,通常是在需要以 bison、flex、protobuf 或其他形式进行预编译时。工具的其他用例包括将输出文件转换为不同格式(如固件映像)或捆绑最终应用程序。

系统工具 使用预装的系统工具非常简单,只需使用 addSystemCommand 创建一个新步骤即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// demo 3.5
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "test",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    const cmd = b.addSystemCommand(&.{
        "flex",
        "-outfile=lines.c",
        "lines.l",
    });
    b.installArtifact(exe);
    exe.step.dependOn(&cmd.step);
}

从这里可以看出,我们只是向 addSystemCommand 传递了一个选项数组,该数组将反映我们的命令行调用。然后,我们按照习惯创建可执行文件,并使用 dependOn 在 cmd 上添加步骤依赖关系。

我们也可以反其道而行之,在编译程序时添加有关程序的小信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
//demo3.6
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "test",
        .root_source_file = .{ .path = "main.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(exe);
    const cmd = b.addSystemCommand(&.{"size"});
    cmd.addArtifactArg(exe);
    b.getInstallStep().dependOn(&cmd.step);
}

size 是一个很好的工具,它可以输出有关可执行文件代码大小的信息,可能如下所示:

文本 数据 BSS Dec 十六进制 文件名 12377 620 104 13101 332d …

如您所见,我们在这里使用了 addArtifactArg,因为 addSystemCommand 只会返回一个 std.build.RunStep。这样,我们就可以增量构建完整的命令行,包括任何 LibExeObjStep 输出、FileSource 或逐字参数。

全新工具

最酷的是 我们还可以从 LibExeObjStep 获取 std.build.RunStep:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 示例片段
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
     const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const game = b.addExecutable(.{
        .name = "game",
        .root_source_file = .{ .path = "src/game.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(game);
    const pack_tool = b.addExecutable(.{
        .name = "pack",
        .root_source_file = .{ .path = "tools/pack.zig" },
        .target = target,
        .optimize = optimize,
    });
    //译者改动:const precompilation = pack_tool.run(); // returns *RunStep
    const precompilation = b.addRunArtifact(pack_tool);
    precompilation.addArtifactArg(game);
    precompilation.addArg("assets.zip");

    const pack_step = b.step("pack", "Packs the game and assets together");
    pack_step.dependOn(&precompilation.step);
}

此构建脚本将首先编译一个名为 pack 的可执行文件。然后将以我们的游戏和 assets.zig 文件作为命令行参数调用该可执行文件。

调用 zig build pack 时,我们将运行 tools/pack.zig。这很酷,因为我们还可以从头开始编译所需的工具。为了获得最佳的开发体验,你甚至可以从源代码编译像 bison 这样的 “外部 “工具,这样就不会依赖系统了!

将所有内容放在一起

一开始,所有这些都会让人望而生畏,但如果我们看一个更大的 build.zig 实例,就会发现一个好的构建文件结构会给我们带来很大帮助。

下面的编译脚本将编译一个虚构的工具,它可以通过 flex 生成的词法器解析输入文件,然后使用 curl 连接到服务器,并在那里传送一些文件。当我们调用 zig build deploy 时,项目将被打包成一个 zip 文件。正常的 zig 编译调用只会准备一个未打包的本地调试安装。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 示例片段
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
    const mode = b.standardOptimizeOption(.{});
    // const mode = b.standardReleaseOptions();

    const target = b.standardTargetOptions(.{});

    // Generates the lex-based parser
    const parser_gen = b.addSystemCommand(&[_][]const u8{
        "flex",
        "--outfile=review-parser.c",
        "review-parser.l",
    });

    // Our application
    const exe = b.addExecutable(.{
        .name = "upload-review",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = mode,
    });

    {
        exe.step.dependOn(&parser_gen.step);

        exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("review-parser.c"), .flags = &.{} });

        // add zig-args to parse arguments

        const ap = b.createModule(.{
            .source_file = .{ .path = "vendor/zig-args/args.zig" },
            .dependencies = &.{},
        });
        exe.addModule("args-parser", ap);

        // add libcurl for uploading
        exe.addIncludePath(std.build.LazyPath.relative("vendor/libcurl/include"));
        exe.addObjectFile(std.build.LazyPath.relative("vendor/libcurl/lib/libcurl.a"));

        exe.linkLibC();
        b.installArtifact(exe);
        // exe.install();
    }

    // Our test suite
    const test_step = b.step("test", "Runs the test suite");
    const test_suite = b.addTest(.{
        .root_source_file = .{ .path = "src/tests.zig" },
    });

    test_suite.step.dependOn(&parser_gen.step);
    exe.addCSourceFile(.{ .file = std.build.LazyPath.relative("review-parser.c"), .flags = &.{} });

    // add libcurl for uploading
    exe.addIncludePath(std.build.LazyPath.relative("vendor/libcurl/include"));
    exe.addObjectFile(std.build.LazyPath.relative("vendor/libcurl/lib/libcurl.a"));

    test_suite.linkLibC();

    test_step.dependOn(&test_suite.step);

    {
        const deploy_step = b.step("deploy", "Creates an application bundle");

        // compile the app bundler
        const deploy_tool = b.addExecutable(.{
            .name = "deploy",
            .root_source_file = .{ .path = "tools/deploy.zig" },
            .target = target,
            .optimize = mode,
        });

        {
            deploy_tool.linkLibC();
            deploy_tool.linkSystemLibrary("libzip");
        }

        const bundle_app = b.addRunArtifact(deploy_tool);
        bundle_app.addArg("app-bundle.zip");
        bundle_app.addArtifactArg(exe);
        bundle_app.addArg("resources/index.htm");
        bundle_app.addArg("resources/style.css");

        deploy_step.dependOn(&bundle_app.step);
    }
}

如你所见,代码量很大,但通过使用块,我们可以将构建脚本结构化为逻辑组。

如果你想知道为什么我们不为 deploy_tool 和 test_suite 设置目标: 两者都是为了在主机平台上运行,而不是在目标机器上。 此外,deploy_tool 还设置了固定的编译模式,因为我们希望快速编译,即使我们编译的是应用程序的调试版本。

总结

看完这一大堆文字,你现在应该可以构建任何你想要的项目了。我们已经学会了如何编译 Zig 应用程序,如何为其添加任何类型的外部库,甚至如何为发布管理对应用程序进行后处理。

我们还可以通过少量的工作来构建 C 和 C++ 项目,并将它们部署到各个地方,而不仅仅是 Zig 项目。

即使我们混合使用项目、工具和其他一切。一个 build.zig 文件就能满足我们的需求。但很快你就会发现… 编译文件很快就会重复,而且有些软件包或库需要大量代码才能正确设置。

在下一篇文章中,我们将学习如何将 build.zig 文件模块化,如何为 Zig 创建方便的 sdks,甚至如何创建自己的构建步骤!

一如既往,继续黑客之旅!