Networking in C · intermediate · ~12 min

Parse an IQ-samples capture header

- By the end you can lay out a fixed-size binary header as a byte-offset map and read each field at the right offset. - By the end you can decode little-endian u32 and u64 integers from a raw byte buffer using shifts, without unsafe pointer casts. - By the end you can bounds-check a buffer and validate a 4-byte magic before trusting any field. - By the end you can write a parse function that returns a clean success/failure code and never reads past the end of the buffer. - By the end you can explain why byte-shift decoding is both portable and alignment-safe compared to casting the buffer to a struct pointer.

Overview

Software-defined-radio (SDR) tools store complex baseband samples — the in-phase (I) and quadrature (Q) components of a signal — in flat binary files, usually with a small fixed header describing the capture. In this lesson you decode only that header: a fixed 20-byte record holding a magic tag, a sample rate, a center frequency, and a sample count. You never touch RF and never demodulate anything; the whole exercise is a pure binary-parsing drill on a fixed buffer.

This builds directly on your prerequisites. You already know pointers (so you can walk a const uint8_t * cursor through a buffer), endianness (so you know little-endian means the lowest-value byte comes first), and structs (so you can hold the decoded result in an iq_hdr_t). Here you combine all three: use pointer arithmetic to reach each field's offset, apply endianness rules to rebuild the integers byte by byte, and store the results in a struct — safely, without ever assuming the buffer is aligned or long enough.

Why it matters

Almost every binary format on disk or on the wire begins with a fixed header you must parse before you can trust the rest: pcap captures, PNG/ZIP/ELF files, TLS records, and SDR captures all follow this shape. Getting header parsing wrong is a classic source of security bugs — reading a length or count field without bounds-checking is exactly how buffer over-reads (think Heartbleed) happen. Learning to bounds-check first, validate a magic, and decode integers in an alignment-safe, endian-explicit way is a transferable defensive skill that keeps a parser from crashing or leaking memory on hostile or truncated input.

Core concepts

The header as a byte-offset map

A fixed binary header is just a run of bytes where each field lives at a known offset. Our IQ header is exactly 20 bytes:

offset  size  field            type
0       4     magic "IQHD"     4 raw ASCII bytes (no NUL)
4       4     sample_rate_hz   u32 little-endian
8       8     center_freq_hz   u64 little-endian
16      4     sample_count     u32 little-endian
                               total = 20 bytes

Think of the buffer as a ruler. The parser is a cursor that jumps to offset 4 to read the rate, offset 8 for the frequency, offset 16 for the count. Nothing is self-describing here — the layout is a contract you and the writer agree on in advance.

Little-endian decoding with shifts

Endianness decides which byte of a multi-byte integer comes first in the file. Little-endian (LE) puts the least-significant byte first. So the four bytes 00 24 F4 00 at offset 4 mean:

byte:   p[0]=0x00  p[1]=0x24  p[2]=0xF4  p[3]=0x00
weight: <<0        <<8        <<16       <<24
value = 0x00 | 0x2400 | 0xF40000 | 0x00000000 = 0x00F42400 = 16000000

You rebuild the integer by shifting each byte into its slot and OR-ing them together. This is the single most important pattern in the lesson, and it works identically on any CPU regardless of the host's native byte order.

Knowledge check: why cast each byte to the wide type before shifting — e.g. (uint32_t)p[3] << 24 rather than p[3] << 24?

Because p[3] is a uint8_t, which the compiler promotes to int (typically 32-bit and signed) in arithmetic. << 24 on a value with the top bit set (0x80–0xFF) shifts into or past the sign bit of a 32-bit int — undefined behaviour. Casting to uint32_t (or uint64_t for the 8-byte field) first makes the shift well-defined and wide enough to hold the result.

Alignment safety: shifts, not pointer casts

A tempting shortcut is uint64_t freq = *(const uint64_t *)(buf + 8);. Avoid it. buf + 8 may not sit on an 8-byte boundary, and dereferencing a misaligned pointer is undefined behaviour in C — it can crash (SIGBUS on strict architectures like some ARM/SPARC), silently read wrong bytes, or be miscompiled. Byte-by-byte shift decoding never dereferences anything wider than a uint8_t, so alignment is never an issue.

Approach Endianness Alignment Verdict
*(uint64_t*)(buf+8) host-dependent (wrong on big-endian) unsafe if misaligned avoid
memcpy(&v, buf+8, 8) still host-dependent safe OK only after an explicit byte-swap
byte shifts (p[0] | p[1]<<8 …) explicit LE, host-independent always safe preferred

Validate before you trust: bounds check, then magic

Order matters. Check pointers are non-NULL, then check n >= 20 before reading any byte — otherwise a short buffer makes you read past the end. Only once you know 20 bytes exist do you memcmp(buf, "IQHD", 4) to confirm this is the format you expect. The magic is 4 raw ASCII bytes with no terminating NUL, so compare exactly 4 bytes; never use strcmp, which would run off the end looking for a \0.

Knowledge check: your parser is handed a 12-byte buffer. What must happen, and what must never happen?

It must return the failure code (-1) because n < 20. It must never read buf[16] (the sample_count) or any byte at offset 12–19 — those bytes do not exist, and reading them is a heap/stack over-read. The length check has to come before any field access.

Syntax notes

int parse_iq_header(const uint8_t *buf, size_t n, iq_hdr_t *out);
  • buf — pointer to the raw capture bytes. const because parsing never modifies input. May be NULL (guard it).
  • n — number of valid bytes available at buf. Passing the true length is the caller's responsibility; the parser uses it to avoid over-reads.
  • out — caller-allocated struct to fill on success. Only written when the function returns 0.
  • returns 0 on success (valid magic, enough bytes); -1 on NULL input, n < 20, or bad magic.
void *memcmp(const void *a, const void *b, size_t len);
  • Compares exactly len bytes; returns 0 when equal. Use it for fixed-length magics (no NUL assumption). #include <string.h>.
typedef struct { uint32_t sample_rate_hz; uint64_t center_freq_hz; uint32_t sample_count; } iq_hdr_t;
  • The decoded result. The struct's in-memory layout is unrelated to the file layout — you decode field by field, so struct padding never matters.

Fixed-width types come from #include <stdint.h>; size_t from <stddef.h>. No allocation happens, so there is nothing to free and nothing to close.

Lesson

Why this matters

Software-defined radio (SDR) captures store complex baseband samples in flat binary files. These are called I/Q samples — the in-phase (I) and quadrature (Q) components of a radio signal.

Every SDR tool — GNU Radio, SDR#, rtl_sdr — writes a small header in front of the samples. The header records:

  • the sample rate
  • the centre frequency
  • the sample count

We are not capturing any RF here. We only decode the header bytes.

What the header looks like (our format)

offset  size  field
0       4     magic "IQHD"
4       4     sample_rate_hz  (u32 LE)
8       8     center_freq_hz  (u64 LE)
16      4     sample_count    (u32 LE)

The header is exactly 20 bytes. All integers are little-endian (LE): the lowest-value byte comes first in the file.

Your job

Implement:

int parse_iq_header(const uint8_t *buf, size_t n, iq_hdr_t *out);

Return 0 when the magic is valid. Return -1 on any of these:

  • NULL inputs
  • n < 20 (not enough bytes)
  • bad magic

Common mistakes

  • Wrong byte order for the u64. Little-endian means the low 4 bytes come first. Reading a u64 as two u32s in the wrong order swaps the halves.
  • Casting (uint64_t *)buf. This is alignment-unsafe and may crash or misread on some platforms. Build the value from individual byte shifts instead.
  • Mis-handling the magic. It is the first 4 raw bytes (IQHD), with no terminating NUL.

What this is NOT

  • Not a demodulator. We never touch the sample data.
  • Not a radio. We do not transmit anything.

Code examples

#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

#define IQ_HDR_SIZE 20

typedef struct {
    uint32_t sample_rate_hz;
    uint64_t center_freq_hz;
    uint32_t sample_count;
} iq_hdr_t;

/* Build a little-endian u32 from 4 bytes, alignment-safe. */
static uint32_t rd_u32_le(const uint8_t *p) {
    return (uint32_t)p[0]
         | (uint32_t)p[1] << 8
         | (uint32_t)p[2] << 16
         | (uint32_t)p[3] << 24;
}

/* Build a little-endian u64 from 8 bytes, alignment-safe. */
static uint64_t rd_u64_le(const uint8_t *p) {
    return (uint64_t)p[0]
         | (uint64_t)p[1] << 8
         | (uint64_t)p[2] << 16
         | (uint64_t)p[3] << 24
         | (uint64_t)p[4] << 32
         | (uint64_t)p[5] << 40
         | (uint64_t)p[6] << 48
         | (uint64_t)p[7] << 56;
}

int parse_iq_header(const uint8_t *buf, size_t n, iq_hdr_t *out) {
    if (!buf || !out)          return -1;   /* NULL guard         */
    if (n < IQ_HDR_SIZE)       return -1;   /* bounds check first  */
    if (memcmp(buf, "IQHD", 4) != 0) return -1; /* magic check    */

    out->sample_rate_hz = rd_u32_le(buf + 4);
    out->center_freq_hz = rd_u64_le(buf + 8);
    out->sample_count   = rd_u32_le(buf + 16);
    return 0;
}

