Skip to content
michael.caisse.io michael.caisse.io

CIB Guide Chapter 3 of 8

Interrupt

How to describe a system's interrupts once, as types, and get zero-cost ISR dispatch from interrupt::manager plus runtime enable control from interrupt::dynamic_controller.

Headers
include/interrupt/
Namespace
interrupt
Standard
C++20
Links
stdx · boost_mp11 · concurrency
Cooperates
flow · nexus · cib::top

How the pieces fit

The interrupt library splits interrupt handling into three layers. You write the first one; the library generates the other two from it.

Compile time Configuration
An interrupt::root<…> type tree: IRQ numbers, priorities, enable and status fields, policies, and the flows each source runs.
Boot & ISR manager
init() programs the interrupt controller. run<N>() is what a vector calls: it checks fields, clears status, and runs flows.
Runtime dynamic_controller
Turns interrupt sources on and off by flow, by resource, or by name, and writes only the enable registers that changed.

Two facts shape everything else in this handbook:

  • Activity is decided at compile time. A flow that no component extends is inactive. An IRQ whose flows are all inactive gets irq_init<false, …> at boot, and its run() compiles to nothing.
  • Runtime control works through enable fields. The dynamic controller never skips flows directly. It sets and clears hardware enable bits, and run() reads those bits back when the interrupt fires.

Quick start

A timer interrupt that bumps an uptime counter. This config has no enable or status fields, so the HAL needs just two functions.

uptime.cpp minimal, single IRQ
#include <cib/cib.hpp>           // flow, nexus, cib::top, interrupt::manager
#include <interrupt/config.hpp>  // irq, shared_irq, sub_irq, root: NOT pulled in by cib.hpp

#include <stdx/utility.hpp>

#include <cstddef>
#include <cstdint>

using interrupt::operator""_irq;

// 1. A flow is the extension point an interrupt runs.
struct tim2_tick : flow::service<> {};

// 2. A component extends the flow.
inline std::uint32_t uptime_ms{};

struct uptime {
    constexpr static auto COUNT_MS =
        flow::action<"count_ms">([] { ++uptime_ms; });

    constexpr static auto config = cib::config(cib::extend<tim2_tick>(*COUNT_MS));
};

// 3. The project exports the flow and collects components.
struct board {
    constexpr static auto config =
        cib::config(cib::exports<tim2_tick>, cib::components<uptime>);
};
using nexus_t = cib::nexus<board>;

// 4. Describe the interrupt: name, IRQ number, priority, enable field,
//    policies, flows.
using irq_config = interrupt::root<
    interrupt::irq<"tim2", 28_irq, 3, interrupt::no_field_t,
                   interrupt::policies<>, tim2_tick>>;

// 5. The HAL. With no fields in the config, init() and irq_init() suffice.
struct board_hal {
    static auto init() -> void {}

    template <bool Enable, interrupt::irq_num_t Irq, std::size_t Priority>
    static auto irq_init() -> void {
        auto const n = stdx::to_underlying(Irq);
        bsp::nvic_set_priority(n, Priority);          // your board support code
        Enable ? bsp::nvic_enable(n) : bsp::nvic_disable(n);
    }
};

// 6. Build the manager: config, HAL, then one or more nexus types.
using irq_manager = interrupt::manager<irq_config, board_hal, nexus_t>;

// 7. Wire the vector and initialize.
extern "C" void TIM2_IRQHandler() { irq_manager::run<28_irq>(); }

int main() {
    irq_manager::init();
    for (;;) {}
}

Every member of interrupt::manager is static, so you use the type alias directly and never need an instance. Remove the uptime component from board and tim2_tick becomes inactive: IRQ 28 stays disabled and TIM2_IRQHandler compiles to an empty function.

Describing interrupts

A configuration is a tree of four node kinds under one interrupt::root. Every node starts with a compile-time string name, which the dynamic controller uses for named enables. Every node’s last parameter is a variadic pack: flows for leaves, children for containers.

TemplatePositionParametersChildren
irqtop levelName, Number, Priority, EnableField, Policies, Flows...flows
shared_irqtop levelName, Number, Priority, EnableField, Policies, SubIrqs...sub_irq / shared_sub_irq
sub_irqunder a shared nodeName, EnableField, StatusField, Policies, Flows...flows
shared_sub_irqunder a shared nodeName, EnableField, StatusField, Policies, SubIrqs...sub_irq / shared_sub_irq
rootthe whole treeTopLevelIrqs...irq / shared_irq

Fields are plain types. The library passes them through to your HAL without interpreting them, and interrupt::no_field_t means “this node has no such field”. A missing enable field always reads as enabled. A missing status field always reads as pending and is never cleared.

Top-level nodes have no status field. When a top-level interrupt fires, that is its status. They can still have an enable field, for example a peripheral-level enable that sits in front of the interrupt controller line.

interrupt::irq: one line, one job

dedicated vector
using namespace interrupt::literals;   // for _irq

using adc_irq = interrupt::irq<
    "adc0",                    // name, used by dynamic_controller::enable<"adc0">()
    45_irq,                    // interrupt::irq_num_t
    6,                         // priority, handed to Hal::irq_init
    ADC0_IE,                   // enable field, or interrupt::no_field_t
    interrupt::policies<>,     // status / resource policies (§4)
    adc0_conversion_done,      // flows to run, in order...
    adc0_watchdog_check>;      // ...more than one is fine

shared_irq and sub_irq: one line, many sources

Use these when a peripheral multiplexes several events onto one vector. On each firing, run() visits every child in declaration order. A child’s flows run when its enable field reads set and its status field reads set.

one vector, three sources
using uart0_irq = interrupt::shared_irq<
    "uart0", 37_irq, 2, interrupt::no_field_t, interrupt::policies<>,
    interrupt::sub_irq<"uart0_rx",  UART0_RXIE, UART0_RXI, interrupt::policies<>, uart0_rx>,
    interrupt::sub_irq<"uart0_tx",  UART0_TXIE, UART0_TXI, interrupt::policies<>, uart0_tx>,
    interrupt::sub_irq<"uart0_err", UART0_ERIE, UART0_ERI, interrupt::policies<>, uart0_error>>;

shared_sub_irq: summary bits over detail bits

Many GPIO blocks have a port-level summary bit in front of per-pin bits. shared_sub_irq models that middle level: it has its own enable and status fields, like a sub_irq, and its own children, like a shared_irq. Nest them as deep as the hardware goes.

port summary → pin detail
using gpio_irq = interrupt::shared_irq<
    "gpio", 40_irq, 5, interrupt::no_field_t, interrupt::policies<>,
    interrupt::shared_sub_irq<
        "porta", PORTA_IE, PORTA_IS, interrupt::policies<interrupt::dont_clear_status>,
        interrupt::sub_irq<"pa3_button", PA3_IE, PA3_IS, interrupt::policies<>, button_pressed>,
        interrupt::sub_irq<"pa7_accel",  PA7_IE, PA7_IS,
                           interrupt::policies<interrupt::clear_status_last>, accel_data_ready>>>;

A container is active when any descendant is active. If no component extends button_pressed or accel_data_ready, then porta, gpio, and IRQ 40 all compile out.

The running example

The rest of this handbook uses this configuration for a small board. It has a timer, a UART behind a gated clock, and a GPIO port.

  • rootirq_config
    • irq"tim2"28_irq · prio 3 · EN none→ tim2_tick
    • shared_irq"uart0"37_irq · prio 2 · EN none
      • sub_irq"uart0_rx"UART0_IER.RXIE / UART0_ISR.RXIneeds uart0_clock→ uart0_rx
      • sub_irq"uart0_tx"UART0_IER.TXIE / UART0_ISR.TXIneeds uart0_clock→ uart0_tx
      • sub_irq"uart0_err"UART0_IER.ERIE / UART0_ISR.ERIneeds uart0_clock→ uart0_error
    • shared_irq"gpio"40_irq · prio 5 · EN none
      • shared_sub_irq"porta"GPIO_IER.PORTA / GPIO_ISR.PORTA · dont_clear_status
        • sub_irq"pa3_button"PA_IER.3 / PA_ISR.3→ button_pressed
        • sub_irq"pa7_accel"PA_IER.7 / PA_ISR.7 · clear_status_last→ accel_data_ready

The UART’s enable and status bits share one register each. That grouping matters to the dynamic controller, which shadows and writes whole registers:

7
6
5
4
3
2
1
0
UART0_IER
ERIE
TXIE
RXIE
board/irq_config.hpp shared by the ISR file and drivers
#pragma once
#include <interrupt/config.hpp>
#include <board/registers.hpp>   // UART0_RXIE, PA3_IS, ... (see §5)
#include <board/flows.hpp>       // struct uart0_rx : flow::service<> {}; ...

using namespace interrupt::literals;

struct uart0_clock;               // a resource: just a tag type

using needs_uart_clock = interrupt::policies<interrupt::required_resources<uart0_clock>>;

using irq_config = interrupt::root<
    interrupt::irq<"tim2", 28_irq, 3, interrupt::no_field_t, interrupt::policies<>, tim2_tick>,

    interrupt::shared_irq<
        "uart0", 37_irq, 2, interrupt::no_field_t, interrupt::policies<>,
        interrupt::sub_irq<"uart0_rx",  UART0_RXIE, UART0_RXI, needs_uart_clock, uart0_rx>,
        interrupt::sub_irq<"uart0_tx",  UART0_TXIE, UART0_TXI, needs_uart_clock, uart0_tx>,
        interrupt::sub_irq<"uart0_err", UART0_ERIE, UART0_ERI, needs_uart_clock, uart0_error>>,

    interrupt::shared_irq<
        "gpio", 40_irq, 5, interrupt::no_field_t, interrupt::policies<>,
        interrupt::shared_sub_irq<
            "porta", PORTA_IE, PORTA_IS, interrupt::policies<interrupt::dont_clear_status>,
            interrupt::sub_irq<"pa3_button", PA3_IE, PA3_IS, interrupt::policies<>, button_pressed>,
            interrupt::sub_irq<"pa7_accel", PA7_IE, PA7_IS,
                               interrupt::policies<interrupt::clear_status_last>,
                               accel_data_ready>>>>;

// The dynamic controller depends only on the config and the HAL, not on any nexus.
// Drivers can include this header without seeing the project's component list.
#include <interrupt/dynamic_controller.hpp>
#include <board/hal.hpp>
using irq_dynamic = interrupt::dynamic_controller<irq_config, board_hal>;

Policies

interrupt::policies<…> is a variadic bag of at most one policy per kind. There are two kinds. Anything you leave out gets its default.

