意外的转换位移结果
作为我正在开发的一个文件解析器的一部分,我写了一个把4字节的char* 转换成无符号long的简单实现:
unsigned long get_4b_size(FILE* file) {
char bytes[4];
fread(bytes, 1, 4, file);
unsigned long output = bytes[0];
for (int i = 1; i < 4; i++) {
output = (output << 8) + bytes[i];
}
return output;
}
这东西非常简单,几乎在所有情况下都能工作,但我似乎发现了一个它不起作用的情形。出于某种原因,数字 "184346"(二进制00000000 | 00000010 | 11010000 | 00011010)导致了意料之外的行为。在调试代码时,我实时看到 "00000010" 只向右移了7 位,而不是8 位。
这并不是我尝试的唯一代码,事实上,在注意到这个返回语句的行为后才用这种方式来尝试:return (bytes[0] << 24) + (bytes[1] << 16) + (bytes[2] << 8) + bytes[3];
我甚至尝试用乘以256而不是位移,但在对其进行运算的那一刻,输出中的2 会突然变成1。我完全被这件事弄糊涂了。有没有人能就我遇到的这个奇怪的边缘情况给出见解?
解决方案
有两个问题:
- 你假设CPU是大端序,这在你使用例如Power PC系统时是正确的。你是这么想的吗?否则,在x86或 ARM的情况下,默认就是小端。请参考 What is CPU endianness?
在小端系统上,184346d的表示是0x2D01A,其在内存中从低地址到高地址的存储顺序是:0x1A、0xD0、0x02、0x00。你的代码假设了相反的顺序。
我们可以通过仅使用移位而非数组索引等方式来避免对底层字节序的假设。
- 你很可能在进行带符号数的运算,因为
char的有符号性是实现定义的,在你的编译器上可能是有符号的。 默认情况下char是有符号还是无符号? 这就是为什么我们绝不要在除了字符串之外的任何场景中使用char类型。相反,应使用unsigned char或uint8_t。
在 bytes[i] 为0xD0等等这种情况——一个不能放入十进制有符号字符的数字——它会从0xD0转换成相应的十进制有符号数,成为负数。
接着在表达式 (output << 8) + bytes[i] 中,对 + 的操作数 bytes[i] 进行隐式类型提升,先通过整型提升提升为一个有符号的32位 int,因此你之前的负数数据会被符号扩展为一个32位的负数,等价于你之前的8 位负数。
然后,“通常的算术转换”会查看类型为 unsigned long 的操作数 (output << 8),并把你的32位负数隐式转换为 unsigned long,这是一个定义明确的转换,最终得到一个较大的无符号数。这个加法本身也可能导致无符号环绕。无论如何,这都会让你得到很大、看起来很奇怪的数字。
我们可以通过把循环体改成在每一步打印来说明:
c
for (int i = 1; i < 4; i++) {
printf("%X<<8 = %X, %X+%d=%X\n",
output,
output<<8,
output<<8,
bytes[i],
(output<<8)+bytes[i]);
output = (output << 8) + bytes[i];
}
这在小端32位二进制补码CPU上会得到如下结果:
none
1A<<8 = 1A00, 1A00+-48=19D0
19D0<<8 = 19D000, 19D000+2=19D002
19D002<<8 = 19D00200, 19D00200+0=19D00200
顺序错了,数值也不小心变成了负数等等。0xD0 = -48,因此在环绕后你实际得到的是0x1A00与 0xFFFF FFFF FFFF FFD0相加的结果,经过环绕后变成0x19D0。
解决方案:
-
不要对字节序做任何假设,或者在开始算法前通过代码检查字节序。或者如果你在小端CPU上,则直接假设为小端。
-
永远不要在任何程序中使用C 的“原生”基础类型,而应始终使用
stdint.h类型。进行位运算或硬件相关编程时不要使用有符号类型(除非你确实需要有符号算术,在该情境下很少见)。 -
每次移位时,最好在左操作数进行移位前将其强制转换为目标类型。在本例中这不会有影响,但如果你要对8 位类型进行移位则会有影响。