What This Chapter Teaches

Every previous chapter focused on making the UART work correctly under normal conditions. This chapter shifts perspective: what happens when the hardware itself fails? A cosmic ray flips a register. A voltage glitch corrupts a state machine. A timing violation writes garbage into a FIFO. In safety-critical systems — automotive, aerospace, medical — you must not only detect these faults but also prove that your design handles them.

Traditional hardware design languages have no opinion about safety. You write the safety logic by hand, document it in a spreadsheet, and manually cross-reference the two whenever the design changes. The spreadsheet drifts. The logic evolves. The mapping between them breaks silently.

skalp takes a different approach. Safety properties are annotations in the source code, attached directly to the entities and signals they describe. The compiler reads these annotations, verifies consistency, and can generate FMEDA (Failure Modes, Effects, and Diagnostic Analysis) data automatically. When you change the design, the safety annotations change with it. There is no external spreadsheet to maintain.

This chapter covers two categories of annotations:

Safety annotations that feed into fault analysis:

  • #[safety_mechanism] — marks an entity as a safety mechanism with type metadata (diagnostic coverage is calculated via fault injection)
  • #[detection_signal] — marks an output that detects faults, telling the fault injection system what to observe
  • #[retention] — marks state that must survive a power-down of its domain, checked against the declared supply tree

Debug annotations that improve simulation and waveform analysis:

  • #[trace] — groups signals for waveform visualization with display names and radix control
  • #[breakpoint] — halts simulation when a condition occurs, with a named error message

All five annotations have zero synthesis cost. The compiler strips them entirely when generating SystemVerilog for synthesis. They exist only in the skalp source and in the compiler’s analysis passes.

The chapter closes with a third topic that builds on #[safety_mechanism]: power-domain declarations and the dependent-failure check — how the compiler verifies that a safety mechanism does not share a supply with the logic it monitors, and how the declared supply tree is exported as IEEE 1801 UPF.

By the end of this chapter you will understand:

  • How Triple Modular Redundancy (TMR) works and why it is the standard pattern for single-fault masking
  • How #[safety_mechanism] declares what kind of protection an entity provides
  • How #[detection_signal] connects fault injection to fault observation
  • How #[retention] declares state that survives a power-down, and what the compiler checks about it
  • How #[trace] organizes waveform signals into groups that travel with the source code
  • How #[breakpoint] creates named simulation stop conditions with error messages
  • How power-domain declarations and the dependent-failure check keep a safety mechanism supply-independent from the logic it monitors
  • How all of these compose in a real design — the running UART project

The running project adds TMR protection to the UART FSM, parity generation and checking, frame error detection, overrun detection, and a full set of debug traces with breakpoints for critical error conditions.


Standalone Example: TMR Counter

Triple Modular Redundancy is the workhorse of safety-critical hardware. The idea is simple: run three copies of the same logic, compare their outputs with a majority voter, and flag any disagreement. A single fault in any one copy is masked by the other two. The voter always produces the correct output as long as at most one copy is corrupted.

Create a file called src/tmr_counter.sk:

// A TMR-protected counter with majority voting and error detection.
//
// Three independent counter instances run in parallel. A majority
// voter selects the correct output. Any disagreement between the
// three copies raises tmr_error for one cycle.
//
// The #[safety_mechanism] annotation tells the compiler that this
// entity exists to protect against faults. The type field feeds
// into automated FMEDA generation. Diagnostic coverage is
// calculated automatically via fault injection — not specified here.

#[safety_mechanism(type = tmr)]
entity TmrCounter<const WIDTH: nat = 8> {
    in clk: clock,
    in rst: reset,
    in enable: bit,
    out count: nat[WIDTH],

    #[detection_signal]
    out tmr_error: bit
}

impl TmrCounter {
    // Three independent counter registers.
    // In synthesis, these become three separate flip-flop chains.
    // The compiler does not optimize them into one — the
    // #[safety_mechanism] annotation prevents merging.
    signal count_a: nat[WIDTH]
    signal count_b: nat[WIDTH]
    signal count_c: nat[WIDTH]

    // All three counters run identical logic.
    // In a real TMR implementation, you might also place these
    // in separate clock regions or voltage domains for spatial
    // diversity, but the logic-level redundancy starts here.
    on(clk.rise) {
        if rst {
            count_a = 0
            count_b = 0
            count_c = 0
        } else if enable {
            count_a = count_a + 1
            count_b = count_b + 1
            count_c = count_c + 1
        }
    }

    // Majority voter: selects the value agreed upon by at least
    // two of the three copies.
    //
    // The match expression covers all four cases of pairwise
    // agreement. In the (false, false) case, a and c must agree
    // (since a != b and b != c, the only 2-of-3 majority left
    // is a and c).
    count = match (count_a == count_b, count_b == count_c) {
        (true, true)   => count_a,  // all three agree
        (true, false)  => count_a,  // a and b agree, c diverged
        (false, true)  => count_b,  // b and c agree, a diverged
        (false, false) => count_a   // a and c agree, b diverged
    }

    // Error detection: any disagreement among the three copies
    // means a fault has occurred. This signal does not affect the
    // output — the voter already masked the fault — but it tells
    // the system that a fault was detected and should be logged
    // or acted upon.
    //
    // The #[detection_signal] annotation on the port declaration
    // tells the fault injection framework which output to observe
    // when injecting faults into this entity. Without it, the
    // framework would not know which signal indicates "fault detected."
    tmr_error = (count_a != count_b) | (count_b != count_c)
}

How the Annotations Work

#[safety_mechanism(type = tmr)] is attached to the entity declaration. It tells the compiler two things:

  1. This entity is a safety mechanism — it exists to protect against hardware faults.
  2. The protection type is TMR — triple modular redundancy with majority voting.

You do not specify diagnostic coverage in the annotation. Instead, the compiler calculates it automatically using fault injection (FI). When you run skalp safety, the tool converts the design to a gate-level netlist, injects faults into the protected logic, and observes whether the #[detection_signal] outputs detect them. The measured detection rate is the diagnostic coverage. The same run walks the design hierarchy, finds every entity with #[safety_mechanism], incorporates the FI-derived coverage, and builds a fault classification table. Entities without safety annotations are classified as unprotected. The coverage numbers feed into the overall safety metrics required by standards like ISO 26262 (automotive) and IEC 61508 (industrial).

#[detection_signal] is attached to the tmr_error output port. It tells the fault injection framework: “when you inject a fault into this entity, check this output to determine whether the fault was detected.” Without this annotation, the framework would inject faults but have no way to measure detection coverage automatically. You could still observe any signal manually, but automation requires knowing which signals are detection outputs.

Adding Debug Annotations

Now let us add debug infrastructure to the same counter. These annotations do not affect safety analysis — they are purely for simulation and waveform viewing.

impl TmrCounter {
    // ... (counter logic from above)

    // Trace annotations group signals for waveform display.
    // When you open the exported waveform in the skalp VS Code extension,
    // signals with the same group appear together.

