【问题标题】:Type-pun uint64_t as two uint32_t in C++20在 C++20 中将 uint64_t 键入为两个 uint32_t
【发布时间】:2021-11-10 10:15:30
【问题描述】:

由于严格的别名规则,此代码将uint64_t 读取为两个uint32_t 是UB:

uint64_t v;
uint32_t lower = reinterpret_cast<uint32_t*>(&v)[0];
uint32_t upper = reinterpret_cast<uint32_t*>(&v)[1];

同样,写uint64_t的上下部分的代码也是UB,原因相同:

uint64_t v;
uint32_t* lower = reinterpret_cast<uint32_t*>(&v);
uint32_t* upper = reinterpret_cast<uint32_t*>(&v) + 1;

*lower = 1;
*upper = 1;

如何在现代 C++20 中以安全和干净的方式编写此代码,可能使用std::bit_cast

【问题讨论】:

  • 你想如何处理字节序?

标签: c++ c++20 type-punning


【解决方案1】:

使用std::bit_cast

Try it online!

#include <bit>
#include <array>
#include <cstdint>
#include <iostream>

int main() {
    uint64_t x = 0x12345678'87654321ULL;
    // Convert one u64 -> two u32
    auto v = std::bit_cast<std::array<uint32_t, 2>>(x);
    std::cout << std::hex << v[0] << " " << v[1] << std::endl;
    // Convert two u32 -> one u64
    auto y = std::bit_cast<uint64_t>(v);
    std::cout << std::hex << y << std::endl;
}

输出:

87654321 12345678
1234567887654321

std::bit_cast 仅在 C++20 中可用。在 C++20 之前,您可以手动实现 std::bit_caststd::memcpy,但有一个例外是这种实现不像 C++20 变体那样 constexpr

template <class To, class From>
inline To bit_cast(From const & src) noexcept {
    //return std::bit_cast<To>(src);
    static_assert(std::is_trivially_constructible_v<To>,
        "Destination type should be trivially constructible");
    To dst;
    std::memcpy(&dst, &src, sizeof(To));
    return dst;
}

对于这种特定的整数情况,非常理想的做法是进行位移/或算术以将一个 u64 转换为两个 u32 并再次返回。 std::bit_cast 更通用,支持任何可简单构造的类型,尽管 std::bit_cast 解决方案应该与具有高级优化的现代编译器上的位算术相同。

位算术的一个额外好处是它可以正确处理字节序,它与字节序无关,不像std::bit_cast

Try it online!

#include <cstdint>
#include <iostream>

int main() {
    uint64_t x = 0x12345678'87654321ULL;
    // Convert one u64 -> two u32
    uint32_t lo = uint32_t(x), hi = uint32_t(x >> 32);
    std::cout << std::hex << lo << " " << hi << std::endl;
    // Convert two u32 -> one u64
    uint64_t y = (uint64_t(hi) << 32) | lo;
    std::cout << std::hex << y << std::endl;
}

输出:

87654321 12345678
123456788765432

注意! 作为@Jarod42 points out,位移解决方案不等同于memcpy/bit_cast 解决方案,它们的等价性取决于字节序。在小端 CPU 上 memcpy/bit_cast 给出最低有效一半 (lo) 作为数组元素 v[0] 和最重要 (hi) 在 v[1] 中,而在大端上最低有效 (lo) 到 v[1] 和最重要的是v[0]。虽然位移解决方案与字节序无关,并且在所有系统上都将最高有效一半 (hi) 设为 uint32_t(num_64 &gt;&gt; 32),将最低有效一半 (lo) 设为 uint32_t(num_64)

【讨论】:

  • 您可以手动实现std::bit_cast,但请注意C++20 std::bit_castconstexpr,如果没有一些编译器魔法,这是不可能的。
  • 可能是 OT:出于某种原因,GCC 添加了一个单指令开销,bit_cast:godbolt.org/z/seoM4za9d(Clang 没有)。
  • @DanielLangr 我认为只有在单独的函数中执行此操作时才会发生这种开销。当您内联此函数或将其代码用作更大代码的一部分时,编译器应该更好地优化掉额外的指令。但在我看来,编译器决定在第三个函数中使用额外的RAX 而不是像在前两个函数中那样直接使用RDI,这看起来很奇怪。
  • @DanielLangr 如果您不使用引用作为返回值,那么一切都会被优化为基本上虚无:godbolt.org/z/dMfxdah6G
  • 请注意,手动移位的解决方案不等同于具有字节序的memcpy/bitcast
【解决方案2】:

以安全干净的方式

不要使用 reinterpret_cast。不要依赖不清楚的代码,这些代码依赖于某些特定的编译器设置和可疑的、不确定的行为。使用具有众所周知的定义结果的精确算术运算。类和运算符重载都在那里等着你。比如一些全局函数:

#include <iostream>

