文章

面向萌新的位与 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)

因此我们脑子要清晰,自己到底需要的是二进制还是十进制。