Hierarchical State Machines (HSM) in C for Telecom and RF Firmware

Hierarchical State Machines (HSM) in C for Telecom and RF Firmware

In telecom, RF, SATCOM, and industrial communication products, firmware coordinates dozens of subsystems that all run at once: PLL lock supervision, thermal protection, VSWR monitoring, link state, calibration sequences, remote firmware update, and a long tail of fault responses. The behavior is reactive and deeply modal — what an event means depends entirely on what the radio is currently doing.

The naive answer is a switch-based finite state machine (FSM). It works until it doesn’t. Each new feature adds states, and each cross-cutting concern (over-temperature, watchdog, comms loss) has to be re-handled in every state. The transition count grows like the product of states and cross-cutting events, and the code rots into an unmaintainable thicket where a one-line safety change touches forty case labels.

Hierarchical State Machines (HSMs) — Harel statecharts, as formalized in UML — fix this with one idea: behavioral inheritance. States nest inside other states, and a child state inherits the event handling of its parents. A fault response written once in a parent applies automatically to every descendant. This article is a working reference for building production HSMs in C on resource-constrained MCUs: the concepts the tutorials skip, a complete and correct dispatch engine, the implementation tradeoffs that actually matter on Cortex-M, and how to wire it all into an RTOS and a host-based test rig. The whole engine is freestanding — no malloc, no recursion, no setjmp — and maps cleanly onto MISRA-C practice.

This post builds on the earlier installments in the series — Event-Driven Firmware Architecture and Multicore Microcontrollers. The HSM is the behavioral core that sits inside the active object described in the event-driven post; if you haven’t read that one, the RTOS-integration section below will make more sense afterward.

The combinatorial wall: why flat FSMs collapse

Take an RF power amplifier with operational modes Idle, Receive, Transmit, Calibration, and Firmware Upgrade. Now layer in the cross-cutting events every mode must respect:

  • EVENT_TEMP_HIGH → go to fault
  • EVENT_OVERVOLTAGE → go to fault
  • EVENT_WATCHDOG → go to fault
  • EVENT_COMMS_LOSS → go to safe state
  • EVENT_ESTOP → shut down

With 5 operational states and 5 cross-cutting events, a flat FSM needs 25 transition handlers for the safety logic alone — and that is before you add the events each mode legitimately cares about. The complexity is multiplicative: O(states × cross-cutting events). Every new mode you add multiplies the safety surface, and every safety policy change is a shotgun edit across the codebase. This is the single most common reason mature radio firmware becomes “too risky to touch.”

An HSM collapses that product into a sum. The five safety transitions live once in a parent Operational state. Adding a sixth operational mode costs you the mode’s own logic and nothing else. The safety surface is now O(cross-cutting events), independent of how many modes exist.

The mental model you actually need

Three formal properties separate a real HSM from a switch with extra steps. If your engine doesn’t honor all three, it will produce subtle, field-only bugs.

1. The active configuration is a path, not a state

In an HSM, more than one state is “active” simultaneously. If the leaf state is Ramp Up, then High Power, Transmit, Operational, and System are also active, because they transitively contain the leaf. The active configuration is the chain from the root down to the current leaf. Entry, exit, and event handling all operate on this chain — which is exactly why ordering matters.

2. Run-to-completion (RTC) semantics

An HSM processes one event to completion — including all the exit actions, transition actions, and entry actions it triggers — before it looks at the next event. There is no preemption inside a single dispatch. This is what makes state machines deterministic and analyzable, and it is the contract that lets you reason about an entire transition as one atomic step. Violating RTC (e.g., dispatching a new event from inside an entry action via a re-entrant call) is the classic way to corrupt an HSM.

3. Event propagation is bottom-up; entry/exit is ordered

An event is offered to the leaf first. If the leaf doesn’t handle it, the event bubbles up to the parent, then the grandparent, until some ancestor consumes it or it reaches the root and is discarded. When a transition fires, exit actions run innermost-first (leaf → up) and entry actions run outermost-first (down → target). This ordering is not cosmetic: it guarantees that a child’s resources are released before its parent’s, and a parent’s invariants are established before its child’s — the same nested acquire/release discipline you already use for init()/deinit() pairs, applied automatically by the engine.

The hard parts the tutorials skip

Most HSM write-ups stop at “events bubble up.” That’s the easy 80%. The 20% that makes an engine correct is below — and it’s where homegrown implementations quietly go wrong.

Least Common Ancestor (LCA) and the exit/enter sets

When you transition from a source state S to a target T, you cannot simply “exit S, enter T.” You must:

  1. Find the Least Common Ancestor L = LCA(S, T) — the deepest state that contains both.
  2. Exit from the current active leaf up to (but not including) L, innermost-first.
  3. Enter from just below L down to T, outermost-first.
  4. Take any initial transitions from T into its default substates, until you reach a leaf.

Consider a transition declared in Operational that targets Fault, while the machine is actually sitting in Operational → Transmit → High Power → Ramp Up:

LCA(Operational, Fault) = System

Exit set  (innermost-first):
  Ramp Up → High Power → Transmit → Operational

Enter set (outermost-first):
  Fault

Then: Fault's initial substate, if any

Notice that Transmit’s exit action — disable the PA — runs automatically as part of leaving the subtree, even though the fault was declared three levels up. That is the whole point: safety teardown is structural, not something each mode remembers to do.

Initial (default) transitions

A composite state is not a place you can “be.” When you land on High Power, you must immediately descend into its initial substate (Ramp Up). Initial transitions are a first-class part of the engine, not an afterthought — and they run their own entry actions on the way down.

Guards

A transition can carry a boolean guard g(e): it fires only if the guard evaluates true for the current event and context. EVENT_TX_REQUEST [pll_locked && temp_ok] -> Transmit. In C you express the guard right inside the handler before returning a transition. Keeping policy in guards rather than buried in action code preserves the readability HSMs are supposed to buy you.

Internal vs. external vs. self transitions

This trips up nearly everyone:

  • Internal transition — handle the event, run an action, but do not exit or re-enter the state. No entry/exit actions fire. (Example: a periodic TICK that updates a counter inside Tracking without leaving it.)
  • External self-transition — a transition from a state back to itself that does exit and re-enter, re-running entry/exit actions. (Example: re-arming a timeout by re-entering Searching.)
  • Local transition — a UML refinement that re-enters substates but not the composite itself.

These produce different side effects. An engine that treats every “stay-ish” transition identically will fire (or skip) your pa_enable() / pa_disable() actions at the wrong time. In the engine below, returning HANDLED() expresses an internal transition; TRAN(self) expresses an external self-transition.

History (shallow and deep) — the telecom killer feature

This is the concept that pays for the whole HSM in radio firmware. Suppose a transient over-temperature kicks you from Transmit → High Power → Stable Output into Fault. After the thermal event clears, where do you go? Back to Idle and force a full re-acquisition? Or resume exactly where you were?

A history pseudostate records the last active substate of a composite so that re-entry restores it:

  • Shallow history (H) restores the last active direct child of the composite.
  • Deep history (H*) restores the entire nested active configuration down to the leaf.

For a SATCOM modem that briefly lost lock, deep history lets Network resume Connected instead of crawling back through Searching → Locking. Because state records live in flash (read-only), the history slot must live in RAM — a single pointer per composite that wants history, written on exit and read on re-entry. The engine sketch below includes the hook.

Orthogonal regions (and why most embedded engines omit them)

UML allows a composite state to contain concurrent regions. They’re expressive but expensive: the active configuration becomes a tree, not a path, and the dispatch logic multiplies. Most production embedded engines deliberately do not implement orthogonal regions, modeling concurrency with separate state machines (active objects) instead. That is almost always the right call on an MCU: one HSM per subsystem, communicating by events.

Implementation strategies in C

