ISR-safety static analysis for STM32 firmware
Project description
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file reentrant-0.1.0.tar.gz.
File metadata
- Download URL: reentrant-0.1.0.tar.gz
- Upload date:
- Size: 40.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37e05d788985413be8ed3a4d1dc0d54cf2297ab9fa1f20ebc386b9805f23a1fb
|
|
| MD5 |
6b05d4f51ac69b59118956e931ff2cb4
|
|
| BLAKE2b-256 |
10400785e014bad0be52209f22be1e1db61373d19de812b6027212280d24c4e5
|
Provenance
The following attestation bundles were made for reentrant-0.1.0.tar.gz:
Publisher:
publish.yml on saintbate/reentrant
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
reentrant-0.1.0.tar.gz -
Subject digest:
37e05d788985413be8ed3a4d1dc0d54cf2297ab9fa1f20ebc386b9805f23a1fb - Sigstore transparency entry: 2141345347
- Sigstore integration time:
-
Permalink:
saintbate/reentrant@33f1ea108183ca982bc31461b4b0980c8b6b729f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/saintbate
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@33f1ea108183ca982bc31461b4b0980c8b6b729f -
Trigger Event:
release
-
Statement type:
File details
Details for the file reentrant-0.1.0-py3-none-any.whl.
File metadata
- Download URL: reentrant-0.1.0-py3-none-any.whl
- Upload date:
- Size: 38.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2ab0121f74896b893e4904f00c9fd819c02a7b9a27242428a05cd745ee775203
|
|
| MD5 |
e105a4ed5305128849b739bf3ca45c70
|
|
| BLAKE2b-256 |
3713af92c7989b9a642da25f49cbe837023405ddc3998af85c00010480fcdd6a
|
Provenance
The following attestation bundles were made for reentrant-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on saintbate/reentrant
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
reentrant-0.1.0-py3-none-any.whl -
Subject digest:
2ab0121f74896b893e4904f00c9fd819c02a7b9a27242428a05cd745ee775203 - Sigstore transparency entry: 2141345620
- Sigstore integration time:
-
Permalink:
saintbate/reentrant@33f1ea108183ca982bc31461b4b0980c8b6b729f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/saintbate
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@33f1ea108183ca982bc31461b4b0980c8b6b729f -
Trigger Event:
release
-
Statement type: