An AArch64 OS in Rust – UART Configuration

In the last post, we successfully bootstrapped the very first steps of our AArch64 operating system. We wrote a minimal boot.s assembly file to set up the stack and called into Rust code, where we printed a simple “Hello, World!” message to the screen using the semihosting feature of QEMU.
This gave us a first taste of running Rust code bare-metal on an Arm virtual machine. However, semihosting is only a temporary solution. If we want real hardware interaction, we need to move away from it.

That brings us to today’s goal.

Setting Up Serial Communication

In this post, I’m diving into one of the core components of early OS development: serial communication via the UART.

Getting basic input/output working through a serial port is one of the first steps in interacting with the outside world. It’s a simple, reliable way to get logs and send commands without needing a full-blown display or keyboard driver.

For this setup, I’m using the PL011 UART, which QEMU emulates when running an Arm-based system. This UART is memory-mapped, and to use it, we need to configure a few key registers.

For now, communication will be handled using polling. In the next post, I will implement general interrupt handling, which will later allow the UART (and other peripherals) to use interrupt-driven communication.

Configuring the UART

With the PL011 UART being memory-mapped, configuring it mostly means writing to a set of registers in the right order. In the chapter 3 Programmers Model, section 3.1, the specification says that the base address of the memory where the registers are mapped is not fixed, but the offset of the registers from the base address is fixed.

At this stage, I’m setting up the UART early during boot, and the initialization parameters are hardcoded. Later, I plan to improve this by fetching these values dynamically, for example using ACPI tables or device tree information.

Here’s how the relevant part of my boot.s looks:


.section .text
.global _start

_start:
    ldr x30, =stack_top
    mov sp, x30
    # In a near future these parameters will be provided by the machine
    # x0: UART base memory address https://github.com/qemu/qemu/blob/master/hw/arm/virt.c#L175
    # x1: UART clock frequency https://github.com/qemu/qemu/blob/master/hw/arm/virt.c#L323
    # x2: UART baud rate Now unused
    mov x0, #0x09000000
    mov x1, #0x3600
    movk x1, #0x16e, LSL #16
    mov x2, #23
    bl init_uart
    bl configure_uart
    bl kmain
    b .

Now that we have an early boot environment and we know where the UART is mapped, it’s time to actually configure it. The PL011 exposes a set of memory-mapped registers that we need to touch in the correct order to get it up and running.

Here is an outline of the registers I will be using:

Registers we’ll be working with
Register Offset Purpose
UARTDR 0x00 Data Register – write a byte to transmit or read received data
UARTFR 0x18 Flag Register – status bits like TX ready, FIFO full, UART busy
UARTIBRD 0x24 Integer Baud Rate Divisor – sets base for baud rate calculation
UARTFBRD 0x28 Fractional Baud Rate Divisor – fine-tunes the baud rate
UARTLCR_H 0x2c Line Control Register – controls data bits, stop bits, FIFO
UARTCR 0x30 Control Register – master on/off switch for UART, TX/RX enable
UARTIMSC 0x38 Interrupt Mask Set/Clear – enables/disables UART interrupts
UARTDMACR 0x48 DMA Control Register – controls DMA for TX/RX

In my code, I use named constants for these offsets and helper functions to read/write them, keeping things tidy. We’ll go into the details of how I use these next.


const DR_OFF: isize = 0x00;
const FR_OFF: isize = 0x18;
const FR_BUSY: u32 = 1 << 3;
const IBRD_OFF: isize = 0x24;
const FBRD_OFF: isize = 0x28;
const LCR_OFF: isize = 0x2c;
const LCR_FEN: u32 = 1 << 4;
const LCR_STP2: u32 = 1 << 3;
const CR_OFF: isize = 0x30;
const CR_UARTEN: u32 = 1 << 0;
const CR_TXEN: u32 = 1 << 8;
const IMSC_OFF: isize = 0x38;
const DMACR_OFF: isize = 0x48;

fn read_reg(offset: isize) -> u32 {
    unsafe {
        return *(UART.base_addr.offset(offset));
    }
}

fn write_reg(offset: isize, val: u32) {
    unsafe {
        let mut contents = *(UART.base_addr.offset(offset));
        contents &= !val;
        contents |= val;
        *(UART.base_addr.offset(offset)) = contents;
    }
}

Once we’ve got everything nicely wrapped in constants and helpers, it’s time to actually configure the UART.

The configuration follows a sequence inspired directly from the Arm PL011 documentation. The idea is to safely bring the UART to a known state, set up the frame format and transmission settings, and then enable it. Here’s how I’m doing it in my configure_uart() function:


 pub fn configure_uart() {
     // 1. Disable UART
     disable_uart();
     // 2. Wait for the end of transmission
     while !uart_ready() {}
     // 3. Flush TX FIFO
     uart_flush_tx_fifo();
     // 4. Set speed (lowest possible for testing)
     set_uart_low_speed();
     // 5. Configure data format
     let mut cfg = 0;
     cfg |= ((UART.data_bits - 1) & 0x3) << 5;
     if UART.stop_bits == 2 {
         cfg |= LCR_STP2;
     }

     uart_write_lcr(cfg);
     // 6. Mask all interrupts
     uart_write_msc(0x7ff);
     // 7. Disable DMA
     uart_write_dmacr(0);
     // 8. Enable TX
     uart_write_cr(CR_TXEN);
     // 9. Enable UART
     uart_write_cr(CR_UARTEN);
 } 

1. Disable the UART

Before making any changes, we disable the UART by writing to the Control Register (UARTCR.UARTEN). This is necessary to safely modify other registers like baud rate divisor and line control. It ensures that there’s no ongoing transmission or reception during configuration.


fn disable_uart() {
    uart_write_cr(CR_UARTEN);
}

2. Wait for transmission to finish

We wait until the UART is no longer busy, checking the UARTFR register. The FR_BUSY bit tells us whether a transmission is still in progress. This avoids corrupting any data mid-transfer.


fn uart_ready() -> bool {
    return (read_reg(FR_OFF) & FR_BUSY) == 0;
}

3. Flush the TX FIFO

We flush the transmit FIFO by clearing the FIFO Enable (FEN) bit in the Line Control Register (UARTLCR_H). This ensures we’re starting from a clean slate and there’s no leftover data in the queue.


fn uart_flush_tx_fifo() {
    write_reg(LCR_OFF, LCR_FEN);
}

4. Set the baud rate divisor (currently minimum speed for testing)

I’m currently setting the baud rate divisor to the lowest possible values in both IBRD and FBRD for simplicity and reliability in early stages:


fn set_uart_low_speed() {
    write_reg(IBRD_OFF, (1 << 16) - 1);
    write_reg(FBRD_OFF, 0);
}

5. Configure the data frame format

This step sets the number of data bits and stop bits. These go into the UARTLCR_H register.

  • Bits 6-5 set the word length: I’m using 8 bits.
  • Bit 3 enables two stop bits if UART.stop_bits == 2: I'm using 1 bit.

let mut cfg: u32 = 0;
unsafe {
    cfg |= (((UART.data_bits - 1) & 0x3) << 5) as u32;
    if UART.stop_bits == 2 {
        cfg |= LCR_STP2;
    }
}
uart_write_lcr(cfg);

6. Mask all interrupts

At this stage, I disable all UART interrupts by writing a mask to the Interrupt Mask Set/Clear Register (UARTIMSC).


uart_write_msc(0x7ff);

7. Disable DMA

I also disable DMA by writing 0 to the DMA Control Register (UARTDMACR). Again, this keeps things minimal and predictable during bring-up.


uart_write_dmacr(0);

8. Enable transmitter

With configuration done, I enable just the transmitter (TX) for now. That’s bit TXEN in UARTCR.


uart_write_cr(CR_TXEN);

9. Enable UART

Finally, I enable the UART by setting the UARTEN bit in UARTCR.


uart_write_cr(CR_UARTEN);

Now that we’ve safely brought up the UART and configured its format, let’s take a look at how the baud rate divisor would be properly calculated for real usage.

Setting the UART Baud Rate Divisor (When Not in Slow Mode)

In production, you'll likely want to set the UART to a meaningful baud rate (e.g., 115200). The PL011 UART splits the baud rate across two registers: UARTIBRD (integer) and UARTFBRD (fractional). The formula given in the Arm documentation is:

$$ \text{Baud rate divisor} = \frac{\text{UARTCLK}}{16 \times (\text{IBRD} + \frac{\text{FBRD}}{64})} $$

As mentioned, the PL011 UART divides the baud rate calculation into an integer part (UARTIBRD) and a fractional part (UARTFBRD). The fractional register is 6 bits wide, meaning it can represent 64 discrete steps. Therefore, the fractional part of the divisor is scaled by 64 and rounded before writing into UARTFBRD.

Here’s what that looks like in Rust:


fn uart_set_speed() {
    unsafe {
        let baud_div = (UART.base_clock * 1000) / (16 * UART.baudrate);
        let ibrd = baud_div / 1000;
        let fbrd = (((baud_div % 1000) * 64 + 500) / 1000) as u32; 
        write_reg(IBRD_OFF, ibrd & 0xffff);
        write_reg(FBRD_OFF, fbrd & 0x3f);
    }
}

Conclusion

Setting up a memory-mapped UART like the PL011 involves careful register manipulation in the right order. We:

  • Disabled the UART
  • Waited for transmission to complete
  • Flushed the transmit FIFO
  • Set a simple, slow baud rate divisor
  • Configured the frame format
  • Masked interrupts
  • Disabled DMA
  • Enabled just the TX line
  • Brought the UART online

Next Steps

In the next post, we'll move beyond simple polling and set up a general interrupt handling framework.
This will let us react asynchronously to UART input and other hardware events — getting us even closer to a real, multitasking operating system!

If you want to follow the progress more closely, you can check out the project's repository. Stay tuned!

References

WordPress Cookie Plugin by Real Cookie Banner