Step 02 · C++ essentials · B2

Bytes, bits and bounded reads

Decode a four-byte value without reading past the buffer or confusing a value with an address.

Before this page: Your first C++ program. View the learning route.

Learn, run, explain

Before you start

Complete your first C++ program. Work through the small predictions below before reading the complete byte reader. Each code fragment illustrates one part of that program; the complete source and build commands follow at the end.

  1. Read an array by index and trace a counted loop.
  2. Combine two bytes, then extend the calculation to four.
  3. Reject an invalid range.
  4. Read the view and result types used by the complete function.

1. Read positions and repeat a calculation

An array's first element has index 0. bytes[1] selects its second element; bytes.size() gives its element count. For five elements the valid indices are 0, 1, 2, 3 and 4. Index 5 is already outside the array.

The complete reader uses this counted-loop fragment:

for (std::size_t i = 0; i < 4; ++i) {
    // Use bytes[offset + i] here, after checking the range.
}

Initialize i to 0 once; check i < 4 before each iteration; execute the body; then ++i adds one. std::size_t is the unsigned integer type used for sizes and indices. Its width depends on the platform.

Predict: with offset = 1, list every index selected by this loop. Is i == 4 ever used inside the body?

Check the loop trace

The indices are 1, 2, 3 and 4. When i becomes 4, the condition is false, so the body does not execute again.

2. The same value in three notations

A bit is 0 or 1; an eight-bit byte represents values 0 through 255. Decimal uses ten digits, binary two, hexadecimal sixteen. 15, 0b1111 and 0x0f all represent the same value. The hexadecimal digits after 9 are a, b, c, d, e and f. Each digit represents four bits, so two hexadecimal digits represent a byte.

std::uint32_t is an unsigned 32-bit integer on the platforms used here. The u suffix in 0x12u makes a literal unsigned. Here we combine small nonnegative values using shifts of 0, 8, 16 and 24, all below the width of a 32-bit value.

Combine just two bytes first

0x12u << 8 shifts the value left eight bits, producing 0x1200. Bitwise OR, |, sets a result bit when either input has that bit set. In this example the two byte fields do not overlap:

0x34u | (0x12u << 8);  // 0x1234
(0x1234u >> 8) & 0xffu;  // 0x12

The second expression shifts right to bring the high byte down, then & 0xffu keeps only its lowest eight bits. & is bitwise AND here: a bit survives only where both operands have a 1. value |= part means value = value | part.

Predict: combine low byte 0xabu with high byte 0xcdu. What does masking the combined value with 0xffu keep?

Check the byte calculation

0xabu | (0xcdu << 8) gives 0xcdab. Masking with 0xffu keeps 0xab. Decimal addition is not the definition of bitwise OR; it gives the same result here because the fields do not overlap.

3. Bytes have an order

Extend the same calculation to four bytes. The byte and contribution columns below use hexadecimal notation.

OffsetByteContribution
0780x00000078
1560x00005600
2340x00340000
3120x12000000

The bytes 78 56 34 12 encode 0x12345678 in little-endian order: the least significant byte comes first. Explicit shifts decode that order regardless of the host's byte order. An offset is a position within this buffer, not an absolute memory address.

Predict: after combining the first two bytes, what is the accumulated value? What does the third byte add?

Check the four-byte trace

The first two produce 0x00005678. The third contributes 0x00340000, making 0x00345678. The fourth completes 0x12345678.

4. Check a range before using it

A five-byte buffer has valid indices [0, 5): include zero, exclude five. A four-byte read starting at 1 is valid; starting at 2 is truncated. Unsigned addition can wrap, so a huge offset + 4 might become small. First reject an offset larger than the size, then check how many bytes remain.

offset > bytes.size() || bytes.size() - offset < 4

This expression is true when the read must fail. || is logical OR: it evaluates the right side only when the left side is false. Thus the subtraction happens only after establishing offset <= bytes.size(). This is different from bitwise |, which combines bits and does not provide this short-circuit guard.

Predict: for size 5, classify starts 0, 1, 2, 5 and 6. For which start is the subtraction skipped?

Check the boundary cases

0 and 1 leave at least four bytes. Starts 2 and 5 leave three and zero bytes, so they fail. Start 6 fails the first comparison and skips subtraction. An empty buffer also fails because it has no four-byte range.

5. Read the input and the result separately

