Serial Output and Debugging
In the last post, we prepared the foundations for our operating system. We set up a basic build system using Cargo and Rust, and we created a simple bootloader to load our kernel into memory.
In this post, we will explore how to output text to the serial port and how to use it for debugging purposes.
We chose the serial interface because it has very few moving parts. We need a stable communication channel so that, even if everything else fails, we can still observe what is happening inside the kernel.
On top of that, emulators such as QEMU and Bochs allow serial output to be redirected to the host’s standard output or to a file. This is extremely useful because it lets us add logging statements to our kernel and observe its behavior externally.
In kernel development, you often won’t have a screen, a debugger, or even memory safety. A serial logger is your lifeline. If something crashes later, this is how we’ll know where.
Writing bytes to Serial I/O
Let’s start by writing some bytes to the serial port base address and see where that leads us.
1...
2fn main() {
3 let serial_port = 0x3F8;
4
5 // notice the `b` at the front, which turns a string into
6 // a static array of bytes
7 for &character in b"Hello, world!" {
8 unsafe {
9 ::core::arch::asm!(
10 "out dx, al",
11 in("dx") serial_port,
12 in("al") character,
13 options(nomem, nostack, preserves_flags)
14 );
15 }
16 }
17}
18...Here we use inline assembly to execute the out instruction, which transfers data from the al (or ax) register to the specified I/O port.
We specify the I/O port to be 0x3F8 which is the first serial communication port address (COM1). Then, in each iteration, we tell the CPU to first load the character into the al register, and then move it to the I/O port.
Additionally, we need to tell QEMU to redirect the Serial I/O over to the host computer’s stdio. This can be done by adding the following command-line argument to QEMU:
1-device isa-serial,chardev=serial0 -chardev stdio,id=serial0We already did this in our src/main.rs file while building the QEMU command, so we don’t need to do anything else.
Finally, when we try to run this (via cargo run uefi in our root directory), we see the following output (not in QEMU screen, but in the host machine’s terminal)
1....
2INFO : Map framebuffer
3INFO : Allocate bootinfo
4INFO : Create Memory Map
5INFO : Create bootinfo
6INFO : Jumping to kernel entry point at VirtAddr(0x8000001430)
7Hello, world! # 👈 Notice this lineNice! Next, instead of manually writing bytes to I/O ports using inline assembly we will be abstracting it away because inline assembly is not portable and can be difficult to maintain, so we’ll use the x86_64 crate that we installed in our housekeeping chapter and use that instead. This will also showcase how the x86_64 crate can be used to abstract away low-level details.
1fn main() {
2+ use x86_64::instructions::port::Port;
3- let serial_port = 0x3F8;
4+ let mut serial_port = Port::<u8>::new(0x3F8);
5
6 for &character in b"Hello, world!" {
7 unsafe {
8- ::core::arch::asm!(
9- "out dx, al",
10- in("dx") serial_port,
11- in("al") character,
12- options(nomem, nostack, preserves_flags)
13- );
14+ serial_port.write(character);
15 }
16 }
17}
Awesome! Run your code once to make sure it still works. It should behave exactly the same way since we have only abstracted away the instructions that we were manually writing.
Initializing the Serial interface properly
The astute among you might’ve noticed that we have just written bytes to a port and haven’t actually initialized the Serial I/O interface.
This happens to work in our case since QEMU sets things up for us, so we don’t have to perform the initialization ourselves. On real hardware, this would likely fail unless we explicitly configure the UART.
In order to make our kernel more robust and portable, we should initialize the Serial I/O interface properly.
The OS Dev Wiki states that in order to use the serial port reliably, we must configure its communication parameters. If both ends disagree on these settings, communication fails.
Alright. Let’s do that.
The hardware layer responsible for controlling the I/O through the serial port is called the Universal Asynchronous Receiver-Transmitter (UART).
We need to be able to program the UART controller in order to control serial behavior.
There are many UART controller models available on x86 systems, however most modern implementations are compatible with the 16550 specification.
The uart_16550 crate provides a safe interface to the UART chipset for initializing and controlling serial behavior, hence it would prove beneficial to use it. Let’s add that to our kernel.
1# in the `kernel` directory
2cargo add uart_16550Then instead of blindly writing bytes to the COM1 port, we’ll first initialize the Serial I/O interface using the uart_16550 crate and only then, write to the port.
1fn main() {
2- use x86_64::instructions::port::Port;
3+ use uart_16550::SerialPort;
4
5- let mut serial_port = Port::<u8>::new(0x3F8);
6+ let mut serial_port = unsafe { SerialPort::new(0x3F8) };
7+ serial_port.init();
8
9 for &character in b"Hello, world!" {
10- unsafe {
11- serial_port.write(character);
12- }
13+ serial_port.send(character);
14 }
15}
Additionally, we can use Rust’s trait system to write bytes in an even simpler way
1- use core::panic::PanicInfo;
2// 👇 This allows us to treat the serial port as a Writable target
3+ use core::{fmt::Write, panic::PanicInfo};
4
5fn main() {
6 use uart_16550::SerialPort;
7 let mut serial_port = unsafe { SerialPort::new(0x3F8) };
8
9 serial_port.init();
10
11 // 👇 instead of manually iterating the bytes in our string and writing them out one by one
12- for &character in b"Hello, world!" {
13- serial_port.send(character);
14- }
15
16 // We'll use the write macro with the serial_port as the target.
17 // and write our string to it
18+ write!(serial_port, "Hello, World!").expect("failed to write to serial output");
19}
Using the serial port for logging
As discussed earlier, we want to use the serial port for logging. We’ll also implement a few macros for better ergonomics.
By adding the #[no_std] attribute, we got rid of Rust stardard library which includes the dbg! macro as well, so we’ll try to replace it by writing one of our own.
At the end, I’ll be super happy if we can get something like this working
1dbg!("This is a log statement");Let’s dive in.
We first need to actually design this abstract system.
We need macros that take in arguments, format them, and write the formatted string to a Serial Port. Instead of creating and initializing a Serial Port object each time we want to log something, we’ll create a global serial interface object that we can access to log messages from our kernel.
We’ll follow the same structure we defined in the housekeeping chapter.
Create a new module in the kernel crate called serial.rs, and import it in the lib.rs
1/// Serial I/O and HAL via UART 16550
2pub mod serial;We’ll then create a global serial port object like so
1use uart_16550::SerialPort;
2
3const COM1_UART_PORT: u16 = 0x3F8;
4
5/// Global COM1 Serial I/O Interface
6pub static SERIAL1: SerialPort = {
7 let mut serial_port = unsafe { SerialPort::new(COM1_UART_PORT) };
8
9 serial_port.init();
10
11 serial_port
12};Rust immediately denies this by saying:
1Compiling kernel v0.1.0 (/path/to/zeno/kernel)
2error[E0015]: cannot call non-const method `SerialPort::init` in statics
3--> kernel/src/serial.rs:9:17
4|
59 | serial_port.init();
6| ^^^^^^
7|
8= note: calls in statics are limited to constant functions, tuple structs and tuple variants
9= note: consider wrapping this expression in `std::sync::LazyLock::new(|| ...)`
10
11For more information about this error, try `rustc --explain E0015`.
12error: could not compile `kernel` (lib) due to 1 previous errorDon’t worry, this error is expected.
static variables are evaluated at compile time and the init function has some runtime behavior such as configuring the baud rate, the data bits, parity etc. and hence, it cannot be computed during compilation.
We’ll need some sort of lazy initialization primitive in order to fix this.
Rust graciously suggests that we use something called a std::sync::LazyLock instead, however given our constraints of a no_std environment, the most common and widely used solution for these kinds of scenarios is a crate called lazy_static along with its spin_no_std feature.
Let’s install the crate (make sure to add the --features spin_no_std flag as well, so it is no_std compatible)
1# in the kernel directory
2cargo add lazy_static --features spin_no_stdThen, in our serial.rs file, wrap our serial object with the lazy_static! macro. Remember to add the ref after static.
1+ use lazy_static::lazy_static;
2
3+ lazy_static! {
4 /// Global COM1 Serial I/O Interface
5- pub static SERIAL1: SerialPort = {
6+ pub static ref SERIAL1: SerialPort = {
7 let mut serial_port = unsafe { SerialPort::new(COM1_UART_PORT) };
8
9 serial_port.init();
10
11 serial_port
12 };
13+ }
Next, in our main.rs file, we’ll use the SERIAL1 static reference and write to it.
1+ use kernel::serial::SERIAL1;
2
3fn main() {
4- use uart_16550::SerialPort;
5- let mut serial_port = unsafe { SerialPort::new(0x3F8) };
6
7- serial_port.init();
8
9- write!(serial_port, "Hello, World!").expect("failed to write to serial output");
10+ write!(SERIAL1, "Hello, World!").expect("failed to write to serial output");
11}
Whoops! We have another problem in our hands. Rust says:
1error[E0596]: cannot borrow data in dereference of `SERIAL1` as mutable
2 --> kernel/src/main.rs:86:12
3 |
486 | write!(SERIAL1, "Hello, World!").expect("failed to write to serial output");
5 | ^^^^^^^ cannot borrow as mutable
6 |
7 = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `SERIAL1`
8
9For more information about this error, try `rustc --explain E0596`.
10error: could not compile `kernel` (bin "kernel") due to 1 previous errorAlright, we need a mutable reference to the SERIAL1 object, but we can’t since it is a static variable. Rust prevents static mutability because it can lead to data races and other concurrency issues.
To fix this, we need some kind of mutual exclusion. We can utilize a mutex but our no_std constraint prevents us from using the standard library’s Mutex so instead, we’ll use the spin crate, which provides a spinlock implementation of a mutex. (Spin lock is a synchronization primitive that repeatedly tries to acquire the lock in a loop until it is released.)
1# in the kernel directory
2cargo add spinand then use the spin::Mutex in our serial module
1+ use spin::Mutex;
2
3- pub static ref SERIAL1: SerialPort = {
4+ pub static ref SERIAL1: Mutex<SerialPort> = {
5 let mut serial_port = unsafe { SerialPort::new(COM1_UART_PORT) };
6
7 serial_port.init();
8
9- serial_port
10+ Mutex::new(serial_port)
11};
And in our main.rs file, we’ll need to acquire the lock before we can write to it which we’ll do as follows:
1fn main() {
2+ let mut serial_port = SERIAL1.lock();
3
4- write!(SERIAL1, "Hello, World!").expect("failed to write to serial output");
5+ write!(serial_port, "Hello, World!").expect("failed to write to serial output");
6}
Now when we run our program, it should print “Hello, World!” to the serial output like before.
Next, to improve this even further, we’ll create a print function to access the serial interface, and an ergonomic macro for us to use (dbg!).
1#[doc(hidden)]
2pub fn print(args: ::core::fmt::Arguments<'_>) {
3 use core::fmt::Write;
4
5 SERIAL1
6 .lock()
7 .write_fmt(args)
8 .expect("Printing to serial failed");
9}
10
11/// Prints to the host through the serial interface.
12#[macro_export]
13macro_rules! dbg {
14 ($($arg:tt)*) => {
15 $crate::serial::print(format_args!($($arg)*))
16 };
17}By using this macro, we’ll delegate the locking, formatting, and error handling to the macro itself, hence, simplifying our driver like so:
1- use core::{fmt::Write, panic::PanicInfo};
2- use kernel::serial::SERIAL1;
3+ use core::panic::PanicInfo;
4+ use kernel::dbg;
5
6fn main() {
7- let mut serial_port = SERIAL1.lock();
8- write!(serial_port, "hello, world!").expect("failed to write to serial output");
9+ dbg!("Hello, World!"); // 👈
10}
Awesome! We now have a very ergonomic and easy to use dbg! macro that mimics the behavior of rust’s own dbg! macro.
Moving forward, we’ll be using this macro extensively throughout the series to log things to our console.
This will accelerate our development process by providing a simple and reliable debugging mechanism.
In the next post we’ll finally start implementing memory management in order to prepare our OS for further improvements.