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