Serial Protocols skill for Claude Code

Capture the bus before blaming the firmware. Claude treats serial bus failures as physical-layer problems first: it checks ground, voltage levels and wiring, sizes I2C pull-ups from the spec's rise-time and sink-current limits with a bundled calculator, checks UART baud error per divisor scheme, and reads decoded logic analyzer captures against the datasheet before touching driver code.

  • nameserial-protocols
  • size162 lines · ≈1,900 tokens
  • files3
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Wires, configures, and debugs I2C, SPI, UART, RS-485, and CAN links between microcontrollers, sensors, and modules — pull-up sizing, bus capacitance, SPI modes, baud-rate error, termination, level shifting, and logic analyzer or oscilloscope captures with protocol decoders. Use when an I2C scan finds nothing or devices NACK, SPI returns 0xFF or garbage, UART prints junk characters, a CAN node goes bus-off, a sensor works on the bench but fails on a long cable, or when choosing pull-ups, clock speeds, and transceivers.

Try asking

  • My I2C scan finds the BME280 on the bench but not on a 2 m cable — why?
  • SPI reads from this flash chip always return 0xFF. Help me debug it
  • What pull-up resistors do I need for four I2C breakouts at 400 kHz on 3.3 V?

Use it when

  • An I2C, SPI, UART, RS-485, or CAN link returns nothing or garbage
  • Choosing pull-ups, termination, transceivers, or level shifters
  • Setting up logic analyzer or oscilloscope captures with decoders
  • A link works on the bench but fails on longer cables or higher speeds

Skip it when

  • Application-level messaging over the network — use mqtt-iot
  • Writing the sketch logic around a working sensor — use arduino-projects

How to check the result

A decoded capture matches the datasheet transaction with ACKs or clean framing, measured I2C rise time is within the mode limit, and error counters stay at zero over a soak.

The common mistake it prevents

Stacking several breakout boards that each carry their own I2C pull-ups, producing a parallel resistance too strong for the devices to pull low.

~/.claude/skills/serial-protocols/SKILL.md

--- name: serial-protocols description: Wires, configures, and debugs I2C, SPI, UART, RS-485, and CAN links between microcontrollers, sensors, and modules — pull-up sizing, bus… license: MIT ---

Done well means: every link has a written spec (voltage, speed, mode, address, termination), the physical layer is verified with a capture before firmware is blamed, pull-ups and termination are computed rather than guessed, and the fix is proven with a before/after capture.

Ground rules

  • Measure before you modify code. A 2-minute logic analyzer capture beats an hour of printf debugging.
  • Every device on a bus shares ground. Missing common ground is the most common cause of “random” garbage.
  • Check voltage levels on both sides. 5 V devices into 3.3 V MCUs need level shifting (I2C: bidirectional MOSFET shifter or a dedicated I2C translator; push-pull SPI/UART: unidirectional buffers such as 74LVC/74AHCT parts). Never connect RS-232 (±12 V) or RS-485/CAN bus lines directly to MCU pins — use a transceiver.
  • Never hot-plug bus wiring on powered boards; it can latch up or damage I/O.
  • Change one thing per test: speed, pull-up, wiring, or code.

Workflow

- [ ] 1. Write the link spec: voltage, speed, mode/format, addresses, cable length
- [ ] 2. Inspect wiring: ground, power at the device pins, pull-ups, swapped lines
- [ ] 3. Capture the bus with a logic analyzer + decoder at the slowest working speed
- [ ] 4. Compare capture to the device datasheet's timing/transaction diagram
- [ ] 5. If edges look bad, scope the analog waveform (rise time, ringing, levels)
- [ ] 6. Fix physical layer first, then configuration, then driver code
- [ ] 7. Re-capture, then raise speed / lengthen cable and test margins

For capture setup, sample-rate rules, and decoder tips, read references/logic-analyzer.md before step 3 if you have not set up a capture for this bus type before.

Calculator script

python3 scripts/bus_calc.py i2c-pullup --vdd 3.3 --cap 120 --mode fast --existing 10000 10000
python3 scripts/bus_calc.py uart-baud --clock 16000000 --scheme avr        # scan common bauds
python3 scripts/bus_calc.py uart-baud --clock 48000000 --baud 115200 --scheme frac16