There is no single “right” HSM in C; there’s a tradeoff space. Here’s the honest comparison for embedded targets:

  • Nested switch (hand-rolled) — Low dispatch cost, tiny footprint, high determinism, hard at scale, not MISRA-friendly at scale.
  • Function-pointer flyweight (QEP-style) — 1 indirect call, const tables in flash, 1 ptr/instance in RAM, high determinism, high debuggability, MISRA-friendly with discipline.
  • Table-driven (rows of transition structs) — Table scan cost, const table in flash, high determinism, high debuggability, MISRA-friendly.
  • Code-generated (QM and similar) — As generated, const tables in flash, high determinism, model + trace debuggability, MISRA-friendly.

The function-pointer flyweight is the production workhorse for C. Because C has no virtual dispatch or templates, function pointers in const structs are the idiom — and they’re the right one. It is fast, deterministic, recursion-free, and needs only one pointer per state machine in RAM.

A complete, correct HSM engine (~120 lines, freestanding C99)

This is a flyweight engine: every State is a const aggregate that lives in flash, and the only RAM the machine needs is a single current pointer. It correctly handles LCA, ordered exit/enter, initial transitions, guards (in the handlers), and external self-transitions. It uses no dynamic allocation, no recursion, and no setjmp/longjmp — suitable for freestanding, MISRA-style builds.

Events and the state record

#include <stdint.h>
#include <stddef.h>

typedef enum {
    SIG_TX_REQUEST, SIG_TX_STOP, SIG_RAMP_COMPLETE,
    SIG_PLL_LOCK,   SIG_PLL_UNLOCK,
    SIG_TEMP_HIGH,  SIG_TEMP_OK,
    SIG_VSWR_HIGH,  SIG_TICK,
    SIG_FAULT_CLEARED
} Sig;

typedef struct {
    Sig     sig;
    int32_t arg;
} Event;

typedef struct Hsm   Hsm;
typedef struct State State;

typedef enum { STATUS_HANDLED, STATUS_IGNORED, STATUS_TRANSITION } Status;

typedef struct {
    Status       status;
    const State *target;
} Reaction;

static inline Reaction HANDLED(void)          { return (Reaction){ STATUS_HANDLED,    NULL }; }
static inline Reaction IGNORED(void)          { return (Reaction){ STATUS_IGNORED,    NULL }; }
static inline Reaction TRAN(const State *t)   { return (Reaction){ STATUS_TRANSITION, t    }; }

typedef Reaction (*ReactFn)(Hsm *, const Event *);
typedef void     (*ActionFn)(Hsm *);

struct State {
    const State *parent;
    ReactFn      react;
    ActionFn     on_entry;
    ActionFn     on_exit;
    const State *initial;
    const char  *name;
};

The engine

struct Hsm {
    const State *top;
    const State *current;
};

#define HSM_MAX_DEPTH 8

static const State *hsm_lca(const Hsm *h, const State *a, const State *b) {
    const State *x, *y;
    for (x = a; x != NULL; x = x->parent)
        for (y = b; y != NULL; y = y->parent)
            if (x == y)
                return x;
    return h->top;
}

static void hsm_descend(Hsm *h) {
    while (h->current->initial != NULL) {
        const State *nxt = h->current->initial;
        if (nxt->on_entry != NULL)
            nxt->on_entry(h);
        h->current = nxt;
    }
}

void hsm_init(Hsm *h, const State *top) {
    h->top     = top;
    h->current = NULL;
}

void hsm_start(Hsm *h) {
    if (h->top->on_entry != NULL)
        h->top->on_entry(h);
    h->current = h->top;
    hsm_descend(h);
}

static void hsm_transition(Hsm *h, const State *src, const State *tgt) {
    const State *anc;
    const State *s;
    const State *path[HSM_MAX_DEPTH];
    int n = 0, i;

    if ((src == tgt) && (src->parent != NULL))
        anc = hsm_lca(h, src->parent, tgt);
    else
        anc = hsm_lca(h, src, tgt);

    for (s = h->current; s != anc; s = s->parent)
        if (s->on_exit != NULL) s->on_exit(h);

    for (s = tgt; s != anc; s = s->parent)
        path[n++] = s;

    for (i = n - 1; i >= 0; --i)
        if (path[i]->on_entry != NULL) path[i]->on_entry(h);

    h->current = tgt;
    hsm_descend(h);
}

