← Back to Projects

Case Study · SOAR — UofC Rocketry

Rocket Data Acquisition System

Real-time sensor logging firmware for a high-powered rocket's avionics bay. Built on an STM32H743 with FreeRTOS — six sensors sampled continuously, packed into a compact binary format, and written to a 1 Gb QSPI flash chip at up to 200 Hz. Data survives power cycles and is recovered via a full binary dump and parse pipeline post-flight.

CC++STM32H743FreeRTOSQSPI FlashSPIEmbedded
200 Hzpeak logging rate
6sensors logged
1 GbQSPI flash

Context

Why this is hard

🚀

No second chances

A rocket flies once. If the logging firmware crashes at ignition, the flight data is gone. Every line of this code needed to be correct before the rocket left the rail.

Hard real-time constraints

Sensors generate data continuously — at 200 Hz during ascent. The system has to keep up without dropping samples, even under the vibration and acceleration load of a live motor burn.

🔋

Survives power loss

If the avionics board loses power on landing, the write cursor position must be preserved. The flash state is saved to a dedicated sector so logging resumes exactly where it left off.

🛰️

Part of SOAR — University of Calgary's rocketry team

SOAR competes in high-powered rocketry competitions. This DAQ system flies on the avionics bay of a full-scale rocket, capturing flight data used to validate simulations, tune recovery systems, and verify motor performance.

Architecture

How data flows from sensor to flash

The system is built around FreeRTOS and a pub/sub DataBroker. Sensor driver tasks publish readings; the LoggingTask subscribes and handles everything from there.

01Sensor Drivers

IMU, Baro, Mag, GPS polled via SPI / UART

02DataBroker

Pub/sub — drivers publish, LoggingTask subscribes

03LoggingTask

Serializes each reading into a 20-byte binary record

04LoggingService

Accumulates records in RAM; flushes 500-byte chunks to flash

05MX66 Flash

1 Gb QSPI. Sector-erased before write. State saved on power loss.

📦

20-byte fixed records

Every sensor reading — regardless of type — is packed into exactly 20 bytes: 1 type byte, 4-byte timestamp, sensor payload, and a sensor ID byte. Fixed size means no dynamic allocation, no fragmentation, and trivial seeking during readback.

🧱

500-byte RAM buffer → flash chunk

Records accumulate in a 500-byte RAM buffer (25 records). When full, the whole chunk is written to flash in one QSPI transaction. This batching strategy dramatically reduces the number of flash write operations, extending the chip's write cycle life.

Sensors

Six sensors, all logged

IMU16G

ID 0

16 g-range inertial measurement unit — accelerometer + gyroscope. Captures lower-intensity motion phases.

IMU32G

ID 1

32 g-range IMU — high-range variant for the violent accelerations at motor ignition and apogee.

BARO07

ID 0

Barometric pressure sensor. Pressure readings used to derive altitude in real time.

BARO11

ID 1

Redundant barometric sensor at a different I²C address for cross-validation.

Magnetometer

3-axis magnetometer for heading reference and orientation estimation.

GPS

UART GPS with NMEA sentence output — chunked into 12-byte payloads per 20-byte flash slot.

Key Implementation

Flash state that survives power loss

The most critical reliability feature. The last sector of the flash chip is reserved as a state record — it stores the current write cursor (sector index + chunk offset) with a magic number and XOR checksum. On every boot, the system reads this record and resumes logging exactly where it left off.

🔑

Magic number + checksum

The state record is prefixed with 0x4C4F4731 ("LOG1") and validated with an XOR checksum. If the record is missing or corrupt — say after a factory erase — the system scans forward chunk-by-chunk to find the first empty slot. No data is lost either way.

📍

Sector + chunk cursor

The 1 Gb chip is divided into 4 KB sectors, each holding 8 × 500-byte chunks. The cursor is (sectorAddress, bufferPerSector). After every full sector write, the state is saved. The firmware never writes to a chunk without first verifying it's erased — if it isn't, it scans forward to the next clean slot.

