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++.
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 = DeadOn 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 :
Computers count in binary (Base-2). Because a bit can only be 0 or 1, each position represents a power of . 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:
If all 8 bits are 0 (00000000), the value is 0. If all 8 bits are 1 (11111111), the value is , which is .
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 ) is 1, then the numeric value of the unsigned integer represented by this byte increases by - that is, .
Conversely, bit 0 on the far right is the least significant as it has the smallest numeric weight. If bit 0 toggled on (), then the overall numeric value of the binary sequence increases by only - that is, .
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 is not a power of - 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 , 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:
| Hex | Decimal | Binary (4 Bits) | Hex | Decimal | Binary (4 Bits) |
|---|---|---|---|---|---|
0 | 0 | 0000 | 8 | 8 | 1000 |
1 | 1 | 0001 | 9 | 9 | 1001 |
2 | 2 | 0010 | A | 10 | 1010 |
3 | 3 | 0011 | B | 11 | 1011 |
4 | 4 | 0100 | C | 12 | 1100 |
5 | 5 | 0101 | D | 13 | 1101 |
6 | 6 | 0110 | E | 14 | 1110 |
7 | 7 | 0111 | F | 15 | 1111 |
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'1010splits into0010(2) and1010(A), which is2Ain hex. - The binary byte
1111'1111splits into1111(F) and1111(F), which isFFin 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 are0(0b0000'0000).0xFF: All 8 bits in the byte are1(0b1111'1111).0x0F: The bottom 4 bits are1(0b0000'1111).0xF0: The top 4 bits are1(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 decimalOctal 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: 2Printing 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: 00101010Modern 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 the0bprefix (#), padded to 10 total characters (0b+ 8 bits).{:#04x}: Formats the value in lowercase hex (x) or uppercase hex (X) with the0xprefix (#), 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: 00101010Summary
In this lesson, we peeled back the abstraction of C++ types to look at how values are represented in memory:
- Byte Addressability: The smallest addressable unit of memory is 1 byte (8 bits). Types like
boolwaste 7 bits of every byte unless we pack data manually. - Fixed-Width Integers: We use unsigned types from
<cstdint>(uint8_t,uint16_t,uint32_t,uint64_t) as predictable, fixed-size containers for bits. - 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). - Inspecting Bits: We can use
std::bitset<N>orstd::format("{:08b}", val)to inspect raw bit patterns without falling into theuint8_tASCII 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.
Bitwise Operators and Flags
Learn to use bitwise operators to pack multiple states into a single byte, reducing memory bandwidth use.