Bits, Binary, and Hexadecimal

Explore how hardware represents data at the bit level, how to read binary and hexadecimal literals, and how to inspect raw bits in C++.

Ryan McCombe
Published

In the previous chapter, we built PlayerStorage, a high-performance container designed around the structure of arrays (SoA) layout. We optimized for linear iteration speed, cache locality, and stable addressing.

When we implemented our to track whether an entity slot was alive or dead, we stored that state using an 8-bit integer:

std::vector<uint8_t> m_alive; // 1 = Alive, 0 = Dead

On the surface, this looks wasteful. To represent a true or false state, we only need two possibilities: 1 or 0. Yet uint8_t consumes 8 bits of memory.

Switching to bool does not fix this. A bool in C++ also consumes 8 bits of RAM, meaning 7 out of every 8 bits - 87.5% of our memory bandwidth - are wasted.

To reclaim that wasted space and unlock algorithms that process dozens of states in a single clock cycle, we need to look beneath standard C++ types and work directly with raw bits.

In this lesson, we will build the foundation for bit-level programming: how bits and bytes physically exist in hardware, how to read and write binary and hexadecimal numbers, and how to inspect the exact bit patterns sitting in your variables.

Bits, Bytes, and Addressability

At the silicon level, your computer does not know what an int, a float, or a Player is. Hardware only understands electrical voltage states: high voltage (1) and low voltage (0).

A single 0 or 1 is called a binary digit, or a bit. It is the fundamental atom of information.

However, the CPU cannot fetch a single isolated bit from RAM. As we explored in our , main memory is organized like a massive array of numbered mailboxes, where every mailbox has a unique memory address.

On modern hardware, the smallest addressable unit of memory is 1 byte, which consists of 8 bits.

Because every distinct object in C++ must have a unique memory address so that pointers and references can target it, no type can be smaller than 1 byte. Even if a bool only cares about a single bit, the compiler must allocate a full 8-bit byte for it.

If we create a struct with three boolean flags, we pay for three separate bytes:

struct Status {
  bool is_alive;    // 1 byte (7 bits wasted)
  bool is_friendly; // 1 byte (7 bits wasted)
  bool is_nearby;   // 1 byte (7 bits wasted)
};

Instead of letting the compiler waste 7 bits per boolean, we often treat a single byte as a suitcase capable of holding 8 independent boolean states. That will be the focus of our next lesson - for now, we'll focus on what raw bits are and how we use a basic integer type as a container to store.

Fixed-Width Unsigned Integers

When we use an integer as a container for raw bits, we need to know exactly how many bits it holds. Standard types like int or long are dangerous here because the C++ standard only guarantees their minimum sizes, which can vary across platforms and compilers.

To get strict hardware guarantees, we include <cstdint> and use fixed-width unsigned integers:

  • uint8_t: Exactly 8 bits (1 byte). Holds 8 flags.
  • uint16_t: Exactly 16 bits (2 bytes). Holds 16 flags.
  • uint32_t: Exactly 32 bits (4 bytes). Holds 32 flags.
  • uint64_t: Exactly 64 bits (8 bytes). Fits cleanly in a single 64-bit CPU register.

Understanding Binary (Base-2)

Humans naturally count in decimal (Base-10). Every digit ranges from 0 to 9, and each position represents a power of 1010:

20510=(2×102)+(0×101)+(5×100) 205_{10} = (2 \times 10^2) + (0 \times 10^1) + (5 \times 10^0)

Computers count in binary (Base-2). Because a bit can only be 0 or 1, each position represents a power of 22. In an 8-bit byte (uint8_t), we index the bits from 0 to 7 moving from right to left:

To find the decimal value of a binary byte, we simply sum the weights of every position that contains a 1. For the bit sequence 00101010:

(1×25)+(1×23)+(1×21)=32+8+2=42 (1 \times 2^5) + (1 \times 2^3) + (1 \times 2^1) = 32 + 8 + 2 = 42

If all 8 bits are 0 (00000000), the value is 0. If all 8 bits are 1 (11111111), the value is 128+64+32+16+8+4+2+1=255128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255, which is 28−12^8 - 1.