std::span<const std::uint8_t> supplies a view of bytes and their count. It borrows existing storage. In this program the array stays alive throughout the function call, and the reader does not save the view. The ownership lesson explains what would invalidate it.

std::optional<std::uint32_t> carries either a decoded integer or no result. std::nullopt means no result. Test the optional before accessing its value with *value; a decoded zero is still a present value. The test does not ask whether the integer itself is nonzero.

const auto value = read_u32(bytes, 0);
if (!value || *value != 0x12345678u) return 1;

auto lets the compiler deduce the variable's type from the initializer; here it is an optional integer. ! negates a condition. The first part detects an absent result; short-circuit OR then prevents reading that absent value. In the decoding loop, std::uint32_t{bytes[offset + i]} converts the selected byte to the wider integer before shifting it.

Predict: should four zero bytes produce an absent optional or a present value of zero?

Check the result contract

A present value of zero. Missing bytes cause failure; a particular decoded number does not. The complete program's comparison with 0x12345678u is a test for its chosen input, not a rule of the reader.

Put the steps together

Trace the complete function in this order: validate the range, initialize the result, select each byte, widen and shift it, combine its bits, return a present result. std::hex formats subsequent integer output as hexadecimal. std::numeric_limits<std::size_t>::max() supplies the largest offset value for a failure check; {} supplies an empty view.

#include <array>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <limits>
#include <optional>
#include <span>

// Teaching format: four little-endian bytes, not an ELF or PM4 parser.
std::optional<std::uint32_t> read_u32(std::span<const std::uint8_t> bytes,
                                     std::size_t offset) {
    if (offset > bytes.size() || bytes.size() - offset < 4) {
        return std::nullopt;
    }
    std::uint32_t value = 0;
    for (std::size_t i = 0; i < 4; ++i) {
        value |= std::uint32_t{bytes[offset + i]} << (8 * i);
    }
    return value;
}

int main() {
    const std::array<std::uint8_t, 5> bytes{0x78, 0x56, 0x34, 0x12, 0xff};
    const auto value = read_u32(bytes, 0);
    if (!value || *value != 0x12345678u) return 1;
    std::cout << "word=0x" << std::hex << *value << '\n';
    std::cout << "field=0x" << ((*value >> 8) & 0xffu) << '\n';
    const auto last = read_u32(bytes, 1);
    if (!last || *last != 0xff123456u) return 1;
    if (read_u32(bytes, 2) || read_u32(bytes, bytes.size()) ||
        read_u32(bytes, std::numeric_limits<std::size_t>::max()) ||
        read_u32({}, 0)) return 1;
    std::cout << "last valid offset=1\n";
    std::cout << "truncated, end, huge and empty reads rejected\n";
}

Use the B1 setup once. From this learning repository's root:

cmake --build _Build/curriculum/beginner --target bytes
ctest --test-dir _Build/curriculum/beginner -R '^beginner_bytes$' --output-on-failure

To see the output directly in PowerShell, run .\_Build\curriculum\beginner\bytes.exe; in a POSIX shell, run ./_Build/curriculum/beginner/bytes. Tests compare the full output and require a successful exit.

Expected output, with exit status 0:

word=0x12345678
field=0x56
last valid offset=1
truncated, end, huge and empty reads rejected

Exercise

Predict the result at offset 1. Then change the first byte from 0x78 to 0x79. Which output and checks should change? Explain why offset 1 is unaffected. Finally design a two-byte reader on paper: specify its return type, length check and shifts before coding it.

Hint

At offset 1 the selected bytes are 56 34 12 ff. The first byte of the entire buffer is not part of that range.

Worked answer and rubric

Offset 1 produces 0xff123456. Changing byte 0 makes the first word 0x12345679; its field at bits 8–15 remains 0x56. A two-byte reader checks remaining size against 2 and combines shifts of 0 and 8. Test empty input, exactly two bytes, a last-valid offset, a truncated range and a huge offset. You are ready when you can justify the guard before discussing the decoding loop.

From teaching bytes to project formats

This is an invented four-byte field, not a PM4 packet or ELF file. Later compare the range checks with src/loader/elf.cpp and the bit fields in src/graphics/guest_gpu/command_processor/pm4Handlers.cpp. A real format also needs its own size, alignment, endianness and semantic rules. Casting a buffer to a struct does not establish those rules.

Before moving on

Ticks record your own assessment. Keep your prediction, actual output and explanation; a tick alone is not evidence of understanding.