[ Back to home ]
2025
Hardware-aware static analysis for embedded code.
A VS Code extension and custom Language Server Protocol that catches RAM overruns, pin conflicts, strapping-pin boot failures, and bus configuration errors in Arduino and ESP32 code as you type, not after the flash. Built solo in a few hours at Hack Western 12, where it won Best Developer Tool.
Install from the VS Code Marketplace or Open VSX · 1.2K installs.
1. Abstract
Embedded development still debugs like it is 1995: write code, hit upload, watch the ESP32 reboot because a strapping pin was held low at reset, or because the RAM estimate was off by 30%. Web developers get type checkers and instant diagnostics; firmware engineers get a serial monitor and a multimeter. Sentinel uses the Language Server Protocol machinery behind TypeScript and Rust tooling to model a microcontroller's hardware constraints and flag them in the editor, before the compile.
The build is a custom Node.js Language Server and a VS Code extension, both written end-to-end during Hack Western 12. The server holds an incremental AST of every .ino and .cpp file plus a per-board hardware state machine (pin map, bus assignments, timer usage, interrupt vectors). A rule engine fires on every keystroke; the extension renders its diagnostics inline beside an interactive board diagram.
The novelty is the surface, not the analyses. PC-Lint, Clang-Tidy, and Cppcheck all exist, but they sit downstream of the compiler and outside the editor. Moving that analysis into the LSP layer gives firmware the loop web developers have had for a decade: keystroke, diagnostic, fix, no flash needed.
- Best Developer Tool, Hack Western 12, then 1.2K installs across the VS Code Marketplace and Open VSX as a solo-maintained extension.
- Per-board hardware models for ATmega328P, ATmega2560, ESP32, and ESP8266: pin maps, peripheral assignments, strapping-pin behaviour at reset.
- Live RAM and Flash estimates against per-board budgets, within roughly 3% of compiled output on the calibration sketch suite.
- ~47 ms typical diagnostic latency from keystroke to inline update.
2. Introduction
The compile, flash, reboot, debug cycle is the tax embedded developers pay every day. The cost is the gap between writing a line and learning it was wrong. A mistyped ESP32 strapping pin is invisible until the chip refuses to boot. A buffer that overflows SRAM by 5% surfaces as a heap-allocation crash an hour in. A wrong I2C clock against a slow sensor is a multi-hour scope session, not a compiler error.
- The feedback loop is the bottleneck. Flashing a sketch takes seconds; debugging a crash after deployment takes hours.
- The hardware is the constraint. Most embedded bugs are mismatches between code and silicon: pin role conflicts, bus contention, memory overflow, peripheral misconfiguration. A type checker that does not know the hardware is blind to them.
- The tooling exists elsewhere. Web and systems developers have had LSP-grade diagnostics for years, and nothing in LSP assumes purely textual analysis.
2.1 Why the Language Server Protocol
LSP already standardises the editor-to-analyzer wire format and is built for incremental analysis. Both match what a hardware-aware analyzer needs.
- One server, many editors. The analysis lives in a single Node.js process, so any editor that speaks LSP (VS Code, Neovim, Helix, Zed) needs a thin client, not a port.
- Incremental by default. Edits arrive as deltas and diagnostics are expected in milliseconds, the build's own latency target.
- Standard diagnostic format. Severity, source, range, code, related information: the editor renders it, the server only fills it in.
2.2 What is actually new
The LSP framework, the VS Code extension surface, and the Arduino CLI integration all exist. The contribution is the hardware-aware server between them, reporting through the standard LSP diagnostic channel.
- Hardware state as a first-class model. Pins, buses, interrupt vectors, strapping-pin behaviour, and timer ownership are tracked per board across the whole project, not file by file.
- Pre-flash hardware diagnostics. Pin conflicts, memory overruns, and strapping-pin hazards that normally show up as boot failures appear inline, before any upload.
- Editor-native, not toolchain-native. Sentinel does not replace
avr-gcc or xtensa-esp32-elf-gcc. It sits upstream of both and surfaces what they miss until link time or runtime.
Section 3 is the engineering behind those three commitments.
3. System
3.1 Memory budget estimation
Two budgets are tracked per board, SRAM and Flash, computed statically from the AST and compared against per-board ceilings (32 KB Flash and 2 KB SRAM on ATmega328P; 4 MB Flash and roughly 320 KB usable SRAM on ESP32 with WiFi active).
- Static allocations from global declarations and array sizes: the cheap, accurate part.
- Stack via per-function frame heuristics: local variable size plus a fixed call-depth scan from
setup() and loop(). An approximation, not a worst-case bound: avr-gcc compresses frames in ways the source does not show, so the estimate is calibrated against compiled output.
- Framework overhead as per-board constants.
Serial on ESP32 carries roughly 1 to 3 KB of RX/TX buffers and driver state; WiFi.begin() adds tens to low hundreds of KB depending on runtime allocation, shown as a step change in the live bar.
- Compiler-specific calibration for
avr-gcc and xtensa-esp32-elf-gcc, fitted against compiled output of the calibration sketch suite at -O0, -O2, and -Os.
- Live bars with thresholds at 80% (amber) and 90% (red). The number is an estimate; the colour is the signal.
- Ablation: without the framework-overhead constants, ESP32 calibration-set error grows from roughly 3% off compiled output to closer to 20%. The work is in the constants, not the AST walk.
3.2 Pin conflict detection
Every GPIO assignment is tracked across files and validated against the board model, with pin role (input, output, INPUT_PULLUP, peripheral-claimed) inferred from pinMode calls, bus initialisations, and analog APIs.
- ESP32 strapping pins (GPIO0, GPIO2, GPIO5, GPIO12, GPIO15) are flagged with the specific boot-mode consequence: GPIO0 low at reset enters download mode, GPIO2 must not be high during flash, GPIO12 sets the flash voltage, GPIO15 controls the UART0 boot log.
- Duplicate assignments across files. Two libraries claiming one GPIO is a runtime failure the compiler misses; the project-wide state machine catches it.
- Hardware-only peripheral pins. Uno SPI must use pins 11, 12, 13; Uno I2C must use A4 and A5. A
pinMode on any of them marks the pin user-claimed, even before SPI.begin().
- INPUT_PULLUP detection: a missing internal pull-up on an I2C line raises a warning.
- Interactive board diagram with hover tooltips and click-to-source, rendered from the same data structure the rule engine reads.
3.3 Bus configuration validation
- I2C:
Wire.setClock() against device-class defaults (standard 100 kHz, fast 400 kHz, fast-plus 1 MHz), 7-bit versus 10-bit addressing, and address conflicts when multiple beginTransmission calls hit one address without a multiplexer.
- SPI: CPOL and CPHA match between master and device, MOSI / MISO / SCK against the board's hardware SPI pins, and chip-select uniqueness.
- UART: baud, parity, stop bits, and which hardware UART is claimed. Uno UART0 shares USB serial, so a GPS module on it plus
Serial.println debugging is a guaranteed conflict.
- Peripheral autocomplete from a database of 200+ common Arduino-ecosystem sensors and breakouts (Adafruit, Sparkfun, generic datasheets). An
0xXX literal matching a known I2C address triggers a suggestion.
- Ablation: without the device database, bus validation collapses to syntax-only checks. The database turns "the bus is configured" into "the bus matches the device."
3.4 Language Server architecture
- Incremental AST parser for
.ino and .cpp, handling Arduino preprocessing (auto-prototyping, header injection) and library resolution across sketch, user, core, and variant include paths.
- Hardware state machine per board (pin map, bus assignments, timer ownership, interrupt vectors, peripheral states), rebuilt incrementally from the AST on each edit.
- Validation rule engine across syntax, semantic, and hardware tiers. Each rule is a small pattern match over the AST and state machine, added as one function plus a registration call.
- Performance: the naive first build took 2 to 3 seconds per analysis. Incremental parsing, symbol-table caching, and rule batching cut typical edit latency to roughly 47 ms on the calibration sketch suite.
- LSP diagnostic surface. Every rule emits a standard
Diagnostic: severity, source sentinel, a range on the offending tokens, and a code that drives the extension's explanation panel.
4. Evaluation
Every number is measured on the calibration sketch suite Sentinel ships with, not an external benchmark.
- Memory estimate accuracy: within roughly 3% of compiled-output RAM and Flash on the calibration sketches across the four supported boards. Heavy dynamic allocation (
String on AVR, dynamic WiFiClient pools on ESP32) degrades it, because those costs materialise only at runtime.
- Diagnostic latency: 47 ms average from keystroke to inline diagnostic on sketches under 2000 lines; P95 under 100 ms.
- Rule coverage: 50+ active rules across memory, GPIO assignment, strapping pins, and the three buses, averaging tens of lines each.
- Adoption: 1.2K installs across the VS Code Marketplace and Open VSX, post-event.
The 3% figure holds on the calibration sketches and degrades outside that distribution. The 200+ device database is mostly assembled from public datasheets, not original research. Read the numbers as evidence for the editor-loop direction, not as a benchmark against PC-Lint or Clang-Tidy.
5. Conclusion and Implications
Embedded tooling is a productivity tax that limits who can build hardware. Moving diagnostics into the editor makes firmware feel like high-level software: fast iteration, instant safety rails, fewer trips to the oscilloscope. The safety payoff: strapping-pin and pin-conflict failures that now surface as field bugs, bricked devices, or boot loops in industrial and medical embedded systems can surface as squiggles in the editor instead. Best Developer Tool at Hack Western 12 showed the gap is felt by those who would benefit most from closing it; the 1.2K installs since are the quieter, more durable signal.
5.1 Limitations and future work
- Memory model is approximate. Heap fragmentation is not modelled, and frame heuristics give a budgeted stack estimate, not a worst-case bound. Next: a per-region ESP32 breakdown (IRAM, DRAM, PSRAM, RTC), since one SRAM pool hides the region that fills first.
- External pull-ups are invisible. Physical resistors on the board cannot be seen, so I2C lines without a pull-up in code are flagged conservatively, giving false positives on boards that supply their own.
- Board coverage is the original four. ATmega328P, ATmega2560, ESP32 (Xtensa LX6), ESP8266. Next: ESP32-S2 / S3 (Xtensa LX7) and RISC-V ESP32-C3 / C6, then STM32 and RP2040.
- No FreeRTOS or timing analysis. Next: per-task stack sizing, priority validation, and cross-core sharing on dual-core ESP32; I2C clock stretching, SPI setup and hold, and UART framing at high baud.
- The 50+ rules are uneven. Some hold across many calibration sketches; others catch the obvious case and miss the edge. Next: an audit that lifts the long tail to the strongest rules.
6. Acknowledgments
Built solo at Hack Western 12, where it won Best Developer Tool. It sits on the Arduino CLI (compile integration), the Language Server Protocol specification (wire format), and the vscode-languageclient reference implementation (extension scaffolding). The peripheral database draws on public datasheets across the Adafruit, Sparkfun, and general Arduino sensor ecosystems. Published on the VS Code Marketplace and the Open VSX Registry as RishiShah.sentinel-arduino.