Testing for freestanding

As some of you may know, I started writing an operating system in Zig. The kernel was first for x86_64, but now I switched to RISC-V as the main architecture (x86_64 code is still in the kernel, but I am focused on riscv64). My current progress in the kernel can be seen in the riscv64 branch of the kernel.
After implementing the kernel side (function signatures) of the Supervisor Binary Interface, I wanted to do some testing.

However, kernel testing is not easy. As the OS the kernel is built for is ‘freestanding’, even compiling the test throws errors. Just using the host target (b.standardTargetOptions(.{})) is also a bad idea, because when the kernel contains code that is conditionally imported for riscv64 and I’m testing on aarch64, then that code will not be included. So I decided to use riscv64-linux-musl when testing the riscv64 kernel and x86_64-linux-musl when testing the x86_64 kernel.
After fixing some a duplicate _start symbol (because I used it as the entry point in my kernel) by renaming it to _entry, ld.lld now complains about missing symbol definitions for symbols things like stack_top (which is defined in my own linker script) that are referenced in my _entry function. As I need to export the _entry function, it will always get included. Just while writing, I got the idea to exclude my inline assembly that references stack_top conditionally. But when doing it that way, my entire kernel code is borked with if (!builtin.is_test) { REFERENCE_SYMBOL; }.

Any better suggestions?

I’m not quite sure from your question if this will help, but I use @extern to obtain linker symbols. For example,

const main_stack_top =  @extern(*u64, .{
    .name = "__main_stack_top__",
    .linkage = .strong,
});

Well, obtaining the symbol is the one thing. The other thing is using it. I used inline assembly: la sp, stack_top (read as load address of stack pointer from symbol stack_top).

So my current solution to my problem (undefined symbols in platform default linker scripts) is like the following (the real code looks different):

const builtin = @import("builtin");

export fn _entry() {
    if (!builtin.is_test) {
        asm volatile ("la sp, stack_top");
    }
}

As of now, this is the only real place where I need to use that, but I am relatively sure other parts will need that too, so it would be good to have a solution for this.

Do you have a custom test_runner?
See for the default implementation in your zig subfolder: lib/compiler/test_runner.zig.


When using zig test, you can specify the test_runner with the option:

  --test-runner [path]           Specify a custom test runner

Using build.zig, you can specify test_runner in addTest TestOptions

    const tests = b.addTest(.{
        .target = target,
        .root_source_file = root_source_file,
        .test_runner = b.path("test_runner.zig"),
    });
1 Like

I think you are braver than I am in attempting to test platform dependent code in this way. I’m usually resigned to building target test programs to run on the target platform and it is all too manual for comfort. But @dimdin suggestion of a custom test runner might be an area worth exploring.

Thanks for that suggestion, I’ll try that.

I just created a custom test runner (see everything on codeberg). Now, that test runner is implemented for the freestanding target and uses my kernel log functions (and other stuff I already made for the kernel).

Now, it is a bit weird: the file src/test_main (which is my test runner) does get analyzed (a global comptime { @compileLog("test"); } is processed), but my custom entry point for the tests (_entry) does not get analyzed (a @compileLog("test"); isn’t processed.
When I run the OS using zig build test now, it runs the normal kernel code instead of the custom kernel testing code.

How?

You don’t need the _entry in test_main.zig.
Keep only one _start (in main.zig, or somewhere that both main.zig and test_main.zig load) as:

// root is: root_source_file (main.zig) for normal run,
//       or test_runner (test_main.zig) for test run.
const root = @import("root");

export fn _start() noreturn {
    // do essential init work
    arch.platform.setup();
    if (builtin.is_test) {
        root.tmain();
    } else {
        root.kmain();
    }
    unreachable;
}

Originally I tried it similar to your approach. But the problem with that solution is that when the entry point (_start) is loaded by both main.zig and test_main.zig, Zig somehow complains about a symbol collision.

That was my reason to create a separate export fn _entry for the test runner.

I don’t know how I got the idea, but now it works:

The issue was either the wrong entry point being loaded when having both _entry and _start (somehow Zig ignores my _entry function and uses _start, even though in the linker script I have clearly stated ENTRY(_entry)) or a symbol collision when using _start in either a separate startup program or everything.

My solution was to leave everything that will be needed when doing @import("root") in the src/test_main.zig file but move the test main function to src/main.zig. Then, I specified src/test_main.zig as the test runner (which didn’t contain the actual test runner code) and src/main.zig as the root source file. src/main.zig exports _start (and there it’ll check whether it’s in testing mode or not and conditionally invoke tmain or kmain).