Rust on STM32 - Part 1
Caveat.
Technology moves somewhat quickly. If you're reading this in 2026 or later, things have probably changed somewhat. Some of the things should still be relevant, but you might want to check if there are newer articles on this topic.
Prior knowledge.
I assume that you, the Reader, are comfortable enough writing Rust. But now, for whatever reason, you need (or want) to use Rust to write something for an embedded device. You've heard that STM32 family is fairly well supported and decided to give it a go. So, what now?
Expectations.
We'll go over how to set up cargo to compile code for whatever microcontroller you're dealing with, how to flash it to said microcontroller and do some very basic debugging. I'll try to explain whatever magic is happening and why (to the best of my ability) - namely what the one crate we pull in is there for.
The hardware.
I will be using a STM32F3DISCOVERY board. Why? I have one on my desk right now. You can follow along using whatever board you have, provided you can find two key pieces of documentation:
- A User manual for the board in question. Okay, it doesn't have to specifically be a user manual, just anything that will tell you what connects to which pins on the microcontroller. I don't recommend trying to follow the traces on the board. You won't need it right now, but if you want to interface with the outside world in the future it'll be a nice thing to have.
- A Reference manual for the microcontroller. The board I'm using has a STM32F303VCT6. The reference manual covers a bunch of STM32F303 variants. They might differ in what peripherals are available, how much RAM/embedded FLASH they have, etc. but otherwise they're fairly similar.
Preparation.
Target.
By default, cargo will compile the program for whatever computer you're using. You're most likely using a 64-bit x86 CPU. The STM32 family is mostly 32-bit ARM (I say mostly since I honestly don't know if they've made any 64-bit versions). Things get more complicated since various versions of ARM can use slightly different instructions and have some other differences (like interrupt handling or having a FPU). So what we need to know is what target (platform) to compile for. List of currently supported targets can be found at https://doc.rust-lang.org/stable/rustc/platform-support.html. From the Reference manual I can tell that the STM32F303x has an "Arm® Cortex®-M4" CPU. The cheat-sheet at https://docs.rust-embedded.org/cortex-m-quickstart/cortex_m_quickstart/ tell us that means I need to use the thumbv7em-none-eabi target. So first thing is to install whatever rust needs to support it.
rustup target add thumbv7em-none-eabi
If you don't want to click the link, here's a copy of the relevant table:
- Cortex M0 -
thumbv6m-none-eabi
- Cortex M0+ -
thumbv6m-none-eabi
- Cortex M3 -
thumbv7m-none-eabi
- Cortex M4 -
thumbv7em-none-eabi
- Cortex M4F -
thumbv7em-none-eabihf
- Cortex M7 -
thumbv7em-none-eabi
- Cortex M7F -
thumbv7em-none-eabihf
memory.x
On your PC, the OS will load the program into the memory (and reserve memory if necessary). Thanks to nifty hardware like the MMU (Memory Management Unit) programs on your PC don't have to worry about not using the same memory addresses as one another. The MMU translates the addresses programs use into physical memory addresses individually, so the programs don't have to worry about anything. Not so on a microcontroller. What's more, every microcontroller has a different amount of memory available, and a different layout. So our next stop is to make sure that the linker will know where to put what (variables, code, etc.). This is where memory.x file and the Reference manual come in. The manual always has a memory map. Which esentially tells you what hardware is available at which addresses. After finding the table relevant to my microcontroller, I now know that I can access 256 KiB of FLASH starting at 0x0800_0000, 40 KiB of RAM at 0x2000_0000 and an additional 8 KiB of even faster RAM at 0x1000_0000. So, my memory.x file looks like this:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 40K
CCRAM (rwx) : ORIGIN = 0x10000000, LENGTH = 8K
}
_stack_start = ORIGIN(CCRAM) + LENGTH(CCRAM);
The CCRAM and _stack_start entries are specific to this hardware, since I want the stack to be placed in the faster RAM. Otherwise the stack would be placed in the regular RAM section.
We're cheating here a tiny bit. Usually you'd also need to specify which sections (code, variables, etc.) go into which memory address (FLASH, RAM, etc.), however we'll use a crate that does this for us. Said crate also does a lot of other tedious chores that we don't want to burden ourselves with just yet. I'll mention it when we get to it. And we have to name the file memory.x since that's what said crate expects to find. Okay, due to how we're copying the file during the build process, it can be named anything we want as long as it's named memory.x once it's copied to the build directory. But it's just easier to keep the name - it'll spare us time later figuring out what this file is for.
Since this file is used by a crate, we have to use those specific names for regions (RAM
and FLASH
), otherwise they'll end up unused. That's also why when defining stack I need to use _stack_start
rather than another name.
New project.
We now get to create a new cargo project (regular executable). Place the memory.x file right next to Cargo.toml. There are two more things we need to do before we move on to the code. We need to make sure cargo compiles our program for the microcontroller, and we need to make sure that it actually uses the memory.x file we've prepared... Or rather the link.x file from the crate, that in turn pulls in our memory.x.
Set default compilation target.
For that we need to create a .cargo directory (with the leading dot) and put a config.toml in there. That file is very simple and in my case looks like this, since I'm using the thumbv7em-none-eabi target:
[]
= "thumbv7em-none-eabi"
You can find out more about the cargo configuration files here: https://doc.rust-lang.org/cargo/reference/config.html.
Make sure memory.x is used.
For this we need to create a build.rs file that will copy (recreate) the memory.x file to where it's needed during compilation and make sure if can be found. You can learn more about it here: https://doc.rust-lang.org/cargo/reference/build-scripts.html, but what we need is relatively simple:
use env;
use File;
use Write;
use PathBuf;
Magic before main()
This will be a bit of a rant. TL;DR: we want to use the cortex-m-rt crate to automate some of menial work.
We tend to think that the program starts at the beginning of the main()
function. That's not true. There is some work done behind the scenes to make sure that all the relevant variables are in RAM, and everything else is set up. This is true for any program (see Matt Godbolts talk at https://www.youtube.com/watch?v=dOfucXtyEsU), but doubly so for microcontrollers, where we might need to initialise RAM and FLASH (if the microcontroller supports interfacing with external chips). We also need some code to exist before we start. Namely we need interrupt handlers. At the very beginning of memory used by any STM32 microcontroller is the Interrupt Vector Table. Essentially, whenever the CPU needs to deal with an interrupt, it looks at the table and starts executing code at the address specified in said table at the position corresponding to the interrupt. The very first interrupt in the table? Reset. This is the address for the function that runs whenever the device reboots. The cortex-m-rt crate comes with code that populates the whole vector table with default interrupt handlers (which either mean that they do nothing, or in cases of interrupts handling severe errors - loop forever). Among those, it has a reset handler that copies whatever needs to be copied from FLASH to RAM, and then runs the function we decide should be run (usually main()
, but it's flexible). You might recall me mentioning cheating and using a crate to specify which sections should be placed where in memory. This is it. It has pretty nice documentation too: https://docs.rs/cortex-m-rt/latest/cortex_m_rt/.
So we add the newest version of the crate (as of time of writing) to Cargo.toml:
[]
= "0.7.3"
Okay, now we can look at src/main.rs. Finally.
src/main.rs. Finally.
Oh, right, we don't have an OS.
Rust std crate has a lot of useful tools: file access, etc. Unfortunately, we don't get to play with those. They need to interface with an OS to work and we don't have one of those. So we need to tell the compiler that we don't want to use the std crate. And we already mentioned that we're not automatically running main()
. Luckily Rust supports that:
We still need to tell cortex-m-rt which function should be treated as the entry point (main()
equivalent), and that's what the entry
macro is for. The funky function signature means that this function will never exit. Which makes sense, since there is nothing to exit to.
use entry;
!
Whew. That was a lot of work to do nothing.
I lied, there's one more thing.
If you try to compile the program right now, Rust will complain that you haven't specified a panic handler. While technically there's no real way for our program to panic just yet, Rust needs to have function that will run whenever a panic happens, usually to try and display some debug information related to the panic. This is usually provided by the std library, which we're not using. So we need to create our own:
use PanicInfo;
!
Okay, now it compiles.
Full src/main.rs
use PanicInfo;
use entry;
!
!
And we need to quickly check if it runs.
We'll use OpenOCD https://openocd.org/ since it'll allow us to debug the code. And check if it's actually running - it's hard to tell if the device is stuck in the infinite loop we put in the main()
function or just crashed.
This will be a two-step operation. First, we need to run OpenOCD, which will connect to the board and sit there, doing nothing. Then we need to connect to it using gdb, which will load the code onto the microcontroller. OpenOCD first:
openocd -f interface/stlink.cfg -f target/stm32f3x.cfg
This runs OpenOCD and tells it to load two files:
interface/stlink.cfg
- this tells OpenOCD what kind of interface is used to communicate with the microcontroller. STM32 family uses stlink.target/stm32f3x.cfg
- this tells OpenOCD what kind of microcontroller it's connecting to. This is somewhat important since each of them handles the internal FLASH somewhat differently. This means that code used to actually write our program to the microcontroller has to be uniquely tailored to that microcontroller. Joy. Unfortunately, as far as I can tell, there is no nice, human-readable list of which config file corresponds to your specific microcontroller. Luckily they're named in a fairly reasonable manner. On linux OpenOCD usually puts those files in/usr/share/openocd/scripts/target/
so you can search that folder for something that matches your microcontroller.
If everything went well, you should see something like
Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : clock speed 1000 kHz
Info : STLINK V2J37M26 (API v2) VID:PID 0483:374B
Info : Target voltage: 2.909521
Info : [stm32f3x.cpu] Cortex-M4 r0p1 processor detected
Info : [stm32f3x.cpu] target has 6 breakpoints, 4 watchpoints
Info : starting gdb server for stm32f3x.cpu on 3333
Info : Listening on port 3333 for gdb connections
So it's gdb time.
No, really, this will be quick.
I guess it is time to run gdb.
arm-none-eabi-gdb -q target/thumbv7em-none-eabi/debug/basic
Reading symbols from target/thumbv7em-none-eabi/debug/basic...
Yes, I named the project basic, but it's not the time to dwell on my lackluster naming skills. The gdb has loaded the file, but it still doesn't know it's supposed to connect to OpenOCD.
(gdb) target remote :3333
Remote debugging using :3333
0x00000000 in ?? ()
We're connected. What's shown in your case may vary, since it depends on the state of the microcontroller. So, we're connected but the code is still not uploaded. Let's change that.
(gdb) load
Loading section .vector_table, size 0x400 lma 0x8000000
Loading section .text, size 0x70 lma 0x8000400
Start address 0x08000400, load size 1136
Transfer rate: 5 KB/sec, 568 bytes/write.
We could run now, but first let's put in a breakpoint. We don't get much choice, so let's just put it at main()
(gdb) break main
Breakpoint 1 at 0x8000440: file src/main.rs, line 12.
Note: automatically using hardware breakpoints for read-only addresses.
Yes, since the the program has been build with debug not only does it know there's a main()
function, it also knows it's in main.rs and even at which line. Time to run the code...
(gdb) continue
Continuing.
Breakpoint 1, basic::__cortex_m_rt_main_trampoline () at src/main.rs:12
12 #[entry]
Okay, we're at the break point. But it's odd... we were supposed to be at main()
, what is this __cortex_m_rt_main_trampoline
? Well, it's part of the cortex-m-rt crate of course, but you already figured that part out. It's there to run our designated entry point (main()
) and make sure there won't be any problems with stack traces. But it would be better to actually see where that is in our source file, right? Well, gdb has us covered.
(gdb) layout src
Ah, much better. Now we can step
(or simply s
) or continue
(c
) and see how we're stuck in the infinite loop. But at least it's an infinite loop we intended to be stuck in.
And we're done.
Yes. I know we're not even pulsing a LED, but getting GPIO up and running would make this somewhat longer. Besides, this leaves us with something to do next time.