← Essays /Post · 26 of 28 · Methods

AI Agents in Embedded: The Spec-Driven TDD We Put Off

Embedded teams skipped TDD for its cost. With AI agents I watched that math change. Notes on testing a Yocto IMU driver from a spec, with JSON feedback.

·9 min read · · · #embedded-linux#ai-agents#testing#developer-workflow#yocto
AI Agents in Embedded: The Spec-Driven TDD We Put Off
On this page

I once asked Claude Code to test an IMU sensor driver on a Raspberry Pi 4 running Yocto. It ran ssh pi lsmod | grep imu, then ssh pi cat /sys/class/imu0/data, then ssh pi dmesg | tail -30. Three tool calls. Fifteen seconds of wall time. Then it declared the driver working, on the grounds that lsmod showed the module loaded and /sys/class/imu0/data returned numbers.

The numbers were garbage. A missing cache sync meant the DMA transfer was corrupting every third sample. dmesg had recorded all of it: intermittent Unhandled fault entries scattered among the normal log lines. The agent read only the last 30 lines, and that window happened to fall on a clean stretch.

That is roughly where AI-assisted embedded Linux development stood in March 2026. The tools work. The workflow doesn’t.

I think the answer is TDD. Embedded teams have known that for years. They skipped it because writing and running tests against target hardware cost too much. Cross-compile, deploy, SSH in, read logs, and start over. The agent changes that calculation. It generates tests from a spec, runs them through a structured wrapper, and keeps working at failures with no human in the loop. The practice isn’t new. What’s new is the economics.

Every example in this series runs on Claude Code, Anthropic’s agentic coding tool. The workflow principles (spec-driven testing, structured feedback, context engineering) carry over to other AI coding agents. CLAUDE.md, subagent orchestration, and /clear are specific to Claude Code.

The Broken Inner Loop

The broken inner loop of embedded driver development: a 4-step cycle of Edit Code, Cross-Compile and Deploy, SSH and Load Module, and Read dmesg and Interpret. AI accelerates only the Edit step, while the manual bottleneck of deploy-test-interpret takes 2-3 minutes per iteration across 10-20 iterations, totaling 30-60 minutes per feature.

A RunSafe Security survey (published January 2026, covering 2025 adoption) puts 80%+ of embedded teams on AI tools now, with 83% having shipped AI-generated code to production. I would discount the exact numbers a little, since the respondents lean toward early adopters. The direction is clear anyway. Adoption is nearly universal. What remains open is what people do with these tools.

Mostly they paste AI output into the same edit-compile-scp-ssh-test cycle from 2015. The agent writes code faster, but code-generation speed was never where the time went. The time goes into the deploy-test-interpret cycle between “code written” and “code verified.” On a Raspberry Pi 4 running a Yocto scarthgap image, one lap of cross-compile plus scp, insmod, and a dmesg check takes 2-3 minutes at minimum. You run that 10-20 times per driver feature. So 30-60 minutes per feature go into loading kernel modules, reading dmesg, adding printk, and redeploying. Most of that is waiting and staring at terminal output.

Fixing the Feedback Loop

Spec-driven feedback loop: spec.md with 6 acceptance criteria feeds an LLM agent that generates tests, calls a single wrapper script crossing the host-target boundary, and receives structured JSON results in 4 seconds. The before/after comparison shows 30-60 minutes reduced to 3-8 minutes, roughly an 8x speedup.

After six months of using Claude Code on embedded Linux projects (Yocto BSP, kernel modules, SPI/I2C sensor drivers), the difference came from two structural changes. Better prompts and smarter models had little to do with it. What mattered was giving the agent a feedback channel it could actually use.

Write the Spec First

This was the change with the largest effect. Before the agent writes any code, I have it write a spec.md with testable acceptance criteria.

Without a spec, the agent checks only the happy path. It asks “does the module load?” and “can I read from sysfs?” and stops there. That the IMU driver has to sustain 1kHz sampling within 1% tolerance, that data must survive 10,000 consecutive DMA transfers without corruption, that RSS has to stay under 2MB after an hour of running: the agent has no way of knowing any of that.

IDCriterionMetric
AC-1Sampling rate 1kHz within 1% tolerancesample count per second
AC-2Data integrity after 10K DMA transfersCRC32 match rate
AC-3RSS memory < 2MB after 1hr soakps RSS measurement
AC-4Recovery within 3s after SPI bus errorfault inject + timer
AC-5IRQ-to-userspace latency < 500ustimestamp delta
AC-6No data loss under CPU stressstress-ng + sample count

A Chalmers/University of Gothenburg study calls a related idea “AI-Friendly Artifacts,” meaning structured documentation that AI tools can parse reliably. The spec table above didn’t come out of that work, but the intuition underneath is the same: give the agent structured input and structured behavior comes back. In practice the agent writes one test function per acceptance criterion, and the results map 1:1 to the spec. Coverage becomes traceable. The gaps become visible.

