Create a Freestanding Rust Binary for a Kernel
Mục tiêu: Configure a Rust project for bare-metal kernel development by disabling the standard library (no\_std) and the main entry point (no\_main), and implementing a required panic handler to resolve linker errors.
Having successfully configured our entire toolchain in the previous step, we’ve transformed cargo run into a powerful command that builds our kernel, bundles it with a bootloader, and launches it in QEMU. However, our last attempt to run it was met with a linker error: undefined symbol: main. This error is our signal to shift focus from project configuration to writing the actual kernel code. We’re now ready to address this error head-on by telling the Rust compiler that our program is not a typical OS-hosted application.
The Standard Library: A Luxury We Don’t Have
By default, every Rust program links against the standard library, known as std. This library is incredibly useful, providing high-level abstractions for threads, collections, file I/O, networking, and more. It achieves this by acting as a wrapper around the functionality provided by the underlying operating system. For example, when you call std::fs::read_to_string, the std library makes system calls to the OS kernel to open the file, allocate memory, and read the data.
This presents a fundamental problem for us: we are writing the operating system. There is no underlying OS to provide these services. We cannot use files because we haven’t written a filesystem driver yet. We cannot create threads because we haven’t built a scheduler. Therefore, linking against the standard library is not an option. We must explicitly tell the compiler that we will build our kernel without it.
Going #![no_std]
To disable the automatic inclusion of the standard library, we use a crate-level attribute at the very top of our main source file (src/main.rs): #![no_std].
Placing this attribute at the top of our crate tells the Rust compiler not to link std. This has a few immediate consequences: * We lose access to all std APIs, including the println! macro, Vec, String, and anything that requires memory allocation or OS services. * We gain the ability to compile for bare-metal targets. * We still have access to the core library (core). The core crate is a platform-agnostic subset of std that contains fundamental types like Option, Result, iterators, and basic primitives. It has no dependencies on an OS, which is why we instructed Cargo to recompile it for our custom target in a previous step using the build-std feature.
Deconstructing the main Entry Point
The second issue is the main function itself. In a standard program, main is not the true entry point. The C runtime library (crt0), linked by the compiler, contains the actual starting point. This runtime sets up the environment (e.g., initializing the stack and placing command-line arguments where the program can find them) and then calls the main function written by the developer.
Since we have no C runtime and no OS to launch us, the concept of a main function is meaningless. The bootloader will load our kernel into memory and jump directly to a specific entry point that we define. We need to tell the compiler to stop looking for a main function and allow us to define our own entry point. This is done with another crate-level attribute: #![no_main].
Applying the Changes to main.rs
It’s time to put this theory into practice. Open your src/main.rs file. It currently contains the standard “Hello, world!” program generated by cargo new. Delete all the existing code in that file and replace it with the following:
// src/main.rs
#![no_std] // Don't link the Rust standard library
#![no_main] // Disable all Rust-level entry points
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
Let’s break down this new code:
#![no_std]: As discussed, this attribute is placed at the top of the file to signal that we are building a “freestanding” binary, not one that links against the standard librarystd.#![no_main]: This attribute tells the compiler that we won’t be using the standardmainfunction as our entry point. We will define our own, OS-specific entry point later.-
The
panicfunction: Once you add#![no_std], the compiler requires you to provide your own panic handler. In a standard program, when a panic occurs (e.g., due to an array index out of bounds), the standard library takes over and prints an error message, unwinds the stack, and exits the program. Since we have nostd, we must provide our own function to handle this situation.#[panic_handler]: This attribute designates the function as the one the compiler should call when a panic occurs.fn panic(_info: &PanicInfo) -> !: The function must have this exact signature. It takes a single argument,PanicInfo, which contains information about where the panic occurred (file, line, message). The return type!signifies that this is a diverging function, meaning it never returns.loop {}: For now, our implementation is very simple. We just enter an infinite loop. This prevents the CPU from executing random memory if it panics, effectively freezing the system. This is a safe and common approach for a minimal kernel.
The New State of Our Build
With these changes, you have officially started writing your kernel. The code no longer depends on any external operating system. Let’s see what happens when we try to build it now. Run the build command in your terminal:
cargo build
This time, the linker error about a missing main symbol is gone! However, you will be greeted by a new linker error:
error: linking with `rust-lld` failed: exit status: 1
...
= note: rust-lld: error: entry symbol not defined: _start
This error is fantastic news. It shows that we have successfully removed the dependency on the main entry point. The linker is now complaining that it cannot find _start, which is the default entry point name it looks for on most bare-metal systems. We have successfully told the compiler what not to do, and now we need to tell it what to do instead.
Our very next task is to define this _start function, which will serve as the true entry point for our kernel, finally resolving the last of the linker errors.
Further Reading
To solidify your understanding of these fundamental concepts, here are some excellent resources:
- A Freestanding Rust Binary: The first chapter of the “Writing an OS in Rust” blog series, which covers these topics in great detail.
- The
corelibrary documentation: Explore the official documentation to see what modules and types are available to you in ano_stdenvironment. - The
PanicInfoStruct: Details on the information provided to the panic handler.
Implement a Custom Panic Handler for a Bare-Metal Rust Kernel
Mục tiêu: Define a custom panic handler function using the #[panic\_handler] attribute to manage unrecoverable errors in a #![no\_std] Rust environment. This function will halt execution in an infinite loop, satisfying the compiler’s requirement for a panic strategy.
In our last step, we took a monumental leap toward creating a true bare-metal executable by adding the #![no_std] and #![no_main] attributes to our main.rs file. This act severed our kernel’s ties to the host operating system’s standard library and its conventional startup process. While this was a necessary and powerful move, it leaves a crucial question unanswered: what happens when something goes wrong?
In a standard Rust program, if the code encounters an unrecoverable error—like accessing an array with an out-of-bounds index—it panics. The standard library (std) catches this panic, prints a helpful error message with a backtrace, and gracefully terminates the program. By removing std, we’ve also removed this safety net. The compiler now requires us to provide our own function to handle these situations. This is our current task: to implement a custom panic handler.
Understanding Panics in a no_std World
A panic represents a state from which your program cannot recover. It’s a signal that an unexpected and fatal error has occurred, violating a program’s fundamental invariants. The Rust compiler enforces that we must account for this possibility.
When a panic is triggered, the compiler can employ one of two strategies:
- Unwinding: This is the default strategy in most environments. The program walks back up the call stack, meticulously running the destructors for all objects that are still in scope. This ensures that resources (like memory or file handles) are properly cleaned up. However, this process is complex and relies on libraries and runtime support that are part of an operating system—support that we, as the OS developers, have not yet built.
- Aborting: This is a much simpler strategy. Upon a panic, the program immediately halts all execution without any cleanup. This is the only viable option for our bare-metal kernel because it has zero dependencies. You may recall that we already enforced this choice in our target JSON file with the line
"panic-strategy": "abort".
Even when we choose to abort, the compiler still needs a specific function to call when a panic is initiated. This function is known as the panic handler, and we designate it with the #[panic_handler] attribute.
Implementing the Panic Handler
Let’s add the required panic handler to our src/main.rs file. The code from the previous step already included this function as a necessity for the #![no_std] attribute to compile. Now, we will delve into a detailed explanation of what each part of that function means.
Your src/main.rs file should look exactly like this:
// src/main.rs
// Do not link the Rust standard library
#![no_std]
// Disable all Rust-level entry points
#![no_main]
use core::panic::PanicInfo;
/// This function is called on panic.
/// The `#[panic_handler]` attribute tells the compiler that this function
/// should be invoked when a panic occurs.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
// Enter an infinite loop. We can't do anything else at this point.
// Returning from the function is not an option, as the return type `!`
// indicates that this function never returns.
loop {}
}
Let’s break down this new function in detail:
#[panic_handler]: This is a special attribute that signals to the Rust compiler that this specific function is our designated panic handler. There can be only one such function in the entire crate.-
fn panic(_info: &PanicInfo) -> !: This is the function’s signature, and each part is significant.- The function name can be anything, but
panicis a strong convention. - The single argument,
_info, has the type&PanicInfo. ThePanicInfostruct contains information about the panic, such as the source file, line number, and an optional message. This is incredibly useful for debugging. For now, we are not using this information, so we prefix the variable name with an underscore (_) to tell the compiler that this is intentional, preventing a warning. In the future, we could use thisinfoobject to print a detailed error message to the screen. - The return type is
!, known as the “never type”. This special type tells the compiler that the function is diverging, meaning it will never return control to its caller. This is a crucial guarantee, as there is no sensible place to return to after a panic.
- The function name can be anything, but
loop {}: This is the body of our handler. It’s a simple, infinite loop. Why do we do this? Since the function can’t return, we must ensure it never ends. An infinite loop is the most straightforward way to halt the CPU’s execution. If we were to omit the loop and the function somehow ended, the CPU would attempt to execute whatever random bytes happen to be in memory after our function’s code, leading to unpredictable behavior and a likely system crash. By looping forever, we put our kernel into a stable, albeit frozen, state.
Verifying Our Progress
With the panic handler in place, let’s see how our build is affected. Run the build command in your terminal from the project root:
cargo build
The build will still fail, but with a different linker error than before:
error: linking with `rust-lld` failed: exit status: 1
...
= note: rust-lld: error: entry symbol not defined: _start
This error is precisely what we want to see. The compiler is no longer complaining about a missing panic handler. We have successfully fulfilled that requirement! The linker is now telling us about the next missing piece: the actual entry point of our program, which on bare-metal systems, is conventionally named _start.
You have now created a robust foundation for your kernel. It is completely self-contained and even has a defined, safe behavior for unrecoverable errors.
Our next task is to finally resolve this linker error by defining the _start function, which will serve as the true beginning of our kernel’s execution when the bootloader hands over control.
Further Reading
- The Rust Book: Unrecoverable Errors with
panic!: A great high-level overview of panicking in Rust. - The
PanicInfoStruct Documentation: The officialcorelibrary documentation forPanicInfo, detailing the information it provides. - The “Never” Type (
!): The official documentation for this special type. - The
#[panic_handler]attribute: The Unstable Book’s entry on thepanic_handlerfeature.
Define the \_start function using #[no\_mangle] to serve as the kernel entry point.
Mục tiêu:
In our previous step, we successfully implemented a custom panic handler, fulfilling a crucial requirement for our #![no_std] environment. This brought us one step closer to a bootable kernel, but it also led us to a new, final linker error: entry symbol not defined: _start. This error is not a setback; it’s the linker’s way of asking a fundamental question: “Now that you’ve told me there’s no main function, where does your program actually begin?”
Our current task is to answer that question by defining the one true entry point for our operating system kernel.
The True Beginning: The _start Function
In a typical application running on an OS like Windows or Linux, the main function feels like the beginning. However, it’s an abstraction. The real entry point is hidden within the C runtime library (crt0), which sets up the stack, prepares command-line arguments, and only then calls main.
In our bare-metal world, we have no C runtime. The bootloader, after initializing the CPU and setting up a basic memory layout, will load our compiled kernel into memory and jump directly to the first instruction. We must tell the linker exactly where that first instruction is. By convention on many platforms, including the one we’re targeting, the linker looks for a function with the specific name _start. This is the symbol it considers the entry point.
Preserving Our Function’s Name with #[no_mangle]
There’s a subtle but critical challenge we must overcome. The Rust compiler, to support features like generics and to prevent name collisions, performs a process called name mangling. It alters the names of functions in the final compiled binary. For example, a function you write as my_function might end up with a symbol name like _ZN7os_kernel11my_function17h0a1b2c3d4e5f6g7E.
This is a problem for us because the linker isn’t looking for a mangled name; it’s looking for the literal, exact name _start. To solve this, we use the #[no_mangle] attribute. Placing this attribute on a function tells the Rust compiler, “Do not perform name mangling on this function. Export it with the exact name I have given it.” This ensures that the linker can find our entry point.
Defining the Entry Point
Let’s add the _start function to our src/main.rs. We’ll also introduce one more piece of syntax, extern "C", which is vital for interoperability. The bootloader is not written in Rust and expects our entry point to follow the standard C Application Binary Interface (ABI). The ABI defines low-level conventions like how function arguments are passed (e.g., in which CPU registers or on the stack). By marking our function as pub extern "C", we ensure it follows these conventions, making it callable from the non-Rust bootloader.
Update your src/main.rs to include the new _start function.
// src/main.rs
// Do not link the Rust standard library
#![no_std]
// Disable all Rust-level entry points
#![no_main]
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
/// This function is the entry point of our kernel. The bootloader will call this function.
/// `no_mangle` ensures that the compiler does not change the name of this function.
/// `extern "C"` makes it follow the C calling convention, which is the standard for interoperability.
#[no_mangle]
pub extern "C" fn _start() -> ! {
// This is the beginning of our kernel. For now, we just loop infinitely.
loop {}
}
Let’s carefully examine the new function:
#[no_mangle]: As we discussed, this prevents the Rust compiler from changing the function’s name, ensuring the linker finds_start.pub: This keyword makes the function public, ensuring it is visible outside of its current module, which is necessary for the linker to see it.extern "C": This specifies the ABI. It ensures our function can be correctly called by the bootloader.fn _start() -> !: This is the function signature.- The name
_startis exactly what the linker is searching for. - The return type is
!, the “never” type. The entry point of an operating system should never, ever return. If it did, the CPU would have no instructions to execute and would likely crash or reboot. Using!enforces this critical rule at compile time.
- The name
loop {}: Just like our panic handler, the body of our entry point is currently an infinite loop. This gives the CPU something to do (spin endlessly) and prevents it from running off into unknown memory. Our kernel is now running, albeit in a halted state.
The Moment of Truth: A Successful Build
With the _start function in place, we have finally provided all the pieces the linker needs. It has a panic handler for error conditions and an entry point for normal execution.
Open your terminal in the project’s root directory and run the build command:
cargo build
This time, you should be greeted with a successful compilation!
Compiling os_kernel v0.1.0 (/path/to/your/project/os_kernel)
Finished `dev` [unoptimized + debuginfo] target(s) in 0.50s
This is a major milestone. You have successfully compiled a completely freestanding, bare-metal Rust binary. Now, for the real test: let’s run it.
cargo run
QEMU will launch, you’ll briefly see some text from the bootimage bootloader, and then… a blank, black screen. The QEMU window will be running, but nothing will happen inside it.
This black screen is the beautiful sight of success! Your kernel has been loaded by the bootloader, the CPU has jumped to your _start function, and it is now executing your loop {} instruction, just as you programmed it to do. Your operating system is running.
You have now created a minimal, bootable kernel. The next exciting step is to make it do something visible. We will create a module to interact directly with the VGA text buffer—the memory-mapped hardware that controls what characters appear on the screen—and begin the process of printing our first message.
Further Reading
- OSDev.org Wiki on Entry Point: A deep dive into the conventions for kernel entry points.
- The
#[no_mangle]Attribute: The official documentation in the Rust Reference. - Rust’s Foreign Function Interface (FFI): The section of The Rust Book explaining how Rust can interface with code written in other languages, including the concept of
extern "C".
Implement VGA Buffer Data Structures in Rust
Mục tiêu: Define the necessary Rust data structures (enums and structs) to safely interface with the memory-mapped VGA text buffer. This involves creating a Color enum, a ColorCode newtype, and a ScreenChar struct, using appropriate #[repr] attributes to ensure correct memory layout for hardware interaction.
Congratulations on successfully building and running your first minimal kernel! That blank, black screen in QEMU is a significant achievement—it’s the blank canvas upon which we will paint our operating system. Your kernel is alive and executing the infinite loop in your _start function.
The logical next step is to make our kernel communicate with the outside world. The most fundamental form of output is printing text to the screen. To do this, we won’t be using a simple println! macro, as that’s part of the standard library we’ve left behind. Instead, we will interact directly with the hardware that controls the display: the VGA text buffer. Our current task is to create the Rust data structures that will represent this hardware interface in a safe and structured way.
Interfacing with Hardware: Memory-Mapped I/O
In bare-metal development, one of the most common ways to communicate with hardware is through memory-mapped I/O. This technique maps hardware control registers or memory buffers to specific addresses in physical memory. From the CPU’s perspective, there’s no difference between writing to RAM and writing to one of these special memory regions. When the CPU writes a value to a memory-mapped address, the hardware device connected to that address reacts.
For legacy VGA text mode, the buffer for the screen is mapped to the physical memory address 0xb8000. This buffer is a grid of character cells, typically 25 rows high and 80 columns wide. Each character cell on the screen corresponds to two bytes in this memory region.
- Byte 1: The ASCII code for the character to be displayed.
- Byte 2: The color attribute, controlling the foreground and background color, and whether the character blinks.
Our goal is to create Rust types that precisely model this two-byte structure.
Creating a New Module
To keep our code organized, we’ll place all the VGA-related logic into its own module.
- Create a new file in your
srcdirectory namedvga_buffer.rs. - Declare this new module in your
src/main.rsfile by addingmod vga_buffer;at the top.
Your src/main.rs should now start like this (we are highlighting only the new line):
// src/main.rs
// Do not link the Rust standard library
#![no_std]
// Disable all Rust-level entry points
#![no_main]
mod vga_buffer;
use core::panic::PanicInfo;
// ... rest of the file
Now, all the following code will be added to your new src/vga_buffer.rs file.
Representing Colors with an Enum
The color attribute byte is composed of a 4-bit foreground color and a 4-bit background color. A Rust enum is the perfect tool to represent the 16 available colors in a type-safe way.
We use the #[repr(u8)] attribute to specify that each enum variant should be represented as an 8-bit unsigned integer. This is crucial because we need to be able to cast these colors to a u8 when we write them to the buffer.
Add the following code to src/vga_buffer.rs:
// src/vga_buffer.rs
#[allow(dead_code)] // This attribute disables warnings for unused variants
#[derive(Debug, Clone, Copy, PartialEq, Eq)] // Derive traits for printing, copying, and comparing
#[repr(u8)] // Represent each enum variant as a u8
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
DarkGray = 8,
LightBlue = 9,
LightGreen = 10,
LightCyan = 11,
LightRed = 12,
Pink = 13,
Yellow = 14,
White = 15,
}
#[allow(dead_code)]: We add this attribute because we will define all 16 colors, but we may not use every single one in our initial code. This prevents the compiler from issuing “unused variant” warnings.#[derive(...)]: This is a powerful Rust feature. It automatically implements certain traits for our type.Copy,Clone,Debug,PartialEq, andEqallow ourColorenum to be easily copied, printed for debugging, and compared.
Combining Colors into a ColorCode
Next, we need a type that represents the full color byte, containing both a foreground and background color. We’ll create a new struct for this.
// src/vga_buffer.rs
// ... (Color enum from above)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)] // Ensure the struct has the same memory layout as its single field
pub struct ColorCode(u8);
impl ColorCode {
/// Creates a new ColorCode from a foreground and background color.
/// The foreground color is in the lower 4 bits, and the background is in the higher 4 bits.
pub fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
This ColorCode is a newtype, which is a struct with a single field. This pattern allows us to create a distinct type (ColorCode) that wraps a primitive type (u8), giving us type safety. We can’t accidentally use a plain u8 where a ColorCode is expected.
#[repr(transparent)]: This attribute is a guarantee to the compiler that ourColorCodenewtype has the exact same memory layout and ABI as its single inner field,u8. This is essential for ensuring it can be correctly written to the hardware buffer.newfunction: This constructor takes our type-safeColorenums and performs the bitwise operations to create the finalu8value. The background color is shifted left by 4 bits, and then combined with the foreground color using a bitwise OR.
Defining a Screen Character
Finally, we’ll create a struct that represents the full two-byte character cell: the ASCII character and its ColorCode.
// src/vga_buffer.rs
// ... (Color enum and ColorCode struct from above)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)] // Guarantee a C-like field ordering
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
The #[repr(C)] attribute is the most critical part here. By default, Rust does not guarantee the order of fields in a struct’s memory layout; it may reorder them for optimization. However, the VGA hardware expects the ASCII byte to come first, followed by the color byte. #[repr(C)] instructs the compiler to lay out the fields in the exact order they are defined, just like a C struct, ensuring compatibility with the hardware.
You have now successfully defined the data structures needed to interface with the VGA text buffer. We have type-safe representations for colors and character cells, with compiler guarantees about their memory layout.
Even with this code in place, running cargo run will still result in the same blank screen. This is expected! We have only defined the types and constants; we haven’t written any code that actually writes instances of these structs to the 0xb8000 memory address.
In our very next task, we will build upon these structures to implement a function that writes a single character to the VGA buffer, taking our first step toward printing a real message on the screen.
Further Reading
- OSDev.org Wiki on VGA Text Mode: A comprehensive resource on how the VGA text buffer works at a low level.
- The Rust Reference on Type Layout (
repr): The official documentation explaining the different#[repr]attributes like(u8),(transparent), and(C). - The Rust Book on
derive: A detailed explanation of thederiveattribute and the common traits it can implement.
Rust VGA Buffer: Implementing write\_byte with unsafe and volatile
Mục tiêu: Create Buffer and Writer structs to model the VGA text buffer. Implement a write\_byte method to write a single character to the screen using unsafe Rust and write\_volatile for memory-mapped I/O.
Excellent! In the previous task, you meticulously defined the data structures that represent the VGA text buffer’s components: Color, ColorCode, and ScreenChar. You created the “what”—the building blocks of our on-screen text. Now, it’s time to create the “how” and the “where”—the logic to write these structures to the correct location in memory to make them appear on the screen.
Our current task is to implement the most fundamental writing primitive: a function that places a single byte (representing a character) onto the screen. This will involve working with raw pointers and introducing the concepts of unsafe Rust and volatile memory operations, which are cornerstones of low-level systems programming.
From Data Structures to a Physical Screen Buffer
We know from the last step that each character cell on the screen is represented by a ScreenChar struct. The hardware’s text buffer is simply a contiguous grid of these cells, typically 80 columns wide by 25 rows high. This entire grid is mapped to a specific physical memory address: 0xb8000.
Our first step is to model this entire grid in Rust. We will create a new struct, Buffer, that represents this 80x25 array. Then, we will create a Writer struct that will encapsulate the logic for writing to this buffer, keeping track of the current cursor position.
All of the following code should be added to your src/vga_buffer.rs file, building upon the Color, ColorCode, and ScreenChar types you already have.
// src/vga_buffer.rs
use core::ptr::write_volatile; // Import for volatile writes
// ... existing Color, ColorCode, and ScreenChar structs ...
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
/// Represents the entire VGA text buffer.
/// `repr(transparent)` ensures it has the same memory layout as its single field.
#[repr(transparent)]
struct Buffer {
chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
/// A writer that can write characters to the VGA text buffer.
/// It keeps track of the current position and color.
pub struct Writer {
column_position: usize,
color_code: ColorCode,
/// A mutable reference to the VGA buffer in memory.
/// The 'static lifetime ensures this reference is valid for the whole program runtime.
buffer: &'static mut Buffer,
}
Let’s analyze these new additions:
use core::ptr::write_volatile;: We import a crucial function from thecorelibrary that we will use to perform our memory write.- Constants: We define
BUFFER_HEIGHTandBUFFER_WIDTHto avoid magic numbers in our code, making it more readable and maintainable. BufferStruct: This struct wraps a 2D array ofScreenChars. The#[repr(transparent)]attribute is vital here. It guarantees that ourBufferstruct has the exact same memory layout as its innercharsfield. This means a pointer to aBufferis identical to a pointer to the 2D array, which is what we need to correctly map over the0xb8000memory region.WriterStruct: This is our main abstraction.column_position: Tracks the current horizontal position of the “cursor” on the last line.color_code: Stores the foreground and background color for any new characters we write.buffer: This is the most important field. It’s a mutable static reference (&'static mut) to aBuffer.&'static mut: This type means “a mutable reference that is valid for the entire duration of the program’s execution ('static)”. This is safe to use here because the VGA text buffer is a fixed piece of hardware memory that exists from the moment the computer boots up and never goes away.- We can create a reference to the
Bufferby casting the integer0xb8000into a raw mutable pointer (*mut Buffer) and then converting that into a safe reference within anunsafeblock. We will do this later when we instantiate theWriter.
Implementing the write_byte Method
Now we will implement the core logic on our Writer struct. We will create a method called write_byte that takes a single u8 and writes it to the screen at the current cursor position.
This is where we must interact with unsafe Rust. We are about to perform an operation—writing to an arbitrary memory location via a pointer—that the Rust compiler cannot possibly verify is safe. We, as the programmers, are taking on the responsibility of guaranteeing that this operation is correct.
Add the following impl block to your src/vga_buffer.rs file:
// src/vga_buffer.rs
// ... (previous code in this file) ...
impl Writer {
/// Writes a single ASCII byte to the buffer.
pub fn write_byte(&mut self, byte: u8) {
match byte {
// If the byte is a newline character, we call a helper function.
b'\n' => self.new_line(),
// For any other printable byte:
byte => {
// If the current line is full, wrap to the next line.
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
// For now, we always write to the last row. Scrolling will be implemented later.
let row = BUFFER_HEIGHT - 1;
let col = self.column_position;
let color_code = self.color_code;
let screen_char = ScreenChar {
ascii_character: byte,
color_code,
};
// This `unsafe` block is where we perform the direct memory write.
// We are asserting to the compiler that this operation is safe.
unsafe {
// `write_volatile` is used instead of a direct assignment (`=`)
// to prevent the compiler from optimizing this write away.
write_volatile(&mut self.buffer.chars[row][col], screen_char);
}
// Advance the cursor to the next column.
self.column_position += 1;
}
}
}
/// Moves the cursor to the beginning of the current line.
/// A full implementation will handle scrolling when the screen is full.
fn new_line(&mut self) {
self.column_position = 0;
// In a future task, this function will be expanded to shift all lines
// up by one, effectively "scrolling" the screen.
}
}
Deconstructing the unsafe and volatile Logic
This is the most critical part of this task. Let’s break it down:
match byte: We handle two cases. If the character is a newline (\n), we move the cursor. Otherwise, we treat it as a printable character. Theb'\n'syntax creates au8byte literal.let screen_char = ...: We construct theScreenCharstruct using the input byte and theWriter’s currentcolor_code.unsafe { ... }: We need this block because the operation inside,write_volatile, operates on raw pointers, which falls outside of Rust’s compile-time safety guarantees.write_volatile(&mut self.buffer.chars[row][col], screen_char): This is the core operation.- The Problem: A smart compiler might notice that we write to the
buffermemory but never read from it anywhere in our program. It could conclude that this write is useless “dead code” and optimize it away entirely. This would be catastrophic, as the write’s purpose is not for our program to read, but to create a side effect on the hardware (displaying a character). - The Solution:
write_volatiletells the compiler, “This write is important and has side effects. You must not reorder it or optimize it away under any circumstances.” It is absolutely essential for memory-mapped I/O. - The first argument is the destination: a mutable raw pointer to the target
ScreenCharin our buffer. - The second argument is the
ScreenCharvalue we want to write.
- The Problem: A smart compiler might notice that we write to the
You have now created a function that can safely write a single character to the screen, complete with a robust abstraction (Writer) that hides the underlying unsafe complexity.
If you cargo run your project now, you will still see a blank screen. This is expected! We have created the tool (write_byte), but we haven’t created an instance of the Writer yet, nor have we called our new function from the _start entry point.
The next task is to build on this foundation by implementing a write_string function that uses write_byte in a loop to print an entire string of text.
Further Reading
To deepen your understanding of these advanced, low-level concepts, please explore these resources:
- The Rustonomicon on Volatile Operations: The definitive guide to
volatilein unsafe Rust. core::ptr::write_volatileDocumentation: The official API documentation for the function we used.- The Rustonomicon on Unsafe Rust: A must-read for understanding the
unsafekeyword and its implications.
Implement String and Formatted Writing for VGA Buffer
Mục tiêu: Extend the VGA text buffer writer by implementing a write\_string method to display full strings and by implementing the core::fmt::Write trait to enable Rust’s standard formatting macros.
Fantastic work on implementing the write_byte function! You’ve successfully bridged the gap between Rust code and the physical hardware of the VGA text buffer, handling the complexities of raw pointers, unsafe blocks, and volatile memory writes. This single-character function is the fundamental atom of our text output system.
Now, we’ll build upon that atom to create something more powerful and useful: a function that can write an entire string to the screen. This will move us from manipulating individual bytes to working with the familiar &str type, making our printing logic much more ergonomic.
From Bytes to Strings
The write_byte method is powerful but low-level. To print a message like “Hello, World!”, calling write_byte for each character would be tedious. The natural next step is to create a write_string method that takes a Rust string slice (&str) and iterates over its bytes, calling our existing write_byte method for each one. This is a perfect example of building safe, high-level abstractions on top of low-level unsafe code.
Let’s add this new method to the impl Writer block in your src/vga_buffer.rs file.
// In src/vga_buffer.rs
// ... inside the `impl Writer` block ...
/// Writes a full string to the VGA text buffer.
///
/// Non-printable ASCII characters (bytes outside the range 0x20..=0x7e)
/// are printed as the '■' character (code 0xfe).
pub fn write_string(&mut self, s: &str) {
// Iterate over the bytes of the input string slice.
for byte in s.bytes() {
match byte {
// If the byte is a printable ASCII character or a newline, write it.
0x20..=0x7e | b'\n' => self.write_byte(byte),
// Otherwise, write our "unprintable character" code.
_ => self.write_byte(0xfe),
}
}
}
Let’s examine this new function in detail:
pub fn write_string(&mut self, s: &str): The function is public so it can be called frommain.rs. It takes a mutable reference toselfbecause it modifies theWriter’s state (specifically, thecolumn_position). The second argument iss: &str, a string slice, which is Rust’s primary view into string data and is available incore.for byte in s.bytes(): The.bytes()method on a string slice returns an iterator over the string’s underlyingu8bytes. This is exactly what we need, as ourwrite_bytefunction operates onu8.-
match byte { ... }: This is a crucial piece of logic for robustness. A&strin Rust is guaranteed to be valid UTF-8, which is a variable-width character encoding. However, the VGA text buffer is much simpler; it expects single-byte characters from a character set called Code Page 437. For now, we are simplifying even further and only supporting printable ASCII characters.0x20..=0x7e | b'\n': This pattern matches any byte that is either in the range of printable ASCII characters (from space to~) or is a newline character. For these bytes, we callself.write_byte(byte)as expected._: This is a “catch-all” pattern. If a byte is not printable ASCII (e.g., it’s a null byte, a tab, or part of a multi-byte UTF-8 character), we don’t want our kernel to crash or display garbage. Instead, we callself.write_byte(0xfe), which will print a solid block character (■), a common placeholder for unprintable characters.
Best Practice: Unlocking Rust’s Formatting Macros
We’ve created a great write_string method, but we can make our Writer even more powerful by integrating it with Rust’s standard formatting infrastructure. In core, there is a trait called core::fmt::Write. If a type implements this trait, it can be used with the write! and writeln! macros, giving us the ability to format numbers, strings, and other types with a familiar, type-safe API.
This is a fantastic example of Rust’s philosophy: by implementing a small, standard interface, our custom type gains access to a huge amount of existing functionality for free.
Add the following code to the end of your src/vga_buffer.rs file.
// In src/vga_buffer.rs, after the `impl Writer` block
use core::fmt;
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
use core::fmt;: We first need to bring thefmtmodule into scope.impl fmt::Write for Writer: This block tells the compiler we are providing an implementation of theWritetrait for ourWriterstruct.fn write_str(...) -> fmt::Result: TheWritetrait requires just one method,write_str. Its job is to take a string slice and write it to the underlying destination.self.write_string(s);: The implementation is incredibly simple! We just call thewrite_stringmethod we just created.Ok(()): Thewrite_strmethod must return afmt::Result. Since our VGA writer cannot fail in a way thatfmtunderstands (we just print a placeholder for bad characters), we always returnOk(())to signal success. The()is the “unit type,” which is the Rust equivalent ofvoid.
You have now created a fully-featured text writer for your kernel. It can handle single bytes and full strings, gracefully deals with unsupported characters, and integrates seamlessly with Rust’s standard formatting macros.
As before, running cargo run will still result in a blank screen. We have assembled all the tools for printing, but we haven’t yet called them from our _start function to actually print a message. That is the very next, and very exciting, task!
Further Reading
- Rust Primitive Type
str: The official documentation for Rust’s string slice type. - The
core::fmt::WriteTrait: A detailed look at the trait that enables formatting macros. - Code Page 437: The character set used by the original IBM PC, which the VGA text mode is based on.
Create a Global VGA Writer for Kernel Printing
*Mục tiêu: Implement a globally accessible, thread-safe writer for the VGA text buffer in a Rust kernel. This involves using the lazy\_static macro for one-time initialization and wrapping the writer in a Mutex from the spin crate to ensure safe concurrent access. The final step is to use this global writer from the \_start entry point to print *
You have done some truly excellent work. In the last few tasks, you’ve built a complete, low-level printing system from the ground up. You started with data structures representing the hardware (ScreenChar), created a low-level function to write a single byte (write_byte), and then built a safer, high-level abstraction on top of it (write_string and the fmt::Write trait). All the machinery is in place.
Now, it’s time for the moment we’ve been building towards: making something appear on the screen. Our current task is to call our printing code from the _start entry point to display a “Hello, World!” message. This will involve creating a single, globally accessible instance of our Writer so that it can be used from anywhere in our kernel.
The Need for a Global Writer and Safe Access
Our VGA Writer needs to be accessible from any part of our kernel’s code. For example, our main _start function will use it, and later, interrupt handlers for the keyboard or timer will also need to print things to the screen. This calls for a global, static instance of the Writer.
However, global mutable state is notoriously dangerous, especially in systems programming. If two different execution contexts (like the main function and an interrupt handler) try to modify the Writer at the same time, you could get a data race, leading to corrupted data or a system crash. The Rust compiler rightly prevents this with its strict ownership and borrowing rules. A simple static mut is possible but is highly discouraged as it requires unsafe blocks for every access and offers no protection against data races.
The idiomatic and safe solution in Rust involves two powerful concepts:
- Lazy Initialization: Our
Writerinitialization involves a non-constant operation (casting0xb8000to a pointer), so we can’t initialize it as a simplestatic. We need a way to initialize it at runtime, the very first time it’s used. Thelazy_staticcrate is perfect for this. - Interior Mutability with Synchronization: To allow safe mutation of the global
Writerfrom multiple contexts, we will wrap it in a Mutex (Mutual Exclusion). A mutex is a synchronization primitive that ensures only one thread of execution can access the data at any given time. We will use a “spinning” mutex from thespincrate, which is ideal forno_stdenvironments as it doesn’t require any OS features.
Step 1: Add Dependencies
First, let’s add the lazy_static and spin crates to our project. Open your Cargo.toml file and add the following lines under the [dependencies] section. For lazy_static, we also need to enable a feature flag for it to work in a no_std environment.
# In Cargo.toml
[dependencies]
# Add these two lines
spin = "0.5.2"
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
Step 2: Create the Global Writer
Now, let’s create our global WRITER instance. Open your src/vga_buffer.rs file and add the following code. This will define the static writer and make it available to the rest of the crate.
Here are the changes for src/vga_buffer.rs:
// In src/vga_buffer.rs
use core::fmt;
use lazy_static::lazy_static;
use spin::Mutex;
use volatile::Volatile; // We will refactor to use the volatile crate for clarity
// ... (Color, ColorCode, ScreenChar structs) ...
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
#[repr(transparent)]
struct Buffer {
// We now use `Volatile` to make our intent clearer
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
// ... (Writer struct, but without `write_volatile` in its `write_byte` method) ...
lazy_static! {
/// A global `Writer` instance that can be used for printing to the VGA text buffer.
///
/// Used by the `print!` and `println!` macros.
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
// This unsafe block is necessary to create a mutable reference to the static VGA buffer.
// It is safe because the VGA buffer is always at address 0xb8000.
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
Wait, volatile is not in core. We actually need another crate for that. Let’s backtrack slightly. The write_volatile and read_volatile functions are the right way to do this without an extra dependency. But using a Volatile wrapper type from the volatile crate can make the code clearer. Let’s stick with the core::ptr::write_volatile to avoid adding another dependency right now. We can refactor write_byte to use it correctly.
Let’s do the correct and simple approach first. We will use lazy_static and spin to create a global WRITER.
Correct Step 2: Create the Global Writer
Open your src/vga_buffer.rs file and add the use statements and the lazy_static! block.
// In src/vga_buffer.rs
// Add these use statements at the top
use lazy_static::lazy_static;
use spin::Mutex;
// ... (Keep your existing code for Color, ColorCode, ScreenChar, Buffer, Writer, and the fmt::Write impl) ...
// Add this block at the bottom of the file
lazy_static! {
/// A global `Writer` instance that can be used for printing to the VGA text buffer.
///
/// It is wrapped in a `Mutex` to ensure that only one execution context can
/// access it at a time, preventing data races.
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
// Creating a mutable reference to this memory-mapped hardware buffer is an
// unsafe operation, as the compiler can't verify its validity. We are
// guaranteeing that this is correct.
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
Let’s break down this powerful macro: * lazy_static! { ... }: This macro from the lazy_static crate allows us to define a static variable that is initialized at runtime on its first use. * pub static ref WRITER: Mutex<Writer>: We declare a public, static variable named WRITER. The ref keyword indicates that it’s a reference, and its type is Mutex<Writer>. * Mutex::new(Writer { ... }): Inside the macro, we create a new Mutex that wraps a newly created instance of our Writer. We initialize it with a starting column position of 0, a nice yellow-on-black color scheme, and most importantly, the reference to the VGA buffer at 0xb8000. The unsafe block here is necessary and self-contained; after this one-time setup, all future access will be safe.
Step 3: Use the Global Writer in _start
Now that we have a global WRITER, we can finally use it in our kernel’s entry point. Open src/main.rs. We will modify the _start function to print our welcome message.
// In src/main.rs
#![no_std]
#![no_main]
mod vga_buffer;
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle]
pub extern "C" fn _start() -> ! {
// Use the `write_string` method from our global WRITER instance.
// The `lock()` method on the Mutex grants us exclusive access.
vga_buffer::WRITER.lock().write_string("Hello, World!");
// We can also use the `fmt::Write` trait we implemented.
// This requires bringing the trait into scope.
use core::fmt::Write;
writeln!(vga_buffer::WRITER.lock(), "The numbers are {} and {}", 42, 1337).unwrap();
// An infinite loop to prevent the CPU from halting or executing garbage.
loop {}
}
Let’s analyze the changes to _start:
- We’ve removed the initial
loop {}. vga_buffer::WRITER.lock(): We access our global staticWRITER. The.lock()method on theMutexwaits (spins) if the lock is held by someone else, and once acquired, it returns a mutable guard object. This guard allows us to safely access and mutate theWriterinside. When the guard goes out of scope at the end of the statement, the lock is automatically released..write_string("Hello, World!"): We call our familiar method on the lockedWriter.- Using
writeln!: To demonstrate the power of implementing thefmt::Writetrait, we also use the standardwriteln!macro. *use core::fmt::Write;: This trait must be in scope to get access to the methods that macros likewriteln!use. *writeln!(...).unwrap(): We call the macro, passing it the locked writer and our format string with arguments. The macro returns afmt::Result, which we.unwrap()on. This is safe because ourwrite_strimplementation never returns anErr. loop {}: We add an infinite loop at the end. This is critical. After printing our message, our kernel has nothing else to do. Without this loop, the_startfunction would return, and the CPU would attempt to execute whatever random data comes after our kernel’s code, leading to a crash.
You have now connected all the pieces. You have a global, safe interface to the VGA buffer, and you are calling it from your kernel’s entry point.
The very next task is the grand finale of this step: building and running the kernel to finally see your message appear in the QEMU emulator!
Further Reading
- The
lazy_staticCrate: The official documentation for this essential tool. - The
spinCrate: Documentation for the simple and effective spinlock mutex. - Rust Book: Fearless Concurrency: A deep dive into Rust’s approach to concurrency safety, including mutexes.
- A Blog Post on
staticvsconst: Understanding the difference between these two keywords is fundamental to Rust.- https://www.google.com/search?q=rust+static+vs+const (A search query, as many great articles exist on this topic).
Run the Bootable Rust Kernel in QEMU
*Mục tiêu: Execute the cargo run command to compile your Rust-based operating system kernel, combine it with a bootloader using the bootimage tool, and launch it in the QEMU emulator to see your *
This is it. The culmination of all your setup and coding efforts in this step. You have meticulously configured your build environment, defined a bare-metal target, written a custom panic handler, established a kernel entry point, and built a complete, safe abstraction over the VGA text buffer. In the last task, you wrote the final lines of code in your _start function to call your global WRITER and print a message. Every single piece is now in place.
Your current and final task in this step is to execute a single, simple command that will unleash the entire toolchain you’ve assembled, transforming your Rust code into a bootable kernel and running it in an emulator to see your message on the screen.
The Command That Does It All: cargo run
Let’s take a moment to appreciate what is about to happen when you run cargo run. Thanks to the configuration you’ve set up in .cargo/config.toml and Cargo.toml, this familiar command is now a powerhouse tailored for kernel development:
-
Build Phase: Cargo will first invoke the Rust compiler (
rustc).- It will read your
.cargo/config.tomland see that the default build target is your customx86_64-os_kernel.json. - It will notice the
build-stdflag and compile thecoreandcompiler_builtinslibraries from source, specifically for your bare-metal target. - It will compile your new dependencies,
spinandlazy_static. - Finally, it will compile your kernel code from
src/main.rsandsrc/vga_buffer.rs, linking everything together into a single, freestanding ELF (Executable and Linkable Format) file.
- It will read your
-
Run Phase: Instead of trying to execute this ELF file on your host OS (which would be impossible), Cargo sees the
runnercommand you specified.- It executes
bootimage runnerand passes the path to your compiled kernel as an argument. - The
bootimagetool takes over. It downloads a pre-compiled, compatible bootloader. - It combines this bootloader with your kernel, creating a complete, bootable disk image in memory.
- It then launches the QEMU emulator (
qemu-system-x86_64) and instructs it to boot from this newly created disk image.
- It executes
The Moment of Truth
Go to your terminal, which should be in the root directory of your os_kernel project, and execute the following command:
cargo run
The Sight of Success
First, you will see output from Cargo in your terminal as it compiles your project. It will look something like this:
Compiling spin v0.5.2
Compiling lazy_static v1.4.0
Compiling os_kernel v0.1.0 (/path/to/your/project/os_kernel)
Finished `dev` [unoptimized + debuginfo] target(s) in X.XXs
Running `bootimage runner /path/to/your/project/os_kernel/target/x86_64-os_kernel/debug/os_kernel`
Immediately after, a new window with the title “QEMU” will pop up. For a brief moment, you might see some text from the bootloader, and then you will see it:
A black screen with your message printed on the last two lines in bright yellow text.
Hello, World!
The numbers are 42 and 1337
This is your kernel, running on bare metal, controlling the hardware and printing to the screen.
The QEMU window will remain open, and your kernel will be “running” inside it. The cursor won’t be blinking, and nothing else will happen. This is the correct and expected behavior because the last instruction in your _start function is an infinite loop (loop {}), which effectively halts the CPU in a safe, busy-wait state after it’s done printing. To exit, you can simply close the QEMU window or press Ctrl+C in the terminal that launched the command.
Congratulations! You have successfully completed one of the most iconic and rewarding milestones in systems programming. You’ve gone from a blank directory to a custom-built, bootable operating system kernel that prints “Hello, World!” to the screen.
What’s Next?
You now have a stable base to build upon. However, our kernel is still very fragile. If any error were to occur, such as a program trying to divide a number by zero, the CPU would trigger an exception. Without any handlers for these exceptions, the system would crash unpredictably.
In the next step of our journey, we will fortify our kernel by implementing handlers for these critical CPU exceptions. This involves setting up a crucial CPU data structure called the Interrupt Descriptor Table (IDT), which will allow us to catch hardware and software exceptions and handle them gracefully, making our kernel significantly more robust.
Further Reading
To reflect on what you’ve achieved and learn more about the underlying processes, check out these resources:
- OSDev.org Wiki: Boot Sequence: A detailed overview of what happens from the moment power is applied to the point where the kernel’s entry point is called.
- The
bootimageRunner: The official documentation explaining how therunnerfeature integrates withcargo run. - QEMU User Documentation: The official manual for the powerful emulator you are now using.