xenonnn4wxenonnn4w

Bare-metal data logger: firmware from the registers up

16th July 2026

I wanted one small project that touched the whole embedded stack the way a storage-firmware role does: a device driver for a bus, a driver for the storage media, a way to report status, and a way to prove the data written is the data read back. So I built a data logger in C. A simulated I2C sensor is sampled, each reading is packed into a CRC-protected record, the record is written to a simulated SPI NOR flash, and status is streamed over a simulated UART. A small Python tool reads the log back and produces a pass/fail report.

The sensor, flash, and UART are simulated in C so the whole thing runs on a laptop with no hardware. The point was to write the drivers the way real register-level firmware is written, so the same code ports to an STM32 by swapping the backends. Code is on GitHub.

The end-to-end path

Every command walks the same pipeline. A sample comes off the sensor, the log layer stamps it and appends a CRC, the flash driver programs it a page at a time, and the UART driver echoes a line of status. The flash contents persist to a file between runs, which is what makes it behave like real non-volatile memory.

sensor.cI2C readlogstore.crecord + CRC16spi_flash.cpage programflash.binnon-volatileuart.cring-buffered TXhost terminal
End-to-end path: a sample flows through record-building, onto the flash, while status is echoed over UART.

Reading the sensor over I2C

On real hardware the first thing an I2C driver does is prove the device is actually there: address it, read its chip-ID register, and check the byte that comes back. That single transaction exercises addressing, the ACK/NACK handshake, and the repeated-START needed to turn the bus around from write to read. The simulated driver keeps that exact shape, so the logic that matters is the logic you would keep on hardware.

int sensor_init(void)
{
    uint8_t id = i2c_read_reg(BMP280_ADDR, REG_ID);
    return (id == BMP280_CHIPID) ? 0 : -1;   /* device present? */
}

One record, one CRC

Each sample becomes a fixed 18-byte record. Fixing the size means the log is just an array on the media: record i lives at i * 18, and an erased slot (all 0xFF, so seq == 0xFFFFFFFF) marks the end of the log with no separate index to keep in sync. The last two bytes are a CRC16-CCITT over the other sixteen. That checksum is the whole verification story: it is computed on write and re-checked on read, so any bit that rots on the media is caught.

sequ320tsu324temps328presu3212crcu161618
The 18-byte record. The last two bytes are a CRC16-CCITT over the first sixteen.
typedef struct __attribute__((packed)) {
    uint32_t seq;         /* 0xFFFFFFFF (erased) marks end-of-log */
    uint32_t tick_ms;
    int32_t  temp_cx100;  /* centi-degrees C */
    uint32_t press_pa;
    uint16_t crc;         /* CRC16-CCITT over the 16 bytes above */
} record_t;

Why flash makes you erase first

NOR flash has a rule that shapes every storage driver: a program operation can only turn 1-bits into 0-bits. You cannot overwrite old data in place; you have to erase a whole sector back to all-0xFF first, and erase granularity (4 KB) is much coarser than program granularity (256 B pages, which a write can't cross). The simulation models the physics literally, a program is a bitwise AND, so the erase-before-write constraint is real and not just a comment.

/* Program a page: can only clear bits, never set them.
 * That is why you must erase (-> 0xFF) before writing new data. */
static void page_program(uint32_t addr, const uint8_t *data, size_t len)
{
    for (size_t i = 0; i < len; i++)
        s_flash[addr + i] &= data[i];
}

A UART that never blocks

Spinning the CPU while each byte shifts out of a UART is wasteful, so real firmware hands bytes to a ring buffer and lets the transmit interrupt drain it in the background. I built that path: uart_putc() enqueues, and a flush stands in for the TXE ISR that empties the buffer to the data register. The ring buffer keeps one slot empty so full and empty are distinguishable without a shared counter that a producer and an interrupt could race on.

uart_putc()app enqueuering buffer256 B, SPSCuart_flush()= TXE ISR drainUART DRwire
UART TX: application code hands bytes to a ring buffer; a flush (standing in for the TXE interrupt) drains them to the wire.

Verification: catching a flipped bit

The payoff is the verification demo. A corrupt command flips a single bit on the media, modelling spontaneous bit-rot. On the next read the record's recomputed CRC no longer matches the stored one, and the Python harness, which reads the log back exactly the way a test rig reads a device over serial, reports the failing record and exits non-zero.

$ ./datalogger corrupt 20     # flip one bit in flash
$ python3 tools/parse_logs.py
============================================
  FIRMWARE VERIFICATION REPORT
============================================
  records read : 8
  crc passed   : 7
  crc failed   : 1
  !! seq 1 CRC FAIL
  RESULT: FAIL      (exit code 1)

What it maps onto

Small as it is, the project lines up with the pieces a firmware role asks for: an I2C driver, an SPI-NOR driver with the erase-before-write behaviour real media has, an interrupt-style UART, a CRC-verified on-media record format, and an automated test harness around the whole thing. Porting to an STM32 is a matter of swapping three backends, the sensor's register read for the I2C peripheral, the flash array for SPI transactions, and the UART's output for the USART data register, while the record, CRC, and log layers stay untouched. The full source, build, and demo are on GitHub.

Tags

#embedded#firmware#c#i2c#spi-flash#uart#crc#verification