UART in QEMU
Motivation
In the previous post we looked at how xv6 kernel startup code is implemented and verified the correctness of it by manual inspecting registers inside GDB.
Today we will add print functionality to our kernel, so that we can print at various stages to verify that we are doing things correctly and also we could make use of this for "printf debugging". Generic Virtual Platform (virt) in QEMU on which we run our OS contains one NS16550A compatible UART (universal asynchronous receiver/transmitter) device.
We will implement the UART driver for this device in Rust and make it as similar as possible to our reference C source code.
Note: I’m implementing just a simple driver without any lock mechanisms, meaning we cannot use the print functionality in a way that a race condition arises. For now, we will just use it in our main core (core 0) to show that UART works fine. Locks will be implemented later. As I’m writing the blog posts as I implement my Rust rewrite, there could be some updates/corrections in my latest source code on Github to what I show here.
NS16550 UART
I would like to give a brief overview about NS16550 UART so that we will know how to implement the driver correctly and in an easy way. This device is the widely used and industry standard in embedded world.
UART acts as a bridge between processor/controller to a peripheral device. It performs:
- Serial-to-Parallel conversion on data received from a peripheral device.
- Parallel-to-Serial conversion on data transmitted to the peripheral device.
UART’s TX and RX lines should be connected to RX and TX lines of the peripheral respectively and it can receive or transmit data independently. There is no clock source to synchronize the data between these, but we do have to set same Baud rate so that the data is received/sent correctly, or else we will see garbage when we print the received data to the console.
This UART also contains separate 16-byte FIFOs with support circuitry to minimize software overhead when handling interrupts. Along with FIFO registers we will have access to some control registers to set:
- baud rate
- word length
- parity type
- number of stop bits
I will explain about these things with the code snippets in the next section.
UART Driver
We will use Polling method instead of Interrupts to implement our driver which makes it easier to implement just by using registers without any involvement of DMA (Direct Memory Access) or other interrupt shenanigans.
QEMU puts UART registers at address 0x10000000 ->
memlayout.rs:
pub const UART0: u64 = 0x10000000;
We will use register offsets to read/write correct UART registers from this Base address using volatile pointers. The reason to use volatile read/writes instead of normal read/writes are:
- Compiler will optimize the variables if it reasons that the variable/memory doesn’t change while it is accessed, it removes it completely.
- But MMIO (Memory Mapped I/O) that we use for the devices can change at any point in time because it depends on the hardware not software.
- So we will explicitly tell the compiler that we know what we are doing, please don’t optimize away MMIO accesses.
- C reference also does the same thing here, we follow the same in Rust.
- You could see any PAC (Peripheral Access Crate) in Rust embedded world, you will see all MMIOs are accessed this way.
uart.rs:
use crate::memlayout::UART0;
use core::ptr::{read_volatile, write_volatile};
#[allow(non_snake_case)]
fn ReadReg(reg: u64) -> u8 {
unsafe { read_volatile((UART0 + reg) as *const u8) }
}
#[allow(non_snake_case)]
fn WriteReg(reg: u64, val: u8) {
unsafe { write_volatile((UART0 + reg) as *mut u8, val) }
}
We will now define const values to store UART control registers that we use:
uart.rs:
const RHR: u64 = 0;
const THR: u64 = 0;
const DLAB_LSB: u64 = 0;
const DLAB_MSB: u64 = 1;
const IER: u64 = 1;
const FCR: u64 = 2;
const FCR_FIFO_ENABLE: u8 = 1 << 0;
const FCR_FIFO_CLEAR: u8 = 3 << 1;
const LCR: u64 = 3;
const LCR_EIGHT_BITS: u8 = 3 << 0;
const LCR_BAUD_LATCH: u8 = 1 << 7;
const LSR: u64 = 5;
const LSR_TX_IDLE: u8 = 1 << 5;
- We have Receive and Transmit Holding Registers (RHR/THR) at the same offset
0. Device disambiguates which one we are talking to byDirection, meaning if we wrote to that address, its THR, if we read from it its RHR. We read/write data to these registers. (For now we only transmit, soRHRis defined for completeness; we will use it when we add input handling.) - Interrupt Enable Register (IER) is present at offset
1which is used to enable/disable Interrupts. - We have programmable baud rate functionality which can be used to set to our required baud rate by using DLAB (divisor latch access bit) and Divisor Latch registers. Here device disambiguates whether we are referring to DLAB_LSB/DLAB_MSB or RHR/THR/IER by setting or resetting LCR_BAUD_LATCH (DLAB) bit in Line Control Register (LCR) register.
- FIFO Control Register (FCR) is used to enable FIFOs and to reset RX and TX FIFOs.
- Line Control Register (LCR) is used for setting word length, parity, stop bits and enabling programmable baud rate.
- Line Status Register (LSR) is used for polling RX/TX statuses to read/write RHR/THR registers.
UART Registers (Courtesy: TI tl16c550c Datasheet)