The spec also holds hardware-specific constraints the agent can’t infer from code alone: sampling tolerances, latency budgets, memory ceilings, how the driver should recover from a fault. If those are never written down, the agent produces the kind of tests a web developer would write. They run, and they say nothing about the constraints that matter on a target device.

Return Structured Test Results

The ad-hoc SSH commands get replaced by one single-command wrapper that deploys, tests, and returns JSON in a single shot. One round-trip instead of N.

L1 (where most people are): The agent fires SSH commands one at a time. ssh target lsmod, ssh target cat /sys/class/imu0/data, ssh target dmesg | tail. 3-5 seconds per call. The agent reads unstructured terminal output by eye. That’s how I started too, and it’s exactly why the DMA corruption bug above slipped through.

L2 (an afternoon’s work if you already have pytest on the host and SSH access to the target): A wrapper script that runs scp, insmod, and pytest in one go and prints structured JSON. The agent reads pass/fail results instead of guessing from raw logs. Setting labgrid up from scratch adds about a day for inventory.yaml, SSH key setup, and Yocto package configuration.

L3 (an investment for mature projects): A test daemon that lives on the target. The agent sends a POST request and JSON comes back. It is the fastest of the three. The cost is maintaining target-side infrastructure.

Here’s the L2 wrapper I use for an IMU sensor driver on a Yocto-based Pi 4. labgrid (from Pengutronix) handles the target abstraction, and pytest-json-report (pip install pytest-json-report) produces machine-readable output:

#!/bin/bash
set -euo pipefail

MODULE_PATH="${1:?Usage: $0 <module-path>}"
TARGET="${TARGET_HOST:-192.168.1.100}"
TEST_DIR="$(dirname "$0")/tests"
REPORT_FILE=$(mktemp /tmp/test-report-XXXXXX.json)

# 1. Deploy module
scp -q "$MODULE_PATH" "root@${TARGET}:/lib/modules/$(ssh root@${TARGET} uname -r)/extra/"
ssh -q "root@${TARGET}" "depmod -a && modprobe -r imu_sensor 2>/dev/null; modprobe imu_sensor"

# 2. Run tests on host -- labgrid reaches the target via SSH
cd "$TEST_DIR"
pytest --lg-env ../inventory.yaml \
       --json-report --json-report-file="$REPORT_FILE" \
       -q 2>/dev/null || true

cat "$REPORT_FILE"
rm -f "$REPORT_FILE"

|| true keeps the script going after a test failure so it still writes the JSON report. Infrastructure failures (pytest crash, missing labgrid config, unreachable target) can leave an empty or malformed report. For production use, it is worth checking that the report file is non-empty and capturing stderr to a log.

When the agent calls ./run_target_tests.sh build/imu_sensor.ko, structured JSON comes back (trimmed here; the real pytest-json-report nests error detail inside a call stage object):

{
  "created": 1773628800,
  "duration": 4.2,
  "exitcode": 1,
  "summary": {"passed": 4, "failed": 2, "total": 6},
  "tests": [
    {"nodeid": "test_imu::test_module_loads", "outcome": "passed"},
    {"nodeid": "test_imu::test_sysfs_readable", "outcome": "passed"},
    {"nodeid": "test_imu::test_sampling_rate_1khz", "outcome": "passed"},
    {"nodeid": "test_imu::test_dma_data_integrity", "outcome": "failed",
     "longrepr": "AssertionError: CRC mismatch in 847 of 10000 transfers (91.5% pass, need 100%)"},
    {"nodeid": "test_imu::test_memory_soak", "outcome": "passed"},
    {"nodeid": "test_imu::test_irq_latency", "outcome": "failed",
     "longrepr": "AssertionError: p99 latency 1.2ms exceeds 500us limit"}
  ]
}

One round-trip. Four seconds. Structured pass/fail. The agent can see what failed and why, right away.

The DMA corruption bug from the opening was caught by AC-2 on the first run. The agent added the missing dma_sync_single_for_cpu() call and re-ran. From bug to fix took under 3 minutes. The Embedded Kit published similar Yocto test-automation infrastructure using their Pluma tool across 10 DUTs. I read that as evidence the pattern scales to production CI.

Beyond Example-Based Tests: Property-Based Testing

The spec-driven approach works well for acceptance criteria you already know. There is one class of bug it misses, though: the edge cases nobody thought to specify.

Example-based tests (AC-1 through AC-6) cover only the scenarios you wrote down yourself. Suppose your SPI frame parser has a buffer-handling bug that trips on one specific byte sequence. Unless you happened to foresee that exact sequence and write it in, no acceptance criterion will catch it.

Property-based testing fills that gap. Instead of writing test cases one by one, you define an invariant (“for any valid input, this property holds”) and let a generator sweep thousands of inputs on its own. When it finds a failure, it shrinks the input down to the smallest case that still reproduces it.

