Violating strict aliasing with reinterpret_cast in C++

The first simple example:

#include <bit>
#include <cstdint>
#include <iomanip>
#include <iostream>

std::uint32_t badBits(const float value)
{
  // const prevents writes, but reading a float through a uint32_t pointer
  // still violates the strict-aliasing rule.
  const auto* bits = reinterpret_cast<const std::uint32_t*>(&value);
  return *bits; // Undefined behavior.
}

std::uint32_t goodBits(const float value)
{
  // std::bit_cast copies the object representation without violating
  // strict-aliasing or object-lifetime rules.
  return std::bit_cast<std::uint32_t>(value);
}

int main()
{
  constexpr float value = 1.0F;

  std::cout << std::hex
      << "bad:  0x" << badBits(value) << '\n'
      << "good: 0x" << goodBits(value) << '\n';
}

When I compile this with

g++ -std=c++23 -O2 -g \
    -Wall -Wextra \
    -Wstrict-aliasing=2 \
    -fsanitize=undefined \
    -fno-sanitize-recover=undefined \
    -fno-omit-frame-pointer \
    a.cpp -o a

I get the following compiler warning:

a.cpp: In function uint32_t badBits(float):
a.cpp:10:67: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
   10 |         const auto* bits = reinterpret_cast<const std::uint32_t*>(&value);

but UB sanitizer does not detect this strict-aliasing violation.

The next example also violates strict aliasing, but it does not generate compiler warnings:

std::uint32_t badBits2(const float value)
{
  // Reading an object's representation through std::byte is allowed.
  const auto* bytes = reinterpret_cast<const std::byte*>(&value);

  // Casting the byte pointer to uint32_t does not create a uint32_t object.
  // Dereferencing the resulting pointer still violates strict-aliasing and
  // object-lifetime rules.
  const auto* bits = reinterpret_cast<const std::uint32_t*>(bytes);
  return *bits; // Undefined behavior.
}

In GCC 16 we’ll use std::start_lifetime_as:

std::uint32_t lifetimeBits(float value)
{
    static_assert(sizeof(float) == sizeof(std::uint32_t));
    static_assert(alignof(float) >= alignof(std::uint32_t));

    auto* bytes = reinterpret_cast<std::byte*>(&value);

    // Ends the lifetime of float and starts the lifetime of uint32_t
    // in the same storage while preserving its object representation.
    const auto* bits =
        std::start_lifetime_as<std::uint32_t>(bytes);

    return *bits;
}

this example requires the source and destination types to have the same size, but different sizes are also possible:

#include <cstdint>
#include <iomanip>
#include <iostream>
#include <memory>

std::uint32_t lifetimeBits(double value) noexcept
{
  static_assert(sizeof(double) >= sizeof(std::uint32_t));
  static_assert(alignof(double) >= alignof(std::uint32_t));

  // Start the lifetime of a uint32_t object in the first four bytes of the
  // storage previously occupied by value. The double object's lifetime ends.
  const auto* bits =
   std::start_lifetime_as<std::uint32_t>(
    static_cast<void*>(std::addressof(value)));

  // The result contains the first four bytes in memory, so it depends on the
  // platform's byte order. This is not a numeric double-to-uint32 conversion.
  return *bits;
}

int main()
{
  constexpr double value = 1.0;

  // On a little-endian system, the first four bytes of double{1.0} are zero.
  std::cout << "lifetime: 0x"
      << std::hex
      << lifetimeBits(value)
      << '\n';
}

GCC 16.2 with the following options:

-std=c++23 -O3 -DNDEBUG -Wall -Wextra -Wstrict-aliasing=2

generates the following code:

        .globl std::ios_base_library_init()
"lifetimeBits(double)":
        movsd   QWORD PTR [rsp-8], xmm0
        lea     rax, [rsp-8]
        mov     eax, DWORD PTR [rax]
        ret
.LC0:
        .string "lifetime: 0x"
"main":
        sub     rsp, 24
        mov     edx, 12
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:"std::cout"
        call    "std::basic_ostream<char, std::char_traits<char>>& std::__ostream_insert<char, std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*, long)"
        mov     rax, QWORD PTR "std::cout"[rip]
        mov     rdx, QWORD PTR [rax-24]
        mov     eax, DWORD PTR "std::cout"[rdx+24]
        and     eax, -75
        or      eax, 8
        mov     DWORD PTR "std::cout"[rdx+24], eax
        mov     rax, QWORD PTR .LC1[rip]
        mov     QWORD PTR [rsp+8], rax
        lea     rax, [rsp+8]
        mov     esi, DWORD PTR [rax]
        mov     edi, OFFSET FLAT:"std::cout"
        call    "std::ostream& std::ostream::_M_insert<unsigned long>(unsigned long)"
        mov     BYTE PTR [rsp+8], 10
        mov     rdx, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rdx-24]
        cmp     QWORD PTR [rax+16+rdx], 0
        je      .L4
        mov     edx, 1
        lea     rsi, [rsp+8]
        mov     rdi, rax
        call    "std::basic_ostream<char, std::char_traits<char>>& std::__ostream_insert<char, std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*, long)"
.L5:
        xor     eax, eax
        add     rsp, 24
        ret
.L4:
        mov     esi, 10
        mov     rdi, rax
        call    "std::ostream::put(char)"
        jmp     .L5
.LC1:
        .long   0
        .long   1072693248

and the following also possible:

#include <cstdint>
#include <memory>

const std::uint32_t* splitDouble(double& value)
{
    static_assert(sizeof(double) >= 2 * sizeof(std::uint32_t));
    static_assert(alignof(double) >= alignof(std::uint32_t));

    return std::start_lifetime_as_array<std::uint32_t>(
        static_cast<void*>(std::addressof(value)),
        2);
}

and even this is also possible:

#include <array>
#include <bit>
#include <cstddef>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <memory>
#include <tuple>

using DoubleParts = std::tuple<std::uint16_t, std::uint16_t, std::uint32_t>;

DoubleParts lifetimeBits(const double value) noexcept
{
  static_assert(
   sizeof(double)
   == sizeof(std::uint16_t) + sizeof(std::uint16_t) + sizeof(std::uint32_t));

  // Copy the object representation into aligned byte storage. The byte array
  // provides storage for the three implicit-lifetime integer objects.
  alignas(std::uint32_t) auto storage =
   std::bit_cast<std::array<std::byte, sizeof(double)>>(value);

  // Start three independent object lifetimes in non-overlapping regions.
  const auto* first =
   std::start_lifetime_as<std::uint16_t>(storage.data());
  const auto* second =
   std::start_lifetime_as<std::uint16_t>(storage.data() + sizeof(std::uint16_t));
  const auto* third =
   std::start_lifetime_as<std::uint32_t>(
    storage.data() + sizeof(std::uint16_t) + sizeof(std::uint16_t));

  // Copy the values into the tuple before the local storage is destroyed.
  return {*first, *second, *third};
}

int main()
{
  constexpr double value = 1.0;
  const auto [first, second, third] = lifetimeBits(value);

  // The values reflect the platform's byte order. They are not numeric
  // conversions from double to integer types.
  std::cout << std::hex
      << "first:  0x" << first << '\n'
      << "second: 0x" << second << '\n'
      << "third:  0x" << third << '\n';
}

Notes

Even if I compile the first example with -fno-strict-aliasing as follows:

g++ -std=c++23 -O2 -g 
    -Wall -Wextra 
    -fno-strict-aliasing 
    -fsanitize=undefined 
    -fno-sanitize-recover=undefined 
    -fno-omit-frame-pointer 
    a.cpp -o a

there is still UB, because the lifetime of const std::uint32_t does not start:

std::uint32_t badBits(const float value)
{
  // const prevents writes, but reading a float through a uint32_t pointer
  // still violates the strict-aliasing rule.
  const auto* bits = reinterpret_cast<const std::uint32_t*>(&value);
  return *bits; // Undefined behavior.
}

Leave a Reply

Your email address will not be published. Required fields are marked *