Status-clear policies

When a node’s enable and status both read set, the library passes two actions to the status policy: clear the status field and run the body. The body is the node’s flows, or its children’s run() for a container.

PolicyOrderReach for it when
clear_status_first default clear → runEdge-style status bits. Clearing first means a new event during the handler sets the bit again, so it isn’t lost.
clear_status_lastrun → clearLevel-style sources, where the condition has to be serviced (a FIFO drained, a line released) before the bit will stay clear.
dont_clear_statusrunRead-only summary bits that the hardware derives from children, or status the handler clears itself as a side effect (read-to-clear data registers).

A status policy is any type with using policy_type = interrupt::status_clear_policy; and a static run(clear, body). You can write your own, for example one that clears before and after:

custom status policy
struct clear_status_around {
    using policy_type = interrupt::status_clear_policy;

    static void run(stdx::invocable auto const &clear_status,
                    stdx::invocable auto const &body) {
        clear_status();
        body();
        clear_status();
    }
};
static_assert(interrupt::status_policy<clear_status_around>);

Required resources

A resource is any tag type standing for something the source depends on: a clock, a power rail, a pin mux, an external chip. required_resources<R...> ties a node to those resources. The dynamic controller keeps that node’s enable field clear whenever any of its resources is off.

combine kinds in one bag, in any order
struct spi1_clock;
struct imu_power;

using imu_irq = interrupt::sub_irq<
    "imu_fifo", IMU_FIFO_IE, IMU_FIFO_IS,
    interrupt::policies<interrupt::clear_status_last,
                        interrupt::required_resources<spi1_clock, imu_power>>,
    imu_fifo_ready>;

The HAL contract

The HAL is the manager’s second template argument: a type with static members. Nothing is injected globally. Members are needed only when the config uses the features that call them.

MemberCalled fromRequired when
static void init()manager::init(), firstalways
template <bool Enable, irq_num_t N, std::size_t Priority>
static void irq_init()
manager::init(), once per top-level nodealways
template <typename Field>
static auto get_field()
run(); the result goes to read / clearany enable or status field
static bool read(field)run(), for enable then statusany enable or status field
static void clear(field)run(), through the status policyany status field
template <typename Field>
static auto get_register()
dynamic controllerany enable field
template <typename Reg>
using register_datatype_t
dynamic controller (shadow type)any enable field
template <typename Reg, typename Field>
constexpr static register_datatype_t<Reg> mask
dynamic controllerany enable field
static void write(reg, value)dynamic controllerany enable field

A memory-mapped HAL

This HAL uses self-describing field types and needs no register library. The addresses are illustrative.

board/registers.hpp
#pragma once
#include <cstdint>

template <std::uintptr_t Address> struct mmio_reg {
    constexpr static auto address = Address;
};

template <typename Reg, std::uint32_t Mask> struct mmio_field {
    using register_t = Reg;
    constexpr static std::uint32_t mask = Mask;
};

using UART0_IER = mmio_reg<0x4000'C004>;   // interrupt enable
using UART0_ISR = mmio_reg<0x4000'C008>;   // interrupt status, write-1-to-clear

using UART0_RXIE = mmio_field<UART0_IER, 1u << 0>;
using UART0_TXIE = mmio_field<UART0_IER, 1u << 1>;
using UART0_ERIE = mmio_field<UART0_IER, 1u << 2>;
using UART0_RXI  = mmio_field<UART0_ISR, 1u << 0>;
using UART0_TXI  = mmio_field<UART0_ISR, 1u << 1>;
using UART0_ERI  = mmio_field<UART0_ISR, 1u << 2>;
// GPIO_IER / GPIO_ISR / PA_IER / PA_ISR follow the same pattern.
board/hal.hpp
#pragma once
#include <board/registers.hpp>
#include <interrupt/fwd.hpp>
#include <stdx/utility.hpp>
#include <cstddef>
#include <cstdint>

struct board_hal {
    // ---- interrupt controller -------------------------------------------
    static auto init() -> void { bsp::nvic_set_priority_grouping(4); }

    template <bool Enable, interrupt::irq_num_t Irq, std::size_t Priority>
    static auto irq_init() -> void {
        auto const n = stdx::to_underlying(Irq);
        bsp::nvic_set_priority(n, Priority);
        Enable ? bsp::nvic_enable(n) : bsp::nvic_disable(n);
    }

    // ---- field access: used by manager::run() -----------------------------
    template <typename Field>
    constexpr static auto get_field() -> Field { return {}; }

    template <typename Field>
    static auto read(Field) -> bool {
        return (reg32(Field::register_t::address) & Field::mask) != 0;
    }

    template <typename Field>
    static auto clear(Field) -> void {
        reg32(Field::register_t::address) = Field::mask;   // write-1-to-clear
    }

    // ---- register access: used by dynamic_controller ----------------------
    template <typename Field>
    constexpr static auto get_register() { return typename Field::register_t{}; }

    template <typename Register>
    using register_datatype_t = std::uint32_t;

    template <typename Register, typename Field>
    constexpr static register_datatype_t<Register> mask = Field::mask;

    template <typename Register>
    static auto write(Register, std::uint32_t value) -> void {
        reg32(Register::address) = value;
    }

  private:
    static auto reg32(std::uintptr_t a) -> std::uint32_t volatile & {
        return *reinterpret_cast<std::uint32_t volatile *>(a);
    }
};

