Leaving the Kernel dependencies behind

In the previous post we settled on using Rust to write our OS so we’ll set up a new rust application.

Make sure you’ve got rust installed on your computer.

Here’s a guide on how to do that.

Setting up

Now let’s create a new rust application. I’ve chosen to call our OS “Zeno” (no particular reason, just sounds cool)

1# create a new cargo project called 'zeno'
2$ cargo new zeno
3
4# run our application for good luck
5$ cargo run
6    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
7     Running `target/debug/zeno`
8
9Hello, world!

Great! the hardest part is over. Rest will be easy.

Leaving kernel dependencies behind

Next. We need to make sure we remove any connection between our rust binary to our host operating system.

Here’s why:

By default, rust and its standard library depend heavily on OS abstractions to do a lot of heavylifting for us and thus, it assumes the presence of an underlying OS and the C runtime.

When you invoke the println! macro, or allocate memory, and pretty much anything that has to do with I/O, rust makes syscalls to the host operating system to make stuff happen.

However, in our case, there is no OS. We are writing our own operating system. Which means whatever abstractions rust relies on to perform those tasks, Gone!

We cannot access them anymore so we need to get rid of any reference to them.

Step 1. Tell rust to avoid using the standard library.

We do this by adding the #![no_std] attribute at the top of our main.rs file.

The no_std attribute explicitly blocks rust from linking any parts of its standard library in our binary (which is the rust compiler’s default behavior)

Only the core library remains. Everything else, that relies on an underlying OS is gone.

The core library is platform agnostic, so we can keep it.

main.rs
1#![no_std] // 👈 Add this line
2
3fn main() {
4    println!("Hello, world!");
5}

Pretty simple right? Well not quite. Trying to compile this code quickly reveals that the rust compiler is not happy.

Although it gives pretty helpful error messages.

compiling with the no_std attribute
 1Compiling zeno v0.1.0 (/path/to/zeno)
 2error: cannot find macro `println` in this scope
 3--> src/main.rs:4:5
 4  |
 54 |     println!("Hello, world!");
 6  |     ^^^^^^^
 7
 8error: `#[panic_handler]` function required, but not found
 9
10error: unwinding panics are not supported without std
11|
12= help: using nightly cargo, use -Zbuild-std with panic="abort" to avoid unwinding
13= note: since the core library is usually precompiled with panic="unwind", rebuilding your crate with panic="abort" may not be enough to fix the problem
14
15error: could not compile `zeno` (bin "zeno") due to 3 previous errors

Let’s unpack this one step at a time

1error: cannot find macro `println` in this scope

This is pretty straightforward. We’ve stripped rust of all its standard library references using the #![no_std] attribute. That includes the println macro as well.

Unfortunately for us, we can’t use println! anymore. so we’ll have to get rid of those invocations for now. We’ll figure out a way to log things later.

main.rs
1#![no_std]
2
3fn main() {} // 👈 `println` gone

But that’s still not enough. There’s another compiler error.

1error: `#[panic_handler]` function required, but not found
2
3error: unwinding panics are not supported without std

Hmmm. The Rustnomicon says (verbatim)

#[panic_handler] is used to define the behavior of panic! in #![no_std] applications.

The #[panic_handler] attribute must be applied to a function with the following signature

fn(&PanicInfo) -> !

Such function must appear once in the dependency graph of a binary / dylib / cdylib crate.

Cool. Let’s add that to our code.

main.rs
 1#![no_std]
 2
 3use core::panic::PanicInfo; // 👈 Add this line
 4
 5fn main() {}
 6
 7
 8// 👇 Add this function (notice the `#[panic_handler]` attribute)
 9#[panic_handler]
10fn panic(_info: &PanicInfo) -> ! {
11    loop {}
12}

💡 Making rust-analyzer happy

If you are using an LSP enabled editor (most likely, you are), As soon as you add the panic function, you might notice that your editor starts yelling at you

Something along the lines of:

found duplicate lang item panic_impl the lang item is first defined in crate std (which test depends on)

This is because we’ve told our bin code to not include any main function, however, by default, cargo tries to compile tests and bench code as well, in which there’s no mention of not including the main function.

In order to fix this, we need to tell cargo to not compile them by adding this to our Cargo.toml file

