Software tasks

In addition to hardware tasks, which are invoked by the hardware in response to hardware events, RTFM also supports software tasks which can be spawned by the application from any execution context.

Software tasks can also be assigned priorities and, under the hood, are dispatched from interrupt handlers. RTFM requires that free interrupts are declared in an extern block when using software tasks; some of these free interrupts will be used to dispatch the software tasks. An advantage of software tasks over hardware tasks is that many tasks can be mapped to a single interrupt handler.

Software tasks are also declared using the task attribute but the binds argument must be omitted. To be able to spawn a software task from a context the name of the task must appear in the spawn argument of the context attribute (init, idle, task, etc.).

The example below showcases three software tasks that run at 2 different priorities. The three software tasks are mapped to 2 interrupts handlers.


# #![allow(unused_variables)]
#fn main() {
//! examples/task.rs

#![deny(unsafe_code)]
#![deny(warnings)]
#![no_main]
#![no_std]

use cortex_m_semihosting::{debug, hprintln};
use panic_semihosting as _;

#[rtfm::app(device = lm3s6965)]
const APP: () = {
    #[init(spawn = [foo])]
    fn init(c: init::Context) {
        c.spawn.foo().unwrap();
    }

    #[task(spawn = [bar, baz])]
    fn foo(c: foo::Context) {
        hprintln!("foo - start").unwrap();

        // spawns `bar` onto the task scheduler
        // `foo` and `bar` have the same priority so `bar` will not run until
        // after `foo` terminates
        c.spawn.bar().unwrap();

        hprintln!("foo - middle").unwrap();

        // spawns `baz` onto the task scheduler
        // `baz` has higher priority than `foo` so it immediately preempts `foo`
        c.spawn.baz().unwrap();

        hprintln!("foo - end").unwrap();
    }

    #[task]
    fn bar(_: bar::Context) {
        hprintln!("bar").unwrap();

        debug::exit(debug::EXIT_SUCCESS);
    }

    #[task(priority = 2)]
    fn baz(_: baz::Context) {
        hprintln!("baz").unwrap();
    }

    // Interrupt handlers used to dispatch software tasks
    extern "C" {
        fn UART0();
        fn UART1();
    }
};

#}
$ cargo run --example task
foo - start
foo - middle
baz
foo - end
bar

Message passing

The other advantage of software tasks is that messages can be passed to these tasks when spawning them. The type of the message payload must be specified in the signature of the task handler.

The example below showcases three tasks, two of them expect a message.


# #![allow(unused_variables)]
#fn main() {
//! examples/message.rs

#![deny(unsafe_code)]
#![deny(warnings)]
#![no_main]
#![no_std]

use cortex_m_semihosting::{debug, hprintln};
use panic_semihosting as _;

#[rtfm::app(device = lm3s6965)]
const APP: () = {
    #[init(spawn = [foo])]
    fn init(c: init::Context) {
        c.spawn.foo(/* no message */).unwrap();
    }

    #[task(spawn = [bar])]
    fn foo(c: foo::Context) {
        static mut COUNT: u32 = 0;

        hprintln!("foo").unwrap();

        c.spawn.bar(*COUNT).unwrap();
        *COUNT += 1;
    }

    #[task(spawn = [baz])]
    fn bar(c: bar::Context, x: u32) {
        hprintln!("bar({})", x).unwrap();

        c.spawn.baz(x + 1, x + 2).unwrap();
    }

    #[task(spawn = [foo])]
    fn baz(c: baz::Context, x: u32, y: u32) {
        hprintln!("baz({}, {})", x, y).unwrap();

        if x + y > 4 {
            debug::exit(debug::EXIT_SUCCESS);
        }

        c.spawn.foo().unwrap();
    }

    extern "C" {
        fn UART0();
    }
};

#}
$ cargo run --example message
foo
bar(0)
baz(1, 2)
foo
bar(1)
baz(2, 3)

Capacity

RTFM does not perform any form of heap-based memory allocation. The memory required to store messages is statically reserved. By default the framework minimizes the memory footprint of the application so each task has a message "capacity" of 1: meaning that at most one message can be posted to the task before it gets a chance to run. This default can be overridden for each task using the capacity argument. This argument takes a positive integer that indicates how many messages the task message buffer can hold.

The example below sets the capacity of the software task foo to 4. If the capacity is not specified then the second spawn.foo call in UART0 would fail (panic).


# #![allow(unused_variables)]
#fn main() {
//! examples/capacity.rs

#![deny(unsafe_code)]
#![deny(warnings)]
#![no_main]
#![no_std]

use cortex_m_semihosting::{debug, hprintln};
use lm3s6965::Interrupt;
use panic_semihosting as _;

#[rtfm::app(device = lm3s6965)]
const APP: () = {
    #[init]
    fn init(_: init::Context) {
        rtfm::pend(Interrupt::UART0);
    }

    #[task(binds = UART0, spawn = [foo, bar])]
    fn uart0(c: uart0::Context) {
        c.spawn.foo(0).unwrap();
        c.spawn.foo(1).unwrap();
        c.spawn.foo(2).unwrap();
        c.spawn.foo(3).unwrap();

        c.spawn.bar().unwrap();
    }

    #[task(capacity = 4)]
    fn foo(_: foo::Context, x: u32) {
        hprintln!("foo({})", x).unwrap();
    }

    #[task]
    fn bar(_: bar::Context) {
        hprintln!("bar").unwrap();

        debug::exit(debug::EXIT_SUCCESS);
    }

    // Interrupt handlers used to dispatch software tasks
    extern "C" {
        fn UART1();
    }
};

#}
$ cargo run --example capacity
foo(0)
foo(1)
foo(2)
foo(3)
bar

Error handling

The spawn API returns the Err variant when there's no space to send the message. In most scenarios spawning errors are handled in one of two ways:

  • Panicking, using unwrap, expect, etc. This approach is used to catch the programmer error (i.e. bug) of selecting a capacity that was too small. When this panic is encountered during testing choosing a bigger capacity and recompiling the program may fix the issue but sometimes it's necessary to dig deeper and perform a timing analysis of the application to check if the platform can deal with peak payload or if the processor needs to be replaced with a faster one.

  • Ignoring the result. In soft real time and non real time applications it may be OK to occasionally lose data or fail to respond to some events during event bursts. In those scenarios silently letting a spawn call fail may be acceptable.

It should be noted that retrying a spawn call is usually the wrong approach as this operation will likely never succeed in practice. Because there are only context switches towards higher priority tasks retrying the spawn call of a lower priority task will never let the scheduler dispatch said task meaning that its message buffer will never be emptied. This situation is depicted in the following snippet:


# #![allow(unused_variables)]
#fn main() {
#[rtfm::app(..)]
const APP: () = {
    #[init(spawn = [foo, bar])]
    fn init(cx: init::Context) {
        cx.spawn.foo().unwrap();
        cx.spawn.bar().unwrap();
    }

    #[task(priority = 2, spawn = [bar])]
    fn foo(cx: foo::Context) {
        // ..

        // the program will get stuck here
        while cx.spawn.bar(payload).is_err() {
            // retry the spawn call if it failed
        }
    }

    #[task(priority = 1)]
    fn bar(cx: bar::Context, payload: i32) {
        // ..
    }
};
#}