A groov-backed HAL

The library’s own tests build the HAL on Intel’s groov register library, which is a test-only dependency. Fields are compile-time path strings such as "enable.33_1", and registers are their parent paths. This is the shape from test/interrupt/common.hpp:

test/interrupt/common.hpp (abridged)
template <typename Group> struct test_hal {
    template <typename Field>
    consteval static auto get_field() -> groov::pathlike auto {
        return groov::make_path<Field::value>();
    }
    template <typename Field>
    consteval static auto get_register() -> groov::pathlike auto {
        return groov::parent(get_field<Field>());
    }

    template <groov::pathlike Register>
    using register_datatype_t =
        typename decltype(groov::resolve(Group{}, Register{}))::type_t;

    template <groov::pathlike Register, typename Field>
    constexpr static register_datatype_t<Register> mask =
        groov::resolve(Group{}, groov::make_path<Field::value>())
            .template mask<register_datatype_t<Register>>;

    template <groov::pathlike P> static auto write(P p, auto raw) -> void {
        groov::sync_write(Group{}(p = raw));
    }
    template <groov::pathlike P> static auto clear(P p) -> void {
        groov::sync_write(Group{}(p = groov::clear));
    }
    // init, irq_init, read ...
};

// fields in the config are then just strings:
template <stdx::ct_string S> using en_field_t = stdx::cts_t<"enable."_cts + S>;
template <stdx::ct_string S> using st_field_t = stdx::cts_t<"status."_cts + S>;

interrupt::manager

declaration include/interrupt/manager.hpp
template <interrupt::root_config Config, typename Hal, typename... Nexi>
using manager = /* detail::manager<dynamic_controller<Config, Hal>, built IRQs...> */;

Each Nexus in Nexi must provide Nexus::service<Flow>() and Nexus::service_v<Flow>.active for every flow in the config. cib::nexus<Project> provides both, and so can any type you write (see §8.2).

init(): what happens at boot

  1. Hal::init() runs once.
  2. For each top-level node, in declaration order: Hal::irq_init<active, Number, Priority>(). Inactive nodes are explicitly initialized disabled.
  3. The dynamic controller initializes from the init policy. The default policy enables every IRQ name, enables only the active flows, enables no resources, and treats top-level nodes as already enabled, so their enable fields are recorded in the shadow but not written.

The three steps are also exposed separately, for boot sequences that need to put work between them:

staged boot
irq_manager::init_mcu();          // Hal::init()
irq_manager::init_top_level();    // Hal::irq_init per top-level node
board::configure_pin_mux();       // e.g. must happen before peripheral enables
irq_manager::init_dynamic();      // default dynamic policy, same as init()

// or pick a policy for the dynamic step:
irq_manager::init<interrupt::dynamic_init::default_policy>();

run<N>(): what a vector calls

run takes the IRQ number as a template argument, so the dispatch is resolved at compile time:

  1. Look up the top-level node whose number is N. If none matches, the call is an empty function.
  2. If the node is inactive, the call is an empty function.
  3. Read the node’s enable field inside conc::call_in_critical_section<dynamic_t::mutex_t>, the same lock the dynamic controller uses. A missing field reads as set.
  4. Read the status field. A missing field reads as set, and top-level nodes never have one.
  5. If both are set, call the status policy with clear status and the body.
  6. For a leaf, the body is: for each flow in order, Nexus::service<Flow>() for each nexus in order. For a container, it is each child’s run(), repeating steps 2–6 one level down.
board/isr.cpp
#include <board/irq_config.hpp>
#include <board/project.hpp>     // nexus_t

using irq_manager = interrupt::manager<irq_config, board_hal, nexus_t>;

extern "C" void TIM2_IRQHandler()  { irq_manager::run<28_irq>(); }
extern "C" void UART0_IRQHandler() { irq_manager::run<37_irq>(); }
extern "C" void GPIO_IRQHandler()  { irq_manager::run<40_irq>(); }

The lock covers only the enable read, not the flows. A flow running under run() can therefore call the dynamic controller without deadlocking, for example to switch off its own interrupt source.

Other members

MemberWhat it gives you
max_irq()constexpr: the largest top-level IRQ number. Use it to size vector tables (§9.6).
config()constexpr stdx::ct_string that renders the configuration, e.g. interrupt::root<interrupt::irq<"a", 17_irq, 42, interrupt::no_field_t, interrupt::policies<>, flow_1>>. Good for golden tests and build logs.
dynamic_tThe dynamic_controller<Config, Hal> type.
hal_tThe HAL type.
default_init_policy_tThe dynamic init policy that init() uses by default.

interrupt::dynamic_controller

declaration include/interrupt/dynamic_controller.hpp
template <typename Root, detail::dynamic_hal_for<Root> Hal,
          typename EnablePolicy = interrupt::dynamic_enable_policy>
struct dynamic_controller;   // all members static

The enable model

The controller keeps three bitsets (stdx::type_bitset), each with one bit per distinct type in the config, plus one shadow value per enable register:

  • names: one bit per IRQ name ("uart0_tx")
  • flows: one bit per flow type named directly on a node
  • resources: one bit per resource type

After any change, the default dynamic_enable_policy decides each affected node’s enable bit:

