CSAPP - 学习笔记 (1): 字节与整数

摘要

最近开了个新坑,打算学习一下 CSAPP 和 Mit S6.081,会把一些学习笔记和实验报告更新在博客上。CSAPP(Computer Systems:A Programmer’s Perspective),即从程序员的角度学习计算机系统,是 CMU 的一门名课,配套同名黑书教材,译为《深入了解计算机系统》。

这一节是 CSAPP 的第一节,包含了整数表示、操作等。

下面是一些有用的链接,供想学习的同学参考:

逻辑运算

逻辑运算:&&, ||, ! 特点:

  • 0 被认为是 false
  • 非 0 值被认为是 true
  • 总是返回 true 或 false
  • 短路求值
    • 短路求值一种友好的特性,它的优点是:
  • 可以用于前置判断,避免非法操作
    • 例如,p&&*p,会先判断指针 p 地址合法,再去访问,避免错误
  • 可以减少无用操作
    • 例如,1||func() 永远返回 true,无需调用 func 函数,可以提高效率 当然,短路求值也有一定的缺点
  • 基于跳转法实现的,短路求值需要增加很多跳转指令,会把代码块拆分为更小的基本块,给优化带来挑战
    • 短路求值的关键是:若结果已确定则直接跳出,否则继续计算结果。需要在每次求解后插入跳转指令。
    • 编译优化算法一般以跳转 / 分支拆分得到的基本块为单位,进行块内和块间优化,基本块越小、数量越多,越不利于优化 如果逻辑操作无副作用,那么可以直接使用算术法,相当于不严格按照短路求值实现,但由于无副作用,所以结果是相同的。

可参考 逻辑表达式的短路是如何实现的? - 知乎 (zhihu.com)

移位

左移: x<<y

左移总是相同的,例如二进制串 "011000010" 左移 3 位后得到 "00010000",低位用 0 补充,溢出的高位消失不见,规则同时适用于正负数。

右移: x>>y

右边的多余低位被丢弃,左侧的填充规则有两种:

  • 逻辑右移:使用 0 填充高位
  • 算数右移:使用符号位填充高位 例如串 "10100010",右移 3 位的结果分别是:
  • 逻辑右移:00101000
  • 算数右移:11101000 在 C/C++ 语言中,右移均使用 ">>" 操作符,编译器对有符号数进行算数右移,对无符号数进行逻辑右移 在 Java 中,算数右移对应 ">>" 操作符,逻辑右移使用 “>>>”。而且 Java 中只存在有符号数。

未定义行为

如果把一个 8 位的数 x,左移 8 位,会发生什么?

  • 直觉来看应该是 0
  • 在大多数机器上,会得到 x 本身,因为移位的长度会对数长取模,8%8=0,即左移 0 位
  • 这是一种未定义行为,不应该依赖它的结果

整数表示

无符号数

对于 w 位的无符号整数,最小值为 0,最大值为 \(2^w-1\),所有位都被解释为正数权重,从高到底依次为 \(2^{w-1},2^{w-2},...,2^0\),数字的值为 \[ B2U(X)=sum_{i=0}^{w-1}x_i\cdot 2^i \] \(B2U(X)\) 表示将比特串 X 转换为无符号数的数值。

有符号数

几乎所有的现代机器都使用补码表示。

补码