i2c-pullup computes the legal pull-up window from the I2C specification’s sink-current and rise-time limits and checks existing breakout-board pull-ups in parallel. uart-baud reports the actual baud and error per divisor scheme. Exit code 1 means a limit is violated.

I2C

ModeSpeedMax rise timeMax bus capacitanceTypical pull-up at 3.3 V
Standard100 kHz1000 ns400 pF4.7–10 kΩ
Fast400 kHz300 ns400 pF2.2–4.7 kΩ
Fast-mode Plus1 MHz120 ns550 pF1–2.2 kΩ (needs 20 mA-capable devices)
  • Open-drain: lines are pulled high by resistors and pulled low by devices. Exactly one set of pull-ups per bus segment (in total). Breakout boards each add their own (often 10 kΩ); five boards in parallel = 2 kΩ, which may be too strong for some devices. Remove extras or compute the parallel value.
  • MCU internal pull-ups (30–50 kΩ) are too weak beyond very short, slow buses.
  • 7-bit addresses: datasheets sometimes print the 8-bit form (address << 1 with R/W). 0x76 vs 0xEC is the same device.
  • Address conflicts: two identical sensors need different address pins or an I2C mux (TCA9548A).
  • Bus stuck low (SDA held by a slave after an MCU reset mid-transfer): clock SCL up to 9 times as GPIO until SDA releases, then send STOP. Implement this recovery at boot.
  • Long runs (> ~1 m): capacitance kills rise time. Use slower clock, stronger pull-ups within limits, a bus extender (P82B715-class) or differential I2C, or switch to RS-485/CAN.

SPI

ModeCPOLCPHAData sampled on
000Rising edge, clock idles low
101Falling edge, clock idles low
210Falling edge, clock idles high
311Rising edge, clock idles high
  • Read the mode from the datasheet timing diagram, not a forum post. Many devices accept 0 and 3.
  • MISO stuck at 0xFF: device not selected (CS wrong polarity/pin), not powered, MISO not connected, or the device tristates MISO and another device on the bus fights it.
  • Off-by-one-bit data: wrong mode. Shifted bytes: CS toggling between bytes when the device needs CS held for the whole transaction.
  • Each device needs its own CS; idle CS high with a pull-up so devices stay deselected during MCU boot.
  • Keep SPI wires short (< 20–30 cm at 10+ MHz). Add 22–33 Ω series resistors at the driver on SCK/MOSI for ringing.

UART

  • Frame: 8N1 is default. Both sides must agree on baud, data bits, parity, stop bits.
  • TX goes to RX, RX to TX, grounds connected. “Nothing received” is often TX-to-TX.
  • Baud error: keep each side under ~2%; the receiver tolerates roughly 4–5% combined for 8N1. A 16 MHz AVR at 115200 is −3.5% (fine against an accurate PC adapter, risky against another AVR); use 250000/500000/1000000 or U2X mode. Check with bus_calc.py uart-baud.
  • Junk characters at boot are normal on some chips (ROM bootloader output at a different baud); ignore until the app’s banner.
  • Flow control (RTS/CTS) is needed above ~1 Mbaud or when the receiver can stall (flash writes). Otherwise size RX buffers for the worst burst and use DMA/idle-line interrupts.
  • USB-UART adapters: verify the adapter’s logic voltage (3.3 V vs 5 V jumper) before connecting.

RS-485

  • Half-duplex differential, up to ~1200 m at low speeds. Twisted pair, daisy chain (no stars), 120 Ω termination at both physical ends only, bias resistors at one point.
  • Drive DE/RE from the UART’s hardware DE signal or toggle it after the last stop bit has left the shift register (TC flag), not when the TX buffer empties.

CAN

  • 120 Ω termination at each end of the bus (measure ~60 Ω between CANH and CANL with power off). Twisted pair, short stubs.
  • Every node needs a transceiver (3.3 V-capable parts such as SN65HVD230/TCAN-class for 3.3 V MCUs). Common ground between nodes recommended.
  • All nodes use the same bit timing: bitrate plus sample point (~75–87.5%). Mismatched sample points cause errors only at certain lengths/temperatures.
  • A node alone on a bus sees no ACKs and goes error-passive / bus-off. Test with at least two nodes or a USB-CAN adapter.
  • Linux: SocketCAN with can-utils:
