In my last post, I laid the groundwork for interrupt handling in my AArch64 OS in Rust. Now, it’s time to put that foundation to good use and switch our UART from a polling-based approach to an interrupt-driven one. In this post, I’ll go over how I made that change and what it means for the project.
The Interrupt-Driven Solution
By using interrupts, we can let the UART notify the CPU when it needs attention. This is a much more efficient approach, as the CPU is free to execute other code until an interrupt occurs. When the UART has data to be read, it sends an interrupt signal to the processor, which then jumps to a specific interrupt handler to process the data.
Building the Foundations: A Safe Buffer
The first step is to modify our UART driver. With polling, we’d just sit and wait for a character. With interrupts, data can arrive at any time, so we need a place to store it until our application is ready to process it. The classic solution is a circular buffer, but we need to ensure access to it is safe from both our main code and our interrupt handlers.
To achieve this, I’ve wrapped the entire buffer in a Mutex. A Mutex acts like a lock, preventing race conditions where different parts of the code might try to change the buffer at the same time and corrupt its state.
For this project, I wrote my own simple Mutex to avoid bringing in larger dependencies. The implementation is based on a spinlock, which is a good fit for an OS kernel. It uses an AtomicBool for the lock and UnsafeCell to hold the data.
The key feature is the lock_irqsafe method. An interrupt handler can’t risk waiting for a lock held by the code it just interrupted—that would cause a deadlock. This special function solves that by disabling interrupts before acquiring the lock and restoring them after. This guarantees that an interrupt can’t fire while the lock is held, making it safe.
Here is the implementation:
use core::arch::asm;
use core::cell::UnsafeCell;
use core::sync::atomic::{AtomicBool, Ordering, AtomicUsize};
#[inline(always)]
fn disable_interrupts() -> u64 {
let daif: u64;
unsafe {
asm!("mrs {}, daif", out(reg) daif);
asm!("msr daifset, #0x2");
}
daif
}
#[inline(always)]
fn restore_interrupts(daif: u64) {
unsafe {
asm!("msr daif, {}", in(reg) daif);
}
}
pub struct Mutex {
lock: AtomicBool,
data: UnsafeCell,
}
unsafe impl Sync for Mutex {}
unsafe impl Send for Mutex {}
impl Mutex {
pub const fn new(data: T) -> Self {
Self {
lock: AtomicBool::new(false),
data: UnsafeCell::new(data),
}
}
pub fn lock(&self, f: impl FnOnce(&mut T) -> R) -> R {
while self
.lock
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
core::hint::spin_loop();
}
let result = f(unsafe { &mut *self.data.get() });
self.lock.store(false, Ordering::Release);
result
}
pub fn lock_irqsafe(&self, f: impl FnOnce(&mut T) -> R) -> R {
let daif_state = disable_interrupts();
let result = self.lock(f);
restore_interrupts(daif_state);
result
}
}
Now, with our Mutex in hand, we can define the UartBuffer. It’s a simple struct with a fixed-size array and two atomic pointers for the head (where the handler writes) and tail (where the application reads). The whole thing is wrapped in our new Mutex to guarantee safe access.
const UART_BUFFER_SIZE: usize = 256;
pub struct UartBuffer {
buffer: [u8; UART_BUFFER_SIZE],
head: AtomicUsize,
tail: AtomicUsize,
}
pub static RX_BUFFER: Mutex = Mutex::new(UartBuffer {
buffer: [0; UART_BUFFER_SIZE],
head: AtomicUsize::new(0),
tail: AtomicUsize::new(0),
});
impl UartBuffer {
pub fn push(&mut self, byte: u8) -> bool {
let head = self.head.load(Ordering::Relaxed);
let next_head = (head + 1) % UART_BUFFER_SIZE;
if next_head == self.tail.load(Ordering::Relaxed) {
return false;
}
self.buffer[head] = byte;
self.head.store(next_head, Ordering::Relaxed);
true
}
fn pop(&mut self) -> Option {
let byte;
let next_tail;
let tail = self.tail.load(Ordering::Relaxed);
if self.head.load(Ordering::Relaxed) == tail {
return None;
}
byte = self.buffer[tail];
next_tail = (tail + 1) % UART_BUFFER_SIZE;
self.tail.store(next_tail, Ordering::Relaxed);
Some(byte)
}
}
Configuring the UART for Interrupts
With the buffer ready, the final piece of the puzzle is to tell the UART hardware to actually generate an interrupt when it receives data. This is done in the configure_uart function by setting the right bit in the Interrupt Mask Set/Clear (IMSC) register. I’ve removed the old code that masked all interrupts and replaced it with a line that specifically unmasks the receive interrupt (IMSC_RXIM).
unsafe {
// 6. Enable RX interrupt
write_mmio(UART.base_addr as usize, IMSC_OFF, 0x00);
set_mmio_bits(UART.base_addr as usize, IMSC_OFF, IMSC_RXIM);
}
Now our UART is ready. It will no longer require the CPU to constantly poll it. Instead, it will fire an interrupt whenever a new character arrives.
The Interrupt Handler: Putting It All Together
UART is configured to send a signal, and the GIC is set up to route that signal. The final piece of the puzzle is the software that runs when the CPU receives the interrupt signal: the interrupt handler.
In my previous post on interrupt handling, I set up a generic handler for IRQs. Now, I’ve modified it to handle UART interrupts. Here is what the main IRQ handler and the new UART-specific handler look like:
#[no_mangle]
pub fn do_irq(id: u32) -> u32 {
match id {
30 => {
uart::print(b"Timer interrupt!\n");
unsafe {
asm!(
"mrs x0, CNTFRQ_EL0",
"msr CNTP_TVAL_EL0, x0",
"isb",
options(nostack, nomem)
);
}
}
33 => {
unsafe {
uart::RX_BUFFER.lock_irqsafe(|rx| {
let ch = utilities::read_mmio(0x9000000, 0) as u8;
let _ = rx.push(ch);
});
utilities::write_mmio(0x9000000, 0x44, 1 << 4);
}
}
_ => {
uart::print(b"Unhandled IRQ: ");
let mut buf = [0u8; 10];
let id_str = u32_to_str(id, &mut buf);
uart::print(id_str);
uart::print(b"\n");
}
}
return id;
}
(A quick note: you’ll see hardcoded addresses like 0x9000000 in the handler. This is temporary! In a future post, I’ll replace these by parsing the Device Tree Blob (DTB) to discover hardware addresses dynamically.)
So, what does all this work get us? It means our main kernel function, kmain, no longer needs to waste time polling the UART. Instead of getting stuck waiting for input, it can simply check our buffer and move on if it’s empty.
The main loop is now very simple. It continuously calls uart::getchar(), which tries to pop a byte from the RX_BUFFER.
- If the buffer is empty, getchar() returns None immediately, and the loop continues. The CPU is free to do other things (though right now, it just loops).
- If the interrupt handler has placed a character in the buffer, getchar() returns Some(character), and we can process it—in this case, by echoing it back to the console.
This creates a simple, responsive echo shell.
Here’s the final kmain:
#[no_mangle]
pub extern "C" fn kmain(_fdt_addr: usize) {
uart::print(b"Hello, from Rust\n");
loop {
if let Some(ch) = uart::getchar() {
uart::print(b"You typed: ");
uart::putchar(ch);
uart::print(b"\n");
}
}
}
Below is a short recording of the output in QEMU demonstrating that things are working properly, with our new interrupt driven UART.

Next steps
With a solid, interrupt-driven I/O system and a working timer, the OS is starting to feel much more capable. The next step is to take a step back and look at the project as a whole—what’s been built so far, and where it’s heading. I’ll be writing a short overview to document the current architecture and development process.
After that, the next technical milestone is eliminating the hardcoded “magic numbers” for memory addresses and interrupt IDs. I’ll be working on parsing the Device Tree Blob (DTB) provided by the bootloader, so the kernel can dynamically discover hardware at runtime.
Once that’s in place, I’ll turn my attention to building a basic scheduler using the timer interrupt—paving the way for multitasking.
If you’d like to follow the development or explore the full source code, the project is available on GitHub. Thanks for following along!
References
- Arm PrimeCell UART (PL011)