Boosting embedded real-time productivity with Imperative Synchronous Programming

Framework Labs
6 min readJan 1, 2024

--

Embedded real-time systems are hard to build and even harder to maintain and evolve.

This is due to the fact that multiple inputs, computations and outputs have to be handled concurrently but still coordinated to each other. Also, the processing often depends on multiple states or modes which can change over time. On top of that, embedded systems have to respond in a timely and optimally deterministic manner further complicating their development.

For a simple example, let’s consider the following embedded real-time system:

We want a night-light consisting of an LED-light which begins to shine when a photoresistor detects low ambient light. We also want to be able to adjust the brightness of the LED with a dial (potentiometer). Turning the dial should adjust the brightness of the light — even in daylight conditions. The whole system should be able to be switched on or off by pressing a button. A small indicator LED will present the system state by blinkling slowly during daylight or shining continously at night when the system is switched on.

Hardware setup of the example system

Even though this is a very simple embedded real-time system, multiple things have to be coordinated like the dial, light sensor and main button on the input side and the main light and indicator LED at the output side.

Introduction to Imperative Synchronous Programming

Activity

A main concept in Imperative Synchronous Programming is that of an activity — like an object in OOP or a function in Functional Programming. An activity is a procedure, which maintains its state — like local variables and the programm counter — between subsequent invocations on the same instance. It thus resembles a coroutine but with the additional feature to receive new arguments on each invocation.

activity ChangeDetector (value: int32) (didChange: bool)
didChange = true
var prevValue = value
await true

repeat
didChange = prevValue != value
prevValue = value
await true
end
end

The syntax shown is that of the Imperative Synchronous Programming language Blech.

This activity called ChangeDetectorhas valueas an input and didChangeas an in-out parameter.

In the body, the activity first sets the didChangeparameter to trueindicating that the first value in the stream of values should already be regarded as a change. It then stores the current value in the prevValuelocal variable.

Await

When the program counter (PC) of the activity reaches the awaitposition, the activity will yield processing for this round. When it is reactivated in the next round, fresh arguments are passed for the parameters of the activity and the program evaluates the argument to await. If it evaluates to true, processing proceeds until the next awaitposition.

In the above activity, an endless repeatloop is entered which calculates the didChangevalue at each round by comparing it to the datum stored in prevValueand then caches the current valuein preValuefor the next round, which is awaited in the final await truestatement.

Rounds are called macro steps and the steps in between are micro steps.

Macro steps can be triggered in different ways, but for real-time systems a time based trigger at a constant frequency is common. Steps are then often called ticks. The micro steps are assumed to take no time (synchronous hypothesis) and thus all output happens at the same time (i.e. synchronous) with the input. This is not true in reality of course but this assumption allows for a simple logic when it comes to concurrency beetween and preemption of activities.

Concurrency

As concurrent activities all run at the same time points and have no extent, there is no indeterminism due to one task processing before or after another. The same temporal determinism allows to preempt (cancel) activities unambiguously.

The synchronous model of computation together with language elements to represent concurrency and preemption directly in the structure of a program (structual concurrency) greatly simplifies reasoning of complex embedded real-time programs.

Let’s compose our ChangeDetectoractivity with two other activities — one to read a button and another one to drive an LED:

activity Light (ambientBrightness: nat32, isDay: bool)
var didChange: bool
var isChanging: bool

cobegin
run ChangeDetector(ambientBrightness)(didChange)
with
run EventExtender(didChange, 20)(isChanging)
with
run LightBrightnessCalculator(ambientBrightness, isDay, isChanging)(lightBrightness)
with
run LightDriver(lightBrightness)
end
end

The cobeginkeyword introduces a group of concurrent synchronous threads (called trails) separated by the withkeyword. Each trail runs a different activity with the help of the runkeyword. Local variables declared at the beginning of the Mainactivity are passed as arguments to the in and in-out parameter lists of the concurrent activities. This setup resembles a component model where sub-components are composed hierarchically and wired internally and externally via ports:

Component view of the Light activity

On each macro step, all trails run their micro steps according to a static schedule created by the compiler from the data dependencies between the trails. This allows for a level of deterministic execution of the overall programm which is not possible with dynamic OS schedulers.

The synchronous execution semantics of concurrent trails represent universal temporal join points which allow to cleanly separate different aspects of a computation into their own activities as proposed by Aspect Oriented Programming (AOP).