dynamic_enable_policy::is_on, paraphrased
bool is_on(node) {
    if (not names[node.name])                    return false;  // named off wins
    if (any_of(node.resources, is_off))          return false;  // needs ALL its resources
    return any_of(node.flows, is_on_flow)                       // ANY own flow on
        or any_of(node.children, is_on);                        // or ANY child on
}

A container like shared_irq has no flows of its own, so it is on exactly when its name is on, its own resources are on, and at least one child is on.

Enable & disable

CallEffect
enable<Ts...>(policies...)
disable<Ts...>(policies...)
Ts can be any mix of flow types and resource types. The call sets or clears their bits and re-evaluates the nodes that name them.
enable<"name"...>(policies...)
disable<"name"...>(policies...)
Sets or clears named enables and re-evaluates the nodes with those names.
init<Policy, AlreadyEnabled...>()Sets the starting bitsets from Policy and writes every enable field (§7.5).
reset()Clears all three bitsets and force-writes every enable field clear. Takes no lock; meant for tests.
refresh_top_level_enables()
refresh_all_enables()
Re-evaluates and force-writes enable fields (§7.6).
hal_t, mutex_tThe HAL, and the tag type used for conc critical sections.
everyday calls against the running example
irq_dynamic::enable<uart0_clock>();                 // rx, tx, err come up (one write: UART0_IER)
irq_dynamic::disable<"uart0_tx">();                 // only TXIE clears
irq_dynamic::disable<uart0_rx, uart0_error>();      // two flows at once, still one write
irq_dynamic::disable<uart0_clock, "pa3_button">();   // ✗ can't mix types and names in one call

irq_dynamic::disable<accel_data_ready>(interrupt::propagate);        // §7.4
irq_dynamic::disable<maybe_absent_flow>(interrupt::ignore_unknowns); // no compile error

By default, naming a type or string that isn’t in the config is a compile error. The diagnostic reads Can't disable flow (…) not in config!, and it says “flow” even when you passed a resource or a name. Pass interrupt::ignore_unknowns to make such calls do nothing instead, which helps in generic code shared across product configs.

Enable-register bench

This bench simulates the controller on a three-node config that mirrors the library’s tests. A shared_sub_irq named "sensors" needs sensor_rail and has two children, each with its own flow and resource. All enable bits live in one register. Toggle bits to see which calls write and which don’t.

SENSOR_IER · simulated

Mirrors dynamic_controller semantics: shadowed register, write-if-changed, re-evaluation limited to affected nodes.

