UBX binary protocol encoder / decoder for embedded C23 targets.
Protocol: u-blox UBX protocol, F10 SPG 6.00 specification, UBX version 40.00 (ref. UBX-23002975-R02)
Primary target: STM32U073KCU6 -- Arm Cortex-M0+, no FPU, 256 KB flash / 40 KB SRAM
Standard: C23 (-std=c23 on GCC >= 14, -std=c2x on GCC 13; auto-detected by the Makefile)
License: Apache-2.0
| Property | How it is achieved |
|---|---|
| No dynamic allocation | All buffers are caller-supplied. malloc/free are never called. |
| No recursion | The streaming parser is a flat state machine. Proven by make stack-analysis. |
| O(1) per call | All functions are O(1) except ubx_decode_message, which is O(buffer_len). |
| No floating-point | -Wdouble-promotion is clean on FPU-less targets by construction. |
| ISR-safe | ubx_parser_feed_byte performs no I/O and no allocation. Safe to call from a UART receive interrupt. |
| Deterministic stack | Worst-case stack depth is statically verified, not a runtime measurement. See S7. |
Measured with arm-none-eabi-gcc, -mcpu=cortex-m0plus -mthumb -Os -ffreestanding.
| Variant | .text |
sizeof(ubx_parser_t) |
|---|---|---|
| Standard | 826 B | 10 B |
UBX_PARSER_STATS |
876 B | 24 B |
Per-function (Cortex-M0+, -Os):
| Function | .text size |
|---|---|
ubx_decode_message |
308 |
ubx_parser_feed_byte |
248 |
ubx_encode_message |
118 |
ubx_message_is_ack |
62 |
ubx_parser_get_message |
38 |
ubx_parser_reset |
26 |
ubx_parser_init |
26 |
libubx/
|
|-- ubx_error.h Result codes and parser state enum.
| Included by ubx_parser.h; include it directly
| only when you need ubx_result_t or
| ubx_parse_state_t without the full API.
|
|-- ubx_parser.h Public API. Include this in your application.
| Self-contained: pulls in ubx_error.h and the
| standard headers it needs. Nothing else is
| required at compile time.
|
|-- ubx_parser.c Implementation. Add this single file to your
| firmware build. No other .c file is needed at
| runtime.
|
|-- ubx_test.c Unit test suite. 145 assertions (150 with
| UBX_PARSER_STATS). NOT compiled into the
| production library. Built and run by
| `make test` / `make test-stats`.
|
|-- ubx_fuzz.c Self-contained mutation fuzzer. GCC + ASan +
| UBSan, no external engine (no Clang, no
| libFuzzer). Verifies library invariants beyond
| what the sanitizers catch. Built by `make fuzz`,
| run by `make fuzz-run`. NOT part of the runtime.
|
|-- Makefile All targets: host build, MCU cross-build, unit
| tests, static stack analysis, fuzzing, and
| cppcheck. The release gate is `make check`.
|
|-- README.md Project overview, quick start, API reference,
| build instructions, and usage examples.
|
|-- LICENSE Full Apache License 2.0 text for this project.
|
|-- corpus/ Fuzzer working directory. Created automatically
| by `make fuzz-run` if absent. Contains:
| - .scratch the last input fed to the library
| before a potential crash, written
| and flushed before every call.
| If a run crashes, this file IS the
| reproducer:
| ./build/ubx_fuzz --replay corpus/.scratch
| Safe to delete between campaigns. NOT part of
| the runtime or the production build.
|
`-- tools/
`-- ubx_stack_check.c Static stack depth analysis tool. Written in
C23, compiled from source by `make stack-analysis`
using the host GCC. Reads the .su and .ci files
produced by GCC -fstack-usage / -fcallgraph-info,
traverses the call graph, detects recursion, and
checks the worst-case total depth against a
configurable threshold. Exits 1 on regression.
NOT part of the runtime.
Only two files are needed at runtime:
ubx_parser.h <- include in your application source
ubx_parser.c <- add to your build system
ubx_error.h is automatically pulled in by ubx_parser.h via #include "ubx_error.h".
The test, fuzz, and tools files are development-only artifacts.
make # host lib + unit tests (default)
make lib # libubx_parser.a (production, no test code)
make lib-stats # same with UBX_PARSER_STATS counters
make test # host tests, ASan + UBSan
make test-stats # same with UBX_PARSER_STATS
make mcu # Cortex-M0+ static library
make mcu-stats # same with UBX_PARSER_STATS
make size # .text / .data / .bss for both MCU builds
make stack-analysis # worst-case stack depth (see S7)
make fuzz # build the fuzzer
make fuzz-run # run 2 M iterations (FUZZ_ITERATIONS=N FUZZ_SEED=N)
make cppcheck # static analysis
make check # everything above -- the release gate
make clean
make helpThe Makefile auto-detects whether the compiler accepts -std=c23 (GCC >= 14) or needs -std=c2x (GCC 13). The tools/ubx_stack_check.c tool is compiled with the host GCC as part of make stack-analysis.
Add ubx_parser.c to your build and include ubx_parser.h. No other files are needed at runtime.
/* Frame geometry */
constexpr uint8_t UBX_SYNC1_BYTE = 0xB5U;
constexpr uint8_t UBX_SYNC2_BYTE = 0x62U;
constexpr uint32_t UBX_HEADER_LEN = 6U;
constexpr uint32_t UBX_CRC_LEN = 2U;
constexpr uint32_t UBX_OVERHEAD_LEN = 8U; /* header + CRC, no payload */
constexpr uint32_t UBX_MAX_PAYLOAD_LEN = 65'535U;
constexpr uint32_t UBX_MAX_FRAME_LEN = 65'543U;
/* ACK/NAK classifier */
constexpr uint8_t UBX_CLASS_ACK = 0x05U;
constexpr uint8_t UBX_ID_ACK_ACK = 0x01U;
constexpr uint8_t UBX_ID_ACK_NAK = 0x00U;
constexpr uint16_t UBX_ACK_PAYLOAD_LEN = 2U;int32_t-backed enum. UBX_OK is always zero; all errors are negative.
uint8_t-backed enum. Read-only from outside the library -- do not write directly.
typedef struct {
uint8_t msg_class;
uint8_t msg_id;
uint16_t payload_len; /* full declared length, even when copy was clamped */
const uint8_t *payload; /* see ownership rules per function */
} ubx_message_t;Opaque streaming parser context. One instance per byte stream. Initialise with ubx_parser_init before use.
With -DUBX_PARSER_STATS, three saturating uint32_t counters are added:
messages_ok, crc_errors, sync_losses. Reset only by ubx_parser_init.
Builds one complete UBX frame into a caller-supplied buffer.
[[nodiscard("dropping the byte count silently discards the encoded frame")]]
int32_t ubx_encode_message(
uint8_t msg_class,
uint8_t msg_id,
const uint8_t *p_payload, /* may be nullptr when payload_len == 0 */
uint16_t payload_len,
uint8_t *p_buffer, /* must hold at least payload_len + 8 bytes */
size_t buffer_len
);Frame layout written:
SYNC1 SYNC2 CLASS ID LEN_L LEN_H [PAYLOAD...] CK_A CK_B
Returns the number of bytes written (always payload_len + 8) on success, or:
| Code | Condition |
|---|---|
UBX_ERROR_INVALID_PARAM |
p_buffer is nullptr, or p_payload is nullptr with payload_len > 0 |
UBX_ERROR_TOO_BIG |
buffer_len < payload_len + UBX_OVERHEAD_LEN -- nothing is written |
On any error the buffer is untouched. p_payload and p_buffer must not overlap.
Finds and decodes the first complete UBX frame in a buffer. The buffer may contain arbitrary leading content (NMEA sentences, noise).
[[nodiscard("dropping the result silently ignores a decode failure")]]
ubx_result_t ubx_decode_message(
const uint8_t *p_buffer,
size_t buffer_len,
ubx_message_t *p_msg,
uint8_t *p_payload_buf, /* optional -- see payload ownership below */
size_t payload_buf_len,
size_t *p_consumed /* optional */
);Payload ownership:
p_payload_buf |
Result | p_msg->payload |
|---|---|---|
Non-nullptr, large enough |
UBX_OK |
Points into p_payload_buf |
Non-nullptr, too small |
UBX_ERROR_TRUNCATED |
Points into p_payload_buf; payload_len has the full declared length; *p_consumed is not advanced -- retry from the same position with a larger buffer |
nullptr |
UBX_OK |
Borrowed pointer into p_buffer -- invalid the instant p_buffer is modified or freed |
| Any | payload_len == 0 |
Always nullptr |
*p_consumed after each return code:
| Code | *p_consumed |
Suggested action |
|---|---|---|
UBX_OK |
Past end of decoded frame | Advance read pointer, process *p_msg |
UBX_ERROR_TRUNCATED |
Preamble position (not advanced) | Retry from same offset with larger p_payload_buf |
UBX_ERROR_NOT_FOUND |
buffer_len |
Discard entire buffer, read more data |
UBX_ERROR_PARTIAL |
Preamble position (or 0) | Keep from this offset, append more data |
UBX_ERROR_BAD_DATA |
Preamble position + 1 | Resume scanning from *p_consumed |
UBX_ERROR_INVALID_PARAM |
0 | Fix the call |
Initialises a streaming parser context. Must be called before the first ubx_parser_feed_byte. Also resets UBX_PARSER_STATS counters (the only function that does).
[[nodiscard("a missed nullptr check here means the parser is used uninitialised")]]
ubx_result_t ubx_parser_init(ubx_parser_t *p_parser);Returns UBX_OK or UBX_ERROR_INVALID_PARAM.
Feeds one byte into the streaming parser. O(1), no allocation, ISR-safe.
[[nodiscard("a missed UBX_OK return silently drops a fully decoded frame")]]
ubx_result_t ubx_parser_feed_byte(
ubx_parser_t *p_parser,
uint8_t byte,
uint8_t *p_payload_buf, /* may be nullptr -- checksum is still validated */
size_t payload_buf_len
);| Return | Meaning |
|---|---|
UBX_OK |
Frame complete and checksum valid. Call ubx_parser_get_message, then ubx_parser_reset. |
UBX_ERROR_PARTIAL |
Normal -- frame not yet complete. The vast majority of calls return this. |
UBX_ERROR_BAD_DATA |
Checksum mismatch. Parser auto-reset to SYNC1; no need to call ubx_parser_reset. |
UBX_ERROR_INVALID_PARAM |
p_parser is nullptr. |
p_payload_buf may be nullptr: payload bytes are discarded but checksum accumulation is unaffected. Bytes received beyond payload_buf_len are silently dropped without affecting checksum validation.
Forgot to call ubx_parser_reset after UBX_OK? The next call detects state == COMPLETE, discards the stale frame, resets to SYNC1, and re-dispatches the incoming byte -- all in the same call, no recursion. With UBX_PARSER_STATS, sync_losses is incremented.
Receiving 0xB5 0xB5 0x62 ...? The second 0xB5 is correctly treated as a potential preamble start; the frame beginning there decodes normally.
Copies the completed message metadata out of the parser.
void ubx_parser_get_message(const ubx_parser_t *p_parser, ubx_message_t *p_msg);Call after ubx_parser_feed_byte returns UBX_OK, before ubx_parser_reset.
p_msg->payloadis alwaysnullptr-- the streaming parser never owns a payload buffer. Access the payload through thep_payload_bufpointer you passed toubx_parser_feed_byte.- If
state != UBX_PARSE_COMPLETE,*p_msgis zeroed (no error signalled, to keep this cheap in an ISR context). nullptrarguments are silently ignored.
Returns the parser to UBX_PARSE_SYNC1, discarding any partial frame.
[[nodiscard("a missed nullptr check here means the parser was never reset")]]
ubx_result_t ubx_parser_reset(ubx_parser_t *p_parser);Call after ubx_parser_get_message following UBX_OK, or at any time to discard a partial frame (timeout, UART error, DMA overrun).
Does not reset UBX_PARSER_STATS counters -- only ubx_parser_init does.
Returns UBX_OK or UBX_ERROR_INVALID_PARAM.
Classifies a decoded message as UBX-ACK-ACK or UBX-ACK-NAK and extracts the acknowledged class/ID.
[[nodiscard("dropping the result silently discards the ACK/NAK classification")]]
bool ubx_message_is_ack(
const ubx_message_t *p_msg,
const uint8_t *p_payload,
bool *p_is_ack, /* optional -- true=ACK, false=NAK */
uint8_t *p_ack_cls_id, /* optional -- payload[0] */
uint8_t *p_ack_msg_id /* optional -- payload[1] */
);Returns true when:
p_msg->msg_class == UBX_CLASS_ACK(0x05)p_msg->msg_idisUBX_ID_ACK_ACKorUBX_ID_ACK_NAKp_msg->payload_len >= UBX_ACK_PAYLOAD_LEN(2)p_payload != nullptr
Returns false in all other cases -- no output parameter is modified. Safe to call speculatively on every decoded message.
UBX multi-byte fields (U2, U4, U8, I2, I4, I8, R4, R8) are little-endian on the wire (UBX-23002975-R02 S3.3.5).
/* Decode from an unaligned byte pointer -- safe on Cortex-M0+ */
[[nodiscard]] static inline uint16_t ubx_decode_u16_le(const uint8_t p_data[static 2]);
[[nodiscard]] static inline uint32_t ubx_decode_u32_le(const uint8_t p_data[static 4]);
[[nodiscard]] static inline uint64_t ubx_decode_u64_le(const uint8_t p_data[static 8]);
/* Encode -- identity on little-endian targets (zero cost) */
[[nodiscard]] static inline uint16_t ubx_encode_u16_le(uint16_t value);
[[nodiscard]] static inline uint32_t ubx_encode_u32_le(uint32_t value);
[[nodiscard]] static inline uint64_t ubx_encode_u64_le(uint64_t value);
/* Convenience macros -- evaluate argument exactly once */
UBX_LE16(ptr) /* -> uint16_t */
UBX_LE32(ptr) /* -> uint32_t */
UBX_LE64(ptr) /* -> uint64_t */The [static N] parameter declaration states that the argument is never nullptr and addresses at least N bytes -- a stronger contract than a bare pointer, enforced by the compiler where supported.
| Code | Value | Meaning |
|---|---|---|
UBX_OK |
0 | Success |
UBX_ERROR_INVALID_PARAM |
-2 | nullptr argument, or p_payload == nullptr with payload_len > 0 |
UBX_ERROR_NOT_FOUND |
-5 | No 0xB5 0x62 preamble found in the buffer |
UBX_ERROR_BAD_DATA |
-6 | Preamble found; Fletcher-8 checksum mismatch |
UBX_ERROR_TOO_BIG |
-7 | Output buffer too small |
UBX_ERROR_PARTIAL |
-8 | Frame start found; more data needed |
UBX_ERROR_TRUNCATED |
-9 | Frame valid; payload copy clamped -- payload_len has the true size |
UBX_ERROR_UNKNOWN (-1), UBX_ERROR_NO_MEMORY (-3), UBX_ERROR_TIMEOUT (-4) are reserved and never returned by this library.
uint8_t rx[512];
size_t rx_len = uart_read(rx, sizeof(rx));
uint8_t payload[UBX_MAX_PAYLOAD_LEN];
ubx_message_t msg;
size_t consumed;
switch (ubx_decode_message(rx, rx_len, &msg, payload, sizeof(payload), &consumed)) {
case UBX_OK:
process(&msg, payload);
break;
case UBX_ERROR_PARTIAL:
/* keep rx[consumed..rx_len), append more bytes, retry */
break;
case UBX_ERROR_BAD_DATA:
/* resume scanning from rx[consumed] */
break;
case UBX_ERROR_NOT_FOUND:
/* discard entire buffer */
break;
case UBX_ERROR_TRUNCATED:
/* msg.payload_len has the real size -- allocate and retry */
break;
default:
break;
}static ubx_parser_t s_parser;
static uint8_t s_payload[UBX_MAX_PAYLOAD_LEN];
static ubx_message_t s_msg;
static volatile bool s_ready;
void gnss_init(void) {
(void)ubx_parser_init(&s_parser);
}
/* Called from the UART receive interrupt */
void gnss_rx_isr(uint8_t byte) {
switch (ubx_parser_feed_byte(&s_parser, byte, s_payload, sizeof(s_payload))) {
case UBX_OK:
ubx_parser_get_message(&s_parser, &s_msg);
s_ready = true;
(void)ubx_parser_reset(&s_parser);
break;
case UBX_ERROR_BAD_DATA:
break; /* auto-reset, nothing to do */
case UBX_ERROR_PARTIAL:
break; /* normal */
default:
(void)ubx_parser_reset(&s_parser);
break;
}
}
/* Called from the main loop / RTOS task */
void gnss_poll(void) {
if (s_ready) {
s_ready = false;
process(&s_msg, s_payload);
}
}uint8_t frame[UBX_OVERHEAD_LEN];
int32_t n = ubx_encode_message(0x01U, 0x07U, nullptr, 0U, frame, sizeof(frame));
if (n > 0)
uart_write(frame, (size_t)n);bool is_ack;
uint8_t ack_cls, ack_id;
if (ubx_message_is_ack(&msg, payload, &is_ack, &ack_cls, &ack_id)) {
if (is_ack)
log_info("ACK class=0x%02X id=0x%02X", ack_cls, ack_id);
else
log_warn("NAK class=0x%02X id=0x%02X", ack_cls, ack_id);
} else {
handle_other(&msg, payload);
}/* NAV-PVT payload (UBX-23002975-R02 S3.15.14) */
uint32_t i_tow = UBX_LE32(&payload[0]); /* U4: iTOW, ms */
uint32_t lon_bits = UBX_LE32(&payload[24]); /* I4: lon, 1e-7 deg */
int32_t lon_raw;
memcpy(&lon_raw, &lon_bits, sizeof(lon_raw)); /* bit-reinterpret, not a cast */
double lon_deg = (double)lon_raw * 1e-7;145 assertions (150 with UBX_PARSER_STATS), compiled with ASan + UBSan.
Coverage measured with gcov: 98.6% lines, 100% branches. The only uncovered lines are the default: + unreachable() in the streaming parser's switch -- provably unreachable: 10 enum values, one handled before the switch, 9 explicit case branches cover the rest.
make test
make test-statsmake stack-analysisProduces a proof (not a measurement) of worst-case stack depth for each public function on Cortex-M0+ at -Os:
| Function | Own frame | Deepest callee | Total |
|---|---|---|---|
ubx_decode_message |
48 B | memcpy 20 B |
68 B |
ubx_encode_message |
24 B | memcpy 20 B |
44 B |
ubx_parser_feed_byte |
8 B | __gnu_thumb1_case_sqi 4 B |
12 B |
ubx_message_is_ack |
16 B | -- | 16 B |
ubx_parser_init/reset/get_message |
0 B | -- | 0 B |
Why this is a proof:
- Own frames come from GCC
-fstack-usage. Every frame is markedstatic-- the size is fixed at compile time and does not depend on input or execution path. - The call graph comes from GCC
-fcallgraph-info, extracted from the compiled object and regenerated on every build. - External callee frames (
memcpy,memchr,__gnu_thumb1_case_sqi) were measured once by disassembling the linked Cortex-M0+ binary (push-register count x 4) and are stable across newlib versions. - Recursion is detected automatically by
tools/ubx_stack_check.c-- the analysis exits 1 immediately if any cycle is found.
The threshold (68 B) guards the total depth (own frame + deepest callee chain). A regression in any component of the chain breaks the build, not only a growth in the own frame.
make fuzz-run # 2M iterations (default)
make fuzz-run FUZZ_ITERATIONS=50000000 # deeper campaign
make fuzz-run FUZZ_SEED=42 # deterministic reproductionGCC-only, no external engine, no Clang. Compiled with ASan + UBSan.
Beyond what the sanitizers check, the fuzzer verifies explicit library invariants on every iteration:
consumed <= buffer_lenon every call toubx_decode_message- On
UBX_OK:payload == nullptr <=> payload_len == 0 - The ACK classifier is consistent with the raw
msg_class/msg_idbytes encode(decode(frame))reproduces the sameclass,id, andpayload_len- The streaming API never sets
msg.payloadto a non-nullptrvalue
A crash writes the reproducer to corpus/.scratch before the trap, so it survives process death:
./build/ubx_fuzz --replay corpus/.scratchmake cppcheckmake check # runs: test, test-stats, mcu, mcu-stats, size, stack-analysis, cppcheckEvery facility below is used because it adds real value -- not as decoration.
| Facility | Where | Why |
|---|---|---|
nullptr |
Every pointer comparison and assignment | Typed keyword, no implicit-conversion ambiguity. Replaces the untyped NULL macro. |
constexpr |
All named protocol constants | Typed, scoped, visible in a debugger by name. Replaces untyped #define. |
auto |
Local variables whose type is already stated by their initialiser | Eliminates redundant type restatement where the RHS already names the type exactly. |
T p[static N] |
ubx_decode_u16/32/64_le parameters |
States and lets the compiler check: the argument is never nullptr and addresses at least N bytes -- a stronger contract than a bare pointer. |
[[nodiscard("reason")]] |
Every public function whose return value must not be silently dropped | The reason string appears verbatim in the compiler diagnostic, explaining why the value matters. |
unreachable() |
The defensive default: in the streaming parser switch |
Standard C23 (<stddef.h>) annotation that a path is provably unreachable -- not __builtin_unreachable(). |
UBX_REPRODUCIBLE |
Encode/decode/is_ack entry points | Feature-tested [[__reproducible__]]: result depends only on arguments, no global state read or written. Compiled away on toolchains that do not yet implement it. |
UBX_UNSEQUENCED |
Endianness decode helpers | Feature-tested [[__unsequenced__]]: no side effects at all, even through pointer arguments. Stronger than reproducible. |
static constexpr (in .c) |
Internal frame-offset constants | Internal linkage: present in the optimiser, absent from the linker symbol table. Verified with nm. |
Fixed-underlying-type enum |
ubx_result_t : int32_t, ubx_parse_state_t : uint8_t |
ABI width is explicit and static_assert-verified, not implementation-defined. |
static_assert |
Struct sizes, enum widths, overflow-safety proof | C23 keyword spelling -- not _Static_assert. |
| Digit separators | 65'535U, 65'543U |
Large decimal literals remain readable without a comment restating the value. |