这里有一个简单的函数,它试图从大端缓冲区读取一个通用的二进制补码整数,我们假设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个字节,那么无论int
和long
的大小如何,都应该使用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中,对具有负值的有符号整数进行左移是未定义的行为。将具有正值的有符号整数左移到符号位是实现定义的行为。另请参阅:
2
E1 << E2
的值是E1左移E2位位置;空出的比特被零填充。如果E1有一个无符号类型,结果的值为E1 × 2**E2
,比结果类型。否则,如果E1具有带符号类型并且非负值,并且E1 × 2**E2
是可表示的在结果类型的相应无符号类型中,则转换为结果类型的值是结果值;否则,行为是未定义的。
(C++17最终工作草案,第8.8节轮班操作员[expr.Shift],第2段,第132页-强调矿)
使用C++20,转换到符号位从实现定义变为定义行为:
2
E1 << 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的第二个例子中使用memcpy
或static_cast
都应该有效。
C++20只将有符号int表示为2的补码的部分主要原因是,大多数现有编译器已经将其表示为2补码。GCC和LLVM(以及Clang)都已经在内部使用了two的补码。
这似乎并不完全可移植(如果这不是最好的答案,这是可以理解的),但我想你知道你将用什么编译器来构建代码,所以你可以在技术上用检查来包装这个或你的第二个例子,看看你是否使用了合适的编译器。