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
delay()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. Boards like the Arduino Uno have very little 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 { A0 } from '@typecad/board-arduino-uno';
const pin = A0.asOutput();
pin.pwm(); // ERROR: A0 does not support PWM on Arduino UnoThe message names the pin, what you asked for, and which pins do support it.
Same pin used twice
import { A4, SDA } from '@typecad/board-arduino-uno';
const analog = A4.asInput();
const i2c = SDA; // WARNING: A4 and SDA are the same physical pinA delay that freezes your screen
function loop() {
delay(1000); // WARNING: Blocking delay inside loop() freezes async/UI
readSensor();
}
// Hint: Use millis() comparisons or Async.sleep(ms) instead.Cuttlefish knows that loop() has to return quickly, so it only flags delay() there — not in one-time setup code.
A variable shared with an interrupt
let flag = false;
function setup() {
D2.asInput().onFalling(() => { 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 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 | PWM Timer Sharing | info | PWM pins sharing the same hardware timer |
| 7 | Timer0 PWM Timing | info | Timer0 conflict with millis()/micros() |
| 8 | Pulldown Support | warning | Pull-down requested on unsupported pins |
| 9 | Interrupt Safety | warning/info | Unsafe operations inside interrupt handlers, reentrancy risk |
| 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 | Heap Allocation | warning/info/error | new on AVR, heap awareness on ESP32, new Array<E>() on AVR |
| 17 | Volatile Inference | info | Globals shared between an interrupt and main code auto-marked volatile |
| 18 | Reentrancy Detection | warning | Functions called from both an interrupt and the main thread |
| 19 | Memory Budget | warning/error | Estimated SRAM usage vs board budget (stack/heap collision) |
| 20 | Blocking Delay in Loop | warning | delay() inside loop() that freezes async/UI rendering |
⚙️ 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 { A0 } from '@typecad/board-arduino-uno';
const pin = A0.asOutput();
pin.pwm(); // ERROR: A0 does not support PWM on Arduino UnoThe diagnostic includes the pin name, the requested capability, and suggests which pins do support it.
Peripheral pin conflicts
Many pins serve dual roles. On Arduino Uno:
- D13 is both a digital pin and SPI clock (SCK)
- A4 is both analog input and I2C data (SDA)
- D0/D1 are used by Serial (UART0)
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 { A4, SDA } from '@typecad/board-arduino-uno';
const analog = A4.asInput();
const i2c = SDA; // WARNING: A4 and SDA are the same physical pinPWM timer sharing
On AVR, PWM pins share hardware timers. The validator detects when multiple PWM pins share the same timer:
// D9 and D10 both use Timer1 on ATmega328P
const led1 = D9.asOutput();
const led2 = D10.asOutput();
led1.pwm(128);
led2.pwm(64);
// INFO: "D9, D10 share timer1 on Arduino Uno. Duty cycle can differ per pin,
// but timer-wide PWM settings are shared across that group."Timer0 PWM timing conflict
Timer0 drives millis() and micros() on AVR. Using PWM on D5 or D6 (Timer0-backed pins) can interfere with timing:
D5.asOutput().pwm(128);
// INFO: D5 uses Timer0 which also drives millis()/micros().
// PWM frequency changes may affect timing accuracy.The timer ID is detected from the board definition’s .timer field or by parsing AVR OC register naming (OC0A → timer0).
Interrupt (ISR) safety
The validator scans interrupt-handler callbacks for operations that are unsafe in interrupt context. Detection covers user-written lambdas (D2.onFalling(() => {...})) as well as HAL-authoring callback() directives — Cuttlefish tracks which callbacks are bound to interrupts across the whole program:
| Operation | Severity | Reason |
|---|---|---|
delay() | warning | Blocks the CPU in interrupt context |
delayMicroseconds() | warning | Blocks and should be avoided in interrupts |
Serial.print() | info | May not work correctly in interrupt context |
Serial.println() | info | May not work correctly in interrupt context |
Serial.write() | info | May not work correctly in interrupt context |
Serial.read() | info | May not work correctly in interrupt context |
I2C0.* / I2C1.* | warning | I2C operations can cause lockups in interrupt context |
SPI0.* / SPI1.* | info | SPI operations may cause issues in interrupt context |
Platforms can customize this list via PlatformStrategy.isrUnsafeOperations().
Example
D2.asInputPullUp().onFalling(() => {
delay(100); // warning: delay() blocks the CPU
Serial.println(" ISR!"); // info: Serial.println() may not work correctly
I2C0.write(0x50, buf); // warning: I2C0.write - I2C operations can cause lockups
});Duplicate handler detection
Attaching multiple handlers to the same pin is also flagged:
D2.asInputPullUp().onFalling(() => { /* handler 1 */ });
D2.asInputPullUp().onFalling(() => { /* handler 2 */ });
// WARNING: Duplicate interrupt handler on pin D2Automatic 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() {
D2.asInput().onFalling(() => { 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() {
D2.asInput().onFalling(() => { 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.Heap allocation detection
Cuttlefish detects new expressions in any position — assignments, returns, call arguments, nested sub-expressions — not just variable declarations:
class Sensor { constructor() {} }
function setup() {
this.sensor = new Sensor(); // detected in assignment
process(new Sensor()); // detected in call argument
return new Sensor(); // detected in return
}Severity scales with the target:
- AVR/megaAVR —
warning: the heap is ~1.5–1.8 KB on a Uno; heavy or churning allocation risks fragmentation.new Array<E>(n)is anerror(it would needstd::vector, which AVR cannot host). - ESP32 and others —
info: these targets have more RAM and a real heap, but allocations insideloop()churn the heap over time. The diagnostic suggests PSRAM for large buffers.
Memory budget validation
On AVR, the 2 KB 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 2048-byte SRAM target
const buf: int32_t[] = [/* ... 900 zeros ... */];
// WARNING: Estimated SRAM usage is 176% of the 2.0 KB budget
// (statics 3.53 KB + stack ~0.03 KB = ~3.56 KB).
// This exceeds available SRAM and 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 { A0 } from '@typecad/board-arduino-uno';
const value = A0.asInput().readAnalog();
if (value > 1023) { /* ... */ }
// WARNING: Arduino Uno ADC is 10-bit (0-1023). Values above 1023 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.write(0x50, data); // ERROR: Unowned access — I2C0 used without being taken
// 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