For embedded C that can run on the host, the pattern goes like this. Compile the module as a shared library, load it through Python CFFI (a foreign function interface that calls C functions directly), and keep feeding it inputs with Hypothesis. Install with pip install cffi hypothesis:

# tests/test_imu_properties.py
from hypothesis import given, strategies as st
import cffi

ffi = cffi.FFI()
ffi.cdef("""
    typedef struct { int16_t accel[3]; int16_t gyro[3]; uint32_t timestamp; } imu_sample_t;
    int parse_imu_frame(const uint8_t *buf, int len, imu_sample_t *out);
""")
lib = ffi.dlopen("./build/libimu_host.so")  # cc -shared -o build/libimu_host.so imu_spi.c

@given(data=st.binary(min_size=1, max_size=64))
def test_parser_never_crashes(data):
    """No input should crash the parser. It either succeeds or returns an error code."""
    buf = ffi.new("uint8_t[]", data)
    sample = ffi.new("imu_sample_t *")
    ret = lib.parse_imu_frame(buf, len(data), sample)
    assert ret in (0, -1)  # 0 = valid frame, -1 = parse error

This finishes in milliseconds on the host, with no target hardware at all. Hypothesis found a bug in my frame parser within 10 seconds: a specific byte combination that looked like a valid header but carried a truncated payload, which made the parser read past the buffer. The shrunk failing case was 7 bytes. I would never have hand-written a test aimed at that sequence.

Property-based tests cover the logic layer. Spec-driven tests cover the integration layer. Used together, they catch bugs at both. The full pipeline post shows how these fit with fuzzing and hardware-in-the-loop validation in a four-layer testing pyramid.

Where the Approach Breaks

The spec-driven wrapper covers roughly half of what matters on a typical embedded Linux driver. The other half looks like this.

Concurrency. Race conditions, lock contention, priority inversion. The agent can run ThreadSanitizer or Helgrind. What it can’t do is design the workload that triggers a specific race. That takes someone who has met that class of bug before.

Hardware faults. Power loss, a stuck SPI bus, interrupt storms. The agent can write software fault injection (stress-ng, forced module unload/reload). Physical faults need physical equipment. labgrid does support power control through PDUs and USB power switches (e.g. SiS-PM), but someone still has to wire it up and decide what “recovery” means.

Agent autonomy itself. Not all driver code carries the same risk. I let the agent generate test suites and Yocto recipes without much oversight. Code that touches register configuration, interrupt handlers, or DMA setup, I review line by line. Knowing where to draw that line takes the kind of experience the agent doesn’t have.

Even inside the area where this works well, there is friction. Last week the agent wrote an AC-4 test (SPI bus error recovery) that reached for spi-fault-inject, a tool living in a newer kernel staging tree. Our Yocto image is pinned to kernel 6.1 LTS, which doesn’t ship that module. The test broke on the target with modprobe: FATAL: Module spi_fault_inject not found. The agent had no way to know until I wrote the kernel version constraint into CLAUDE.md (the project-root file the agent reads for persistent constraints). Detours like this cost 10-15 minutes just to diagnose, and they never appear in the tidy workflow write-ups.

The new bottleneck is spec quality. Give the agent a vague spec (“driver should handle errors”) and it produces vague tests that pass trivially. Give it a precise one (“recover within 3s after an SPI bus error, re-initialize the sensor, resume sampling without a data gap”) and it produces tests that catch real bugs.

The rest needs human-designed scenarios, long-running CI, and physical test equipment. When someone claims automated coverage past that point, I assume they are selling something.

Where to Start

If I were starting again, I would pick one driver or kernel module I was already working on. Write a spec.md with 5 acceptance criteria. Wire up the pytest plus labgrid wrapper (or even a bash script that runs a few checks and prints pass/fail). Have the agent generate tests from the spec, and run them with one command. What a structured feedback loop feels like is something you only learn by going through it.

If there is a parser or a data-processing module, a Hypothesis property test is worth adding. Compile the C to a .so, load it with CFFI, define one invariant. You get to watch it surface an edge case you missed.

The moment the agent reads a JSON test failure and fixes the bug in under a minute, something falls into place. The agent didn’t get smarter. The feedback loop finally got fast enough to iterate on.

We always knew TDD was the right call for embedded. We just couldn’t afford it. Now we can.

The next post is about why the agent still writes broken DMA handlers even with a perfect spec, and how context engineering catches that. The final post puts the complete project anatomy in one place. For why this work lands on embedded engineers in the first place, see AI Finally Needs Hardware Engineers.

ecro
Written by ecro

Created an LLM benchmark for firmware. EmbedEval →

Building a project-shaped agent harness for Claude Code, Cursor, and Codex. harness-maker →

Building a serial and SSH terminal where coding agents work on the board beside you. NeuroTerm →

Comments

Loading comments...