Cargo.toml
1...
2[[bin]]
3name = "zeno" # the name of our crate
4test = false # tell cargo to not compile tests
5bench = false # tell cargo to not compile benches
6...

Additionally, another one of the default behaviors of the rust compiler is to unwind the stack whenever a program panics. This is done to allow cleanup of resources like memory, files, etc by calling Drop::drop methods on each stack frame.

However, a panic in our kernel means immediate shutdown i.e aborting the entire kernel. So we tell rust that we are no longer using unwinding panics by setting panic = "abort" in our Cargo.toml

Cargo.toml
1[profile.dev] # 👈 For dev mode
2panic = "abort"
3
4[profile.release] # 👈 For release mode
5panic = "abort"

Lastly, the compiler says:

1error: using `fn main` requires the standard library
2  |
3  = help: use `#![no_main]` to bypass the Rust generated entrypoint and declare a platform specific entrypoint yourself, usually with `#[no_mangle]`
4
5error: could not compile `zeno` (bin "zeno") due to 1 previous error

Alright Rust, Fine! Let’s remove the main function, and add the #![no_main] attribute as well

main.rs
1#![no_std]
2#![no_main] // 👈 Add this line
3
4use core::panic::PanicInfo;
5
6#[panic_handler]
7fn panic(_info: &PanicInfo) -> ! {
8    loop {}
9}

Seems straight forward, although a bit counter intuitive. I mean, If we get rid of main, then what? We need some kind of entry point into our OS right?

How does the hardware know where to start execution.

We get a hint of that when we try to compile. Albeit in the form of a giant, cryptic error.

In the midst of all the chaos, we see the line

1rust-lld: error: undefined symbol: __libc_start_main

Where did this __libc_start_main come from?

I thought we told rust to get rid of all references to any system libraries, including libc

Yes. Yes we did. And to be fair, Rust did honor our request. Rust did not include any system library references. In fact the error says the same thing too, that we didn’t find any __libc_start_main symbol present

However, rustc is just a compiler

After the rust compiler is done with our code, it passes it to the linker, which then converts our generated code into an executable.

The problem is, the default linker, in this case rust-lld assumes that we are using the C runtime, which of course we do not.

Wait what!? C has a runtime??

Isn’t C one of those languages that can just run without any runtime?

Well.. yes, but not quite.

When you hear the C “runtime”, its not a runtime in the traditional sense as in, there’s no VM or an interpreter that runs your programs, instead runtime in this case basically means that the main function gets wired to the crt0 startup routines.

The crt0 routines do a bunch of things including (but not limited to) making sure that you have a valid stack to work with, initializing the heap for you so that you can dynamically allocate memory, and gathering all the arguments to your program as well as the environment variables.

When you write C programs it’s not

1void main(int argc, char **argv, char **envp) {
2    // ..
3}

but something along the lines of

 1int main(int argc, char **argv, char **envp) {
 2    // ..
 3}
 4
 5// 👇 crt0 does this for you
 6void _start() {
 7    int argc = ...;
 8    char **argv = ...;
 9    char **envp = ...;
10    
11    /* setup stuff */
12
13    // 👇 Call to the main function that you wrote
14    int exit_code = main(
15        argc, 
16        argv, 
17        envp
18    ); 
19   
20    /* cleanup stuff */
21    exit(exit_code);
22}

When you start a program, it doesn’t actually jump to the main function but instead, it jumps to a symbol called _start (unless you intentionally override the default and tell the linker to emit a different e_entry ELF header)

In our case, we need to tell the linker to not expect the C runtime.

We do this by passing in additional arguments to our command

1# Linux
2cargo rustc -- -C link-arg=-nostartfiles
3
4# Windows
5cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
6
7# macOS
8cargo rustc -- -C link-args="-e __start -static -nostartfiles"

Now, if we try to build our kernel using these arguments

1Compiling zeno v0.1.0 (/path/to/zeno)
2 Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s

🎉 Woohooo!!

We’re now completely detached from our OS boundaries and have ventured into the deep waters. We’re on our own now.

There’s no entry point to our program anymore, so we can’t run anything.

But still. At least the code compiles.


In the next post, we’ll explore how we’ll setup a new entry point to our OS, and also try to get it to boot.

Next: Booting into our kernel