Create your own
Lesson illustration

Fixed-Width Integer Encoding and Overflow in C++

Hello again. In the previous lesson, you used RAII and std::unique_ptr to make ownership and cleanup reliable across normal returns and exceptions. This lesson turns to a different kind of reliability boundary: the finite set of integer values a machine can represent.

You will learn to read and encode fixed-width bit patterns as unsigned and two’s-complement signed integers, then connect those representations to C++ rules. The crucial distinction is that unsigned overflow has a specified modular result, while signed overflow is undefined behavior—even on hardware where the bits appear to “wrap.”


One bit pattern, different numerical meanings

A computer stores bits; a type supplies an interpretation. Consider the 8-bit pattern:

1110 1101

It can mean different values:

  • As an unsigned 8-bit integer, it is .
  • As a two’s-complement signed 8-bit integer, it is .
  • In hexadecimal, it is ED.

The bits have not changed. Only the interpretation has.

For an -bit unsigned integer with bits , the value is:

Each bit position has a nonnegative weight. For 8 bits, those weights are:

bit:     7    6   5   4   3   2   1   0
weight: 128  64  32  16   8   4   2   1

So:

0010 1101 = 32 + 8 + 4 + 1 = 45
1110 1101 = 128 + 64 + 32 + 8 + 4 + 1 = 237

An unsigned -bit type has exactly possible patterns and values in this range:

For 8 bits, that is through .

Watch this short portion of Computer Science: The Magic of Two's Complement Binary Math from C++ Weekly With Jason Turner. It establishes the four-bit version first, which is much easier to reason about than jumping directly to 32-bit machine integers.

C++ Weekly - Ep 243 - Computer Science: The Magic of Two's Complement Binary Math

C++ Weekly’s visual walkthrough builds unsigned binary, shows why a simple sign bit is awkward, and then derives two’s complement through ordinary binary addition.

Watch the encodings, especially the transition from unsigned values to sign-magnitude and two’s complement. Then watch binary addition to see why the same addition circuitry can support both signed and unsigned interpretations.

Three 8-bit tables compare the same binary patterns interpreted as unsigned values, sign-magnitude values, and two’s-complement signed values. In particular, `1000 0000` is unsigned 128 but signed -128 under two’s complement.

Why not use a separate sign bit?

A tempting design is sign-magnitude:

  • The highest bit says positive or negative.
  • The remaining bits give the magnitude.

For 8 bits, 0000 0000 would mean , but 1000 0000 would mean . Two representations for zero waste a pattern and complicate arithmetic.

Modern systems instead use two’s complement for signed integers. In C++20’s integer model, standard signed integer types use two’s-complement representation. This gives one zero and lets the processor use ordinary binary addition for signed and unsigned values alike.


Reading two’s-complement values

For an -bit two’s-complement value, the highest bit has a negative weight:

For an 8-bit signed value, the leftmost bit weighs , not . The range is therefore:

For 8 bits:

Here are several useful patterns to memorize:

BitsUnsigned interpretationSigned two’s-complement interpretation
0000 000000
0000 000111
0111 1111127127
1000 0000128-128
1111 1111255-1
1110 1101237-19

For example, decode 1110 1101 as signed:

There is also a faster practical method for negative values:

  1. Notice that the top bit is 1, so the value is negative.
  2. Flip every bit.
  3. Add one.
  4. Read the resulting positive magnitude, then apply a minus sign.

For 1110 1101:

original:  1110 1101
invert:    0001 0010
add one:   0001 0011

0001 0011 is , so the original pattern represents .

This is why negation of a two’s-complement bit pattern is often described as “invert the bits and add one.” At a machine level, it is a very convenient identity:

There is one asymmetric edge case: the most negative value has no positive counterpart in the same signed type. In 8 bits, is representable, but is not. That asymmetry is the source of several boundary bugs.

Hexadecimal is useful because one hex digit represents exactly four bits:

1110 = E
1101 = D

Thus 1110 1101 becomes 0xED. When inspecting memory, registers, packet bytes, or debugger output, hex is usually more compact than binary while preserving the bit structure.


Fixed width and the hardware’s arithmetic

A fixed-width integer has room for only a fixed number of bit patterns. If an 8-bit addition produces a ninth carry bit, that carry cannot be stored in the 8-bit result.

For example:

  1111 1111   255
+ 0000 0001     1
------------
1 0000 0000

The lower eight bits are 0000 0000. Hardware commonly retains only those bits when operating at that width.

Mathematically, this is arithmetic modulo . For an 8-bit unsigned value:

Likewise:

This is not an accident or an error for unsigned types. It is their specified model.

The same bit-level addition can have a very different signed interpretation. In four bits:

0111 + 0001 = 1000

As unsigned values, this is , which is representable.

As signed two’s-complement values, it appears to be . But the mathematically correct signed result, , is outside the signed four-bit range of through . The bit pattern exists, but it does not make the signed operation valid in C++.

That distinction between stored bits, hardware behavior, and language semantics is essential.


C++ makes unsigned and signed overflow different contracts

C++ specifies unsigned arithmetic modulo , where is the number of value bits in the unsigned type. For example, if std::uint32_t exists, it has exactly 32 value bits:

#include <cstdint>
#include <iostream>
#include <limits>

int main() {
    std::uint32_t value =
        std::numeric_limits<std::uint32_t>::max();

    std::cout << value << '\n';  // 4294967295

    ++value;

    std::cout << value << '\n';  // 0
}

The increment is defined. Its result is:

This is called unsigned wraparound. It can be useful when modular arithmetic is genuinely the intended model: bit masks, hashes, some protocol sequence numbers, and low-level binary algorithms all use it deliberately.

Now compare signed arithmetic:

#include <iostream>
#include <limits>

int main() {
    int value = std::numeric_limits<int>::max();

    ++value;  // undefined behavior

    std::cout << value << '\n';
}

The increment asks C++ to represent one more than the largest int. That result does not fit. C++ calls this undefined behavior.

Undefined behavior does not mean “C++ guarantees two’s-complement wraparound.” Your particular CPU may produce the low-order bit pattern corresponding to a negative value, especially in an unoptimized debug build. But once signed overflow occurs, the C++ standard places no requirements on the program’s behavior.

Read the concise reference section below before running the lab. It states the language-level rule directly.

Arithmetic operators - cppreference.com

Read cppreference’s “Arithmetic operators” reference, focusing on its distinction between the defined modular model for unsigned types and undefined behavior for signed overflow.

In the “General explanation” section, find the “Overflows” subsection. Read the overflow rules, beginning with the paragraph on unsigned arithmetic and continuing through the list of possible manifestations of signed overflow. Focus on the word “undefined”: it applies to the signed operation, not merely to its final numerical result.

A compiler can exploit this rule. Consider:

bool strictly_increases(int x) {
    return x + 1 > x;
}

For every execution that has defined behavior, x + 1 must be representable. Therefore, it must be greater than x. An optimizing compiler may legally treat this function as always returning true.

The special input INT_MAX does not force the function to return false; evaluating x + 1 for that input is already undefined behavior. This is why checking for signed overflow after performing the overflowing operation is not a valid strategy.

Watch the selected excerpts from Undefined Behavior in C++: What Every Programmer Should Know and Fear by Fedor Pikus at CppCon. The first explains the optimization consequence; the second connects it to UBSan.

Undefined Behavior in C++: What Every Programmer Should Know and Fear - Fedor Pikus - CppCon 2023

Fedor Pikus demonstrates that compilers may assume signed overflow never occurs, then contrasts the resulting optimization with the explicitly defined unsigned case.

Watch the comparison for the signed x + 1 > x case and the different unsigned result. Later, watch the UBSan segment for the practical limitation of runtime undefined-behavior detection: it catches only paths your tests actually execute.


WSL lab: observe defined wrapping and diagnose signed overflow

Create unsigned_wrap.cpp:

#include <cstdint>
#include <iostream>
#include <limits>

int main() {
    std::uint32_t value =
        std::numeric_limits<std::uint32_t>::max();

    std::cout << "before: " << value << '\n';

    ++value;  // defined: arithmetic modulo 2^32

    std::cout << "after:  " << value << '\n';
}

Compile with warnings and run it:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O0 \
    unsigned_wrap.cpp -o unsigned_wrap

./unsigned_wrap

