Rust on STM32 - Part 2: interrupt boogaloo
Caveat.
Same as last time, this is written in late 2024. If you're reading this in 2026 or later you might want to check that nothing has changed regarding the crates I'm referencing.
Recap.
Last time we've written, compiled, flashed, ran and debugged (well, gdb was open, that counts, right?) a program that did... nothing. Now let's move on to more exciting things. Like blinking a LED. I'll be using the same hardware, but you can use whatever you want provided you can read the documentation and find the differences.
- Board STM32F3DISCOVERY
- MCU STM32F303VCT6
- CPU Arm® Cortex®-M4
- compilation target thumbv7em-none-eabi
Okay, what do I do to blink the thing?
If we had access to a HAL (Hardware Abstraction Layer) this would be somewhat simple. Okay, there are (multiple) crates that provide a HAL for the STM32F3 family. But let's do it manually now.
Amongst all the pins you can find on your MCU, some are controlled by the GPIO (General Purpose Input Output) module. This means that those particular pins can be configured to either try and detect whether there is voltage on them or not (input) or provide voltage (or not) to whatever is connected to them. LED gets provided with voltage -> there's current flowing -> LED is on. Great. So what now?
Look for LEDs.
Most discovery boards have around 3 LEDs connected to GPIO. You might also expect a power LED and LEDs that show communication over the debugging interface. The ones we need tend to be labelled LD1, LD2 and so on. In my case, the User manual for the board helpfully says that I get to play with 8 LEDs:
- LD3 connected to
PE9
- LD4 connected to
PE8
- LD5 connected to
PE10
- LD6 connected to
PE15
- LD7 connected to
PE11
- LD8 connected to
PE14
- LD9 connected to
PE12
- LD10 connected to
PE13
What's with the PE and a number? Well, according to the Reference manual, this MCU has 8 separate GPIO modules, labelled A to H. So PE9 means "pin 9 of GPIOE". Okay, let's play with LD3 for now. On to the manual!
Okay, one thing before we do that.
RCC
Each MCU tends to have a lot of peripherals. If you're lucky (or unlucky, depending on the point of view) you might be using all of them. But most likely you'll want to use just a few. Also, anything that's on uses power, and if you're using a MCU you might be power-conscious. Perhaps the device needs to run for months on battery power. So pretty much every MCU boots with everything turned off and expects you to turn the peripherals on (and configure them). On the STM32 family that's done via the RCC (Reset and clock control) registers. Thanks to the Reference manual I know that GPIOE is controlled by bit 21 of RCC_AHBENR
register, which is offset by 0x14 from the beginning of the RCC memory block. A quick glance at the memory table tells me that IT begins at 0x4002_1000. I quickly note down that the GPIOE block begins at 0x4800_1000. It will come in handy later. So here's quick code to turn GPIOE on:
// base address of RCC registers
const RCC_BASE: u32 = 0x4002_1000;
// pointers to AHBENR and APB1ENR registers of RCC
const RCC_AHBENR: *mut u32 = as _;
// operations on pointers are always considered unsafe
unsafe
It's not pretty, but it works. Unfortunately, there's no way to remove unsafe when dealing with memory-mapped configuration registers.
Now to configure the GPIO. This will be easy, right?
Ummm... It's not rocket science if that's what you mean, but we need to make several choices. Since the same pin can be configured for input, output and even some alternative modes, this will take more than one line of code.
Mode of operation.
Input or output. Simple. Manual claims it's set in GPIOx_MODER
register (offset 0x00) and it's 2 bits per pin, with b01 meaning output.
Output type.
Push-pull or open-drain. For LEDs we want push-pull. GPIOx_OTYPER
(offset 0x04), 1 bit per pin, we want it set to 0 (default). Yay! Less work.
Output speed.
Let's set this to high speed. GPIO_x_OSPEEDR
(offset 0x08), 2 bits per pin, b11 means high speed.
Pull-up or pull-down.
State of pin when we're not driving it. Nothing, we're driving the pin all the time. GPIOx_PUPDR
(offset 0x0C), 2 bits per pin, we want b00 (again, default for this module).
We can ignore the alternate function registers. So we just need to set the mode and speed. While it's not prohibitively large, it might be a good idea to put it in its own module just in case we want to use other GPIO banks. Since we're in this part of the manual, we can also note that we can read or set the state of all GPIOx pins via the GPIOx_ODR
register (offset 0x14). There's also a nifty register that allows up to turn them on (or off) without a read - GPIOx_BSSR
(offset 0x18), since it's a stateless register - when writing only the set bits will have any effect (upper half of the register is for turning off, lower half for turning on). We could access everything using pointers, but there is a nifty crate volatile-register
that makes things a bit cleaner if we create a struct (and force Rust to keep the order of fields):
Now just add some more code to the main() function, making it look like this:
!
It lives!
Well, it compiles, and at least on my system, cargo build
reports no errors. Now we get to put in on the MCU again. This time we'll simplify the flashing/debugging part using configuration files for OpenOCD and GDB.
OpenOCD
OpenOCD automatically tries to load openocd.cfg from whatever directory it's run in and execute the commands within. So all we need to do is make the openocd.cfg with the following contents:
# use the ST-Link interface
source [find interface/stlink.cfg]
# we connect to STM32F3x family of MCUs
source [find target/stm32f3x.cfg]
It shouldn't be surprising that it looks quite similar to the parameters we passed to it last time. Now we can run OpenOCD with just
openocd
And 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
[stm32f3x.cpu] halted due to breakpoint, current mode: Thread
xPSR: 0x01000000 pc: 0x08000730 msp: 0x10001ed0
GDB
While GDB does not automatically load a file with commands, it can be told to. So we create a gdb.cfg with contents:
# connect to OpenOCD
target remote :3333
# print demangled ASM symbols
set print asm-demangle on
# set a breakpoint at main()
break main
# load the program onto the MCU
load
# run a single ASM instruction - so start the code but stop immediately
stepi
Now we run
gdb -x gdb.cfg -q target/thumbv7em-none-eabi/debug/basic
And we should see something like...
Reading symbols from target/thumbv7em-none-eabi/debug/basic...
0x08000730 in core::ptr::read_volatile::precondition_check (addr=0x48001014, align=4) at /rustc/3f5fd8dd41153bc5fdca9427e9e05be2c767ba23/library/core/src/ptr/mod.rs:1704
warning: 1704 /rustc/3f5fd8dd41153bc5fdca9427e9e05be2c767ba23/library/core/src/ptr/mod.rs: No such file or directory
Breakpoint 1 at 0x800050c: file src/main.rs, line 12.
Note: automatically using hardware breakpoints for read-only addresses.
Loading section .vector_table, size 0x400 lma 0x8000000
Loading section .text, size 0x750 lma 0x8000400
Loading section .rodata, size 0x324 lma 0x8000b50
Start address 0x08000400, load size 3700
Transfer rate: 12 KB/sec, 1233 bytes/write.
halted: PC: 0x08000a3c
cortex_m_rt::DefaultPreInit () at src/lib.rs:1058
1058 pub unsafe extern "C" fn DefaultPreInit() {}
(gdb)
Now if we continue
(twice, we've set a breakpoint at main()
) we should see the LEDs cycling.
Obviously, this is wasteful. Introducing a delay using an iterator loop is crude, but easy to write. Is there a better way, perhaps? Yes. Unfortunately it's way more complicated to write. But let's try anyway.
Interrupts.
All CPUs support interrupts. Interrupts make it possible for the CPU to drop whatever it's doing right now and instead execute a specific function (interrupt handler). And then (hopefully) return to whatever it was doing previously. On older hardware this might require the interrupt handler to store the state of the CPU somewhere and then restore it before returning, but as far as I can tell ARM cpus nowadays do that in hardware, so that's one less thing to worry about. Now, the documentation will talk about exceptions, events, interrupts and external interrupts.
Exceptions.
In case of ARM documentation, an exception includes anything that causes the CPU to store its current stack and switch to executing code that handles said exception. This includes interrupts, but also things like dividing by 0, trying to execute an illegal instruction, etc. There is a list of exceptions that appear in every Cortex-M CPU, there is no way to ignore them, and you need to write code that handles them. You might notice that we haven't done that. That's because the cortex-m-rt crate deals with those (it can do it since those are the same on all Cortex-M CPUs).
Interrupt.
Interrupts are device-specific and can be disabled (or rather, can be enabled since by default they're not active). They are generated by peripherals within the MCU (like for example the TIM2 general-purpose timer present in the STM32F303VCT6).
External interrupts/events.
External interrupts are generated by a signal coming to a MCU pin (usually a GPIO pin). There is usually a way to select which external interrupt is triggered by which pin. The distinction between regular interrupts and events is mostly relevant if you want to put the CPU to sleep using WFI (Wait for Interrupt) or WFE (Wait for Event) instruction.
You might recall that I mentioned that those are device-specific. In case of Cortex-M devices, there is an area of memory called the vector table that holds the addresses of functions that handle the exceptions. While the first 16 addresses are the same for all Cortex-M devices, the rest is unique to each MCU. Back to the Reference manual!
Oh boy, that's 85 entries (We don't care for exceptions here - each interrupt will have a "position" column with a number). Some of them are unused, but we still need to include those when implementing our vector table. Usually you'd use a HAL crate that provides all this for you, and we could. But in the interest of science (and in case you might encounter a MCU that doesn't have a HAL crate yet) let's do it step-by-step. The cortex-m-rt crate docs are somewhat helpful in this regard, we'll need to:
- Create an enum that lists every possible Interrupt on this MCU.
- Implement the InterruptNumber trait from the cortex-m crate for this enum.
- Create a list of interrupt handler functions (as extern "C") with names that match the enum values.
- Create a device.x file that informs the linker to use the DefaultHandler for all those C function we've just defined if it can't find another implementation.
- Create a static variable named
__INTERRUPTS
that's the actual vector table and contains pointers (or 0 if that particular position in the table doesn't correspond to anything) to functions that handle the interrupt at that position.
Here comes a lot of code:
unsafe
extern "C"
// stolen from cortex-m-rt docs:
// any position in the table is either a pointer (handler)
// or 0
pub union IntHandler
pub static __INTERRUPTS: = ;
device.x
PROVIDE(WWDG = DefaultHandler);
PROVIDE(PVD = DefaultHandler);
PROVIDE(TAMPER_STAMP = DefaultHandler);
PROVIDE(RTC_WKUP = DefaultHandler);
PROVIDE(FLASH = DefaultHandler);
PROVIDE(RCC = DefaultHandler);
PROVIDE(EXTI0 = DefaultHandler);
PROVIDE(EXTI1 = DefaultHandler);
PROVIDE(EXTI2_TS = DefaultHandler);
PROVIDE(EXTI3 = DefaultHandler);
PROVIDE(EXTI4 = DefaultHandler);
PROVIDE(DMA1_CH1 = DefaultHandler);
PROVIDE(DMA1_CH2 = DefaultHandler);
PROVIDE(DMA1_CH3 = DefaultHandler);
PROVIDE(DMA1_CH4 = DefaultHandler);
PROVIDE(DMA1_CH5 = DefaultHandler);
PROVIDE(DMA1_CH6 = DefaultHandler);
PROVIDE(DMA1_CH7 = DefaultHandler);
PROVIDE(ADC1_2 = DefaultHandler);
PROVIDE(USB_HP_CAN_TX = DefaultHandler);
PROVIDE(USB_LP_CAN_RX0 = DefaultHandler);
PROVIDE(CAN_RX1 = DefaultHandler);
PROVIDE(CAN_SCE = DefaultHandler);
PROVIDE(EXTI9_5 = DefaultHandler);
PROVIDE(TIM1_BRK_TIM15 = DefaultHandler);
PROVIDE(TIM1_UP_TIM16 = DefaultHandler);
PROVIDE(TIM1_TRG_COM_TIM17 = DefaultHandler);
PROVIDE(TIM1_CC = DefaultHandler);
PROVIDE(TIM2 = DefaultHandler);
PROVIDE(TIM3 = DefaultHandler);
PROVIDE(TIM4 = DefaultHandler);
PROVIDE(I2C1_EV = DefaultHandler);
PROVIDE(I2C1_ER = DefaultHandler);
PROVIDE(I2C2_EV = DefaultHandler);
PROVIDE(I2C2_ER = DefaultHandler);
PROVIDE(SPI1 = DefaultHandler);
PROVIDE(SPI2 = DefaultHandler);
PROVIDE(USART1 = DefaultHandler);
PROVIDE(USART2 = DefaultHandler);
PROVIDE(USART3 = DefaultHandler);
PROVIDE(EXTI15_10 = DefaultHandler);
PROVIDE(RTC_ALARM = DefaultHandler);
PROVIDE(USB_WAKE_UP = DefaultHandler);
PROVIDE(TIM8_BRK = DefaultHandler);
PROVIDE(TIM8_UP = DefaultHandler);
PROVIDE(TIM8_TRG_COM = DefaultHandler);
PROVIDE(TIM8_CC = DefaultHandler);
PROVIDE(ADC3 = DefaultHandler);
PROVIDE(FMC = DefaultHandler);
PROVIDE(SPI3 = DefaultHandler);
PROVIDE(UART4 = DefaultHandler);
PROVIDE(UART5 = DefaultHandler);
PROVIDE(TIM6_DAC = DefaultHandler);
PROVIDE(TIM7 = DefaultHandler);
PROVIDE(DMA2_CH1 = DefaultHandler);
PROVIDE(DMA2_CH2 = DefaultHandler);
PROVIDE(DMA2_CH3 = DefaultHandler);
PROVIDE(DMA2_CH4 = DefaultHandler);
PROVIDE(DMA2_CH5 = DefaultHandler);
PROVIDE(ADC4 = DefaultHandler);
PROVIDE(COMP1_2_3 = DefaultHandler);
PROVIDE(COMP4_5_6 = DefaultHandler);
PROVIDE(COMP7 = DefaultHandler);
PROVIDE(USB_HP = DefaultHandler);
PROVIDE(USB_LP = DefaultHandler);
PROVIDE(USB_WAKE_UP_RMP = DefaultHandler);
PROVIDE(FPU = DefaultHandler);
We need to update build.rs to include device.x during the build process. It's nearly the same as the memory.x file we did previously, just this time we don't need to explicitly pass it as an argument to the linker.
create
.unwrap
.write_all
.unwrap;
println!;
We also need to tell cortex-m-rt that we provide the device-specific bits. It's just a feature flag in Cargo.toml:
= {="0.7.3", =["device"]}
If we build now, we should end up with a binary that includes all the symbols we've just defined, but they all point to DefaultHandler. We can check this with llvm-nm (or arm-none-eabi-nm):
llvm-nm target/thubmv7em-none-eabi/debug/basic
080009b0 r .L__unnamed_1
08000b04 r .L__unnamed_1
080008e4 r .L__unnamed_1
08000929 r .L__unnamed_1
08000b14 r .L__unnamed_2
080009b8 r .L__unnamed_2
08000b24 r .L__unnamed_3
08000a78 r .L__unnamed_4
08000b34 r .L__unnamed_4
08000b44 r .L__unnamed_5
08000a88 r .L__unnamed_5
08000b54 r .L__unnamed_6
08000b64 r .L__unnamed_7
08000b98 r .Lanon.e2aed2c090f157bcf6cb57637cbc44d6.151
08000bc4 r .Lanon.e2aed2c090f157bcf6cb57637cbc44d6.161
08000bcc r .Lanon.e2aed2c090f157bcf6cb57637cbc44d6.254
08000bfc r .Lanon.e2aed2c090f157bcf6cb57637cbc44d6.256
080007cc T ADC1_2
080007cc T ADC3
080007cc T ADC4
080007cc T BusFault
080007cc T CAN_RX1
080007cc T CAN_SCE
080007cc T COMP1_2_3
080007cc T COMP4_5_6
080007cc T COMP7
080007cc T DMA1_CH1
080007cc T DMA1_CH2
080007cc T DMA1_CH3
080007cc T DMA1_CH4
080007cc T DMA1_CH5
080007cc T DMA1_CH6
080007cc T DMA1_CH7
080007cc T DMA2_CH1
080007cc T DMA2_CH2
080007cc T DMA2_CH3
080007cc T DMA2_CH4
080007cc T DMA2_CH5
080007cc T DebugMonitor
080007cc T DefaultHandler
080007cc T DefaultHandler_
080007d0 T DefaultPreInit
080007cc T EXTI0
080007cc T EXTI1
080007cc T EXTI15_10
080007cc T EXTI2_TS
080007cc T EXTI3
080007cc T EXTI4
080007cc T EXTI9_5
080007cc T FLASH
080007cc T FMC
080007cc T FPU
080008dc T HardFault
080008c4 t HardFaultTrampoline
080008dc T HardFault_
080007cc T I2C1_ER
080007cc T I2C1_EV
080007cc T I2C2_ER
080007cc T I2C2_EV
080007cc T MemoryManagement
080007cc T NonMaskableInt
080007cc T PVD
080007cc T PendSV
080007cc T RCC
080007cc T RTC_ALARM
080007cc T RTC_WKUP
08000194 T Reset
080007cc T SPI1
080007cc T SPI2
080007cc T SPI3
080007cc T SVCall
080007cc T SysTick
080007cc T TAMPER_STAMP
080007cc T TIM1_BRK_TIM15
080007cc T TIM1_CC
080007cc T TIM1_TRG_COM_TIM17
080007cc T TIM1_UP_TIM16
080007cc T TIM2
080007cc T TIM3
080007cc T TIM4
080007cc T TIM6_DAC
080007cc T TIM7
080007cc T TIM8_BRK
080007cc T TIM8_CC
080007cc T TIM8_TRG_COM
080007cc T TIM8_UP
080007cc T UART4
080007cc T UART5
080007cc T USART1
080007cc T USART2
080007cc T USART3
080007cc T USB_HP
080007cc T USB_HP_CAN_TX
080007cc T USB_LP
080007cc T USB_LP_CAN_RX0
080007cc T USB_WAKE_UP
080007cc T USB_WAKE_UP_RMP
080007cc T UsageFault
080007cc T WWDG
08000208 t _ZN107_$LT$core..ops..range..RangeInclusive$LT$T$GT$$u20$as$u20$core..iter..range..RangeInclusiveIteratorImpl$GT$9spec_next17h87deef3b9747cad0E
080007d2 t _ZN36_$LT$T$u20$as$u20$core..any..Any$GT$7type_id17h5d03e3bed1ad177dE
08000338 t _ZN47_$LT$i32$u20$as$u20$core..iter..range..Step$GT$17forward_unchecked17hdaef956feeb2130eE
080003a0 t _ZN49_$LT$usize$u20$as$u20$core..iter..range..Step$GT$17forward_unchecked17he64fb95f65bb8a50E
080001d0 t _ZN4core3num23_$LT$impl$u20$usize$GT$13unchecked_add18precondition_check17haaa55c5cfad6df5aE
08000288 t _ZN4core3ops5range25RangeInclusive$LT$Idx$GT$3new17h9341ce72098abf30E
080003c4 t _ZN4core3ptr13read_volatile18precondition_check17hc327a3f3f93c4fe9E
0800046c t _ZN4core3ptr14write_volatile18precondition_check17h0bf2ef4cb8398d75E
08000326 t _ZN4core4hint21unreachable_unchecked18precondition_check17h87cb21eabc78d79aE
080002a0 t _ZN4core4iter5range101_$LT$impl$u20$core..iter..traits..iterator..Iterator$u20$for$u20$core..ops..range..Range$LT$A$GT$$GT$4next17h046781330206c1c0E
080002b0 t _ZN4core4iter5range110_$LT$impl$u20$core..iter..traits..iterator..Iterator$u20$for$u20$core..ops..range..RangeInclusive$LT$A$GT$$GT$4next17h8f584c6881865542E
08000878 T _ZN4core9panicking11panic_const24panic_const_mul_overflow17h0da61a2da85a50adE
0800089e T _ZN4core9panicking11panic_const24panic_const_shl_overflow17hac81dbaa9c119e4dE
0800084c T _ZN4core9panicking14panic_nounwind17hf09b9dbf942f5246E
08000816 T _ZN4core9panicking18panic_nounwind_fmt17h578611f5fcb20fc6E
080007f4 T _ZN4core9panicking9panic_fmt17h9c038da28d7534eeE
080006ec t _ZN5basic18__cortex_m_rt_main17h9e18fb86fc230962E
08000668 t _ZN5basic5gpioe10toggle_led17hc54de8062db56a01E
08000516 t _ZN5basic5gpioe9setup_led17h5cb1dcafba754cfeE
080002c0 t _ZN63_$LT$I$u20$as$u20$core..iter..traits..collect..IntoIterator$GT$9into_iter17h917989b75c64c4c8E
080002d2 t _ZN63_$LT$I$u20$as$u20$core..iter..traits..collect..IntoIterator$GT$9into_iter17hd6eae9cd7e150d6cE
080002dc t _ZN89_$LT$core..ops..range..Range$LT$T$GT$$u20$as$u20$core..iter..range..RangeIteratorImpl$GT$9spec_next17h1e526a5cd9475b89E
08000008 R __EXCEPTIONS
08000040 R __INTERRUPTS
08000004 R __RESET_VECTOR
20000000 B __ebss
20000000 R __edata
08000040 R __eexceptions
08000c0c R __erodata
080008e4 T __etext
20000000 B __euninit
080007d0 T __pre_init
08000008 R __reset_vector
20000000 B __sbss
20000000 R __sdata
20000000 B __sheap
08000c0c A __sidata
080008e4 R __srodata
08000194 T __stext
20000000 B __suninit
08000000 R __vector_table
08000c20 R __veneer_base
08000c20 R __veneer_limit
10002000 A _stack_start
08000194 R _stext
080006e4 T main
080006dc t rust_begin_unwind
Okay, we're nearly there.
Handling interrupts.
Okay, we need something to generate an interrupt first. A timer seems like a good idea - after all, we want to blink the LED. This means another thing to set up. As usual, we split this into a separate module. After reading up on TIM2 registers in the reference manual we get this:
Let's add a function to configure the timer:
Whew, we have the device set up to trigger an interrupt when we want it to, now we just need to define a function that will run when the interrupt is triggered.
The cortex-m
crate has a helper attribute to facilitate writing interrupt handlers. As usual, it needs things to be named in a specific fashion. You need to import the attribute and name your enum with all the interrupt names interrupt
:
use interrupt;
use crate Interrupt as interrupt;
Then all we need to do is this:
// the name has to match a value from the Interrupt enum.
As usual, we need to enable the TIM2 module in the RCC and actually call the setup_timer_interrupt(), so the main() function now looks like this:
!
And we're done!