void hsm_dispatch(Hsm *h, const Event *e) {
    const State *s = h->current;
    Reaction r = { STATUS_IGNORED, NULL };

    for (; s != NULL; s = s->parent) {
        r = (s->react != NULL) ? s->react(h, e) : IGNORED();
        if (r.status != STATUS_IGNORED) break;
    }
    if (r.status == STATUS_TRANSITION)
        hsm_transition(h, s, r.target);
}

const State *hsm_state(const Hsm *h) { return h->current; }

Adding history

typedef struct { const State *last_leaf; } History;
static History tx_history;

static void transmit_exit(Hsm *h) {
    tx_history.last_leaf = hsm_state(h);
    pa_disable();
}

static Reaction transmit_resume_react(Hsm *h, const Event *e) {
    (void)h; (void)e;
    return TRAN(tx_history.last_leaf != NULL ? tx_history.last_leaf : &s_idle);
}

Worked example: an RF power-amplifier transmit chain

Target: an STM32H7-class MCU driving a PA, with an external RF synthesizer (e.g., an ADF4371) providing the PLL_LOCK signal, an on-die and board thermistor feeding TEMP_HIGH, and an ADC current-sense loop. The hierarchy:

System
├── Operational            (watchdog, thermal & VSWR supervision)
│   ├── Idle               (initial)
│   └── Transmit           (entry: enable PA   exit: disable PA)
│       ├── Low Power
│       └── High Power     (entry: raise gain  exit: lower gain)
│           ├── Ramp Up    (initial)
│           ├── Stable Output
│           └── Ramp Down
└── Fault                  (entry: PA off, latch fault, log)

The safety contract — TEMP_HIGH, PLL_UNLOCK, VSWR_HIGH all force Fault — is written once, in Operational. Every transmit substate inherits it.

Handlers

static Reaction operational_react(Hsm *h, const Event *e) {
    (void)h;
    switch (e->sig) {
        case SIG_TEMP_HIGH:
        case SIG_VSWR_HIGH:
        case SIG_PLL_UNLOCK:
            return TRAN(&s_fault);
        default:
            return IGNORED();
    }
}

static Reaction idle_react(Hsm *h, const Event *e) {
    (void)h;
    if (e->sig == SIG_TX_REQUEST) return TRAN(&s_transmit);
    return IGNORED();
}

static Reaction transmit_react(Hsm *h, const Event *e) {
    (void)h;
    if (e->sig == SIG_TX_STOP) return TRAN(&s_idle);
    return IGNORED();
}

static Reaction ramp_up_react(Hsm *h, const Event *e) {
    (void)h;
    if (e->sig == SIG_RAMP_COMPLETE) return TRAN(&s_stable);
    return IGNORED();
}

static Reaction fault_react(Hsm *h, const Event *e) {
    (void)h;
    if (e->sig == SIG_FAULT_CLEARED) return TRAN(&s_idle);
    return IGNORED();
}

static void transmit_entry(Hsm *h)   { (void)h; pa_enable();  }
static void transmit_exit_(Hsm *h)   { (void)h; pa_disable(); }
static void high_power_entry(Hsm *h) { (void)h; gain_high();   }
static void high_power_exit(Hsm *h)  { (void)h; gain_normal(); }
static void fault_entry(Hsm *h)      { (void)h; pa_disable(); fault_latch(); log_fault(0); }

The state table (const, in flash)

