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.

IMU32G acceleration data plotted post-flight — the spike is motor ignition
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.
IMU, Baro, Mag, GPS polled via SPI / UART
Pub/sub — drivers publish, LoggingTask subscribes
Serializes each reading into a 20-byte binary record
Accumulates records in RAM; flushes 500-byte chunks to 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.
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.
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 (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.