RISC-V & Rust 2: "Hello, world" in Rust

Table of contents

1. Introduction

In this article we'll continue to explore the peripherals of the Allwinner D1 CPU and will initialize the UART, so that we can communicate with the outside world like big important adults and not with some sparkly lights. And we will actually use Rust this time.

To get Rust code running, we need to do some prep work first. This includes:

Code aside, we'll also need a USB-UART 3.3V TTL adapter to connect the board to the computer.

Now, let's get to work. But before we begin, let's take a peek at the final project structure so we can reference it in the following sections.

.
├── boot                 # Crate directory
│   ├── .cargo
│   │   └── config.toml  # We will overwrite some cargo defaults for convenience here
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── link.ld          # Linker script
│   ├── Makefile         # Convenience crate-level makefile
│   └── src
│       ├── boot.rs      # Rust code entry point
│       ├── boot.S       # Assembly boot part that jumps to the Rust code
│       ├── mmio.rs      # MMIO register access helpers
│       ├── panic.rs     # Panic handler
│       └── uart.rs      # UART initialization and access
├── Makefile             # Top-level makefile from the previous article
└── scripts              # Image checksum generation script
    └── gencksum

The boot crate contains all of our source code, as well as a linker script and convenience makefile. At the top level we also have the scripts directory with the gencksum script (unchanged from the previous article) and the top level Makefile, which is also essentially the same as in the first article.

2. Running Rust code

As a first step, we need to add a RISC-V 64GC compiler target. It has a proper tier 2 support and can be simply installed via rustup: rustup target add riscv64gc-unknown-none-elf. Side note: we will stick with the stable rustc channel for now, but will probably have to switch to nightly at some point.

Now, let's go through project files.

Cargo.toml is boring, we simply disable unwind on panic and tell rustc to optimize for binary size:

[package]
name = "boot"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "boot"
path = "src/boot.rs"

[dependencies]

[profile.release]
panic="abort"
opt-level="z"

Crate level makefile simply forwards to cargo and exists mostly for convenience. One thing worth mentioning here is that we include cargo dep files, so we can invoke cargo only when source files are changed. To achieve that, we also have to make an amendment to cargo defaults in the .cargo/config.toml to make paths in dep files relative. Including dependency files is not necessary at all, but it removes unnecessary visual noise when calling the crate level makefile with no changes (e.g. when flashing).

# copy target binary to the crate root to make it easier
# to access from the upper level makefile
boot.elf: target/riscv64gc-unknown-none-elf/release/boot
	cp target/riscv64gc-unknown-none-elf/release/boot boot.elf

target/riscv64gc-unknown-none-elf/release/boot:
	RUSTFLAGS="-C link-arg=-Tlink.ld" cargo build \
		  --release \
		  --target riscv64gc-unknown-none-elf --verbose
clean:
	cargo clean
	rm -f boot.elf

.PHONY: clean

target/riscv64gc-unknown-none-elf/release/boot: link.ld
-include target/riscv64gc-unknown-none-elf/release/boot.d

The linker script also doesn't have anything fancy:

ENTRY(_start)

SECTIONS
{
    . = 0x20000; /* SRAM A1 */
    . = ALIGN(1);

    .text : { KEEP(*(.text.boot)) *(.text .text.*) }

    . = ALIGN(16);
    .rodata : { *(.rodata .rodata.* .srodata.*) }

    . = ALIGN(16);
    .data : { *(.data .data.* .sdata) }

    . = ALIGN(4);
    __bss_start = .;
    .bss : {
        bss = .;
        *(.bss .bss.*)
    }
    . = ALIGN(4);
    __bss_end = .;

    __end = .;
}

Finally, in src/boot.S we have a bit of assembly to prepare the CPU for running the Rust part of the program. The boot header part didn't change at all since the previous article, but the rest of the code is quite different. Let's take a closer look:

  1. First, we take some precautions and disable the interrupts. It is usually required only if system goes up after a some kind of soft reset, as interrupts are always disabled after a cold boot or hard reset, but better be safe than sorry.

  2. Next, we enable T-HEAD instructions extension. We have no use for it yet, but let's get it ready anyways for when we will need it.

  3. We invalidate the I and D caches. Not sure if it is needed after a cold boot or reset, but the original bootloader does it and so are we.

  4. Then, we set the stack pointer at the top of the SRAM 32K A1 region. 32Kb should be enough to fit both the image and stack. In the following articles, we will initialize DRAM and won't have to worry about it at all.

  5. Finally, we fill .bss section with zeros and jump to the Rust code (_main is defined in boot.rs).

