AI Agents in Embedded: The Full Pipeline
Complete project anatomy for AI-agent-driven embedded development. Specs, property tests, review agents, and feedback loops on i.MX8M Plus.
On this page
- The Project Tree
- The Spec
- Four Test Layers
- Layer 1: Property-Based Tests (milliseconds, host)
- Layer 2: Spec-Driven Tests (seconds, target)
- Layer 3: Fuzzing (hours, host)
- Layer 4: Hardware-in-the-Loop (minutes, real hardware)
- CLAUDE.md: What Stays, What Goes
- Review Agents
- The Complete Loop
- Where This Breaks
- Getting Started
The first post in this series built a spec-driven feedback loop. Acceptance criteria in a markdown file, a test wrapper that spits out JSON, an agent that iterates on the structured results. The second post stacked three layers of context on top: CLAUDE.md for hardware constraints, subagents dedicated to review, and feedback with the compiler in the loop.
Both posts explain why each piece matters. Neither one shows what the project actually looks like once you open the folder. This post fills that gap. One project. Every file. How it all fits together.
The target is an IMU sensor driver for i.MX8M Plus. SPI with DMA, built with Yocto scarthgap, tested on a Raspberry Pi 4. I developed it end to end with Claude Code. It’s the same driver I kept using as the running example across the previous two posts. This time I take it apart completely.
The Project Tree
sensor-driver/
├── CLAUDE.md
├── .claude/
│ ├── rules/
│ │ ├── kernel.md
│ │ ├── dts.md
│ │ └── test.md
│ └── agents/
│ ├── supervisor.md
│ ├── concurrency-reviewer.md
│ ├── register-contract-reviewer.md
│ ├── side-effect-reviewer.md
│ ├── coding-standard-reviewer.md
│ └── test-coverage-reviewer.md
├── specs/
│ └── imu-driver.md
├── tests/
│ ├── conftest.py
│ ├── inventory.yaml
│ ├── test_imu_driver.py
│ ├── test_imu_properties.py
│ ├── test_imu_hil.py
│ └── fuzz/
│ ├── corpus/
│ └── harness_spi_parser.c
├── scripts/
│ ├── run_target_tests.sh
│ └── static_analysis.sh
├── docs/
│ └── hardware/
│ └── imu-register-map.md
├── drivers/
│ └── imu/
│ ├── imu_core.c
│ ├── imu_dma.c
│ ├── imu_spi.c
│ └── imu.h
└── meta-sensor/
└── recipes-kernel/
└── linux-imx/
└── sensor-driver.bbappend
Each directory plays its own role in the pipeline. I’ll go through them in the order the agent runs into them during a typical development cycle.
CLAUDE.md — The ~20 lines the agent reads every session. Hardware constraints and learned corrections, nothing more. No project-structure descriptions; the agent infers those from the repo itself. Details are in the context engineering post.
.claude/rules/ — Scoped context that loads only when the agent edits a matching file. kernel.md binds to drivers/**/*.c, dts.md to *.dts and *.dtsi, test.md to tests/**/*.py. Instead of dumping everything into CLAUDE.md, this keeps each context small and focused.
.claude/agents/ — System prompts for the five specialized reviewers and the supervisor. One file defines one agent’s role and checklist, and stops right there. The concurrency and register contract reviewers each spin up 2 instances in parallel for a redundant check. The other three run just once. The supervisor collects the results from every instance.
specs/ — Acceptance criteria written as markdown tables. One file per driver or module. The agent builds its tests from these.
tests/ — Four kinds of tests gather here. test_imu_driver.py holds spec-driven example tests, test_imu_properties.py holds property-based tests (Hypothesis + CFFI), and fuzz/ holds AFL++ harnesses. conftest.py and inventory.yaml are the labgrid config for reaching the target.
scripts/ — Two wrapper scripts. run_target_tests.sh deploys the module, runs pytest, and hands back JSON. static_analysis.sh runs cppcheck and clang-tidy, then dumps JSON.
docs/hardware/ — Register maps, errata notes, and pinout diagrams pulled from datasheets. The register contract reviewer consults these during review.
drivers/imu/ — The actual driver source, split by concern: core logic, DMA handling, SPI communication.
meta-sensor/ — The Yocto layer. The .bbappend slots the driver into the BSP build.
The Spec
Everything starts here. Before the agent writes a single line of driver code, this file already exists:
# IMU Sensor Driver (ICM-42688-P) Acceptance Criteria
## Target
- SoC: i.MX8M Plus (Cortex-A53)
- Interface: SPI (mode 3, 10MHz)
- DMA: SDMA channel, 64-byte aligned buffers
- Test board: Raspberry Pi 4 (Yocto scarthgap, kernel 6.1 LTS)
| ID | Criterion | Metric | Method |
|---|---|---|---|
| AC-1 | Sampling rate 1kHz within 1% tolerance | sample count per second | sysfs counter over 10s |
| AC-2 | Data integrity after 10K DMA transfers | CRC32 match rate = 100% | CRC on raw buffer vs parsed |
| AC-3 | RSS memory < 2MB after 1hr soak | /proc/meminfo delta | stress-ng + periodic sample |
| AC-4 | Recovery within 3s after SPI bus error | time to first valid read | unbind/rebind SPI device |
| AC-5 | IRQ-to-userspace latency < 500us (p99) | timestamp delta | cyclictest-style measurement |
| AC-6 | No data loss under CPU stress | sample gap detection | stress-ng —cpu 4 + counter |
## Constraints
- Kernel 6.1 LTS: no spi-fault-inject module available
- DMA buffers: 64-byte aligned (L1 cache line on Cortex-A53)
- SPI mode 3 (CPOL=1, CPHA=1) for ICM-42688-P
- CTRL_REG1 at 0x4E (not 0x11 as in MPU-6050)
The constraints section matters just as much as the criteria. It stops the agent from generating tests that reach for unavailable tools or set up the hardware wrong. Every constraint traces back to a failure I actually hit during development.
Four Test Layers
The spec drives example-based tests. But example-based tests only cover the scenarios I thought to write down. So four layers catch bugs at progressively deeper levels.
Layer 1: Property-Based Tests (milliseconds, host)
These run on the host, against C code compiled as a shared library. No target hardware needed. CFFI is a Python foreign function interface for calling C functions directly, and it loads the compiled C straight in. Then Hypothesis generates inputs by the thousand and shrinks any failure down to a minimal case. Installing both is one line: pip install cffi hypothesis.
# tests/test_imu_properties.py
from hypothesis import given, strategies as st, settings
import cffi
ffi = cffi.FFI()
ffi.cdef("""
typedef struct {
int16_t accel[3];
int16_t gyro[3];
uint32_t timestamp;
uint8_t status;
} imu_sample_t;
int parse_imu_frame(const uint8_t *buf, int len, imu_sample_t *out);
int build_imu_frame(const imu_sample_t *sample, uint8_t *buf, int max_len);
""")
lib = ffi.dlopen("./build/libimu_host.so")
@given(data=st.binary(min_size=1, max_size=64))
def test_parser_never_crashes(data):
"""No input -- valid, truncated, or garbage -- should crash the parser."""
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)
@given(
accel=st.tuples(*[st.integers(-32768, 32767)] * 3),
gyro=st.tuples(*[st.integers(-32768, 32767)] * 3),
ts=st.integers(0, 2**32 - 1),
)
def test_roundtrip_preserves_values(accel, gyro, ts):
"""Encode then decode must recover the original values."""
sample_in = ffi.new("imu_sample_t *")
for i in range(3):
sample_in.accel[i] = accel[i]
sample_in.gyro[i] = gyro[i]
sample_in.timestamp = ts
buf = ffi.new("uint8_t[64]")
n = lib.build_imu_frame(sample_in, buf, 64)
assert n > 0
sample_out = ffi.new("imu_sample_t *")
assert lib.parse_imu_frame(buf, n, sample_out) == 0
assert tuple(sample_out.accel) == accel
assert tuple(sample_out.gyro) == gyro
assert sample_out.timestamp == ts
@given(data=st.binary(min_size=1, max_size=15))
@settings(max_examples=10000)
def test_short_frames_rejected(data):
"""Frames shorter than minimum valid length must return -1."""
buf = ffi.new("uint8_t[]", data)
sample = ffi.new("imu_sample_t *")
# minimum frame: header(2) + payload(12) + crc(2) = 16 bytes
assert lib.parse_imu_frame(buf, len(data), sample) == -1
This turned up a bug in 10 seconds that I’d missed for three weeks: a specific 7-byte sequence that matched the frame header but had a truncated payload. The parser was reading past the buffer boundary. Hypothesis shrank it to the minimal case on its own.
What it catches: buffer overflows, off-by-one errors, encoding/decoding mismatches, edge cases in protocol parsing. The kind of thing that bites when garbage arrives over SPI from electrical noise, yet no human would ever think to write a test case for.
Layer 2: Spec-Driven Tests (seconds, target)
These run on a real Raspberry Pi 4 through labgrid. One test function per acceptance criterion.
# tests/test_imu_driver.py
import time
def test_sampling_rate_1khz(target):
"""AC-1: Sampling rate 1kHz within 1% tolerance."""
target.run("echo 1000 > /sys/class/imu/imu0/sampling_rate")
target.run("echo 0 > /sys/class/imu/imu0/sample_count")
time.sleep(10)
count = int(target.run("cat /sys/class/imu/imu0/sample_count"))
rate = count / 10.0
assert 990 <= rate <= 1010, f"Rate {rate} Hz outside 1% of 1000 Hz"
def test_dma_data_integrity(target):
"""AC-2: Data integrity after 10K DMA transfers."""
result = target.run("imu_integrity_test --transfers 10000 --verify crc32")
# imu_integrity_test is a small C program deployed to the target
lines = result.strip().split('\n')
last = lines[-1] # "PASS: 10000/10000 CRC match" or "FAIL: 9153/10000"
assert last.startswith("PASS"), f"Integrity check: {last}"
def test_spi_error_recovery(target):
"""AC-4: Recovery within 3s after SPI bus error."""
# Driver exposes force_error sysfs since spi-fault-inject is unavailable on kernel 6.1
target.run("echo 1 > /sys/class/imu/imu0/force_error")
time.sleep(0.5)
start = time.time()
for _ in range(30):
data = target.run("cat /sys/class/imu/imu0/data")
if "error" not in data.lower():
elapsed = time.time() - start
assert elapsed < 3.0, f"Recovery took {elapsed:.1f}s, limit 3s"
return
time.sleep(0.1)
raise AssertionError("Driver did not recover within 3s")
The run_target_tests.sh wrapper deploys the module, runs all the tests, and returns JSON. The agent never has to touch SSH itself.
What it catches: regressions against the acceptance criteria, target-specific timing issues, and integration problems that only surface on real hardware with real peripherals.
Layer 3: Fuzzing (hours, host)
A fuzz harness for the SPI frame parser, compiled with AFL++ instrumentation:
/* tests/fuzz/harness_spi_parser.c */
#include <stdint.h>
#include <stdlib.h>
#include "imu.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size > 256) return 0; /* limit input size */
imu_sample_t sample;
parse_imu_frame(data, (int)size, &sample);
return 0;
}
# Build and run
afl-clang-fast -o fuzz_parser tests/fuzz/harness_spi_parser.c drivers/imu/imu_spi.c -I drivers/imu/
afl-fuzz -i tests/fuzz/corpus -o findings -- ./fuzz_parser
I seed the corpus with valid frames, truncated frames, and known-bad sequences. AFL++ runs overnight in CI. In the first week alone it caught two bugs: an integer overflow in timestamp parsing, and a use-after-free that fired when the CRC check failed on certain frame lengths.
What it catches: memory safety bugs, undefined behavior, crash-inducing inputs. Spec-driven tests never see these, because they only trigger on specific byte patterns no human would produce by hand.
Layer 4: Hardware-in-the-Loop (minutes, real hardware)
labgrid orchestrates the real-hardware tests: DMA under CPU stress, power-cycle recovery, sustained sampling under thermal load. Slow, but it catches timing and peripheral-interaction bugs.
# tests/test_imu_hil.py (runs via labgrid with power control)
def test_dma_under_cpu_stress(target, power):
"""AC-6: No data loss under CPU stress."""
target.run("stress-ng --cpu 4 --timeout 30 &")
target.run("echo 0 > /sys/class/imu/imu0/sample_count")
time.sleep(30)
count = int(target.run("cat /sys/class/imu/imu0/sample_count"))
expected = 30 * 1000 # 30s at 1kHz
loss = expected - count
assert loss == 0, f"Lost {loss} samples under stress ({count}/{expected})"
What it catches: timing and peripheral-interaction bugs under stress, power-cycle recovery behavior, and thermal effects that unit or spec tests let slip.
The four layers form a pyramid. Layer 1 runs in milliseconds on every code change. Layer 2 runs in seconds on every test cycle. Layer 3 runs overnight, Layer 4 on demand or weekly. Most bugs die in layers 1 and 2. The expensive layers pick up whatever slips through.
CLAUDE.md: What Stays, What Goes
A bloated CLAUDE.md burns tokens without making the results any better (the context engineering post covers why). Only content you can’t infer earns its keep.
Here’s what my CLAUDE.md looked like at first, and what it became after trimming:
Before (58 lines, wasteful):
## Project Structure
sensor-driver/ contains the IMU driver source code...
tests/ contains pytest-based tests using labgrid...
meta-sensor/ is the Yocto layer...
## Build
Uses meson build system. Cross-compile with:
meson setup build --cross-file cross-aarch64.ini
ninja -C build
## Testing
Run tests with: ./scripts/run_target_tests.sh build/imu_sensor.ko
The agent infers all of this from meson.build, inventory.yaml, and the folder structure. Those 30-plus lines do nothing.
After (22 lines, focused):
## Hardware
- SoC: i.MX8M Plus (Cortex-A53), SPI IMU: ICM-42688-P
- CTRL_REG1 = 0x4E (not 0x11). SPI mode 3 (CPOL=1, CPHA=1)
- DMA buffers: 64-byte aligned (L1 cache line)
## Learned Corrections
- 2026-01-15: ICM-42688-P CTRL_REG1 is 0x4E, not 0x11
- 2026-01-18: DMA buffers must be 64-byte aligned on Cortex-A53
- 2026-02-03: SPI mode 3 for ICM-42688-P, not mode 0
- 2026-02-22: Kernel 6.1 LTS lacks spi-fault-inject module
- 2026-03-01: spin_lock_bh() in process context, spin_lock() in softirq
## Never Do This
- kmalloc(GFP_KERNEL) or msleep under spin_lock
- CPU access to DMA buffer without dma_sync_single_for_cpu
- Assume register addresses from generic datasheets -- always check ICM-42688-P
Every line traces back to a real bug. The file grows when the agent makes a new mistake, and shrinks again once a linter or a scoped rule takes over enforcing that constraint.
Review Agents
Not every reviewer runs on every change. Each agent checks a file glob when it starts, to figure out whether it has anything to look at:
| Reviewer | Triggers on | Instances |
|---|---|---|
| Concurrency | drivers/**/*.c, drivers/**/*.h | 2 (parallel) |
| Register contract | drivers/**/*.c, *.dts, *.dtsi | 2 (parallel) |
| Side-effect | any non-test file | 1 |
| Coding standard | drivers/**/*.c, drivers/**/*.h | 1 |
| Test coverage | tests/**/*.py, specs/**/*.md | 1 |
The concurrency and register contract reviewers run 2 instances each (I covered why in the context engineering post): a single LLM pass sometimes glides right past an issue that the same model catches on a second run.
A test-only commit wakes just the test coverage reviewer. A device tree change brings up the register contract reviewer and skips concurrency. Most commits touch driver code, so 3-4 reviewers fire with 5-6 total instances. The supervisor pulls all of it together:
Each reviewer’s system prompt follows the same shape: one sentence pinning down its scope, a checklist of specific things to verify, and an explicit order to ignore anything outside that scope.
The concurrency reviewer’s core prompt:
You review ONLY concurrent execution safety. Ignore style, naming, performance.
## Checklist
- [ ] No spinlock held in sleepable context
- [ ] Correct lock variant for execution context
- [ ] DMA buffers synced before CPU access
- [ ] No shared state accessed without protection
- [ ] Lock ordering consistent across all paths
The register contract reviewer:
You verify ONLY that hardware register accesses match the datasheet.
Reference: docs/hardware/imu-register-map.md
## Checklist
- [ ] Register addresses match ICM-42688-P datasheet
- [ ] Bit field masks and shifts correct
- [ ] Read/write direction matches register type
- [ ] Initialization sequence follows datasheet power-on requirements
- [ ] SPI mode, clock speed, and CS polarity correct
The supervisor applies a simple rule. If 2 or more instances or reviewers flag the same issue, that’s high confidence and the agent fixes it on its own. If only 1 flags it, that’s medium confidence: it gets flagged for human review and lands in the report, but never auto-fixed. For a critical reviewer running 2 instances, the moment both instances agree it’s already high confidence.
Before each review pass, scripts/static_analysis.sh runs cppcheck and clang-tidy and emits JSON. That JSON goes into each reviewer’s prompt as extra context. Stack the static-analysis findings on top of the LLM review and you catch more than either one alone.
The Complete Loop
Here’s how one cycle plays out, timed on my setup: Windows laptop host, RPi4 target, wired over ethernet.
| Step | Action | Time |
|---|---|---|
| 1 | Agent reads spec + generates/modifies driver code | ~30s |
| 2 | Cross-compile (aarch64, meson + ninja) | ~15s |
| 3 | Static analysis (cppcheck + clang-tidy to JSON) | ~10s |
| 4 | Property-based tests (Hypothesis, 1000 examples) | ~3s |
| 5 | Deploy + target tests (run_target_tests.sh to JSON) | ~240s |
| 6 | Agent reads all results, fixes failures | ~30s |
| Total | ~5.5min |
Steps 3 and 4 run on the host while step 5 runs on the target. If the property-based tests or static analysis catch a bug first, the agent fixes it before blowing 4 minutes on a target deployment. The ordering is the point: fail fast, fail cheap.
Once the agent’s fix passes every test, the reviewers finally run (~45 seconds total with concurrent API calls). Which reviewers show up depends on the files that changed. Touching driver code brings up both critical reviewers (2 instances each) plus side-effect and coding standard. Changing only tests brings up the test coverage reviewer and nothing else. The supervisor produces a report. High-confidence issues get auto-fixed and re-tested. Medium-confidence issues just get flagged. A typical first pass turns up 1-2 issues, usually locking discipline or a missing DMA sync. By the second iteration the code usually ships clean.
The whole loop, from “here’s the spec” to “all tests pass, all reviewers clear,” takes 2-3 iterations over 15-20 minutes for a typical driver feature. Set that against the manual workflow from the first post: 30-60 minutes per iteration, 3-5 iterations, and happy-path coverage only.
Where This Breaks
The pipeline handles the cases it was built for. Past that is where it stops.
Formal verification. Martin Kleppmann predicts that AI will push formal verification into the mainstream within a few years. The tooling isn’t ready yet for general-purpose embedded driver work. Property-based testing is the closest practical approximation right now.
Physical debugging. When the logic analyzer shows the SPI clock at 8MHz instead of 10MHz, or the DMA transfer completes but the interrupt never fires, the pipeline is no help. That’s oscilloscope territory. What the pipeline covers is “the code is correct according to its spec.” Whether that spec matches physical reality is still a human problem.
Security regression, context degradation, and spec quality get the full treatment in the context engineering post. The short version: iterative refinement can quietly introduce vulnerabilities (the subagent reviewers act as a safety net), the agent starts drifting past the 2-hour mark (use /clear to reset), and the pipeline is ultimately only as good as the spec (writing precise acceptance criteria is the biggest human investment there is; see the TDD post).
Getting Started
You don’t need all of this up front. Each layer catches a different class of bug, and you can stop wherever you like.
| Step | Action | Reference |
|---|---|---|
| 1 | specs/<driver>.md with 5 acceptance criteria | Post 1 |
| 2 | Test wrapper script (deploy + test + JSON) | Post 1 |
| 3 | CLAUDE.md with hardware constraints | Post 2 |
| 4 | One reviewer agent (concurrency or register) | Post 2 |
| 5 | Property-based tests + static analysis in CI | This post |
The agent isn’t the point. The feedback loops are. The agent just makes them cheap enough to actually use.
Comments
Loading comments...