sudo ip link set can0 type can bitrate 500000 restart-ms 100
sudo ip link set up can0
candump -tA can0
cansend can0 123#DEADBEEF
ip -details -statistics link show can0     # error counters, bus-off events

Failure lookup

SymptomLikely cause
I2C scan finds nothingNo pull-ups, SDA/SCL swapped, device unpowered, wrong pins in code
I2C NACK on some readsRise time too slow (cap/pull-up), clock stretching unsupported by master
I2C works short, fails longCapacitance; slow down or buffer
SPI garbage only at high speedRinging, long wires, wrong sample edge; lower speed to confirm
UART junk charactersBaud mismatch, inverted logic (RS-232 vs TTL), no common ground
UART drops bytesRX buffer overflow; use DMA or flow control
CAN bus-offMissing termination, wrong bitrate, only one node, CANH/CANL swapped

Verification

  1. A decoded capture shows the expected transaction (address/command/data) matching the datasheet, with ACKs (I2C) or correct framing (UART/CAN).
  2. For I2C, scope rise time (30%–70%) is within the mode limit; bus_calc.py agrees with the fitted pull-ups.
  3. Error counters (CAN, UART framing/overrun) stay at zero over a soak test.
  4. Margin test: run at 2× the needed speed or the longest cable briefly; if it fails there, document the margin rather than shipping at the edge.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/logic-analyzer.md 69 lines · 3.3 KB
# Logic analyzer and scope captures

Read before setting up a capture for a bus you have not captured in this project.

## Tools

| Tool | Use |
|---|---|
| Saleae Logic 2 | Polished UI, analog channels on Pro models, scripting API |
| sigrok / PulseView + sigrok-cli | Free; works with cheap FX2-based 8-channel analyzers; many decoders |
| Oscilloscope (≥ 50 MHz, 2+ channels) | Rise times, ringing, voltage levels, noise — anything analog |
| USB-CAN adapter + SocketCAN | CAN frames, error frames, bus load |

A logic analyzer shows what logic levels a threshold detector saw. It cannot show a
slow edge, a level that sits at 1.6 V, or ringing — use the scope for those.

## Sample rate rules

- Sample at ≥ 4× the fastest signal frequency for decoding; 10× for timing measurements.
  - I2C 400 kHz → ≥ 4 MS/s.
  - SPI 8 MHz → ≥ 32 MS/s (cheap 24 MS/s analyzers cannot decode it reliably; slow the
    bus for debugging).
  - UART 115200 → ≥ 1 MS/s.
- Set the analyzer's logic threshold to match the bus (1.65 V for 3.3 V logic). Wrong
  thresholds produce phantom glitches.
- Connect the analyzer ground to the circuit ground near the bus, not across the bench.

## Capture recipe

1. Trigger on the start of the interesting transaction: falling SDA while SCL high
   (I2C start), CS falling edge (SPI), first start bit (UART), or a GPIO you toggle in
   firmware just before the transfer.
2. Capture a few hundred milliseconds around the trigger.
3. Add the protocol decoder with the exact settings: I2C address display 7-bit; SPI
   mode, bit order, CS polarity; UART baud, parity, stop bits, inverted or not.
4. Export the decoded table (CSV) when comparing against expected sequences.

```bash
# sigrok-cli example: capture 2 s at 4 MHz on an FX2 analyzer and decode I2C
sigrok-cli -d fx2lafw --config samplerate=4m --time 2s \
  -C D0=SCL,D1=SDA -P i2c:scl=SCL:sda=SDA -A i2c=address-read:address-write:data-read:data-write
```

Check `sigrok-cli --list-supported` and the decoder's options (`-P i2c --show`) for
your installed version; option names occasionally change.

## Debug GPIO trick

Toggle a spare GPIO at key firmware points (ISR entry, transfer start, error path) and
capture it alongside the bus. This correlates firmware timing with the wire and
exposes latency, retries, and missed interrupts without serial logging overhead.

