Skip to content
Anatol Ulrich edited this page Apr 19, 2024 · 15 revisions

To begin, you should:

  • Have an MCU that embassy supports. We currently support nrf, stm32f0, stm32f4, stm32g0, stm32h7, stm32l0, stm32l1, stm32l4, stm32wb and stm32wl series MCUs. We aim to support all common stm32 families.
  • Know how to compile, load, and debug a rust program on an embedded target.
  • Have an embedded probe that your IDE supports.
  • Have the nightly rust compiler with the applicable eabi target, for example thumbv7em-none-eabi.

TODO: add links to tutorials when applicable in the list above

If you're not familiar with embedded development, consult the rust embedded development book.

Because embassy uses some unstable features to support async, embassy requires the rust nightly compiler. Even though embassy currently requires the nightly compiler, you can be confident that software written with embassy will continue to be supported into the future as the required compiler features become stabilized.

Start with an example

Get and configure the example program

First, let's start with an example from the embassy project. To download it:

git clone --recurse-submodules https://github.com/embassy-rs/embassy.git

You need git to run this command

Then go into the embassy/examples folder and check the folder matching the MCU you want to use. For instance, if your chip is an STM32F407VGT6, choose the stm32f4/ folder.

cd embassy/examples/stm32f4/

The folder has the following structure:

.
├── .cargo
│   └── config.toml
├── Cargo.toml
└── src
    ├── bin
    │   ├── blinky.rs
    │   ├── button_exti.rs
    │   ├── button.rs
    │   ├── can.rs
    │   ├── hello.rs
    │   ├── spi_dma.rs
    │   ├── spi.rs
    │   ├── usart_dma.rs
    │   └── usart.rs
    └── example_common.rs

3 directories, 12 files

As in every Rust/Cargo project, the file Cargo.toml contains the library dependencies for the project. Notice the hidden .cargo/config.toml file. It contains some additional configuration used to build the binary, upload it into the chip and debug it. The code is, as usual, into the src/ directory. There are several examples, each of them into a file in the bin/ directory. Before diving into the code, let's setup the project for your specific chip.

First, to take into account its specific memory layout, edit the following line in Cargo.toml, in the [dependencies] block:

embassy-stm32 = { version = "0.1.0", path = "../../embassy-stm32", features = ["defmt", "defmt-trace", "stm32f429zi", "unstable-pac", "memory-x", "time-driver-tim2"]  }

As you may have noticed, this line contains a chip name: stm32f429zi. Change it to match your chip (eg: stm32f407vg for STM32F407VGT6). This is used by "memory-x" feature to generate a memory.x file describing the memory of the chip, so that the linker knows where to put each part of your program. Advanced users: you can also supply a custom memory.x file by putting it in your project root (where Cargo.toml sits) - in this case remove the memory-x feature.

Another component needs to know about the chip: probe-run, which interacts with a debugger to upload the program into the chip and get debug informations. Open .cargo/config.toml and edit the following line to match your chip (eg: STM32F407VGTx for STM32F407VGT6).

runner = "probe-run --chip STM32F429ZITx"

Now let's check that the blinky.rs program runs on the chip:

cargo run --bin blinky

This may download things, then it compiles the code and finally:

    Finished dev [unoptimized + debuginfo] target(s) in 1m 56s
     Running `probe-run --chip STM32F407VGTx target/thumbv7em-none-eabi/debug/blinky`
