Home › Embedded, industrial & energy › Safety-critical design
algorithm medium

Model checking and formal proof

Problem Engineers of concurrent or safety-critical systems must show that a protocol or a piece of control logic is correct in every case, including rare event orders that tests, reviews and simulations never reach.

Exhaustively explore a model's reachable states (or prove its invariants with a solver or proof assistant) to find rule violations with a shortest counterexample trace.

explicit-state O(reachable states x transitions) time, O(states) memory; reachable states grow as the product of the parts' states
model-checkingtla-plusspinsmttheorem-provinginvariants
Overview

Concurrent systems fail in interleavings nobody wrote a test for. A model checker takes a small precise model and tries every reachable state, so a rule such as "never two lock holders" is checked against all event orders. A broken rule comes with the shortest event sequence that breaks it. The price is state explosion; reductions, symbolic encodings and proofs buy back scale.

Key facts

Use when

  • A protocol has concurrency, retries or failures: cloud replication, OS kernels, CPU bus and cache protocols.
  • Safety logic must hold in all cases: railway interlocking, avionics and automotive state machines, safety monitors.

Avoid when

  • The risk is timing or numeric precision: measure instead.
  • Testing suffices: fuzzing is cheaper.

Deep dive

How it works

Parts:

  • model — initial states plus a transition relation (processes, shared variables, messages, failures), in TLA+, Promela, SMV or code
  • property — safety invariant (bad state never reached) or temporal formula (LTL/CTL) for liveness
  • visited set — hash set of expanded states, with a parent link per state for the trace
  • frontier — queue (breadth-first) or stack (depth-first) of states to expand
  • engine — explicit-state search, BDD, SAT/SMT unrolling, or proof assistant

Explicit-state operations:

  1. Seed. Put the initial states in the visited set and the frontier.
  2. Expand. Pop a state; compute one successor per enabled transition.
  3. Check. Test the invariant, and deadlock (no transition enabled, not a legal end state).
  4. Dedupe. An unvisited successor is stored with its parent and queued.
  5. Stop. On a violation, follow the parent links back: that is the counterexample. An empty frontier proves the property for the model.

Liveness:

  • LTL speaks about every run: G !(cs0 && cs1) (always), G (try0 -> F cs0) (a request is eventually served), plus X next, U until. CTL quantifies over paths: AG (all paths, always), EF (some path, eventually), e.g. AG EF reset.
  • A liveness violation is a lasso: a path into a cycle that never reaches the goal. Finding it needs cycle detection (nested DFS, strongly connected components), not one bad state.
  • Without fairness a scheduler may never run a process. Assume weak fairness (a continuously enabled step eventually happens) or strong fairness (a step enabled infinitely often happens).

Beyond explicit states:

  • Reductions. Symmetry: states equal up to permuting identical processes keep one canonical form (TLC symmetry sets). Partial-order: skip interleavings of independent steps (Spin).
  • Symbolic. Hold state sets as binary decision diagrams, step whole sets (NuSMV).
  • Bounded. Unroll k steps and ask a SAT or SMT solver for Init(s0) and T(s0,s1) and ... and not P(si). Satisfiable: counterexample. Unsatisfiable: no bug within k steps (CBMC, Kani, Apalache).
  • Deductive. Prove the invariant inductive: true initially, preserved by every step. Done in proof assistants (Isabelle/HOL, Rocq, Lean) or verifiers that send obligations to SMT solvers (Dafny, Why3, Frama-C, SPARK).
 model + property --> engine --+--> explicit: BFS/DFS, visited set --> "ok, N states" | trace
                               +--> symbolic: BDD image steps ------> "ok" | trace
                               +--> bounded: unroll k, SAT/SMT ------> "no bug <= k" | model
                               +--> proof: invariant + obligations --> proved | open goal

Code

Breadth-first checker for two processes entering a critical section: naive test-then-set (breaks mutual exclusion), set-flag-first (safe, deadlocks), Peterson's algorithm (correct).

use std::collections::{HashMap, VecDeque};

#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Proto { Naive, FlagFirst, Peterson }

// pc: 0 idle, 1-2 entry steps, 3 critical section, 4 exit
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct State { pc: [u8; 2], flag: [bool; 2], turn: usize }

const INIT: State = State { pc: [0; 2], flag: [false; 2], turn: 0 };