    #[trace(group = "tmr_internals", display_name = "Copy A")]
    signal count_a: nat[WIDTH]

    #[trace(group = "tmr_internals", display_name = "Copy B")]
    signal count_b: nat[WIDTH]

    #[trace(group = "tmr_internals", display_name = "Copy C")]
    signal count_c: nat[WIDTH]

    #[trace(group = "tmr_voted", display_name = "Voted Output", radix = hex)]
    signal voted_count: nat[WIDTH]

    // Breakpoint: halt simulation if a TMR error is detected.
    // The is_error flag tells the simulator to treat this as a
    // failure, not just a stop condition.
    #[breakpoint(is_error = true, name = "TMR_FAULT", message = "TMR voter detected disagreement among counter copies")]
    signal tmr_fault_trigger: bit
    tmr_fault_trigger = tmr_error
}

#[trace(group = "tmr_internals", display_name = "Copy A")] controls how this signal appears in the waveform viewer. The group field organizes related signals into a named folder. The display_name field overrides the signal’s HDL name with a human-readable label. The optional radix field controls the display format: hex, bin, dec, or unsigned.

The annotation is carried all the way into the exported waveform, so the presentation is part of the file rather than part of someone’s local setup:

{"name":"acc","width":8,"type":"nat","group":"core",
 "display":{"radix":"hex","displayName":"Accumulator"}}

The key difference from traditional waveform setup: in SystemVerilog, waveform grouping lives in the simulator’s GUI configuration file. Every engineer sets it up manually. When you share a project, each person recreates the signal groups from scratch. In skalp, the grouping travels with the source code. Clone the repo, run the tests, open the waveform — the groups are already there.

#[breakpoint(is_error = true, name = "TMR_FAULT", message = "...")] creates a named stop condition in the generated SystemVerilog. It becomes an always-block that fires when the signal goes high:

    // Breakpoint: TMR_FAULT
    always @(*) if (tmr_fault_trigger) begin
        $error("BREAKPOINT [TMR_FAULT]: TMR voter detected disagreement among counter copies"); $stop;
    end

is_error selects $error over a plain $display, and the name and message travel into the message text, so a third-party SV simulator halts with a labelled reason instead of an anonymous stop.

One honest limitation: the Rust testbenches you will write in Chapter 10 run SKALP’s own behavioral simulator, which does not evaluate these breakpoints. A #[breakpoint] will not fail a Rust test — assert on the signal directly for that. The breakpoint engine behind skalp debug is driven by the IDE debug protocol rather than by source annotations.

The Retention Annotation

One more annotation that does not appear in the TMR counter but matters for other designs:

#[retention(strategy = shadow, save = pmu_save, restore = pmu_restore)]
signal calibration_value: bit[16]

#[retention] marks state that must survive a power-down of its own domain — held on an always-on retention supply while the main rail drops. Calibration values, configuration loaded once at startup, and accumulated statistics are the classic candidates: state that is expensive to reconstruct and small enough that retaining it is cheaper than recomputing it. strategy is auto (the default), balloon, or shadow; save and restore name the control signals that sequence the save and the wake, and accept a hierarchical path just like switch controls do.

Because retention is defined against the supply tree, the compiler can check it. Three findings, all real output:

Retention in a domain that never powers off preserves nothing, and pays area and leakage to do it:

PDC warning: `Soc.housekeeping` is #[retention]-annotated in power domain `vdd_aon`,
  which never powers off — retention cells cost area and leakage but preserve nothing here

A domain that declares a reduced-voltage state — the classic ret: 0.6V — is promising state retention. If nothing in it is annotated, the state table and the RTL disagree:

PDC warning: power domain `vdd_cpu` declares a reduced-voltage state but no element in it
  is #[retention]-annotated — the state table promises state retention the design does not
  implement

And the save/restore controls sequence the power-down, so they have to outlive it. A control driven from inside the very domain being retained fails the build — it is the same argument as a power switch that gates its own controller:

error: power domain `vdd_cpu`: retention save control `c.local_save` is driven from
  `vdd_cpu`, which is inside the domain being retained — the control dies with the state
  it is meant to preserve

The strategy exports to UPF as set_retention and set_retention_control. Choosing and placing the retention cells themselves is the implementation flow’s job; the compiler states the requirement and checks that it is coherent.

Coming from SystemVerilog?

Safety and debug annotations are the area with the largest gap between skalp and traditional HDLs:

SystemVerilogskalpNotes
No language support#[safety_mechanism(type = tmr)]Safety analysis lives in spreadsheets in SV
No language support#[detection_signal]Fault injection mapping is manual in SV
UPF set_retention in a side file#[retention]Retention intent lives with the register, and is checked against the supply tree
Simulator GUI config#[trace(group = "...")]Waveform grouping travels with source code
$display, $error#[breakpoint(is_error = true)]Named, structured, CI-integrated
$error + $stop in an always block#[breakpoint]Same mechanism, generated from one annotation with a name and message

The biggest shift is conceptual. In SystemVerilog, safety analysis is a separate discipline performed by safety engineers using spreadsheets and documents. The connection between the RTL and the safety analysis is maintained by humans. When the RTL changes, someone must manually update the FMEDA spreadsheet. This process is slow, error-prone, and frequently out of date.

In skalp, safety metadata is part of the source code. When you add TMR to an entity, you annotate it at the same time. When you refactor, the annotations move with the code. When you run skalp safety, the tool reads the annotations directly — no spreadsheet synchronization required. The safety analysis is always consistent with the design because they are the same artifact.

For debug, the shift is similar. SystemVerilog waveform setup is per-engineer, per-tool, and per-session. skalp’s #[trace] annotations are checked into version control. Every engineer who opens the project sees the same signal groups. This eliminates the “how do I set up my waveform viewer” conversation that happens on every project.


Running Project: Safety-Hardened UART

The UART from previous chapters works correctly when all hardware behaves perfectly. Now we add protection against hardware faults and debug infrastructure for development. This touches three areas: TMR on the FSM state registers, parity and framing error detection on the serial data, and trace/breakpoint annotations throughout.

Part 1: TMR on the TX State Machine

The most critical register in the UART transmitter is the FSM state. If a fault flips the state register, the transmitter can enter an illegal state and corrupt the serial output. TMR protects against this by maintaining three copies of the state and voting on every cycle.

Update src/uart_tx.sk to add TMR protection to the state register:

// UART Transmitter with TMR-protected FSM state.
//
// The state register is triplicated. A majority voter determines
// the active state on every cycle. Any disagreement raises
// fsm_error for external monitoring.

#[safety_mechanism(type = tmr)]
entity UartTx<
    const CLK_FREQ_HZ: nat = 50_000_000,
    const BAUD_RATE: nat = 115200,
    const DATA_BITS: nat = 8
> {
    in clk: clock,
    in rst: reset,
    in tx_start: bit,
    in tx_data: bit[DATA_BITS],
    out tx_serial: bit,
    out tx_busy: bit,
    out tx_done: bit,

    #[detection_signal]
    out fsm_error: bit,

    out tx_parity: bit
}