Now that we have defined all the required Registers and its offsets, we could now initialize the UART in the following way for Polled mode of operation instead of Interrupt mode of operation:
uart.rs:
pub fn uartinit() {
WriteReg(IER, 0x00);
WriteReg(LCR, LCR_BAUD_LATCH);
WriteReg(DLAB_LSB, 0x03);
WriteReg(DLAB_MSB, 0x00);
WriteReg(LCR, LCR_EIGHT_BITS);
WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);
}
UART initialization is done as below:
First we disable interrupts as we are not using UART in Interrupt operation mode. Note that the C reference re-enables TX/RX interrupts at the end of
uartinit; we leave them disabled because we are purely polling, which keeps this driver simpler.Then we set DLAB bit so that offset 0 and 1 will refer to DLAB_LSB and DLAB_MSB respectively.
We use
0x03as our divisor to get to our desired baud rate of 38400 using the following divisor formula:divisor = XIN frequency input ÷ (desired baud rate x 16), where XIN frequency input is 1.8432-MHz Crystal.
This baud setup has no observable effect on QEMU’s console output (QEMU still consumes the divisor, but synchronous TX doesn’t use it and the host-tty setting is a no-op for our
-nographicbackend). We program it anyway to stay faithful to real hardware, where it’s what sets the line speed.We are using
8N1, i.e, 8 data bits (LCR_EIGHT_BITSsets the low two bits of LCR), parity disabled, 1 stop bit. As we are only touching the low 2 bits of LCR register (number of data bits), all other bits will be zero, hence we get no parity, 1 stop bit and DLAB reset back to 0.Then, we reset the FIFOs and enable them.
Now that we have initialized UART, we can write a simple printf function which sends characters to THR register by polling LSR_TX_IDLE bit until it is set. This bit will say if we can send next character over a UART THR register or not.
uart.rs:
pub fn uartputc_sync(c: u8) {
while (ReadReg(LSR) & LSR_TX_IDLE) == 0 {}
WriteReg(THR, c);
}
uartputc_sync is the most important base that we use to add PRINT functionality in the kernel.
In C reference code, formatting of values (integers, hex, strings) is done by hand-rolled functions, but in Rust we will make use of Traits to get all of that for free from the core::fmt machinery.
print.rs:
use crate::uart::uartputc_sync;
use core::fmt::{self, Write};
struct UartWriter;
impl Write for UartWriter {
fn write_str(&mut self, s: &str) -> fmt::Result {
for &c in s.as_bytes() {
uartputc_sync(c);
}
Ok(())
}
}
pub fn _print(args: fmt::Arguments) {
let _ = UartWriter.write_fmt(args);
}
#[macro_export]
macro_rules! print {
($($arg:tt)*) => {{
$crate::print::_print(format_args!($($arg)*));
}};
}
#[macro_export]
macro_rules! println {
() => {{ $crate::print::_print(format_args!("\n")); }};
($($arg:tt)*) => {{
$crate::print::_print(format_args!($($arg)*));
$crate::print::_print(format_args!("\n"));
}};
}
Above is the full implementation in Rust for using print! and println! macros that we are familiar with in std Rust. Now even in no_std kernel, we can use the same thing. The way we achieve this is by creating a zero-sized struct called UartWriter and implement Write Trait for this struct wherein we make use of our base function uartputc_sync to send characters over UART. For printing we will make use of write_fmt function which will be implemented automatically for us because we implemented Write Trait.
Now, In our main function, we will make use of this macros:
main.rs:
#[unsafe(no_mangle)]
pub extern "C" fn main() -> ! {
if r_tp() == 0 {
uartinit();
println!("xv6 kernel is booting");
println!("Scheduler is not implemented yet !!");
STARTED.store(true, Ordering::Release);
} else {
while !STARTED.load(Ordering::Acquire) {}
}
loop {
unsafe { core::arch::asm!("wfi") }
}
}
When we run the QEMU, it should print our 2 println lines.
Running on QEMU
Similar to our previous post, we can verify our print functionality as follows:
Follow the below steps:
- Checkout my repo at specified commit hash
❯ git clone https://github.com/Karthik-d-k/xv6rs.git
❯ cd xv6rs/
❯ git checkout 1dc5ebbd01ef42c9261b67d6146e1c61d528a881
- Run QEMU
❯ just qemu
cd kernel && cargo build --release
Compiling kernel v0.1.0 (/home/adt8kor/lws/os/xv6/xv6rs/kernel)
...
Finished `release` profile [optimized + debuginfo] target(s) in 0.31s
$HOME/tools/xpack-riscv-none-elf-gcc-15.2.0-1/bin/riscv-none-elf-objdump -d target/riscv64gc-unknown-none-elf/release/kernel > kernel/kernel.asm
$HOME/tools/xpack-riscv-none-elf-gcc-15.2.0-1/bin/riscv-none-elf-objdump -t target/riscv64gc-unknown-none-elf/release/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$/d' > kernel/kernel.sym
*** Exit QEMU with Ctrl-A X ***
qemu-system-riscv64 -machine virt -bios none -m 128M -nographic -smp 3 -kernel target/riscv64gc-unknown-none-elf/release/kernel
xv6 kernel is booting
Scheduler is not implemented yet !!
As you can see, QEMU successfully writes out characters to the UART THR register and we can see the same in the terminal.
Mangled UART output
Our print functionality as of now only works if there’s only one writer at a time. I will show exactly how the output will be mix-matched if we were to use print macros from all the cores.
Note that this is a logical race on a shared hardware resource (both cores hammering the THR register), not a Rust data race / undefined behaviour. This is exactly what a lock will serialize later.
We shall change the main to call println! macro regardless of which core we are in, i.e, both core 1 and core 2 can write the characters at the same time.
#[unsafe(no_mangle)]
pub extern "C" fn main() -> ! {
let core_no = r_tp();
if core_no == 0 {
uartinit();
println!("xv6 kernel is booting on CORE {}", core_no);
println!("Scheduler is not implemented yet !!");
STARTED.store(true, Ordering::Release);
} else {
while !STARTED.load(Ordering::Acquire) {}
println!("CORE {}", core_no);
}
loop {
unsafe { core::arch::asm!("wfi") }
}
}
❯ just qemu
cd kernel && cargo build --release
Compiling kernel v0.1.0 (/home/adt8kor/lws/os/xv6/xv6rs/kernel)
...
Finished `release` profile [optimized + debuginfo] target(s) in 0.31s
$HOME/tools/xpack-riscv-none-elf-gcc-15.2.0-1/bin/riscv-none-elf-objdump -d target/riscv64gc-unknown-none-elf/release/kernel > kernel/kernel.asm
$HOME/tools/xpack-riscv-none-elf-gcc-15.2.0-1/bin/riscv-none-elf-objdump -t target/riscv64gc-unknown-none-elf/release/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$/d' > kernel/kernel.sym
*** Exit QEMU with Ctrl-A X ***
qemu-system-riscv64 -machine virt -bios none -m 128M -nographic -smp 3 -kernel target/riscv64gc-unknown-none-elf/release/kernel
xv6 kernel is booting on CORE 0
Scheduler is not implemented yet !!
CORE CORE 21
Last line will be different/mangled every time you re-run QEMU.
Conclusion
That is it for this post, I will come back with new posts when I get time to port new functionality/feature to my kernel.