/// Successor when process `i` steps; None = blocked.
fn step(p: Proto, s: State, i: usize) -> Option<State> {
    let (j, mut n) = (1 - i, s);
    n.pc[i] = match (p, s.pc[i]) {
        (Proto::Naive, 0) => if s.flag[j] { return None } else { 1 },  // test ...
        (Proto::Naive, 1) => { n.flag[i] = true; 3 }                   // ... then set: not atomic
        (Proto::FlagFirst, 0) => { n.flag[i] = true; 1 }
        (Proto::FlagFirst, 1) => if s.flag[j] { return None } else { 3 },
        (Proto::Peterson, 0) => { n.flag[i] = true; 1 }
        (Proto::Peterson, 1) => { n.turn = j; 2 }                      // give way
        (Proto::Peterson, 2) => if !s.flag[j] || s.turn == i { 3 } else { return None },
        (_, 3) => 4,
        (_, 4) => { n.flag[i] = false; 0 }
        _ => unreachable!(),
    };
    Some(n)
}

#[derive(Debug, PartialEq)]
pub enum Verdict {
    Ok { states: usize },
    MutexViolated(Vec<(usize, State)>), // (process that moved, state after)
    Deadlock(Vec<(usize, State)>),
}

/// Breadth-first: the first bad state found has a shortest path.
pub fn check(p: Proto) -> Verdict {
    let mut parent: HashMap<State, Option<(State, usize)>> = HashMap::from([(INIT, None)]);
    let mut queue = VecDeque::from([INIT]);
    let trace = |parent: &HashMap<State, Option<(State, usize)>>, mut s: State| {
        let mut t = vec![];
        while let Some(&Some((prev, who))) = parent.get(&s) { t.push((who, s)); s = prev; }
        t.reverse();
        t
    };
    while let Some(s) = queue.pop_front() {
        if s.pc == [3, 3] { return Verdict::MutexViolated(trace(&parent, s)); } // invariant
        let mut moved = false;
        for i in 0..2 {
            if let Some(n) = step(p, s, i) {
                moved = true;
                parent.entry(n).or_insert_with(|| { queue.push_back(n); Some((s, i)) });
            }
        }
        if !moved { return Verdict::Deadlock(trace(&parent, s)); }
    }
    Verdict::Ok { states: parent.len() }
}

fn main() {
    match check(Proto::Naive) {
        Verdict::MutexViolated(t) => {
            assert_eq!(t.len(), 4);
            for (who, s) in &t { println!("P{who} -> {s:?}"); }
        }
        v => panic!("{v:?}"),
    }
    assert!(matches!(check(Proto::FlagFirst), Verdict::Deadlock(t) if t.len() == 2));
    assert_eq!(check(Proto::Peterson), Verdict::Ok { states: 26 });
}

Run output: the naive protocol prints a 4-step trace (both test, then both set and enter). FlagFirst deadlocks after 2 steps (both flags set, both waiting). Peterson: no violation, 26 states.

Complexity and constants

  • Time O(S x T) for S reachable states with T transitions each; memory O(S). S is a product: n identical processes with k local states give up to k^n combinations.
  • Bounded checking: one SAT/SMT query of size O(k x |T|), NP-hard.
  • Measured on this Mac (Apple M4 Pro), release build, std HashMap (SipHash): Peterson check about 2.0 us per run (26 states, about 77 ns per state), mean of 100,000 runs.

Efficiency

  • Cost: per state one hash lookup plus one insert per successor; memory-bound once the visited set outgrows cache and then RAM. The DynamoDB model ran on distributed TLC with ten EC2 instances (Newcombe et al.).
  • Levers:
    • symmetry reduction — up to n! fewer states for n interchangeable processes (theory) — identical replicas
    • partial-order reduction — skips equivalent interleavings (Spin) — independent local steps
    • bitstate hashing — one bit per state, small chance of missing states (Spin README) — memory-limited searches
    • small instance — fewer states; AWS checked "sufficiently large" instances (Newcombe et al.)
  • Naive → standard → state of the art:
    • hand-picked test interleavings · incomplete
    • explicit search with reductions · bounded by RAM
    • BDDs, bounded SMT, inductive invariants, proofs · unbounded, with effort

Implementation notes

  • Pack states into fixed-size integers so the visited set stays small and cheap to hash. TLC keeps fingerprints (from memory).

Pitfalls

  • The model is not the code: a checked Peterson says nothing about a C version lacking a memory barrier.
  • Liveness unchecked: AWS's lock-free model "failed to find a liveness bug as we did not check liveness" (Newcombe et al.).
  • Missing fairness gives spurious starvation; too much hides real starvation.
  • Parameters too small (2 nodes) pass while the bug needs 3; coarse atomic steps hide races.
  • Vacuity: a stuck model satisfies every invariant; check interesting states are reachable.
  • "No bug in k steps" is not a proof.

Variants and alternatives