You should see the maximum 32-bit unsigned value followed by 0. This is expected, portable behavior for this type.

Now create signed_overflow.cpp:

#include <iostream>
#include <limits>

int main() {
    int value = std::numeric_limits<int>::max();

    std::cout << "before: " << value << '\n';

    ++value;  // undefined behavior

    std::cout << "after:  " << value << '\n';
}

Compile it with UndefinedBehaviorSanitizer:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O1 \
    -fsanitize=undefined -fno-sanitize-recover=undefined \
    signed_overflow.cpp -o signed_overflow

./signed_overflow

UBSan should report a signed integer overflow at the increment. The precise wording varies by compiler version, but it will identify that the maximum int value cannot be incremented representably.

A sanitizer finding is useful evidence, but its absence is not a proof of safety. UBSan instruments executed code paths. If a boundary input is never tested, an overflow in that path remains undiscovered.

A small but important uint8_t caveat

Do not assume every expression involving an 8-bit variable is evaluated in 8 bits:

#include <cstdint>

std::uint8_t a = 250;
std::uint8_t b = 10;

auto sum = a + b;  // typically an int with value 260

C++ applies integral promotions before many arithmetic operations. On your WSL compiler, a and b promote to int, so sum is 260, not 4.

However:

a += b;

stores the result back into an unsigned 8-bit object. The conversion to std::uint8_t is defined modulo , so a becomes 4.

This is a subtle but practical lesson: distinguish the width of the storage object from the type in which an expression is evaluated. For testing actual 32-bit unsigned wraparound, std::uint32_t is clearer than std::uint8_t.


Design rules for reliable integer code

The correct choice depends on what the number means, not simply on which type has a convenient range.

When wrapping is intended

Use an unsigned type only when the modular behavior is part of the design. Document the modulus mentally or in code comments. A 32-bit unsigned value naturally lives in the ring of values modulo .

For example, this can be appropriate when intentionally manipulating bit patterns:

std::uint32_t flags = 0;
flags |= 0b0000'0100;

It can also be appropriate for a bounded sequence number if the protocol explicitly defines how values wrap.

When wrapping is not intended

For quantities such as balances, counts subject to limits, array sizes after arithmetic, timestamps, or user-provided values, handle the range before calculating a result that might overflow.

A safe increment API can make the boundary explicit:

#include <cstdint>
#include <limits>
#include <optional>

std::optional<std::int32_t> increment_if_possible(
    std::int32_t value) {

    if (value == std::numeric_limits<std::int32_t>::max()) {
        return std::nullopt;
    }

    return value + 1;
}

The addition occurs only after the upper-bound case has been excluded.

A common alternative for operations on 32-bit signed values is to calculate in a wider type, validate the result, then narrow only if it fits. That approach is useful only when you can establish that the wider intermediate type itself cannot overflow.

Do not confuse conversions with signed overflow

These cases are different:

std::uint32_t bits = static_cast<std::uint32_t>(-1);

Converting a signed value to an unsigned type is defined modulo the destination’s range. For 32 bits, bits becomes the all-ones pattern.

By contrast:

int x = std::numeric_limits<int>::max();
x + 1;  // signed arithmetic overflow: undefined behavior

The first is a defined conversion. The second is an overflowing signed arithmetic operation.

A concise rule to retain is:

Unsigned integers wrap by specification. Signed integers must stay in range.


Takeaways

  • An -bit unsigned integer represents values from to .
  • Two’s-complement signed integers represent values from to .
  • The same bit pattern can represent different values depending on whether it is interpreted as signed or unsigned.
  • To negate a two’s-complement bit pattern, invert its bits and add one; the minimum signed value is the important asymmetric edge case.
  • Unsigned arithmetic is defined modulo , so wraparound is predictable.
  • Signed overflow is undefined behavior in C++, even if a particular machine appears to wrap its bits.
  • Narrow integer types such as std::uint8_t are often promoted before arithmetic; storage width and expression type are not always the same.
  • Use UBSan to expose executed overflow paths, but prevent overflow through range checks and deliberate type choices.

Next, you will connect these abstract bit patterns to real execution by examining a short compiler-generated assembly listing and mapping its instructions back to the C++ operations that produced them.

Can't find a good explanation? Sign up and we'll make it for you

Sign up