补码(Two's Complement),使用最高位作为符号位,权重为 \(-2^{w-1}\),剩余位与无符号数的权重相同。 \[ B2T(X)=-x_{w-1}\cdot 2^{w-1}+sum_{i=0}^{w-2}x_i\cdot 2^i \] 在补码场景下,负数最小值的表示为 \(T_{min}=10\cdots 0\),即只有负权重,负数最大值(-1)的表示为 \(11\cdots 1\),即全部为 1,正数最大值为 \(T_{max}=01\cdots 1\)。 w 位补码的表示范围为 \(-2^{w-1} \sim 2^{w-1}-1\),正数比负数少一个,这可能会导致各种边界错误。 补码标识下,正负数的转换,可以通过 取反 + 1 得到,例如 4 位数下,2 的表示为 "0010",按位取反得到 "1101",再 + 1 得到 “1110”,这就是 - 2.

  • 这是由于负数数量比正数多 1 个。
  • \(T_{min}\) 的相反数还是 \(T_{min}\)

除了补码,还有两种标准表示方法:

反码

反码(One's Complement),除了最高位的权是 \(-(2^{w-1}-1)\),不是 \(-2^{w-1}\),剩下的跟补码是一样的, \[ B2O(X)=-x_{w-1}\cdot (2^{w-1}-1)+sum_{i=0}^{w-2}x_i\cdot 2^i \]

原码

原码(Sign-Magnitude),最高有效位是符号位,用来确定剩下的位应该取负权还是正权: \[ B2S(X)=-(-1)^{x_{w-1}}\cdot sum_{i=0}^{w-2}x_i\cdot 2^i \] 这两种方法都有一个奇怪的属性,对于数字 0 有两种表示,把 \([00\cdots 0]\) 解释为 + 0,把 \([10\cdots 0]\) 解释为 - 0,虽然过去存在基于反码表示的机器,但几乎所有的现代机器都使用补码。浮点数中有使用原码。

转换

无符号数和符号数的转换遵循以下规则:

  • 保留位特征,即数字的比特串存储不会改变
  • 重新解释,根据比特的权重重新计算
  • 可能会有未期望的效果,例如,加或减 \(2^w\),即转换出现问题
  • 同时包含有符号和无符号数的表达式,有符号数会转换为无符号数 无符号数和有符号数进行比较 / 计算时,会将有符号数隐式转换为无符号数,例如 \(-1>0u\),在使用时要注意。 例如,下面的代码,无符号数 \(i\ge 0\) 恒成立,代码会数组越界或者死循环。
1
2
3
4
unsigned int i;
for (i = cnt-1; i >= 0; i--) {
func(a[i]);
}

另外,sizeof 的返回值也是无符号数。 上面的倒数计数的场景,需要改写成如下形式才能正常工作,它利用了溢出的特性,第一眼看上去会很反直觉。

1
2
3
4
unsigned int i;
for (i = cnt-1; i < cnt; i--) {
func(a[i]);
}

在 C 语言中,无符号数和有符号数的转换是隐式的,不会有任何的 warning 或者 error。而在 Go 语言中,需要显式对数值进行类型转换,避免了可能的问题。

扩展

如何把一个 w 位的数扩展为 w+k 位的数,数值不变?

有符号数

做法是,新增的高位复制符号位,剩余位保持不变。 例如 "1100",扩展增加 1 位,结果是 "11100"。观察可知,\(-2^3=-2^4+2^3\),最高位及更高位的权重和不变,整个结果不变。

无符号数

增加 0 即可。

截断

当一个 w+k 位的数去掉高 k 位时,对

  • 无符号数,等价于对 \(2^w\) 取模
  • 有符号数,可能正负会发生变化

整数运算

加法

考虑 w 位的两个数 u,v 相加

无符号数

等价于两数相加后截断只保留低 w 位(可能会有进位被丢弃) \[ s=UAdd_w(u,v)=(u+v)mod 2^w \] 例如两个 4 位数,\(13+5=18\ mod\ 16=2\)

补码加法

有符号的加法在比特特征上与无符号数相同,但是从表现上看,会出现两种情况:

  • 正数溢出:两个正数相加,超过 \(T_{max}\),变为负数
  • 负数溢出,两个负数相加,小于 \(T_{min}\),变为正数 减法可以转换为加法求解。

乘法

两个 w 位数相乘,可能需要至多 2w 位存储结果(考虑不可达的上界,两个 w+1 位的数相乘,最高位为 1,其余位时 0,结果为 2w+1 位)。 同样,需要截断取低 w 位,对无符号数来说,等价于取模。 对于有符号数,经过证明,其乘法与无符号数的乘法位级表示是等价的,同样可能会出现溢出现象。 由于乘法会消耗更多时钟周期(现代计算机需要 3 个),移位只需要 1 个时钟周期,编译器会对乘法进行优化。移位与无符号整数乘比特等价,进而与符号整数相乘等价。

除法

同样的,除法也可以优化为右移操作,由于整数除法总是舍入到 0,对于补码除法,需要引入偏置位进行修正。

无符号数使用建议

从前面转换一节讲的例子,可以看到无符号整数的使用可能会引发奇怪的问题。下面是一些使用无符号数的场景

  • 加密运算
  • 表示集合,bitmap 需要强调的是,“无符号数适用于非负值的场景” 是一种常见的误区,一个例子就是之前提到的倒排问题。在《Go 程序设计语言》中,作者也建议了一般不要使用无符号数,除非像加密算法这种特殊的场景。

大端与小端

多个字节存储时,根据字节在内存中的地址顺序,有两种存储方式,例如 0x01234567,

  • 大端,高位字节在先,01,23,45,67
    • 网络传输
  • 小端,低位字节在先,67,45,23,01
    • x86,ARM 处理器 大端优势是读取直观,但这对计算机来说无关紧要,计算机只需要一个统一的标准进行计算即可。