了解CPU
一个典型的 CPU 核心(Core)内部由许多协同工作的组件组成,主要可以分为 运算单元、控制单元、存储单元 三大部分。
1. 运算单元:ALU(Arithmetic Logic Unit)
加法器只是 ALU 的一部分。ALU 是 CPU 的“大脑实验室”,它负责所有的数学和逻辑判断:
- 乘法器/除法器 (Multiplier/Divider): 虽然可以用加法模拟乘法,但为了性能,现代 CPU 有专门的硬件电路处理高精度的乘除运算。
- 位移器 (Shifter): 专门负责二进制位的左移、右移。这在处理数据对齐、位运算(Bitwise operations)时速度极快。
- 逻辑门 (Logic Gates): 处理
AND、OR、XOR、NOT等逻辑判断。
2. 控制单元:CU(Control Unit)
如果 ALU 是“工人”,那么控制单元就是“工头”。它不干具体的活,但负责指挥:
- 指令寄存器 (IR): 存放当前正在执行的指令。
- 指令译码器 (Instruction Decoder): 计算机看不懂你写的 Go 代码,它只能看二进制指令。译码器负责把指令“翻译”成电信号,告诉 ALU 该加法还是该移位。
- 程序计数器 (PC / Instruction Pointer): 记录下一条指令在内存里的地址。如果没有它,程序就不知道下一步该往哪跑。
3. 存储单元:寄存器(Registers)
这是 CPU 内部速度最快、成本最高的“记事本”。
- 通用寄存器 (GPRs): 存放临时运算数据。比如
a = b + c,CPU 会先从内存把b和c搬到寄存器里。 - 状态寄存器 (Flags / Status Register): 记录运算后的“后遗症”。比如刚才的加法是否溢出(Overflow)、结果是否为 0、是否产生了进位(Carry)。
4. 现代 CPU 的“特种部队”
除了上述经典架构,现代 CPU 还有很多强大的辅助模块:
- FPU (Floating Point Unit): 专门处理小数(浮点数)运算。以前是独立的芯片,现在都集成在 CPU 内部。
- Cache (L1/L2/L3): 缓存。因为内存(RAM)太慢,CPU 内部会自带三级缓存来存放常用数据,减少等待。
- 分支预测器 (Branch Predictor): CPU 里的“占卜师”。它会预测你的
if-else会走哪一边,并提前把那边的指令加载好,以提高运行效率。
总结:CPU 的“指挥体系”
你可以把 CPU 想象成一个高效的工厂流水线:
- PC 告诉你去哪拿图纸(取指)。
- Decoder 翻译图纸(译码)。
- Registers 准备好原材料(取数)。
- ALU 开始干活,可能是加法器,也可能是位移器(执行)。
- Status Register 汇报干得怎么样(更新状态)。
时钟频率: 时钟频率就是“快门”按下的频率,而 CPU 就像是在拍一部极其高速的定格动画。
负数怎么表示? 或者怎么让CPU做减法?
让 CPU 只用“加法器”就能做减法,从而简化硬件设计。
True Form(原码), One’s Complement(反码), Two’s Complement(补码)
| Number | True Form | One’s complement | Two’s complement |
|---|---|---|---|
| 7 | 0111 | 0111 | 0111 |
| 0 | 0000 | 1111 | 0000 |
| -3 | 1011 | 1100 | 1101 |
| 1 | 0001 | 0001 | 0001 |
| -1 | 1001 | 1110 | 1111 |
反码与补码总结
- 反码 (One’s complement):对二进制各位取反(0→1,1→0)。负数的反码是其正数按位取反。反码存在两个零(+0 和 -0),加法时要处理端回圈加(end-around carry)。
- 补码 (Two’s complement):先对各位取反,再加 1;也可视为模 $2^N$ 的补数($2^N - x$)。补码只有一个零,数值范围为 $-2^{N-1} \dots 2^{N-1}-1$。补码的优点是加减可统一为普通二进制加法,硬件实现简单,故被广泛采用
补码的本质是同余运算, 为什么要加1?先只从数学角度思考
1. 补码的本质:找“补数”
在数学上,补码(Two’s Complement)的逻辑是:负数 $x$ 的补码 = 模 $M - |x|$。
想象一个只有 4 位的计算机,它能表示的最大状态数是 $2^4 = 16$。这里的 16 就是“模” ($M$)。 如果你想计算 $3 - 7$:
- 在模 16 的系统中,减去 7 等同于加上它的补数:$16 - 7 = 9$。
- 所以 $3 - 7 \equiv 3 + 9 = 12 \pmod{16}$。
2. 为什么会有“加 1”这个动作?
既然公式是 $M - |x|$,那么在二进制里怎么实现这个减法呢?
假设我们要算 $-7$ 的 8 位补码:
- 模 $M = 2^8 = 256$(二进制是
1 0000 0000)。 - 我们需要算:
1 0000 0000-0000 0111($7$)。
直接做这个减法很麻烦,因为涉及连续借位。数学家发现了一个技巧:
- 先用 “全 1” 的数去减(即 $2^8 - 1 = 255$,二进制是
1111 1111)。 1111 1111-0000 0111=1111 1000。
注意: 用全 1 去减,等同于按位取反(这就是反码的来源)。
- 但我们要的是减去 $256$,而刚才只减去了 $255$,还差一个 $1$。
- 所以,最后必须把这欠下的 $1$ 加回去。
结论:
- 取反:是为了避开借位,快速得到 $M - 1 - |x|$。
- 加 1:是为了修正误差,补齐到完整的模 $M$。
3. “加 1”解决的致命问题:正负零
如果不加这个 1(只停留在反码阶段),会出现一个数学灾难:
在 4 位系统中:
- $+0$ 是
0000 - $-0$(按位取反)是
1111 - 这样 $0$ 就有了两个表示方式,导致 $1 + (-1)$ 的结果是
1111(负零),而不是真正的零。
当我们加上 1 后:
1111 (反码) $+ 1 = 10000$。
在 4 位系统里,最高位的 1 溢出被丢弃,结果变成了 0000。
4. 总结
“加 1”这个动作,在逻辑上是为了补全模运算的缺口,在工程上是为了消除“负零”,并让整个数轴可以连续。
正是因为这多出来的“加 1”,补码系统比原码多出了一个可以表示的数字:
- 8 位原码范围:$-127$ 到 $+127$
- 8 位补码范围:$-128$ 到 $+127$(那个
-128就是利用了原本重复的0的位置)。
你想看看 $-128$ 这个特殊的补码在内存里长什么样吗?(它在 8 位系统里非常特殊,取反加一后还是它自己)。
位运算(Go / Java)
常见位运算:按位与、按位或、按位异或 、按位取反、左移、右移
按位与 &、按位或 |、按位异或 ^、按位取反(Go 用一元 ^、Java 用 ~)、左移 <<、右移 >>(算术右移,保留符号位)。Java 另有无符号右移 >>>;在 Go 中可通过先转换为无符号类型再右移来实现逻辑右移。
常用技巧:
- 取第 n 位(0 起):
(x >> n) & 1 - 置位第 n 位:
x |= 1 << n - 清零第 n 位:Go:
x &^= 1 << n或x &= ^(1<<n);Java:x &= ~(1 << n) - 翻转第 n 位:
x ^= 1 << n
示例 — Go:
package main
import "fmt"
func main() {
var a int32 = 5 // 00000101
var b int32 = -5 // 补码表示
fmt.Printf("a = %08b %d\n", a, a)
fmt.Printf("b = %08b %d\n", b, b)
fmt.Printf("a&b = %08b %d\n", a&b, a&b)
fmt.Printf("a|b = %08b %d\n", a|b, a|b)
fmt.Printf("a^b = %08b %d\n", a^b, a^b)
fmt.Printf("^a = %08b %d\n", ^a, ^a) // 一元 ^ 为按位取反
fmt.Printf("a<<1 = %08b %d\n", a<<1, a<<1)
fmt.Printf("b>>1 = %08b %d\n", b>>1, b>>1) // 算术右移(保留符号)
fmt.Printf("uint32(b)>>1 = %08b %d\n", uint32(b)>>1, uint32(b)>>1) // 逻辑右移示例
}
示例 — Java:
public class Bits {
public static void main(String[] args) {
int a = 5;
int b = -5;
System.out.println(Integer.toBinaryString(a));
System.out.println(Integer.toBinaryString(b));
System.out.println("a&b: " + (a & b));
System.out.println("a|b: " + (a | b));
System.out.println("a^b: " + (a ^ b));
System.out.println("~a: " + (~a)); // 按位取反
System.out.println("a<<1: " + (a << 1));
System.out.println("a>>1: " + (a >> 1)); // 算术右移
System.out.println("a>>>1: " + (a >>> 1)); // 逻辑右移
}
}
示例:用补码得到负数
- 补码求负:
-x == (^x) + 1(按位取反再加 1)。在 Go/Java 中,这在整数算术中自然成立。
以上示例可直接复制到本地运行以观察二进制表示与位运算效果。
浮点数的精度与高精度计算
浮点数存储和计算的误差
浮点数(float/double)在存储和计算时都不是完全精确的:
- 绝大多数十进制小数(如 0.1)无法用有限二进制位精确表示,只能近似存储。
- 浮点数运算会产生舍入误差,误差可能逐步累积。
- IEEE 754 标准本质是“近似表示”,适合科学计算、工程模拟等允许误差的场景,不适合金融、计费等要求绝对精确的应用。
如何实现精确计算
- 定点数方案:将金额等数据放大为整数(如元转为分),全部用整数类型计算,最后再缩放回去。
- 高精度库:如 Python 的
decimal.Decimal、Java 的BigDecimal、Go 的math/big包等,支持任意精度小数运算,避免二进制误差。 - 避免直接用 float/double 进行金额、计数等敏感计算。
浮点数转高精度类型的注意事项
- 从 float/double 转为高精度类型(如 Java 的 BigDecimal、Go 的 big.Float),通常需要指定精度和舍入方式(如四舍五入)。
- 推荐优先用字符串构造高精度类型(如
new BigDecimal("0.1")或big.NewFloat("0.1")),这样不会引入二进制误差。 - 如果必须从 float/double 转换,建议明确指定精度和舍入方式。
Java 示例
// 推荐:用字符串构造,精确
BigDecimal bd1 = new BigDecimal("0.1");
// 不推荐:用 double 构造,可能有误差
BigDecimal bd2 = new BigDecimal(0.1);
// 设置精度和四舍五入
bd1 = bd1.setScale(2, RoundingMode.HALF_UP); // 保留2位小数,四舍五入
Java 默认不会自动四舍五入,只有调用 setScale 并指定舍入模式时才会四舍五入。
Go 示例
import "math/big"
// 推荐:用字符串构造,精确
f, _, _ := big.NewFloat(0).Parse("0.1", 10)
// 不推荐:用 float64 构造,可能有误差
f2 := big.NewFloat(0.1)
// 设置精度
f.SetPrec(53) // 53位二进制精度(约16位十进制)
// 格式化输出
f.Text('f', 2) // 保留2位小数
Go 构造 big.Float 时不会自动四舍五入,需手动设置精度和格式化输出。
简化建议
- 如果数据源本身是字符串(如 “0.1234”),直接用字符串初始化高精度类型,能避免二进制误差和多余步骤。
- 若必须从 float64 导入,步骤无法再简化,因为 float64 本身已近似,big.Float 只是“还原”其二进制值。
浮点数用于表示小数和极大/极小的实数范围。现代计算机普遍采用 IEEE 754 标准。
1. 浮点数的结构(以 32 位单精度为例)
- 符号位(S):1 位,0 表示正数,1 表示负数
- 指数位(E):8 位,存储“移码”指数(实际指数 = 存储值 - 127)
- 尾数/有效数(M):23 位,存储小数部分(有隐含的最高位 1)
二进制结构:
| S | E | M |
|---|-------|-------------------|
| 1 | 8 bit | 23 bit |
数值计算公式:
$$ (-1)^S \times 1.M \times 2^{E-127} $$
2. 特殊值
- 全 0 指数:表示 0 或“非规格化数”(Denormalized)
- 全 1 指数:表示无穷大(Inf)、非数(NaN)
3. 常见陷阱
- 精度丢失:如 0.1 无法精确表示,导致 $0.1 + 0.2 \neq 0.3$
- 舍入误差:浮点数运算结果可能略有偏差
- 比较大小要小心:判断相等应用“误差容忍”
4. 示例
以十进制 0.15625 为例:
- 转二进制:$0.00101$
- 规格化:$1.01 \times 2^{-3}$
- S=0, E=124 (127-3), M=010…0
5. 扩展
- 64 位双精度(double):1+11+52 位,精度更高
- 浮点数不是“精确数学”,金融等场景需用定点数或高精度库
double 近似值的唯一性和固定性
为什么 0.1 这样的十进制字面量在 double 类型下总是“固定”的?
- double 类型(IEEE 754 标准)能表示的二进制小数是有限且唯一的。
- 对于任意一个十进制字面量(如 0.1),编译器会把它转换成 double 能表示的、最接近它的那个二进制数,这个过程是确定性的。
- double 有 64 位,每个十进制数都只能映射到唯一的 double 二进制表示。
- 所以 0.1 这个字面量,无论你在哪里写,都会被转换成同一个 double 近似值,结果是固定的。
结论: 因为 double 的存储格式和转换算法是标准且唯一的,所以同一个十进制字面量总会得到同一个 double 近似值。