.section ".text.boot"

.global _start

/* Prohibits instruction compression
   Without this the first jump in the header may end up compressed, resulting in header corruption,
   as BROM expects the 32 bit instruction there */
.option norvc

/* BROM header */
_start:
j _payload        /* jump over the metadata below to the actual payload */
.ascii "eGON.BT0" /* header marker (magic) */
.word 0x5f0a6c39  /* checksum initial value */
.word 0x00000000  /* payload size */
.word _payload - _start /* header size */
.word 0 /* public header size (we don't need one) */
.word 0 /* public header version */
.word 0 /* return address (dont care about this one) */
.word 0x20000 /* run address of the payload (SRAM A1) */
.word 0 /* boot cpu / eGON version (don't care) */
.dword 0 /* platform information (don't care) */

/* entry point */
_payload:

/* disable interrupts */
csrw mie, zero

/* enable THEAD extended instruction set */
li t1, 1<<22
csrs 0x7c0, t1

/* invalidate caches (MCOR CSR) */
li t2, 0x30013
csrs 0x7c2, t2

/* setup stack at the top of SRAM A1 */
li sp, 0x00027FF0

/* zero out bss */
ld t0, __bss_start
ld t1, __bss_end

_zero_bss:
beq t0, t1, _boot_main
sw zero, 0(t0)
addi t0, t0, 4
j _zero_bss

_boot_main:
/* jump to rust code */
j _main

_hang:
j _hang

The top level makefile didn't change much, so I'll skip it for brevity. You can always find it in the accompanying repo: repo.

And at last, we're ready to write some Rust code.

3. Hello, world

Our code will be printing a "Hello, world" message over the UART0 port. Accroding to D1 datasheet, UART0 signals are multiplexed on PB8 (TX) and PB9 (RX) SoC pins. On MangoPi, those pins are wired to pins 8 and 10 respectively of the extension header.

Before we begin with the UART initialization, let's make a little quality of life helper for reading and writing MMIO registers, as we will need to work with them extensively later on. Our helper will follow a simple builder-style pattern that encapsulates the read-modify-write cycle. We won't bother with generic register width support just yet for sake of simplicity.

There goes the mmio.rs:

#[derive(Clone, Copy)]
#[must_use]
pub struct Reg32 {
    p: *mut u32,
    v: u32,
}

impl Reg32 {
    pub unsafe fn read(addr: u64) -> Self {
        let p = addr as *mut u32;
        let v = core::ptr::read_volatile(p);
        Self { p, v }
    }

    pub fn set_field<const SHIFT: usize, const LEN: usize>(mut self, v: u32) -> Self {
        let mask = !((1u32 << LEN).wrapping_sub(1) << SHIFT);
        self.v &= mask;
        self.v |= v << SHIFT;
        self
    }

    pub fn is_bit_set<const SHIFT: usize>(&self) -> bool {
        (self.v & (1 << SHIFT)) != 0
    }

    pub fn field<const SHIFT: usize, const LEN: usize>(&self) -> u32 {
        let mask = (1u32 << LEN).wrapping_sub(1);
        (self.v >> SHIFT) & mask
    }

    pub unsafe fn write(self) {
        core::ptr::write_volatile(self.p, self.v)
    }
}

D1 User manual gives a good and detailed description of the UART initialization procedure, so I'll show only a gist of it here. See section 9.2.4.1 of the D1 User Manual for the detailed description. We will be using UART in FIFO mode with polling (no interrupts yet!).

Here are basic steps we need to take:

  1. Deassert reset and enable clock gating on UART0 in the CCU (Clock Controller Unit) UART configuration register (CCU_UART_BGR_REG).

  2. Configure PB8 and PB9 pins function to UART0-TX and UART0-RX and enable pull-up.

  3. Configure the baudrate to 115200. This is done by writing clock divisor value into the divisor latch register (DLL/DLH). The catch is that this register is multiplexed with RBR/THR register (receiver buffer register / transmit holding register), so we have to enable DLL/DLH access first and disable it after we're done.

  4. Setup UART mode (8 bit, 1 stop bit, no parity, no break control). This is done by writing to the UART_LCR register.

  5. Enable and reset FIFO (UART_FCR).

And here is the code (uart.rs):

pub unsafe fn uart_init() {
    // Step 1
    mmio::Reg32::read(CCU_BASE + CCU_UART_BGR_REG)
        .set_field::<0, 1>(1) // UART0 gating enable
        .set_field::<16, 1>(1) // UART0 reset deassert
        .write();

    // Step 2
    // Configure pinmux
    mmio::Reg32::read(GPIO_BASE + GPIO_PB_CFG1)
        .set_field::<0, 4>(0b0110) // PB8 = UART0-TX
        .set_field::<4, 4>(0b0110) // PB9 = UART0-RX
        .write();

    mmio::Reg32::read(GPIO_BASE + GPIO_PB_PULL)
        .set_field::<16, 2>(1) // PB8_PULL = Pull_up
        .set_field::<18, 2>(1) // PB9_PULL = Pull_up
        .write();

    // Configure baud rate
    mmio::Reg32::read(UART0_BASE + UART_FCR)
        .set_field::<0, 1>(1) // FIFOE = 1 (enable FIFO)
        .write();

    mmio::Reg32::read(UART0_BASE + UART_HALT)
        .set_field::<0, 1>(1) // HALT_TX = 1
        .write();

    mmio::Reg32::read(UART0_BASE + UART_LCR)
        .set_field::<7, 1>(1) // DLAB = 1 (enable divisor latch register access)
        .write();

    mmio::Reg32::read(UART0_BASE + UART_DLL)
        .set_field::<0, 8>(13) // DLL = 13 (divisor Latch = 13, baud rate = 115200)
        .write();

    mmio::Reg32::read(UART0_BASE + UART_DLH)
        .set_field::<0, 8>(0) // DLH = 0 (divisor Latch = 13, baud rate = 115200)
        .write();

    mmio::Reg32::read(UART0_BASE + UART_LCR)
        .set_field::<7, 1>(0) // DLAB = 0 (disable divisor latch register access)
        .write();

    mmio::Reg32::read(UART0_BASE + UART_HALT)
        .set_field::<0, 1>(0) // HALT_TX = 0
        .write();

    // Step 3
    // Setup mode
    mmio::Reg32::read(UART0_BASE + UART_LCR)
        .set_field::<0, 2>(0b11) // Data Length Select = 8 bits
        .set_field::<2, 1>(0) // 1 stop bit
        .set_field::<3, 1>(0) // partiy disabled
        .set_field::<4, 2>(0) // partiy mode (doesnt really matter because partiy is disabled)
        .set_field::<6, 1>(0) // break control = 0
        .write();

    // Setup FIFO
    mmio::Reg32::read(UART0_BASE + UART_FCR)
        .set_field::<0, 1>(1) // FIFOE = 1 (enable FIFO)
        .set_field::<1, 1>(1) // RFIFOR = 1 (reset rx FIFO)
        .set_field::<2, 1>(1) // XFIFOR = 1 (reset tx FIFO)
        .write();
}

Note that steps are annotated as per D1 User Manual and not the list above.

Sending bytes to the UART is done by simply writing the next byte to the THR register. But we have to ensure that the TX FIFO has some free space before that.

pub unsafe fn uart_write(b: u8) {
    while !mmio::Reg32::read(UART0_BASE + UART_USR).is_bit_set::<1>() {
        // wait for a free space in FIFO (UART_USR[TFNF] = 1)
    }

    mmio::write32(UART0_BASE + UART_THR, b as u32);
}

We can also provide core::fmt::Write implementation, so can we use fancy formatting with the write! macro:

pub struct UART0;

impl core::fmt::Write for UART0 {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        for &b in s.as_bytes() {
            unsafe { uart_write(b) };
        }

        Ok(())
    }
}