int main(void) {
    /* A hermetic fixture: 20 header bytes, all little-endian.
       magic "IQHD", sample_rate = 16000000, center_freq = 100000000,
       sample_count = 1024. No radio, no file, no network. */
    const uint8_t fixture[IQ_HDR_SIZE] = {
        'I','Q','H','D',                 /* magic                     */
        0x00,0x24,0xF4,0x00,             /* 16000000 LE u32           */
        0x00,0xE1,0xF5,0x05,0x00,0x00,0x00,0x00, /* 100000000 LE u64  */
        0x00,0x04,0x00,0x00              /* 1024     LE u32           */
    };

    iq_hdr_t h;
    if (parse_iq_header(fixture, sizeof fixture, &h) != 0) {
        fprintf(stderr, "parse failed\n");
        return 1;
    }

    printf("magic        : OK\n");
    printf("sample_rate  : %u Hz\n", h.sample_rate_hz);
    printf("center_freq  : %llu Hz\n", (unsigned long long)h.center_freq_hz);
    printf("sample_count : %u\n", h.sample_count);

    /* Negative tests prove the guards fire. */
    printf("short buffer : %d (want -1)\n",
           parse_iq_header(fixture, 10, &h));
    uint8_t bad[IQ_HDR_SIZE];
    memcpy(bad, fixture, sizeof bad);
    bad[0] = 'X';
    printf("bad magic    : %d (want -1)\n",
           parse_iq_header(bad, sizeof bad, &h));
    printf("null buffer  : %d (want -1)\n",
           parse_iq_header(NULL, sizeof bad, &h));
    return 0;
}

Line by line

  • #define IQ_HDR_SIZE 20 — names the one magic number the whole parser depends on, so the bounds check and the fixture never drift apart.
  • typedef struct { ... } iq_hdr_t; — the decoded output. Its field order is for the programmer's convenience; it has nothing to do with the on-disk order.
  • rd_u32_le / rd_u64_le — the reusable little-endian decoders. Each takes a const uint8_t * and rebuilds an integer with shifts. Every byte is cast to the wide unsigned type before the shift, so no shift ever touches a sign bit or overflows an int.
  • if (!buf || !out) return -1; — reject NULL pointers before dereferencing either one.
  • if (n < IQ_HDR_SIZE) return -1; — the critical bounds check. It runs before any byte is read, guaranteeing offsets 0–19 are all in range.
  • if (memcmp(buf, "IQHD", 4) != 0) return -1; — compares exactly the 4 magic bytes; no NUL assumption, no strcmp.
  • out->sample_rate_hz = rd_u32_le(buf + 4); — pointer arithmetic moves the cursor to offset 4; the decoder reads 4 bytes there. Similarly buf + 8 (8 bytes) and buf + 16 (4 bytes).
  • return 0; — success is signalled only after every field is written, so a caller that sees 0 can trust *out fully.
  • The fixture array — a hand-built 20-byte header used instead of a real file, keeping the demo hermetic. The comments annotate each field's bytes.
  • The three negative-test printfs — feed a short buffer, a corrupted magic, and a NULL pointer, each expected to return -1, proving the guards actually fire.

Common mistakes

1. Casting the buffer to a wide pointer (alignment + endianness bug)

uint64_t freq = *(const uint64_t *)(buf + 8);   /* WRONG */

Why it breaks: buf + 8 may be misaligned (undefined behaviour, can SIGBUS), and even when it works it yields the host's byte order — wrong on a big-endian machine.

uint64_t freq = rd_u64_le(buf + 8);              /* FIXED: explicit LE, always aligned */

2. Shifting a byte without widening it first

uint32_t v = p[0] | p[1]<<8 | p[2]<<16 | p[3]<<24;  /* WRONG */

Why it breaks: p[3] promotes to signed int; << 24 on 0x80–0xFF shifts into the sign bit — undefined behaviour.

uint32_t v = (uint32_t)p[0] | (uint32_t)p[1]<<8
           | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24;  /* FIXED */

3. Reading before bounds-checking

uint32_t rate = rd_u32_le(buf + 4);
if (n < 20) return -1;                            /* WRONG: too late */

Why it breaks: if n is 6, you already over-read bytes 6–7. The check must precede every access.

if (n < 20) return -1;
uint32_t rate = rd_u32_le(buf + 4);               /* FIXED */

4. Comparing the magic with strcmp

if (strcmp((const char *)buf, "IQHD") != 0) ...   /* WRONG */

Why it breaks: the magic has no terminating NUL; strcmp reads past offset 3 until it finds a chance \0, over-reading and giving unpredictable results.

if (memcmp(buf, "IQHD", 4) != 0) ...              /* FIXED: exactly 4 bytes */

Debugging tips

  • Wrong integer values? Print the raw bytes first: for (size_t i=0;i<20;i++) printf("%02X ", buf[i]);. Compare against your offset map by hand before blaming the decoder. xxd file.iq | head shows the same thing for a real file.
  • Swapped halves in the u64? That is the classic sign of decoding it as two u32s in the wrong order, or mixing LE and BE. Trace the shift weights <<0, <<8, ... <<56 against the byte positions.
  • Crash or SIGBUS on some platforms only? Suspect a pointer cast to uint32_t*/uint64_t*. Build with -fsanitize=address,undefined; UBSan reports "load of misaligned address" and "left shift of negative value" precisely.
  • Over-reads on truncated input? Run under Valgrind (valgrind ./m) or ASan; feed a deliberately short buffer and confirm you get -1, not a diagnostic about an invalid read.
  • Use a debugger: in gdb, x/20xb buf dumps 20 bytes in hex so you can eyeball the header, and a breakpoint on parse_iq_header lets you step each field read.

Memory safety

The dominant hazard here is a buffer over-read: reading a field at an offset that lies beyond the bytes actually provided. This is prevented by checking n >= 20 before any access — never derive an access from a length field you have not first validated. The second hazard is misaligned access undefined behaviour, avoided entirely by decoding through uint8_t shifts rather than casting to wider pointer types; the byte-shift readers touch memory only one byte at a time, which is always legal. The third is signed-shift / integer-promotion UB, avoided by casting each byte to uint32_t/uint64_t before shifting. The parser allocates nothing and holds no state, so there is no leak, double-free, or use-after-free surface; out is written only on the success path, so a caller that ignores the return code and reads *out after a failure sees only its own uninitialised memory — always check the return value. If you later parse a count or length from the header and use it to size a read of the sample data, treat that value as untrusted and bounds-check it against the real file size before looping.

Real-world uses

This exact pattern — bounds-check, verify a magic, decode fixed-offset little-endian integers — is how real code reads pcap capture headers, WAV/RIFF chunk headers, PNG/ZIP/ELF signatures, GNU Radio and rtl_sdr metadata sidecars, and network protocol headers. SDR toolchains (GNU Radio file sources, SigMF metadata) rely on precisely this kind of header parse to know a capture's sample rate and center frequency before touching the samples. Best practice in production parsers: keep a single read_uNN_le/_be helper family and never hand-roll shifts at each call site; always validate length and magic before trusting any field; treat every field read from external input as hostile and bounds-check any value used to size a subsequent read or allocation; and prefer explicit-endianness decoders over *(T*)ptr casts so the code is correct on every architecture and safe under ASan/UBSan.

Practice tasks

  1. Add a bool valid_magic(const uint8_t *buf, size_t n) helper that returns true only when n >= 4 and the first four bytes are IQHD, and have parse_iq_header call it.

  2. Add a big-endian pair rd_u32_be/rd_u64_be and a second parser that reads the same 20-byte layout as big-endian; feed both the same fixture and print the differing sample_rate to see byte order in action.

  3. Write a hexdump(const uint8_t *buf, size_t n) that prints the header 16 bytes per line with offsets, then use it to visually confirm the magic sits at offset 0.

  4. Extend the format to a 24-byte header with a trailing u32 crc at offset 20; update IQ_HDR_SIZE, the bounds check, and the struct, and decode the new field.

  5. Harden the parser against truncation by writing a driver that calls parse_iq_header with every length from 0 to 20 and asserts it returns -1 for all lengths below 20 and 0 at exactly 20 — run it under -fsanitize=address,undefined and confirm no reports.

Summary

  • The header is a fixed 20 bytes: a 4-byte magic plus three little-endian integers (u32, u64, u32) at known offsets.
  • Order of operations is mandatory: NULL guard, then bounds-check n >= 20, then memcmp the 4-byte magic, then decode fields.
  • Decode integers with byte shifts, casting each byte to the wide unsigned type before shifting; this is both alignment-safe and endianness-explicit.
  • Never cast the buffer to uint32_t*/uint64_t* (alignment + host-endianness bugs) and never use strcmp on the magic (no NUL).
  • Return 0 only after all fields are written; callers must check the return before reading the struct.
  • This is the same defensive binary-parse pattern used for pcap, PNG, ELF, and network headers — reusable everywhere.

Practice with these exercises