Zero to Photon:
Scheduler.h: threads for MSP430 and ARM32
2024 . 3 . 19
At the heart of Photon is a MSP430, which has many responsibilities: power management, image sensor comms, flash storage comms, STM32 i2c comms, time tracking, time/motion/button event handling, and LED control.

The early software for Photon's MSP430 was implemented without formal threads. As the features grew, the code became a mess of asynchronous logic and global state. A better solution was needed, and Scheduler.h allowed the codebase to be organized as discrete threads with synchronous logic and local state, greatly improving its readability and writability.


Scheduler.h overview

Scheduler.h is a C++ threads implementation for embedded devices, implemented as a single header. At the time of writing, it's ~650 lines, and supports MSP430 and ARM32.

Single core concurrency

Scheduler.h is intended for single-core embedded chips; it offers concurrency, not parallelism.

Cooperative scheduling

Scheduler.h is a cooperative (non-preemptive) scheduler.

The cooperative nature can be both a blessing and a curse: knowing that other threads cannot run until the current thread yields makes reasoning about concurrency much easier and often lends itself to simplified logic. In a sense, the current thread holds a global mutex that prevents other threads from interrupting it until it decides relinquish the mutex (by sleeping or yielding).

On the other hand, if there's a bug that prevents a thread from yielding, other threads will not run. In practice this doesn't tend to be an issue because embedded systems tend to have limited complexity due to the limited codespace, unlike generic systems that run arbitrary code. (The beloved Mac OS 9 comes to mind – delightful, but not the most stable!)

Round-robin execution

Scheduler.h threads don't have priorities — they are organized as a compile-time linked-list and executed as such.

This means that there's a user-defined deterministic order that the threads are executed. Sometimes this can be useful in that it allows one thread to assume that another thread was given the oppurtunity to execute before itself.

Auto power down

When no threads have work to do, Scheduler.h calls a user-defined sleep function, which puts the processor to sleep to conserve power.

Small code size

Scheduler.h is intended to be as small as possible to fit within the confines of our wee 16-bit friends.

The two-thread code example given below consumes 886 bytes of codespace when compiled with gcc -Os, which equates to about 6% of the chip's codespace.

Stack overflow detection

Scheduler.h implements simple stack overflow detection by writing canary values at the bottom of the stack. Whenever a thread yields, the stack is inspected, and if the canary values are corrupt, a user-defined function is called with an index representing which thread overflowed.

Stack overflow detection can be disabled to eliminate both the codespace and runtime penalty, which might be chosen for production builds. (Photon leaves stack overflow detection enabled to catch problems in the field.)

Stack overflow detection is especially helpful when trying to determine the minimum size for a thread's stack to avoid wasting RAM.

Custom stack size

Each thread explicitly defines its stack and therefore defines how large it should be. This allows more complex threads to have larger stacks than simpler threads, conserving RAM.

Yielding

The usefulness of a concurrency library like Scheduler.h comes from the expressiveness with which you can pause the current thread until a particular event occurs.

Scheduler.h implements the following ways to yield to the scheduler:

  • Wait(fn)

    Pauses the current thread until fn returns true.

  • Wait(ticks, fn)

    Pauses the current thread until fn returns true, or for ticks to elapse.

    Returns false if the timeout elapsed, and true otherwise.

  • WaitDeadline(deadline, fn)

    Pauses the current thread until fn returns true, or for deadline to arrive.

    Returns false if the deadline arrived, and true otherwise.

  • Sleep(ticks)

    Pauses the current thread for ticks.

  • Delay(ticks)

    Pauses the current thread for ticks, but doesn't allow other threads to run.

    While waiting, the chip is put to sleep by calling the user-supplied T_Sleep template function.

  • Yield()

    Yields to the scheduler to start executing the next thread.

    A thread that continuously calls Yield() does not allow the chip to sleep, because the scheduler must assume that the thread still has work to do.

    The Wait() functions should therefore be preferred to Yield().


Integration and Usage

Scheduler.h is a single header that's intended to be easy to integrate into new or existing projects.

Requirements

Scheduler.h has two main requirements:
  1. Sleep function: provided as a template argument, the sleep function is called when no threads have work to do and should put the chip to sleep.

    For example, a MSP430 sleep function would modify the SR register to enter one of its low-power modes, while a ARM32 sleep function would execute the WFI instruction.

  2. Hardware-based tick timer: must call Scheduler::Tick() from the interrupt context when the timer fires.

    The period of the timer is provided to the scheduler via a template argument (see Usage section, below).

Usage

Using Scheduler.h requires defining your own scheduler type and configuring it via template arguments:
using Scheduler = Toastbox::Scheduler<
    // T_TicksPeriod: time period between ticks
    std::ratio<1, SysTickFreqHz>,
    
    // T_Sleep: function to put processor to sleep
    Sleep,
    
    // T_StackGuardCount: number of stack canaries to use
    // 0 == disables stack overflow detection
    4,
    
    // T_StackOverflow: function to handle stack overflow
    StackOverflow,
    
    // T_StackInterrupt: interrupt stack pointer
    // ARM32 can have a separate stack for interrupt handling.
    // If supplied here, it will be monitored for overflow.
    // Unused if T_StackGuardCount==0.
    StackInterrupt,
    
    // T_Tasks: list of threads
    TaskButton,
    TaskLED
>;
Once the scheduler type is defined, it needs to be invoked:
int main() {
    Scheduler::Run();
    return 0;
}
The scheduler starts by executing the first thread that was supplied as a template argument. (TaskButton in the above example.)

By default, all threads except the first are stopped, and must explicitly be started with Scheduler::Start() for them to execute.


Example

A Scheduler.h example targeting MSP430 is available on GitHub.

This example implements two threads; TaskLED toggles one LED every second:

void TaskLED::Run() {
    for (;;) {
        P1OUT ^= BIT0;
        Scheduler::Sleep(Scheduler::Ms<1000>);
    }
}
while TaskButton waits for a button to be pressed, and then rapidly flashes a second LED 100 times:
void TaskButton::Run() {
    for (;;) {
        Scheduler::Wait([] { return _Pressed; });
        for (int i=0; i<100; i++) {
            P1OUT ^= BIT1;
            Scheduler::Sleep(Scheduler::Ms<20>);
        }
        _Pressed = false;
    }
}

Porting

Scheduler.h can be ported to additional architectures by implementing three functions:

  1. IntState::Get(): returns whether interrupts are currently enabled
  2. IntState::Set(): sets whether interrupts are enabled
  3. __TaskSwap(): pushes callee-saved registers, saves/restores the stack pointer, and pops callee-saved registers

Search Scheduler.h for msp430 to see these three locations that require architecture-specific code.