Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 31

Controlling Struct Packing with Pragmas

Recommended resources

Hello! Welcome back.

In our last lesson, we saw how to optimize a struct's memory footprint by reordering its members from largest to smallest. This is a fantastic, "free" optimization that reduces size while maintaining performance because all members remain naturally aligned.

However, in embedded and automotive development, you often don't have the luxury of reordering. You must create structs that precisely match a memory layout defined by an external source, like a hardware register map or a communication protocol. These layouts are often not ordered optimally for alignment. This lesson tackles exactly that scenario. We'll learn how to force the compiler to remove padding altogether using directives like #pragma pack and __attribute__((packed)), and critically, we'll analyze the trade-offs between memory size and performance that this introduces.

1. The Cost of Unaligned Access

Before we tell the compiler to remove padding, we must understand why it was added in the first place. As we've discussed, padding ensures that each data member is naturally aligned—that is, a 4-byte int starts on an address divisible by 4, an 8-byte double on an address divisible by 8, and so on.

A CPU is designed to read data from memory in word-sized chunks (e.g., 4 or 8 bytes at a time) starting at aligned addresses. When you force it to access a multi-byte type at an unaligned address, it cannot do it in a single operation.

This is the performance trade-off at the heart of today's lesson.

Memory Alignment & Padding: Performance Impact
This diagram illustrates the performance cost of unaligned access. On the left, an `int` crosses a word boundary, forcing the CPU to perform two memory fetches. On the right, padding ensures the `int` is aligned, allowing the CPU to fetch it in a single cycle.

For a deeper look, consider the work the CPU must do internally. An unaligned read isn't just one extra fetch; it's a sequence of fetching, shifting, and combining bytes to assemble the requested data in a register.

Unaligned Memory Access Process
This diagram details the steps a CPU takes to handle a 4-byte read from an unaligned address (address 1). It involves multiple memory reads, bit-shifting operations, and finally combining the results. This multi-step process is significantly slower than a single, aligned memory access.

In your work with automotive-grade microcontrollers (like ARM Cortex-M series), the consequences can be even more severe. Depending on the core's configuration, an unaligned access might not just be slow—it can trigger a BusFault or HardFault, crashing the system. This makes understanding and controlling alignment a matter of system stability, not just performance.


2. Directives for Controlling Packing

When you absolutely need a struct to have a specific, byte-for-byte layout, you can use compiler-specific directives to enforce it. These tools instruct the compiler to use a specific alignment boundary or to eliminate padding entirely.

Let's explore the two most common methods.

Mastering C++ Memory Optimization: Alignment, Padding, and ...

This article, "Mastering C++ Memory Optimization", provides clear code examples for both __attribute__((packed)) and #pragma pack. We'll use it to see these directives in action.

Please read the sections "2. Using Compiler-specific Pragmas or Directives" and "4. Using #pragma pack". Pay attention to the syntax and how both methods achieve the same result of reducing the struct size by eliminating padding.

Let's break down these two approaches.

__attribute__((packed))

This is a GCC and Clang-specific extension. You apply it directly to the struct definition. It tells the compiler to pack the members with a 1-byte alignment, effectively removing all padding.

Consider the poorly ordered struct from our last lesson:

// Default alignment (64-bit system)
struct s_default {
    char   c; // 1 byte
    double d; // 8 bytes
    int    s; // 4 bytes
};
// sizeof(s_default) is 24 bytes

// With packing attribute
struct __attribute__((packed)) s_packed {
    char   c; // 1 byte
    double d; // 8 bytes
    int    s; // 4 bytes
};
// sizeof(s_packed) is 13 bytes (1 + 8 + 4)

The size is now the exact sum of its members, but the double d starts at offset 1 and the int s starts at offset 9—both are unaligned.

#pragma pack

The #pragma pack(n) directive is supported by a wider range of compilers, including MSVC. It sets the maximum alignment for subsequent struct members to n bytes. Using #pragma pack(1) achieves the same effect as __attribute__((packed)).

