Skip to content

nrychen/libubx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

libubx

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


Contents

  1. Design
  2. Footprint
  3. Files
  4. Build
  5. API reference
  6. Usage patterns
  7. Quality gates
  8. C23 facilities used

1. Design

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.

2. Footprint

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

3. Project layout

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.

What ships in a firmware integration

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.


4. Build

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 help

The 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.

Integrating into a firmware project

Add ubx_parser.c to your build and include ubx_parser.h. No other files are needed at runtime.


5. API reference

Constants

/* 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;

Types

ubx_result_t

int32_t-backed enum. UBX_OK is always zero; all errors are negative.

ubx_parse_state_t

uint8_t-backed enum. Read-only from outside the library -- do not write directly.

ubx_message_t

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;

ubx_parser_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.


ubx_encode_message

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.


ubx_decode_message

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

ubx_parser_init

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.


ubx_parser_feed_byte

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.


ubx_parser_get_message

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->payload is always nullptr -- the streaming parser never owns a payload buffer. Access the payload through the p_payload_buf pointer you passed to ubx_parser_feed_byte.
  • If state != UBX_PARSE_COMPLETE, *p_msg is zeroed (no error signalled, to keep this cheap in an ISR context).
  • nullptr arguments are silently ignored.

ubx_parser_reset

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.


ubx_message_is_ack

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_id is UBX_ID_ACK_ACK or UBX_ID_ACK_NAK
  • p_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.


Endianness helpers

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.


Return codes

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.


6. Usage patterns

One-shot decode

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;
}

Streaming parser in a UART ISR

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);
    }
}

Poll request (zero-payload frame)

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);

Handling ACK/NAK after a configuration command

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);
}

Reading payload fields (endianness helpers)

/* 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;

7. Quality gates

Unit tests

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-stats

Static stack analysis

make stack-analysis

Produces 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 marked static -- 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.

Mutation fuzzer

make fuzz-run                              # 2M iterations (default)
make fuzz-run FUZZ_ITERATIONS=50000000    # deeper campaign
make fuzz-run FUZZ_SEED=42               # deterministic reproduction

GCC-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_len on every call to ubx_decode_message
  • On UBX_OK: payload == nullptr <=> payload_len == 0
  • The ACK classifier is consistent with the raw msg_class/msg_id bytes
  • encode(decode(frame)) reproduces the same class, id, and payload_len
  • The streaming API never sets msg.payload to a non-nullptr value

A crash writes the reproducer to corpus/.scratch before the trap, so it survives process death:

./build/ubx_fuzz --replay corpus/.scratch

cppcheck

make cppcheck

Release gate

make check   # runs: test, test-stats, mcu, mcu-stats, size, stack-analysis, cppcheck

8. C23 facilities used

Every 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.