The PCB arrived on a Tuesday. Six-axis IMU — TDK InvenSense ICM-42688-P. 24 MHz SPI, sub-degree-per-second noise density, 2.5 × 3 mm LGA package. I'd done SPI before. I figured I'd have data rolling in by lunch.
I did not have data rolling in by lunch.
The ICM-42688-P is a genuinely excellent chip. It's also one of those datasheets where the answer to every weird thing that happens to you is on page 47, in a note, in a different font. By the time I had a clean driver running I'd tripped five separate initialization traps — each one invisible until the moment it wasn't. This is the post I wish I'd found when I cracked open that PDF.
All of the register addresses, bit fields, timing values, and conversion formulas below come directly from the TDK InvenSense ICM-42688-P datasheet (DS-000347 Rev 1.6). I cross-checked everything against the full extracted text before writing a single line of code.
What the ICM-42688-P actually is
Three-axis gyroscope, three-axis accelerometer, temperature sensor, 2048-byte FIFO. The gyro goes up to ±2000 dps, the accel up to ±16g, both at up to 32 kHz ODR. It runs from 1.71 V to 3.6 V so it's fine on a 3.3 V ESP32 supply. SPI tops out at 24 MHz. Current draw in full 6-axis low-noise mode is 0.88 mA — good enough for battery-powered applications.
The part that catches everyone: it has five register banks. Bank 0 contains all sensor data registers. Banks 1 and 2 contain filter configuration. Bank 4 contains APEX (pedometer, tap detection, tilt) and user offset registers. Bank 3 is empty. Every register in every bank shares the same 7-bit address space, and you switch between them by writing a single bank-select register. That register, REG_BANK_SEL, lives at address 0x76 in every bank.
Hardware setup
Wiring on an ESP32, using the VSPI peripheral:
| ICM-42688-P pin | ESP32 pin |
|---|---|
| VDD, VDDIO | 3.3 V |
| GND | GND |
| SPI_SCK (CLK) | GPIO 18 |
| SPI_SDI (MOSI) | GPIO 23 |
| SPI_SDO (MISO) | GPIO 19 |
| SPI_CS (CS) | GPIO 5 |
| INT1 | GPIO 4 |
| AP_AD0 | GND (selects I²C address 0x68, doesn't matter in SPI mode) |
Pull CS high through a 10 kΩ resistor so it doesn't float during power-on. The chip won't respond to SPI until VDD has been stable for at least 1 ms.
Start here: the header file
Every register we need, in one place:
#pragma once
#include <stdint.h>
#include <stdbool.h>
/* -------------------------------------------------------
Register addresses — Bank 0 unless noted
------------------------------------------------------- */
#define ICM_REG_DEVICE_CONFIG 0x11 // Soft reset, SPI mode select
#define ICM_REG_INT_CONFIG 0x14 // INT1/INT2 pin polarity, drive, mode
#define ICM_REG_FIFO_CONFIG 0x16 // FIFO mode (bypass/stream/stop-on-full)
#define ICM_REG_TEMP_DATA1 0x1D // Temperature MSB
#define ICM_REG_ACCEL_DATA_X1 0x1F // Accel X MSB (burst read starts here)
#define ICM_REG_GYRO_DATA_X1 0x25 // Gyro X MSB
#define ICM_REG_INT_STATUS 0x2D // Interrupt status (read-to-clear)
#define ICM_REG_FIFO_COUNTH 0x2E // FIFO byte count MSB
#define ICM_REG_FIFO_COUNTL 0x2F // FIFO byte count LSB
#define ICM_REG_FIFO_DATA 0x30 // FIFO read port
#define ICM_REG_SIGNAL_PATH_RESET 0x4B // FIFO flush (bit 1)
#define ICM_REG_INTF_CONFIG0 0x4C // Endianness, SPI/I2C config
#define ICM_REG_PWR_MGMT0 0x4E // Gyro/accel enable, power mode
#define ICM_REG_GYRO_CONFIG0 0x4F // Gyro FSR and ODR
#define ICM_REG_ACCEL_CONFIG0 0x50 // Accel FSR and ODR
#define ICM_REG_FIFO_CONFIG1 0x5F // Which data goes into FIFO
#define ICM_REG_INT_CONFIG1 0x64 // INT_ASYNC_RESET — READ THIS NOTE
#define ICM_REG_INT_SOURCE0 0x65 // Route interrupts to INT1
#define ICM_REG_WHO_AM_I 0x75 // Read-only, always returns 0x47
#define ICM_REG_BANK_SEL 0x76 // Bank select — same address in all banks
/* -------------------------------------------------------
Identity
------------------------------------------------------- */
#define ICM42688_WHOAMI 0x47 // Expected value of WHO_AM_I
/* -------------------------------------------------------
SPI protocol
------------------------------------------------------- */
#define ICM_SPI_READ 0x80 // OR with address for read transactions
#define ICM_SPI_WRITE 0x00 // Bit 7 = 0 for writes (default)
/* -------------------------------------------------------
PWR_MGMT0 (0x4E) field values
------------------------------------------------------- */
#define ICM_GYRO_OFF (0x00 << 2)
#define ICM_GYRO_STANDBY (0x01 << 2) // Drive on, data NOT valid
#define ICM_GYRO_LN (0x03 << 2) // Low-noise mode
#define ICM_ACCEL_OFF (0x00 << 0)
#define ICM_ACCEL_LP (0x02 << 0) // Low-power mode
#define ICM_ACCEL_LN (0x03 << 0) // Low-noise mode
#define ICM_TEMP_ENABLE (0x00 << 5) // Bit 5 = 0 → temp sensor on
/* -------------------------------------------------------
Scale factors (16-bit mode)
------------------------------------------------------- */
// Gyro: maps GYRO_UI_FS_SEL[2:0] → LSB per dps
static const float ICM_GYRO_SENS[] = {
16.4f, // 000 → ±2000 dps
32.8f, // 001 → ±1000 dps
65.5f, // 010 → ±500 dps
131.0f, // 011 → ±250 dps
262.0f, // 100 → ±125 dps
524.3f, // 101 → ±62.5 dps
1048.6f, // 110 → ±31.25 dps
2097.2f, // 111 → ±15.625 dps
};
// Accel: maps ACCEL_UI_FS_SEL[2:0] → LSB per g
static const float ICM_ACCEL_SENS[] = {
2048.0f, // 000 → ±16g
4096.0f, // 001 → ±8g
8192.0f, // 010 → ±4g
16384.0f, // 011 → ±2g
};
/* -------------------------------------------------------
Output data structure
------------------------------------------------------- */
typedef struct {
float ax, ay, az; // Acceleration (g)
float gx, gy, gz; // Angular rate (°/s)
float temp_c; // Temperature (°C)
bool valid; // false if startup sentinel (0x8000) detected
} icm42688_data_t;The SPI layer
The ICM-42688-P uses a simple one-byte address + N-byte data protocol. Bit 7 of the address byte selects read (1) or write (0). Everything is MSB-first.
#include "icm42688.h"
#include <SPI.h>
#include <Arduino.h>
#define ICM_CS_PIN 5
#define ICM_SCK_PIN 18
#define ICM_MOSI_PIN 23
#define ICM_MISO_PIN 19
#define ICM_INT1_PIN 4
// SPI clock: 8 MHz is well within the 24 MHz max and safe for most board layouts.
// The device defaults to SPI Mode 0 (CPOL=0, CPHA=0) after power-on.
static SPISettings icm_spi_settings(8000000, MSBFIRST, SPI_MODE0);
static void icm_write(uint8_t reg, uint8_t val) {
SPI.beginTransaction(icm_spi_settings);
digitalWrite(ICM_CS_PIN, LOW);
SPI.transfer(reg & 0x7F); // bit 7 = 0 → write
SPI.transfer(val);
digitalWrite(ICM_CS_PIN, HIGH);
SPI.endTransaction();
}
static uint8_t icm_read(uint8_t reg) {
SPI.beginTransaction(icm_spi_settings);
digitalWrite(ICM_CS_PIN, LOW);
SPI.transfer(reg | ICM_SPI_READ); // bit 7 = 1 → read
uint8_t val = SPI.transfer(0x00);
digitalWrite(ICM_CS_PIN, HIGH);
SPI.endTransaction();
return val;
}
// Burst read: assert CS once and clock out `len` bytes starting at `reg`.
// The device auto-increments the address for contiguous register blocks.
static void icm_read_burst(uint8_t reg, uint8_t *buf, size_t len) {
SPI.beginTransaction(icm_spi_settings);
digitalWrite(ICM_CS_PIN, LOW);
SPI.transfer(reg | ICM_SPI_READ);
for (size_t i = 0; i < len; i++) {
buf[i] = SPI.transfer(0x00);
}
digitalWrite(ICM_CS_PIN, HIGH);
SPI.endTransaction();
}
static void icm_set_bank(uint8_t bank) {
// REG_BANK_SEL lives at 0x76 in every bank — safe to write without switching first
icm_write(ICM_REG_BANK_SEL, bank & 0x07);
}Before going further, verify the connection works:
void setup() {
Serial.begin(115200);
pinMode(ICM_CS_PIN, OUTPUT);
digitalWrite(ICM_CS_PIN, HIGH);
SPI.begin(ICM_SCK_PIN, ICM_MISO_PIN, ICM_MOSI_PIN, ICM_CS_PIN);
delay(10);
uint8_t who = icm_read(ICM_REG_WHO_AM_I);
Serial.printf("WHO_AM_I = 0x%02X (expected 0x%02X)\n", who, ICM42688_WHOAMI);
}If WHO_AM_I returns 0x47, SPI is working. If it returns 0x00 or 0xFF, check your wiring and SPI mode before going any further. The WHO_AM_I register is readable from any register bank without switching — it's one of the few registers guaranteed to work immediately after power-on.
Now: the traps.
Trap 1: Everything is off by default
You configure the ODR and FSR, then read sensor data. All values come back as 0x80 0x00 — that's 0x8000 as a 16-bit integer, which is -32768 in signed arithmetic. It never changes. The chip is not broken. The chip is asleep.
From the datasheet, PWR_MGMT0 (0x4E) reset value is 0x00. That means:
bits 3:2 GYRO_MODE = 00 → gyro OFF
bits 1:0 ACCEL_MODE = 00 → accel OFF
bit 5 TEMP_DIS = 0 → temp sensor enabled, but not useful when accel/gyro are offEvery register write you did to GYRO_CONFIG0 and ACCEL_CONFIG0 was accepted — the registers hold your values — but the sensors themselves are not running. There is no implicit "power on" when you write configuration registers. You have to explicitly enable the sensors.
Trap 2: The 200 µs write lockout after PWR_MGMT0
Once you know about PWR_MGMT0, the temptation is to write your full initialization sequence in one shot:
// Naïve initialization — this silently corrupts a register write
icm_write(ICM_REG_GYRO_CONFIG0, 0x06); // ±2000 dps, 1 kHz
icm_write(ICM_REG_ACCEL_CONFIG0, 0x06); // ±16g, 1 kHz
icm_write(ICM_REG_PWR_MGMT0, 0x0F); // Enable gyro LN + accel LN
icm_write(ICM_REG_INT_CONFIG1, 0x00); // <-- this write may be silently droppedThe PWR_MGMT0 register description in the datasheet warns that after changing the GYRO_MODE or ACCEL_MODE bits, you must not write to any other register for at least 200 µs.
The write to INT_CONFIG1 lands within a few microseconds of the PWR_MGMT0 write and is silently discarded. The register keeps its reset value of 0x10. You won't see an error — the SPI transaction completes normally. The data just doesn't get written.
The fix is one line:
icm_write(ICM_REG_PWR_MGMT0, 0x0F);
delayMicroseconds(250); // mandatory: 200 µs minimum
icm_write(ICM_REG_INT_CONFIG1, 0x00); // now safe to writeTrap 3: The gyroscope takes 30 milliseconds to stabilize
You've written PWR_MGMT0, you've waited 250 µs, you've written all your other registers. You poll the data immediately and the first handful of samples look wrong — large, noisy, or exactly -32768. The accelerometer data looks fine. Only the gyro is bad.
The accelerometer startup time from sleep to valid data is around 10 ms. The gyroscope startup time is 30 ms typical. During that 30 ms window, the gyro data registers hold the sentinel value 0x8000 (-32768 in signed 16-bit). Reading before the gyro is ready gives you garbage.
The same sentinel also appears if you lose power momentarily or if the FIFO holds an empty slot with FIFO_HOLD_LAST_DATA_EN = 0 (the default). So you need to check for it at runtime too, not just during startup.
// After enabling sensors, wait for startup before trusting data
icm_write(ICM_REG_PWR_MGMT0, 0x0F);
delayMicroseconds(250);
delay(50); // 50 ms covers gyro (30 ms typ) and accel (10 ms) with marginAnd in the data read path, always validate before using the values:
int16_t gx_raw = (int16_t)((raw[8] << 8) | raw[9]);
if (gx_raw == (int16_t)0x8000) {
// Gyro not yet valid — discard this sample
return false;
}Trap 4: The interrupt pin that never fires
You configure INT_SOURCE0 to route DATA_RDY to INT1. You configure the pin as push-pull active-high. You attach your ISR. Nothing happens. The data-ready interrupt never fires, even though polling the sensor data works perfectly.
The culprit is INT_CONFIG1 (0x64), which has a reset value of 0x10 — bit 4 set. The datasheet calls this bit INT_ASYNC_RESET. Section 12.6 says:
"After device power-up, user should change setting to 0 from default setting of 1, for proper INT1 and INT2 pin operation."
With INT_ASYNC_RESET = 1 (the factory default), neither INT1 nor INT2 will function correctly. Clearing it is mandatory, not optional.
// REQUIRED: clear INT_ASYNC_RESET before using any interrupt
// Default value is 0x10 (bit 4 = 1). Must be set to 0.
icm_write(ICM_REG_INT_CONFIG1, 0x00);This is the gotcha that has its own dedicated section in the datasheet and still trips everyone. I spent an afternoon with a logic analyser on INT1 before finding this.
Trap 5: Bank switching — always come home to Bank 0
You need to configure the anti-aliasing filter bandwidth in Bank 1. You write REG_BANK_SEL to 0x01, configure your registers, then start reading sensor data. Everything comes back as 0x00.
The sensor data registers live in Bank 0. After switching to Bank 1, any read to address 0x1F (where ACCEL_DATA_X1 lives in Bank 0) returns whatever Bank 1 has at that address — which is a completely different register, probably reserved.
The rule is absolute: always switch back to Bank 0 after accessing any other bank.
// Safe bank-scoped write helper
static void icm_write_bank(uint8_t bank, uint8_t reg, uint8_t val) {
icm_set_bank(bank);
icm_write(reg, val);
icm_set_bank(0); // always return to Bank 0
}The five-bank layout for reference:
| Bank | Key contents |
|---|---|
| 0 | All sensor data, PWR_MGMT0, GYRO/ACCEL_CONFIG, FIFO, INT config, WHO_AM_I |
| 1 | Gyro AAF/notch filter config (GYRO_CONFIG_STATIC2–10), timestamp |
| 2 | Accel AAF config (ACCEL_CONFIG_STATIC2–4), self-test data |
| 3 | (no user-accessible registers) |
| 4 | APEX config (pedometer, tilt, tap, WOM), user offset registers |
For most designs you'll only ever touch Bank 0. Only go to Banks 1, 2, or 4 if you need to tune filters or read self-test data — and always come back.
The complete initialization sequence
With all five traps mapped, here is the correct initialization order:
bool icm42688_init(void) {
// 1. SPI peripheral setup
pinMode(ICM_CS_PIN, OUTPUT);
digitalWrite(ICM_CS_PIN, HIGH);
SPI.begin(ICM_SCK_PIN, ICM_MISO_PIN, ICM_MOSI_PIN, ICM_CS_PIN);
// 2. Wait for device power-on (VDD stable → register access available)
delay(10);
// 3. Verify device identity before touching anything else
uint8_t who = icm_read(ICM_REG_WHO_AM_I);
if (who != ICM42688_WHOAMI) {
Serial.printf("[ICM] WHO_AM_I = 0x%02X, expected 0x%02X\n", who, ICM42688_WHOAMI);
return false;
}
// 4. Soft reset — returns all registers to reset values, clears FIFO
icm_write(ICM_REG_DEVICE_CONFIG, 0x01); // SOFT_RESET_CONFIG = 1
delay(2); // Reset completes in < 1 ms; 2 ms is safe
// 5. TRAP 4: Clear INT_ASYNC_RESET — default 0x10 breaks INT pins
// Must be done before configuring any interrupt routing
icm_write(ICM_REG_INT_CONFIG1, 0x00);
// 6. Configure gyroscope: ±2000 dps, 1 kHz ODR
// GYRO_CONFIG0 [7:5] = 000 (±2000 dps), [3:0] = 0110 (1 kHz)
icm_write(ICM_REG_GYRO_CONFIG0, 0x06);
// 7. Configure accelerometer: ±16g, 1 kHz ODR
// ACCEL_CONFIG0 [7:5] = 000 (±16g), [3:0] = 0110 (1 kHz)
icm_write(ICM_REG_ACCEL_CONFIG0, 0x06);
// 8. TRAP 1: Enable sensors — device is OFF by default after reset
// GYRO_MODE = 11 (LN), ACCEL_MODE = 11 (LN), TEMP_DIS = 0
icm_write(ICM_REG_PWR_MGMT0, ICM_GYRO_LN | ICM_ACCEL_LN | ICM_TEMP_ENABLE);
// 9. TRAP 2: Mandatory 200 µs wait after changing PWR_MGMT0
delayMicroseconds(250);
// 10. Configure INT1: push-pull, active-high, pulsed (100 µs pulse width)
// INT_CONFIG [2:0]: bit2=mode(0=pulsed), bit1=drive(1=push-pull), bit0=polarity(1=active-high)
icm_write(ICM_REG_INT_CONFIG, 0x03);
// 11. Route DATA_RDY interrupt to INT1 pin
// INT_SOURCE0: bit 3 = UI_DRDY_INT1_EN
icm_write(ICM_REG_INT_SOURCE0, 0x08);
// 12. TRAP 3: Wait for sensors to reach valid state
// Gyro: ~30 ms typical; accel: ~10 ms. Use 50 ms to be safe.
delay(50);
Serial.println("[ICM] Init complete");
return true;
}Reading sensor data
All sensor registers are big-endian, two's complement signed 16-bit. The registers are contiguous from TEMP_DATA1 (0x1D) through GYRO_DATA_Z0 (0x2A) — 14 bytes total. One burst read gets everything:
bool icm42688_read(icm42688_data_t *out) {
uint8_t raw[14];
// Burst read: TEMP_DATA1 (0x1D) → GYRO_DATA_Z0 (0x2A), 14 bytes
// Layout: [temp×2][ax×2][ay×2][az×2][gx×2][gy×2][gz×2]
icm_read_burst(ICM_REG_TEMP_DATA1, raw, sizeof(raw));
// Reconstruct signed 16-bit values from big-endian byte pairs
int16_t temp_raw = (int16_t)((raw[0] << 8) | raw[1]);
int16_t ax_raw = (int16_t)((raw[2] << 8) | raw[3]);
int16_t ay_raw = (int16_t)((raw[4] << 8) | raw[5]);
int16_t az_raw = (int16_t)((raw[6] << 8) | raw[7]);
int16_t gx_raw = (int16_t)((raw[8] << 8) | raw[9]);
int16_t gy_raw = (int16_t)((raw[10] << 8) | raw[11]);
int16_t gz_raw = (int16_t)((raw[12] << 8) | raw[13]);
// TRAP 3: Check for startup sentinel — 0x8000 means data is not yet valid
// Occurs during ~30 ms gyro startup and from power-on reset
if (gx_raw == (int16_t)0x8000 || ax_raw == (int16_t)0x8000) {
out->valid = false;
return false;
}
// Convert to physical units
// Accel: configured ±16g → scale factor 2048 LSB/g
out->ax = (float)ax_raw / 2048.0f;
out->ay = (float)ay_raw / 2048.0f;
out->az = (float)az_raw / 2048.0f;
// Gyro: configured ±2000 dps → scale factor 16.4 LSB/(°/s)
out->gx = (float)gx_raw / 16.4f;
out->gy = (float)gy_raw / 16.4f;
out->gz = (float)gz_raw / 16.4f;
// Temperature: T(°C) = raw / 132.48 + 25
// (This formula applies to register reads. FIFO 8-bit temp uses a different formula.)
out->temp_c = ((float)temp_raw / 132.48f) + 25.0f;
out->valid = true;
return true;
}The burst read matters for data integrity: reading temp + accel + gyro in a single SPI transaction ensures all values are from the same ODR sample. Reading them as separate transactions at 1 kHz ODR risks capturing data from different samples.
Polling driver, complete sketch
#include "icm42688.h"
#include <SPI.h>
void setup() {
Serial.begin(115200);
while (!Serial) {}
if (!icm42688_init()) {
Serial.println("ICM init failed — check wiring");
while (true) {}
}
}
void loop() {
icm42688_data_t data;
if (icm42688_read(&data)) {
Serial.printf(
"Accel [g]: %6.3f %6.3f %6.3f "
"Gyro [dps]: %7.2f %7.2f %7.2f "
"Temp: %.1f°C\n",
data.ax, data.ay, data.az,
data.gx, data.gy, data.gz,
data.temp_c
);
}
delay(10); // ~100 Hz polling rate
}Going interrupt-driven: FreeRTOS task pattern
Polling works, but it burns CPU and your sample timing drifts with whatever else the loop is doing. The right approach for embedded systems is to let the sensor interrupt the processor when a new sample is ready, then process it in a dedicated task.
The ESP32 Arduino framework includes FreeRTOS. Here's how to wire the ICM-42688-P's DATA_RDY interrupt into a task notification:
#include "icm42688.h"
#include <SPI.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
static TaskHandle_t icm_task_handle = nullptr;
static QueueHandle_t data_queue;
// ISR: runs on INT1 rising edge (push-pull, active-high)
// Sends a task notification to unblock icm_reader_task.
// IRAM_ATTR places this function in IRAM so it runs even if flash cache misses.
static void IRAM_ATTR icm_data_ready_isr(void) {
BaseType_t higher_prio_woken = pdFALSE;
vTaskNotifyGiveFromISR(icm_task_handle, &higher_prio_woken);
portYIELD_FROM_ISR(higher_prio_woken); // yield if we woke a higher-priority task
}
// Reader task: blocked on task notification, reads sensor on each interrupt
static void icm_reader_task(void *pvParameters) {
icm42688_data_t data;
for (;;) {
// Block indefinitely until ISR sends a notification.
// pdTRUE clears the notification value after waking (auto-reset).
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
if (icm42688_read(&data) && data.valid) {
// Send to queue — other tasks (e.g. filter, logging) consume from here
xQueueSend(data_queue, &data, 0); // 0 timeout: drop if queue full
}
}
}
// Consumer task: receives from queue, does something with the data
static void icm_consumer_task(void *pvParameters) {
icm42688_data_t data;
for (;;) {
if (xQueueReceive(data_queue, &data, portMAX_DELAY)) {
Serial.printf(
"ax=%.3f ay=%.3f az=%.3f gx=%.2f gy=%.2f gz=%.2f T=%.1f\n",
data.ax, data.ay, data.az,
data.gx, data.gy, data.gz,
data.temp_c
);
}
}
}
void setup() {
Serial.begin(115200);
if (!icm42688_init()) {
Serial.println("ICM init failed");
while (true) {}
}
// Queue holds up to 8 samples — sized to absorb a brief consumer stall
data_queue = xQueueCreate(8, sizeof(icm42688_data_t));
// Create reader task on core 1, priority 5 (above normal, below time-critical)
xTaskCreatePinnedToCore(
icm_reader_task, // task function
"icm_reader", // name for debugging
4096, // stack size (bytes)
nullptr, // parameter
5, // priority
&icm_task_handle, // task handle — must be set BEFORE attaching ISR
1 // core (1 = app core, away from WiFi on core 0)
);
// Create consumer task on core 1, lower priority so reader runs first
xTaskCreatePinnedToCore(
icm_consumer_task,
"icm_consumer",
4096,
nullptr,
4, // lower than reader so reader always runs when data is available
nullptr,
1
);
// Attach ISR only after task handle is valid
pinMode(ICM_INT1_PIN, INPUT);
attachInterrupt(digitalPinToInterrupt(ICM_INT1_PIN), icm_data_ready_isr, RISING);
Serial.println("Running at 1 kHz interrupt-driven");
}
void loop() {
// Nothing here — all work is done in FreeRTOS tasks
vTaskDelay(portMAX_DELAY);
}A few things worth noting about this pattern:
The task handle must be assigned before the ISR is attached. If the interrupt fires before icm_task_handle is valid, vTaskNotifyGiveFromISR writes to a null pointer. The order in setup() — create task first, then attachInterrupt — is not optional.
Pin the reader task to core 1. On the ESP32, the WiFi and BLE stack runs primarily on core 0. Pinning the IMU reader to core 1 isolates it from the WiFi interrupt latency that can cause jitter at 1 kHz.
Queue size matters. At 1 kHz with an 8-deep queue, you have an 8 ms window to drain the queue before samples start dropping. If your consumer does anything expensive (serial print, SD write), decouple it with a larger queue or process in batches.
The lesson
The ICM-42688-P has been on the market since 2020 and it's in everything — drone flight controllers, VR headsets, handheld game controllers. The traps described here are not obscure corner cases. Every single one of them is in the datasheet. They just require reading the right section at the right time, not skimming the register map and assuming the reset values are sane.
The pattern that makes driver work less painful: before writing a single line of initialization code, pull the full datasheet and read the power-on sequencing section, the reset values table, and any notes on registers with unusual defaults. The ICM-42688-P has exactly one register (INT_CONFIG1) where the factory-default value is explicitly documented as wrong and must be overridden. That's a low count. Some chips have four or five.
Check the reset values. Read the timing requirements. Don't poll sensor data inside the same function that writes PWR_MGMT0. The math to convert raw ADC counts to real units is the easy part — the tricky part is the 200 µs you didn't know you had to wait.