我从事一个中型开源项目,该项目需要将原始字节解释为不同类型。这是通过创造性地使用重新解释转换来实现的。然而,在一个简单的测试用例中,使用 GCC 10 或更高版本编译,优化级别高于 O1,测试失败。较低版本的 GCC 以及 Clang 不会出现此问题。

我们设法编写了一个可重现的小示例,如下所示。它接受一个uint64_t并返回一个指向包含uint32_t来自的 数据的 数组的指针uint64_t。预期结果是bar()返回数字 1 作为uint32_t

#include <cstddef>
#include <cstdint>

struct RegisterValue {
    size_t bytes = 8;
    alignas(8) char localValue[16] = {42};
    void set(uint64_t value) {
        uint64_t* view = reinterpret_cast<uint64_t*>(this->localValue);
        view[0] = value;
    }
    uint32_t* getAsVector() {
        if (bytes <= 16) {
            return reinterpret_cast<uint32_t*>(this->localValue);
        } else {
            static uint32_t t = {0xdeadbeef};
            return &t;
        }
    }
};
extern "C" uint32_t bar() {
    RegisterValue v;
    v.set(0x0000000200000001);
    return v.getAsVector()[0];
}

试用该代码

reinterpret_cast本质上, GCC 正在积极优化所有ing,导致汇编只返回立即数。这是预期的。但是,在较低的优化级别 (O1) 下,这会返回预期结果 (1),但在较高的级别 (O2、O3) 下,这会错误地返回 42。更令人困惑的是,几乎对源代码的任何更改(例如删除if/else保留第一个返回getAsVector())都会导致预期的行为。

这听起来像是 UB,但我们已经阅读了并且不能自信地说这是否是 UB。如果是,那么我们需要更新我们的实现,如果不是,则表明存在编译器错误。

任何见解都会有帮助。

7

  • 1
    “…仅返回立即数”这是什么意思?立即数?


    – 

  • 1
    我不是 C++ 专家,但在 C 中,这显然违反了严格的别名规定。我认为 C++ 继承了这些规则,因为它们与可能的编译器优化相关。


    – 

  • 2
    @463035818_is_not_an_ai


    – 


  • 2
    将字节输入和输出整数的标准方法是使用memcpy。编译器知道这一点并会对其进行优化。请参阅


    – 


  • 1
    @463035818_is_not_an_ai 周伟军的回答是正确的。你可以通过以下链接了解我的意思


    – 


最佳答案
2

是的,你正在做的是未定义行为,类型别名冲突,因为你localValue通过访问你的uint64_t*。 根据uint64_t*可以通过 进行类型访问char*,但反之亦然。 第一部分可以用 修复std::memcpy

void set(uint64_t value) {
    std::memcpy(localValue, &value, sizeof(value));
}

但我没有任何直接的想法,uint32_t* getAsVector()无需引入额外的类字段来保存结果。您可以返回const char*并让用户处理uint32转换,也可以返回一个std::vector<std::uint32_t>

1

  • 1
    谢谢。这是我们的解决方案之一,但我们得出了相同的结论,即 getAsVector 需要更多字段或需要以不同的方式返回数据。这不是世界末日,但需要在整个代码库中进行大量更新,并且与类的交互会稍微更烦人一些。


    – 

TL;DR:答案的第一部分包含由于 32 位和 64 位之间的切换而导致的问题(感谢 @PasserBy 的宝贵意见)。最后提供了更新的代码片段。

它正位于 UB 的边缘。

正确的做法是

alignas(std::uint64_t) alignas(std::uint32_t) char localValue[2*sizeof(std::uint64_t)];

(如果我理解你的意图的话)。

然后,在 setter 中:

std::uint64_t* view = std::launder(reinterpret_cast<uint64_t*>(this->localValue));

假设std::uint64_t确实实现为标量类型(应该是并且可以使用检查)。

为什么会这样:标量类型是隐式生命周期类型,这意味着,基本上,它们是通过(例如创建字节数组)隐式创建的(开始它们的生命周期)。

此外,如果我写入它,它将
std::uint64_t与它的存储一样长()。

std::launder必要向编译器明确地表明std::uint64 _t在给定的地址处有效存在。

另外,在 getter 中:

std::uint32_t array[4] = std::launder(reinterpret_cast<uint32_t*>(this->localValue)); // actual size depends on the need but must match storage size
return array;

应用与上述相同的参数(数组也是隐式生命周期类型)。

您的代码的另一个问题是,当您获取缓冲区时,如果之前std::uint32 _t没有使用过,则可能会有一个不确定的值set,因为没有任何内容可以保证值表示会被保留。

如果您希望42将其用作默认初始化值,则可能需要std::start_lifetime_as(c++23) 来保留初始化中的值表示。在这种情况下,它会起作用,因为整数的值表示在 c++ 中定义得很好。对于其他类型来说,情况就不那么简单了。

这也验证了 32 位数组和单个 64 位之间的切换。

TWIST:至今std::start_lifetime_as仍不受支持: https:

作为一种解决方法,你可以用 constexpr 静态版本来初始化它set。也许 lambda 也可以。


#include <array>
#include <cstddef>
#include <cstdint>
#include <new>

struct S {
    alignas(std::uint64_t)
        std::array<std::byte, sizeof(std::uint64_t)> localValue = []() {
            std::array<std::byte, sizeof(std::uint64_t)> init;
            std::uint32_t* val =
                std::launder(reinterpret_cast<std::uint32_t*>(init.data()));
            val[0] = 0;
            val[1] = 42;
            return init;
        }();

    void set(uint64_t value) {
        std::uint32_t* view =
            std::launder(reinterpret_cast<std::uint32_t*>(localValue.data()));
        // not proud of this but well defined IMHO :)
        view[0] = static_cast<std::uint32_t>(value >> sizeof(std::uint32_t));
        view[1] = static_cast<std::uint32_t>((value << sizeof(std::uint32_t)) >>
                                             sizeof(std::uint32_t));
    }
    uint32_t* getAsVector() {
        return std::launder(
            reinterpret_cast<std::uint32_t*>(localValue.data()));
    }
};

在此版本中,保证 32 位整数数组存在于每个路径上。仅设置 32 位值表示。

11

  • @DanWeaver 我忽略了你问题中的一个细节:事实上,你不想要一个 64 位整数,而是想要 2 个 32 位整数?为什么 setter 是 64 位?我的答案必须相应地更新,但同样的推理也适用。


    – 

  • @PasserBy 已修复,谢谢。


    – 

  • 抱歉,我的错误,出于某种原因,它只接受参数包,但不接受多个参数。alignas(std::uint64_t) alignas(std::uint32_t) char ...不过你可以写。


    – 


  • @PasserBy 我看了并做了相应更新,我也误读了文档;)


    – 


  • 如果没有std::start_lifetime_as,代码将是 UB。C++20 隐式生命周期在创建新对象时仍然不会保留值,这意味着尝试读取std::uint64_tasstd::uint32_t将导致不确定的值。


    –