reentrant
Static analysis for ISR-safety bugs in STM32 firmware written in C.
Reentrant catches the class of bug where a global variable is written inside an interrupt service routine and also accessed in main-loop code without volatile or a critical-section guard — a data race that can cause silent corruption on ARM Cortex-M.
$ reentrant analyze ./my-firmware/
Analyzing 47 file(s)…
LLM explanation skipped — set ANTHROPIC_API_KEY to enable.
┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Variable ┃ Declared at ┃ ISR context ┃ Non-ISR access ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ rx_ready │ uart.c:12 │ HAL_UART_RxCpltC… │ main.c:44 │
│ tick_count │ timers.c:8 │ TIM2_IRQHandler │ main.c:61 │
└────────────┴────────────────┴────────────────────┴────────────────────┘
2 potential ISR-safety issue(s). Add 'volatile' or wrap non-ISR accesses
in a critical section.
What it detects
isr-shared-var — a global/static variable is written in an ISR and accessed
in main-loop code without volatile or a critical-section guard.
| Pattern | Detected |
|---|---|
Global/static written in *_IRQHandler, read in main |
✓ |
HAL weak callbacks (HAL_UART_RxCpltCallback, etc.) |
✓ |
| Transitive ISR context via call graph | ✓ |
Compound writes (|=, &=, ++, --) |
✓ |
isr-blocking-call — a call to a known blocking/non-reentrant API from ISR
context (or a function transitively reachable from one), checked against a
curated table (reentrant/data/isr_unsafe_apis.toml):
| Category | Examples |
|---|---|
| libc | malloc, free, printf |
| STM32 HAL blocking variants | HAL_Delay, HAL_UART_Transmit, HAL_SPI_Transmit, HAL_I2C_Master_Transmit |
FreeRTOS APIs missing their FromISR counterpart |
xQueueSend, xQueueReceive, xSemaphoreGive, xSemaphoreTake, vTaskDelay |
A call is not flagged if the same function also calls the ISR-safe counterpart
(e.g. xSemaphoreGiveFromISR) — that's the CMSIS-RTOS inHandlerMode() runtime-dispatch
pattern every STM32Cube + FreeRTOS project ships, not a bug.
isr-stale-read (Tier 2, advisory) — the inverse of isr-shared-var: an ISR only
reads a variable that non-ISR code writes without volatile or a guard. The ISR may
see a stale or torn value, but this pattern is common and mostly benign for
write-once-at-init config/threshold variables, so it's advisory only rather than
blocking — see Tiers.
What it does NOT flag (safe patterns recognised)
| Guard | Example |
|---|---|
volatile qualifier |
volatile int flag |
CMSIS __IO alias |
__IO uint32_t reg |
__disable_irq / __enable_irq |
CMSIS bare-metal |
| PRIMASK save/restore | uint32_t p = __get_PRIMASK(); __disable_irq(); … __set_PRIMASK(p); |
FreeRTOS taskENTER_CRITICAL / taskEXIT_CRITICAL |
|
FreeRTOS portDISABLE_INTERRUPTS / portENABLE_INTERRUPTS |
|
| ISR-only variables (no non-ISR access) |
Install
Requires Python 3.11+.
# Not yet on PyPI — install directly from GitHub for now:
pip install git+https://github.com/saintbate/reentrant.git
Usage
# Analyse a whole firmware directory
reentrant analyze ./path/to/firmware/
# Single file
reentrant analyze src/main.c
# PR-scoped: only report findings that touch lines changed since origin/main
reentrant analyze . --diff-base origin/main
# SARIF output for GitHub Code Scanning
reentrant analyze . --sarif --no-explain > reentrant.sarif
# JSON output
reentrant analyze . --json --no-explain
# Skip LLM explanation layer
reentrant analyze . --no-explain
Exit code 0 — no blocking findings (or all suppressed by LLM). Exit code 1 — at
least one Tier 1 finding present. See Tiers below.
Tiers
Every rule is Tier 1 (precise enough to fail CI) or Tier 2 (advisory — shown as a PR comment, never blocks). The exit code depends only on Tier 1: a noisy heuristic can never fail your build, no matter how it's classified internally.
isr-shared-var and isr-blocking-call are Tier 1, validated at 0% false positives
against the real-world benchmark corpus. isr-stale-read is the first Tier 2 rule —
by design it's noisier (config/threshold variables written once at init produce many
candidates that look identical to real bugs from a purely syntactic view), which is
why it's advisory-only. Run against the real-world benchmark: 49 raw candidates, of
which LLM triage (see below) suppressed 31 (63%) as implausible, leaving 18 for
review — a working demonstration of the Tier 2 + LLM-triage design, not just a plan.
Diff-aware scoping
--diff-base <ref> scopes findings to a PR: analysis still runs whole-repo (ISR
reachability genuinely needs the full call graph), but the reported findings are
filtered to ones where the declaration, the ISR access, or the non-ISR access falls
on a line the diff touched. A finding surfaces if the PR introduced the unsafe ISR
write or the unguarded read or changed the declaration (e.g. dropped volatile) —
pre-existing bugs the PR didn't touch are suppressed, so a PR doesn't get buried under
issues it had nothing to do with.
LLM explanation layer (Tier 2 triage)
Set ANTHROPIC_API_KEY to enable a post-analysis pass using Claude Haiku that:
- Generates a plain-English description of the race condition and a one-line fix suggestion
- Suppresses likely false positives it can identify from full function context
This is also how Tier 2 candidates get triaged before showing up as advisory findings
— the same pass runs on every finding. Rules that already self-explain deterministically
(isr-blocking-call, from its curated table) are skipped: there's nothing for the LLM
to add, and re-triaging a rule that's precise by construction only risks an incorrect
suppression.
export ANTHROPIC_API_KEY=sk-ant-...
reentrant analyze ./my-firmware/
Skip it with --no-explain for CI pipelines where cost or latency matters.
GitHub Actions
Add .github/workflows/reentrant.yml to your firmware repo:
name: ISR Safety
on:
push:
branches: [main]
pull_request:
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — required for --diff-base to work
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install reentrant
- name: Run ISR safety analysis
id: reentrant
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
DIFF_ARGS="--diff-base origin/${{ github.base_ref }}"
else
DIFF_ARGS=""
fi
reentrant analyze . --sarif --no-explain $DIFF_ARGS > reentrant.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: reentrant.sarif
category: reentrant
- if: steps.reentrant.outcome == 'failure'
run: exit 1
Findings appear as inline annotations on the PR diff in the Security tab.
The SARIF upload requires GitHub Advanced Security. This is free for all public repositories; private repositories need a GHAS licence.
Feedback loop
Disagree with a finding? Reply on the PR:
/false-positive reentrant/isr-shared-var Core/Src/main.c:42 initialised before ISR enabled
.github/workflows/false-positive.yml parses that, records the verdict (rule, tier,
a snapshot of the surrounding code, your note) as one line in .reentrant/feedback.jsonl,
and commits it back to the PR branch — a growing, labeled dataset of real bugs vs
false positives, meant to eventually inform which heuristics graduate from Tier 2
to Tier 1 (or get cut).
The same thing works locally without a PR:
reentrant feedback --rule reentrant/isr-shared-var --verdict fp \
--note "initialised before ISR enabled" Core/Src/main.c 42
Known limitation: PRs from forks can't be committed back to — GitHub doesn't grant the base repo's Actions run write access to a fork's branch. Feedback on a fork PR is acknowledged with a comment but not persisted automatically; a maintainer can run the CLI command above locally instead.
Limitations
- C only (no C++)
- Single-core Cortex-M — does not model multi-core shared memory (STM32H7 M4/M7)
- Does not model OS-level mutual exclusion (mutexes, semaphores) — only interrupt-disable guards
- Does not detect ABA / lock-free algorithmic correctness issues
- Whole-repo analysis assumes a single build target. Symbol and function identity are
scoped by declaring file for
staticsymbols, but non-static globals and function names share one namespace across the entire analyzed tree. This is a non-issue for a normal firmware repository (which only has onemain(), oneSysTick_Handler, etc.), but a repo that bundles many mutually-exclusive build targets in one source tree — vendor example-project collections (e.g. official ST Cube packages with 200+ example projects), or a codebase supporting many MCU families/architectures (ChibiOS's per-architecturechcore.c, Betaflight's per-vendorsystem_stm32*.c) — can produce false positives where analysis incorrectly treats same-named symbols from different, never-linked-together targets as one shared symbol. Confirmed viatests/measure_fp.pyagainst real ST Cube, ChibiOS, and Betaflight sources. Not fixed today; would need build-target-aware scoping.
Development
git clone https://github.com/saintbate/reentrant
cd reentrant
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest # corpus + diff-scoping + tier-system tests
python tests/measure_fp.py # real-world FP measurement
Publishing to PyPI
Not yet published — .github/workflows/publish.yml is ready but needs a one-time
setup on PyPI before the first release (PyPI Trusted Publishing — no API token to
manage):
- Create a PyPI account if you don't have one, at pypi.org.
- Go to pypi.org → Your account → Publishing and add a new "pending publisher":
PyPI project name
reentrant, GitHub ownersaintbate, repositoryreentrant, workflow namepublish.yml, environment namepypi. - Back in this repo, bump the version in
pyproject.toml, commit, tag it (git tag v0.1.0 && git push origin v0.1.0), then create a GitHub Release from that tag. The workflow runs automatically and PyPI accepts the upload because it trusts this exact repo + workflow — no secret ever touches this codebase. - Once published, switch
pip install git+https://github.com/saintbate/reentrant.gitback topip install reentrantin this README and in bothreentrant.ymlandfalse-positive.yml.
Release files for reentrant 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| reentrant-0.1.0.tar.gz | 40.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| reentrant-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 79.3 kB
Release files / reentrant-0.1.0.tar.gz
| Download URL | reentrant-0.1.0.tar.gz |
|---|---|
| Size | 40.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
37e05d788985413be8ed3a4d1dc0d54cf2297ab9fa1f20ebc386b9805f23a1fb
|
|
BLAKE2b-256 checksum How to use checksums |
10400785e014bad0be52209f22be1e1db61373d19de812b6027212280d24c4e5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 11, 2026.
Transparency logRelease files / reentrant-0.1.0-py3-none-any.whl
| Download URL | reentrant-0.1.0-py3-none-any.whl |
|---|---|
| Size | 38.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2ab0121f74896b893e4904f00c9fd819c02a7b9a27242428a05cd745ee775203
|
|
BLAKE2b-256 checksum How to use checksums |
3713af92c7989b9a642da25f49cbe837023405ddc3998af85c00010480fcdd6a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 11, 2026.
Transparency log