Nobody sells a cheap IMU with PPS input, so I built one

The finished unit: a diecast aluminium box with an SMA connector for PPS and a cable gland for USB

Building custom hardware in the age of LLMs.

The prototype IMU on the bench next to a monitor showing its live orientation and sensor plots in Rerun

The prototype on the bench and its live data in Rerun. Turning the board turns the box.

At Cartio Robotics we needed an IMU (Inertial Measurement Unit) for our data collection platform. The commercially available ones were either not that good, good but expensive, or locked to some proprietary protocol. So I built one.

Just to be clear: I did not build the actual IMU sensor. What I built is everything around it to make it a functional unit: the microcontroller interface, the firmware, the protocol and the enclosure.

It turns out you can get a pretty good IMU with GPS PPS (Pulse Per Second) sync for about 50€ in parts, plus some bits and pieces from your junk box.

Why would you want an IMU?

An IMU measures acceleration and rotation rate along the x, y and z axes, sometimes with a magnetometer thrown in. They range from fibre-optic units on nuclear submarines that cost as much as a car and are good enough to find true north by sensing the Earth’s rotation, to MEMS (Micro-Electro-Mechanical Systems) chips that cost less than a coffee. These are the ones in your phone. The difference between them is mostly how quickly their errors grow.

For lidar and camera odometry and mapping, which is what we do, the IMU earns its keep in three ways.

  • Odometry. It gives the Kalman filter a much better prediction between update steps, rather than the motion model “assuming we kept going at the same speed”.
  • Point cloud deskewing. A spinning lidar takes tens of milliseconds to complete a sweep. If the vehicle moves during the sweep, the cloud smears. The IMU tells you how much so you can correct for it. This is called deskewing.
  • Dead reckoning. When GNSS drops out under trees or between buildings, the IMU keeps you going for a while. Not forever. Drift is the whole game with cheap sensors.

We are also curious about how much performance you can squeeze out of cheap sensors, and about arrays of cheap sensors instead of one very expensive one.

The IMU sensor: TDK ICM-42688-P

I settled on the TDK ICM-42688-P on a widely available breakout board. The most-cited alternative is the Bosch BMI270, which is everywhere in the FPV and flight-controller world. For reference I’ve also put the MPU-6050 in the datasheet comparison at the end of this post. That’s the chip inside most of the cheap “6-axis IMU” modules on AliExpress and in a lot of hobby robots.

I went with the TDK for four reasons:

  1. Availability. Easy to get, at a good price, on a well laid out breakout board.
  2. Software support in Rust (the language I prefer for embedded applications).
  3. Three times better temperature stability on gyro bias, and four times on gyro scale factor. For a sensor that lives in an aluminium box on a vehicle, temperature is the enemy.
  4. Lower noise. The BMI270 has about two and a half times the gyro noise and twice the accelerometer noise.

The microcontroller: RP2040

The ICM-42688-P talks SPI (Serial Peripheral Interface). SPI is a common chip-to-chip bus for short-distance, synchronous, full-duplex communication. To get USB and/or Ethernet you need a microcontroller to bridge the sensor and the host. I used the RP2040 for its low cost, excellent documentation, excellent debugging support and excellent Rust tool chain support.

The RP2040 is a dual-core Cortex-M0+ with a programmable I/O block (PIO) that is handy for odd timing jobs like PPS. Rust support has been solid since the rp2040-hal crate landed in 2021, and Embassy has had first-class support for it for a couple of years now.

The ICM-42688-P breakout board wired to a Raspberry Pi Pico over SPI

SPI wired up

Writing the firmware: Closing the loop

I have been writing software for a very long time, and I used to write all code my hand. Even assembly. For better or worse, I guess those days are gone now. In this project I used Claude Code for rapid prototyping but any modern agent should work, although results may vary. I have found that using Claude Code works really well for firmware development provided you meet some requirements:

Documentation. Put all available documentation in the project repository (data sheets, reference manuals, crate docs) and tell Claude how to search it efficiently. I’m fond of ripgrep-all for this, since it searches inside PDFs and caches the text.

