If Rust turns out to be genuinely useful in an embedded setting, I definitely won’t be subjecting myself to C again anytime soon.
Just kidding — I like you, C…
…nah, not really. 😄
Setup
Hardware
Target MCU – STM32F030
This is a solid Cortex-M0 device. The variant I’m using offers around 35 usable GPIO pins, 64KB of flash, and 8KB of RAM. The real selling point, though, is the price — roughly $1 USD in low quantities.
Programmer – ST-Link V2
The inexpensive AliExpress clone, which can be picked up for around $3 USD.
Software
IDE – VS Code
I’ve been using VS Code for a while now and it’s my daily driver for Python, C#, and Rust development.
OS – Ubuntu KDE 20.04 (Kubuntu)
Recently upgraded from Kubuntu 18.04. It’s been mostly stable, although I’ve had a few interesting issues when docking and undocking.
Getting Started Resources
There are a few additional tools that need to be installed (Rust, GDB, OpenOCD, etc.). The following resources are absolutely worth checking out:
After installing and setting everything up, we can finally start writing code.
…Hang on. How do we actually test that code?
Hardware-Level Debugging
The resources above walk through setting up a debug environment in VS Code. This allows you to step through code while it executes on the MCU.
Below is one of the debugger configurations from my launch.json file, with a few custom tweaks:
Unit Testing.
Can we run unit tests on the host instead of the target MCU?
Yes — but it requires some conditional compilation using cfg_attr.
When not running unit tests, the code is compiled with no_std and no_main, and certain modules are excluded:
1
2
3
4
5
6
7
8
9
10
11
12
#![cfg_attr(test, allow(unused_imports))]#![cfg_attr(not(test), no_std)]#![cfg_attr(not(test), no_main)]// set the panic handler
externcratecortex_m_semihosting;#[cfg(not(test))]externcratepanic_semihosting;#[cfg(not(test))]usecortex_m_semihosting::hprintln;usepanic_semihostingas_;
This setup allows logic to be tested on the host while still compiling cleanly for the embedded target.
Code Size While Debugging
At around 1–1.5k lines of code, I was already running out of space on the 64KB flash.
That raised a big question:
If this is the size of a small Rust codebase, is Rust actually usable in embedded systems?
The Solution
Enable optimization.
By default, debug builds include a huge amount of metadata. Adding the following to Cargo.toml drastically reduced the binary size:
Normally, opt-level defaults to 0. Switching to “z” made a massive difference.
RTOS and Embedded Libraries
RTOS
For this project, I used RTIC, a real-time concurrency framework written entirely in Rust.
While it’s not as mature as something like FreeRTOS, it fits my use case perfectly. One of its biggest advantages is safe data sharing between priority-based interrupts — something that’s notoriously easy to get wrong in traditional embedded systems.
useembedded_hal::{digital,serial};usecrate::buf;usebuf::{Buf,IBuf};usecrate::dev;usedev::IDev;usecrate::error;useerror::BufErr;useerror::DevErr;usenb::block;/// Serial Device
pubstructDevSerial<USART,TXEN,RXEN,SENDBUF,RECVBUF>{interface: USART,tx_en: TXEN,tx_buf: SENDBUF,rx_en: RXEN,rx_buf: RECVBUF,}impl<USART,TXEN,RXEN,SENDBUF,RECVBUF>DevSerial<USART,TXEN,RXEN,SENDBUF,RECVBUF>whereUSART: serial::Write<u8>+serial::Read<u8>,TXEN: digital::v2::OutputPin,RXEN: digital::v2::OutputPin,SENDBUF: IBuf<u8>,RECVBUF: IBuf<u8>,{pubfnnew(interface: USART,tx_en: TXEN,tx_buf: SENDBUF,rx_en: RXEN,rx_buf: RECVBUF,)-> Self{DevSerial{interface: interface,tx_en: tx_en,tx_buf: tx_buf,rx_en: rx_en,rx_buf: rx_buf,}}}impl<USART,TXEN,RXEN,SENDBUF,RECVBUF>IDevforDevSerial<USART,TXEN,RXEN,SENDBUF,RECVBUF>whereUSART: serial::Write<u8>+serial::Read<u8>,TXEN: digital::v2::OutputPin,RXEN: digital::v2::OutputPin,SENDBUF: IBuf<u8>,RECVBUF: IBuf<u8>,{/// Receive Byte
fnget_recv_byte_mut(&mutself)-> Result<(),DevErr>{//Read serial port
letdata=block!(self.interface.read()).map_err(|_|DevErr::SerialErr)?;//Check if buffer is overflowing if it is then clear the buffer
//This should not happen often unless we get lots of garbage
//on the comms line
matchself.rx_buf.write_val_mut(data){Ok(_)=>{}Err(BufErr::Overflow)=>{self.rx_buf.clear_mut()?;}_=>panic!("Empty"),//This should never happen
};Ok(())}/// Return receive buffer as slice
fnget_recv_buf_as_slice(&self)-> Result<&[u8],DevErr>{Ok(self.rx_buf.get_as_slice()?)}fnclear_recv_buf_from_first_to_val_mut(&mutself,val: u16)-> Result<(),DevErr>{self.rx_buf.clear_from_first_to_val_mut(val)?;Ok(())}/// Clear receive buffer
fnclear_recv_buf_mut(&mutself)-> Result<(),DevErr>{self.rx_buf.clear_mut()?;Ok(())}/// Transmit a single u8 from buffer this will
/// remove the transmitted byte from the buf
/// This is useful for interupts
fntransmit_single_u8_mut(&mutself)-> Result<(),DevErr>{block!(self.interface.write(self.tx_buf.get_first_val_mut()?)).map_err(|_|DevErr::SerialErr)?;Ok(())}/// Write a single u8 into the send buffer
fnwrite_u8_to_send_buf_mut(&mutself,data: u8)-> Result<(),DevErr>{self.tx_buf.write_val_mut(data)?;Ok(())}/// Return send buffer as mut slice
fnget_send_buf_as_mut_slice_mut(&mutself)-> Result<&mut[u8],DevErr>{Ok(self.tx_buf.get_as_mut_slice_mut()?)}/// Clear send buffer
fnclear_send_buf_mut(&mutself)-> Result<(),DevErr>{self.tx_buf.clear_mut()?;Ok(())}}#[cfg(test)]modtests{#![allow(non_snake_case)]usesuper::*;useembedded_hal::digital::v2::{InputPin,OutputPin};useembedded_hal_mock::pin::{MockasPinMock,StateasPinState,TransactionasPinTransaction,};useembedded_hal_mock::MockError;useembedded_hal::blocking::serial::Write;useembedded_hal::serial::Read;useembedded_hal_mock::serial::{MockasSerialMock,TransactionasSerialTransaction};useheapless::consts::{U128,U16,U256,U32,U64};// type level integer used to specify capacity
#[test]fncallGetRecvByteMut_set3ElementReturn3_expect3ElementsInOrder(){// Setup
letdio_expectations=[PinTransaction::set(PinState::High)];// Create pin
lettx_en_mock=PinMock::new(&dio_expectations);letrx_en_mock=PinMock::new(&dio_expectations);letfirst=10;letsecond=20;letthird=30;// Configure expectations
letserial_expectations=[SerialTransaction::read_many([first,second,third])];letserial_mock=SerialMock::new(&serial_expectations);lettx_buf=buf::Buf::<u8,U16>::new();letrx_buf=buf::Buf::<u8,U16>::new();letmutrs485=DevSerial{interface: serial_mock,tx_en: tx_en_mock,tx_buf: tx_buf,rx_en: rx_en_mock,rx_buf: rx_buf,};rs485.get_recv_byte_mut().unwrap();rs485.get_recv_byte_mut().unwrap();rs485.get_recv_byte_mut().unwrap();//println!("{:?}", rs485.get_recv_buf_as_slice().unwrap());
//Test
assert_eq!(first,rs485.get_recv_buf_as_slice().unwrap()[0]);assert_eq!(second,rs485.get_recv_buf_as_slice().unwrap()[1]);assert_eq!(third,rs485.get_recv_buf_as_slice().unwrap()[2]);}#[test]fncallClearRecvBufMut_set3ElementsThenClear_expectEmptyBuf(){// Setup
letdio_expectations=[PinTransaction::set(PinState::High)];// Create pin
lettx_en_mock=PinMock::new(&dio_expectations);letrx_en_mock=PinMock::new(&dio_expectations);letfirst=10;letsecond=20;letthird=30;// Configure expectations
letserial_expectations=[SerialTransaction::read_many([first,second,third])];letserial_mock=SerialMock::new(&serial_expectations);lettx_buf=buf::Buf::<u8,U16>::new();letrx_buf=buf::Buf::<u8,U16>::new();letmutrs485=DevSerial{interface: serial_mock,tx_en: tx_en_mock,tx_buf: tx_buf,rx_en: rx_en_mock,rx_buf: rx_buf,};rs485.get_recv_byte_mut().unwrap();rs485.get_recv_byte_mut().unwrap();rs485.get_recv_byte_mut().unwrap();rs485.clear_recv_buf_mut().unwrap();//println!("{:?}", rs485.get_recv_buf_as_slice().unwrap());
lettest: &[u8]=&[];//Test
assert_eq!(test,rs485.get_recv_buf_as_slice().unwrap());}}