const State s_system      = { .parent = NULL,          .initial = &s_operational, .name = "System"      };
const State s_operational = { .parent = &s_system,     .react = operational_react, .initial = &s_idle,    .name = "Operational" };
const State s_idle        = { .parent = &s_operational, .react = idle_react,                               .name = "Idle"        };
const State s_transmit    = { .parent = &s_operational, .react = transmit_react,
                               .on_entry = transmit_entry, .on_exit = transmit_exit_,
                               .initial = &s_low_power, .name = "Transmit" };
const State s_low_power   = { .parent = &s_transmit,                                .name = "LowPower"    };
const State s_high_power  = { .parent = &s_transmit,    .on_entry = high_power_entry, .on_exit = high_power_exit,
                               .initial = &s_ramp_up,    .name = "HighPower"   };
const State s_ramp_up     = { .parent = &s_high_power,  .react = ramp_up_react,      .name = "RampUp"      };
const State s_stable      = { .parent = &s_high_power,                               .name = "Stable"      };
const State s_fault       = { .parent = &s_system,      .react = fault_react, .on_entry = fault_entry, .name = "Fault" };

What happens on TEMP_HIGH in Ramp Up

Active config: System → Operational → Transmit → High Power → Ramp Up

hsm_dispatch({SIG_TEMP_HIGH})
  ramp_up_react      → IGNORED   (bubble)
  high_power         → (bubble)
  transmit_react     → IGNORED   (bubble)
  operational_react  → TRAN(Fault)   <-- handled ONCE, here

  LCA(Operational, Fault) = System
  Exit (innermost-first): RampUp, HighPower[gain_normal], Transmit[pa_disable]
  Enter:                  Fault[pa_disable, fault_latch, log_fault]

The PA is de-energized and the gain backed off automatically, with no if (temp_high) anywhere in the transmit code. Add a Calibration mode, a Beamforming mode, ten more modes — the thermal contract is unchanged.

Wiring the HSM into an RTOS (active objects)

static Hsm        pa_sm;
static EventQueue pa_q;

void pa_task(void *arg) {
    Event e;
    (void)arg;
    hsm_init(&pa_sm, &s_system);
    hsm_start(&pa_sm);

    for (;;) {
        if (queue_pop_blocking(&pa_q, &e))
            hsm_dispatch(&pa_sm, &e);
    }
}

void TIM2_IRQHandler(void) {
    Event e = { SIG_TICK, 0 };
    queue_push_from_isr(&pa_q, &e);
}

Three rules that keep this safe: never call hsm_dispatch() from an ISR; one HSM per subsystem; guard the shared queue, not the state machine.

Testing the HSM on the host

static const char *trace[16];
static int         trace_n;
static void emit(const char *s) { trace[trace_n++] = s; }

void pa_enable(void)        { emit("PA_ON");     }
void pa_disable(void)       { emit("PA_OFF");    }
void gain_high(void)        { emit("GAIN_HI");   }
void gain_normal(void)      { emit("GAIN_NORM"); }
void fault_latch(void)      { emit("LATCH");     }
void log_fault(int32_t x)   { (void)x; emit("LOG"); }

void test_thermal_teardown(void) {
    static const char *const want[] = {
        "PA_ON", "GAIN_NORM", "PA_OFF", "PA_OFF", "LATCH", "LOG"
    };
    Hsm sm;
    Event tx   = { SIG_TX_REQUEST, 0 };
    Event hot  = { SIG_TEMP_HIGH, 95 };

    trace_n = 0;
    hsm_init(&sm, &s_system);
    hsm_start(&sm);
    hsm_dispatch(&sm, &tx);
    hsm_dispatch(&sm, &hot);

    assert(hsm_state(&sm) == &s_fault);
    /* Golden trace proves PA off + gain backed off BEFORE the fault latches. */
}

Anti-patterns to avoid

  • Long-running work inside a handler. Handlers must initiate and return. Blocking inside a state stalls run-to-completion. Kick off the work (DMA, timer, peripheral) and react to its completion event.
  • Dispatching events from inside entry/exit actions. This breaks RTC and re-enters the engine mid-transition. Post an event to the queue instead.
  • Smuggling state into globals. Keep context in the machine’s own data. Globals defeat testability and reintroduce coupling.
  • Over-nesting. Past 4–5 levels the model gets hard to reason about. If you’re going deeper, factor a subtree into its own active object.
  • Reinventing orthogonal regions inside one machine. If two concerns are truly independent, give them two machines.
  • NULL handler pointers without engine guards. Either always provide handlers or keep the null-checks the engine performs.

