Bits, bit fields and std::bitset

Bit operations are how a program talks to a format: a file header, a network packet, a colour, a set of flags. The operators are the same ones lesson 008 introduced for flags; this lesson uses them to pack and unpack fields and to look at the bits themselves.

C++ has three tools for the job. The operators work on unsigned integers, std::bitset gives a fixed-size sequence with named operations, and a bit field declares how many bits a struct member takes. The lesson ends with std::popcount, the C++20 way to count the ones.

Shifts and masks

A shift moves the bits of an unsigned integer left or right, filling with zeros. A mask is an & with a value that has ones only where the field lives, so the result keeps those bits and clears the rest.

The program starts from zero, sets two bits with |=, shifts the whole value left by one and prints it as a binary string and as a decimal. The binary literal 0b0101 is a C++14 addition and makes the mask readable.

Use unsigned types for bit work. Shifting a signed value into its sign bit is undefined, and the result of ~ on a signed int is negative, which is rarely what a mask wants.

#include <bitset> #include <iostream> int main() { unsigned int flags = 0b0000; flags |= 0b0101; flags <<= 1; std::cout << std::bitset<8>(flags) << ' ' << flags << '\n'; std::cout << std::bitset<8>(~flags) << '\n'; }

g++ -std=c++23 -Wall -Wextra operators.cpp -o operators
./operators

00001010 10
11110101

std::bitset

std::bitset<8> is a fixed-size sequence of bits with named operations: set, reset, test, count and to_string. The size is a template argument, so the object holds exactly eight bits and no allocation.

The program starts from 10110000, sets bit 0, resets bit 7 and prints the result, the number of set bits and the value of bit 4. The output reads left to right with the most significant bit first, which is the same order as the binary literal.

A bitset is the readable choice for a set of up to a few dozen flags. For a set that grows at run time, a std::vector<bool> or a set of unsigned words is the alternative.

#include <bitset> #include <iostream> int main() { std::bitset<8> bits{0b10110000}; bits.set(0); bits.reset(7); std::cout << bits << ' ' << bits.count() << ' ' << bits.test(4) << '\n'; }

g++ -std=c++23 -Wall -Wextra bitset.cpp -o bitset_demo
./bitset_demo

00110001 3 1

Packing three fields

A 24-bit colour packs red, green and blue into one integer, one byte each. Packing is a shift and an or; unpacking is a shift and a mask, and the mask is 0xFF because a byte has eight bits.

The program packs 0x12, 0x34 and 0x56 into one value and takes it apart again. The numbers printed are decimal: 1193046 for the packed value and 18, 52 and 86 for the three bytes.

This is exactly how a file format or a protocol describes a field, and it is why the shifts and masks are worth learning by hand before reaching for a library. The same operations appear in lesson 075 when UTF-8 bytes are decoded.

#include <iostream> int main() { unsigned int color = (0x12u << 16) | (0x34u << 8) | 0x56u; unsigned int red = (color >> 16) & 0xFFu; unsigned int green = (color >> 8) & 0xFFu; unsigned int blue = color & 0xFFu; std::cout << color << ' ' << red << ' ' << green << ' ' << blue << '\n'; }

g++ -std=c++23 -Wall -Wextra pack.cpp -o pack
./pack

1193046 18 52 86

Bit fields in a struct

A bit field declares how many bits a member occupies: unsigned int mode : 3; gives mode three bits, so it holds 0 to 7. The members are packed into the storage of the struct, which keeps a small record small.

The program stores three values and prints the size of the struct and the values back. The struct is four bytes because the three fields fit in one unsigned int and the compiler does not split them further.

The layout of bit fields is implementation-defined: the order of the bits, the padding and the behaviour at a boundary are up to the compiler. Use them for memory inside one program, never as a file format that another program has to read.

#include <iostream> struct flags { unsigned int enabled : 1; unsigned int mode : 3; unsigned int level : 4; }; int main() { flags f{}; f.enabled = 1; f.mode = 5; f.level = 9; std::cout << sizeof(flags) << ' ' << f.enabled << ' ' << f.mode << ' ' << f.level << '\n'; }

g++ -std=c++23 -Wall -Wextra bitfields.cpp -o bitfields
./bitfields

4 1 5 9

Counting the set bits

The trick v &= v - 1 clears the lowest set bit of v, so a loop that repeats it counts the ones in as many steps as there are ones. It is the classic hand-written popcount and still the clearest explanation of what the operation does.

C++20 added std::popcount in <bit>, which the compiler turns into a single instruction on processors that have one. The program counts the same value both ways and prints two equal numbers.

<bit> also has std::countl_zero, std::rotl and std::byteswap, and it is the header to check before writing a bit loop by hand.

#include <bit> #include <iostream> int main() { unsigned int value = 0b10110110u; int manual = 0; for (unsigned int v = value; v != 0; v &= v - 1) { ++manual; } std::cout << manual << ' ' << std::popcount(value) << '\n'; }

g++ -std=c++23 -Wall -Wextra popcount.cpp -o popcount
./popcount

5 5

Exercises

  1. Pack two 12-bit values into one 24-bit integer and unpack them again. Which mask do you need for each half?
  2. Add a fourth bit field to flags and print sizeof again. At which point does the struct grow?
  3. Count the set bits of 0xFFFFFFFF with the loop and with std::popcount, and time both for a million iterations.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Which operation tests whether a bit is set?

    Answer

    A bitwise and with a mask that has that bit

    The and keeps only the bits present in both operands, so the result is non-zero exactly when the bit is set.

  2. What does 1u << 5 produce?

    Answer

    32

    One shifted left five times is 2 to the fifth.

  3. What does std::bitset<8> add to an unsigned integer?

    Answer

    A fixed size known at compile time and named bit operations

    The size is a template argument, and the class provides count, test and to_string.

  4. What is a bit field in a struct?

    Answer

    A member with a width in bits

    The declaration gives the number of bits, and the compiler packs the members into the storage.

  5. What does this program print? #include <bitset> #include <iostream> int main() { unsigned value = 10; std::cout << std::bitset<8>(value) << ' ' << value << " / " << std::bitset<8>(~value); }

    Answer

    00001010 10 / 11110101

    Ten is 00001010 in eight bits, and the complement in eight bits is 11110101.

  6. What does this program print? #include <bitset> #include <iostream> int main() { std::bitset<8> bits{0b00110001}; std::cout << bits << ' ' << bits.count() << ' ' << bits.test(0); }

    Answer

    00110001 3 1

    The bits are 00110001, three of them are set, and the lowest bit is one.

  7. What does this program print? #include <iostream> int main() { unsigned packed = (18u << 16) | (52u << 8) | 86u; std::cout << packed << ' ' << ((packed >> 16) & 0xFF) << ' ' << ((packed >> 8) & 0xFF) << ' ' << (packed & 0xFF); }

    Answer

    1193046 18 52 86

    The three fields are shifted into one integer, and the masks read them back in order.

  8. What does this program print? #include <iostream> struct flags { unsigned first : 3; unsigned second : 2; unsigned third : 4; }; int main() { flags f{4, 1, 5}; std::cout << f.first << ' ' << f.second << ' ' << f.third << ' ' << f.first + f.third; }

    Answer

    4 1 5 9

    Each field holds its value, and 4 plus 5 is 9.

  9. What do the two popcount results print?

    Answer

    5 5

    Both values have five bits set, one counted by hand and one by the library.

  10. Why are bit operations done on unsigned types?

    Answer

    Shifts and overflow on signed negative values are not portable

    The standard defines the bit operations on the representation, and the unsigned types are the ones with a defined representation.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev