· 6 min read ·

reinterpret_cast Is a Lie (About Object Lifetime)

Source: isocpp

There is a cast in C++ that nearly every systems programmer has reached for at least once, convinced it was the right tool. You have a std::byte* pointing at a buffer loaded from a network packet, a hardware register, or a binary file. You want to treat that memory as a MyStruct. So you write:

std::byte* raw = load_packet();
MyStruct* s = reinterpret_cast<MyStruct*>(raw);
std::cout << s->field << '\n';

This is undefined behavior. The cast succeeds, the pointer arithmetic is valid, and on most platforms with most compilers you will get the expected integer out the other side. But the C++ abstract machine has no MyStruct object at that address. You accessed an object that, as far as the standard is concerned, does not exist.

Andreas Fertig covers this trap in a recent isocpp.org post, noting that even developers from embedded backgrounds, where casting byte buffers is routine practice, are often surprised to learn that reinterpret_cast does not start an object’s lifetime. It changes what type the pointer claims to point at. Nothing more.

What Object Lifetime Actually Means

The C++ standard defines objects as having a lifetime, a period during which they can be legitimately accessed. Lifetime begins when storage is obtained and initialization completes. Lifetime ends when the destructor runs or the storage is released. Accessing an object outside its lifetime is undefined behavior, regardless of what the underlying bytes look like.

reinterpret_cast is a compile-time pointer transformation. It produces a pointer of the target type, but it does not create any object at the destination. From cppreference on reinterpret_cast:

Converts between types by reinterpreting the underlying bit pattern.

The bit pattern in the pointer value, yes. Not the storage that the pointer addresses. The cast creates no object and performs no initialization.

The Strict Aliasing Connection

This is where the compiler actually has teeth. The strict aliasing rule says you may only access an object through a glvalue whose type matches the object’s dynamic type, or through char, unsigned char, or std::byte. Cppreference’s strict aliasing section lists the full set of exceptions.

Why does the compiler care? Because it uses aliasing rules to reason about memory independently of control flow. Consider:

void f(int* p, float* q) {
    *p = 1;
    *q = 1.0f;
    *p = 2;
}

Since int* and float* cannot alias under strict aliasing, the compiler is permitted to assume that writing through q cannot affect *p. It may eliminate the first store to *p entirely, or reorder operations around the write to q. If you break strict aliasing with a reinterpret_cast, those transformations produce wrong results, and the standard considers the program undefined at that point, not the compiler incorrect.

GCC and Clang both exploit this aggressively at -O2 and above. MSVC has historically been more lenient, which is part of why the bug hides in practice. As Microsoft’s C++ team documented in late 2024, even MSVC does not guarantee defined behavior for aliasing violations; it simply optimizes less aggressively in some cases, which means the undefined behavior happens not to be observable on that toolchain.

The Classic Float Bits Example

Type punning to inspect the IEEE 754 bit representation of a float is the most reproduced example of this mistake:

float f = 1.0f;
uint32_t bits = *reinterpret_cast<uint32_t*>(&f); // UB

The traditional fix was memcpy:

float f = 1.0f;
uint32_t bits;
memcpy(&bits, &f, sizeof(bits)); // defined behavior

memcpy through unsigned char is explicitly permitted to copy object representations. Any competent compiler eliminates the call at optimization levels above -O0.

C++20 gave us std::bit_cast, which wraps the same semantics in a type-safe interface:

float f = 1.0f;
auto bits = std::bit_cast<uint32_t>(f); // defined, C++20

std::bit_cast requires both types to have the same size and be trivially copyable. It works for value punning, where you have an existing object and want to read its bits as a different type.

The Problem std::bit_cast Does Not Solve

Embedded work often involves a different scenario. You have raw storage, perhaps from malloc, mmap, a DMA buffer, or a memory-mapped peripheral register block. The bytes are already correct. You need to access them as a structured type without overwriting them.

memcpy would copy the bytes into a new object, which requires allocating separate storage. placement new would construct an object in-place, but construction may overwrite the bytes you care about. For zero-initialized types it definitely will.

C++20 addressed part of this with P0593R6 (Implicit Object Creation), which made certain operations like malloc, operator new, and memcpy implicitly start object lifetimes for implicit-lifetime types. This made a lot of existing allocator and serialization code retroactively well-defined. But it did not cover arbitrary byte buffers you receive through your own mechanisms, such as a pointer to a mapped file or a hardware ring buffer.

C++23 added std::start_lifetime_as to handle exactly that:

#include <memory>

struct PacketHeader {
    uint16_t length;
    uint16_t checksum;
    uint32_t sequence;
};

void process(std::byte* raw) {
    PacketHeader* hdr = std::start_lifetime_as<PacketHeader>(raw);
    // hdr now points to a legitimately existing PacketHeader object.
    // The bytes in raw are preserved unchanged.
    handle_packet(hdr->sequence, hdr->length);
}

The contrast with placement new is the point:

// placement new: initializes the object, overwrites existing bytes
new (raw) PacketHeader{}; // your packet bytes are gone

// start_lifetime_as: starts lifetime without initializing, bytes intact
PacketHeader* hdr = std::start_lifetime_as<PacketHeader>(raw);

As the Stack Overflow answer on the topic puts it: you want to take raw bytes you received from an external source and read them as if they were a T. That is fundamentally different from replacing those bytes with a properly initialized T. start_lifetime_as does the former; placement new does the latter.

The type T must be an implicit-lifetime type: a scalar, an array, an aggregate with no user-provided constructors, or a class type with a trivial destructor. This covers the vast majority of structures encountered in network protocol parsing, hardware register access, or binary deserialization.

Why This Matters in Embedded Contexts

Memory-mapped I/O is the canonical use case. A peripheral lives at a fixed hardware address defined in a datasheet. The natural C++ representation is a struct:

struct UartRegisters {
    volatile uint32_t data;
    volatile uint32_t status;
    volatile uint32_t control;
};

// Common approach, technically UB once you dereference:
auto* uart = reinterpret_cast<UartRegisters*>(0x40011000UL);

// The integer-to-pointer conversion is implementation-defined,
// but start_lifetime_as makes the subsequent accesses well-defined:
auto* raw = reinterpret_cast<std::byte*>(static_cast<uintptr_t>(0x40011000UL));
auto* uart = std::start_lifetime_as<UartRegisters>(raw);

For hardware addresses, the integer-to-pointer conversion is already implementation-defined rather than undefined, so embedded compiler vendors targeting specific architectures have always given this a pragmatic pass. But for software buffers, where the standard does not extend the same latitude, start_lifetime_as is the correct tool.

Fertig’s observation in his post is that students come in already knowing reinterpret_cast and ask why they need start_lifetime_as when they can just cast. The answer is that the cast changes the pointer type. The object at the destination address is a separate question entirely, and the cast does not answer it.

Practical Decision Tree

Given the full landscape, the choice between these tools follows a fairly direct pattern:

  • You have two values of different types and want to reinterpret bits: use std::bit_cast (C++20).
  • You have a byte buffer and want to construct a new object with specific values: use placement new or std::construct_at.
  • You have a byte buffer already containing the right bytes and want to access it as a typed object: use std::start_lifetime_as (C++23).
  • You need to copy an object representation into new storage: use memcpy, which C++20 implicit object creation already covers for implicit-lifetime types.
  • You are on C++17 or earlier and cannot use any of the above: memcpy into a properly allocated object is the universally safe fallback.

reinterpret_cast remains valid for converting between pointer types when the aliasing rules permit it, and for converting between pointers and integers on implementations where that is defined. What it does not do is create an object. It never did.

The C++ object model is stricter than the hardware model. Hardware does not track lifetimes. The C++ abstract machine does, and optimizing compilers reason about it continuously. The distance between “this works at -O0 on my machine” and “this is defined behavior” is exactly the space that start_lifetime_as was designed to close.

Was this interesting?