﻿# Pitfalls of bit fields in C\+\+

C\+\+ is a language that lets developers do absolutely anything, and then it just silently watches as they blow everything up\. We're used to thinking in terms of bytes, but what if the standard type system seems too wasteful? When your inner perfectionist demands compactness, bit fields are the best solution\. Why use an entire eight\-bit container for a simple yes\-or\-no flag when you can carve out exactly one bit with surgical precision? Add a colon, and suddenly you're the master of memory\. However, when it comes to saving memory, order and predictability end\. A programmer is left to deal with the subtleties of standards and the behavior of specific platforms\. Let's take a look under the hood of C\+\+ and see how bit fields actually work\.

![1423_Bitfields/image1.png](https://import.viva64.com/docx/blog/1423_Bitfields/image1.png)

## Introduction

How did I end up here, and why did I even decide to cover this topic?

For quite some time, I've been working on supporting the MISRA C\+\+ 2023 rules\. Yes, PVS\-Studio analyzer learns how to check whether a codebase complies with this standard\. My task was to implement support for a rule that regulates the co\-use of enumerations and bit fields\. It all looked simple in my head: all I had to do was check whether the `enum` values with a fixed fundamental data type fit within the specified bit field width\.

Have you ever started a task that seemed like a fun little adventure that would take just a couple of hours, only to find yourself stuck in a bottomless pit of troubleshooting for an entire week? That's exactly what happened to me\. I got bogged down in trying to implement support for this rule\.

As I tried to untangle the mess, I realized one thing: bit fields in C\+\+ are an underrated topic that every C\+\+ developer should know about\.

## The basics: what are bit fields?

In C\+\+, a bit field is a data\-packing mechanism that allows you to allocate memory within a structure or class bit by bit instead of byte by byte\. Unlike ordinary types, which always occupy at least one byte, bit fields enable you to allocate exactly the number of bits you want to a particular variable\. This makes them invaluable when memory is strictly limited or when you need to store a set of flags as compactly as possible\.

Let's take a look at the syntax for declaring bit fields\. It may differ slightly depending on your technical requirements, but the fundamental structure is as follows:

```cpp
type-specifier declarator : constant-expression ;
```

Here, `constant-expression` specifies how many bits to allocate to a particular field\.

The syntax for bit fields is quite interesting\. We use a colon to give the compiler a direct instruction to override the usual memory allocation rules\. In standard C\+\+ code, the smallest addressable unit is a byte, which contains `CHAR_BIT` bits\. The implementation defines the `CHAR_BIT` macro, but its most common value is eight\. The colon separates the variable's logical name from its actual size\. This forces the compiler to divide memory into small chunks of a specific size\.

This approach saves memory\. How? It eliminates empty and redundant bits\. When you declare standard variables, the processor rounds their size up to whole bytes due to architectural constraints\. Bit fields work around this limitation by packing multiple fields into a shared storage unit\.

To understand how bit fields save space, we'll compare two ways of storing the same data\. Let's say we need to record three system parameters:

* activity status \(on/off\): 1 bit is enough \(values 0 or 1\);
* mode \(4 modes\): 2 bits are enough \(combinations 00, 01, 10, and 11\);
* error code \(from 0 to 7\): 3 bits are enough \(combinations from 000 to 111\)\.

All the essential information about the object state takes up exactly six bits\.

**Option 1\. Standard structure**

If we create a standard structure in C\+\+, the compiler allocates one byte of memory for each type, since this is the minimum addressable size:

```cpp
struct StandardStruct 
{
  bool is_active;   
  char mode;        
  char error_code; 
};
```

<details>
   <summary>The full code</summary>

[The example:](https://godbolt.org/z/1s9v7hhc9)

```cpp
#include <iostream>

struct StandardStruct 
{
  bool is_active;   
  char mode;        
  char error_code; 
}; 

int main()
{
  StandardStruct stStruct;
  std::cout << "Sizeof stStruct: " << sizeof(stStruct)
            << "byte(s)" << std::endl;
}
```


</details>


<details>
   <summary>Program output</summary>

```cpp
Sizeof stStruct: 3 byte(s)
```


</details>
The program uses 24 memory bits\. 24 memory cells were allocated for our six units of information; the remaining 18 cells simply remain empty because the processor architecture can't directly handle units smaller than one byte\.

**Option 2\. Structure using bit fields**

Now let's add the colon syntax\. The compiler starts with a fundamental data type, or container, such as `unsigned char`, which is one byte \(eight bits\) in size\. It then begins packing fields tightly inside the container:

```cpp
struct BitFieldStruct
{
  unsigned char is_active  : 1; 
  unsigned char mode       : 2; 
  unsigned char error_code : 3; 
};
```

<details>
   <summary>The full code</summary>

[The example:](https://godbolt.org/z/9PqjcvoT8)

```cpp
#include <iostream>

struct BitFieldStruct
{
  unsigned char is_active  : 1; 
  unsigned char mode       : 2; 
  unsigned char error_code : 3; 
};

int main()
{
  BitFieldStruct stStruct;
  std::cout << "Sizeof stStruct: " << sizeof(stStruct)
            << " byte(s)" << std::endl;
}
```


</details>


<details>
   <summary>Program output</summary>

```cpp
Sizeof stStruct: 1 byte(s)
```


</details>
As a result of this mechanism, the entire structure is compressed to one byte\. In memory, it appears as a single eight\-bit array divided into sectors: 

![1423_Bitfields/image2.png](https://import.viva64.com/docx/blog/1423_Bitfields/image2.png)

The compiler handles everything: when reading from or writing to the `mode` variable, it automatically generates hidden bit shifts and masks to isolate the required two bits from the rest of the data\. This high\-density packaging significantly reduces RAM usage in large arrays and embedded systems \(microcontrollers\)\.

How bit fields work and their limitations

We've covered the basic principles and syntax of bit fields\. Now let's take a closer look at how they work and explore the rules imposed by the language\.

Bit fields can exist only inside structures or classes\. Developers can't declare a bit field as a standalone variable inside a function\. It's also impossible to apply the `const` or `volatile` qualifiers directly to an individual bit field\. Instead, you can apply them indirectly by declaring an object of that structure or class with the relevant qualifier\.

### Data types for bit fields

The next thing to discuss is which data types you can use with bit fields\. The C\+\+ standard allows this mechanism only for _integral_ types and _enumerations_\. Trying to limit the bit width of floating\-point types \(`float`, `double`\) or complex objects such as `std::string` is technically impossible and will result in a compilation error\.

Bit fields support the following types:

_1_\. Integral types \(`char`, `wchar_t`, `char8_t`, `char16_t`, `char32_t`, `bool`, `short`, `int`, `long`, and `long long`\)_\._

These are the standard options for creating masks and flags\. Before C\+\+14, explicitly specifying the `signed` or `unsigned` modifier for an `int` bit field was critical\. Otherwise, the compiler determined how to interpret the signed bit\. For example, a one\-bit `int` field without an explicit unsigned modifier could take 0 and \-1 instead of the expected 0 and 1\. For a `bool` field, allocating exactly one bit is enough\.

_2\. _Enumerations \(`enum` and `enum class`\)_\._

Bit fields work well for storing `enum` types with a small set of possible values\. If an `enum` has four possible states, allocating two bits is enough\.

_3\. _`std::byte` \(_C\+\+20\)\._

Starting with [C\+\+20](https://timsong-cpp.github.io/cppwp/n5050/cstddef.syn#lib:byte), `std::byte` officially joined the list of permitted types\. If a structure handles raw data at the hardware level, using `std::byte` clearly communicates the developer's intent and prevents accidental arithmetic errors\.

### Rules for working with bit fields

The third important topic to cover is the rules governing bit fields\. In C\+\+, four key restrictions apply\. **Developers cannot**:

* declare an array of bit fields;
* take the address of a bit field;
* create a pointer to a bit field;
* create a reference to a bit field\.

Let's look at some examples to understand why bit fields follow these rules and what happens when you try to get around them\. We'll start with a simple structure:

```cpp
struct HardwareFlags
{
  unsigned int mode   : 3;
  unsigned int status : 1;
};
```

#### No taking addresses or creating pointers

The rule against taking addresses or creating pointers stems from a fundamental aspect of computer architecture: a bit field has no physical memory address\. Modern processor architectures use the byte as the smallest addressable unit of memory \(`CHAR_BIT bits`\)\. A processor simply has no way to address, say, the third bit within a byte\.

For this reason, we can't use the address\-of operator `&` on a bit field:

```cpp
HardwareFlags flags;
auto address = &flags.mode;
int* ptr = &flags.mode;     // doesn't compile
```

Since individual bit segments don't have unique addresses in a memory cell, it's physically impossible to obtain a direct pointer to such a variable\.

#### No address, no references

There's another restriction that comes from the inability to take a bit field's address: you can't bind a standard reference `&` to a bit field\. In C\+\+, references rely on pointers and memory addressing\. Without an address, there's simply nothing for a reference to bind to:

```cpp
HardwareFlags flags;
void checkMode(const unsigned int& m);

unsigned int& ref = flags.mode;        // doesn't compile
checkMode(flags.mode);                 // doesn't compile
```

#### Arrays: why pointer arithmetic falls short

I'm afraid I have some bad news: you can't create an array of bit fields either\. The reason lies in pointer arithmetic\. When you create an array, the compiler guarantees that its elements occupy consecutive memory locations\. When you access the `i`\-th element using `arr[i]`, the compiler calculates its address relative to the start of the array via pointer arithmetic\.

```cpp
struct BrokenStruct
{
  unsigned int items[5] : 2; // doesn't compile
};
```

## The anatomy of alignment

Although bit fields aim to save memory, C\+\+ doesn't specify their exact layout within a structure\. Compiler developers have considerable freedom in how they pack bits, so a structure's actual size depends on the target platform\. This flexibility is impressive, but to understand why it exists, let's take a look at [what the standard says](https://timsong-cpp.github.io/cppwp/n5050/class.bit#1):

> Allocation of bit\-fields within a class object is implementation\-defined\. Alignment of bit\-fields is implementation\-defined\. Bit\-fields are packed into some addressable allocation unit\.
>
> \[Note 1: Bit\-fields straddle allocation units on some machines and not on others\. Bit\-fields are assigned right\-to\-left on some machines, left\-to\-right on others\. — end note\]

In reality, this means that the layout rules come not from the language, but from the [Application Binary Interface](https://en.wikipedia.org/wiki/Application_binary_interface) \(ABI\) defined for a particular OS\. For example, many Unix\-like systems use the [Itanium ABI](https://itanium-cxx-abi.github.io/cxx-abi/abi.html), but it also leaves some aspects of bit ordering unspecified\. The packing rules can vary depending on the OS and architecture\. For example, MSVC on Windows and GCC on Linux may compile the same structure differently\.

In this section, we'll focus specifically on how bit fields work\. I'll touch on alignment briefly, as I've already covered it in a series of articles: the [first part](https://pvs-studio.com/en/blog/posts/cpp/1339/) introduces the basic principles, while the [second](https://pvs-studio.com/en/blog/posts/cpp/1340/) and [third](https://pvs-studio.com/en/blog/posts/cpp/1369/) explore how inheritance and virtual functions affect alignment\.

### How compilers divide memory into allocation units

The phrase "Bit fields are packed into some addressable allocation unit" describes the concept of an [_allocation unit_](https://timsong-cpp.github.io/cppwp/n5050/class.bit#1)\. According to the C\+\+ standard, an allocation unit is an addressable block of hardware memory, such as a byte or machine word, into which bit\-fields are packed consecutively\. The exact behavior of these units is implementation\-defined and depends largely on the platform's ABI\.

A compiler never allocates memory for individual bits out of thin air\. Instead, it starts with the fundamental data type you specify when declaring a field and uses it to determine the size of the physical container\. For example, if you use `unsigned char`, the allocation unit is one byte \(8 bits\)\. If you choose `unsigned int`, it occupies four bytes \(32 bits\)\. The compiler takes this entire physical container and starts slicing it into the specified logical segments, one bit at a time, until no space remains\. Once the container runs out of room, the compiler allocates a new unit for the next fields\.

Let's look at how a compiler packs data:

![1423_Bitfields/image3.png](https://import.viva64.com/docx/blog/1423_Bitfields/image3.png)

<details>
   <summary>The packing algorithm</summary>

1. The compiler receives a list of bit fields declared in a structure\.
1. It allocates the first container \(memory allocation unit\) based on the size of the fundamental data type\.
1. The first bit field occupies the beginning of the newly allocated container\.
1. The compiler calculates how many free bits remain in the current container\.
1. It takes the next bit field from the list for analysis\.
1. It compares the field's width in bits with the number of free bits remaining\.
1. If the field fully fits in the remaining space, the compiler places it in the current container, directly next to the previous one\.
1. After successfully placing the field, the compiler updates the free\-bit counter and moves on to the next field\.
1. If the field's width exceeds the available space, the compiler must resolve a standard ambiguity\.
1. The compiler consults the ABI rules for the target architecture\. On x86 and ARM, the compiler moves the entire field to a new container to preserve CPU performance\. On other architectures, such as some RISC processors, it may split the field into two parts to maximize memory saving, filling the remaining space in the current container\.
1. The compiler closes the current container, leaving any unused bits empty\.
1. It opens a new, empty container \(allocation unit\)\.
1. The field that didn't fit occupies the beginning of the new container\.
1. The compiler then recalculates the number of free bits in the new container and repeats these checks for the remaining fields\.
1. The process is complete once the compiler has assigned all bit fields in the list to their respective containers\.

**Note\.** The direction in which the compiler fills a container depends on the ABI rules for the target architecture\. The order can be from least to most significant bit or vice versa\.


</details>
### sizeof bloat: good vs\. bad alignment

Why are we even going into such detail about the compiler's step\-by\-step algorithm? Bit fields can become a ticking time bomb if one doesn't understand these inner workings\. You write code expecting to save memory, but end up with a structure that's several times larger than normal\. Declaring bit fields haphazardly can trigger sizeof bloat\. The compiler loses its capability to pack data tightly and starts inserting unused bytes\.

Two classic developer mistakes usually lead to such hidden memory leaks:

* Creating giant fields whose widths exceed the physical capacity of the current memory container\.
* Mixing data types indiscriminately, so adjacent fields keep switching between fundamental data types \(for example, `char`, then `int`, and then `char` again\)\. This is a critical issue on Windows \(Microsoft ABI\), where the compiler starts a new allocation unit whenever the type changes, leaving unused padding behind\. This limitation doesn't affect GCC and Clang on Linux and macOS: they ignore changes in base types and continuously pack fields, minimizing alignment padding\.

To see just how much of a difference this makes, let's compare two scenarios: one that shows how not to do it, and one that shows how to do it right\.

**Example N1\. Inefficient layout \(poor alignment\)**

Imagine a structure where we've tried to save memory by mixing different [types](https://godbolt.org/z/Gfohbh69o)\. For this example, let's use MSVC on Windows:

```cpp
struct BadPacking
{
  unsigned char a : 4;
  unsigned int b  : 3;
  unsigned char c : 4;
};
```

The structure fields will be arranged as follows:

![1423_Bitfields/image4.png](https://import.viva64.com/docx/blog/1423_Bitfields/image4.png)

The constant switching between types \(`char` \> `int` \> `char`\) completely disrupts continuous packing\. MSVC treats each type change as a command to close the current allocation unit\. It stops filling the byte after `a`, leaves the remaining bits as padding, and aligns the address for the next block\. It then opens a 4\-byte container for the `int` `b` field, which takes three bits, and immediately closes it to open a container for the `char` type\. As a result, large gaps of unused memory remain\. The compiler also has to follow alignment rules, so it performs final alignment on the entire structure\. Then, `sizeof(BadPacking)` returns 12 bytes\. Clang and GCC, on the other hand, can handle this structure efficiently: they know how to pack these fields as tightly as possible\.

**Example N2\. Optimized layout \(good alignment\)**

To fix this, simply follow the golden rule: convert all fields to a single base [type](https://godbolt.org/z/jzKxvfxKx) and group them by meaning\.

```cpp
struct GoodPacking {
    unsigned int b : 3; 
    unsigned int a : 4;  
    unsigned int c : 4;  
};
```

Let's take a look at how the elements are arranged in the structure now:

![1423_Bitfields/image5.png](https://import.viva64.com/docx/blog/1423_Bitfields/image5.png)

The compiler now works with uniform and predictable four\-byte blocks\. The `b` field occupies almost the entire first block, while the `a` and `c` fields require only eight bits combined, fitting perfectly into the second `int` block\. As a result, `sizeof(GoodPacking)` returns just eight bytes \(exactly two `int` containers\)\. The data is packed as tightly as possible so the processor can access it without any unnecessary overhead\. This gives us a compact structure\.

### Hardware padding: reserving space for unused bits

When writing drivers, implementing network protocols, or working with processor registers, you may encounter hardware controllers with gaps in their bit layout\. For example, a chip manufacturer may reserve certain bits in a hardware byte for future use, meaning you can't write or read data to or from them\.

To precisely mirror the register's physical layout in code, you can use [unnamed bit fields with non\-zero widths](https://timsong-cpp.github.io/cppwp/n5050/class.bit#2) as placeholders or padding:

```cpp
struct TestController
{
  unsigned char transmit_ready : 1; 
  unsigned char                : 3; 
  unsigned char receive_ready  : 1;
  unsigned char                : 3; 
};
```

However, they still occupy their designated space in memory when the compiler generates the structure\. This enables you to map a C\+\+ structure to the microcontroller's physical memory layout with bit\-level precision, without shifting any of the meaningful flags\.

Forcing a packing boundary: why use a zero\-width bit field?

In some cases, such as when working with hardware interfaces, it's necessary to prevent the compiler from packing fields into the current allocation unit\. For example, you may need to ensure that the next data block starts at a new byte or machine word\. To do this, you can use an [unnamed zero\-width bit field](https://timsong-cpp.github.io/cppwp/n5050/class.bit#2):

```cpp
struct Test
{
  unsigned char command : 4; 
  unsigned char         : 0; 
  unsigned char data    : 4; 
};
```

A zero\-width bit field acts as an artificial boundary\. It tells the compiler that the current memory allocation unit is closed\. The next field must start in a new allocation unit and can't use any remaining bits, even if plenty of space remains\. One important rule to remember when working with these fields is that you **can't give a zero\-width bit field a name**\. It must always remain unnamed\.

## Under the hood: how compilers store bit fields

Now, it's time to take a look under the hood\. The internal structure of bit fields in C\+\+ is subject to implementation\-defined behavior\. The C\+\+ standard abstracts away the physical details, providing only high\-level rules and delegating the challenging tasks to the compiler and ABI\. To understand how data actually occupies memory, we need to take a closer look at three fundamental factors: bit allocation order, compiler differences across architectures, and binary portability\.

### Bit allocation order

The first and most important factor that determines a structure's physical layout in memory is the order in which the compiler places bits inside an allocated container\. Most developers are familiar with byte order, which determines how multibyte values are mapped to memory addresses\. A similar concept exists at a lower level, though: the compiler decides which direction to fill individual bits within a particular byte or machine word\. The C\+\+ standard deliberately leaves this detail unspecified, so each compiler's implementation determines how it fills each container\. In real world, compilers use one of two opposite approaches: packing from the least significant bit to the most significant bit \(LSB\-first, Little Endian\) or from the most significant bit to the least significant bit \(MSB\-first, Big Endian\)\.

In the [_LSB\-first_](https://en.wikipedia.org/wiki/Bit_numbering#Least_significant_bit) scheme, the compiler fills the allocated container from right to left\. In other words, packing begins with the least significant bit, or bit 0\. The first bit field declared in the structure occupies the rightmost bits, and each subsequent field sits directly next to it, extending leftward toward bits with greater binary significance\. GCC, Clang, and MSVC all use this approach when compiling code for architectures such as x86, x86\-64, and ARM\.

The opposite strategy is [_MSB\-first \(Most Significant Bit\-first\)_](https://en.wikipedia.org/wiki/Bit_numbering#Most_significant_bit)\. In this case, the compiler packs bits from left to right, starting with the highest available bit: for example, the seventh bit in a one\-byte container\. The first field in the structure occupies the most significant bits on the left side of the container, while subsequent fields follow in sequence toward the least significant bits\. This is common in compilers for [network processors](https://en.wikipedia.org/wiki/Network_processor)\.

Let's see how this works in real code\. Take a look at the following [structure](https://godbolt.org/z/MYM1x8rPK):

```cpp
struct HeaderFlags
{
  uint8_t version : 3; 
  uint8_t type    : 2; 
  uint8_t id      : 3; 
};
```

<details>
   <summary>The full code </summary>

```cpp
#include <cstdint>
#include <cstring>
#include <iostream>

struct HeaderFlags
{
  uint8_t version : 3; 
  uint8_t type    : 2; 
  uint8_t id      : 3; 
};

uint8_t result_byte = 0;

int main()
{
  HeaderFlags flags;
  flags.version = 1;
  flags.type = 2;
  flags.id = 4;

  std::memcpy(&result_byte, &flags, sizeof(HeaderFlags));
    
  return 0;
}
```


</details>
We created a `HeaderFlags` structure that occupies exactly one byte and assigned three values to its fields\. To safely extract the packed byte without violating memory access rules, we used `std::memcpy` and copied the result into the `result_byte` global variable\.

Now let's see how different compilers handle this code\. To avoid wading through unnecessary assembly instructions, we'll enable the `-O2` optimization level\.

The [GCC x86\-64](https://godbolt.org/z/6G4s9envG) output:

```cpp
        .globl std::ios_base_library_init()
"main":
        mov     BYTE PTR "result_byte"[rip], -111 ; <=
        xor     eax, eax
        ret
"result_byte":
        .zero   1
```

The `mov` instruction contains the `-111` negative value, which is how x86\-64 assembly represents one\-byte constants\. Converting it to the familiar unsigned format gives us `256 - 111 = 145`, which is `0x89` in hexadecimal or `0b1001'0001` in binary\. The compiler has eliminated all the intermediate packing instructions and written the final result directly to memory\. Since the target cell occupies four bytes, the `0x00000089` value appears in memory as the `89 00 00 00` byte sequence\. The fact that the least significant byte 89 appears at the lowest memory address confirms the LSB\-first \(Little Endian\) layout\. 

Switching the compiler to the Big\-Endian MIPS architecture, such as mips\-linux\-gcc 16\.1\.0, reveals a completely different picture\. The assembly output for this platform [looks as follows](https://godbolt.org/z/99hYqr6b4):

```cpp
        .globl std::ios_base_library_init()
main:
        lui     $2,%hi(result_byte)
        li      $3,52                 # 0x34
        sb      $3,%lo(result_byte)($2)
        jr      $31
        move    $2,$0

result_byte:
        .space  1
```

Here, the `li $3, 52` instruction loads the precomputed constant `52`, which is `0x34` in hexadecimal and `0b0011'0100` in binary\. The compiler uses the MSB\-first strategy, packing bits from left to right: the `version` field occupies the most significant bits 7–5, type occupies bits 4–3, and `id` lands in the least significant bits 2–0\. We can illustrate the bit layout schematically as follows:

![1423_Bitfields/image6.png](https://import.viva64.com/docx/blog/1423_Bitfields/image6.png)

This example shows how changing the bit endianness can completely change the physical representation of data\. Directly transferring raw bytes between systems that use different bit\-packing schemes can corrupt the data structure\. Understanding this mechanism leads us to the second key factor: the architectural differences between compilers and the role of the ABI\.

### Platform dependence: the anatomy of the MSVC vs\. GCC/Clang conflict

We often think that fundamental data types in C\+\+ behave deterministically on the same hardware architecture\. However, bit fields have an important caveat: the compiler decides how to arrange them in memory\. The in\-memory layout of the same structure compiled for an x86\-64 processor can differ significantly between Windows and Linux\. The physical layout of bit fields follows the rules defined by the ABI\. The ABI specifies how the operating system's platform expects the compiler to group, align, and divide data into individual fields\.

Several ABI standards define different approaches to data packing\. Here are some of the most widely used ones:

1. [System V ABI](https://refspecs.linuxbase.org/elf/gabi41.pdf) \(based on the Itanium C\+\+ ABI\) is used by default in GCC and Clang on Linux, macOS, and FreeBSD\. Although developers originally designed this standard for Intel Itanium processors, it has become a common foundation for GCC and Clang across various architectures, including [x86](https://www.sco.com/developers/devspecs/abi386-4.pdf), [x64](https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf), and [ARM64](https://github.com/ARM-software/abi-aa/blob/main/sysvabi64/sysvabi64.rst)\.
1. [RISC\-V ABI](https://docs.riscv.org/reference/abi/v1.0/index.html) is a specification for the RISC\-V open\-standard architecture\. This standard isn't formally derived from the System V ABI, but it shares many similar data packing and alignment rules\. The goal is to pack bit fields in memory as tightly as possible\.
1. [Microsoft x64 ABI](https://learn.microsoft.com/en-us/cpp/build/x64-software-conventions?view=msvc-170) is used by default in MSVC on Windows systems running on the x86\-64 architecture\.
1. [Microsoft ARM64 ABI](https://learn.microsoft.com/en-us/cpp/build/arm64-windows-abi-conventions?view=msvc-170) is the modern Windows standard for ARM64 processors\. It's designed with x64 limitations in mind\. Its structure alignment rules offer greater flexibility, and its calling convention enables programs to pass data directly through registers, reducing stack usage\.

These documents differ in a single rule\.

**The rule\. Compatibility between different data types**

Developers can declare consecutive bit fields with different fundamental data types within the same structure\. This kind of type mixing is one of the main causes of hidden memory bloat, as compilers use very different algorithms to handle boundaries between allocation units with different fundamental types\.

Let's see what happens when these rules conflict\. We'll run an experiment using Compiler Explorer and measure the structure's size with the `sizeof` operator\. Let's write the following structure:

```cpp
struct PackagingConflict
{
  uint8_t  mode : 4; 
  uint32_t type : 4; 
};
```

<details>
   <summary>The full code</summary>

```cpp
#include <iostream>
#include <cstdint>

struct PackagingConflict {
    uint8_t  mode : 4; 
    uint32_t type : 4; 
};

int main() {
    std::cout << "--- BITFIELD PACKING SIZES ---" << std::endl;
    std::cout << "1. PackagingConflict size: " << sizeof(PackagingConflict)
              << " byte(s)" << std::endl;    
    return 0;
}
```


</details>
Look at the outputs from [Clang](https://godbolt.org/z/nY8WMGGT7) and [MSVC](https://godbolt.org/z/qrjj17vcE):

<details>
   <summary>The Clang program output</summary>

```cpp
--- BITFIELD PACKING SIZES ---
1. PackagingConflict size: 4 byte(s)
```


</details>


<details>
   <summary>The MSVC program output</summary>

```cpp
--- BITFIELD PACKING SIZES ---
1. PackagingConflict size: 8 byte(s)
```


</details>
Here's a visual layout of the memory components:

![1423_Bitfields/image7.png](https://import.viva64.com/docx/blog/1423_Bitfields/image7.png)

Upon analyzing this structure, we can spot a clear difference: Clang packs it into four bytes, while MSVC inflates it to eight\. Since the fields use different fundamental data types \(`uint8_t` and `uint32_t`\), both compilers initially keep them separate\. They allocate one byte for the mode and add three bytes of padding so the type starts at a properly aligned four\-byte boundary\. Clang then applies an Itanium ABI optimization, trimming the unused part of the second allocation unit to save space\. MSVC, on the other hand, strictly follows the Microsoft ABI rule that the structure's total size must be a multiple of its largest fundamental type \(`uint32_t`, or four bytes\)\. It preserves the full four\-byte allocation unit for each block, doubling the structure's final size\.

The real danger is when we move between platforms with different ABIs, the well\-defined physical boundaries of data in memory simply disappear\. Hidden padding and differences in how compilers handle unused space within allocation units can completely change the internal field layout\. This makes bit fields unsuitable for direct byte\-by\-byte copying, file storage, or binary serialization\. If a structure is saved using Clang's four\-byte layout and then read using MSVC's eight\-byte layout, the program reads from incorrect offsets, resulting in corrupted data\.

### Binary fragility and industry standards

Due to inconsistencies in alignment and bit ordering, structures containing bit fields offer virtually no binary portability\. C\+\+ compilers treat bit fields as a black box: you write concise code, but leave the actual memory layout to chance\.

Using bit fields is virtually guaranteed to cause problems in three areas:

**1\. Network I/O\.** Sending a structure directly over a socket with a call like `send(sock, &packet, sizeof(packet), 0)` is a ticking time bomb\. If the server runs Linux \(Clang\) and the client runs Windows \(MSVC\), ABI differences can shift bit positions, turning valid flags into a meaningless jumble\.

**2\. File I/O \(serialization\)\.** For example, writing bit fields directly to a file byte by byte with `fwrite` ties the data to a specific ABI\. According to Section [6\.7\.2\.1](https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2310.pdf#page=95) of the ISO C standard, the order in which compilers lay out bit fields and padding is implementation\-defined behavior\. Writing such a structure to a file on Linux and then reading it on Windows can cause differences between the Microsoft and System V ABIs to result in the program reading corrupted data from incorrect offsets\.

The C\+\+ object model in C\+\+23 partially addresses the issue of object lifetimes with the [`std::start_lifetime_as`](https://en.cppreference.com/cpp/memory/start_lifetime_as) function\. However, this function only validates the creation of an object from raw bytes in memory; it doesn't address differences in the physical layout of fields across platforms\. Moreover, copying uninitialized structure padding when writing data to a file technically involves reading indeterminate values, which leads to undefined behavior\.

**3\. Drivers and hardware \(Hardware MMIO\)\.** A microcontroller's hardware defines the bit layout of its registers, and it never changes\. In C, however, the order and alignment of bit fields, as well as the width of memory accesses, depend on the implementation\. So, describing a hardware register with bit fields doesn't guarantee that each field will map to the intended bits\. Switching compilers, compiler versions, or build flags can shift the fields, causing writes to affect the wrong register bits\.

Developers avoid bit fields entirely for any task that requires a strictly defined data layout, such as network communication, file storage, or hardware registers\. Instead, they use fixed\-width integer types \(`uint8_t`, `uint16_t`, and `uint32_t`\) combined with explicit bit shifts \(`<<, >>`\) and bit masks \(`&, |`\)\.

Differences between compiler ABIs make it difficult to use standard bit fields without manual alignment for cross\-platform data exchange\. A program compiled on Linux using GCC or Clang produces a data structure that a Windows application built with MSVC can't read correctly because the Windows application expects alignment and padding bits in different locations\.

## Use cases: where bit fields actually make sense

In C\+\+ development, bit fields serve specific engineering needs rather than abstract memory\-saving goals\. There are two main scenarios where this feature proves most useful\.

**1\. Compact metadata for millions of objects\.** Developers often need to store metadata, such as status flags, type IDs, and reference counts, for huge numbers of small elements when designing game engines, custom memory allocators, or databases\. Allocating a separate variable for each flag can waste enormous amounts of memory due to compiler\-imposed alignment\. Bit fields let you pack several independent properties into a single byte, minimizing memory usage\.

**2\. Optimizing CPU cache usage \(Cache Lines\)\.** Modern processors don't read data from RAM one item at a time\. Instead, they fetch large blocks of data called cache lines, typically 64 bytes each\. When a data structure becomes too large, each cache line contains fewer useful objects\. This forces the processor to repeatedly wait for slow RAM access, resulting in a cache miss\. Using bit fields to pack data tightly allows the CPU cache to hold several times more objects\. An algorithm can run several times faster when it iterates over objects sequentially in large batches because it retrieves data from the CPU's ultra\-fast cache\.

## Modern alternatives to bit fields

Due to portability issues, dependence on platforms, and restrictions on address taking, developers often need to look for alternatives to traditional bit fields\. Modern C\+\+ offers three main architectural alternatives, each designed to address a specific use case\.

### The classic approach: manual bit masks and shifts

If you need to have complete control over the physical position of each bit, the classic C\-style approach is your best option\. Rather than relying on the compiler to automatically pack bits, you select a fixed\-width integer type and manually manipulate individual bits using bitwise operators \(`&,` `|`, `~`, `^`, `<<`, and `>>`\)\.

Take a look at the example:

```cpp
class DeviceConfig
{
public:
  void set_mode(uint8_t mode)
  {
    data = (data & ~MODE_MASK) | ((mode << 1) & MODE_MASK);
  }

  uint8_t get_mode() const
  {
    return (data & MODE_MASK) >> 1;
  }

private:
  static constexpr uint8_t ENABLE_CLOCK_MASK = 0x01;
  static constexpr uint8_t MODE_MASK         = 0x0E;
  static constexpr uint8_t INTERRUPT_MASK    = 0xF0;

private:
  uint8_t data = 0;
};
```

Yes, this code is more complex, but its binary layout is now completely independent of the compiler and ABI\. This makes it an ideal approach for implementing network protocols and writing device drivers\. The only thing developers need to keep in mind is the processor's byte order, or endianness\. Multi\-byte values appear in reverse order on Big Endian architectures compared to Little Endian ones\.

### For maximum safety: the std::bitset container

The built\-in [`std::bitset`](https://timsong-cpp.github.io/cppwp/n5050/bitset) class is the best choice if code safety and convenient manipulation of Boolean flags matter more than saving memory in individual structures\.

This container provides a fixed\-size array of bits whose size is specified as a template parameter at compile time\. It encapsulates all bitwise operations, providing developers with a high\-level, type\-safe interface\.

Look at the following [example](https://godbolt.org/z/vh47czvWT):

```cpp
#include <bitset>
#include <iostream>

enum Permission
{
  Read = 0,
  Write = 1,
  Execute = 2,
  Admin = 3,
  MaxPermissions 
};

void ManagePermissions()
{
  std::bitset<MaxPermissions> user_rights;
  user_rights.set(Permission::Read);    
  user_rights.set(Permission::Write);   
  user_rights.flip(Permission::Execute); 
  
  if (user_rights.test(Permission::Write))
  {
    std::cout << "The user can write files.\n";
  }

  std::cout << "Current rights mask: " << user_rights << "\n";
}

int main()
{
  ManagePermissions();
}
```

The main advantage of `std::bitset` is that it protects against human error\. If you pass an invalid index to one of its member functions—[`std::bitset<N>::set`](https://timsong-cpp.github.io/cppwp/n5050/bitset#lib:set_(member),bitset_), [`std::bitset<N>::reset`](https://timsong-cpp.github.io/cppwp/n5050/bitset#lib:reset,bitset_), [`std::bitset<N>::flip`](https://timsong-cpp.github.io/cppwp/n5050/bitset#lib:flip,bitset_), or [`std::bitset<N>::test`](https://timsong-cpp.github.io/cppwp/n5050/bitset#lib:test,bitset)—such as index 15 for a four\-bit bitset, the function throws a `std::out_of_range` exception\. With manual bit masks, the same mistake could lead to unpredictable reads from unrelated memory or corrupt adjacent data\.

There's a caveat, though: `std::bitset` typically rounds its internal storage up to the processor's native word size, so it can't save memory at the individual\-bit level\.

### For large numbers of flags: the std::vector<bool\> specialization

Using a regular `bool` array to manage a huge, dynamically sized array of Boolean flags wastes a lot of memory because each element occupies an entire byte\. To fix this issue, the standard library provides a specialized version of `std::vector`: [`std::vector<bool>`\.](https://timsong-cpp.github.io/cppwp/n5050/vector.bool)

This container works differently from ordinary vectors\. The standard library optimizes it for memory efficiency by packing `bool` elements bit by bit into larger integer types \(machine words\)\. Usually, it can store 32 or 64 Boolean values in a single memory block\.

The following is an example of using a large dynamic array:

```cpp
#include <vector>

std::vector<bool> feature_flags(10000, false);

void process_features()
{
  feature_flags[42] = true; 
  if (feature_flags[42])
  {
    // code
  }
}
```

Despite its convenience, `std::vector<bool>` is one of the most controversial features of the C\+\+ standard\. Developers should exercise caution when using it\. Because it packs bits so tightly, its elements aren't actual `bool` objects in memory\. As a result, you can't take the address of an element directly \(the `bool *ptr = &feature_flags[0];` code won't compile\) or pass an element by reference or pointer\. The `[]` operator returns a special temporary proxy object that mimics reference behavior rather than a reference\. This is the trade\-off for compressing data in dynamically allocated memory\.

## Conclusion

Well, that's all I have for you today\. What can I say? Bit fields in C\+\+ perfectly embody the language's philosophy\. They give you complete freedom to cut out exactly one bit, then leave you alone to navigate the hidden landmines buried in compilers and system ABIs\. Now that you've looked under the hood and seen how this intricate memory optimization coexists with platform\-dependent chaos, there's no room for illusions\. Bit fields are a powerful tool for optimizing cache lines and working with hardware\. However, they can also create hidden issues when you try to send a structure over the network\. Now you know the rules of this low\-level game\. How you wield this power over memory in your own projects is up to you\.


> \[\*\*Get a free one\\\-month trial\*\*\]\(https://pvs\-studio\.com/en/pvs\-studio/try\-free/\)