Apalache vs TLCSMT-based bounded runs and inductive invariants where integer clocks or Byzantine faults blow up explicit states.
Spin, NuSMVSpin: C-like Promela models, LTL; NuSMV/nuXmv: hardware and control models (BDD, SAT; SMT in nuXmv).
CBMC / Kanithe code itself, up to a loop bound.
Symbolic executionfinds bugs on real paths, no proof.
Proof assistants and deductive verifiersIsabelle, Rocq, Lean for unbounded claims (seL4); SPARK, Frama-C, Why3, Dafny for annotated code (SPARK proves absence of run-time errors).
Pick: concurrent design → TLA+/Spin; hardware or control → NuSMV; existing C/Rust → CBMC/Kani; unbounded state or top assurance → proof assistant.

In production

  • Cloud: Amazon modelled S3, DynamoDB, EBS and a lock manager in TLA+/PlusCal; DynamoDB's model found a data-loss bug with a 35-step shortest trace (Newcombe et al.).
  • Operating systems: seL4 has machine-checked proofs of functional correctness, binary correctness and security properties (sel4.systems; Isabelle/HOL in seL4/l4v).
  • Hardware: Intel reported formal verification replacing testing in Core i7 execution-engine validation (Kaivola et al.).
  • Space and rail: SPARK case studies at Astrium Space Transportation and Mitsubishi Electric R&D Centre Europe (ERTS 2014); pairs with coding standards and fault-tree analysis.
  • Protocols such as Raft, Multi-Paxos and two-phase commit are typical targets.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
TLA+ tools (TLC)Javatlaplus/tlaplustla2tools.jar from releases (Java 11+)————explicit-state TLC
ApalacheScalaapalache-mc/apalachedocker pull ghcr.io/apalache-mc/apalache:main————TLA+ via Z3 or CVC5
SpinCnimble-code/Spinbrew install spin————Promela, LTL, bitstate
NuSMVCnusmv.fbk.eudownload from site————BDD and SAT; nuXmv is binary-only
CBMCC++diffblue/cbmcbrew install cbmc————C and C++ bounded checking
KaniRustmodel-checking/kanicargo install --locked kani-verifier————run cargo kani setup after install

Sources

  • Newcombe et al., How Amazon Web Services Uses Formal Methods, CACM 58(4), 2015. DOI · earlier report (PDF)
  • Klein et al., seL4: Formal Verification of an OS Kernel, SOSP 2009. DOI · proofs · code
  • Clarke, Emerson, Sistla, Automatic verification of finite-state concurrent systems using temporal logic specifications, TOPLAS 1986. DOI
  • Burch, Clarke, McMillan, Dill, Symbolic model checking: 10^20 states and beyond, 1992. DOI
  • Biere, Cimatti, Clarke, Zhu, Symbolic Model Checking without BDDs, TACAS 1999. DOI
  • Holzmann, The model checker SPIN, IEEE TSE 1997. DOI
  • Kaivola et al., Replacing Testing with Formal Verification in Intel Core i7 Processor Execution Engine Validation, CAV 2009. DOI
  • AdaCore, Rail, Space, Security: Three Case Studies for SPARK 2014, 2013. blog
  • Lamport, TLA+ home page. site

Builds on

Used by

interlocking logic · plannedRailway route setting, locking, flank protection and release as verified boolean logic

Systems (13):

Alternatives and related

Deterministic simulation testingConcurrency and failure bugs in a distributed system appear once in millions of runs and cannot be reproduced. Engineers need to run many hostile schedules in minutes and replay any failure exactly.RaftSeveral machines must agree on an ordered log of commands and keep identical state, surviving failures, for metadata, locks, configuration or sharded databases. This is the understandable consensus protocol for that.Multi-PaxosReplicated systems need several machines to agree on one ordered log of commands despite failures. This is the classic consensus protocol, preferred when you need more flexibility than Raft offers.
symbolic execution · plannedExplore program paths with symbolic inputs and SMT-checked path constraints
Coverage-guided fuzzingAnyone shipping code that parses untrusted bytes (codecs, protocol stacks, interpreters, firmware) must find crashing inputs nobody thought to write. Hand-written test cases and random bytes both miss deep paths.Hierarchical state machineA device, UI or protocol reacts differently to the same event depending on its mode, and modes nest (power, then active, then paused). A flat list of modes needs the same reaction copied into every mode.Safety-critical coding standardsTeams writing firmware or control code whose failure could cause harm, or whose certification is audited, must decide which coding rules to adopt. This guide answers what the common standards enforce and how.Fault tree analysisA safety engineer must show how likely an unwanted system-level event is, and which parts of the design cause it, before a device with redundancy, shared supplies and several failure paths is released.
safety monitor architecture · plannedIndependent doer/checker safety monitor that forces a safe state when the main channel misbehaves
drat proof certificates · plannedDRAT and LRAT proofs emitted by SAT solvers and checked independently
Deadlock detectionSeveral workers each hold some exclusive resources and wait for others in a system with locks, such as transactions updating two rows; if they wait on each other in a circle, none ever proceeds.