(HOST) INFO  flashing program (71.36 KiB)
(HOST) INFO  success!
────────────────────────────────────────────────────────────────────────────────
0 INFO  Hello World!
└─ blinky::__embassy_main::task::{generator#0} @ src/bin/blinky.rs:18
1 INFO  high
└─ blinky::__embassy_main::task::{generator#0} @ src/bin/blinky.rs:23
2 INFO  low
└─ blinky::__embassy_main::task::{generator#0} @ src/bin/blinky.rs:27
3 INFO  high
└─ blinky::__embassy_main::task::{generator#0} @ src/bin/blinky.rs:23
4 INFO  low
└─ blinky::__embassy_main::task::{generator#0} @ src/bin/blinky.rs:27

It works! Press ctrl-c to stop the program and the debugger. Now let's switch to the funniest part: let's take a look at the code!

The running code

Open src/bin/blinky.rs.

#![no_std]
#![no_main]

To put it in a nutshell, this tells the compiler this program has no access to std and that there is no main function (because it is not run by an OS). Then some other macros are used to tell the compiler to use some of its unstable features.

Then it imports some stuff used in this program. Notice that it uses example_common, which is not a library: it is another example file. Actually for this specific example program the example_common module can be replaced by:

use defmt::{info, unwrap}; // macros to log messages
use defmt_rtt as _;        // indicates that the log target is the debugger
use panic_probe as _;      // defines behavior on panic

There is also a macro call, defmt::timestamp!, which prepends some text to all the messages that will be printed via the debugger.

Then, there is the main function, which is the function run when the chip starts receiving power or on reset:

#[embassy::main]
async fn main(_spawner: Spawner, p: Peripherals) {

It is declared as such using the embassy::main macro. It is conventional to name this function main but actually you can give it the name you want. This function receives a Spawner, which can be used to spawn tasks, and a Peripherals structure to use the peripherals of the chip.

This function is async, to enable the use of asynchronous things inside it.

This main function first prints Hello World! through the debugger.

info!("Hello World!");

Then it defines an output on pin PB7. The initial logical level is set to High, and the speed of this peripheral is not critical here so its speed is set to Low.

let mut led = Output::new(p.PB7, Level::High, Speed::Low);

This variable has to be mutable to be able to change the pin state later, to make the led blink.

Then, there is an infinite loop.

loop {
    info!("high");
    unwrap!(led.set_high());
    Timer::after(Duration::from_millis(300)).await;

    info!("low");
    unwrap!(led.set_low());
    Timer::after(Duration::from_millis(300)).await;
}

On each iteration, it prints some text, updates the LED state and waits for 300 ms. .await indicates that other things can be done during this delay. But... Wait... For now, there is nothing else to be done!

Add asynchronism

Let's asynchronously:

  • print through the debugger
  • blink the LED

First, write the two functions:

async fn blink_led(mut led: Output<'_, embassy_stm32::peripherals::PB7>) {
    const DURATION: Duration = Duration::from_millis(300);

    loop {
        unwrap!(led.set_high());
        Timer::after(DURATION).await;
        unwrap!(led.set_low());
        Timer::after(DURATION).await;
    }
}

async fn blink_info() {
    const DURATION: Duration = Duration::from_millis(1000);

    loop {
        info!("high");
        Timer::after(DURATION).await;
        info!("low");
        Timer::after(DURATION).await;
    }
}

The durations are not the same to be able to notice asynchronism: the LED will blink faster than the prints.

Now let's use these functions in main:

#[embassy::main]
async fn main(_spawner: Spawner, p: Peripherals) {
    let led = Output::new(p.PB7, Level::High, Speed::Low);
    futures::join!(blink_led(led), blink_info());
}

You can run this program and notice asynchronism.

If you have already used async Rust, you already know the join! macro. It runs all the futures (~ async function calls) asynchronously until all of them end. In our case, none of them end so there is another way to write this code: we can spawn the tasks. This is a way to start doing something and do not wait for it to end.

First, add #[embassy::task] before both the two tasks:

#[embassy::task]
async fn blink_led(mut led: Output<'static, embassy_stm32::peripherals::PB7>) {

The caller does not wait for the task to end so the borrowed data it receives must have 'static lifetime

#[embassy::task]
async fn blink_info() {

Now, let's update main to spawn these tasks:

#[embassy::main]
async fn main(spawner: Spawner, p: Peripherals) {
    let led = Output::new(p.PB7, Level::High, Speed::Low);
    spawner.spawn(blink_led(led)).unwrap();
    spawner.spawn(blink_info()).unwrap();
}

That's it! embassy makes embedded asynchronous Rust as simple as that. Now you can create your own project.

Create a project using embassy

TODO: Write section based on https://embassy.dev/book/dev/new_project.html

Hello, World

This is the old version of this tutorial

Embassy has two options for creating a project: the basic and advanced API. The basic API assumes that every task runs at the same priority and that delays will be needed. The advanced API allows multiple-priority tasks and usage with other frameworks such as RTIC, but is more complex to set-up. Here, we will begin with the basic API.

Though embassy does not require a particular HAL, the stm32-rs HAL is used to configure clocks if the basic API is used. Other HALs may be used with other peripherals, provided that they also use the standard svd2rust PAC.

The main Function

To begin, let's create our main function:

#[embassy::main(use_hse = 48)]
async fn main(spawner: Spawner) {
    let (dp, clocks) = embassy_stm32::Peripherals::take().unwrap();

    spawner.spawn(run1()).unwrap();
}

First, we the write main function and add an embassy::main attribute. The embassy::main attribute sets-up a simple clock and executor configuration using the specified arguments. In this case we use a high-speed external oscillator running at 48 Mhz. Then, we take our device peripherals with embassy_stm32::Peripherals::take(). This replaces the standard pac::Peripherals::take() call, because embassy takes some of the peripherals during set-up. If the basic set-up is used, pac::Peripherals::take() will return None, though cortex_m::Peripherals::take() can still be used as normal. Finally, we use the spawner to spawn a task.

Declaring a task

Every task is declared with the task macro, as such. Every task is a standard async function defined with the task attribute. Only functions defined with the task attribute can be spawned with the spawner. Tasks can accept any number of arguments, but they cannot be defined with generic parameters.

#[task]
async fn run1() {
    loop {
        info!("tick");
        Timer::after(Duration::from_ticks(13000 as u64)).await;
    }
}