Mistakes Cuttlefish Catches
On every build, Cuttlefish reads your code and checks it against the board you’re targeting. It looks for the kinds of mistakes that are easy to make with hardware but hard to spot by eye — and that the C++ compiler won’t warn you about. When it finds one, it points at the line and suggests a fix.
The Kinds of Mistakes It Catches
Here are the most common ones, in plain terms:
- Using a pin for something it can’t do. For example, asking a pin for a fading “PWM” signal when that pin doesn’t support it.
- Two things on the same pin or bus. Like using the same physical pin twice through different names, or forgetting to release a shared bus.
- Slow code that freezes the screen. A blocking sleep inside your main loop pauses everything — including any display you’ve drawn.
- Risky code inside an interrupt. Interrupts (the code that runs when a pin suddenly changes) have to finish fast. Long delays or bus calls there can lock up the board.
- Running low on memory. Embedded boards have limited memory. Cuttlefish estimates how much your program will use and warns you before it silently corrupts.
- Variables shared with interrupts. A value an interrupt writes and your main loop reads needs special handling — Cuttlefish adds that for you automatically.
A Few Examples
Wrong pin type
import { GPIO, PWM } from '@typecad/hal';
import { A0 } from '@typecad/board';
const pin = new GPIO(A0, GPIO.OUTPUT);
new PWM(A0, { periodNs: 1000000 }); // ERROR: A0 is not a PWM-capable pinThe message names the pin, what you asked for, and which pins do support it.
Same pin used twice
import { ADC, GPIO } from '@typecad/hal';
import { A4, SDA } from '@typecad/board';
const analog = new ADC(A4);
const i2c = new GPIO(SDA, GPIO.OUTPUT); // WARNING: A4 and SDA are the same physical pinA blocking wait that freezes your screen
import { Time } from '@typecad/hal';
function loop() {
Time.sleep(1000); // blocks the single-threaded main loop for a full second
readSensor();
}
// Fix: make the wait cooperative — inside an `async function`,
// `await Time.sleep(1000)` is rewritten into a non-blocking state-machine
// split that yields between checks.The blocking-delay check flags bare blocking delay()-style calls inside loop() — the repeated entry point — with the code blocking-delay-in-loop. The fix is to make the wait cooperative: await Time.sleep(1000) inside an async function is rewritten into a non-blocking state-machine split, so it does not trip the warning. On RTOS targets (Zephyr), a plain blocking sleep is allowed because k_msleep yields the CPU on its own — only a busy-wait triggers the warning there.
Cuttlefish knows that loop() has to return quickly, so it only flags a blocking wait there — not in one-time setup() code.
A variable shared with an interrupt
let flag = false;
function setup() {
button.onInterrupt(GPIO.INT_EDGE_FALLING, () => { flag = true; });
}
function loop() {
if (flag) { /* ... */ flag = false; }
}
// INFO: 'flag' is written in an interrupt handler and read in main code
// — emitted with the right marking so the compiler doesn't cache it.Cuttlefish marks flag correctly for you. This fixes a whole class of bugs where a loop seems to ignore a value an interrupt just set.
Running the Checks
The checks run automatically every time you build. For a full written report, add --diagnostics:
cuttlefish build --diagnosticsThis writes diagnostics.md and diagnostics.json listing every check, how the pins and buses are used, and the memory estimate.
⚙️ Advanced details — the full list of checks
Cuttlefish runs about 20 checks in sequence during every build. Each one produces a diagnostic with a severity (error, warning, or info), a message, and usually a suggested fix. The orchestrator is runProgramValidations().
| # | Check | Severity | What it catches |
|---|---|---|---|
| 1 | Pin Capabilities | error | Using pin features it doesn’t support (e.g., PWM on a non-PWM pin) |
| 2 | Peripheral Validation | warning | Peripheral usage conflicts with board data |
| 3 | Unsafe Pins | warning | Pins that interfere with critical peripherals |
| 4 | Resource Analysis | info | Overall resource usage, pin/peripheral allocation |
| 5 | Pin Alias Conflicts | warning | Same physical pin used via different aliases |
| 6 | Pulldown Support | warning | Pull-down requested on unsupported pins |
| 7 | Interrupt Safety | warning/info | Unsafe operations inside interrupt handlers, reentrancy risk |
| 8 | Volatile Inference | info | Globals shared between an interrupt and main code auto-marked volatile |
| 9 | Reentrancy Detection | warning | Functions called from both an interrupt and the main thread |
| 10 | ADC Range | warning | Reads beyond ADC resolution |
| 11 | Unit Suspicion | warning | Suspicious numeric values (wrong units) |
| 12 | Pin Mode Config | warning | Reads/writes without prior mode configuration |
| 13 | Peripheral Ownership | error | Using peripherals without taking ownership |
| 14 | Ownership Analysis | error/warning | Use-after-move, borrow violations, let→const promotion |
| 15 | Try-Catch Validation | error | Try-catch on platforms with -fno-exceptions |
| 16 | Memory Budget | warning/error | Estimated SRAM usage vs board budget (stack/heap collision) |
| 17 | Blocking Delay in Loop | warning | A blocking delay() inside loop() that freezes async/UI rendering |
| 18 | Network Usage | error/warning | WiFi/HTTP misuse — radio-less targets, HTTP without WiFi, blocking calls in loop(), weak AP passwords |
⚙️ Advanced details — pins, timers, and interrupts
Pin capability validation
Each pin on a board has a defined set of capabilities. The validator checks every HAL method call against the board definition:
import { GPIO, PWM } from '@typecad/hal';
import { A0 } from '@typecad/board';
const pin = new GPIO(A0, GPIO.OUTPUT);
new PWM(A0, { periodNs: 1000000 }); // ERROR: A0 is not a PWM-capable pinThe diagnostic includes the pin name, the requested capability, and suggests which pins do support it.
Peripheral pin conflicts
Many pins serve dual roles. On the ESP32-S3:
- GPIO8 is both a digital pin and I2C data (SDA)
- GPIO12 is both a digital pin and SPI data (MOSI)
- GPIO43/GPIO44 carry the console UART
Using a pin for GPIO while its peripheral is active produces a conflict diagnostic.
Pin alias conflicts
The same physical pin can be referenced by different names:
import { ADC, GPIO } from '@typecad/hal';
import { A4, SDA } from '@typecad/board';
const analog = new ADC(A4);
const i2c = new GPIO(SDA, GPIO.OUTPUT); // WARNING: A4 and SDA are the same physical pinInterrupt (ISR) safety
The validator scans interrupt-handler callbacks for operations that are unsafe in interrupt context. Detection covers user-written lambdas (onInterrupt(...) handlers) as well as HAL-authoring callback() directives — Cuttlefish tracks which callbacks are bound to interrupts across the whole program:
| Operation | Severity | Reason |
|---|---|---|
delay() / busy-wait forms | warning | Sleeping or spinning the CPU in interrupt context stalls the scheduler |
| I2C bus calls | warning | I2C transactions may sleep (driver locking + clock stretching) |
| SPI transfers | warning | Transfers take driver locks and may wait on DMA completion |
UART writes (UART0.writeLine()) | info | Output blocks until the TX FIFO has room — a full FIFO stalls the ISR |
Platforms can customize this list via PlatformStrategy.isrUnsafeOperations().
Example
button.onInterrupt(GPIO.INT_EDGE_FALLING, () => {
UART0.writeLine(" ISR!"); // info: output blocks until the TX FIFO has room
chip.device(0x50).write([0x50]); // warning: I2C transactions may sleep
});Duplicate handler detection
Attaching multiple handlers to the same pin is also flagged:
button.onInterrupt(GPIO.INT_EDGE_FALLING, () => { /* handler 1 */ });
button.onInterrupt(GPIO.INT_EDGE_FALLING, () => { /* handler 2 */ });
// WARNING: Duplicate interrupt handler on the pinAutomatic volatile inference
The classic embedded bug: a global flag set in an interrupt and polled in loop() becomes an infinite loop under -Os because the compiler caches it in a register. Cuttlefish knows which callbacks are interrupts and which globals they write — so it auto-marks shared variables volatile without any annotation:
let flag = false;
function setup() {
button.onInterrupt(GPIO.INT_EDGE_FALLING, () => { flag = true; });
}
function loop() {
if (flag) { /* ... */ flag = false; }
}
// INFO: 'flag' is written in an interrupt handler and read in main code
// — emitted as `volatile` to prevent register caching.The emitted C++ declares volatile bool flag = false; automatically. This eliminates a class of bugs the C++ compiler cannot detect — “this function runs in interrupt context” is a call-graph property, not a function property.
Reentrancy detection
If a function is called from both an interrupt and main-thread code, an interrupt firing mid-execution can corrupt local state:
function process() { /* ... */ }
function setup() {
button.onInterrupt(GPIO.INT_EDGE_FALLING, () => { process(); }); // called from interrupt
}
function loop() {
process(); // also called from main — reentrancy risk
}
// WARNING: 'process' is called from both an interrupt handler and main-thread code.Memory budget validation
On embedded targets the SRAM is shared by globals, heap, and stack with no memory protection — a collision corrupts memory silently. Cuttlefish compares its static memory estimate against the board’s SRAM budget:
// 900 int32_t values = 3600 bytes on a board with a small SRAM budget
const buf: int32_t[] = [/* ... 900 zeros ... */];
// WARNING: Estimated SRAM usage exceeds the board's budget
// (statics 3.53 KB + stack ~0.03 KB = ~3.56 KB).
// This will likely corrupt memory at runtime.The estimate accounts for global variables, struct sizes, string literals, unsized arrays (element count recovered from the initializer), and call-stack depth. The board’s SRAM size is resolved from the board definition or an architecture-specific fallback.
ADC range validation
Reads beyond the board’s ADC resolution are flagged:
import { ADC } from '@typecad/hal';
import { A0 } from '@typecad/board';
const value = new ADC(A0).read();
if (value > 4095) { /* ... */ }
// WARNING: a 12-bit ADC reads 0-4095. Values above 4095 are unreachable.Validation is board-data-driven — returns no diagnostic when board data is missing.
⚙️ Advanced details — ownership and bus rules
Ownership analysis
The ownership validator implements Rust-inspired rules for transpile-time validation. It is opt-in — if no ownership types appear in the program, only const-suggestion checks run.
Use-after-move
let buf: Owned = new Buffer(64);
let other = buf; // ownership transferred
buf.read(); // ERROR: 'buf' was moved and cannot be used again
// Hint: const buf_ref: Shared = buf; // add this before the moveAssign to immutable borrow
const ref: Shared = data;
ref = newData; // ERROR: Cannot assign to 'ref' — it is an immutable borrow
// Hint: Change to Mutable if reassignment is neededOwned copy warning
let buf: Owned = new Buffer(64);
let copy = buf; // INFO: Non-primitive Owned creates a C++ copy
// Hint: const copy: Shared = buf; // borrow by reference insteadReturn local reference
function getData(): Shared {
const local: Owned = new Buffer(64);
return local; // ERROR: Returning borrow of local variable — dangling reference
}Const promotion
When the whole-program reassignment analysis proves a let is never written after initialization, Cuttlefish automatically emits it as const in C++ — enabling ROM placement and constant folding:
let x = 42; // INFO: 'x' is never reassigned — emitted as `const`
// Emitted: const int x = 42;This is information the C++ compiler cannot recover across translation units. The inverse demotion (const → non-const when a member is mutated) also applies automatically.
Borrow mismatch
function process(data: Mutable) { /* ... */ }
const readonly: Shared = buffer;
process(readonly); // ERROR: Cannot pass Shared to Mutable parameterPeripheral ownership
When you opt into bus ownership by calling take(), all subsequent bus access must occur within a taken section:
Double take
I2C0.take();
I2C0.take(); // ERROR: Double-take — I2C0 is already owned
// Hint: Call 'I2C0.release()' before taking it againUnowned release
I2C0.release(); // WARNING: I2C0.release() called but bus was not taken
// Hint: Ensure you call 'I2C0.take()' before releasingUnowned access
I2C0.take();
I2C0.release();
I2C0.device(0x50).write(data); // ERROR: Unowned access — I2C0 used outside a claim
// Hint: Call 'const bus = I2C0.take();' and use the returned objectLeak detection
SPI0.take();
// ... end of program
// WARNING: SPI0 was taken but never released
// Hint: Call 'SPI0.release()' when finishedBus names recognized: I2C0/I2C1, SPI0/SPI1, UART0, Wire, Serial.
On This Page