struct UpperUint64Ref {
   uint64_t &v;
   UpperUint64Ref(uint64_t &v) : v(v) {}
   UpperUint64Ref operator=(uint32_t a) {
      v &= 0x00000000ffffffffull;
      v |= (uint64_t)a << 32;
      return *this;
   }
   operator uint64_t() {
      return v;
   }
};
struct LowerUint64Ref { 
    uint64_t &v;
    LowerUint64Ref(uint64_t &v) : v(v) {}
    /* as above */
};
UpperUint64Ref upper(uint64_t& v) { return v; }
LowerUint64Ref lower(uint64_t& v) { return v; }

int main() {
   uint64_t v;
   upper(v) = 1;
}

或者接口对象:

#include <iostream>

struct Uint64Ref {
   uint64_t &v;
   Uint64Ref(uint64_t &v) : v(v) {}
   struct UpperReference {
       uint64_t &v;
       UpperReference(uint64_t &v) : v(v) {}
       UpperReference operator=(uint32_t a) {
           v &= 0x00000000ffffffffull;
           v |= (uint64_t)a << 32u;
       }
   };
   UpperReference upper() {
      return v;
   }
   struct LowerReference {
       uint64_t &v;
       LowerReference(uint64_t &v) : v(v) {}
   };
   LowerReference lower() { return v; }
};
int main() {
   uint64_t v;
   Uint64Ref r{v};
   r.upper() = 1;
}

【讨论】:

【解决方案3】:

使用std::memcpy

#include <cstdint>
#include <cstring>

void foo(uint64_t& v, uint32_t low_val, uint32_t high_val) {
    std::memcpy(reinterpret_cast<unsigned char*>(&v), &low_val,
                sizeof(low_val));
    std::memcpy(reinterpret_cast<unsigned char*>(&v) + sizeof(low_val),
                &high_val, sizeof(high_val));
}

int main() {
    uint64_t v = 0;
    foo(v, 1, 2);
}

使用O1,编译器将foo 简化为:

        mov     DWORD PTR [rdi], esi
        mov     DWORD PTR [rdi+4], edx
        ret

意味着没有额外的副本,std::memcpy 只是作为编译器的提示。

【讨论】:

    【解决方案4】:

    std::bit_cast 是不够的,因为结果会因系统的字节序而异。

    幸运的是&lt;bit&gt; 还包含std::endian

    请记住,优化器通常会在编译时解析 ifs 始终为真或假,我们可以测试字节顺序并采取相应措施。

    我们只知道如何处理大端或小端。如果不是其中之一,则 bit_cast 结果不可解码。

    另一个可能破坏事物的因素是填充。使用 bit_cast 假定数组元素之间的填充为 0。

    所以我们可以检查是否没有填充和字节序是大还是小,看看它是否可转换。

    • 如果它不是可铸造的,我们会按照旧方法进行一堆班次。 (这可能很慢)
    • 如果字节序为big——返回bit_cast的结果。
    • 如果字节序是little -- 颠倒顺序。 与 c++23 字节交换不同,因为我们交换元素。

    我任意决定 big-endian 的顺序正确,高位在 x[0]。

    #include <bit>
    #include <array>
    #include <cstdint>
    #include <climits>
    #include <concepts>
    
    template <std::integral F, std::integral T>
        requires (sizeof(F) >= sizeof(T))
    constexpr auto split(F x) { 
        enum consts {
            FBITS=sizeof(F)*CHAR_BIT,
            TBITS=sizeof(F)*CHAR_BIT,
            ELEM=sizeof(F)/sizeof(T),
            BASE=FBITS-TBITS,
            MASK=~0ULL >> BASE
        };
        using split=std::array<T, ELEM>;
        const bool is_big=std::endian::native==std::endian::big;
        const bool is_little=std::endian::native==std::endian::little;
        const bool can_cast=((is_big || is_little)
            && (sizeof(F) == sizeof(split)));
    
        // All the following `if`s should be eliminated at compile time
        // since they are always true or always false
        if (!can_cast)
        {
            split ret;
            for (int e = 0; e < ELEM; ++e)
            {
                ret[e]=(x>>(BASE-e*TBITS)) & MASK;
            }
            return ret;
        }
        split tmp=std::bit_cast<split>(x);
        if (is_big)
        {
            return tmp;
        }
        split ret;
        for (int e=0; e < ELEM; ++e)
        {
            ret[e]=tmp[ELEM-(e+1)];
        }
        return ret;
    }
    
    auto tst(uint64_t x, int y)
    {
        return split<decltype(x), uint32_t>(x)[y];
    }
    

    我认为这应该被定义为行为。

    编辑:将 uint64 基础更改为模板参数并进行细微的编辑调整

    【讨论】:

    • 在这里查看:godbolt.org/z/cjM8W64qz
    • @Jarod42 这应该处理字节序
    • 问题是 OP 是否需要 endian-aware 结果,因为这样的任务通常假设您使用内存顺序,而不是值顺序,将数据转换为网络格式
    【解决方案5】:

    别费心了,反正算术更快:

    uint64_t v;
    uint32_t lower = v;
    uint32_t upper = v >> 32;
    

    【讨论】:

      猜你喜欢
      • 2023-04-01
      • 1970-01-01
      • 2020-03-09
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2017-06-13
      • 2018-04-21
      • 2019-07-23
      相关资源
      最近更新 更多