How to implement conditional compilation in zig?

I tried build-options,
but it just worked at .addExecutable(),
not worked at .addStaticLibrary().

I’m wrapping a c library to build a zig package/library,
and need to map some conditional compilation macros to the outer zig variables/configs.

build-options is the only useful mechanism I can find out right now,
but I can’t work it out inside .addStaticLibrary() scenario.

I don’t understand which part of this doesn’t work for static libraries. Could you elaborate?

Say, after defining one option in build.zig:

    const options = b.addOptions();
    options.addOption(usize, "digits", 50);

and add it to the library:

    const lib = b.addStaticLibrary("mylib", "src/mylib.zig");
    lib.addOptions("build_options", options);

Then, reference it in src/mylib.zig or the other zig files of this package’s source tree:

    const DIGITS = @import("build_options").digits;

The compiler would complain no package named 'build_options' available within package 'mylib'.

Seems to work perfectly fine here with pretty much the same code.

Try to add a caller artifact.
Maybe something won’t be invoked without executable building.

I tried that in an executable target and printed out the option, so I doubt that’s it.

If you can share the code that’s giving you trouble I can have a look at that.

OK.

In build.zig:

const options = b.addOptions();
options.addOption(usize, "digits", 50);

const lib = b.addStaticLibrary("demo", "src/main.zig");
lib.addOptions("build_options", options);
lib.install();

const exe = b.addExecutable("caller", "caller.zig");
exe.setBuildMode(mode);
exe.addPackagePath("demo", "src/main.zig");
exe.addObjectFile("./zig-out/lib/libdemo.a");
exe.install();

In src/main.zig:

const std = @import("std");
const ds = @import("build_options").digits;

pub fn demo() anyerror!void {
    std.log.info("{}\n", .{ds});
}

In caller.zig:

const d = @import("demo");

pub fn main() void {
    d.demo();
}

OK, I think both in this case and in the case of How to set @cImport file path?, you’re trying to use the library as if it was part of the same compilation unit as the executable, instead of just depending on it as a library.

The lib.addOptions() call adds the build_options package to the compilation of src/main.zig and to that one only – when you get to the point of compiling caller.zig you then import the src/main.zig sources and try to access a build_options package which is no longer exposed anywhere, as it was added to lib but not to exe.

In general, if you want src/main.zig to be a library, then you should only use it as a library. Whatever you want to access from the outside, you need to explicitly export, and then you can do exe.linkLibrary(lib) instead of exe.addObjectFile("hardcoded/path/to/lib.a").

See the code example in How to set @cImport file path? for inspiration and try again.

1 Like

@jmc Thanks for your patient explanation!

But, I’m sorry that there are still many question marks:

Is that the single way to release and use a library with export/extern?
I don’t quite like this mechanism, as it’s not easy to find out which library the invoked interface is from.
And, it seems that the library source code must be available too, as .linkLibrary(lib) needs to define the lib with source file, right?

The reason why I choose .addObjectFile is it is the only interface that can accept third-party file, like libdemo.a.
Is it possible to build a exe package that only use libdemo.a without source code?

When to use .addPackage()?

Maybe I should study some package manager, like gyro?

I don’t know why the compiler throws error: no package named 'build_options' available within package 'demo',
even add exe.addOptions("build_options", options).

Finally, I adopt this suggestion, ignoring .addOptions() totally:

const options = b.addOptions();
options.addOption(usize, "digits", 50);

const demo_pkg: std.build.Pkg = .{
    .name = "demo",
    .source = comptime .{ .path = std.fs.path.dirname(@src().file).? ++ std.fs.path.sep_str ++ "src/main.zig" },
    .dependencies = &[_]std.build.Pkg{
        .{ .name = "build_options", .source = options.getSource() },
    },
};

const lib = b.addStaticLibrary("demo", "src/main.zig");
lib.install();

const exe = b.addExecutable("caller", "caller.zig");
exe.addPackage(demo_pkg);
exe.addObjectFile("./zig-out/lib/libdemo.a"); // or, exe.linkLibrary(lib);
exe.install();

When you call addPackagePath, it does not know anything about the lib you created earlier - they use the same source code, but are aware of independent metadata. Calling addStaticLibrary creates a library that can be linked, which, like in C, requires you to declare symbols for the linker to link.
Essentially, addStaticLibrary, addExecutable and addPackage[Path] are all referring to independent entities.
E.g., if you were to do

const lib = b.addStaticLibrary("demo", "src/main.zig");
lib.addOptions("build_options", options);

const exe = b.addExecutable("caller", "caller.zig");
exe.linkLibrary(lib);
exe.install();

it would link any symbols exported by src/main.zig (either directly or transitively), although it doesn’t in this example.

If all you want to do is create a source library, you needn’t make any call to addStaticLibrary, thus your build.zig code can simply be:

const options = b.addOptions();
options.addOption(usize, "digits", 50);