Let Claude guide the hardware. Claude is very good at pin assignment. Give it the data sheet and the board pin out and let it propose which GPIO does what.

Tooling. I use NixOS. All tools and tool chain requirements live in a direnv in the project repo. This avoids dependency hell. NixOS is also very good at managing the udev rules you need for different hardware.

Close the loop. I have found that for best performance, your agent needs a way to inspect and test its own work. With the IMU soldered in, I connect the target board both via USB and via SWD using a Pico Pi SWD debug probe. Claude can then flash, read logs, halt, reset and poke registers without me in the middle.

The whole firmware is about 1800 lines of Rust (using Embassy-rs ).

Firmware overview

The Pico reads the ICM-42688-P over SPI at 1 kHz, driven by the sensor’s data-ready interrupt, and streams every sample as a MAVLink HIGHRES_IMU message over USB. Each message carries the Pico’s own microsecond timestamp of the data-ready edge, so USB latency does not matter. A HEARTBEAT and SYSTEM_TIME go out at 1 Hz, TIMESYNC replies on request, and every PPS edge goes out as a CAMERA_TRIGGER message. MAVLink has no PPS message, but it does have a camera and IMU sync-pulse message, which is the same thing wearing a different hat.

By using the MAVLinks protocl we get easy integration with existing software. It also makes agentic coding easier because our implementation can be cross checked against an existing implementation.

One detail for anyone using the mavlink crate in no_std: serialise each message through its concrete type, not through the MavMessage enum. The enum pulls every message of the common dialect into flash.

Trick one: timestamps in hardware, not in software

The obvious way to timestamp an edge is an interrupt handler that reads the timer. The problem is that the handler runs when the CPU gets to it. With USB traffic, other interrupts and the odd critical section, “when the CPU gets to it” varies by tens of microseconds, and those show up as jitter in the data.

The RP2040 has programmable I/O (PIO) state machines, tiny processors that run independently of the cores. One state machine per input runs this program:

.wrap_target
    wait 0 pin 0
    wait 1 pin 0
    push noblock
.wrap

This is a rising edge detector:

  • wait 0 pin 0 stalls until the input pin is low. The 0 after pin is relative to the state machine’s input base, so it means “the first input pin”, not GPIO 0.
  • wait 1 pin 0 stalls until the same pin goes high. Together the two waits mean the program only gets past this point on a low-to-high transition, once per edge.
  • push noblock copies the state machine’s input shift register into the RX FIFO. Nothing is ever shifted into that register, so the pushed word is always zero. Its value does not matter. Its arrival is the event.
  • .wrap_target and .wrap loop back to the top at no instruction cost.

The RX FIFO going from empty to not empty is wired as the pacing request for a DMA channel whose only job is to copy the 1 MHz timer register into a static variable. So the chain is: pin goes high, state machine pushes, DMA fires, timer value lands in RAM. The edge is stamped within a few clock cycles of happening, whatever the CPU is doing at the time. Software only wakes up afterwards, checks the DMA fired, reads the stamp, and re-arms the channel for the next edge.

pub async fn next(&mut self) -> Result<u64, StampError> {
    self.sm.rx().wait_pull().await;          // an edge happened

    let ch = pac::DMA.ch(self.dma);
    let fired = ch.trans_count().read() == 0 && !ch.ctrl_trig().read().busy();
    let stamp = STAMPS[SM].load(Ordering::Relaxed);
    let overrun = !self.sm.rx().empty();     // more than one edge queued?
    self.arm();                              // ready for the next one

    if !fired   { return Err(StampError::NotFired); }
    if overrun  { return Err(StampError::Overrun); }
    let now = Instant::now().as_micros();
    let age = (now as u32).wrapping_sub(stamp);
    if age > MAX_AGE_US { return Err(StampError::Stale); }
    Ok(now - u64::from(age))                 // extend 32-bit stamp to 64
}

