Close to the Metal

June 8, 2026

Embedded Journey - part 2

ATmega Adventures 2: Porting Blinky to Rust

After getting Blinky working in raw C, I found out that Rust can do embedded stuff as well. I've always wanted to learn Rust, so I thought why not give it a go now?

avr atmega328p arduino embedded software teacher rust

In the previous post I got the classic embedded “Hello, World!” working: blinking an LED on the ATmega328P chip on my Arduino Uno. That version was written in raw C. No Arduino abstractions, just working with the registers directly. It was a good first step because it forced me to look at what is actually happening on a lower level.

A few days ago I was browsing the internet for embedded programming content when I stumbled upon a YouTube channel called The Rusty Bits. In his videos he explores Rust on embedded systems. I was excited because I have been wanting to learn Rust for a while. Learning that I could do embedded programming in Rust made me really happy. So, naturally I dove right in. I did not want to jump straight into a completely new embedded project yet. Instead, I wanted to port my existing Blinky project from C to Rust.

The goal

The goal was simple: getting a close-to-the-metal Rust version of Blinky running on the Arduino. It was a bit more challenging than I expected initially. First off, I needed to install Rust and get some Rust knowledge, so I headed off to the official Rust page and started reading ‘The Book’ (it’s what they call the official Rust book) while simultaneously practicing writing Rust code using Rustlings, an interactive Rust practice tool.

After spending a few evenings learning Rust’s syntax and concepts, I decided to give Blinky a go.

From C to Rust

In the C version I could rely on the avr/io.h file for defined register names and such. In my Rust version there was no such thing. This meant I had to define these register mappings myself. Luckily I already had some exposure to the chip’s datasheet, and noticed that the register’s memory addresses are shown next to them.

Example of a register diagram the ATmega328P datasheet

That meant I could use those addresses, map them to constants and read/write to the register using it. I’m not entirely sure if I like this descriptive way of declaring variables in Rust yet. But at least you can never be unsure about a variable’s properties.

const DDRD: *mut u8 = 0x2A as *mut u8;
const PORTD: *mut u8 = 0x2B as *mut u8;

const TCCR1B: *mut u8 = 0x81 as *mut u8;
const TCNT1H: *mut u8 = 0x85 as *mut u8;
const TCNT1L: *mut u8 = 0x84 as *mut u8;

Quite the work, only to be able to reference one pin and the built-in timer… Jeez. The rest of the program was pretty straightforward: make it so that on every half of a second we toggle the LED on PORTD5. Except that I had to figure out how to work with shifting a 16-bit value to two 8-bit values. This was quite the puzzle but eventually it clicked and I got it working.


#![no_main]
#![no_std]

use core::{
    panic::PanicInfo,
    ptr::{read_volatile, write_volatile},
};

// PortD5 related config
const DDRD: *mut u8 = 0x2A as *mut u8;
const PORTD: *mut u8 = 0x2B as *mut u8;

const PORTD5: u8 = 5;
const DDD5: u8 = 5;

// Timer related config
const TCCR1B: *mut u8 = 0x81 as *mut u8;

const TCNT1H: *mut u8 = 0x85 as *mut u8;
const TCNT1L: *mut u8 = 0x84 as *mut u8;

const CS10: u8 = 0;
const CS12: u8 = 2;

const SECOND: u16 = 15_625;

#[unsafe(no_mangle)]
pub extern "C" fn main() -> ! {
    configure_timer();

    set_pd5_output();

    loop {
        if read_tcnt1() >= SECOND / 2 {
            toggle_pd5();
            write_tcnt1(0);
        }
    }
}

fn toggle_pd5() {
    unsafe {
        let current_portd = read_volatile(PORTD);
        write_volatile(PORTD, current_portd ^ (1 << PORTD5));
    }
}

fn set_pd5_output() {
    unsafe {
        let ddrd = read_volatile(DDRD);
        write_volatile(DDRD, ddrd | (1 << DDD5));
    }
}

fn configure_timer() {
    unsafe {
        let tccr1b: u8 = read_volatile(TCCR1B);
        write_volatile(TCCR1B, tccr1b | (1 << CS10) | (1 << CS12));
    }
}

fn read_tcnt1() -> u16 {
    let low = unsafe { read_volatile(TCNT1L) } as u16;
    let high = unsafe { read_volatile(TCNT1H) } as u16;

    (high << 8) | low
}

fn write_tcnt1(value: u16) {
    let high = (value >> 8) as u8;
    let low = value as u8;

    unsafe { write_volatile(TCNT1H, high) };
    unsafe { write_volatile(TCNT1L, low) };
}

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

The first two lines are used to instruct the compiler to not expect the “normal” entry point of a Rust program and that I can’t use Rust’s standard library: a microcontroller doesn’t have an operating system, no heap, no files etc. We can only use bits and pieces from core which contains the lowest-level parts of Rust.

Because I’m not using fn main() as an entry point, I need to let the AVR toolchain know where to hook into my program. This is done here:

#[unsafe(no_mangle)]
pub extern "C" fn main() -> ! {}

The no_mangle attribute is used because the Rust compiler usually changes function names to add additional information it needs. However, we need the main function name in C still, so we turn off the mangling for this function. You might notice it also returns an exclamation mark (-> is Rust’s way of defining the return value of a function). This means that this main function never returns, which makes sense. Once our program is flashed to a microcontroller it never stops running, hopefully.

You might’ve also noticed there’s a lot of unsafe keywords sprinkled across the code. At first this felt strange to me as Rust is advertised to be such a safe language. Turns out it’s needed because we’re directly accessing memory addresses, and Rust doesn’t know what we’re writing to nor what memory address belongs to what. We figure that out ourselves using the chip’s datasheet.

I use read_volatile and write_volatile because these addresses are not normal memory. They represent hardware registers, and their values can change outside of the normal flow of the program. I do not fully understand how the compiler deals with this volatile access, but the basic idea is that it should not treat hardware register addresses like ordinary memory.

Lastly we have the biggest challenge, which was writing to the 16-bit timer even though our chip is 8-bit. I wondered how that would work… Well it turns out that the 16-bit timer is split into two 8-bit registers. To read and write this 16-bit timer I have to shift bits around and move around between Rust’s datatypes u8 and u16, for 8-bit and 16-bit respectively.

After all that, I finally got it working. I have to say that most of the struggle came from working with a target configuration and the Rust toolchain. Flashing the chip with my C code felt easier. But I think I like the expressiveness of Rust more. It sure made me learn a lot more.

Next steps

So there we have it. My first introduction (two if you count Blinky in C and Blinky in Rust as two separate things) to embedded programming is done. I will continue to make more projects and incrementally learn more about different inputs and outputs as well as electronics and more embedded concepts. Of course, I will be both documenting and sharing them on my blog, so stay tuned!