L03 Bit Byte Integer
对于无符号数运算,这里使用4位进行运算(0-15):
假设我们运算 13+5 = 18 实际上是 1101+0101= 1 0010
但是多出来的一位被丢弃了,因为只有四位容量,所以结果成了a+b mod 2^n n为位数
对于补码,同样以4位作为举例,最大数为0111 是+7 最小数为 1000 是-8。
那么对于 5-3 可视为 5+(-2) 也就是 0101 + 1110 = 1 0011 但是最高位被丢弃,所以得到 0011 也就是3。
对于 3-5 可视为 3+(-5) 也就是 0011+ 1011 = 1110 也就是-2
-3+(-6) 也就是 1101 + 1010 = 1 0111 但是最高位被抛弃了,也就是 7 这产生了负溢出
7+5 也就是 0111+ 0101 = 1100 这是-8 这被称作正溢出
上述两种情况被称作补码溢出,那么,对于两个整数相加,需要w+1的位数用于存储。对于两个w位的整数相乘,可能需要2w位用于存储。但是这样会将我们的内存耗尽,所以我们只能考虑截断。
对于 3 * 5 也就是 0011*0101 ,这里使用一种位移的方法来完成:
我们可以把其中一个乘数拆开:
将 5 (
0101 ) 拆解为:$$5 = 4 + 1 = 2^2 + 2^0$$
那么 $3 \times 5$ 就可以瞬间转化为:
$$3 \times 5 = 3 \times (4 + 1) = (3 \times 4) + (3 \times 1)$$
用二进制位移直接秒杀
现在我们把上面的拆解直接套进二进制里(假设是 8 位系统,3 是
0000 0011):
- 乘以 4:等于把 3 左移 2 位 (
3 << 2)
0000 0011$\rightarrow$ 0000 1100 (十进制的 12)- 乘以 1:等于 3 本身保持不变 (
3 << 0)
0000 0011$\rightarrow$ 0000 0011 (十进制的 3)- 把两个结果相加:
看!
0000 1111 正好就是十进制的 15。你根本不需要去算1*1=1、1*0=0 这种繁琐的竖式交叉乘法,只需要看一眼乘数里哪几位是 1 ,把被乘数左移对应的位数,然后加起来就搞定了。计算机也是这么干的吗?
是的,甚至比这更聪明!
在现代 CPU 内部,乘法器(Multiplier)的核心逻辑就是这个。当计算机看到
0101(5)时:
- 发现最低位(第0位)是
1$\rightarrow$ 把0011留下来。- 发现第1位是
0$\rightarrow$ 跳过。- 发现第2位是
1$\rightarrow$ 把0011左移2位,和前面的结果加起来。对于更复杂的乘法,计算机还会使用一种叫 布斯算法(Booth’s Algorithm) 的神级优化,它可以通过“有符号转换”,把连续的
1 变成一次减法和一次加法。例如,乘以 7(0111)会被计算机看作 乘以 **$(8 – 1)$**,也就是(x << 3) - x,连加法都省了好几次!
但是在这里 因为是有符号数,这个数变成了-1
在硬件设计上,CPU 根本不需要为“有符号乘法”和“无符号乘法”设计两套不同的乘法器电路。 它只需要设计一个最简单的无符号乘法器,直接把两个 $w$ 位数乘起来,然后闭着眼睛直接截断,只留下低 $w$ 位。
- 如果你代码里定义的是
unsigned,这个保留下来的低 $w$ 位就是无符号结果。 - 如果你代码里定义的是
signed,这个保留下来的低 $w$ 位(通过补码解释)就是有符号结果。
这也就是为什么汇编语言里,在进行这种截断的乘法时,它们可以共享底层几乎相同的算术逻辑单元(ALU)的原因。
对于负数,比如 -6 是 1010 对其右移是算数位移,即1101,这是 -3 再对其除以二,也就是向右移动 这是 1110这是-2。出现了向下取整。
所以内存的最小存储单元实际上是Byte
内存的地址指向的也是每个字节
现在比如 0x0001 里面就只写一个A 那么ASCII是65 ,所以直接读取 0x0001 里面的数据得到的是 0100 0001 刚好一字节。
但是,在实际工作中,CPU 读写内存的习惯并不是一次只读 1 字节。
-
如果 CPU 每次只读 1 字节,那么要读一个 64 位的整数,CPU 就要去内存里跑 8 趟,这太慢了。
-
所以,CPU 内部的寄存器和数据总线是有宽度的,这个宽度就是字长(Word Size) 。
- 32位系统:字长为 4 字节(32 bits)
- 64位系统:字长为 8 字节(64 bits)
为了追求速度,CPU 每次去内存拿数据,都是整块整块地往回搬。这个“块”的大小,就是它的字长。
对于当前主流计算机,64bit的设备实际上的内存读取只能读取到 47位左右,也就是128TB。
在大端序(Big-Endian)和小端序(Little-Endian)的背后,其实是一场关于“多字节数据该怎么塞进字节内存里”的硬件设计之争。
我们刚刚聊过,像 int(4字节)或 short(2字节)这样的多字节变量,在内存里要连续占用多个“字节抽屉”。那么,数据的“高位”和“低位”,到底谁该睡在“低地址”的抽屉里?
这就是大小端序要解决的根本问题。
什么是大端序和小端序?
我们用一个非常直观的例子来解释。假设你有一个 32 位的十六进制整数:0x12345678。这个数字有 4 个字节:
-
12 是高位字节(权重最大,像百位十位) -
78 是低位字节(权重最小,像个位)
现在我们要把它们存入从 0x0001 到 0x0004 的 4 个连续内存抽屉中:
大端序 (Big-Endian):先存大头(高位在前)
大端序最符合人类的直觉。写字时我们先写大数(左边),存内存时我们也把高位字节存放在低地址:
| 内存地址 | 0x0001 | 0x0002 | 0x0003 | 0x0004 |
|---|---|---|---|---|
| 存放的数据 | 12 |
34 |
56 |
78 |
口诀: 高位字节存放在低地址。你从内存地址从小到大读,刚好就是
12 34 56 78,顺着读。
小端序 (Little-Endian):先存小头(低位在前)
小端序则相反,它把低位字节存放在低地址:
| 内存地址 | 0x0001 | 0x0002 | 0x0003 | 0x0004 |
|---|---|---|---|---|
| 存放的数据 | 78 |
56 |
34 |
12 |
口诀: 低位字节存放在低地址。你从内存地址从小到大读,顺序是逆序的
78 56 34 12。
-
大端序的好处(直观、便于判断):
- 符号判定极快:因为最高位(包含符号位)就在第一个字节(最低地址),CPU 想要判断这个数是正数还是负数,只需要看第一个字节的最高位即可,不需要读完整个数。
- 符合人类阅读习惯:网络传输、内存直接打印时,大端序的数据排布和人类书写完全一致,调试网络数据包时非常省心。
-
小端序的好处(便于强制类型转换):
-
类型转换极快:还记得我们刚才讨论的“8位扩展到16位”或者反过来“32位强制截断到8位”吗?
假设你要把一个 32 位的0x00000008 截断成 8 位的char。- 在小端序里,
08 就在起始地址0x0001。CPU 甚至不需要移动指针,直接从0x0001 读取 1 字节,拿出来的就是08。 - 但在大端序里,
08 被存在了最后一个地址0x0004。CPU 必须先做地址偏移计算,挪动 3 个字节才能拿到它。
- 在小端序里,
-
数学运算方便:计算机做加法和乘法时,都是从“个位”(低位)开始算的。小端序先读到低位,硬件电路上可以直接边读边算,不用等高位读完。
-
硬件决定端序
- x86 和 x64 架构(Intel / AMD 芯片) :
它们是彻头彻尾的小端序(Little-Endian) 。因为个人电脑和现代服务器市场几乎被 x86 垄断,所以我们日常接触的 Windows、Linux、macOS,只要跑在 Intel/AMD 芯片上,在内存里都是小端序。 - ARM 架构(手机芯片、M系列 Mac 芯片) :
ARM 芯片设计得非常鸡贼,它支持双端序(Bi-Endian) 。也就是说它既支持大端也支持小端,但在实际使用中,我们的 iOS、Android、macOS (ARM) 都会把它配置为小端序运行,以此和 x86 世界保持软件上的兼容。 - 网络传输(网络字节序) :
TCP/IP 协议族规定:网络数据传输必须一律使用大端序。所以,当一个小端序的 x86 电脑要发数据给另一台电脑时,操作系统必须先调用汇编指令(如htons、htonl)将内存中的“小端数据”翻转成“大端数据”再发出去。