const demo_pkg: std.build.Pkg = .{
    .name = "demo",
    .source = comptime .{ .path = std.fs.path.dirname(@src().file).? ++ std.fs.path.sep_str ++ "src/main.zig" },
    .dependencies = &[_]std.build.Pkg{
        .{ .name = "build_options", .source = options.getSource() },
    },
};

const exe = b.addExecutable("caller", "caller.zig");
exe.addPackage(demo_pkg);

Often this is preferable if you’re only working with zig, as it allows you to leverage all the advantages of compiling zig (dead code elimination, comptime, root declaration awareness, async, etc).

If you need to interact with the ABI layer at all, that is when you may want to consider exporting your functions, and/or linking functions as extern:

// build.zig:build
const lib = b.addStaticLibrary("foo", "foo.zig");
const exe = b.addExecutable("bar", "bar.zig");
exe.linkLibrary(lib);
exe.install();

// foo.zig
export fn add(a: u32, b: u32) u32 {
    return a +% b;
}

// bar.zig
const std = @import("std");
extern fn add(a: u32, b: u32) u32;
pub fn main() void {
    std.debug.print("{}\n", .{add(1, 2)}); // should print 3
}

Also, I would recommend against exe.addObjectFile("./zig-out/lib/libdemo.a") at all, as you end up relying on details that are opaque to the build system.

2 Likes

@InKryption Thanks for your elaboration!
I’m learning and studying zig’s building system,
not sure how to implement static/dynamic linking properly,
and still have a few doubts about the meaning/usage of package/library/link.

It seems that, in zig’s building system, there is no clear concept and usage about building artifact that can be published and shared independently, like static/dynamic library or bin/exe.

In the code demo, I wanna mock the situation that .addStaticLibrary() building one independent library artifact in project A and .addObjectFile() statically linking the library to build an exe artifact in project B.
However I found that even with .addObjectFile(), exe should also call .addPackage() or .addPackagePath(), or else it cannot find the library. This is one of the questions that puzzles me.

According to my understanding, .addStaticLibrary() and .addExecutable() are declaring to build independent artifacts: library and exe.
But I’m still confused by the concept package, should it be used just as building blocks of one library or exe artifact?

I quite appreciate zig’s building system, I think it has great potential power.

export/extern is another puzzle for me.
Is it the only method that can be used for one independent library artifact to publish its interfaces?
I haven’t found its underlying mechanism, and so far as I know when using export/extern I cannot use the library name to organize the namespace of the extern interfaces.


So to speak, I wanna @import() the published/shared independent library artifacts in one exe project, after linking the library artifacts statically or dynamically.

But I’m still confused by the concept package, should it be used just as building blocks of one library or exe artifact?

A package in zig is a “module”, which is comprised of source code. When you compile zig, the code directly used by your main file is in the “root” package. The root package can then have available to it other packages, also comprised of source code - in the end, these are all compiled as a single binary object, whether into an actual object file, a library, or an executable - no linking, as they are not compiled independently.

Is it the only method that can be used for one independent library artifact to publish its interfaces?

Yes, those are the keys to interacting with the ABI boundary.
To clarify a few terms:

  • pub fn: declares a function which will be visible to other packages that @import the file it is declared in, but not visible to the linker.
  • export fn: declares a function which will be visible to the linker, wherein it can be referenced by other compiled zig source via an extern declaration. This is equivalent to declaring a non-static function in C.
  • extern fn: declares a function which will be defined in another object (if in zig code, by an export fn declaration, and if in C, then by a normal function declaration).

Note: you can have pub extern fn and pub export fn if you want to make those declarations also visible to other packages.

If what you want is a “header + implementation” style project, then you can do exactly that:

// lib/header.zig
pub extern fn add(a: u32, b: u32) u32;
pub extern fn sub(a: u32, b: u32) u32;
// lib/impl.zig
pub export fn add(a: u32, b: u32) u32 {
    return a +% b;
}
pub export fn sub(a: u32, b: u32) u32 {
    return a -% b;
}
// build.zig
const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const lib_impl = b.addStaticLibrary("mylib_impl", "lib/impl.zig");
    const caller = b.addExecutable("caller", "caller.zig");

    caller.addPackagePath("mylib", "lib/header.zig");
    caller.linkLibrary(lib_impl);

    b.default_step.dependOn(&caller.run().step);
}
// caller.zig
const std = @import("std");
const mylib = @import("mylib");

pub fn main() void {
    std.debug.print("{}, {}\n", .{ mylib.add(1, 3), mylib.sub(7, 3) }); // prints 4, 4
}

export fn add defines and exposes a function to the linker, and extern fn add asks the linker to expose that same function to us in another object. All the package does here is allow us to pull in that header-esc package in to use those extern fn declarations.

2 Likes

This great post is very informative and helpful for me!
@InKryption Thanks!

1 Like

Some more questions:

When export/extern the functions with non-primary types, like customized struct type, how to deal with them?
And, how to export the functions that are inside a struct?