Framework landscape: roll your own, or adopt one?

  • Roll your own (the engine above) — Best when you want full control, a tiny footprint, and to understand every cycle. You’re responsible for local transitions, history, and tracing.
  • QP/C (Quantum Leaps) — Best for production and safety-critical work. Real framework to learn; an event-driven paradigm shift if your team thinks in superloops.
  • Code generation (QM and similar) — Best when you want the model to be the source of truth and the C to be generated. Tooling in the build; generated-code review discipline required.

QP/C (current generation, QP 7.x) is the reference event-driven framework for this style of code in C. Its hierarchical event processor is engineered so each state machine needs only a single function pointer in RAM and uses no recursion — the same efficiency target as the flyweight engine above, hardened over two decades. It ships native ports for ARM Cortex-M and ready-made integrations with FreeRTOS, Zephyr, ThreadX, and embOS.

HSMs are flight-proven at the highest assurance levels. NASA’s Mars 2020 / Perseverance flight software uses hierarchical state machines as a core structuring mechanism — a useful data point when someone argues statecharts are “academic.”

Flat FSM vs. HSM — the senior summary

Flat FSMHierarchical State Machine
Safety logic is O(states × events)Safety logic is O(events), written once in a parent
Cross-cutting changes are shotgun editsCross-cutting changes are one-line edits
Resource teardown is per-state boilerplateTeardown is structural via ordered exit actions
“Resume where I was” is bespoke and fragileHistory pseudostates make resume a primitive
Hard to unit test (hardware-coupled)Pure logic; host-testable with mocked actions
Transition count explodes with featuresNew modes are local additions
Poor fit for large radio firmwareThe standard for it

Best practices for embedded HSMs

  • Keep depth at 3–5 levels. Beyond that, factor a subtree into its own active object.
  • Parents own the generic, children own the specific. Power, fault, comms supervision belong in parents; RX/TX/calibration specifics belong in leaves.
  • Use entry/exit actions to enforce hardware invariants. “PA on” in Transmit entry and “PA off” in its exit guarantees the amplifier is never energized outside Transmit.
  • Store the hierarchy as const records in flash. One pointer of RAM per machine; everything else read-only.
  • Initiate-and-return in every handler. Never block; never run a long operation inline.
  • One machine per subsystem; communicate by events. This replaces orthogonal regions and scales to multicore.
  • Express internal vs. external transitions deliberately. HANDLED() for internal, TRAN(self) for external self-transitions.
  • Null-check optional handlers in the engine, so leaf states can omit them and the table stays MISRA-clean.
  • Test the behavior on the host. Mock the HAL, drive event sequences, assert on state and action traces in CI.

Where HSMs are used in industry

Hierarchical state machines are the default behavioral architecture wherever firmware is reactive, modal, and safety-relevant: telecom base stations and remote radio units (RRUs), 5G beamforming controllers, RF power amplifiers and frequency synthesizers, SATCOM modems, automotive ECUs (body control, ADAS, powertrain), medical devices, industrial automation controllers, and aerospace/avionics flight software.

The reason is always the same: HSMs turn the multiplicative complexity of modal firmware into additive complexity, push safety policy into one auditable place, and produce behavior you can actually test off-target. In C specifically, the function-pointer flyweight gives you all of that with a footprint of a single pointer in RAM and a hierarchy that lives entirely in flash — exactly the determinism and discipline a radio, and a certification auditor, demand.

Next in the series: layering publish-subscribe event delivery and a model-based code-generation workflow on top of the active-object + HSM foundation.

Leave a Reply

Your email address will not be published. Required fields are marked *