面向萌新的位与 0xFF 解释
TL; DR
0xFF
通常用于过滤,也叫掩码。和它进行与运算,结果是只保留一个数据的最低8位,其他位全部置0。
背景
在位运算中经常看到A & 0xFF
的身影,甚至已经成了习惯性动作,但一直没有深究它的具体意义,直到这次忘记了神奇的 0xFF,于是发生了很糟糕的事情...
本来是一个很简单的需求——将 int
转为字节数组,然后再转换回来。为了不误导大家,这里先给出正确的实现(kotlin):
fun Int.toBytes(bytes: ByteArray? = null): ByteArray {
val result = bytes ?: ByteArray(4)
if (result.size < 4) {
throw IllegalArgumentException("bytes capacity must >= 4")
}
result[0] = (this shr 24).toByte()
result[1] = (this shr 16).toByte()
result[2] = (this shr 8).toByte()
result[3] = this.toByte()
return result
}
fun ByteArray.toInt(): Int {
if (this.size < 4) {
throw IllegalArgumentException("bytes size must >= 4")
}
return ((this[0].toInt() and 0xff) shl 24) or
((this[1].toInt() and 0xff) shl 16) or
((this[2].toInt() and 0xff) shl 8) or
(this[3].toInt() and 0xff)
}
可以看到,在进行 bytes -> int 方向的转换时,每一字节都必须进行一下与运算,而反向转换时就不需要。
要想彻底搞清楚原因,需要一些基本的计算机原理知识。
整形的编码
我们知道,任何数据在计算机中都是二进制,所以我们需要采用不同的编码,只有按照正确编码进行解释,才能确保数据不会混乱。否则面对一大坨0101谁也搞不清楚这是什么东西。关于编码,这是一个很广泛的话题,今天重点讨论的是整形编码。
(方便起见,下面只用1个字节长度进行举例,约定使用大端序)
计算机中采用补码方式存储,除此之外还有原码、反码两种。原码/反码/补码计算器
原码
原码非常简单,最高位是符号位,0为正,1为负,剩下的就是数字的二进制表示法。
反码
- 正数的反码就是原码
- 负数的反码是除了符号位外,按位取反
补码
-
正数的补码就是原码
-
负数的补码是 反码 +1
十进制 | 原码 | 反码 | 补码 |
---|---|---|---|
+2 | 0000 0010 | 0000 0010 | 0000 0010 |
-3 | 1000 0011 | 1111 1100 | 1111 1101 |
转换
int -> bytes
我们知道 java 中 int 是32位,需要 4B 来存储,即:bytes[0] 储存最高的8位,bytes[3] 储存最低的8位。toByte()
函数的则是取最低的8位。上面的代码就很容易理解了。
result[1] = (this shr 16).toByte()
例如这段代码,32位的 int 右移 16 位后,高16位用原先的符号位进行填充。此时最低的8位正好是原先的16~24位。还不懂的看下面的图:
00000000 10000000 11000000 11100000
|------> 实际有效的数据 <------|---> 被丢弃 <---|
00000000 00000000 00000000 10000000 11000000 11100000
|--- 符号位填充 ---|------> 原先的数据 <------|
bytes -> int
这个时候就有坑了。也许你觉得,只要分组还原拼起来就行,比如这样:
... or (bytes[1].toInt() shl 16) or ...
这么做相当于默认了一个假设:
当把 1B 的数据转换成 4B 的 int 时,前24位会填充0,最后8位是实际的数据。
然后你把这最后8位左移16,对应到原先的位置上(也就是16~24位)。以此类推,通过或运算依次把4个位置的数据都拼回去。
然而这个假设并不成立。
toInt()
的具体行为是:最低8位保持原样,高的24位用符号位填充。于是就有可能得到一堆意料之外的1。还是上面的例子:
00000000 10000000 11000000 11100000
经过转换,bytes[1] = 10000000
针对这1B来讲,其表示的是十进制 -128,符号位为1
于是转成 int 结果是:
11111111 11111111 11111111 10000000
|---- 符号位填充 -----| 原始数据 |
将其左移16位与其他部分求或,会导致 24~32 位的数据发生变化。(本来应该是0的变成了1)
00000000 00000000 00000000 00000000 <- 已经还原过b[0]的数据 (b[0]=0)
11111111 10000000 00000000 00000000 <-b[1] 左移16后的数据
-------------------------------------- or
11111111 10000000 00000000 00000000
可以看到高8位被改变了,而这一步操作应该是只改变16~24位的数据才对
以此类推,当最后一位还原完成时,前面的数据可能已经被干扰得不成样子了。当然,这种情况并不一定会发生,如果碰巧,原先的数据每一字节的第一位都是0,则不会触发这个 bug,例如所有小于 127 的数。
铺垫终于讲完了,0xFF 的作用就是把除了最低8位之外的所有数据全部置0!
xxxxxxxx 10101010
00000000 11111111 (0xff)
------------------ and
00000000 10101010
可以看到,低8位的数据不变,其他位不管原来是啥都会变成0.
总结
为什么只有一个方向转换的时候需要 and 0xff
?还有哪些情况需要 and 0xff
?
个人总结的是,从较大的数据转到较小的数据时通常没有问题,因为多余的被舍弃,低位数据一般会原样保留。但从小数据转到大数据时要注意,多出来的地方不一定填充0,此时要进行 and 0xff
运算,确保只取我们需要的那一部分。
另外一个要点的,计算机内部转换往往是保证数值一致,而不是二进制一致。
例如 byte -127 为 10000001
,将其转为 int 为 11111111 11111111 11111111 10000001
,它依然代表十进制下的 -127 (不信的自己算算看),但是二进制已经不一致了。
通过 and 0xff
运算,我们令它二进制一致,即多余的高位 置0,那么它十进制就不一致了(0...0 10000001
= 129)
因此我们脑子要清晰,自己到底需要的是二进制还是十进制。