Rust and C side-by-side on the STM32F103C8T6 (“Blue pill”) board

For most people, the first program they write for an MCU is “blinky”. This post explores blinky in C and Rust for the STM32F103C8T6 Blue pill board. This board has a built-in LED on port C bit 13. Programs are downloaded via an STLink-V2 SWD debug interface clone.

What does the program do?

When you power on an STM32F103 it looks to the lower addresses in FLASH memory to figure out what to do. The first entry (32 bit) should contain the value for the initial stack pointer. This is typically set to the top of RAM. The next entry is the address of the code that handles reset : the reset_handler.
Typically, the job of the reset handler is to initialize global and static variables and maybe perform some clock initialization. When this is done it then calls on the “main” function although this is not essential : you can write all your code in the reset handler. I prefer to use the main function.
In order to make the LED blink the main function must configure the appropriate GPIO pin as an output (Port C, bit 13). Having done this, the program enters an endless loop which does the following:
Set port C bit 13 high (turning on the LED)
Wait for a while (so the user can see the LED change)
Set port C bit 13 low (turning off the LED)
Wait for a while (so the user can see the LED change)
And that’s it. Now lets look at the C and Rust ways of doing this. Note: these are very much minimal programs that seek to bridge the gap between the higher level languages and the hardware. They don’t necessarily represent a recommended programming style for complex systems.

Blinky in C : memory layout

The figure above shows how the linker script file (linker_script.ld) lays out the memory image output by the linker. Flash memory starts at address 0x08000000. The initial stack pointer value is set by the line:


which evaluates to 0x20005000.
The interrupt vector table is then placed immediately after this – the reset vector being the first and only entry in this case. The text section contains code, rodata contains constants and so on. The ARM.exidx section contains data that can be used during debugging to perform a stack backtrace. The last section is not really relevant to this example but is included for completeness. This is used to help initialize global and static data.

Blinky in C : code

/* User LED for the Blue pill is on PC13 */
#include  // This header includes data type definitions such as uint32_t etc.

// Simple software delay.  The larger dly is the longer it takes to count to zero.
void delay(uint32_t dly) {
// GPIO configuration
void config_pins() {
    // Make pointers to the relevant registers
    volatile uint32_t * rcc_apb2enr = (  (volatile uint32_t *) 0x40021018  );
    volatile uint32_t * gpioc_crh = (  (volatile uint32_t *) 0x40011004  );
    // Turn on GPIO C
    *rcc_apb2enr |= (1 << 4); // set bit 4
    // Configure PC13 as an output
    *gpioc_crh |= (1 << 20); // set bit 20
    *gpioc_crh &= ~((1 << 23) | (1 << 22) | (1 << 21)); // clear bits 21,22,23
void led_on() {
    // Make a pointer to the output data register for port C
    volatile uint32_t * gpioc_odr = (  (volatile uint32_t *) 0x4001100c  );
    *gpioc_odr |= (1 << 13); // set bit 13

void led_off() {
    // Make a pointer to the output data register for port C
    volatile uint32_t * gpioc_odr = (  (volatile uint32_t *) 0x4001100c  );
    *gpioc_odr &= ~(1 << 13); // clear bit 13
// The main function body follows 
int main() {
    // Do I/O configuratoin
    while(1) // do this forever
// The reset interrupt handler
void reset_handler() {
    main(); // call on main 
    while(1); // if main exits then loop here until next reset. 

// Build the interrupt vector table
const void * Vectors[] __attribute__((section(".vector_table"))) ={

Let's start at the bottom of the code: Here you can see the interrupt vector table being defined. It begins with:

    const void * Vectors[] __attribute__((section(".vector_table"))) ={

What does this mean:
Vectors is a an array of constant pointers to undefined types. It is given the linker section attribute ".vector_table" which places it at the appropriate place in the program memory image. There is just one element in this array: reset_handler which represents the address of that function. So, when reset or power up happens, the first code to be executed will be at the address “reset_handler”. In this example, the reset_handler code simply calls on main. If by some chance the main function exits, reset_handler then enters an empty endless loop.
The main function calls on lower level functions : config_pins, led_on, delay and led_off.

The job of config_pins is to set up GPIO Port C, bit 13 as an output. Note the way pointers to the hardware registers are created. This mechanism is less than ideal because the pointer takes up valuable RAM. It is more usual to use #define macros for these pointers instead but this approach is taken here so that the C code lines up with the Rust code more closely.
The functions led_on, led_off behave in a similar way. The delay function implements a simple software delay loop. The program is built with the following command line (this is in a “batch” file in the github repository called build.bat – this batch file extension is chosen so that it can be directly executed on Windows as well as Linux)

    arm-none-eabi-gcc -static -mthumb -g -mcpu=cortex-m3 *.c -T linker_script.ld -o main.elf -nostartfiles 

This invokes the arm gcc compliler, performs static linking (no dll’s), generates “thumb” code, with debugging information for the arm-cortex-m3 core. All C files in the current directory are included. The linker is instructed to use this particular linker script, the output program will be called main.elf and the linker is instructed not to include any default initialization code (the reset_handler function does this). You must have arm-none-eabi-gcc installed on your system and reachable via the PATH environment variable.

Blinky in Rust
Rust uses a linker file just like C – in fact it is identical apart from a couple of minor points:
1) It is called memory.x (which is in line with other examples on the Internet)
2) It doesn’t contain the section relating to the initialization of global and static variables.

While the C version of the program sits entirely in one directory, the Rust version is distributed across a number of sub-directories. This is because the Rust build tool Cargo is used. The directory tree is as follows:

This is obviously WAY more complex however you really only have to concern yourself with the highlighted files.

The config file in the “.cargo” directory contains the following:

rustflags = ["-C", "link-arg=memory.x"]

target = "thumbv7m-none-eabi"

This tells the rust compiler the target architecture : thumbv7m (Cortex m3), none (no operating system), eabi : defines the function calling convention, the way data is organized in memory and the object file format as embedded application binary interface. The rustflags setting causes the linker to use the linker script file memory.x when generating the program image.

The file Cargo.toml contains the following

name = "slightly_rusty"
version = "0.1.0"

# enable debugging in release mode.
debug = true

This simply names the output executable file name, specifies the version number and causes debugging data to be included in the release version (can be handy while testing)

The memory.x linker script file has been discussed above.

The rust code for this project is included in It is as follows:


use core::panic::PanicInfo;

fn delay(mut dly : u32) {
    while dly > 0
        dly = dly -1;
// GPIO configuration
fn config_pins() {
   unsafe {
        // Make pointers to the relevant registers
        let rcc_apb2enr  = 0x40021018 as *mut u32;
        let gpioc_crh    = 0x40011004 as *mut u32; 
        // Turn on GPIO C
        *rcc_apb2enr |= 1 << 4; // set bit 4
        // Configure PC13 as an output
        *gpioc_crh |= 1 << 20;  // set bit 20
        *gpioc_crh &= !((1<<23) | (1<<22) | (1 << 21)); // clear bits 21,22,23

fn led_on() {
    unsafe {
        // Make a pointer to the output data register for port C
        let gpioc_odr  = 0x4001100C as *mut u32; 
        *gpioc_odr |= 1 << 13; // set bit 13

fn led_off() {
    unsafe {
        // Make a pointer to the output data register for port C
        let gpioc_odr  = 0x4001100C as *mut u32; 
        *gpioc_odr &= !(1  ! = reset_handler;

// Rust requires a function to handle program panics - this one simply loops.  You 
// could perhaps write some code to output diagnostic information.
fn panic(_panic: &PanicInfo) -> ! {
    loop {}

The code is pretty similar to the C code so let’s just look at some of the differences.
First of all, we see the #![no_main] macro. This tells rust that there is no default startup function. Without this the compiler generates an error saying “error: requires `start` lang_item” which presumably means it can’t find the
default entry point for this program – not a problem here as the reset_handler is the entry point.
The #![no_std] macro tells the rust ( and the linker) not to include the rust standard library as it is not implemented (at least not fully) for the thumbv7m-none-eabi target.

The next line: use core::panic::PanicInfo is a bit like a C-include. It includes the definition for the type “PanicInfo” which is needed by the panic handler at the end of the program. The rust functions that follow have obvious parallels with their C counterparts. The key differences are:
unsafe : this keyword suspends some of Rust’s compile time memory/type safety checking to allow raw pointers to be used.
(unsafe reference : The data types are quite similar to those defined in stdint.h : uint32_t = u32 etc. Pointer behaviour is similar to C also. Consider this line from config_pins:

  let rcc_apb2enr  = 0x40021018 as *mut u32;

This declares rcc_apb2enr as a pointer to the address 0x40021018 which contains a changeable unsigned 32 bit integer. Pointer deferencing is just like C’s.
One additional difference: the bitwise NOT is an exclamation point (!) as opposed to C’s tilde (~)

To build the rust program the following command is executed from the same directory as the Cargo.toml file:

cargo build

Assuming all goes well the output executable is written to the file

Loading on to the chip
The output files main.elf and slightly_rusty are loaded in the same way on to the target hardware. Start an openocd session in one terminal from the top level directory (rust_vs_c) as follows
/usr/bin/openocd -f stm32f103_aliexpress.cfg
This is the default openocd (version 0.10.0) that comes with Ubuntu 19.04
Assuming your devices are plugged in ok you should see an output something like this:
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
adapter speed: 1000 kHz
adapter_nsrst_delay: 100
none separate
none separate
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : clock speed 950 kHz
Info : STLINK v2 JTAG v17 API v2 SWIM v4 VID 0x0483 PID 0x3748
Info : using stlink api v2
Info : Target voltage: 3.244914
Info : stm32f1x.cpu: hardware has 6 breakpoints, 4 watchpoints

In another window run arm-none-eabi-gdb and execute the following commands
target remote :3333
load c/main.elf
monitor reset

This loads the C version of the progam and you should see the blue pill’s onboard LED blink.
Execute the following commands to run the rust version:
load rust/target/thumbv7m-none-eabi/debug/slightly_rusty
monitor reset

Hopefully the LED starts blinking again.

Where to from here? There are crates (rust libraries) online that define the various peripherals for STM32F103 devices etc. I’ve begun looking at these and while they are great in that they convert the STM32 SVD files to Rust they are also quite big – leading to a quite large final executable. More investigations are needed as well as a project to drive it all along.
Code for these examples is over on github

One thought on “Rust and C side-by-side on the STM32F103C8T6 (“Blue pill”) board

  1. Daniel September 4, 2019 / 2:52 pm

    Actually you’re building in “dev” configuration which turns off inlining for everything. You should add the `–release` option to your `cargo build` call to get optimised binaries.

    > leading to a quite large final executable

    Building in dev configuration can lead to a slightly larger binary if you’re using the svd2rust generated crate. In release configuration this is a true zero-cost abstraction and will not add a single byte to the binary.

    Liked by 1 person

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s