Flows
Resources
IRQ names
Replay a test case
value 0x0Hal::write calls 0
nodeis_on nowregister bit
Calls, newest first

    Propagation

    When you toggle a flow or resource, the controller re-evaluates only the nodes that directly name it. A parent whose children all just turned off keeps its enable bit. Usually that’s harmless: children with clear enable bits never run, and the parent’s bit costs nothing.

    Pass interrupt::propagate to also re-evaluate every ancestor that has the flow or resource anywhere beneath it. Ancestors then open and close with their children, which matters when the parent enable is what gates a wake source or a shared line’s pending state.

    from test/interrupt/dynamic_controller.cpp
    // "shared0" (EN0) → "sub1" (EN1, test_flow_1_t), "sub2" (EN2, test_flow_2_t)
    shared_sub_dynamic_t::init();                                         // 0b111
    
    shared_sub_dynamic_t::disable<test_flow_1_t>();                       // 0b101
    shared_sub_dynamic_t::disable<test_flow_2_t>(interrupt::propagate);   // 0b000: parent closes
    
    shared_sub_dynamic_t::enable<test_flow_1_t>(interrupt::propagate);    // 0b011: parent reopens

    Named enables never propagate. disable<"porta">() clears only porta’s own bit. Its children’s bits stay set, but they never run, because porta’s enable read fails first.

    Init policies

    An init policy is a struct that picks the starting resources and flows. Assemble one from the building blocks in interrupt::dynamic_init:

    Building blockStarts on
    all_resources_policyevery resource in the config
    resources_policy<Rs...> / no_resources_policyjust Rs... / none
    all_flows_policyevery flow named in the config, whether or not it’s extended
    flows_policy<Fs...> / no_flows_policyjust Fs... / none
    all_irqs_policyincluded in both stock policies. Names always start on, whatever you choose here.
    dynamic_enable_top_levelconstexpr static bool read by manager::init. When true, top-level enable fields are written too, not assumed already set.

    There are two stock policies. dynamic_init::default_policy (all resources, all flows) is the default for dynamic_controller::init(). The manager’s own default (active flows only, no resources) is described in §6.1.

    custom boot policy
    // Start the UART live, keep everything else resource-gated.
    struct boot_policy : interrupt::dynamic_init::resources_policy<uart0_clock>,
                         interrupt::dynamic_init::flows_policy<uart0_rx, uart0_error,
                                                               button_pressed>,
                         interrupt::dynamic_init::all_irqs_policy {
        constexpr static auto dynamic_enable_top_level = false;
    };
    
    irq_manager::init<boot_policy>();

    Reset & refresh

    The controller never reads enable registers back. Updates are computed from the shadow as (shadow & ~mask) | new_bits, and written only when the value changes. When the hardware loses state behind its back, for example when a power domain drops during deep sleep, re-apply the current state:

    after wake from a state-losing sleep
    irq_dynamic::refresh_top_level_enables();  // only top-level nodes that have enable fields
    irq_dynamic::refresh_all_enables();        // every node with an enable field

    Both functions re-evaluate is_on against the current bitsets and write unconditionally. reset() is harsher: it clears every name, flow, and resource bit and writes all enable fields clear. Tests use it to return the static state to a known baseline between cases.

    Writes & locking

    • Batched by register. One call touching several fields in one register makes at most one Hal::write for that register. The tests assert exactly one write for disable<test_resource_0, test_resource_1, test_resource_2>().
    • Write-if-changed. A call that leaves every affected bit unchanged makes no write.
    • Locked. enable, disable, init, and both refreshes run inside conc::call_in_critical_section<mutex_t>. So does manager::run()’s enable-field read. Status reads, status clears, and flows run outside the lock.
    • Small footprint. RAM use is three bitsets sized by the number of distinct names, flows, and resources, plus one register_datatype_t per enable register.

    Working with the rest of cib

    cib_interrupt links only against stdx, Boost.MP11, and the baremetal concurrency library. Its connection to flow and nexus is structural: it asks a nexus type for service<Flow>() and service_v<Flow>.active, and anything that answers those works.

    LibraryCouplingWhat passes between them
    flowshape IRQ payloads are flow::service types. A flow’s compile-time active flag decides whether an IRQ is enabled and compiled in. The dynamic controller treats flow types as enable keys.
    nexusshape cib::nexus<Project> is the usual Nexi argument. run() calls Nexus::service<Flow>(), and several nexi can be passed at once.
    cib::topshape RuntimeStart is the documented point for enabling interrupts. Call manager::init() from an action there.
    concurrency (conc)link Every enable-state change and every enable-field read in run() goes through conc::call_in_critical_section<dynamic_t::mutex_t>. Bare-metal targets inject a policy.
    logvia flow Named flows log start, end, and each action, all inside ISR context.
    stdxlink ct_string IRQ names, type_bitset enable state, ct_format for config(), STATIC_ASSERT diagnostics.
    groovtests Register access in the library’s test HAL. It’s an example, not a requirement.

    flow: what “active” means

    A built flow reports active when at least one action was added to it across all components. The interrupt library uses this in three places:

    • Compile-out. A leaf node with no active flow has an empty run(). A container with no active descendant is empty too.
    • Boot enable. irq_init<active, N, P> leaves an inactive top-level line disabled at the controller.
    • Dynamic defaults. The manager’s default init policy enables only active flows, so enable bits for unextended flows stay clear.

    Flows run inside the vector, so write their actions as ISR code. Actions can still use every flow feature, including ordering, milestones, and conditionals:

    drivers/uart0.hpp
    struct uart0_driver {
        constexpr static auto DRAIN_RX = flow::action<"uart0_drain_rx">([] {
            while (uart0::rx_ready()) { rx_ring.push(uart0::read_byte()); }
        });
        constexpr static auto WAKE_CONSOLE = flow::action<"uart0_wake_console">([] {
            console_task.notify();
        });
    
        constexpr static auto config = cib::config(
            cib::extend<uart0_rx>(*DRAIN_RX >> *WAKE_CONSOLE));
    };

    nexus: one, several, or your own

    Pass one or more nexus types after the HAL. A node is active if its flow is active in any of them. When the node fires, flows run in declaration order, and each flow runs in every nexus in argument order. Every nexus must be able to provide every flow in the config.

    two nexi
    using irq_manager =
        interrupt::manager<irq_config, board_hal, cib::nexus<platform>, cib::nexus<application>>;

    Because the coupling is structural, a plain type can stand in for a nexus. That helps when a codebase isn’t built on cib components yet, and in unit tests:

    a hand-written nexus (same shape as test_nexus)
    template <auto Fn> struct fn_service {
        constexpr static bool active = true;
        auto operator()() const -> void { Fn(); }
    };
    
    // each "flow" type carries its handler
    struct adc0_done { constexpr static auto handler = +[] { adc::latch_sample(); }; };
    
    struct legacy_handlers {
        template <typename Flow>
        constexpr static auto service_v = fn_service<Flow::handler>{};
    
        template <typename Flow>
        constexpr static auto service() { return service_v<Flow>(); }
    };
    
    using irq_manager = interrupt::manager<irq_config, board_hal, legacy_handlers>;

    cib::top: enabling interrupts at RuntimeStart

    cib::top runs EarlyRuntimeInit, then RuntimeInit, then RuntimeStart, then MainLoop forever. Its own comments name RuntimeStart as the place to start enabling interrupts. The manager’s type depends on the project’s nexus, so declare a name-only action in the component and define it in the translation unit that owns the manager. That avoids a circular include. Declare the cib_func specialization in the component header: declaring the manager instantiates the nexus, and a specialization first seen after that fails with “explicit specialization after instantiation”.

    board/interrupts_component.hpp
    #include <cib/top.hpp>
    #include <nexus/func_decl.hpp>
    
    // declared here, before any TU can instantiate the nexus; defined in isr.cpp
    template <> auto cib_func<"enable_interrupts">() -> void;
    
    struct interrupts_component {
        // name-only action: its body is supplied with cib_func<"enable_interrupts">
        constexpr static auto ENABLE = flow::action<"enable_interrupts">();
    
        constexpr static auto config =
            cib::config(cib::extend<cib::RuntimeStart>(*ENABLE));
    };
    board/isr.cpp
    #include <board/irq_config.hpp>
    #include <board/project.hpp>
    #include <nexus/func_decl.hpp>
    
    using irq_manager = interrupt::manager<irq_config, board_hal, cib::nexus<project>>;
    
    template <> auto cib_func<"enable_interrupts">() -> void {
        irq_manager::init();
        irq_dynamic::enable<uart0_clock>();   // clock was ungated during RuntimeInit
    }
    
    extern "C" void UART0_IRQHandler() { irq_manager::run<37_irq>(); }
    // ...

    Components that only need to switch interrupts at runtime include board/irq_config.hpp and use irq_dynamic. They never see the nexus, so they create no include cycle.

    concurrency: critical sections on bare metal

    On hosted builds, conc defaults to a std::mutex per tag type, so tests with threads work unchanged. On a freestanding target with no default policy, compilation fails until you inject one. manager::run() takes the lock inside an ISR, so the policy must restore the previous interrupt mask instead of unconditionally re-enabling interrupts:

    board/concurrency.hpp (Cortex-M)
    #include <conc/concurrency.hpp>
    
    struct [[nodiscard]] primask_lock {
        primask_lock() : saved{__get_PRIMASK()} { __disable_irq(); }
        ~primask_lock() { __set_PRIMASK(saved); }   // restore, don't blindly enable
        std::uint32_t saved;
    };
    
    struct cortex_m_policy {
        // the first template argument identifies the "mutex"; one global switch here
        template <typename = void, std::invocable F, std::predicate... Pred>
        static auto call_in_critical_section(F &&f, Pred &&...pred)
            -> decltype(std::forward<F>(f)()) {
            while (true) {
                [[maybe_unused]] primask_lock lock{};
                if ((... and pred())) { return std::forward<F>(f)(); }
            }
        }
    };
    
    template <> inline auto conc::injected_policy<> = cortex_m_policy{};

    log: keep ISR flows quiet

    A flow::service given a name logs its start, its end, and every action it runs, and here all of that happens inside the ISR. For high-rate interrupts, declare the flow unnamed or with the none log policy:

    board/flows.hpp
    struct tim2_tick   : flow::service<> {};                                   // silent
    struct uart0_rx    : flow::service<"uart0_rx", flow::log_policies::none> {}; // named, silent
    struct uart0_error : flow::service<"uart0_error"> {};                     // rare: log it

    Use cases

    TX-empty interrupt only while sending

    A UART’s TX-empty status is set whenever the transmitter is idle. If it stays enabled with nothing queued, the vector fires continuously. Keep it off by name and open it only while bytes are waiting:

    drivers/uart0_tx.hpp
    inline auto uart0_start() -> void {
        irq_dynamic::disable<"uart0_tx">();   // nothing to send yet
        irq_dynamic::enable<uart0_clock>();   // rx + err come up; tx stays named-off
    }
    
    inline auto uart0_send(std::span<std::uint8_t const> bytes) -> void {
        tx_ring.push(bytes);
        irq_dynamic::enable<"uart0_tx">();    // no write if it's already on
    }
    
    struct uart0_tx_component {
        constexpr static auto FEED = flow::action<"uart0_feed_tx">([] {
            uart0::write_byte(tx_ring.pop());
            if (tx_ring.empty()) {
                irq_dynamic::disable<"uart0_tx">();   // safe inside run(): lock not held
            }
        });
        constexpr static auto config = cib::config(cib::extend<uart0_tx>(*FEED));
    };

    Clock gating and power domains

    Model each clock or rail as a resource and toggle it in the same flow action that gates the hardware. Every interrupt depending on it follows automatically, with one write per affected register:

    power/uart0_power.hpp
    struct uart0_power {
        constexpr static auto GATE = flow::action<"gate_uart0">([] {
            irq_dynamic::disable<uart0_clock>();   // close enables before the clock stops
            clocks::gate(clock_id::uart0);
        });
        constexpr static auto UNGATE = flow::action<"ungate_uart0">([] {
            clocks::ungate(clock_id::uart0);
            irq_dynamic::enable<uart0_clock>();    // reopen once registers are live
        });
    
        constexpr static auto config = cib::config(
            cib::extend<enter_low_power>(*GATE),
            cib::extend<exit_low_power>(*UNGATE));
    };
    
    // If the domain loses register contents, write the shadowed enables back:
    struct wake_restore {
        constexpr static auto RESTORE = flow::action<"restore_irq_enables">(
            [] { irq_dynamic::refresh_all_enables(); });
        constexpr static auto config = cib::config(cib::extend<exit_deep_sleep>(*RESTORE));
    };

    One board config, many product variants

    Keep one irq_config per silicon and let each product’s component list decide which interrupts exist. There’s no #ifdef, and nothing unused ships:

    products/*.hpp
    struct pedometer_product {
        constexpr static auto config = cib::config(
            cib::exports<tim2_tick, uart0_rx, uart0_tx, uart0_error,
                         button_pressed, accel_data_ready>,
            cib::components<uptime, uart0_driver, button, accelerometer>);
    };
    
    struct keyfob_product {
        constexpr static auto config = cib::config(
            cib::exports<tim2_tick, uart0_rx, uart0_tx, uart0_error,
                         button_pressed, accel_data_ready>,
            cib::components<uptime, button>);   // no UART, no accelerometer
    };
    // keyfob: IRQ 37 is irq_init<false>, UART0_IRQHandler compiles empty,
    // and pa7_accel's run() is gone. The "gpio" vector serves only pa3_button.

    Mixed status semantics in one tree

    Real peripherals mix clearing rules. Set a policy per node to match the datasheet:

    • "porta" uses dont_clear_status: its summary bit is the hardware OR of pin bits and clears when they do.
    • "pa3_button" keeps the default clear_status_first: it’s edge-latched, so a bounce during the handler re-latches instead of vanishing.
    • "pa7_accel" uses clear_status_last: the sensor holds its line until its FIFO is read, so clear only after the flow drains it.

    Diagnostics and fault isolation

    Named enables let a fault handler or debug console silence one misbehaving source without touching the rest:

    diag/irq_storm_guard.hpp
    struct storm_guard {
        constexpr static auto COUNT = flow::action<"accel_storm_count">([] {
            if (++accel_hits_this_tick > 200) {
                irq_dynamic::disable<"pa7_accel">();
                fault_log::record(fault::irq_storm_pa7);
            }
        });
        constexpr static auto config =
            cib::config(cib::extend<accel_data_ready>(*COUNT));
    };
    
    // console command: re-arm it
    auto cmd_irq_on_pa7() -> void { irq_dynamic::enable<"pa7_accel">(); }

    Generating a RAM vector table

    Because run<N>() is empty for unconfigured numbers, you can instantiate it for every IRQ up to max_irq():

    board/vectors.cpp
    constexpr auto irq_count = stdx::to_underlying(irq_manager::max_irq()) + 1;
    
    template <std::size_t N>
    auto dispatch() -> void { irq_manager::run<static_cast<interrupt::irq_num_t>(N)>(); }
    
    template <std::size_t... Ns>
    constexpr auto make_table(std::index_sequence<Ns...>) {
        return std::array<void (*)(), sizeof...(Ns)>{&dispatch<Ns>...};
    }
    
    // external IRQs only: place after your core's system exception entries
    constinit auto irq_vectors = make_table(std::make_index_sequence<irq_count>{});

    Host-side unit tests

    The library’s own test suite shows the pattern: a recording HAL, std::thread for concurrency checks with the default conc policy, and reset() between cases.

    test/irq_config_test.cpp
    TEST_CASE("uart comes up only after its clock") {
        irq_dynamic::reset();
        fake_registers::clear();
    
        irq_manager::init();
        CHECK((fake_registers::get<UART0_IER>() & UART0_RXIE::mask) == 0);
    
        irq_dynamic::enable<uart0_clock>();
        CHECK((fake_registers::get<UART0_IER>() & UART0_RXIE::mask) != 0);
    }
    
    TEST_CASE("config renders as expected") {
        using namespace stdx::literals;
        STATIC_CHECK(irq_manager::config() == R"(interrupt::root<interrupt::irq<"tim2", 28_irq, ...)"_cts);
    }
    
    TEST_CASE("run and disable race safely") {
        auto t1 = std::thread([] { irq_dynamic::disable<"uart0_rx">(); });
        auto t2 = std::thread([] { irq_manager::run<37_irq>(); });
        t1.join(); t2.join();
    }

    Pitfalls

    SymptomCauseFix
    “no template named irq<cib/cib.hpp> includes the manager but not the config templates.#include <interrupt/config.hpp>
    Resource-gated sources never fireThe manager’s default init enables no resources.Call enable<Resource>(), or init with a policy (§7.5).
    A disabled flow still runsEnables are per source. Another enabled flow keeps the shared bit set, and run() runs every flow on that node.Give the flows separate nodes, or disable by name.
    Parent enable stays set after all children are offWithout propagate, only direct owners are re-evaluated.Pass interrupt::propagate.
    A top-level peripheral enable is never setBy default, manager::init() assumes top-level enable fields are already set.Set them in Hal::init(), or use dynamic_enable_top_level = true.
    An enable bit you set by hand revertsThe controller writes from its shadow and never reads the register back.Change enable registers only through the controller.
    Toggling one IRQ also toggles anotherBoth have the same name, and names are the keys of the enable bitset.Keep names unique.
    A vector fires endlessly after bootAn init policy enabled a flow with no actions, so run() never clears status.Enable only active flows (the manager’s default).
    Compile error: “Can’t disable flow (X) not in config!”X isn’t a flow, resource, or name in this config.Fix the type, or pass interrupt::ignore_unknowns.
    Freestanding build fails in concNo default critical-section policy exists without <mutex>.Specialize conc::injected_policy<> (§8.4).

    Notes on interrupts.adoc

    docs/interrupts.adoc predates several API changes. This handbook follows the current headers and tests. Where they differ:

    interrupts.adoc saysCurrent code
    irq<17_irq, 4, EN, policies<>, flow>Every node takes a name first: irq<"name", 17_irq, 4, EN, policies<>, flow>
    manager<config, nexus_t>manager<config, Hal, Nexi...>
    template <> inline auto interrupt::injected_hal<> = my_hal{};No injection: the HAL is a template argument.
    HAL provides run<P>(irq, isr)The library applies status policies itself. The HAL provides get_field / read / clear, and the register members for dynamic control (§5).
    dynamic_controller<config>dynamic_controller<config, Hal>
    turn_off_resource<R>() / turn_on_resource<R>()disable<R>() / enable<R>(). The same calls take flows and names.
    A resource being off stops flows from runningIt clears the enable field. run() skips the node because it reads that field.