Rewriting


This is the first blog post about Zinnia! You may have noticed that the website looks a bit different now - I'll write here every now and then about cool things I've been working on, or to vent my frustrations. :D

Now for some actual news:

I have made the decision to rewrite and port the entire codebase over to C23. It will retain full userspace ABI (and hopefully API) compatibility with the Rust version.

Why

Writing Rust has been a decent experience in many aspects. Tagged unions, Generics and Iterators come to mind. But it doesn't come without issues. There are some real problems both with the toolchain and how the language and/or standard library expects you to write code.

Here are my biggest gripes with the language:

Cargo and rustc are very inflexible

Cargo is probably one of my least favorite build systems of all time. Any moderately complex setup is impossible to implement without resorting to a second build system like make or another set of shell scripts. The way features are defined, and how crates are linked together just doesn't work well with the way I want modular drivers to work.

The next point comes from me having to package Rust and Zinnia for bootstrap: Cargo didn't even know how to properly build out-of-tree for a long time, a thing that is very common for distributions. Not to mention how incredibly annoying it is to port the toolchain and programs to a new target. This will likely be its own blog post in the future...

On the other hand, rustc also has a limited set of flags that can be used to influence code layout. I originally wanted to implement a EFISTUB boot method, but conditionally selecting the executable format or doing linker tricks à la PicoEFI is not realistically doable as it stands currently.

Building is unstable

As Zinnia is built as a shared library to make the modular driver system work, we need to build core, alloc and compiler-rt with a custom toolchain target, which forces us to use a nightly toolchain, just so we can specify a freestanding soft-float target with shared library support. -Zbuild-std has been unstable for years and I don't see any progress being made there to change that. Many interesting features in Rust are still guarded behind nightly and are unlikely to get stabilized in the near future.

alloc has no real fallible allocations

If you look at the API of most collections in alloc, you'll quickly realize that it was designed for use in userspace. The recent stabilization of allocator_api feels more like a bandaid fix, rather than proper OOM safety. Sure, I could drop alloc and roll my own, but then I would miss out on crates that make use of it (Arc is an important one) and it is considerably more code to maintain.

By the way: the actual feature implementing fallible allocations for a few of the containers (like Vec and Arc) is... you guessed it, still unstable!

No direct heap allocations

This is one of the first hurdles I encountered and still had to deal with until now: Rust creates all objects with a non-'static lifetime on the stack. A kernel thread usually has a way smaller stack to work with than normal userspace programs. Creating big heap objects will first allocate space on the stack, and only then move the data to the allocated buffer. There is no (portable or non-UB) way of replicating a placement new from C++, where the object is initialized on heap memory directly. As a result, Zinnia uses a stack with a size of at least 28 KiB, and up to 128 KiB in debug mode.

LLVM optimizes these stack allocations at higher opt-levels, which means that even in debug mode, I have to build with at least 1 or higher if I want to be able to even boot.

Compile times and sizes

Most of the core code is put in a single crate, as everything inside the kernel more or less has to use bits from everywhere. This makes it difficult to split the kernel into multiple crates, resulting in crazy compile times.

On my AMD Threadripper 9960X machine, I ended up with a staggering results like:

Finished `release` profile [optimized] target(s) in 14.60s

and:

Removed 7390 files, 4.0GiB total

This is not what I'm thinking of when I say "lightweight", which is one of the goals I want to achieve for Zinnia.

No OOP

Rust prefers dyn trait for dynamic dispatch, though OOP is a more common pattern inside a kernel, as many driver interfaces are basically vtables with some data attached.

struct my_driver_ops {
    void (*do_stuff)(struct my_driver*, int);
};

struct my_driver {
    int foo;
    int bar;
    const struct my_driver_ops* ops;
};

The above code doesn't translate well to Rust if you want to use a trait instead of function pointers. There are multiple ways to approach a design like the above, here are two that I've used in Zinnia:

// 1. Pass the data as a parameter to the trait function
struct MyDriver {
    foo: i32,
    bar: i32,
}

trait MyDriverOps {
    fn do_stuff(&self, driver: &MyDriver, a: i32);
}

// 2. Include the data in the trait
trait MyDriverOps {
    fn get_data(&self) -> &MyDriver;
    fn do_stuff(&self, a: i32);
}

struct MyImpl { data: MyDriver }

impl MyDriverOps for MyImpl {
    fn get_data(&self) -> &MyDriver { &self.data }
    fn do_stuff(&self, a: i32) { log!("foo {}", a + self.get_data().bar); }
}

This is quite unergonomic, most prominently found in the VFS and DRM code. It gets particularly annoying to write when the data needs interior mutability.

Using few unsafe blocks doesn't make the system safer

Compile-time memory safety is one of Rust's biggest appeals. For high-level code, this is definitely a nice-to-have and something I will miss in C. Nonetheless, it's not a silver bullet. There are too many ways to shoot yourself in the foot with unsafe code on bare-metal systems, and Rust makes it very easy to accidentally run into undefined behavior. This occured mostly while working on things like synchronization objects, scheduler code, virtual memory management, and handling of DMA memory.

The times where I could actually make use of them were in the network stack and the VFS, where it's easy to overrun your buffers in other languages (see recent Linux CVEs).

What's next?

I will continue to rewrite the old systems and making changes all over. My goal is to get at least feature parity with the Rust version. For now this will only cover the x86_64 port and a few drivers, as the riscv64 port was already slightly defunct.

You will be able to find the code on the rewrite branch very soon.


Thanks for reading!

Marvin