Most and Least Significant Bits

In this scheme, bits at higher indices are more significant. If we have a byte (8 bits) and the leftmost bit (bit 77) is 1, then the numeric value of the unsigned integer represented by this byte increases by 128128 - that is, 272^7.

Conversely, bit 0 on the far right is the least significant as it has the smallest numeric weight. If bit 0 toggled on (11), then the overall numeric value of the binary sequence increases by only 11 - that is, 202^0.

Binary Literals and Digit Separators

In C++, if we write int x{42}, we are writing a decimal literal. What if we don't care about the decimal number 42, and instead want to specify the exact bits 00101010 directly in our source code?

We can write a binary literal by prefixing the bits with 0b (or 0B):

#include <cstdint>

int main() {
  // All three variables store the exact same bits in RAM:
  uint8_t a{42};
  uint8_t b{0b00101010};
  uint8_t c{0B00101010};
}

When working with 16-bit, 32-bit, or 64-bit numbers, long strings of 1s and 0s quickly become unreadable. We can insert single quotes (') anywhere between digits as visual digit separators. The compiler completely ignores them:

#include <cstdint>

int main() {
  // Grouping by 4 bits makes binary much easier to scan:
  uint8_t flags{0b0010'1010};

  // Grouping a 32-bit integer by bytes (8 bits):
  uint32_t mask{0b11111111'00000000'10101010'01010101};
}

Understanding Hexadecimal (Base-16)

Even with digit separators, writing out 64 binary digits in source code or reading them in a debugger is exhausting. On the other hand, decimal numbers are terrible for bit manipulation because 1010 is not a power of 22 - looking at the decimal number 19,483, we have no idea which bits are set.

We solve this using hexadecimal (Base-16), or hex for short. Because 16=2416 = 2^4, a single hexadecimal digit maps to exactly 4 bits (sometimes called a nibble). Two hexadecimal digits map to exactly 1 byte (8 bits).

Hexadecimal uses the standard digits 0 through 9 for the first ten values, and the letters A through F for the values 10 through 15:

HexDecimalBinary (4 Bits)HexDecimalBinary (4 Bits)
000000881000
110001991001
220010A101010
330011B111011
440100C121100
550101D131101
660110E141110
770111F151111

To convert any byte between binary and hexadecimal, we can split the 8 bits into two 4-bit halves and translate each half independently:

  • The binary byte 0010'1010 splits into 0010 (2) and 1010 (A), which is 2A in hex.
  • The binary byte 1111'1111 splits into 1111 (F) and 1111 (F), which is FF in hex.

Hexadecimal Literals (0x)

In our code, we can write a hexadecimal literal by prefixing the value with 0x (or 0X). Upper and lowercase letters (0xff vs 0xFF) are treated identically:

#include <cstdint>

int main() {
  // All three initialize 'mask' to the exact same bits:
  uint8_t dec_mask{255};
  uint8_t bin_mask{0b1111'1111};
  uint8_t hex_mask{0xFF};

  // A 32-bit RGBA color (Red, Green, Blue, Alpha bytes):
  uint32_t color{0xFF'00'7F'FF};
}

Once you recognize a few patterns, hex lets you read common bit layouts at a glance. For example:

  • 0x00: All 8 bits in the byte are 0 (0b0000'0000).
  • 0xFF: All 8 bits in the byte are 1 (0b1111'1111).
  • 0x0F: The bottom 4 bits are 1 (0b0000'1111).
  • 0xF0: The top 4 bits are 1 (0b1111'0000).

Error: Octal Literals and the Leading Zero Trap

Any integer literal starting with a 0 (and not followed by b or x) is interpreted as octal (Base-8) in C++. This means that, for example, 042 and 42 are not equivalent - 042 is actually 34 in decimal.

This is unintuitive and a common source of bugs:

int padded{042}; // Octal - Evaluates to 34 in decimal

Octal is rarely useful- the fact that C++ makes it so easy to use by accident is a legacy rule inherited from the C programming language. It is expected to change in the future - replacing the 0 prefix with 0o for octals is currently targeted for the C++29 standard.

Displaying Bits

When building systems that work on individual bits, we will frequently want to log our values using a binary or hexadecimal representation. There are two immediate obstacles in our way.

The uint8_t Character Trap

First, if we pass a uint8_t directly to std::cout, it will not print a number at all.

Because the standard library defines uint8_t as a type alias (typedef) for unsigned char, std::cout assumes we are trying to print an ASCII text character:

#include <cstdint>
#include <iostream>

int main() {
  uint8_t flags{65};
  std::cout << "Flags: " << flags;

  uint8_t low_flags{2};
  std::cout << "\nLow:   " << low_flags ;
}
Flags: A
Low:

The value 65 is the ASCII code for 'A', and the value 2 is a non-printable ASCII control character, so the second line will appear completely blank on most systems.

To force std::cout to treat a uint8_t as an integer, we can promote it static_cast<int>(flags). Alternatively, system programmers will often use the unary + operator as a shorthand to achieve the same effect:

#include <cstdint>
#include <iostream>

int main() {
  uint8_t flags{65};
  std::cout << "Flags: " << static_cast<int>(flags);

  uint8_t low_flags{2};
  std::cout << "\nLow:   " << +low_flags;
}
Flags: 65
Low:   2

Printing Binary with std::bitset

Even if we promote the integer, std::cout defaults to printing in base-10 decimal. The easiest way to print the exact binary bits of any value using std::cout is to wrap it in std::bitset<N> from the <bitset> header, where N is the number of bits we want to display:

#include <bitset>
#include <cstdint>
#include <iostream>

int main() {
  uint8_t flags{0x2A}; // 42 in decimal

  std::cout << "Decimal: " << +flags << "\n";
  std::cout << "Hex:     0x" << std::hex << +flags << "\n";
  std::cout << "Binary:  " << std::bitset<8>{flags};
}
Decimal: 42
Hex:     0x2a
Binary:  00101010

Modern Formatting with std::format()

If we are using C++20's <format> library, or C++23's std::println(), formatting integers as binary or hexadecimal is built right into the format string syntax, and it never mistakes uint8_t for a text character.

We introduce std::format in more detail in a , but the most common ways to present binary or hexadecimal representations are:

  • {:08b}: Formats the value in binary (b), padded with leading zeros (0) to 8 characters (8).
  • {:#010b}: Includes the 0b prefix (#), padded to 10 total characters (0b + 8 bits).
  • {:#04x}: Formats the value in lowercase hex (x) or uppercase hex (X) with the 0x prefix (#), padded to 4 total characters (0x + 2 hex digits).

For example:

#include <cstdint>
#include <format>
#include <iostream>

int main() {
  uint8_t flags{42};

  std::cout << std::format(
    "Dec: {:d} | Hex: {:#04X} | Bin: {:08b}",
    flags, flags, flags
  );
}
Dec: 42 | Hex: 0x2A | Bin: 00101010

Summary

In this lesson, we peeled back the abstraction of C++ types to look at how values are represented in memory:

  1. Byte Addressability: The smallest addressable unit of memory is 1 byte (8 bits). Types like bool waste 7 bits of every byte unless we pack data manually.
  2. Fixed-Width Integers: We use unsigned types from <cstdint> (uint8_t, uint16_t, uint32_t, uint64_t) as predictable, fixed-size containers for bits.
  3. Binary and Hexadecimal: Binary (0b) lets us target exact bit positions from right (LSB, Bit 0) to left (MSB), while hexadecimal (0x) condenses every 4 bits into a single readable character (0-F).
  4. Inspecting Bits: We can use std::bitset<N> or std::format("{:08b}", val) to inspect raw bit patterns without falling into the uint8_t ASCII character trap.

Now that we know how to read, write, and inspect individual bits, we are ready to manipulate them. In the next lesson, we will unlock the CPU's bitwise operators to set, clear, toggle, and query multiple boolean states packed inside a single byte.

Next Lesson
Lesson 34 of 76

Bitwise Operators and Flags

Learn to use bitwise operators to pack multiple states into a single byte, reducing memory bandwidth use.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate