Quick Cap
Structs group related data with a predictable memory layout; unions overlay multiple interpretations onto the same bytes; bitfields give symbolic names to individual bits inside a register. Together they are how embedded C code talks to hardware, packs protocol frames, and keeps RAM usage under control.
Interviewers test whether you can predict sizeof results (padding catches most candidates), explain when union type punning is safe, and articulate why bitfield code that works on one compiler may silently break on another.
Key Facts:
- Struct padding is real: The compiler inserts gaps so each field sits at its natural alignment boundary.
sizeof(struct)is almost never the sum of field sizes. __attribute__((packed))removes padding but can cause unaligned-access faults on architectures that require aligned loads (Cortex-M0, some RISC-V cores).- Union type punning is defined behavior in C (C99 and later, footnote 95 / C11 6.5.2.3). It is UB in C++ -- know the distinction.
- Bitfield layout is implementation-defined: Bit ordering (MSB-first vs LSB-first), padding, and storage-unit boundaries vary by compiler and target ABI.
- Designated initializers (
(type){ .field = val }) make struct/union code self-documenting and are mandatory in many embedded coding standards. - Opaque pointers (
struct foo;forward declaration + accessor functions) are the C idiom for encapsulation and information hiding.
Deep Dive
At a Glance
| Concept | Detail |
|---|---|
| Struct | Fields laid out sequentially; compiler adds padding for alignment |
| Union | All members share the same starting address; size = largest member |
| Bitfield | Named groups of bits inside a struct; layout is implementation-defined |
| Packing | __attribute__((packed)) or #pragma pack(1) removes padding |
| Designated init | .field = value syntax; partial init zeros remaining fields |
| Flexible array | type arr[]; as last struct member; size determined at allocation time |
Struct Layout and Padding
The compiler aligns each struct field to its natural boundary (a uint32_t starts at an address divisible by 4, a uint16_t at one divisible by 2). It also pads the end of the struct so that arrays of the struct remain aligned.
/* Poorly ordered struct -- maximum padding */struct badly_ordered {uint8_t a; // offset 0 (1 byte)// 1 byte padding at offset 1uint16_t b; // offset 2 (2 bytes)uint8_t c; // offset 4 (1 byte)// 3 bytes padding at offsets 5-7uint32_t d; // offset 8 (4 bytes)};// sizeof = 12, but only 8 bytes of actual data/* Same fields, better order -- zero wasted bytes */struct well_ordered {uint32_t d; // offset 0 (4 bytes)uint16_t b; // offset 4 (2 bytes)uint8_t a; // offset 6 (1 byte)uint8_t c; // offset 7 (1 byte)};// sizeof = 8 -- no padding at all
Rule of thumb: Order fields from largest alignment to smallest. This is free memory savings with no runtime cost.
A quick sizeof quiz that interviewers love:
struct mix {char x; // 1 byte + 3 paddingint y; // 4 byteschar z; // 1 byte + 3 padding (end padding for array alignment)};// sizeof(struct mix) == 12 on a 32-bit platform, not 6
On a 32-bit ARM, struct { uint8_t a; uint32_t b; } is 8 bytes, not 5. The compiler inserts 3 bytes of padding after a so that b lands on a 4-byte boundary. If you hard-code memcpy(dst, &s, 5), you skip the last 3 bytes of b.
Packed Structs
When the struct must match an external layout -- a hardware register bank, a protocol frame on the wire, or a binary file format -- you need to suppress padding:
/* GCC / Clang */typedef struct __attribute__((packed)) {uint8_t cmd;uint16_t addr;uint32_t data;} spi_frame_t; // sizeof = 7, guaranteed/* MSVC / IAR / portable alternative */#pragma pack(push, 1)typedef struct {uint8_t cmd;uint16_t addr;uint32_t data;} spi_frame_t;#pragma pack(pop) // sizeof = 7
Unaligned access is a hardware fault on Cortex-M0/M0+ and some RISC-V cores. Even on Cortex-M3/M4 (which handle it in hardware), unaligned loads take extra cycles and are non-atomic. Use packed structs for wire formats and hardware overlays, not for general-purpose data structures.
Unions
A union allocates enough memory for its largest member. All members start at offset 0 and share the same bytes.
Type punning (reinterpreting bytes as a different type):
typedef union {float f;uint32_t u;} float_bits_t;/* Inspect the IEEE 754 representation of a float */float_bits_t fb;fb.f = -1.0f;printf("0x%08X\n", fb.u); // 0xBF800000/* Extract sign, exponent, mantissa */uint32_t sign = (fb.u >> 31) & 1;uint32_t exponent = (fb.u >> 23) & 0xFF;uint32_t mantissa = fb.u & 0x7FFFFF;
This is defined behavior in C (C99 6.5.2.3, footnote 95; C11 6.5.2.3 paragraph 3). It is undefined behavior in C++ -- use memcpy there instead. Interviewers frequently test this distinction.
Register overlay (union of struct and raw value):
typedef union {struct {uint32_t enable : 1;uint32_t tx_en : 1;uint32_t rx_en : 1;uint32_t loopback : 1;uint32_t baud_div : 16;uint32_t reserved : 12;} bits;uint32_t raw;} uart_ctrl_t;volatile uart_ctrl_t *const UART0_CTRL =(volatile uart_ctrl_t *)0x40011000;/* Read-modify-write using named fields */uart_ctrl_t tmp = *UART0_CTRL;tmp.bits.enable = 1;tmp.bits.tx_en = 1;tmp.bits.baud_div = 104; // 115200 baud*UART0_CTRL = tmp;/* Or blast the whole register at once */UART0_CTRL->raw = 0x00680007;
Struct vs Union -- Side by Side
| Property | Struct | Union |
|---|---|---|
| Memory | Sum of fields + padding | Size of largest member |
| All fields valid simultaneously? | Yes | No -- only the last-written member is valid |
| Primary use | Grouping related data | Memory-efficient variants, type punning |
| Hardware register use | Bitfield overlay of a single register | Bitfield + raw-value overlay together |
Bitfields
Bitfields give symbolic names to groups of bits inside a struct. They are the most natural way to describe a hardware register:
typedef struct {uint32_t mode : 2; // bits [1:0]uint32_t speed : 2; // bits [3:2]uint32_t pull : 2; // bits [5:4]uint32_t alt_func : 4; // bits [9:6]uint32_t reserved : 22; // bits [31:10]} gpio_config_t;volatile gpio_config_t *const GPIOA_CFG =(volatile gpio_config_t *)0x40020000;GPIOA_CFG->mode = 0x01; // Output modeGPIOA_CFG->speed = 0x03; // High speedGPIOA_CFG->alt_func = 0x07; // AF7 (USART)
Portability warnings -- the big three:
- Bit ordering: The C standard does not specify whether bit 0 is the LSB or MSB of the storage unit. GCC on ARM uses LSB-first; some other compiler/target combinations use MSB-first. Code that assumes a particular bit position is non-portable.
- Storage-unit boundaries: When a bitfield would straddle a storage-unit boundary, some compilers insert padding; others split the field. The behavior is implementation-defined.
- Signedness of plain
intbitfields:int mode : 2might be signed (-2 to 1) or unsigned (0 to 3) depending on the compiler. Always useuint32_torunsigned intexplicitly.
Because of these issues, safety-critical codebases (MISRA, AUTOSAR) often ban bitfields for hardware registers and use explicit shift-and-mask macros instead:
/* MISRA-compliant alternative to bitfields */#define GPIO_MODE_MASK 0x03u#define GPIO_MODE_SHIFT 0u#define GPIO_SET_MODE(reg, val) \((reg) = ((reg) & ~(GPIO_MODE_MASK << GPIO_MODE_SHIFT)) \| (((val) & GPIO_MODE_MASK) << GPIO_MODE_SHIFT))
Designated Initializers and Compound Literals
C99 designated initializers let you name the fields you are setting. Any field you omit is zero-initialized -- this is guaranteed by the standard and eliminates an entire class of "forgot to initialize" bugs:
typedef struct {uint32_t id;uint8_t priority;uint8_t dlc;uint8_t data[8];} can_msg_t;/* Designated initializer -- self-documenting, order-independent */can_msg_t msg = {.id = 0x123,.priority = 3,.dlc = 4,.data = {0xDE, 0xAD, 0xBE, 0xEF},};// msg.data[4] through msg.data[7] are guaranteed to be 0/* Compound literal -- creates a temporary struct in-place */send_can(&(can_msg_t){.id = 0x200,.dlc = 2,.data = {0x01, 0x02},});
Flexible Array Members
A flexible array member (FAM) is a zero-length array at the end of a struct. It lets you allocate a struct with a variable-length payload in a single allocation:
typedef struct {uint16_t length;uint8_t type;uint8_t data[]; // flexible array member -- must be last} packet_t;/* Allocate a packet with 64 bytes of payload */packet_t *pkt = malloc(sizeof(packet_t) + 64);pkt->length = 64;pkt->type = MSG_SENSOR_DATA;memcpy(pkt->data, sensor_buf, 64);/* sizeof(packet_t) does NOT include data[] -- it returnsthe size of length + type + any padding (typically 4 bytes) */
In embedded systems without malloc, you can use FAMs with static buffers via a union trick or simply define a fixed maximum size.
Opaque Pointers for Encapsulation
C has no classes, but opaque pointers provide equivalent information hiding. The public header declares the struct without defining it; only the implementation file knows the layout:
/* sensor.h -- public API */typedef struct sensor sensor_t; // forward declaration onlysensor_t *sensor_create(uint8_t addr);int sensor_read(sensor_t *s);void sensor_destroy(sensor_t *s);/* sensor.c -- private implementation */struct sensor {uint8_t i2c_addr;uint16_t last_reading;uint8_t calibration[16];};sensor_t *sensor_create(uint8_t addr) {sensor_t *s = malloc(sizeof(*s));s->i2c_addr = addr;return s;}
Callers cannot access i2c_addr or calibration -- they can only use the public API. This pattern is used in FreeRTOS (TaskHandle_t), lwIP, and most well-designed embedded C libraries.
Debugging Story: The Register Write That Vanished
A team was bringing up a new SPI peripheral on a Cortex-M4. They defined a register overlay struct with bitfields and wrote configuration values -- but the peripheral never responded. Reads back from the register showed zeros. The code compiled cleanly and the register address was correct.
The root cause: the compiler was using LSB-first bit ordering, but the team had written their bitfield assuming MSB-first (matching the datasheet's bit numbering). The "enable" bit was being written to bit 31 instead of bit 0. Because the peripheral required bit 0 to be set before accepting any other writes, every subsequent configuration was ignored.
The fix: they replaced the bitfield struct with explicit shift-and-mask defines, verified each bit position with a logic analyzer, and added a compile-time assertion (_Static_assert) checking that the bitfield layout matched the expected register value.
Lesson: Never assume bitfield bit ordering matches your datasheet. Verify on your specific compiler and target, or use explicit masks.
What interviewers want to hear: You can predict sizeof for any struct by walking through alignment and padding rules. You know the difference between __attribute__((packed)) and #pragma pack and when each is appropriate. You understand that union type punning is legal in C but UB in C++. You can explain all three bitfield portability hazards (bit order, storage-unit boundaries, signedness). You use designated initializers by default and know about flexible array members and opaque pointers as design tools.
Interview Focus
Classic Interview Questions
Q1: "What is sizeof this struct, and why?"
struct example { char a; int b; char c; };
Model Answer Starter: "On a 32-bit platform, sizeof is 12. a sits at offset 0 (1 byte), then the compiler inserts 3 bytes of padding so b lands at offset 4 (its natural 4-byte alignment). b occupies offsets 4-7. c sits at offset 8 (1 byte), and then 3 bytes of end padding are added so that an array of this struct keeps b aligned. Total: 1 + 3 + 4 + 1 + 3 = 12. If we reorder to int b; char a; char c;, the size drops to 8 with zero wasted bytes."
Q2: "Is reading a different union member than the one last written undefined behavior?"
Model Answer Starter: "In C, no -- it is defined behavior since C99. The standard explicitly permits type punning through unions (C99 footnote 95, C11 6.5.2.3). The bytes of the last-written member are reinterpreted as the type of the member being read. However, in C++ it IS undefined behavior -- the C++ standard only permits reading the active member. For C++ code, use memcpy for type punning instead. In practice, most embedded compilers support union type punning regardless, but it is important to know the standards distinction."
Q3: "What are the portability problems with bitfields?"
Model Answer Starter: "There are three main issues. First, bit ordering -- the standard does not specify whether bit 0 is the least or most significant bit of the storage unit, so a bitfield that maps to hardware register bit 0 on one compiler might map to bit 31 on another. Second, storage-unit boundary behavior -- when a bitfield would cross a storage-unit boundary, some compilers pad and some split. Third, signedness -- int x : 2 might be signed or unsigned depending on the compiler. For portable hardware register access, I either verify the layout with static assertions or fall back to explicit shift-and-mask macros."
Q4: "When would you use a packed struct, and what are the risks?"
Model Answer Starter: "Packed structs are necessary when the in-memory layout must exactly match an external format -- a hardware register bank, a network protocol header, or a binary file record. The risk is that removing padding creates unaligned field accesses. On Cortex-M0 and some RISC-V cores, unaligned access triggers a hard fault. Even on Cortex-M3/M4 where hardware handles it, unaligned loads are slower and non-atomic. I use packed structs only for wire formats and register overlays, never for general-purpose data structures."
Q5: "How do you achieve encapsulation in C?"
Model Answer Starter: "With opaque pointers. The public header forward-declares the struct (typedef struct foo foo_t;) without defining its contents. Only the .c file that implements the module defines the struct's fields. Callers can only interact through the API functions -- they cannot access internal fields because the compiler does not know the struct's size or layout outside the implementation file. FreeRTOS uses this pattern for all its handle types. The tradeoff is that allocation must happen inside the module (the caller cannot stack-allocate an opaque type since its size is unknown)."
Trap Alerts
- Don't say: "Union type punning is undefined behavior" -- it is defined in C (C99+). Saying it is UB reveals confusion between the C and C++ standards.
- Don't forget: End padding -- structs are padded at the end so that arrays of the struct maintain alignment.
struct { int x; char y; }is 8 bytes, not 5. - Don't ignore: Bitfield portability -- if you describe bitfields for register access without mentioning the implementation-defined bit ordering, the interviewer will probe until you do.
Follow-up Questions
- "How would you verify at compile time that a packed struct has the expected size?"
- "Can you take the address of a bitfield member? Why or why not?"
- "What happens if you pass a packed struct member by pointer to a function expecting an aligned pointer?"
- "How does
_Static_asserthelp with struct layout verification?"
Practice
❓ What is sizeof(struct S) where struct S { char a; int b; char c; } on a 32-bit platform?
❓ Is reading a union member different from the one last written undefined behavior in C99?
❓ What does __attribute__((packed)) do to a struct?
❓ Why are bitfields considered non-portable for hardware register access?
❓ What is the advantage of using an opaque pointer (forward-declared struct) in a C API?
Real-World Tie-In
Automotive CAN Driver -- A CAN peripheral driver used bitfield structs to map CAN controller registers. When the codebase was ported from GCC/ARM to the Green Hills compiler for a safety-certified ECU, every register write was hitting the wrong bits. The bitfield ordering differed between the two compilers. The team replaced all bitfield register definitions with shift-and-mask macros and added _Static_assert checks on struct sizes, eliminating the portability issue permanently.
IoT Sensor Protocol -- A LoRaWAN sensor node packed telemetry into 11-byte frames using __attribute__((packed)) structs that exactly matched the over-the-air format. This avoided manual serialization/deserialization code and reduced the frame-building function from 40 lines of byte-stuffing to a single memcpy. The key constraint: the gateway ran on x86 Linux, so both ends used the same packed struct definition with explicit endianness conversion macros for multi-byte fields.
Medical Device Firmware -- An infusion pump used opaque pointers to encapsulate the dose-calculation module. Regulatory reviewers (IEC 62304) required proof that the safety-critical calculation logic could not be accidentally modified by other modules. The opaque pointer pattern -- combined with const-correct accessor functions -- provided that guarantee at the language level, satisfying the assessor without adding runtime overhead.