Now, we have everything in place to write our main function that prints out the "Hello, world!" to the UART:

#![no_std]
#![no_main]
#![allow(dead_code)]

use core::arch::global_asm;
use core::fmt::Write;
use core::panic::PanicInfo;

mod mmio;
mod uart;

global_asm!(include_str!("boot.S"));

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    let _ = core::write!(&mut crate::uart::UART0, "panic: {}", info);
    loop {}
}

#[no_mangle]
pub extern "C" fn _main() -> ! {
    unsafe { uart::uart_init() };

    core::write!(&mut uart::UART0, "Hello, world!\r\n").unwrap();
    core::write!(&mut uart::UART0, "Formatting test: {}, {}, 0x{:x}\r\n", "string", -42, 0xd00dfeedu64).unwrap();

    loop {}
}

Finally, you can hook up UART-USB converter to PB8 (TX) and PB9 (RX) pins on the expansion header, spin up picocom on your PC, reset the board and behold the appearance of our simple-minded greeting on the screen.

4. Detour: reducing image size

Even though our code seems to be working fine, there was one little thing that somewhat bothered me: the size of boot.bin binary. Even though we have enabled optimization for code size, the image.bin takes whole 6128 bytes, which is not a lot, but still more than I expected for a 150-ish LOC bare bones "hello, world" program. So I decided to try and see how much I can cut down the size.

Let's take a look on what code is eating up so much space by inspecting the boot.elf.S:

$ riscv64-elf-objdump -t  boot/target/riscv64gc-unknown-none-elf/release/boot | grep \.text | cut -f 2
0000000000000000 _payload
0000000000000000 _zero_bss
0000000000000000 _boot_main
0000000000000000 _hang
000000000000000a _ZN44_$LT$$RF$T$u20$as$u20$core..fmt..Display$GT$3fmt17h4192a0b596a4e40cE
00000000000000b6 _ZN4core3fmt5Write10write_char17h7f6e58d9e9d2b311E
0000000000000022 _ZN54_$LT$boot..uart..UART0$u20$as$u20$core..fmt..Write$GT$9write_str17h7ed5580a0bec0a94E
0000000000000016 _ZN4core3fmt5Write9write_fmt17hef2eb0a66a92f1bcE
0000000000000016 _ZN53_$LT$core..fmt..Error$u20$as$u20$core..fmt..Debug$GT$3fmt17hdca64eb9591e9836E
000000000000003a .hidden rust_begin_unwind
0000000000000020 _ZN36_$LT$T$u20$as$u20$core..any..Any$GT$7type_id17hfebf5adace5d3f59E
000000000000001e _ZN44_$LT$$RF$T$u20$as$u20$core..fmt..Display$GT$3fmt17h5163a1a52c9355ceE
0000000000000016 _ZN42_$LT$$RF$T$u20$as$u20$core..fmt..Debug$GT$3fmt17hde0288ac879e080fE
0000000000000064 _ZN4core3fmt9Formatter12pad_integral12write_prefix17hdb2700bf22a5252fE
0000000000000164 _ZN4core3fmt3num3imp7fmt_u6417h3cac5da69467fec0E
000000000000014c _main
0000000000000000 _start
0000000000000166 _ZN73_$LT$core..panic..panic_info..PanicInfo$u20$as$u20$core..fmt..Display$GT$3fmt17ha85a20b200ccfb94E
00000000000001b4 _ZN4core3fmt5write17h767ff83705ad65bfE
0000000000000016 _ZN4core3fmt9Formatter9write_str17haab3f7808028ca03E
0000000000000030 _ZN4core3fmt3num3imp52_$LT$impl$u20$core..fmt..Display$u20$for$u20$i32$GT$3fmt17h4a1acc7ddb9f859bE
0000000000000076 _ZN4core3fmt3num53_$LT$impl$u20$core..fmt..LowerHex$u20$for$u20$u64$GT$3fmt17h078adf36dc3279b5E
0000000000000070 _ZN4core6result13unwrap_failed17hbbec56b263573d9cE
0000000000000032 _ZN4core9panicking9panic_fmt17h810a81210a211498E
0000000000000060 _ZN4core5slice5index26slice_start_index_len_fail17h1a7a9f9ef4b92e9fE
000000000000022c _ZN4core3fmt9Formatter3pad17h6817dd92b69a4dc0E
000000000000001e _ZN4core3fmt3num3imp52_$LT$impl$u20$core..fmt..Display$u20$for$u20$u32$GT$3fmt17h0afdf1931a385e40E
000000000000001c _ZN4core3fmt3num3imp52_$LT$impl$u20$core..fmt..Display$u20$for$u20$u64$GT$3fmt17h12eb30e5036c91d5E
0000000000000076 _ZN4core3fmt3num53_$LT$impl$u20$core..fmt..LowerHex$u20$for$u20$i64$GT$3fmt17h6089ca89ee9b4b45E
0000000000000226 _ZN4core3fmt9Formatter12pad_integral17h68c1966362f9910eE
00000000000001a8 _ZN4core3str5count14do_count_chars17h86fd44c4dafdd258E
0000000000000016 _ZN57_$LT$core..fmt..Formatter$u20$as$u20$core..fmt..Write$GT$9write_str17h0a235e78250adadaE
000000000000001c _ZN4core3fmt3num3imp54_$LT$impl$u20$core..fmt..Display$u20$for$u20$usize$GT$3fmt17h8f91236671cfb307E
0000000000000076 _ZN4core3fmt3num55_$LT$impl$u20$core..fmt..LowerHex$u20$for$u20$usize$GT$3fmt17hc60948a95e6874abE
0000000000000076 _ZN4core3fmt3num55_$LT$impl$u20$core..fmt..LowerHex$u20$for$u20$isize$GT$3fmt17hfc2351cb7e401039E

Formatting ate up all our space!

While we can totally live with this, as we have the whole 32K of SRAM and will be initializing the DRAM soon anyway, still I decided (mostly out of stubborness) to get rid of core::fmt formatting code and see how much space this will save us.

Long story short, I ended up writing a simple printf clone. It is rather limited, but for a bootloader, it is good enough. We will switch back to core::fmt when we will start writing the kernel part.

Here's our NIH printf! macro (uart.rs). I've omitted the full implementation for brevity. Complete implementation: [here]

pub fn printfv(format: &str, args: &[&dyn core::any::Any]) -> Option<()> {
    // implementation
}

macro_rules! printf {
    ($x:literal $(,)? $($arg:expr),*) => {{
        #[allow(unused_imports)]
        use core::borrow::Borrow;
        $crate::uart::printfv($x, &[ $(&($arg.borrow() as *const _)),* ]);
    }};
}

pub(crate) use printf;

In the boot.rs we're now using printf! instead of core::fmt:

#[no_mangle]
pub extern "C" fn _main() -> ! {
    unsafe { uart::uart_init() };

    uart::printf!("Hello, world!\r\n");
    uart::printf!("printf formatting test: %s, %d, 0x%x\r\n", "string", -41i64, 0xd00dfeedu64);

    loop {}
}

Now, if we compile the code and take a look at boot.bin, we can see that we have reduced the size from 6128 to 1416 bytes!

However, there's one drawback to this approach: if we accidentally call any code from core that uses formatting, we will end up with our original 6K boot.bin, which is not ideal. But the only realistic way this can happen is if we call the code that triggers panic!

The head-on approach to fixing this is to rebuild core without the panic_immediate_abort feature, which makes panics immediately abort without printing out the message. This can be done with unstable build-std and build-std-features cargo features. Another and a quite radical approach would be disallowing code that calls panic! altogether. There's actually an easy (but hack-ish) way to do this that I stole from dont_panic crate:

use core::panic::PanicInfo;

extern "C" {
    pub fn rust_panic_called_where_shouldnt() -> !;
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    unsafe { rust_panic_called_where_shouldnt(); }
}

Basically, we're declaring an external function without the actual definition and rely on compiler optimizations. If there is no code that panics, the panic handler function gets eliminated by optimizer. Otherwise, we will get a linker error for trying to call an undefined function.

5. What's next?

That's it for now. In the next article, we will be initializing DRAM and bringing the SoC clocks up to the full speed.

As always, you can find the complete sources for the article on GitHub:

Top