Parsing a Wire Header by Casting
An ARM Cortex-M0 sensor node receives frames through a driver that supplies unsigned char *frame, which may point to an odd address inside a shared FIFO. The wire format is one byte with version in the high nibble and payload length in the low nibble, followed by a 16-bit big-endian message id. A developer writes:
struct header {
uint8_t version : 4;
uint8_t length : 4;
uint16_t id;
};
struct header *h = (struct header *)frame;
if (h->version != 2) reject();
uint16_t id = h->id;
The code passes on the x86-64 build machine. On the Cortex-M0 target it sometimes hard-faults at the h->id load. When the struct is compiled with __attribute__((packed)), the fault disappears, but ids are still wrong for some frames, and a code auditor still calls the code undefined behavior. The target is little-endian and requires natural alignment for 16-bit accesses.
1. Explain why the cast works on x86-64 but faults on Cortex-M0. Address alignment, object effective type, and strict aliasing in your answer.
Read the worked answer
The object being accessed is an array of unsigned char, but the code treats it as a struct header. C11 6.5/7 allows an object to be accessed through a character type, but the reverse is not allowed: accessing a character array through an unrelated struct type does not have defined effective type. The compiler is therefore allowed to assume that the struct pointer and the character buffer do not alias, which can produce incorrect optimization even on a machine that tolerates unaligned accesses.
Alignment is the immediate hardware problem. The struct contains a uint16_t, so the struct type has an alignment requirement of 2 on this target. The driver may hand over an odd address. The member id is at offset 2 in the unpacked layout, so an odd base makes the address of id odd as well. Cortex-M0 requires natural alignment for 16-bit loads; an odd address causes a fault. x86-64 tolerates unaligned scalar loads, so the same code happens to run there.
There is also a size/layout assumption. The wire header is 3 bytes, but the unpacked struct likely occupies 4 bytes because of padding before id. Casting does not parse the wire format; it reinterprets memory using the compiler's layout rules.
2. Why does `__attribute__((packed))` remove the fault but still produce wrong ids? Discuss bit-field layout and endianness.
Read the worked answer
Packing removes the padding before id, so id moves to offset 1. With an odd base address, offset 1 gives an even address, which happens to satisfy the 16-bit alignment requirement in this particular case. That explains why the hard fault disappears. Packing does not make the access generally safe: on other base addresses the member can still be unaligned, and accessing a packed struct member through a pointer can still be undefined behavior. Some compilers generate byte-wise accesses for packed members, but that is an implementation detail, not a C guarantee.
Bit-field allocation is implementation-defined. The C standard does not specify whether version occupies the high or low nibble of the storage unit, nor how adjacent bit-fields are packed. The wire format puts version in the high nibble. If the compiler places the first declared bit-field in the low bits, h->version reads the wire length nibble instead. That produces incorrect version checks and incorrect lengths even though the bytes are in memory.
Endianness is separate. The wire id is big-endian, but the Cortex-M0 is little-endian. Reading h->id interprets the first wire byte as the low byte and the second as the high byte, so the value is byte-swapped. Packing cannot fix either the bit-field order or the byte order.
3. Write a portable decode function for this frame. Show how to extract the nibbles and the 16-bit id without casting the buffer to a struct.
Read the worked answer
Decode byte by byte:
#include <stdint.h>
int decode_header(const unsigned char *frame, size_t len,
unsigned *version, unsigned *plen, uint16_t *id) {
if (len < 3) return -1;
unsigned b0 = frame[0];
*version = b0 >> 4; /* high nibble */
*plen = b0 & 0x0Fu; /* low nibble */
*id = (uint16_t)(((uint16_t)frame[1] << 8) |
(uint16_t)frame[2]);
return 0;
}
The nibble extraction uses shifts and masks, which are defined by the C standard and do not depend on bit-field allocation order. The id is assembled explicitly from the two wire bytes in big-endian order. The cast to uint16_t after shifting is safe because the result is masked to 16 bits by the cast; the intermediate frame[1] << 8 is evaluated as int, which can represent all values from 0 to 65280 on the target. The function also checks the length before reading bytes 1 and 2, preventing an out-of-bounds read if a short frame arrives. No alignment assumption is made, no strict-aliasing violation occurs, and the code works on both little-endian and big-endian hosts. If the header starts after a prefix byte, pass frame + 1 and reduce len accordingly.