A crucial best practice is to use #pragma pack(push, 1) and #pragma pack(pop). This saves the current alignment setting, applies your new rule, and then restores the original setting. This prevents you from accidentally affecting the layout of other structs in the same translation unit—a common source of subtle bugs.

// sizeof(s_default) is 24 bytes from before




#pragma pack(push, 1) // Set alignment to 1 byte
struct s_pragma_packed {
    char   c;
    double d;
    int    s;
};



#pragma pack(pop) // Restore default alignment

// sizeof(s_pragma_packed) is 13 bytes

// A struct defined here would use default alignment again
struct s_normal_again {
    char c;
    int i;
};
// sizeof(s_normal_again) is 8, not 5

3. When to Use Packing: The Embedded Context

Given the performance and safety risks, why would we ever use packing? As mentioned, you use it when the memory layout is not negotiable. This is a common scenario in your domain.

Memory Alignment in Embedded C: More Than Just Saving RAM

This post, "Memory Alignment in Embedded C", is written for professionals just like you. It perfectly summarizes the trade-offs and best practices for packing in an embedded context.

Please read the sections titled "Packed vs Aligned Tradeoff" and "Best Practices". This will directly connect the concepts we've discussed to practical applications in firmware and automotive systems.

As the resource highlights, the non-negotiable use cases are:

  1. Hardware Register Maps: A peripheral's registers are at fixed memory addresses with a layout defined by the silicon manufacturer's datasheet. A packed struct is the perfect tool to create a C object that overlays this register block, allowing you to access registers as struct members (e.g., TIMER0->CONTROL_REG).
  2. Communication Protocols: When you receive a data frame over CAN, Automotive Ethernet (SOME/IP), or any other bus, the data fields are in a precise order and have no padding. To interpret this raw byte buffer, you can cast a pointer to it to a pointer to a packed struct, giving you immediate, named access to each field.
  3. Binary File Formats or Interoperability: When reading data from a file or interfacing with a system compiled with different alignment rules, packed structs ensure that your program's interpretation of the data matches the source.

The key is that in these cases, the memory layout is dictated by an external contract, and a packed struct is the C language's tool for adhering to that contract.


4. Summary of Trade-offs

Let's distill this into a clear choice for an interview question like, "When would you use a packed struct versus just reordering the members?"

Method When to Use Pros Cons
Reordering Members General-purpose struct optimization. ✅ Fast access (retains alignment).
✅ Good memory savings.
✅ Highly portable.
❌ Not usable when a specific, non-optimal layout is required.
Packing (#pragma or __attribute__) Mapping to fixed external layouts. ✅ Minimal memory usage.
✅ Exactly matches hardware, network protocols, or file formats.
❌ Slow access due to unaligned loads.
❌ Risk of hardware faults on some CPUs.
❌ Less portable (#pragma behavior can vary).

Best Practice: Always prefer reordering members for optimization. Use packing only when you need to conform to an externally defined memory layout.


Conclusion

Today, we've added a powerful but sharp tool to your low-level programming toolkit. You now know how to use #pragma pack and __attribute__((packed)) to strip padding from structs, enabling you to map them precisely to hardware registers and communication protocols. Most importantly, you understand the critical trade-off: this control comes at the cost of performance and, on some systems, stability. Being able to articulate this trade-off is a sign of a senior, experienced engineer.

Key Takeaways:

  • Packing a struct removes padding bytes, making its size the sum of its members.
  • This is achieved with #pragma pack(1) or __attribute__((packed)). Always use push/pop with pragmas.
  • The primary use case is to match an externally defined memory layout (e.g., hardware registers, network packets).
  • The main trade-off is a loss of performance and potential for hardware faults due to unaligned memory access.

In our next lesson, we will move on to performing pointer arithmetic on struct pointers. The sizeof(struct) is the fundamental "unit" for this arithmetic. Your understanding of how to calculate, optimize (by reordering), and control (by packing) a struct's size will be directly applied as we see how ptr++ on a struct pointer increments the address.