Skip to content

endian: read and write little-endian fields byte-wise (unaligned access fault) - #129

Merged
johnpolymath merged 2 commits into
polymathrobotics:mainfrom
rosterloh:endian-unaligned
Sep 18, 2026
Merged

johnpolymath merged 2 commits into
polymathrobotics:mainfrom
rosterloh:endian-unaligned

Conversation

@rosterloh

Copy link
Copy Markdown
Contributor

The six little-endian accessors in endian.c cast a uint8_t* at an arbitrary offset to uint16_t* / uint32_t* / uint64_t* and dereference it:

const uint32_t *bytes = (const uint32_t *)(data + *pos);
return *bytes;

A pstop frame begins with two uint8_t fields, so every multi-byte field lands on an unaligned offset: stamp at 2, received_stamp at 10, id at 18, receiver_id at 22, and so on. Not one of the 32- or 64-bit fields is naturally aligned; the u16 checksum at 46 is only 2-aligned.

Impact

On x86 and Xtensa the hardware absorbs the unaligned access, which is why this has not surfaced β€” it works fine on the ESP32 targets. On a target that traps it, decoding a frame faults immediately. Cortex-M3 under QEMU:

E: ***** USAGE FAULT *****
E:   Unaligned memory access
E: Faulting instruction address (r15/pc): 0x0000435c
E: >>> ZEPHYR FATAL ERROR 0: CPU exception on CPU 0

It is also undefined behaviour independently of the hardware: the cast violates both the alignment requirement and strict aliasing, so a compiler is entitled to assume the pointer is aligned and emit a wide load on any target.

The change

Rewrites all six to build and split values byte by byte β€” exactly as the big-endian half already does. Those were always correct, which is why only the _le variants are touched.

No behavioural change on a little-endian host. The encoded bytes are identical, which I verified against golden frames captured from the previous implementation rather than regenerated from the new one.

Testing

Target Before After
x86-64 (native_sim) 50/50 50/50, byte-identical frames
Cortex-M3 (QEMU) USAGE FAULT on first decode 14/14

The x86 run includes golden-frame tests that assert encoded output against byte arrays captured from this library before the change, so a silent shift in the wire format would have failed them.

Context

Found while adding qemu_cortex_m3 coverage to a Zephyr application built on pstop_c, so that its unit suites can run on macOS β€” native_sim is Linux-only. Independent of #121 (that touches only zephyr/, this only pstop_c/); the two apply cleanly together.

πŸ€– Generated with Claude Code

The six little-endian accessors cast a uint8_t* at an arbitrary offset to
uint16_t*/uint32_t*/uint64_t* and dereference it:

    const uint32_t *bytes = (const uint32_t *)(data + *pos);
    return *bytes;

A pstop frame begins with two uint8_t fields, so every multi-byte field in
it lands on an unaligned offset: stamp at 2, received_stamp at 10, id at 18,
receiver_id at 22, and so on. None of the 32- or 64-bit fields is naturally
aligned, and the u16 checksum at 46 is only 2-aligned.

On x86 and Xtensa the hardware absorbs this, which is why it has not
surfaced. On a target that traps unaligned access it is fatal. Decoding a
frame on Cortex-M3 faults immediately:

    E: ***** USAGE FAULT *****
    E:   Unaligned memory access
    E: Faulting instruction address (r15/pc): 0x0000435c

It is also undefined behaviour everywhere, independently of the hardware:
the cast violates both the alignment requirement and strict aliasing, so a
compiler is entitled to assume the pointer is aligned and emit a wide load
regardless of target.

Rewrites all six to build and split values byte by byte, exactly as the
big-endian half already does -- those were always correct, which is why only
the _le variants are touched here. No behavioural change on a
little-endian host: the encoded bytes are identical, verified against
golden frames captured from the previous implementation.

Verified on x86-64 and on Cortex-M3 under QEMU, where encode/decode now
round-trips instead of faulting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork β€” automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge β€” no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review β€” findings

Rewrites the little-endian read_uint*_le and write_uint*_le helpers to assemble and disassemble values byte by byte instead of casting the buffer to a wider pointer and dereferencing it. This removes reliance on host endianness and unaligned-access behaviour, so the LE codecs produce correct results on big-endian and alignment-strict platforms.

No blocking issues surfaced. 1 lower-confidence candidate did not survive cross-model review.

Analysis details β€” impact, health, verification

Impact & health

Graphify review

Impact β€” 45 functions depend on the 13 functions this change touches.

Health β€” this change adds coupling hotspots:

  • new: pstop_message_decode() β€” 7 callers, 6 callees
  • new: pstop_message_encode() β€” 7 callers, 6 callees
  • new: send_msg() β€” 3 callers, 5 callees
  • new: sess_bond_step() β€” 1 callers, 8 callees
  • new: main() β€” 0 callers, 31 callees
  • new: comparator_task() β€” 0 callers, 23 callees
  • new: main() β€” 0 callers, 15 callees
  • new: comparator_task() β€” 0 callers, 12 callees
  • …and 3 more β€” each is listed as a finding

Verification β€” 45 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS β€” objectively clean (no health regressions, tests not run β€” proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 29 function(s) in the blast radius were not formally verified this run

Β· 11 more finding(s) on lines outside this diff (see the check run).

@iliabaranov

Copy link
Copy Markdown
Contributor

@johnpolymath is this valid/relevant here?

I want to be extra careful as this is safety related, so there are serious concerns for any changes.

@johnandromeda johnandromeda left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! I'll merge it in. Thanks!

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge β€” no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review β€” findings

Rewrites the little-endian read/write helpers (read_uint16_le, read_uint32_le, read_uint64_le, write_uint16_le, write_uint32_le, write_uint64_le) to assemble and emit values byte-by-byte with explicit shifts instead of casting the buffer to a wider integer pointer. This avoids unaligned access and type-punning UB, and makes the little-endian encoding correct regardless of host byte order.

No blocking issues surfaced. 1 lower-confidence candidate did not survive cross-model review.

Analysis details β€” impact, health, verification

Impact & health

Graphify review

Impact β€” 45 functions depend on the 13 functions this change touches.

Health β€” this change adds coupling hotspots:

  • new: pstop_message_decode() β€” 7 callers, 6 callees
  • new: pstop_message_encode() β€” 7 callers, 6 callees
  • new: send_msg() β€” 3 callers, 5 callees
  • new: sess_bond_step() β€” 1 callers, 8 callees
  • new: main() β€” 0 callers, 34 callees
  • new: comparator_task() β€” 0 callers, 25 callees
  • new: main() β€” 0 callers, 15 callees
  • new: comparator_task() β€” 0 callers, 12 callees
  • …and 3 more β€” each is listed as a finding

Verification β€” 45 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS β€” objectively clean (no health regressions, tests not run β€” proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 29 function(s) in the blast radius were not formally verified this run

Β· 11 more finding(s) on lines outside this diff (see the check run).

@johnpolymath
johnpolymath merged commit df2d0d5 into polymathrobotics:main Sep 18, 2026
8 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants