如何从uint8_t的缓冲区读取带符号整数,而不调用未定义或实现定义的行为



这里有一个简单的函数,它试图从大端缓冲区读取一个通用的二进制补码整数,我们假设std::is_signed_v<INT_T>:

template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
INT_T result = 0;
for (size_t i = 0; i < sizeof(INT_T); i++) {
result <<= 8;
result |= *data;
data++;
}
return result;
}

不幸的是,这是未定义的行为,因为最后一个<<=转移到符号位。


所以现在我们尝试以下操作:

template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
std::make_unsigned_t<INT_T> result = 0;
for (size_t i = 0; i < sizeof(INT_T); i++) {
result <<= 8;
result |= *data;
data++;
}
return static_cast<INT_T>(result);
}

但我们现在调用static_cast中实现定义的行为,从无符号转换为有符号。


在"定义明确"的领域中如何做到这一点?

首先将字节组装成一个无符号值。除非您需要组装9个或更多八位字节的组,否则一个符合要求的C99实现保证具有足够大的类型来容纳所有八位字节(C89实现保证有足够大的无符号类型来容纳至少四个)。

在大多数情况下,如果您想将一个八位字节序列转换为一个数字,您就会知道您期望的八位字节数。如果数据编码为4个字节,那么无论intlong的大小如何,都应该使用4个字节(可移植函数应该返回类型long)。

unsigned long octets_to_unsigned32_little_endian(unsigned char *p)
{
return p[0] | 
((unsigned)p[1]<<8) |
((unsigned long)p[2]<<16) |
((unsigned long)p[3]<<24);
}
long octets_to_signed32_little_endian(unsigned char *p)
{
unsigned long as_unsigned = octets_to_unsigned32_little_endian(p);
if (as_unsigned < 0x80000000)
return as_unsigned;
else
return (long)(as_unsigned^0x80000000UL)-0x40000000L-0x40000000L;
}

请注意,减法分为两部分进行,每个部分都在有符号长的范围内,以考虑到LNG_MIN为-2147483647的系统的可能性。在这样的系统上尝试转换字节序列{0,0,0,0x80}可能会产生未定义的行为[因为它会计算值-2147483648],但代码应该以完全可移植的方式处理"长"范围内的所有值。

不幸的是,这是未定义的行为,因为最后一个<lt;=转换为符号位。

实际上,在C++17中,对具有负值的有符号整数进行左移是未定义的行为。将具有正值的有符号整数左移到符号位是实现定义的行为。另请参阅:

2E1 << E2的值是E1左移E2位位置;空出的比特被零填充。如果E1有一个无符号类型,结果的值为E1 × 2**E2,比结果类型。否则,如果E1具有带符号类型并且非负值,并且E1 × 2**E2可表示的在结果类型的相应无符号类型中,则转换为结果类型的值是结果值;否则,行为是未定义的。

(C++17最终工作草案,第8.8节轮班操作员[expr.Shift],第2段,第132页-强调矿)


使用C++20,转换到符号位从实现定义变为定义行为:

2E1 << E2的值是与E1 × 2**E2 modulo 2**N一致的唯一值,其中N是结果的类型。[注:E1为左移E2位;空出位为零填充。--尾注]

(C++20最新工作草案,第7.6.7节轮班操作员[expr.Shift],第2段,第129页)

示例:

int i = 2147483647;  // here: 2**31-1 == INT_MAX, sizeof(int) = 32
int j = i << 1;      // i.e. -2

断言:-2是与2147483647 * 2 % 2**32一致的唯一值

检查:

a ≡ b (mod n)      | i.e. there exists an integer k:
<=> a - b = k * n
=> -2 - 2147483647 * 2 = k * 2**32
<=> -4294967296 = k * 2**32
<=> k = -1                 | i.e. there is an integer!

值CCD_ 15是唯一的,因为在域CCD_。


这是C++20强制有符号整数类型的二补码表示的结果:

3[..]对于带符号整数类型的每个值x与x模2N一致的对应无符号整数类型具有与其价值表示41)这也被称为二的补码表示[..]

(C++20最新工作草案,第6.8.1节基本类型[basic.basic],第3段,第66页)


这意味着使用C++20,您的原始示例会按原样调用已定义的行为。


附加说明:这并不能证明什么,但GCC/Clang未定义的行为净化程序(使用-fsanitize=undefined调用)仅在编译std<=的此示例时触发C++17,然后只抱怨负值的偏移(两者都如预期):

#include <stdio.h>
#include <limits.h>
int main(int argc, char **argv)
{
int i = INT_MAX - 1 + argc;
int j = i << 1;
int k = j << 1;
printf("%d %d %dn", i, j, k);
return 0;
}

示例会话(在Fedora 31上):

$ g++ -std=c++17 -Wall -Og sign.cc -o sign -fsanitize=undefined
$ ./sign                                                       
sign.cc:8:15: runtime error: left shift of negative value -2
2147483647 -2 -4
$ g++ -std=c++2a -Wall -Og sign.cc -o sign -fsanitize=undefined 
$ ./sign
2147483647 -2 -4

要提出替代解决方案,复制位和避免UB的最佳方法是通过memcpy:

template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
std::make_unsigned_t<INT_T> tmp = 0;
for (size_t i = 0; i < sizeof(INT_T); i++) {
tmp <<= 8;
tmp |= *data;
data++;
}
INT_T result;
memcpy(&result, &tmp, sizeof(tmp));
return result;
}

有了这个,你就不会从将未签名类型转换为签名类型中获得UB,而且通过视光,它可以编译成与你的示例完全相同的程序集。

#include <cstdint>
#include <cstring>
#include <type_traits>
template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
std::make_unsigned_t<INT_T> tmp = 0;
for (std::size_t i = 0; i < sizeof(INT_T); i++) {
tmp <<= 8;
tmp |= *data;
data++;
}   
return static_cast<INT_T>(tmp);
}
template<typename INT_T>
INT_T read_big_endian2(uint8_t const *data) {
std::make_unsigned_t<INT_T> tmp = 0;
for (std::size_t i = 0; i < sizeof(INT_T); i++) {
tmp <<= 8;
tmp |= *data;
data++;
}   
INT_T res;
memcpy(&res, &tmp, sizeof(res));
return res;
}
// Just to manifest the template expansions.
auto read32_1(uint8_t const *data) {
return read_big_endian<int32_t>(data);
}
auto read32_2(uint8_t const *data) {
return read_big_endian2<int32_t>(data);
}
auto read64_1(uint8_t const *data) {
return read_big_endian<int64_t>(data);
}
auto read64_2(uint8_t const *data) {
return read_big_endian2<int64_t>(data);
}

使用clang++ /tmp/test.cpp -std=c++17 -c -O3编译为:

_Z8read32_1PKh:  # read32_1
movl    (%rdi), %eax
bswapl  %eax
retq
_Z8read32_2PKh:  # read32_2
movl    (%rdi), %eax
bswapl  %eax
retq
_Z8read64_1PKh:  # read64_1
movzbl  (%rdi), %eax
shlq    $8, %rax
movzbl  1(%rdi), %ecx
orq     %rax, %rcx
shlq    $8, %rcx
movzbl  2(%rdi), %eax
orq     %rcx, %rax
shlq    $8, %rax
movzbl  3(%rdi), %ecx
orq     %rax, %rcx
shlq    $8, %rcx
movzbl  4(%rdi), %eax
orq     %rcx, %rax
shlq    $8, %rax
movzbl  5(%rdi), %ecx
orq     %rax, %rcx
shlq    $8, %rcx
movzbl  6(%rdi), %edx
orq     %rcx, %rdx
shlq    $8, %rdx
movzbl  7(%rdi), %eax
orq     %rdx, %rax
retq
_Z8read64_2PKh:  # read64_2
movzbl  (%rdi), %eax
shlq    $8, %rax
movzbl  1(%rdi), %ecx
orq     %rax, %rcx
shlq    $8, %rcx
movzbl  2(%rdi), %eax
orq     %rcx, %rax
shlq    $8, %rax
movzbl  3(%rdi), %ecx
orq     %rax, %rcx
shlq    $8, %rcx
movzbl  4(%rdi), %eax
orq     %rcx, %rax
shlq    $8, %rax
movzbl  5(%rdi), %ecx
orq     %rax, %rcx
shlq    $8, %rcx
movzbl  6(%rdi), %edx
orq     %rcx, %rdx
shlq    $8, %rdx
movzbl  7(%rdi), %eax
orq     %rdx, %rax
retq

在带有clang++ v8的x86_64-linux-gnu上。

大多数时候,带有优化的memcpy将编译到与您想要的完全相同的程序集,但没有UB的额外好处。


更新核心性:OP正确地指出,这仍然是无效的,因为有符号的int表示不需要是2的补码(至少在C++20之前),这将是实现定义的行为。

AFAICT,直到C++20,似乎还没有一种在不知道有符号int的位表示的情况下对int执行位级操作的整洁的C++方法,这是实现定义的。也就是说,只要你知道你的编译器将把C++积分类型表示为二的补码,那么在OP的第二个例子中使用memcpystatic_cast都应该有效。

C++20只将有符号int表示为2的补码的部分主要原因是,大多数现有编译器已经将其表示为2补码。GCC和LLVM(以及Clang)都已经在内部使用了two的补码。

这似乎并不完全可移植(如果这不是最好的答案,这是可以理解的),但我想你知道你将用什么编译器来构建代码,所以你可以在技术上用检查来包装这个或你的第二个例子,看看你是否使用了合适的编译器。

相关内容

  • 没有找到相关文章

最新更新