## What to look for

| Bus | Healthy capture | Red flags |
|---|---|---|
| I2C | START, address + ACK, data + ACK, STOP | NACK after address (wrong address/unpowered), SDA stuck low, SCL stretched forever |
| SPI | CS low for whole transaction, clock count = 8 × bytes | CS toggling between bytes, MISO constant 0xFF/0x00, clock idle level wrong for mode |
| UART | Clean decoded text, no framing errors | Framing errors (baud), all bytes wrong (inversion), gaps (flow control) |
| CAN | Frames with ACK slot asserted | Error frames, missing ACK (no other node or termination) |

## Scope checks

- I2C rise time: measure 30%–70% of VDD on SDA/SCL. Compare with the mode limit.
- Low level: I2C VOL should be ≤ 0.4 V; higher means pull-ups too strong or a weak
  driver.
- Ringing/overshoot on SPI clocks beyond ~0.5 V above VDD stresses inputs; add series
  resistors or shorten wires.
- Use the probe's short ground spring, not the long lead, for fast edges.
scripts/bus_calc.py 152 lines · 6.7 KB
#!/usr/bin/env python3
"""Bus sizing helpers for embedded serial links (stdlib only).

Subcommands:
  i2c-pullup  Pull-up resistor window for an I2C bus (UM10204 rise-time and
              sink-current limits).
  uart-baud   Actual baud rate and error for a peripheral clock and divisor
              scheme; flags anything over the error threshold.

Examples:
  python3 bus_calc.py i2c-pullup --vdd 3.3 --cap 120 --mode fast
  python3 bus_calc.py uart-baud --clock 16000000 --baud 115200 --scheme int --oversample 16
  python3 bus_calc.py uart-baud --clock 48000000 --scheme frac16

Exit status: 0 = all values within limits, 1 = at least one value out of
limits (no valid pull-up, or baud error above threshold), 2 = bad input.
"""
from __future__ import annotations

import argparse
import sys

# UM10204 (I2C-bus specification) limits per mode.
I2C_MODES = {
    # mode: (max rise time ns, max bus capacitance pF, sink current at VOL mA, freq kHz)
    "standard": (1000.0, 400.0, 3.0, 100),
    "fast": (300.0, 400.0, 3.0, 400),
    "fast-plus": (120.0, 550.0, 20.0, 1000),
}

E24 = [1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0,
       3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1]

COMMON_BAUDS = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600, 1000000]


def e24_values(lo: float, hi: float) -> list[float]:
    out = []
    for decade in (1, 10, 100, 1000, 10000, 100000):
        for v in E24:
            r = v * decade
            if lo <= r <= hi:
                out.append(r)
    return out


def fmt_ohms(r: float) -> str:
    return f"{r / 1000:.2f} kOhm" if r >= 1000 else f"{r:.0f} Ohm"


def i2c_pullup(args: argparse.Namespace) -> int:
    if args.vdd <= 0 or args.cap <= 0:
        print("error: --vdd and --cap must be positive", file=sys.stderr)
        return 2
    tr_ns, cb_max, iol_ma, freq = I2C_MODES[args.mode]
    status = 0
    vol = 0.4 if args.vdd > 2.0 else 0.2 * args.vdd
    rp_min = (args.vdd - vol) / (iol_ma / 1000.0)
    # tr is measured 30%..70% of VDD: tr = 0.8473 * Rp * Cb
    rp_max = (tr_ns * 1e-9) / (0.8473 * args.cap * 1e-12)

    print(f"I2C {args.mode} mode ({freq} kHz), VDD {args.vdd:.2f} V, bus capacitance {args.cap:.0f} pF")
    print(f"  VOL(max) {vol:.2f} V, sink current {iol_ma:.0f} mA, max rise time {tr_ns:.0f} ns")
    if args.cap > cb_max:
        print(f"  FAIL: {args.cap:.0f} pF exceeds the {cb_max:.0f} pF bus limit for this mode "
              "(shorten wiring, split the bus, or add a buffer such as a bus extender)")
        status = 1
    print(f"  Rp(min) = {fmt_ohms(rp_min)}  (stronger pull-up overloads the open-drain sink)")
    print(f"  Rp(max) = {fmt_ohms(rp_max)}  (weaker pull-up makes edges too slow)")
    if rp_min > rp_max:
        print("  FAIL: no resistor satisfies both limits; reduce capacitance or drop to a slower mode")
        return 1
    choices = e24_values(rp_min, rp_max)
    if choices:
        # Prefer the value nearest the geometric middle, nudged toward stronger.
        target = (rp_min * rp_max) ** 0.5 * 0.8
        best = min(choices, key=lambda r: abs(r - target))
        print(f"  E24 values in range: {', '.join(fmt_ohms(r) for r in choices[:12])}"
              + (" ..." if len(choices) > 12 else ""))
        print(f"  Suggested: {fmt_ohms(best)} (total, including any module pull-ups in parallel)")
        if args.existing:
            par = 1 / sum(1 / r for r in args.existing)
            ok = rp_min <= par <= rp_max
            print(f"  Existing pull-ups in parallel: {fmt_ohms(par)} -> {'OK' if ok else 'OUT OF RANGE'}")
            if not ok:
                status = 1
    else:
        print("  WARN: window is narrower than the E24 spacing; use 1% E96 values")
    return status