Preemption

Preemption allows activities to be stopped from the outside to precisely change the control flow on defined conditions. Strong preemption will prevent an activity to run at the current tick. In Blech, the when abortconstruct is used which aborts the body when the condition becomes true:

activity LightController (isEnabled: bool, ambientBrightness: nat32, isDay: bool)
repeat
if not isEnabled then
await isEnabled
end
when not isEnabled abort
run Light(ambientBrightness, isDay)
end
end
end

Weak preemption on the other hand stops an activity after the current tick and is expessed in Blech like this:

activity LightController (isEnabled: bool, ambientBrightness: nat32, isDay: bool)
repeat
if not isEnabled then
await isEnabled
end
cobegin weak
run Light(ambientBrightness, isDay)
with
await not isEnabled
end
end
end

The first trail is marked weakindicating that it can be preempted if all other (non-weak) trails have finished in this step.

Preemption simplifies programming by separating the orchestration of activities from the logic implemented in the called activities, which are then feed from checking preconditions repeatedly near the leaves of the call-chain.

Example

With this initial understanding of Imperative Synchronous Programming, let’s return to the embedded real-time system example sketched at the beginning.

The code and online simulation of the system can be found in this WokWi project. It does not use Blech but the proto_activities library instead which allows to implement imperative synchronous programs in C in a style simmilar to Blech with the help of Protothreads.

The logic of the program becomes apparent, when you follow the structure of the activities from the entry-point activity Main at the bottom of the file “sketch.ino”:

pa_activity (Main, pa_ctx(pa_use(ButtonRecognizer); pa_use(ModeController); 
pa_use(ModeIndicator); pa_co_res(3); bool isPressed; Mode mode)) {
pa_co(3) {
pa_with (ButtonRecognizer, pa_self.isPressed);
pa_with (ModeController, pa_self.isPressed, pa_self.mode);
pa_with (ModeIndicator, pa_self.mode);
} pa_co_end;
} pa_end;

The syntax of proto_activities tries to mimic that of Blech, but more manual steps are necessary.

In a context (pa_ctx) all embedded activities (via pa_use) as well as variables with a macro-step life-time have to be declared. Also, storage for concurrent trails have to be reserved with pa_co_res.

At the top-level, a recognizer for button presses is set up and its output is fed to the mode controller. Its output is connected to the mode indicator activity.

Zooming in on the ModeController, we see a simple state machine at work which toggles between the ON-state realized by theOnModeController activity and the OFF-state by OffModeController. Both sub-activities are strongly preempted whenever isPressedbecomes true from pressing the main-button of the system detected by the concurrent ButtonRecognizer:

pa_activity (ModeController, pa_ctx(pa_use(OnModeController); pa_use(OffModeController)), 
bool isPressed, Mode& mode) {
pa_repeat {
pa_when_abort (isPressed, OnModeController, mode);

mode = Mode::OFF;
pa_when_abort (isPressed, OffModeController);
}
} pa_end;

As activities themselves are able to keep state between macro steps, no state machines are needed to represent and manage states or modes. State machines are notorious for being hard to maintain and extend whereas activities offer a modular alternative to them allowing for fearless state handling instead.

As a final look at the example, OnModeController concurrently reads the photoresistor to detect day-and-night and the potentiometer for the intended LED brightness and passes both values to the Lightactivity discussed above. Also, isDayis mapped to type Modeto allow the concurrent ModeIndicatorto separate between STAND-BY and ON mode:

pa_activity (OnModeController, pa_ctx(pa_co_res(4); pa_use(Light); pa_use(BrightnessDial); 
pa_use(PhotonSensor); pa_use(ModeMapper);
int brightness; bool isDay),
Mode& mode) {
pa_co(4) {
pa_with (PhotonSensor, pa_self.isDay);
pa_with (BrightnessDial, pa_self.brightness);
pa_with (Light, pa_self.brightness, pa_self.isDay);
pa_with (ModeMapper, pa_self.isDay, mode);
} pa_co_end;
} pa_end;

Summary

Together with the simple semantics of the synchronous model of computation (effectively SC MoC), the approachable imperative style and the language-provided component model, Imperative Synchronous Programming allows to create correct-by-construction embedded real-time systems at a higher level of productivity.

--

--