How do I customize start code?

In MicroZig, we structure applications similar to build.zig: the user provided code is imported as a module into the root of the application. We originally did this because it very easily allows us to inject whatever code we need before we explicitly call the user’s main(). Depending on the hardware, this can vary greatly, and no, we should not be upstreaming this to the stdlib.

The downside to this approach is that packages who use root declarations to configure themselves at compile-time can no longer do so, as the user’s code is not root. Like the std_options declaration for the standard library. So I’m looking into alternatives, and re-evaluating why we put startup code in a root module to begin with, and I’ve become stumped.

For the standard library, startup code is injected in start.zig, which has a comptime block that exports functions, or does a number of things before main(), depending on the system. This is exactly what MicroZig wants to do, so I tried replicating the behaviour, but I failed, and that’s why I need help.

In this branch I’ve made the user’s application the root of the executable, and in the microzig package I’ve replicated what the standard library does:

  • Force import of start.zig at comptime: std vs MicroZig
  • Force import of root at comptime in start.zig: std vs MicroZig

These are the two things I understand are needed in order to have comptime logic run which sets up startup code. There does still seem to be a difference though, because I need to explictly use the microzig import in order to trigger my startup code, where if I make an empty program that doesn’t even import std, the main function shows up in the executable:

0000000100099734 <_test.main>:
100099734: a9bf7bfd     stp     x29, x30, [sp, #-0x10]!
100099738: 910003fd     mov     x29, sp
10009973c: a8c17bfd     ldp     x29, x30, [sp], #0x10
100099740: d65f03c0     ret

However if I make a blinky program in MicroZig, that actually uses the import! I still need to explicitly “use” it in order to have the comptime startup code setup to evaluate:

const std = @import("std");
const microzig = @import("microzig");
const rp2xxx = microzig.hal;
const time = rp2xxx.time;

// Even though microzig is imported and used in this blinky program, I still
// need to explicitly "use" it. If I don't, I get a linking _warning_ saying
// that I'm missing a symbol that the linkerscript (created by MicroZig)
// expects, and I get an empty program. If I "use" it explicitly, all is fine.
comptime {
    _ = microzig;
}

// Our no-op is needed otherwise we get missing posix compile errors -- A
// separate issue.
pub const std_options: std.Options = .{
    .logFn = microzig.options.logFn,
};

const pin_config: rp2xxx.pins.GlobalConfiguration = .{
    .GPIO25 = .{
        .name = "led",
        .direction = .out,
    },
};

const pins = pin_config.pins();

pub fn main() !void {
    pin_config.apply();

    while (true) {
        pins.led.toggle();
        time.sleep_ms(250);
    }
}

Is the stdlib specially cased, where its root always gets comptime evaluated regardless of the user importing it? That would explain this behaviour, or it’s something I don’t understand.

3 Likes

start.zig is not special. The _start symbol is special. What you need is a naked _start function.
You can also change the name of the _start symbol from your linker script to prevent conflicts with the _start symbol from std library.

1 Like

Yes, the std lib is specially cased in the sense that it is inserted into the set of things that are unconditionally semantically analyzed. This is in src/Zcu/PerThread.zig in the function populateModuleRootTable:

    // Start with:
    // * `std_mod`, which is the main root of analysis
    // * `root_mod`, which is `@import("root")`
    // * `main_mod`, which is a special analysis root in tests (and otherwise equal to `root_mod`)
    // All other modules will be found by traversing their dependency tables.
    try roots.ensureTotalCapacity(gpa, 3);
    roots.putAssumeCapacity(zcu.std_mod, undefined);
    roots.putAssumeCapacity(zcu.root_mod, undefined);
    roots.putAssumeCapacity(zcu.main_mod, undefined);

One can negate this effect by putting pub const _start = {}; in the root module because the logic in the start.zig comptime block notices this and avoids doing anything. Or you can pub export fn _start(... for the same effect. As long as the root module has a public _start declaration, std lib start code will do nothing.

Note that on MIPS the start symbol is __start (two underscores) for some reason.

6 Likes

Ah there we go, thank you that makes sense!

We haven’t been negating the startup code from stdlib in MicroZig, I think because it’s freestanding, it doesn’t really export anything, and then we come in and populate stuff that works with our linker scripts.

Just to confirm, having an option in the build system to have a module unconditionally evaluated is a bad idea right? To me it feels like a way packages could export symbols, and do other things at comptime you might not want, and not give you an option to overload/fix.

If that’s the case then it sounds like MicroZig either needs to accept a little bit of boilerplate, or find some other solution.

From the MicroZig user’s perspective is there an ergonomic improvement to tweaking their build script in order to unconditionally evaluate MicroZig module, versus putting @import("microzig") in their application? Perhaps naively, to me, the @import seems the more ergonomic option (and is not a departure from status quo).

3 Likes

So what I’m seeing, is that it’s less requiring importing MicroZig in their application, it’s the combination of the import and the explicit comptime block:

const microzig = @import("microzig");

comptime {
    _ = microzig;
}

In the blinky example in the original post, despite the program using microzig to interact with hardware, I still that comptime block. Otherwise I get linking errors due to the comptime context of the microzig import not evaluating, and exporting the _start symbol (our linkerscript requires that symbol). I’m unsure how this is happening as I would expect calling time.sleep_ms(250); (ultimately microzig.hal.time.sleep_ms()) would cause comptime blocks in the microzig namespace to be evaluated.

I ask about the posibility of a build flag, because MicroZig could set it on behalf of the user. In this scenario, they wouldn’t have to ensure their imports look a certain way, or add this comptime block.

If I am getting something wrong, both in implementation or understanding, I would be quite happy to require someone to only @import("microzig") to have the startup code instated.

I would expect that as well. I think it would be good to determine why that is not the case, before considering a modification to the language or compiler.

Could it be, perhaps, that the user’s code that calls time.sleep_ms is itself not being evaluated? If it’s microzig that’s inspecting the root source file for pub fn microzig_main or whatnot, that function would not be referenced because microzig was not referenced.

2 Likes


That’s it for sure, Chickens and their god damned eggs

3 Likes