The overrun part is to check that the above does’t fail for some reason.

Trick two: run from RAM

The RP2040 executes code from external flash through a 16 KB cache. A cache miss stalls the core for a few hundred nanoseconds. I saw this as timestamp outliers, and as CPU load that changed between builds for no reason other than what happened to land in the cache.

So at boot, before anything else runs, a short assembly routine copies all code from flash into a 64 KB region of SRAM and the chip runs from there. The linker script puts every .text input section into a .ram_text output section whose load address is in flash and whose run address is in RAM. Only the reset path, the copy loop itself, and the HardFault handler stay in flash. Embassy already defines the pre-init hook, so the linker wraps it:

__wrap___pre_init:
    ldr r0, =__sram_text
    ldr r1, =__eram_text
    ldr r2, =__siram_text
0:  cmp r1, r0
    beq 1f
    ldm r2!, {r3}
    stm r0!, {r3}
    b 0b
1:  ldr r0, =__real___pre_init
    bx r0

(The 64kB limit is checked at build time).

Mechanical

An IMU that is not rigidly mounted is a very expensive random number generator. I put it in a die cast aluminium box, drilled the lid for mounting, and fixed the breakout with standoffs. The SMA connector is the PPS input. The cable gland carries USB. The black gland on the opposite side is a pressure vent to prevent condensation inside the box.

Drilling holes in the lid of the diecast aluminium enclosure

Drilling the enclosure.

The breakout board on brass standoffs inside the open box, with the PPS coax running to the SMA connector

Everything in the box. Breakout on brass standoffs, PPS coax to the SMA.

Conclusion

When I wrote this code, only God and I knew how it works. Now, only God knows it.

For about 50€ in parts I now have the IMU I could not buy: open hardware, open firmware, a standard protocol, and a PPS input so it shares a clock with the GNSS receiver and the lidar.

I am blown away by what a modern LLM can do in firmware and hardware development. The agent came up with tricks I am fairly sure I would not have found myself: timestamps captured in hardware, running from RAM, a HardFault handler that hides its evidence in the watchdog’s scratch registers. If you know how to build hardware, and you set up a debug environment where the agent can flash, probe and test its own work, it will accelerate firmware development enormously. What used to take months now takes days.

The price is intimacy with the code. When you have not written every line yourself, you do not know it the way you used to. The quote at the top of this section used to be a joke about forgetting. Now it’s true from day one. For a prototype I think that is a good trade. For code you have to maintain for years, I am less sure. Or, as SpongeBob would ask: Where is the love?

Datasheet comparison

Typical values from each vendor’s datasheet for the ICM-42688-P, the BMI270 and the MPU-6050, as referenced from the sensor section :

Parameter ICM-42688-P BMI270 MPU-6050 What it limits
Gyro noise density 2.8 mdps/√Hz 7 mdps/√Hz (performance mode) 5 mdps/√Hz Short-term heading noise
Initial gyro offset ±0.5 °/s ±0.5 °/s ±20 °/s Must be calibrated out at startup
Gyro zero-rate drift vs temp ±0.005 °/s/°C ±0.015 °/s/K ±20 °/s over -40 to 85 °C (≈0.16 °/s/°C) How long dead reckoning stays useful
Gyro sensitivity drift vs temp ±0.005 %/°C 0.02 %/K ±2 % over the range Heading error proportional to how much you turn
Gyro cross-axis sensitivity ±1.25 % 0.2 % ±2 % Turn leaks into the other axes
Accel noise density 65 to 70 µg/√Hz 160 µg/√Hz 400 µg/√Hz Short-term velocity noise
Initial accel offset ±20 mg ±20 mg ±50 mg X/Y, ±80 mg Z Must be calibrated out
Accel zero-g drift vs temp ±0.15 mg/°C ±0.25 mg/K ±35 mg X/Y, ±60 mg Z over 0 to 70 °C (≈0.5 to 0.9 mg/°C) Position drift over a long run
Rust driver icm426xx bmi270 mpu6050