def uart_baud(args: argparse.Namespace) -> int:
    if args.clock <= 0 or args.oversample <= 0:
        print("error: --clock and --oversample must be positive", file=sys.stderr)
        return 2
    bauds = [args.baud] if args.baud else COMMON_BAUDS
    print(f"UART clock {args.clock:,.0f} Hz, scheme {args.scheme}, oversample {args.oversample}x, "
          f"threshold {args.threshold:.1f}%")
    print(f"  {'target':>9} {'divisor':>12} {'actual':>12} {'error':>8}")
    status = 0
    for baud in bauds:
        ideal = args.clock / (args.oversample * baud)
        if args.scheme == "int":
            div = max(1, round(ideal))
            div_txt = f"{div}"
        elif args.scheme == "avr":
            # AVR-style UBRR register: actual = f / (os * (UBRR + 1))
            ubrr = max(0, round(ideal) - 1)
            div = ubrr + 1
            div_txt = f"UBRR={ubrr}"
        else:  # frac16: divisor with 4 fractional bits (1/16 steps)
            div = max(1.0, round(ideal * 16) / 16)
            div_txt = f"{div:.4f}"
        actual = args.clock / (args.oversample * div)
        err = (actual - baud) / baud * 100
        flag = "" if abs(err) <= args.threshold else "  <-- too far off"
        if flag:
            status = 1
        print(f"  {baud:>9} {div_txt:>12} {actual:>12.1f} {err:>7.2f}%{flag}")
    print("  Note: both ends contribute error; keep each side under ~2% so the sum stays below the "
          "~4-5% an 8N1 frame tolerates.")
    return status


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)

    i2c = sub.add_parser("i2c-pullup", help="pull-up resistor window for I2C")
    i2c.add_argument("--vdd", type=float, required=True, help="pull-up supply voltage in volts")
    i2c.add_argument("--cap", type=float, required=True,
                     help="total bus capacitance in pF (about 10 pF per device pin + ~1 pF/cm of wire)")
    i2c.add_argument("--mode", choices=sorted(I2C_MODES), default="standard")
    i2c.add_argument("--existing", type=float, nargs="*", default=[],
                     help="pull-up values already on the bus in ohms (e.g. breakout boards)")
    i2c.set_defaults(func=i2c_pullup)

    u = sub.add_parser("uart-baud", help="baud-rate error for a clock and divisor scheme")
    u.add_argument("--clock", type=float, required=True, help="peripheral clock in Hz")
    u.add_argument("--baud", type=int, help="target baud (omit to scan common rates)")
    u.add_argument("--scheme", choices=["int", "avr", "frac16"], default="int",
                   help="int: integer divisor; avr: UBRR+1 register; frac16: 1/16 fractional divisor")
    u.add_argument("--oversample", type=int, default=16, help="oversampling factor (16, 8, ...)")
    u.add_argument("--threshold", type=float, default=2.0, help="max acceptable error in percent")
    u.set_defaults(func=uart_baud)

    args = p.parse_args()
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc