A Real-Time Interrupt-driven Concurrency (RTIC) example demonstrating interrupt-driven LED blinking and OLED display updates on the STM32F446RE Nucleo board.
This project introduces RTIC fundamentals through a practical example:
- Timer-driven interrupts using TIM2
- Shared I2C bus for OLED display using
shared-buscrate - Type-safe concurrency with RTIC's resource management
- Embedded graphics rendering with
embedded-graphics
- Board: STM32 Nucleo-F446RE
- MCU: STM32F446RET6 (Cortex-M4F, 180 MHz, 512KB Flash, 128KB RAM)
- Display: SSD1306 OLED (128x32 or 128x64) via I2C
- LED: LD2 (Green) on PA5 (active-low)
| Component | Pin | Description |
|---|---|---|
| LED (LD2) | PA5 | On-board green LED (active-low) |
| I2C SCL | PB8 | OLED clock line |
| I2C SDA | PB9 | OLED data line |
| I2C VCC | 3.3V | OLED power |
| I2C GND | GND | OLED ground |
Note: OLED I2C address is typically 0x3C. Verify with an I2C scanner if you have connection issues.
-
Tasks and Interrupts
- Hardware interrupt handler (
TIM2) bound to RTIC task - Efficient event-driven execution (no polling loops)
- Hardware interrupt handler (
-
Resource Management
- Local resources: Exclusive access within a single task
- Shared resources: Safe concurrent access between tasks
- Compile-time deadlock prevention
-
Static Scheduling
- Zero-cost abstractions
- Predictable timing and latency
- No dynamic memory allocation
-
Idle Loop
- Low-power sleep mode using
wfi(Wait For Interrupt) - CPU only wakes for interrupts
- Low-power sleep mode using
- Blinks the on-board LED at 2 Hz (500ms period)
- Updates OLED display with tick counter on each interrupt
- Prints debug messages via defmt/RTT
- Runs entirely interrupt-driven (no busy loops)
# Install Rust and embedded tools
rustup target add thumbv7em-none-eabihf
cargo install probe-rs --features clicargo build --releasecargo run --releaseOr using probe-rs directly:
probe-rs run --chip STM32F446RETx target/thumbv7em-none-eabihf/release/rtic_oled_bringupThe project uses defmt for efficient logging over RTT (Real-Time Transfer):
# Terminal 1: Start RTT server
probe-rs run --chip STM32F446RETx target/thumbv7em-none-eabihf/release/rtic_oled_bringup
# You'll see output like:
# Starting init...
# System initialized at 48 MHz
# LED should be ON now (active-low)
# Init complete, entering main loop
# Timer interrupt #1
# Timer interrupt #2
# ...src/main.rs
├── Type Aliases (I2C bus, display interface)
├── #[app] Module
│ ├── Shared Resources (currently empty)
│ ├── Local Resources (led, timer, display, etc.)
│ ├── #[init] - Hardware initialization
│ ├── #[task] tim2_handler - Timer interrupt handler
│ └── #[idle] - Low-power idle loop
- Clock Setup: 8 MHz HSE → 48 MHz SYSCLK
- GPIO: Configure PA5 (LED), PB8/PB9 (I2C)
- I2C: Initialize at 400 kHz (Fast Mode)
- Shared Bus: Create thread-safe I2C bus manager
- OLED: Initialize SSD1306 display
- Timer: Configure TIM2 for 2 Hz interrupts
Timer Interrupt (TIM2) every 500ms
↓
tim2_handler() executes
↓
├─ Clear interrupt flag
├─ Toggle LED (PA5)
├─ Increment counter
├─ Clear OLED display
├─ Render new text with counter
└─ Flush to display
↓
Return to idle (wfi)
| Crate | Version | Purpose |
|---|---|---|
rtic |
2.1 | Real-Time Interrupt-driven Concurrency framework |
stm32f4xx-hal |
0.21 | Hardware Abstraction Layer for STM32F4 |
ssd1306 |
0.8 | OLED display driver |
embedded-graphics |
0.8 | 2D graphics library |
shared-bus |
0.3 | Thread-safe peripheral sharing |
heapless |
0.8 | Static data structures (no heap) |
defmt |
0.3 | Efficient logging framework |
- Flash: ~8-10 KB (of 512 KB available)
- RAM: ~2-3 KB (includes OLED framebuffer)
- Stack: Configured in
memory.x(typically 8-16 KB)
In init():
timer.start(2_u32.Hz()).unwrap(); // 2 Hz = 500ms periodOptions:
1_u32.Hz()- 1 second period10_u32.Hz()- 100ms period1000_u32.Hz()- 1ms period (1 kHz)
If you have a 128x64 display:
// In Local struct:
display: Ssd1306<I2CInterface<I2cProxy>, DisplaySize128x64, BufferedGraphicsMode<DisplaySize128x64>>,
// In init():
let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)In init():
let clocks = rcc.cfgr
.use_hse(8.MHz())
.sysclk(84.MHz()) // Up to 180 MHz supported
.freeze();Note: Higher frequencies increase power consumption but provide more processing headroom.
-
Check I2C address: Default is
0x3C, some displays use0x3Dlet interface = I2CInterface::new(bus.acquire_i2c(), 0x3D, 0x40); // Try 0x3D
-
Check display size: Ensure
DisplaySize128x32matches your hardware -
Check wiring: Verify VCC, GND, SCL, SDA connections
-
Check pull-ups: I2C requires pull-up resistors (many OLED modules have them built-in)
- Verify pin: Nucleo-F446RE uses PA5 (not PD12 like Discovery boards)
- Check active-low: LED turns on with
set_low(), off withset_high() - Verify clock: Ensure HSE is working (check debug output)
See NOTES.md for common issues and solutions.
-
Button Input (PC13 - Blue button):
// Add to Local resources button: Pin<'C', 13, Input>, // In init: let gpioc = dp.GPIOC.split(); let button = gpioc.pc13.into_pull_up_input(); // In task: if cx.local.button.is_low() { // Button pressed }
-
Multiple Timers:
#[task(binds = TIM3, local = [timer3])] fn tim3_handler(cx: tim3_handler::Context) { // Different timing task }
-
LoRa Module Integration (Coming next!):
- SPI communication
- Interrupt-driven packet reception
- Shared SPI bus management
- RTIC Book - Official documentation
- STM32F4 HAL Docs - Hardware abstraction layer
- Embedded Graphics Book - Graphics library
- The Embedded Rust Book - General embedded Rust
This is example/tutorial code for learning purposes.
- Antony Mapfumo https://www.mapfumo.net
Created as part of a 4-month embedded systems learning plan.

