我从事一个中型开源项目,该项目需要将原始字节解释为不同类型。这是通过创造性地使用重新解释转换来实现的。然而,在一个简单的测试用例中,使用 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
最佳答案
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_t
asstd::uint32_t
将导致不确定的值。
–
|
–
–
–
memcpy
。编译器知道这一点并会对其进行优化。请参阅–
–
|