MEC-460 · Lesson 6 of 12 · 21 min
Embedded programming and real-time systems
With the interfaces of the seal test fixture settled, Gilles and Owen turn to its firmware. Embedded software differs from the programs Owen has written for analysis in two ways. It never ends: the fixture runs from power-on until power-off, reacting to buttons, sensors and messages for years at a time. And it is real-time: a correct answer delivered late is wrong. If the program samples the pressure irregularly, the filter's behaviour changes; if it misses a step pulse, the press stalls; if it reacts to the stop button half a second late, someone's patience runs out, and in a machine with real hazards someone could be hurt. This lesson covers the patterns that make embedded programs predictable: polling loops, interrupts, state machines, debouncing and scheduling.
The super-loop and interrupts
The simplest structure is a super-loop: initialize the hardware, then repeat forever reading inputs, deciding and writing outputs. It is easy to understand and debug, and many small machines run on nothing else. Its weakness is timing: every task waits for every other, so a slow operation such as redrawing the display delays the next pressure sample. Interrupts solve that. A peripheral, such as a timer reaching its count or a UART receiving a byte, can make the processor stop what it is doing, run a short interrupt service routine (ISR) and return. A timer interrupt every 2 ms gives perfectly regular sampling regardless of what the main loop is doing.
Interrupts bring their own rules. An ISR must be short, because while it runs, lower-priority work and possibly other interrupts wait. It must not call functions that block or take unpredictable time. Data shared between an ISR and the main loop must be declared volatile in C, so the compiler re-reads it each time, and accessed atomically, so the main loop never reads a value half-updated by an interrupt. The listing shows the pattern for ST-1's pressure sampling: the ISR reads the ADC result and updates the filter, and the main loop reads the filtered value with interrupts briefly disabled.
/* Timer interrupt every 2 ms: sample the ADC and update the filter. Keep it short. */
volatile float p_filtered; /* shared with the main loop */
void TIM2_IRQHandler(void)
{
TIM2->SR = 0; /* acknowledge the interrupt */
float p = adc_to_bar(ADC1->DR); /* conversion started by the timer */
p_filtered += 0.1f * (p - p_filtered); /* first-order IIR filter, alpha = 0.1 */
}
float pressure_bar(void)
{
__disable_irq(); /* read the shared value without tearing */
float p = p_filtered;
__enable_irq();
return p;
}State machines
Machines work in sequences, and the clearest way to program a sequence is as a finite state machine. The program is always in exactly one state, such as FILL or MEASURE. In each state it checks a small set of conditions, and when one is true it performs an action and moves to another state. The figure shows the ST-1 test as a state machine, and the C listing implements it as a function called once per millisecond. Each state is a few lines, the transitions are explicit, and the program never waits in a loop: if the pressure has not reached 4 bar, the function simply returns and checks again a millisecond later. That non-blocking style lets one processor run the test sequence, the display, the communication and the button scanning together.
/* ST-1 test sequence: one call per 1 ms tick from the main loop */
typedef enum { IDLE, CLAMP, FILL, SETTLE, MEASURE, UNCLAMP, FAULT } state_t;
static state_t state = IDLE;
static uint32_t t_enter; /* time the current state was entered, ms */
static float p_start; /* filtered pressure at start of measurement, bar */
void st1_step(uint32_t now)
{
if (stop_requested() || sensor_fault()) { /* any state: go safe */
valve_fill(OFF); valve_vent(ON); clamp(OFF);
state = FAULT;
}
switch (state) {
case IDLE:
if (start_pressed() && part_present()) { clamp(ON); state = CLAMP; t_enter = now; }
break;
case CLAMP:
if (clamp_closed()) { valve_fill(ON); state = FILL; t_enter = now; }
else if (now - t_enter > 2000) state = FAULT; /* clamp did not close */
break;
case FILL:
if (pressure_bar() >= 4.0f) { valve_fill(OFF); state = SETTLE; t_enter = now; }
else if (now - t_enter > 5000) state = FAULT; /* gross leak or no air */
break;
case SETTLE:
if (now - t_enter >= 2000) { p_start = pressure_bar(); state = MEASURE; t_enter = now; }
break;
case MEASURE:
if (now - t_enter >= 5000) {
float decay_mbar = (p_start - pressure_bar()) * 1000.0f;
report_result(decay_mbar <= 8.0f, decay_mbar);
valve_vent(ON); state = UNCLAMP; t_enter = now;
}
break;
case UNCLAMP:
if (now - t_enter >= 500) { valve_vent(OFF); clamp(OFF); state = IDLE; }
break;
case FAULT:
if (reset_pressed() && !sensor_fault()) { valve_vent(OFF); state = IDLE; }
break;
}
}Two features of the listing matter more than the happy path. Every state that waits for something physical has a timeout: if the clamp does not close in two seconds or the pressure does not reach 4 bar in five, the machine goes to a FAULT state rather than waiting forever. And a check at the top of the function moves to a safe condition, vent open, fill closed and clamp released, from any state if the stop button is pressed or a sensor reports a fault, such as the transducer reading below its 0.5 V live zero. Leaving those out is the most common reason a small machine hangs in the middle of a cycle and needs a power reset. The FAULT state requires a deliberate reset, so a fault cannot clear itself and restart the press unexpectedly.
Debouncing
When a mechanical switch closes, its contacts bounce for a few milliseconds, producing a burst of transitions. A program that counts rising edges will see several presses for one push. The usual cure is to accept a change of state only after the input has held the new value for several consecutive samples. The program below simulates a start button sampled every millisecond that bounces for about four milliseconds when pressed and three when released. The raw signal has two rising edges; the debouncer, which requires five equal samples, reports one press, accepted 26 ms after the start of the record, a few milliseconds after the bouncing stopped. A delay of 5 to 20 ms is imperceptible to an operator and long enough for most switches. The same idea filters spurious transitions from sensors, but with care: a debounce delay on a safety input would slow the safety function, which is another reason safety inputs go to the safety controller.
# Firmware for seal test fixture ST-1: a debounced start button and a test sequence state machine,
# then a check that the periodic tasks can all meet their deadlines
import random
random.seed(3)
# 1. A start button that bounces for about 4 ms, sampled every 1 ms; accept a change after 5 equal samples
signal = [0] * 20 + [random.choice([0, 1]) for _ in range(4)] + [1] * 40 + [random.choice([0, 1]) for _ in range(3)] + [0] * 30
stable, count, presses = 0, 0, 0
for t, s in enumerate(signal):
if s != stable:
count += 1
if count >= 5:
stable, count = s, 0
if stable == 1: presses += 1; print(f" press accepted at t = {t} ms")
else:
count = 0
raw_edges = sum(1 for a, b in zip(signal, signal[1:]) if a == 0 and b == 1)
print(f"Raw rising edges seen: {raw_edges}; debounced presses: {presses}")
# 2. Test sequence: IDLE -> CLAMP -> FILL -> SETTLE -> MEASURE -> PASS or FAIL -> UNCLAMP -> IDLE
def run_test(decay_mbar):
state, t, log = "IDLE", 0.0, []
while True:
log.append(state)
if state == "IDLE": state = "CLAMP"
elif state == "CLAMP": t += 0.6; state = "FILL"
elif state == "FILL": t += 1.5; state = "SETTLE"
elif state == "SETTLE": t += 2.0; state = "MEASURE"
elif state == "MEASURE":
t += 5.0; state = "PASS" if decay_mbar <= 8.0 else "FAIL"
elif state in ("PASS", "FAIL"): result = state; state = "UNCLAMP"
elif state == "UNCLAMP": t += 0.5; log.append("IDLE"); return result, t, log
print("Good seal:", run_test(3.1)[0], "| leaking seal:", run_test(14.6)[0], f"| test time {run_test(3.1)[1]:.1f} s")
print(" -> ".join(run_test(3.1)[2]))
# 3. Periodic tasks on the fixture's microcontroller: (name, period ms, worst-case execution ms)
tasks = [("ADC sample and filter", 2, 0.30), ("button scan", 1, 0.05), ("stepper pulse planner", 5, 0.90),
("display update", 100, 12.0), ("PLC message", 20, 1.5)]
U = sum(c / p for _, p, c in tasks)
n = len(tasks)
bound = n * (2 ** (1 / n) - 1)
print(f"CPU utilization {100*U:.1f} %; rate-monotonic bound for {n} tasks {100*bound:.1f} %"
f" -> {'schedulable by the bound' if U <= bound else 'needs exact analysis'}")press accepted at t = 26 ms Raw rising edges seen: 2; debounced presses: 1 Good seal: PASS | leaking seal: FAIL | test time 9.6 s IDLE -> CLAMP -> FILL -> SETTLE -> MEASURE -> PASS -> UNCLAMP -> IDLE CPU utilization 57.5 %; rate-monotonic bound for 5 tasks 74.3 % -> schedulable by the bound
The middle part of the program runs the same state machine in Python for a good seal and a leaking one, confirming that the leaking seal with 14.6 mbar of decay fails while a 3.1 mbar seal passes, and that one test takes 9.6 s from clamp to release. That fits comfortably inside the 86 s takt, which is why one fixture serves the whole cell.
Scheduling periodic tasks
The fixture's firmware has several periodic tasks with different periods and deadlines: sampling the ADC every 2 ms, scanning buttons every millisecond, planning stepper pulses every 5 ms, updating the display every 100 ms and exchanging a message with the PLC gateway every 20 ms. Whether a set of tasks can all meet their deadlines depends on how much of the processor they use and how they are prioritized. Their utilization is the sum of each task's worst-case execution time divided by its period. For ST-1 it is 57.5 percent.
Under rate-monotonic scheduling, the classical result for preemptive priority scheduling, each task gets a fixed priority that is higher the shorter its period. Liu and Layland showed in 1973 that such a set of independent periodic tasks, each with a deadline equal to its period, always meets its deadlines if the total utilization is no more than \(n(2^{1/n} - 1)\), which is 74.3 percent for five tasks and approaches 69 percent for many. ST-1's 57.5 percent is below the bound, so the set is schedulable without further analysis. A set above the bound may still be schedulable, but needs an exact response-time analysis. The display task, with its long 12 ms execution, would be dangerous in a super-loop, where it would delay the 1 ms button scan; under preemptive priorities it simply gets interrupted.
Real-time operating systems
For more than a handful of tasks, or tasks that must wait for events, a small real-time operating system (RTOS) provides preemptive scheduling, timers, queues and mutexes, so each task can be written as a simple loop. It also introduces new failure modes: priority inversion, where a low-priority task holding a shared resource blocks a high-priority one, deadlock, and stack overflow. Most RTOSs offer priority inheritance and stack checking to manage them. ST-1 is small enough that Gilles chooses a super-loop for the state machine and display, with interrupts for the ADC, buttons and stepper pulses, and keeps the RTOS for larger projects.
Worked problem
Adding a data logger
Lucie asks ST-1 to log every reading of a test to an SD card for later analysis, a task that runs every 10 ms with a worst-case execution time of 3.0 ms. Is the six-task set still schedulable by the rate-monotonic bound?
Show solution
- New utilization: 0.575 + 3.0/10 = 0.875.
- Bound for six tasks: \(6(2^{1/6} - 1) = 0.735\).
- 0.875 > 0.735, so the bound cannot guarantee the deadlines; an exact response-time analysis is needed, and with 87.5 percent load it is likely to fail.
Answer: Not by the bound. Moving the logger to a background task that writes buffered data whenever the processor is idle, with no hard deadline, is the practical fix.
Testing embedded code
Firmware faults are easier to prevent than to find in a running machine. Gilles keeps the logic, such as the state machine and filter, separate from the hardware access functions, so the logic can be compiled and unit-tested on a desktop computer with simulated inputs, exactly as the Python versions in this lesson do. Static analysis tools catch uninitialized variables and overflows. Coding rules such as MISRA C, widely used in automotive and industrial firmware, ban the constructs most likely to cause undefined behaviour. A watchdog timer, a hardware timer that resets the processor unless the program regularly restarts it, recovers the fixture if the program hangs, and its reset should leave the outputs in their safe state. None of this makes firmware suitable for safety functions on its own; that requires a development process to the level the safety function demands, which the cell's safety controller already provides.
Common mistakes
MistakeWaiting in a loop for a condition inside a state machine.
InsteadReturn and re-check on the next tick so other tasks keep running.
MistakeOmitting timeouts on states that wait for hardware.
InsteadGive every wait a timeout leading to a fault state.
MistakeDoing slow work inside an interrupt routine.
InsteadKeep ISRs short; set flags or update small shared values and do the rest in the main loop.
MistakeSharing variables with an ISR without volatile and atomic access.
InsteadDeclare shared data volatile and protect multi-step reads and writes.
MistakeCounting raw edges of a mechanical switch.
InsteadDebounce by requiring several consecutive equal samples.
Summary and key terms
- Embedded programs run continuously and must meet deadlines, not just produce correct values.
- Interrupts give precise timing; ISRs must be short and share data carefully.
- Non-blocking state machines with timeouts and a safe fault state make sequences predictable.
- Debouncing with five equal 1 ms samples turned a bouncing button's two edges into one press.
- ST-1's periodic tasks use 57.5 % of the processor, below the rate-monotonic bound of 74.3 % for five tasks.
- Logic is unit-tested off target; a watchdog recovers hangs; safety functions stay in the safety controller.
- Super-loop
- A program structure that repeats input, decision and output steps forever.
- Interrupt service routine
- A short function run by the processor in response to a hardware event.
- Volatile
- A C qualifier telling the compiler a variable can change outside the normal program flow.
- Finite state machine
- A program that is always in one of a set of states and changes state on defined conditions.
- Timeout
- A time limit after which a waiting state treats the condition as a fault.
- Debouncing
- Filtering out the rapid transitions of a bouncing contact.
- Utilization
- The fraction of processor time used by a set of tasks.
- Rate-monotonic scheduling
- Fixed-priority scheduling in which shorter-period tasks get higher priority.
- RTOS
- A real-time operating system providing preemptive scheduling and synchronization.
- Watchdog timer
- A hardware timer that resets the processor unless the program restarts it regularly.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Tasks with (period, execution) of (4 ms, 1 ms), (10 ms, 2 ms) and (20 ms, 4 ms). What is the utilization, and is it within the rate-monotonic bound?
Show answer
0.25 + 0.20 + 0.20 = 0.65; the bound for three tasks is 0.780, so yes.
A button bounces for up to 6 ms and is sampled every 2 ms. How many equal samples should the debouncer require?
Show answer
At least 4 (8 ms), so the bounce cannot satisfy it.
Why does the ST-1 FAULT state need a deliberate reset?
Show answer
So a fault cannot clear itself and restart the press unexpectedly.
What two properties must data shared between an ISR and the main loop have?
Show answer
It must be volatile and read or written atomically, for example with interrupts briefly disabled.
What does a watchdog timer protect against, and what must happen to the outputs when it fires?
Show answer
A hung program; the reset must leave the outputs in their safe state.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.