impl UartTx {
    const CYCLES_PER_BIT: nat = CLK_FREQ_HZ / BAUD_RATE
    const COUNTER_WIDTH: nat = clog2(CYCLES_PER_BIT)
    const BIT_INDEX_WIDTH: nat = clog2(DATA_BITS + 2)

    // TMR: three copies of the state register.
    signal state_a: TxState
    signal state_b: TxState
    signal state_c: TxState

    // The voted state — used by all downstream logic.
    signal state: TxState

    // Majority voter for FSM state.
    state = match (state_a == state_b, state_b == state_c) {
        (true, true)   => state_a,
        (true, false)  => state_a,
        (false, true)  => state_b,
        (false, false) => state_a
    }

    // Error detection: any state copy disagrees.
    fsm_error = (state_a != state_b) | (state_b != state_c)

    // Internal signals (not triplicated — TMR protects only the
    // state register, which is the most critical single point of
    // failure in the FSM).
    signal baud_counter: nat[COUNTER_WIDTH]
    signal bit_index: nat[BIT_INDEX_WIDTH]
    signal shift_reg: bit[DATA_BITS]
    signal baud_tick: bit

    baud_tick = (baud_counter == CYCLES_PER_BIT - 1)

    // Parity generation: XOR all data bits.
    // This is a combinational reduction — the compiler generates
    // an XOR tree, not a chain.
    tx_parity = tx_data[0] ^ tx_data[1] ^ tx_data[2] ^ tx_data[3] ^
                tx_data[4] ^ tx_data[5] ^ tx_data[6] ^ tx_data[7]

    // Sequential logic: all three state copies are updated
    // with the same next-state logic.
    on(clk.rise) {
        if rst {
            state_a = TxState::Idle
            state_b = TxState::Idle
            state_c = TxState::Idle
            baud_counter = 0
            bit_index = 0
            shift_reg = 0
        } else {
            // Baud counter — shared across all states.
            if baud_tick {
                baud_counter = 0
            } else if state != TxState::Idle {
                baud_counter = baud_counter + 1
            }

            // Next-state logic — computed once, written to all three copies.
            match state {
                TxState::Idle => {
                    tx_serial = 1
                    if tx_start {
                        shift_reg = tx_data
                        state_a = TxState::Start
                        state_b = TxState::Start
                        state_c = TxState::Start
                        baud_counter = 0
                    }
                },
                TxState::Start => {
                    tx_serial = 0
                    if baud_tick {
                        bit_index = 0
                        state_a = TxState::Data
                        state_b = TxState::Data
                        state_c = TxState::Data
                    }
                },
                TxState::Data => {
                    tx_serial = shift_reg[0]
                    if baud_tick {
                        shift_reg = shift_reg >> 1
                        if bit_index == DATA_BITS - 1 {
                            state_a = TxState::Stop
                            state_b = TxState::Stop
                            state_c = TxState::Stop
                        } else {
                            bit_index = bit_index + 1
                        }
                    }
                },
                TxState::Stop => {
                    tx_serial = 1
                    if baud_tick {
                        state_a = TxState::Idle
                        state_b = TxState::Idle
                        state_c = TxState::Idle
                    }
                }
            }
        }
    }

    // Output signals.
    tx_busy = (state != TxState::Idle)
    tx_done = (state == TxState::Stop) & baud_tick
}

Part 2: RX Error Detection

The receiver needs three types of error detection: parity errors (data corruption), framing errors (stop bit not high), and overrun errors (new data arrives when the FIFO is full).

Update src/uart_rx.sk to add error detection:

// UART Receiver with parity checking, frame error detection,
// and overrun monitoring.

entity UartRx<
    const CLK_FREQ_HZ: nat = 50_000_000,
    const BAUD_RATE: nat = 115200,
    const DATA_BITS: nat = 8
> {
    in clk: clock,
    in rst: reset,
    in rx_serial: bit,
    in expected_parity: bit,
    out rx_data: bit[DATA_BITS],
    out rx_valid: bit,

    #[detection_signal]
    out parity_error: bit,

    #[detection_signal]
    out frame_error: bit
}

impl UartRx {
    const CYCLES_PER_BIT: nat = CLK_FREQ_HZ / BAUD_RATE
    const HALF_BIT: nat = CYCLES_PER_BIT / 2
    const COUNTER_WIDTH: nat = clog2(CYCLES_PER_BIT)
    const BIT_INDEX_WIDTH: nat = clog2(DATA_BITS + 2)

    signal state: RxState
    signal baud_counter: nat[COUNTER_WIDTH]
    signal bit_index: nat[BIT_INDEX_WIDTH]
    signal shift_reg: bit[DATA_BITS]
    signal rx_serial_prev: bit

    // Parity check: XOR all received data bits and compare
    // against expected parity. A mismatch means data corruption.
    signal computed_parity: bit
    computed_parity = shift_reg[0] ^ shift_reg[1] ^ shift_reg[2] ^
                      shift_reg[3] ^ shift_reg[4] ^ shift_reg[5] ^
                      shift_reg[6] ^ shift_reg[7]

    on(clk.rise) {
        if rst {
            state = RxState::Idle
            baud_counter = 0
            bit_index = 0
            shift_reg = 0
            rx_valid = 0
            parity_error = 0
            frame_error = 0
            rx_serial_prev = 1
        } else {
            rx_serial_prev = rx_serial
            rx_valid = 0
            parity_error = 0
            frame_error = 0

            match state {
                RxState::Idle => {
                    if rx_serial_prev & !rx_serial {
                        state = RxState::Start
                        baud_counter = 0
                    }
                },
                RxState::Start => {
                    if baud_counter == HALF_BIT - 1 {
                        if !rx_serial {
                            baud_counter = 0
                            bit_index = 0
                            state = RxState::Data
                        } else {
                            state = RxState::Idle
                        }
                    } else {
                        baud_counter = baud_counter + 1
                    }
                },
                RxState::Data => {
                    if baud_counter == CYCLES_PER_BIT - 1 {
                        baud_counter = 0
                        shift_reg = (rx_serial << (DATA_BITS - 1))
                                  | (shift_reg >> 1)
                        if bit_index == DATA_BITS - 1 {
                            state = RxState::Stop
                        } else {
                            bit_index = bit_index + 1
                        }
                    } else {
                        baud_counter = baud_counter + 1
                    }
                },
                RxState::Stop => {
                    if baud_counter == CYCLES_PER_BIT - 1 {
                        if rx_serial {
                            // Valid stop bit — emit byte.
                            rx_data = shift_reg
                            rx_valid = 1

                            // Check parity.
                            if computed_parity != expected_parity {
                                parity_error = 1
                            }
                        } else {
                            // Stop bit is low — framing error.
                            frame_error = 1
                        }
                        state = RxState::Idle
                    } else {
                        baud_counter = baud_counter + 1
                    }
                }
            }
        }
    }
}

Part 3: Overrun Detection in UART Top

The overrun condition happens at the system level: the RX produces valid data, but the FIFO is already full. The new byte is lost. This is detected in UartTop where the FIFO and receiver are wired together.

Add overrun detection and debug annotations to src/uart_top.sk:

use fifo::FIFO;
use uart_tx::UartTx;
use uart_rx::UartRx;

entity UartTop<
    const CLK_FREQ_HZ: nat = 50_000_000,
    const BAUD_RATE: nat = 115200,
    const DATA_BITS: nat = 8,
    const FIFO_DEPTH: nat = 16
> {
    in clk: clock,
    in rst: reset,

    // TX interface
    in tx_data: bit[DATA_BITS],
    in tx_valid: bit,
    out tx_ready: bit,

    // RX interface
    out rx_data: bit[DATA_BITS],
    out rx_valid: bit,
    in rx_read: bit,

    // Serial lines
    out tx_serial: bit,
    in rx_serial: bit,

    // Status
    out tx_fifo_full: bit,
    out rx_fifo_empty: bit,

    // Error outputs
    #[detection_signal]
    out tx_fsm_error: bit,

    #[detection_signal]
    out rx_parity_error: bit,

    #[detection_signal]
    out rx_frame_error: bit,

    #[detection_signal]
    out rx_overrun: bit
}

impl UartTop {
    const CYCLES_PER_BIT: nat = CLK_FREQ_HZ / BAUD_RATE
    const HALF_BIT: nat = CYCLES_PER_BIT / 2
    const COUNTER_WIDTH: nat = clog2(CYCLES_PER_BIT)
    const FIFO_ADDR_WIDTH: nat = clog2(FIFO_DEPTH)

    // ── Sub-entity Instantiation ───────────────────────────────

    inst uart_tx = UartTx<CLK_FREQ_HZ, BAUD_RATE, DATA_BITS> {
        clk: clk,
        rst: rst,
        tx_start: tx_fifo_read_valid,
        tx_data: tx_fifo_data
    }

    tx_serial          = uart_tx.tx_serial
    tx_busy_internal   = uart_tx.tx_busy
    tx_done_internal   = uart_tx.tx_done
    tx_fsm_error       = uart_tx.fsm_error
    tx_parity_internal = uart_tx.tx_parity

    inst uart_rx = UartRx<CLK_FREQ_HZ, BAUD_RATE, DATA_BITS> {
        clk: clk,
        rst: rst,
        rx_serial: rx_serial,
        expected_parity: 0
    }

    rx_byte_data    = uart_rx.rx_data
    rx_byte_valid   = uart_rx.rx_valid
    rx_parity_error = uart_rx.parity_error
    rx_frame_error  = uart_rx.frame_error

    inst tx_fifo = FIFO<DATA_BITS, FIFO_DEPTH> {
        clk: clk,
        rst: rst,
        wr_en: tx_valid & tx_ready,
        wr_data: tx_data,
        rd_en: tx_fifo_read
    }

    tx_fifo_data           = tx_fifo.rd_data
    tx_fifo_full           = tx_fifo.full
    tx_fifo_empty_internal = tx_fifo.empty

    inst rx_fifo = FIFO<DATA_BITS, FIFO_DEPTH> {
        clk: clk,
        rst: rst,
        wr_en: rx_write_en,
        wr_data: rx_byte_data,
        rd_en: rx_read
    }

    rx_data               = rx_fifo.rd_data
    rx_fifo_full_internal = rx_fifo.full
    rx_fifo_empty         = rx_fifo.empty

    // ── Internal Signals ───────────────────────────────────────

    signal tx_busy_internal: bit
    signal tx_done_internal: bit
    signal tx_parity_internal: bit
    signal tx_fifo_data: bit[DATA_BITS]
    signal tx_fifo_empty_internal: bit
    signal tx_fifo_read: bit
    signal tx_fifo_read_valid: bit

    signal rx_byte_data: bit[DATA_BITS]
    signal rx_byte_valid: bit
    signal rx_fifo_full_internal: bit
    signal rx_write_en: bit

    // ── TX Path Control ────────────────────────────────────────

    tx_ready = !tx_fifo_full
    tx_fifo_read = !tx_fifo_empty_internal & !tx_busy_internal
    tx_fifo_read_valid = tx_fifo_read

    // ── RX Path Control ────────────────────────────────────────

    rx_valid = !rx_fifo_empty

    // Write to RX FIFO only if it is not full.
    // If it IS full and new data arrives, that is an overrun.
    rx_write_en = rx_byte_valid & !rx_fifo_full_internal

    // ── Overrun Detection ──────────────────────────────────────

    // Overrun occurs when the receiver produces valid data but
    // the FIFO has no room. The byte is lost.
    #[breakpoint(is_error = true, name = "FIFO_OVERRUN", message = "RX FIFO overrun — data lost")]
    signal rx_overrun_detect: bit
    rx_overrun_detect = rx_byte_valid & rx_fifo_full_internal
    rx_overrun = rx_overrun_detect

    // ── Retention: Error Counters ──────────────────────────────

    // Sticky error registers: once set, they remain high until
    // explicitly cleared by reset. This ensures transient errors
    // are not missed by slow-polling software.

    #[retention]
    signal parity_error_sticky: bit

    #[retention]
    signal frame_error_sticky: bit

    #[retention]
    signal overrun_error_sticky: bit

    on(clk.rise) {
        if rst {
            parity_error_sticky = 0
            frame_error_sticky = 0
            overrun_error_sticky = 0
        } else {
            if rx_parity_error {
                parity_error_sticky = 1
            }
            if rx_frame_error {
                frame_error_sticky = 1
            }
            if rx_overrun_detect {
                overrun_error_sticky = 1
            }
        }
    }

    // ── Debug Trace Annotations ────────────────────────────────
    //
    // These annotations organize signals into groups for waveform
    // viewing. They have zero synthesis cost — stripped entirely
    // during compilation. But they make simulation debugging
    // dramatically faster because every engineer who opens the
    // waveform sees the same organized signal groups.

    // Note: traces can only observe signals visible in THIS entity —
    // ports, locally declared signals, and sub-entity OUTPUTS read
    // via dot access. The TX FSM state and baud tick are internal to
    // UartTx, so their traces belong inside the UartTx impl itself.

    #[trace(group = "uart_tx", display_name = "TX Serial Out")]
    signal tx_serial_trace: bit

    #[trace(group = "uart_tx", display_name = "TX Parity", radix = bin)]
    signal tx_parity_trace: bit

    #[trace(group = "uart_rx", display_name = "RX Data Valid")]
    signal rx_valid_trace: bit

    #[trace(group = "uart_rx", display_name = "RX Data", radix = hex)]
    signal rx_data_trace: bit[DATA_BITS]

    #[trace(group = "uart_rx", display_name = "RX Parity Error")]
    signal rx_parity_err_trace: bit

    #[trace(group = "uart_rx", display_name = "RX Frame Error")]
    signal rx_frame_err_trace: bit

    #[trace(group = "uart_errors", display_name = "TX FSM Error")]
    signal tx_fsm_err_trace: bit

    #[trace(group = "uart_errors", display_name = "RX Overrun")]
    signal rx_overrun_trace: bit

    #[trace(group = "uart_errors", display_name = "Parity Sticky")]
    signal parity_sticky_trace: bit

    #[trace(group = "uart_errors", display_name = "Frame Sticky")]
    signal frame_sticky_trace: bit

    // Connect trace signals to actual signals.
    tx_serial_trace = tx_serial
    tx_parity_trace = tx_parity_internal
    rx_valid_trace = rx_byte_valid
    rx_data_trace = rx_byte_data
    rx_parity_err_trace = rx_parity_error
    rx_frame_err_trace = rx_frame_error
    tx_fsm_err_trace = tx_fsm_error
    rx_overrun_trace = rx_overrun_detect
    parity_sticky_trace = parity_error_sticky
    frame_sticky_trace = frame_error_sticky

    // ── Additional Breakpoints ─────────────────────────────────

    #[breakpoint(is_error = true, name = "FRAME_ERROR", message = "RX framing error — stop bit not high")]
    signal frame_err_break: bit
    frame_err_break = rx_frame_error

    #[breakpoint(is_error = false, name = "TX_COMPLETE", message = "TX transmission complete")]
    signal tx_complete_break: bit
    tx_complete_break = tx_done_internal
}

What We Added

Let us step back and see the full picture of what the annotations provide:

Safety mechanism chain. UartTx is marked #[safety_mechanism(type = tmr)]. Its fsm_error output is marked #[detection_signal]. When you run skalp safety, the tool knows: “UartTx is a TMR-protected entity. Faults in the state register are masked by the voter. Detection is reported on fsm_error.” The diagnostic coverage is calculated automatically by the fault injection system — you never specify it manually. This feeds directly into an ISO 26262 safety case.

Error detection outputs. The UART top-level exports four detection signals: tx_fsm_error, rx_parity_error, rx_frame_error, and rx_overrun. A system-level safety monitor can observe these and take corrective action — reset the UART, log the fault, or escalate to a higher-level safety controller. Each is marked #[detection_signal] so the fault injection framework can automate coverage measurement.

Sticky error registers. Parity, frame, and overrun errors are pulsed signals — they go high for one cycle when the error occurs. If the CPU polls the status register at a slower rate, it might miss a transient error, so these registers latch the error and hold it until reset. The latching is done by the logic itself, not by the annotation: the #[retention] here marks the registers as state worth preserving across a power-down, and in a design with no supply tree declared it emits synthesis attributes (RETAIN, DONT_TOUCH) that keep the registers from being merged or optimized away. Declare power domains, as the power domains section does, and the same annotation becomes a checked claim against the supply states.

Organized debug traces. Three trace groups — uart_tx, uart_rx, and uart_errors — organize the most important signals for debugging. When you open the waveform file, you immediately see the TX state machine, RX data flow, and error conditions without manually hunting through hundreds of signals. This setup is checked into version control and shared by the entire team.

Simulation breakpoints. Two breakpoints trigger during simulation. FIFO_OVERRUN halts with an error if the RX FIFO overflows — this is always a bug in the testbench or a design misconfiguration. FRAME_ERROR halts with an error on framing violations. TX_COMPLETE halts without an error when a transmission finishes — useful for stepping through individual bytes during manual debugging.


Build and Test

Your project structure should now look like this:

