17

I'm trying to write an x86 bootloader and operating system completely in Rust (no separate assembly files, only inline assembly within Rust).

My bootloader works completely as intended within the QEMU emulator, I'm far into development of my kernel and my bootloader has never failed. I'm just worried if my bootloader has any undefined behavior and this behavior just happens to work for me.

One of the first "bootloader" things I need to do is set the stack pointer to a valid area of memory to act as my bootloader's stack. My bootloader uses local variables, and also sets the stack pointer with inline assembly.

// extreme oversimplification of what I have as my bootloader

#![no_std]
#![no_main]

#[no_mangle]
fn entry() -> !
{
    // place the stack area just before the boot sector
    unsafe { core::arch::asm!
    (
        "mov sp, 0x7c00"
    )}

    let bootloader_variable_1 = ...;
    let bootloader_variable_2 = ...;
    // do things with bootloader variables
}

My main concern is that the compiler allocates some space for local variables on the stack before anything within my entry function is ran and the compiler expects these variables to be at specific offsets, but then I manually change the stack pointer, invalidating all of the offsets.

Looking at the disassembly (x86 Intex syntax) of the generated binary when built in release mode, I see...

push bx
sub sp, 12

mov sp, 0x7c00
...

The generated assembly runs two commands before my function, both of which edits the stack pointer, and then I overwrite it. I'm surprised there hasn't been any problems up to this point.

I'm not the most fluent in x86 assembly, but it seems like all instances of local variables are being optimized away from the stack and are used in processor registers instead, which is my best guess on why my bootloader works right now.

Is this cause for concern? Can I safely set the stack pointer in a no_std environment (at the very start of the program) and not corrupt any local variables? Will this scheme work on any and all Rust-compliant compiler? If not, is there any way to do this without some external assembly file?

I excluded my full bootloader stage 1 code and the full disassembly, but I can add it if anyone thinks it could help.

2 Answers 2

20

The way to do this would be to make _entry a naked function, for which the compiler will not generate any additional assembly code beyond exactly what you specify.

Naked functions are currently an experimental feature and will require a nightly compiler and the feature #![feature(naked_functions)]. Or, you can use the naked_function crate, which implements naked functions using the global_asm! macro available since Rust 1.59.0.

Either way, you will then write your entry function like so:

#[naked]
pub extern "C" fn entry() -> ! {
    unsafe {
        asm!(
            "mov sp, 0x7c00",
            "call {main}",
            "hlt",
            main = sym main,
            options(noreturn)
        );
    }
}

This sets up the stack pointer, then calls a main function as the real entry point; main can then be written as a regular Rust function as the stack has been set up. It then issues a HLT to stop the CPU, in case main returns.

2
  • 1
    Also, it’s customary to write the halt step as 1: cli ; hlt ; jmp 1b or at least cli ; hlt, to protect against interrupts being enabled when it is reached. Commented 2 days ago
  • 2
    main() must be an extern "C" function, as calling convention for Rust is unspecified. Commented 2 days ago
14

Yes, this is a concern. Function calls may include a prologue that may include any instruction, including ones that manipulate the stack.

Fortunately, Rust includes a solution. Unfortunately, it is still unstable. Fortunately, it was decided to stabilize. Unfortunately, it still wasn't (since 2022). Fortunately, there is a crate that covers 99% of the use cases, including yours (and really, it is just a wrapper around global_asm!()).

The solution is naked functions, functions that are guaranteed to have no prologue or epilogue. Unfortunately, that means the compiler cannot rely on properties necessary for Rust code being fulfilled, and that means that all they can have is a big giant asm!() block.

But that doesn't mean you need to write your code in assembly. You can make an extern "C" function that contains your code. Since it is extern "C", it will have a known calling convention, which means you can call it from your assembly after you do the required setup. But since the Rust compiler is allowed to insert prologue/epilogue, you can write normal Rust code there. But it will run only after your setup, as required.

Not the answer you're looking for? Browse other questions tagged or ask your own question.