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.
interrupt::root<…> type tree: IRQ numbers, priorities, enable and status fields, policies, and the flows each source runs.init() programs the interrupt controller. run<N>() is what a vector calls: it checks fields, clears status, and runs flows.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 itsrun()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.
#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.
| Template | Position | Parameters | Children |
|---|---|---|---|
irq | top level | Name, Number, Priority, EnableField, Policies, Flows... | flows |
shared_irq | top level | Name, Number, Priority, EnableField, Policies, SubIrqs... | sub_irq / shared_sub_irq |
sub_irq | under a shared node | Name, EnableField, StatusField, Policies, Flows... | flows |
shared_sub_irq | under a shared node | Name, EnableField, StatusField, Policies, SubIrqs... | sub_irq / shared_sub_irq |
root | the whole tree | TopLevelIrqs... | 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
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.
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.
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"→ tim2_tick
- shared_irq"uart0"
- sub_irq"uart0_rx"needs uart0_clock→ uart0_rx
- sub_irq"uart0_tx"needs uart0_clock→ uart0_tx
- sub_irq"uart0_err"needs uart0_clock→ uart0_error
- shared_irq"gpio"
- shared_sub_irq"porta"
- sub_irq"pa3_button"→ button_pressed
- sub_irq"pa7_accel"→ 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:
#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.
| Policy | Order | Reach for it when |
|---|---|---|
clear_status_first default | clear → run | Edge-style status bits. Clearing first means a new event during the handler sets the bit again, so it isn’t lost. |
clear_status_last | run → clear | Level-style sources, where the condition has to be serviced (a FIFO drained, a line released) before the bit will stay clear. |
dont_clear_status | run | Read-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:
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.
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.
| Member | Called from | Required when |
|---|---|---|
static void init() | manager::init(), first | always |
template <bool Enable, irq_num_t N, std::size_t Priority>static void irq_init() | manager::init(), once per top-level node | always |
template <typename Field>static auto get_field() | run(); the result goes to read / clear | any enable or status field |
static bool read(field) | run(), for enable then status | any enable or status field |
static void clear(field) | run(), through the status policy | any status field |
template <typename Field>static auto get_register() | dynamic controller | any 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 controller | any enable field |
static void write(reg, value) | dynamic controller | any enable field |
A memory-mapped HAL
This HAL uses self-describing field types and needs no register library. The addresses are illustrative.
#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. #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:
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
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
Hal::init()runs once.- For each top-level node, in declaration order:
Hal::irq_init<active, Number, Priority>(). Inactive nodes are explicitly initialized disabled. - 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:
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:
- Look up the top-level node whose number is
N. If none matches, the call is an empty function. - If the node is inactive, the call is an empty function.
- 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. - Read the status field. A missing field reads as set, and top-level nodes never have one.
- If both are set, call the status policy with clear status and the body.
- 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’srun(), repeating steps 2–6 one level down.
#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
| Member | What 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_t | The dynamic_controller<Config, Hal> type. |
hal_t | The HAL type. |
default_init_policy_t | The dynamic init policy that init() uses by default. |
interrupt::dynamic_controller
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:
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
| Call | Effect |
|---|---|
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_t | The HAL, and the tag type used for conc critical sections. |
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.
| node | is_on now | register bit |
|---|
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.
// "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 block | Starts on |
|---|---|
all_resources_policy | every resource in the config |
resources_policy<Rs...> / no_resources_policy | just Rs... / none |
all_flows_policy | every flow named in the config, whether or not it’s extended |
flows_policy<Fs...> / no_flows_policy | just Fs... / none |
all_irqs_policy | included in both stock policies. Names always start on, whatever you choose here. |
dynamic_enable_top_level | constexpr 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.
// 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:
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::writefor that register. The tests assert exactly one write fordisable<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 insideconc::call_in_critical_section<mutex_t>. So doesmanager::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_tper 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.
| Library | Coupling | What passes between them |
|---|---|---|
| flow | shape | 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. |
| nexus | shape | cib::nexus<Project> is the usual Nexi argument. run() calls Nexus::service<Flow>(), and several nexi can be passed at once. |
| cib::top | shape | 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. |
| log | via flow | Named flows log start, end, and each action, all inside ISR context. |
| stdx | link | ct_string IRQ names, type_bitset enable state, ct_format for config(), STATIC_ASSERT diagnostics. |
| groov | tests | 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:
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.
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:
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”.
#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));
}; #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:
#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:
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:
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:
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:
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"usesdont_clear_status: its summary bit is the hardware OR of pin bits and clears when they do."pa3_button"keeps the defaultclear_status_first: it’s edge-latched, so a bounce during the handler re-latches instead of vanishing."pa7_accel"usesclear_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:
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():
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_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
| Symptom | Cause | Fix |
|---|---|---|
“no template named irq” | <cib/cib.hpp> includes the manager but not the config templates. | #include <interrupt/config.hpp> |
| Resource-gated sources never fire | The manager’s default init enables no resources. | Call enable<Resource>(), or init with a policy (§7.5). |
| A disabled flow still runs | Enables 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 off | Without propagate, only direct owners are re-evaluated. | Pass interrupt::propagate. |
| A top-level peripheral enable is never set | By 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 reverts | The controller writes from its shadow and never reads the register back. | Change enable registers only through the controller. |
| Toggling one IRQ also toggles another | Both have the same name, and names are the keys of the enable bitset. | Keep names unique. |
| A vector fires endlessly after boot | An 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 conc | No 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 says | Current 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 running | It clears the enable field. run() skips the node because it reads that field. |