uart-tutorial/
  skalp.toml
  src/
    counter.sk         (Chapter 1)
    uart_tx.sk          (Chapter 2, updated with TMR and parity)
    uart_rx.sk          (Chapter 3, updated with error detection)
    fifo.sk             (Chapter 4)
    uart_top.sk         (updated with safety and debug annotations)
    adder.sk            (Chapter 5)
    tmr_counter.sk      (this chapter's standalone example)

Build the safety-hardened design:

skalp build src/uart_top.sk -o build

The compiler processes all annotations. Trace and breakpoint annotations are recorded for simulation but produce no synthesis output.

Run the fault-injection-driven safety analysis:

skalp safety --source src/uart_top.sk -o safety_collaterals --asil D

This converts the design to a gate-level netlist, runs a fault injection campaign against every gate, and observes the #[detection_signal] outputs. The console shows the campaign statistics, for example (from the standalone TMR counter):

🔥 Running Fault Injection Campaign...
   Cycles per fault: 100
   Max faults: all
   Using CPU simulation
   Faults simulated: 888
   Detected: 153
   Safe faults: 711 (80.1%)
   DC (overall): 86.44%

📋 Generating FI-Driven FMEA...

and writes fmeda_report.md and safety_analysis.yaml into the output directory — a fault classification table with measured diagnostic coverage, ready to be reviewed by a safety engineer or folded into an ISO 26262 safety case.

Breakpoints defined by #[breakpoint] annotations are carried through to the simulator’s breakpoint engine: a breakpoint with is_error = true stops the simulation when its condition fires, reporting the breakpoint’s name and message, while non-error breakpoints pause or log.

To inspect waveforms, add tb.export_waveform("build/uart_safety.skw.gz").unwrap(); at the end of a test. Open the .skw.gz file in the skalp VS Code extension. You should see three organized groups: uart_tx with the TX state and baud tick, uart_rx with the RX data and validity, and uart_errors with all error signals and sticky registers. No manual viewer configuration needed.

The fault injection in skalp safety is what verifies the TMR coverage: faults injected into the triplicated state registers are masked by the voter, faults elsewhere either propagate to a detection signal (detected) or never affect an output (safe). The measured detection rate is the diagnostic coverage for this safety mechanism. This FI-derived coverage is what goes into the generated FMEDA report — no manual coverage numbers needed.


Testing Your Design

Safety-critical designs need two levels of testing. First, verify the design works correctly during normal operation — all detection signals should stay quiet. Second, inject faults at the protocol level and verify the detection signals fire. This mirrors the functional safety V-model: validate the safety mechanism works before trusting it in the FMEDA.

Here are tests from tests/ch09_test.rs:

TmrCounter — normal operation

use skalp_testing::Testbench;

#[tokio::test]
async fn test_tmr_counter_no_errors() {
    let mut tb = Testbench::with_top_module("src/tmr_counter.sk", "TmrCounter")
        .await.unwrap();
    tb.reset(2).await;

    // Run for a long time — tmr_error should never fire
    // during normal (fault-free) operation
    tb.set("enable", 1u8);
    tb.clock(1000).await;

    tb.expect("tmr_error", 0u32).await;
}

UartRx — parity and frame errors

#[tokio::test]
async fn test_rx_safety_parity_error() {
    let mut tb = Testbench::with_top_module("src/uart_rx.sk", "UartRx")
        .await.unwrap();
    tb.reset(2).await;
    tb.set("rx_serial", 1u8);

    // Expect odd parity
    tb.set("expected_parity", 1u8);
    tb.clock(10).await;

    // Send 0x55 (4 ones -> even parity = 0, but we expect 1)
    drive_rx_byte_safety(&mut tb, 0x55).await;
    tb.clock(5).await;

    tb.expect("rx_data", 0x55u32).await;
    tb.expect("parity_error", 1u32).await; // mismatch detected
}

#[tokio::test]
async fn test_rx_safety_frame_error() {
    let mut tb = Testbench::with_top_module("src/uart_rx.sk", "UartRx")
        .await.unwrap();
    tb.reset(2).await;
    tb.set("rx_serial", 1u8);
    tb.set("expected_parity", 0u8);
    tb.clock(10).await;

    // Drive a byte with corrupted stop bit (low instead of high)
    drive_rx_byte_frame_error(&mut tb, 0x42).await;
    tb.clock(5).await;

    tb.expect("frame_error", 1u32).await;
}

UartTop — overrun detection

#[tokio::test]
async fn test_top_safety_overrun() {
    let mut tb = Testbench::with_top_module("src/uart_top.sk", "UartTop")
        .await.unwrap();
    tb.reset(2).await;
    tb.set("rx", 1u8);
    tb.clock(10).await;

    // Fill the RX FIFO (depth 16) without reading
    for i in 0..16 {
        drive_rx_byte(&mut tb, i as u8).await;
    }
    tb.clock(5).await;

    // Next byte causes overrun
    drive_rx_byte(&mut tb, 0xFF).await;
    tb.clock(5).await;

    tb.expect("rx_overrun", 1u32).await;
}

Run with:

cargo test

Exercise: Write a test_top_safety_clean test that transmits and receives a byte through the full UartTop and verifies all four error signals (tx_fsm_error, rx_parity_error, rx_frame_error, rx_overrun) remain zero throughout.


Power Domains and Common-Cause Failures

Everything so far assumed the watchdogs, voters, and checkers are powered. That assumption deserves scrutiny. A safety mechanism that shares a supply with the logic it monitors dies at the same instant as that logic — a brownout on the shared rail takes out the function and its monitor together, and the fault is detected by nothing. ISO 26262 calls this a dependent failure: two elements that are supposed to fail independently but share a common cause. Supply is the classic one. Two rails that come out of two different regulators are still not independent if both regulators hang off the same source.

Traditionally this analysis lives outside the design: power intent goes in a UPF file maintained by a different team, and supply independence is checked by review. skalp moves the supply tree into the source, where the compiler can walk it.

Declaring the Supply Tree

Power domains are declared at the top level of a source file, and every domain records how it is derived:

// An externally provided supply — a board rail, a battery.
power_domain vbat: external;

// A rail produced by an on-die regulator macro, with its
// legal voltage states.
power_domain vdd_sys = regulated(vbat, macro = u_buck_sys,
                                 states = { on: 0.9V, ret: 0.6V, off });

// A power-gated rail: derived from a parent through a power
// switch. The switch control is an expression — the polarity
// lives in the `!`, not in a separate flag — and the acknowledge
// comes back on its own signal.
power_domain vdd_cam = switched(vdd_sys, on_when = !pmu_cam_sleep,
                                ack_on = pmu_cam_ack,
                                states = { on: 0.9V, off });

The three derivation kinds — external, regulated, switched — form a tree rooted at the external supplies. That ancestry is the whole point: vdd_sys and anything regulated from vbat share an ancestor, so they are not independent of each other. A domain with a different external root is. The compiler validates the tree as it builds it — duplicate names, unknown parents, and cycles are build errors.

Entities bind to a domain with #[power_domain(name)], and the binding is checked. A typo is not a silently ignored annotation, it is a build failure:

error: entity `Watchdog`: #[power_domain(vdd_watch)] references an undeclared power domain — declare it with `power_domain vdd_watch: ...;`
   ┌─ src/power_watchdog.sk:24:1
   │  
24 │ ╭ entity Watchdog {
25 │ │     in clk: clock,
26 │ │     in kick: bit,
27 │ │     out timeout: bit
28 │ │ }
   │ ╰─^

Entities without their own binding inherit the domain of the context that instantiates them — containment follows the instance tree, the same way it does physically on the die.

The Dependent-Failure Check

Here is the check in action. Suppose the watchdog that supervises the system controller is bound to vdd_io — a perfectly separate-looking rail, with its own LDO:

#[power_domain(vdd_io)]
#[safety_mechanism(type = watchdog)]
entity Watchdog {
    in clk: clock,
    in kick: bit,
    out timeout: bit
}

vdd_io comes from u_ldo_io and vdd_sys comes from u_buck_sys — two different regulators. But both regulators draw from vbat. One sagging battery rail, and the controller and its watchdog brown out together. The compiler walks the supply tree and refuses to build:

Error: Failed to compile HIR to MIR with CDC analysis: power-domain dependent-failure check failed with 1 error(s):
  safety mechanism `Watchdog` (instance `wd` in `SysController`) is in power domain `vdd_io`, which shares a supply ancestor with its context's domain `vdd_sys` — not supply-independent from the logic it monitors (common-cause failure) — bind it to an independent supply, or justify with #[power_domain(vdd_io, allow_shared_supply)]

This is the same philosophy as the CDC analysis in Chapter 8: the property is structural, so the compiler enforces it structurally. You cannot forget to run the dependent-failure review, because it runs on every build of every design that declares a supply tree.

The right fix is the one the error message names first: bind the mechanism to an independent supply. vdd_mon below is a separate external input — a different root of the tree — so it shares no ancestor with vdd_sys.

Sometimes the shared supply is a deliberate, analyzed decision — the ISO 26262 escape hatch of a justified and documented deviation. allow_shared_supply is that justification, in the source, next to the binding it excuses:

#[power_domain(vdd_io, allow_shared_supply)]
#[safety_mechanism(type = watchdog)]
entity Watchdog {

The build then succeeds, but the finding does not disappear — it is downgraded to a warning that names the escape hatch, so it stays visible in every build log and every review:

PDC warning: safety mechanism `Watchdog` (instance `wd` in `SysController`) is in power domain `vdd_io`, which shares a supply ancestor with its context's domain `vdd_sys` — not supply-independent from the logic it monitors (common-cause failure) [downgraded: allow_shared_supply]

The Complete Design

Create a file called src/power_watchdog.sk:

// power_watchdog.sk — Supply-tree declarations and a
// supply-independent watchdog.
//
// The supply tree records HOW each rail is derived. The
// dependent-failure check uses that ancestry: two rails
// regulated from the same source are NOT independent.

power_domain vbat: external;
power_domain vdd_sys = regulated(vbat, macro = u_buck_sys,
                                 states = { on: 0.9V, ret: 0.6V, off });
power_domain vdd_io  = regulated(vbat, macro = u_ldo_io,
                                 states = { on: 1.8V, off });
power_domain vdd_cam = switched(vdd_sys, on_when = !pmu_cam_sleep,
                                ack_on = pmu_cam_ack,
                                states = { on: 0.9V, off });
power_domain vdd_mon: external;

// The watchdog monitors SysController. Binding it to vdd_mon —
// a separate supply path — satisfies the dependent-failure
// check. Binding it to vdd_io (or anything else under vbat)
// would fail the build.
#[power_domain(vdd_mon)]
#[safety_mechanism(type = watchdog)]
entity Watchdog {
    // vdd_sys can power down while vdd_mon stays up, so it is the nets
    // arriving FROM the controller that need clamping. Clamping `kick`
    // low is the safe default: no kicks means the watchdog times out,
    // which is what a dead controller should look like.
    #[isolation(clamp = low)]
    in clk: clock,
    #[isolation(clamp = low)]
    in kick: bit,
    out timeout: bit
}

impl Watchdog {
    signal cnt: bit[8] = 0
    signal timeout_q: bit

    on(clk.rise) {
        if kick {
            cnt = 0
        } else {
            cnt = cnt + 1
        }
    }

    timeout_q = cnt == 255
    timeout = timeout_q
}

#[power_domain(vdd_sys)]
entity SysController {
    in clk: clock,
    in kick_in: bit,
    out wd_timeout: bit
}

impl SysController {
    inst wd = Watchdog { clk: clk, kick: kick_in }
    wd_timeout = wd.timeout
}

The two #[isolation(clamp = low)] attributes deserve a pause, because which ports carry them is not the obvious answer.

The instinct is to isolate the watchdog’s outputtimeout leaves vdd_mon and enters vdd_sys, so surely that is the crossing to protect. It is not. An isolation cell exists to clamp a net whose source has been de-energized while its sink is still up; a net whose source stays powered has nothing to clamp. vdd_mon is an always-on external rail, so timeout is driven at all times. It is vdd_sys — the controller’s rail, with an off state — that can go down while the monitor keeps running. So the nets at risk are the ones going into the watchdog. Drop the attributes and the compiler says exactly that:

PDC warning: port `wd.clk` crosses from power domain `vdd_sys` (which can be off) into
  `vdd_mon` (which can be on) with no #[isolation] strategy — an un-clamped net from a
  de-energized domain floats
PDC warning: port `wd.kick` crosses from power domain `vdd_sys` (which can be off) into
  `vdd_mon` (which can be on) with no #[isolation] strategy — an un-clamped net from a
  de-energized domain floats

The analysis is per port and direction-aware, and it names the port rather than the edge. Annotating those two inputs clears both warnings.

If the design also declares a power_states table (below), the requirement gets sharper still: isolation is asked for only where some declared system state actually has the source off while the sink is on. A rail that is gated in principle but never off while its consumer runs needs no cells at all, and the compiler stays quiet.

Level shifters need no annotation. They follow from the declared state voltages, in whichever direction the crossing implies:

PDC warning: port `p.d` needs a level shifter (up): `vdd_aon` operates at 0.90 V,
  `vdd_io` at 1.80 V

A domain that declares no voltages produces no shifter findings — the requirement is unknown there, not absent. Both strategies export to UPF as set_isolation / set_isolation_control (asserting while the domain is off, the inverse sense of the switch’s on_when) and set_level_shifter. The compiler reports the requirement; placing the cells is the implementation flow’s job.

The UPF Output

Build the design:

skalp build src/power_watchdog.sk -o build
📄 Power intent: "build/design.upf"
✅ Build complete!
📄 Output: "build/design.sv"

Whenever a design declares power domains, skalp build writes design.upf — IEEE 1801 Unified Power Format — next to design.sv. This is the file the physical implementation flow consumes. For the design above it contains the supply ports and nets, the power domains with their -elements, the power switch for the gated rail (with its on-state Boolean and acknowledge), and the voltage states:

create_supply_port VBAT
create_supply_net VBAT
connect_supply_net VBAT -ports VBAT
create_supply_net VDD_SYS
# VDD_SYS is driven by regulator macro instance `u_buck_sys` (analog IP)
...
create_power_domain PD_vdd_mon -elements {wd}
create_supply_set SS_vdd_mon -function {power VDD_MON} -function {ground VSS}

create_power_switch SW_vdd_cam -domain PD_vdd_cam\
    -input_supply_port {sw_in VDD_SYS}\
    -output_supply_port {sw_out VDD_CAM}\
    -control_port {sw_ctrl pmu_cam_sleep}\
    -on_state {on_s sw_in {!pmu_cam_sleep}}\
    -off_state {off_s {pmu_cam_sleep}}\
    -ack_port {sw_ack pmu_cam_ack {pmu_cam_ack}}

add_power_state SS_vdd_sys -state on {-supply_expr {power == `{FULL_ON, 0.90}`}}
add_power_state SS_vdd_sys -state ret {-supply_expr {power == `{FULL_ON, 0.60}`}}
add_power_state SS_vdd_sys -state off {-supply_expr {power == `{OFF}`}}

The direction of this flow matters. UPF is an export backend: the power model lives in the IR, where the compiler’s checks run, and the UPF file is generated from it. In a traditional flow the UPF file is the model — hand-written, hand-maintained, and cross-checked against the RTL by tools that see the two artifacts for the first time at signoff. Here the supply tree, the domain bindings, and the RTL are the same source, so they cannot drift apart. The generated file says so in its header: the checked model in the source is authoritative.

Proving Independence: Domain-Loss Fault Injection

Everything above is a static argument. The supply tree says the watchdog is on an independent rail, the CCF check confirms no shared ancestor, and the build passes. But a declaration tree is a claim about the design, not a measurement of it. The FMEDA’s independence argument deserves the same treatment the diagnostic coverage number gets: injection.

Run the safety flow on a design that declares power domains and a second campaign appears after the per-gate stuck-at run:

skalp safety --source src/power_watchdog.sk
⚡ Power-fault campaign (domain loss):
   `vdd_sys` (3 primitives killed): no observable effect (domain drives no monitored output)
   `vdd_mon` (39 primitives killed): no observable effect (domain drives no monitored output)

Every gate belonging to a domain is killed at once — the digital-level model of a power switch stuck off or a regulator whose output collapses — and the run is compared against a golden one. Each domain gets one of three verdicts: the loss is detected (a #[detection_signal] fires while the domain is dead), the loss corrupts outputs undetected (a genuine safety gap — whatever should have noticed either shares the dying supply or never observes it), or the loss has no observable effect on the monitored outputs.

Two “no observable effect” verdicts is a disappointing result, and it is worth understanding rather than shrugging at, because the reason is instructive. The watchdog’s threshold is 255 counts, and the campaign simulates far fewer cycles than that — so timeout never asserts in either run, dead rail or not. The design also has no #[detection_signal], so there is nothing for the campaign to watch even if the timeout did fire. The report is telling the truth: as written and as exercised, this design produces no evidence either way.

Make three changes and the same campaign produces evidence. Mark the watchdog’s output as the detection signal, shorten the threshold so it fires inside the campaign window, and give the controller a heartbeat of its own that feeds the kick — so that killing vdd_sys genuinely stops the kicks:

#[power_domain(vdd_mon)]
#[safety_mechanism(type = watchdog)]
entity Watchdog {
    in clk: clock,
    in kick: bit,
    #[detection_signal]
    out timeout: bit
}

impl Watchdog {
    signal cnt: bit[4] = 0
    on(clk.rise) {
        if kick {
            cnt = 0
        } else {
            if cnt < 8 {
                cnt = cnt + 1
            }
        }
    }
    timeout = cnt == 8
}

#[power_domain(vdd_sys)]
entity SysController {
    in clk: clock,
    out heartbeat: bit,
    out wd_timeout: bit
}

impl SysController {
    signal beat: bit = 0
    on(clk.rise) { beat = !beat }
    heartbeat = beat

    inst wd = Watchdog { clk: clk, kick: beat }
    wd_timeout = wd.timeout
}
⚡ Power-fault campaign (domain loss):
   `vdd_sys` (6 primitives killed): loss DETECTED by detection signals ✓
   `vdd_mon` (25 primitives killed): no observable effect (domain drives no monitored output)

That first line is the independence claim measured rather than asserted. vdd_sys dies, its heartbeat stops, the kicks stop arriving, and the counter on the still-powered vdd_mon rail runs out and raises timeout. If the watchdog had shared a supply with the controller, it would have died in the same instant and the line would read outputs corrupt, loss NOT detected ✗ — which is exactly what a dependent failure looks like from the outside.

The verdict is taken at the mechanism’s own domain boundary, and that detail is what makes the result trustworthy. If a detection signal reaches the top level through a buffer that lives in the domain being killed, the campaign reports the loss as undetected — correctly, because in silicon that signal really would go dark with the rail.

What Is Checked Today — and What Is Not

The implemented subset now covers a full loop: supply-tree declarations with validation, the checked #[power_domain] binding with containment inheritance, the dependent-failure check with its justified escape hatch, the coarse cross-domain isolation warning, control-cone checks on switched domains (no domain may gate its own supply, and a controller must stay live), the power_states table with ancestry legality and per-state and per-edge liveness, per-port isolation requirements and voltage-derived level-shifter inference, UPF export including the PST, its transitions, and the isolation/level-shifter strategies, the FPGA bank/VCCIO check, and domain-loss fault injection. Still ahead: ordering within a transition (isolate before switch-off, restore order on wake), pin-level related-supply compatibility, insertion of the isolation and level-shifter cells (today the requirement is reported, not emitted), #[retention] semantics tied to the supply states, and the remaining power-fault classes — switch stuck-on, regulator droop, and overvoltage.

One honest consequence of ancestry-based independence is worth knowing if you target FPGAs: on an FPGA, all fabric logic shares VCCINT. Two “domains” of fabric logic are never supply-independent, whatever the source says — and the dependent-failure check, given a supply tree that reflects that, will report it truthfully rather than pretend the rails are separate.

The toolchain enforces the FPGA reality directly. skalp synth on an FPGA device refuses a design with switched or regulated domains — fabric has no power islands to implement them with — unless you pass --power-stub, which prototypes the ASIC power intent as always-on and prints a report of every stubbed element (switches become always-on, regulators are assumed externally supplied) ending with the shared-VCCINT caveat above. Nothing is ever stubbed silently. What FPGAs do have is per-bank I/O rails, and that is checked on every build: declare bank voltages in a constraint physical block, and a port whose io_standard disagrees with its bank’s rail fails the build:

bank/VCCIO compatibility check failed with 1 error(s):
  port `clk` of `Blink`: io_standard LVCMOS33 requires a 3.3 V rail, but bank 0 declares 1.8 V (VCCIO mismatch)

Quick Reference

ConceptSyntaxExample
Safety mechanism#[safety_mechanism(type = T)]#[safety_mechanism(type = tmr)]
Detection signal#[detection_signal]#[detection_signal] out error: bit
Retention#[retention]#[retention] signal cal_value: bit[16]
Trace group#[trace(group = "G")]#[trace(group = "pipeline")]
Trace display name#[trace(display_name = "Label")]#[trace(display_name = "Stage 1")]
Trace radix#[trace(radix = R)]#[trace(radix = hex)] — also bin, dec, unsigned
Combined trace#[trace(group = "G", display_name = "L", radix = R)]#[trace(group = "uart_tx", display_name = "TX Data", radix = hex)]
Breakpoint (error)#[breakpoint(is_error = true, name = "N", message = "M")]#[breakpoint(is_error = true, name = "OVERFLOW", message = "Counter overflow")]
Breakpoint (info)#[breakpoint(is_error = false, name = "N", message = "M")]#[breakpoint(is_error = false, name = "DONE", message = "Transfer complete")]
TMR voter patternmatch (a == b, b == c) { ... }See standalone example above
Parity computationXOR reductionp = d[0] ^ d[1] ^ ... ^ d[7]
FI-driven FMEDAskalp safetyskalp safety --source src/uart_top.sk -o safety_collaterals --asil D
External supplypower_domain N: external;power_domain vbat: external;
Regulated railpower_domain N = regulated(parent, macro = M, states = { ... });power_domain vdd_sys = regulated(vbat, macro = u_buck_sys, states = { on: 0.9V, ret: 0.6V, off });
Switched railpower_domain N = switched(parent, on_when = E, ack_on = S, states = { ... });on_when = !pmu_cam_sleep — polarity is the !, never a flag
Domain binding#[power_domain(name)]Checked — an undeclared domain is a build error
CCF escape hatch#[power_domain(name, allow_shared_supply)]Downgrades the dependent-failure error to a warning
Isolation strategy#[isolation(clamp = low)]On the port whose SOURCE domain can power off; per-port and direction-aware
Level shifters(none)Inferred from the declared state voltages, with direction
UPF exportskalp buildEmits IEEE 1801 design.upf next to design.sv when domains are declared
System power statespower_states { run = { d: on, ... }, transitions = { run -> idle } };Legality + liveness checked; exported as a UPF PST
Domain-loss FIskalp safetyKills each domain wholesale; reports detected / corrupt-undetected / no-effect

Next: Testing and Verification

The UART is now feature-complete. It transmits and receives serial data with baud rate generation, buffers bytes with parameterized FIFOs, structures its interfaces with structs and enums, crosses clock domains safely, and protects its critical state with TMR and error detection. The safety annotations feed into automated FMEDA generation. The debug annotations make simulation practical.

But none of this matters if it is not tested. How do you know the TMR voter actually masks faults? How do you verify that parity errors are detected correctly? How do you confirm that the FIFO overrun breakpoint fires at the right time?

In Chapter 10, you will build a complete Rust testbench for the UART. skalp’s test framework uses Rust — the same language the compiler is written in — to drive stimulus, check results, and measure coverage. You will write tests for normal operation, error injection, boundary conditions, and timing corner cases. The testbench will exercise every feature you have built across the entire tutorial.

Continue to Chapter 10: Testing and Verification.