前言
在实际开发的过程中,二进制离我们越来越远,需要直接处理二进制的场景很少。最近恰好在研究 WebSocket 协议,里面涉及到很多二进制相关的操作,所以趁机把忘掉的东西再捡回来一些。话不多说,直接进入今天的正题。
二进制位运算
符号 | 描述 | 规则 | 例子 |
---|---|---|---|
& | 与 | 两个位都为1时,结果才为1 | 1100 & 1011 = 1000 |
| | 或 | 任意一位为1,结果就为1 | 1100 & 1011 = 1111 |
^ | 异或 | 两个位不同时结果才为1,相同结果为0 | 1100 & 1011 = 0111 |
~ | 取反 | 0变1,1变0 | ~1100 = 0011 |
<< | 左移 | 各二进制位全部左移若干位,高位丢弃,低位补0 | 1100 << 1 = 1000 |
>> | 右移 | 各二进制位右移若干位(无符号数,高位补0;有符号数,有的编译器补符号位,有的编译器补0) | 1100 >> 1 = 0110 |
再来举几个计算过程的例子:
与 (&)
1 1 0 0 1 1 0 0
& 1 0 0 1 1 0 1 1
————————————————————
1 0 0 0 1 0 0 0
或 (|)
1 1 0 0 1 1 0 0
| 1 0 0 1 1 0 1 1
————————————————————
1 1 0 1 1 1 1 1
异或 (^)
1 1 0 0 1 1 0 0
^ 1 0 0 1 1 0 1 1
————————————————————
0 1 0 1 0 1 1 1
取反 (~)
~ 1 1 0 0 1 1 0 0
————————————————————
0 0 1 1 0 0 1 1
左移 (<<)
1 1 0 0 1 1 0 0 << 1
—————————————————————————
1 0 0 1 1 0 0 0
Note:全部位向左移动一位,高位(左侧)的一个 1 被丢弃,低位(右侧)补充一个 0
右移 (>>)
1 1 0 0 1 1 0 0 >> 1
—————————————————————————
0 1 1 0 0 1 1 0
Note:全部位向右移动一位(无符号),高位(左侧)补充一个 0 ,低位(右侧)的一个 0 被丢弃
在 JavaScript 中验证
在 JavaScript 中,二进制的字面表达为:0b
或 0B
+ 二进制位,示例:0b11001100
,所以我们可以在 JavaScript 中试试上面学到的这些位运算操作。在控制台输入以下代码,看看效果先:
console.log( 0b11001100 & 0b10011011 ) // 0b10001000 → 136
console.log( 0b11001100 | 0b10011011 ) // 0b11011111 → 223
console.log( 0b11001100 ^ 0b10011011 ) // 0b01010111 → 87
console.log( ~0b11001100 ) // 0b00110011 → 51
console.log( 0b11001100 << 1 ) // 0b10011000 → 152
console.log( 0b11001100 >> 1 ) // 0b01100110 → 102
Chrome 控制台中运行效果如下:
这 TM 就尴尬了,有 2 处(取反 ~
、左移 <<
)运算结果和我们预想的不一样。先别慌,我们一个一个来剖析:
从简单的看起,先来分析左移运算 (<<
) 为啥跟我们期望的不一样。首先,我们需要知道一个前提,在 v8 JS 引擎中,小整数(-2^30 至 2^30 -1)是占 4 个字节(32位)空间,而不是我们通常认知中的占用 64 位空间,这里主要是考虑到一些性能优化,想要了解更多详情可前去阅读官方博客文章,这里就不展开了。但是看下我们上面的计算是用 8 位来运算的,导致左移过程中高位的一个 1 被丢弃了,所以与实际运算结果出现了偏差。既然这样,那就用 32 位来计算试试:
00000000 00000000 00000000 11001100 << 1
—————————————————————————————————————————————
00000000 00000000 00000001 10011000
在控制台输入以下代码,看看结果(Tips: 数字中间的 _
是 EcmaScript 的新语法,表示连字符,直接忽略就可以了):
console.log( 0b00000000_00000000_00000000_11001100 << 1 ) // 0b00000000_00000000_00000001_10011000 → 408
正如预期结果一样,SO 这个问题就这么轻松的解了🤷♂️,没有难度嘛。
再来看看取反(~
)这个问题,我们先用 32 位来模拟演算一下计算机的运算逻辑:
~ 00000000 00000000 00000000 11001100
—————————————————————————————————————————
11111111 11111111 11111111 00110011
先拿掉第 1位符号位(负数),算出来的结果是:-2147483443
。额,这和实际计算的结果 -205
差距有点大呀。。。到底是哪儿出了问题呢?几经周折,终于弄清楚了这里面的蹊跷:
先来了解几个概念:原码、反码、补码,不知道这些知识还给大学老师了没?
原码
符号位加上真值的绝对值, 即用第一位表示符号(正数为 0,负数为 1), 其余位表示值,我们以上提到的二进制都是用原码表示。为了方便,以下都使用 8 位来示例:
[+1] → [0000 0001]原
[-1] → [1000 0001]原
反码
- 正数的反码是本身
- 负数的反码是:符号位不变,其他各位取反
[+1] → [0000 0001]原 → [0000 0001]反
[-1] → [1000 0001]原 → [1111 1110]反
补码
- 正数的补码是本身
- 负数的补码是:在 反码 的基础上 + 1
[+1] → [0000 0001]原 → [0000 0001]反 → [0000 0001]补
[-1] → [1000 0001]原 → [1111 1110]反 → [1111 1111]补
补码求原码
计算方式跟 原码转补码 方式一样
- 如果是正数,补码的原码是本身
- 如果是负数,先求反码,再 + 1
[0000 0001]补 → [0000 0001]反 → [0000 0001]原 → [+1]
[1111 1111]补 → [1000 0000]反 → [1000 0001]原 → [-1]
了解这些概念后,我们需要注意的是:计算机系统中,所有的数值都是以补码的形式存储的。大概的原因就是,使用补码后可以让符号位一起参与进来运算,这样计算机的数字运算设计就比考虑符号位要简易很多。那我们再回过头来看这个问题:
00000000 00000000 00000000 11001100 原码
00000000 00000000 00000000 11001100 原码的补码
~
11111111 11111111 11111111 00110100 取反后的补码
经过上述的模拟运算,计算机内存里存的 32 位值应该是:11111111 11111111 11111111 00110100
(补码),但是给人类看的是可读的原码,需要将补码转为原码(第一位是 1 表示负数,需要 先转反码后再 + 1):
11111111 11111111 11111111 00110100 补码
10000000 00000000 00000000 11001011 转反码
+1
————————————————————————————————————————————————
10000000 00000000 00000000 11001100 原码
把原码拿到控制台转 10 进制看下,果然,这是我们想要的结果(Note:第 1 位是符号位,表示负数,剩下的再拿去转换)。
此外,在 JavaScript 中,还需注意以下问题:
>>
表示有符号右移(符号位不会被移动),例如:-0b1100 >> 1 == -0b0110
;>>>
表示无符号右移,例如:-0b1100 >>> 1 == 2147483642
(符号位已经在右移的过程中被补充的 0 替换了)- JavaScript 将数字存储为 64 位浮点数,但是所有的位运算都以 32 位的二进制执行。在执行位运算之前,JavaScript 将数字转换成 32 位有符号整数,执行位运算后,再将结果转为 64 位浮点数,所以不难解释以下现象:
(2 ** 32) | 0 = 0
: 高位被丢弃,后面 32 位(包括符号位)都是 0 ,所以结果是 +05.5 | 0 = 5
: 位运算会先转成整数,小数被忽略,可以用这一特性用于向下取整
在 JavaScript 中创建二进制数据
JavaScript 提供了以下几个类来创建二进制数据:
- Int8Array : 8位有符号整型的二进制数组
- Uint8Array : 8位无符号整型的二进制数组
- Uint8ClampedArray : 8位无符号整型固定数组,与 Uint8Array 的用法差异详见 stackoverflow
- Int16Array : 16位有符号整型的二进制数组
- Uint16Array : 16位无符号整型的二进制数组
- Int32Array : 32位有符号整型的二进制数组
- Uint32Array : 32位无符号整型的二进制数组
- Float32Array : 32位有符号浮点数的二进制数组
- Float64Array : 64位有符号浮点数的二进制数组
- BigInt64Array : 64位有符号大整型的二进制数组,用法示例:
new BigInt64Array([100n])
- BigUint64Array : 64位无符号大整型的二进制数组,用法示例:
new BigUint64Array([100n])
以上所有的类都继承于 TypedArray 类,可以使用 TypedArray 原型上的属性和方法。
举个栗子,把一串数据以 8 位有符号整形的方式写入内存中:
var arr = new Int8Array([10, -20, 30, 40, 50])
// 或者
var arr = Int8Array.from([10, -20, 30, 40, 50])
arr.buffer // -> 返回 ArrayBuffer 对象
arr[0] // 10
arr[1] // -20
arr[2] // 30
arr[3] // 40
arr[4] // 50
实例属性 instance.buffer
返回 ArrayBuffer 对象,这是一个原始二进制数据缓冲区。因为一个字节有 8 位(1 byte = 8 bit),所以 Int8Array
与 Uint8Array
是比较常用的二进制数据创建类。其他的创建类用法与之类似,可以参考相关 MDN 文档。
ArrayBuffer 与 Blob
- ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区
- Blob 对象表示一个不可变、原始数据的类文件对象,它的数据可以按文本或二进制的格式进行读取,也可以转换成
ReadableStream
来用于数据操作
以上是 MDN 上对于两者的名词解释。我是这样理解的,ArrayBuffer 是固定大小的,创建的时候必须提前分配好空间;而 Blob 则更侧重于以某种方式对二进制数据进行消费,这两者的区别有一丢丢像 node 里 Buffer 和 stream 的区别。
创建 ArrayBuffer
new ArrayBuffer(10); // 创建 10 个字节长度的 ArrayBuffer 对象
new Uint8Array([10, 20, 30, 40, 50]).buffer; // 通过 Uint8Array 类创建 ArrayBuffer 对象,并写入数据
创建 Blob
通过传入 ArrayBuffer 对象作为构造参数创建:
const buffer = new Uint8Array([10, 20, 30, 40, 50]).buffer;
const blob = new Blob(buffer);
还可以指定合适的 MIME 类型:
const blob = new Blob([
JSON.stringify({ foo: 123, bar: 'hello' }, null, 2)
], {type : 'application/json'});
Note: File 类继承与 Blob 类
一些技巧
1. 将二进制数据转为图片 URL
const blob = new Blob([buffer]); // blob 或者 buffer 一般来源于本地文件上传 或 HTTP 响应二进制文件数据等
const url = URL.createObjectURL(blob); // 返回值 url 可直接用于 img 标签的 src 属性上
2. 获取/处理二进制数据
一般情况下不会遇到这种场景,但是在一些依赖二进制的传输协议里,处理二进制是非常常见的操作。通常头部(二进制前 n 位)存当前数据的元信息(一些标志、数据长度等),后面存真实需要传输的数据。
例如:在 WebSocket 协议里,第 1 位是 FIN 标识(消息可分片传输,FIN 为 1
表示当前的消息已经传输完了),那么在实战中该如何取二进制的第 1 位呢?其实,我们可以简化一下操作,只需要取第 1 个字节的第 1 位就可以了:
// nodejs
const buffer = Buffer.from('...') // buffer 来自 WebSocket 消息
// Tips: `&` 运算符,只有2个位都为 1 ,结果才为 1
// `0b10000000` 只有第 1 位是 `1`,所以与之相与,只有2种结果:
// - 0b10000000: 第一位是 `1`
// - 0b00000000: 第一位是 `0`
const FIN = buffer[0] & 0b10000000 === 0b10000000; // 如果第 1 位是 `1`,结果为 true
结语
以上就是笔者在学习探索过程中的一些总结,个人能力有限,难免有错误或者不恰当的地方,敬请指正~