Networking in C · intermediate · ~12 min
- 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.
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.
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.
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.
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 auint8_t, which the compiler promotes toint(typically 32-bit and signed) in arithmetic.<< 24on a value with the top bit set (0x80–0xFF) shifts into or past the sign bit of a 32-bitint— undefined behaviour. Casting touint32_t(oruint64_tfor the 8-byte field) first makes the shift well-defined and wide enough to hold the result.
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 |
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) becausen < 20. It must never readbuf[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.
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.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);
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;
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.
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:
We are not capturing any RF here. We only decode the header bytes.
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.
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:
n < 20 (not enough bytes)(uint64_t *)buf. This is alignment-unsafe and may crash or misread on some platforms. Build the value from individual byte shifts instead.IQHD), with no terminating NUL.#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;
}
#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.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.printfs — feed a short buffer, a corrupted magic, and a NULL pointer, each expected to return -1, proving the guards actually fire.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 */
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.<<0, <<8, ... <<56 against the byte positions.uint32_t*/uint64_t*. Build with -fsanitize=address,undefined; UBSan reports "load of misaligned address" and "left shift of negative value" precisely.valgrind ./m) or ASan; feed a deliberately short buffer and confirm you get -1, not a diagnostic about an invalid read.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.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.
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.
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.
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.
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.
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.
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.
n >= 20, then memcmp the 4-byte magic, then decode fields.uint32_t*/uint64_t* (alignment + host-endianness bugs) and never use strcmp on the magic (no NUL).