LoggingService.cpp — flash write pipeline
LoggingStatus LoggingService::LogToMX66() {
    LoadFlashStateFromStorage();        // no-op after first call

    LoggingStatus status = MemAppend(&loggingData);   // append to RAM buffer

    if (!done && status == LOG_FLASH_READY) {
        // Erase the sector before the first chunk write
        if (bufferPerSector == 0)
            MX66xxQSPI_EraseSector(sectorAddress);

        // Write the 500-byte RAM buffer to flash
        MX66xxQSPI_WriteSector(txBuf, sectorAddress,
                               bufferPerSector * 500, RAM_LOG_SIZE);

        // Read back and verify — no silent corruption
        MX66xxQSPI_ReadSector(sectorBuf, sectorAddress,
                              bufferPerSector * 500, RAM_LOG_SIZE);

        if (BytesEqual(sectorBuf, txBuf, RAM_LOG_SIZE)) {
            bufferPerSector++;
            if (bufferPerSector == 8) {       // sector full (8 × 500 = 4000 B)
                sectorAddress++;
                bufferPerSector = 0;
                SaveFlashState();             // persist cursor after every sector
            }
            return LOGGING_SUCCESS;
        }
        return LOGGING_ERR;
    }
    return LOG_FLASH_NOT_READY;
}

Data Recovery

From binary flash to readable data

After the rocket lands, a flash dump command is triggered over the debug interface. The firmware walks every written sector and chunk, deserializes each 20-byte record back to typed structs, and prints them to the serial terminal. A Python parser then ingests the output for analysis.

Serial terminal — flash dump readback
BARO11(ID=1) Timestamp=367373 Pressure=88244 Temp=22.99IMU16G(ID=0) Timestamp=367388 Accel=[0,0,0] Gyro=[-8,-8,-8] Temp=24IMU32G(ID=1) Timestamp=367394 Accel=[0,0,1] Gyro=[-262,253,35] Temp=23BARO07(ID=0) Timestamp=367394 Pressure=88227 Temp=23.08BARO11(ID=1) Timestamp=367396 Pressure=88245 Temp=22.98BARO07(ID=0) Timestamp=367445 Pressure=88224 Temp=23.08IMU16G(ID=0) Timestamp=367445 Accel=[0,0,0] Gyro=[-8,-8,-8] Temp=24IMU32G(ID=1) Timestamp=367445 Accel=[0,0,1] Gyro=[-297,280,17] Temp=23BARO11(ID=1) Timestamp=367445 Pressure=88242 Temp=22.98BARO07(ID=0) Timestamp=367445 Pressure=88227 Temp=23.07IMU32G(ID=1) Timestamp=367445 Accel=[0,0,1] Gyro=[-262,78,-61] Temp=23IMU16G(ID=0) Timestamp=367463 Accel=[0,0,0] Gyro=[-8,-8,-8] Temp=24BARO07(ID=0) Timestamp=367463 Pressure=88233 Temp=23.08BARO11(ID=1) Timestamp=367465 Pressure=88240 Temp=22.99IMU32G(ID=1) Timestamp=367469 Accel=[0,0,1] Gyro=[-350,131,43] Temp=23
🐍

Python parser closes the loop

A Python script parses the serial dump output, reconstructs each sensor stream, and produces time-series data ready for plotting. The acceleration chart in the hero was generated this way — raw binary off the chip, decoded, and visualized.

Flight Data

Real data from a real test

IMU32G acceleration data recovered from flash and plotted over the full logging session. The dramatic spike around timestamp 100,000–125,000 ms captures the high-g event — Accel Z peaks near 1,750 units, with X and Y showing the associated vibration and attitude change. Before and after the event, all axes are near zero, showing the rocket at rest.

IMU32G acceleration data over time
01

IMU32G (32g-range) acceleration — X (blue), Y (orange), Z (green) — plotted across the full logging window. The Z-axis spike is the motor ignition event. Data was recovered via flash dump and parsed with the Python pipeline.

Takeaways

What this built

⚙️

Embedded systems are unforgiving

There's no debugger when the rocket is in the air. Every edge case — corrupt flash state, full chip, out-of-bounds writes — had to be handled in code before the first test.

🔄

Reliability comes from redundancy

Dual IMUs, dual barometers, write-verify after every chunk, and flash state checksums. Redundancy isn't over-engineering — it's the minimum bar for safety-critical flight hardware.

📈

The data tells the story

A 20-byte binary record means nothing until it's decoded. Building the full loop — sensor → binary → flash → dump → parse → chart — is what transforms embedded work into engineering insight.

← All Projects🔒 Source in private organization