Skip to Content
Course content

148: Working with Bit Fields and Packed Structs

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen to almost every developer the first time they try to write a driver or a network protocol implementation. You define a struct to match a hardware specification exactly, you use bit fields to save space, and then you realize the data you're sending is being rejected by the device because your "2-byte" packet is actually 4 or 8 bytes long.

Take a look at this snippet. I'm trying to map a status byte from a fictional industrial sensor. The spec says the first 2 bits are the version, the next 3 are the error code, and the final 3 are the operational mode.

struct SensorStatus {
    unsigned int version : 2;
    unsigned int errorCode : 3;
    unsigned int mode : 3;
};

// ... somewhere in the code ...
SensorStatus status;
std::cout << "Size of status: " << sizeof(status) << " bytes" << std::endl;
// Expected: 1, Actual: 4 (on most 64-bit systems)

The Mystery of the Growing Struct

You'll notice that even though we only defined 8 bits of data, sizeof(SensorStatus) returns 4. Why? Because the compiler is trying to be helpful. CPUs are generally much faster at accessing data when it's aligned to their natural word size (usually 4 or 8 bytes). The compiler sees you're using unsigned int as the underlying type for your bit fields and decides to pad the entire struct to the size of a full integer to keep the memory alignment efficient.

This is fine for internal logic, but it's a disaster when you're casting a raw buffer from a socket or a serial port directly into a struct. You'll be reading padding bytes as if they were actual data, and your offsets will be completely shifted.

Forcing Tight Alignment with Packing

To fix this, we need to tell the compiler to stop worrying about CPU alignment and start worrying about the exact memory layout. We do this using "packing." While the C++ standard doesn't have a universal keyword for this, almost every major compiler provides a way to do it. The most common is #pragma pack(1), which tells the compiler to align members on 1-byte boundaries.

I also recommend changing the underlying type from unsigned int to uint8_t (from <cstdint>). This makes your intentions explicit: you are working with a single byte.

#include <iostream>
#include <cstdint>

#pragma pack(push, 1) // Save current alignment and set to 1 byte
struct SensorStatus {
    uint8_t version : 2;
    uint8_t errorCode : 3;
    uint8_t mode : 3;
};
#pragma pack(pop) // Restore original alignment

int main() {
    SensorStatus status;
    std::cout << "Size of status: " << sizeof(status) << " bytes" << std::endl;
    // Actual: 1
    return 0;
}

By wrapping the struct in pack(push, 1) and pack(pop), we ensure that only this specific struct is packed. You don't want to pack your entire project; doing so can cause a significant performance hit or even crashes on architectures that forbid unaligned memory access (like some ARM chips).

The Trap of Bit Field Ordering

Here is a bit of a warning: bit fields are notoriously implementation-defined. The C++ standard doesn't actually mandate whether the first bit field you define goes into the most significant bit (MSB) or the least significant bit (LSB). If you're writing code that must be portable across different compilers or CPU endianness, bit fields can be a gamble.

In my experience, if you're doing extremely high-precision binary protocol work, it's often safer to use a uint8_t and perform manual bitmasking (e.g., (value & 0x03)). But for the vast majority of internal applications and specific compiler targets, packed bit fields are a massive productivity boost and make your code much more readable than a sea of shift-and-mask operations.




📋 Practical Task

Implementing a Compact MIDI-style Message Header

You are building a system to parse a custom MIDI-like protocol. The protocol defines a 16-bit (2-byte) header with the following requirements:

  • Priority: 3 bits
  • Channel: 4 bits
  • Command Type: 9 bits

Your Task:

  1. Create a struct named MidiHeader using bit fields to match these exact requirements.
  2. Use the appropriate packing mechanism to ensure that sizeof(MidiHeader) is exactly 2 bytes.
  3. In your main function, instantiate the struct, set the Priority to 5, the Channel to 10, and the Command Type to 256.
  4. Print the sizeof(MidiHeader) to verify it is 2, and print the values of the members to ensure they were stored correctly.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.