csapp 深入理解计算机系统(二)

595 阅读12分钟

信息的存储

通常情况下,程序将内存视为一个非常大的数组,数组的元素是由一个个的字节组成,每个字节都由一个唯一的数字来表示,我们称为地址。这些所有的地址集合就称为虚拟地址空间。

1.PNG

Byte

字节,信息存储的基本单位。1个字节由8个位组成,在二进制表示法中,每一个位的值可能有两种状态,0或者1。当这8个位全为0,表示一个字节的最小值。当这8个位全为1时,表示最大值。用十进制来表示,一个字节的取值范围就在0~255之间。我们把这种按照一位一位表示数据的方式称为位模式。使用二进制表示法比较冗长,而十进制表示法与位模式之间的转换又比较麻烦。因此,我们引入十六进制数来表示位模式。

我们熟悉的十进制,是由数字0~9组成的。对于十六进制数,则是由数字0~9和字母A~F来表示16个可能的数值。在C语言中,十六进制是以0x开头,这个X可以是小写,也可以是大写。

2.PNG

二进制转十六进制,具体请查阅原书。

我们看一下如何将形如2的N次方的数快速转成十六进制数。

3.png

其中 i 取值 0、1、2、3,对应hex 为 1、2、3、8。

举个例子:

n = 11 = 3 + 2 * 4
2^11 = 0x800

Words

字长(word size)决定了虚拟地址空间的最大可以到多少,对于一个字长为w的机器,虚拟地址空间的大小为0~2^w-1,目前大多数机器都是64位字长的机器。

64位的机器做了向后兼容,因此为32位机器编译的程序也可以运行在64位机器上。在64位机器上,可以通过这条命令编译生成可以在32位机器上运行的程序。

// 32-bit program
gcc -m32 -o hello32 hello.c

// 64-bit program
gcc -m64 -o hello64 hello.c

注意,hello32既可以运行在32位机器上,也可以运行在64位机器上。

对于32位程序和64位程序,主要的区别还是在于程序是如何编译的,而不是运行机器的类型。

C语言中,支持整数和浮点数多种数据格式。

signedunsigned32-bit64-bit
[signed]charunsigned char11
shortunsigned short22
intunsigned44
longunsigned long48
int32_tuint32_t44
int64_tuint64_t88
char*48
float44
double88

Addressing and Byte Ordering

以0x01234567为例

大端法

4.PNG

小端法

5.PNG

此外,虽然整型和浮点数都是对数值12345进行编码,但是它们却有着完全不同的字节模式。

TypeValueHex
int123450x00003039
float12345.00x00e44046

用二进制的形式来表示

0x00003039

0000 0000 0000 0000 0011 0000 0011 1001

0100 0110 0100 0000 1110 0100 0000 0000

0x00e44046

对其进行移位,我们会发现有一个13位的匹配序列。这是不是一个巧合呢?后续在浮点数部分会进行解答。

6.PNG

字符串表示

C语言中字符串被编码为以NULL字符串结尾的字符数组。例如,

const char *s = "abcde";

这个字符串虽然只有5个字符,但是长度却为6,就是因为结尾字符的存在。用十六进制表示就是

61 62 63 64 65 00

使用ASCII码来表示字符,在任何系统上都会得到相同的结果。因此,文本数据比二进制数据具有更强的平台独立性。

布尔运算

~
01
10
&01
000
101
|01
001
111
^01
001
110

C语言中的一个特性就是支持按位进行布尔运算。

掩码操作

通过位运算可以得到特定的位序列。例如,通过&0xFF,就可以得到最低有效字节0x0000 00EF。

7.PNG

移位操作

左移

将最高位丢弃移动位数,同时在右侧填充相应位数的0。例如,8位二进制数0110 0011,左移一位就是丢弃最高的1位,并在右端补一个0。

右移

分为逻辑右移和算术右移。逻辑右移就是和左移方向相反,将右侧丢弃移动位数,在左侧用0进行填充。算术右移,当算术右移的操作对象的最高位等于0时,算术右移和逻辑右移是一样的,没有任何差别。但是当操作数的最高位为1时,算术右移之后,左端需要补1,而不是补0。

虽然C语言中并没有明确的规定有符号数应该使用哪一种类型的右移方式。但是实际上,几乎所有的编译器以及机器的组合都是对有符号数使用算术右移。对于无符号数,右移一定是逻辑右移。

整数的表示

C data typeMinimumMaximumBytes
[signed] char-2^72^7-11
[unsigned] char02^8-11
short-2^152^15-12
unsigned short02^16-12
int-2^312^31-14
unsigned02^32-14
long-2^632^63-18
unsigned02^64-18
int32_t-2^312^31-14
uint32_t02^32-14
int64_t-2^632^63-18
uint64_t02^64-18

long类型的大小需要注意一下,这个类型的取值范围是与机器字长相关的。

在64位机器上,long类型占8个字节。在32位机器上,long类型只占4个字节。

无符号数的编码方式

x=[xw1,xw2,,x0]\vec{x} = [x_{w-1}, x_{w-2}, \cdots, x_0]

8.PNG

B2Uw(x)=xw12w1+xw22w2++x020=i=0w1xi2iB2U_w(\vec{x})=x_{w-1}\cdot2^{w-1} + x_{w-2}\cdot2^{w-2}+\cdots+x_0\cdot2^0=\sum^{w-1}_{i=0}x_i2^i

有符号数的编码

补码(two's-complement)

B2T2(x)=xw12w1+i=0w2xi2iB2T_2(\vec{x})=-x_{w-1}2^{w-1} + \sum^{w-2}_{i=0}x_i2^i

这里需要注意最高位的权重是-2^(w-1)次方,当最高位等于1时,表示负数;最高位等于0时,表示非负数。

无符号数与有符号数的转换

举个例子:

short int a = -12345;
unsigned short b = (unsigned short)a;
printf("a = %d, b = %u", a, b);

结果: a = -12345, b = 53191

二进制表示

-12345: 1100 1111 1100 0111

53191: 1100 1111 1100 0111

对于大多数c语言的实现,有符号和无符号之间的转换规则是:

位模式不变,但是解释这些位的方式改变了。

接下来,我们看一下,在位数相同的情况下无符号数与有符号之间的转换:

B2UwB2Tw=xw12w1xw12w1=xw12wB2U_{w}-B2T_{w} = x_{w-1}\cdot2^{w-1}-x_{w-1}\cdot-2^{w-1} = x_{w-1}\cdot2^w
B2Uw=B2Tw+xw12wB2U_w = B2T_w + x_{w-1}\cdot2^w
T2Uw(x)={x+2w,x<0x,x0T2U_w(x)= \left\{ \begin{aligned} x + 2^w, x<0 \\ x, x \ge 0 \end{aligned} \right.
B2Tw=B2Uwxw12wB2T_w = B2U_w-x_{w-1}\cdot2^w
U2Tw(u)={u,uTMaxwu2w,u>TMaxwU2T_w(u)=\left\{ \begin{aligned} u, u \le TMax_{w} \\ u - 2^w, u > TMax_{w} \end{aligned} \right.

在C语言中,在执行一个运算时,如果一个运算数是有符号数,另一个运算数是无符号数,那么C语言会隐式的将有符号数强制转换成无符号数来执行运算。

c语言中还有一个常见的运算是在不同字长的整数之间进行转换。

  • 将一个无符号数转换成一个更大的数据类型

    只需要在扩展的数位进行补零即可

  • 将有符号数转换成一个更大的数据类型

    需要执行符号位扩展,这个符号位就是最高位。当有符号数表示非负数时,最高位是0,此时扩展的数位进行补零即可。当有符号数表示负数时,最高位是1,此时扩展的数位需要进行补1。

  • 较大数据类型转换成较小类型的情况

    直接从高位进行舍弃,然后按当前的解释方式来解释剩余位数

整数的运算

无符号数加法

先看一个例子:

unsigned char a = 255;
unsigned char b = 1;

unsigned char c = a + b;
printf("c=%d", c);

// 我们期望是256
// 实际是0

产生这个结果的原因是a加b的和超过了unsigned char类型所能表示的最大值255,这种情况称为溢出。

对于x和y,0 <= x < 2^w, 0 <= y < 2^w

x+wuy={x+y,x+y<2wx+y2w,2wx+y<2w+1x +^u_wy= \left\{ \begin{aligned} x + y, x+y<2^w \\ x + y - 2^w, 2^w \le x + y < 2^{w+1} \end{aligned} \right.

对于无符号整数判断溢出

int uadd_ok(unsigned x, unsigned y)
{
    unsigned sum = x + y;
    if(sum >= x)
        return 1;
    else
        return 0;
}

数学证明

0x<2w,0y<2wx+yx,x+yyoverflow:x+y2wy2w<0x+y2w<x0 \le x < 2^w, 0 \le y < 2^w \\ x + y \ge x, x + y \ge y \\ overflow: x + y - 2^w \\ y - 2^w < 0 \\ x + y - 2^w < x

可以证明,当发生溢出时,得到的和小于其中任意一个数(x或者y)。

有符号数加法

2w1x<2w11,2w1y<2w11-2^{w-1} \le x < 2^{w-1}-1, -2^{w-1} \le y < 2^{w-1}-1
x+wty={x+y2w,2w1x+yx+y,2w1x+y<2w1x+y+2w,x+y<2w1x +^t_wy= \left\{ \begin{aligned} x + y - 2^w, 2^{w-1} \le x + y \\ x + y, -2^{w-1} \le x + y < 2^{w-1} \\ x + y + 2^w, x + y < -2^{w-1} \end{aligned} \right.

溢出判断

x0,y0x+y<0x \ge 0, y \ge 0 \\ x + y < 0
x0,y0x+y>0x \le 0, y \le 0 \\ x + y > 0

减法

对于x, 0 <= x < 2^w

x+x=x+x=0x + x^{'} = x^{'} + x = 0

x‘为x的加法逆元。

对于无符号数,0 <= x < 2^w,0 <= x' < 2^w

wux={x,x=02wx,x0-^u_wx = \left\{ \begin{aligned} x, x = 0 \\ 2^w - x, x \ge 0 \end{aligned} \right.

对于有符号数, -2^{w-1} <= x < 2^{w-1} - 1

wtx={x,x>TMinwTMinw,x=TMinw-^t_wx = \left\{ \begin{aligned} -x, x > TMin_w \\ TMin_w, x = TMin_w \end{aligned} \right.

无符号数乘法

w位的x和y

(xy)mod2w(x \cdot y) mod 2^w

补码的乘法

w位的x和y

U2Tw((xy)mod2w)U2T_w((x \cdot y) mod 2^w)

假设x和y表示有符号数,x'和y'表示无符号数,x与x‘的二进制表示相同,y与y’的二进制表示相同。

x=x+xw12wy=y+yw12wx' = x + x_{w-1} \cdot 2^w \\ y' = y + y_{w-1} \cdot 2^w
(xy)mod2w=[(x+xw12w)(y+yw12w)]mod2w=[xy+(xw1y+yw1x)2w+xw1yw122w]mod2w=(xy)mod2w(x' \cdot y') mod 2^w = [(x + x_{w-1} \cdot 2^w) \cdot (y+y_{w-1} \cdot 2^w)] mod 2^w \\ = [x \cdot y + (x_{w-1}y + y_{w-1}x)2^w + x_{w-1}y_{w-1}2^{2w}] mod 2^w \\ = (x \cdot y) mod 2^w

虽然无符号数和补码两种乘法乘积的完整位表示不同,但是截断之后结果的位级表示却相同。

由于乘法指令的执行需要多个时钟周期,很多C语言的编译器试图用移位、加法以及减法来代替整数乘法的操作。

x2kx<<kx \cdot 2^k \rightarrow x << k

举个例子

x14x * 14
x14=x(23+22+21)=(x<<3)+(x<<2)+(x<<1)=x(2421)x \cdot 14 = x \cdot (2^3 + 2^2 + 2^1) \\ = (x << 3) + (x << 2) + (x << 1) \\ = x \cdot (2^4-2^1)

对于除法,无符号数采用的是逻辑右移,而有符号数采用的是算术右移。

无符号整数的除法,还会遇到除不尽的情况,总是朝向0的方向进行舍入。

对于补码除法,需要特别注意一下。

例如,对-12340/(2^4)时,移位导致-771.25向下舍入为-772。根据整数除法向零舍入的原则,我们期望得到的结果是-771。因此,移位之前需要加入一个偏置,来修正这种不合适的舍入。其中,偏置的值为1左移k位减去1。

对于补码除以2的k次幂的情况,当x小于0时,需要先加上偏置,再进行算术右移。对于x大于0的情况,可以直接进行算术右移。

(x < 0 ? x + (1 << k) - 1 : x) >> k

浮点数

含有小数值得二进制数

dmdm1d1d2dnd_m d_{m-1} \cdots d_1 d_2 \cdots d_{-n}
b=i=nm2i×bib = \sum^m_{i=-n} 2^i \times b_i

但是对于这种定点表示方法,并不能很有效的表示非常大的数。

IEEE浮点数的表示

V=(1)s×M×2EV = (-1)^s \times M \times 2^E

单精度浮点数float

9.PNG

其中最高位31位表示符号位s,当s=0时,表示正数;s=1时,则表示负数。从第23位到第30位,这8个二进制位与阶码的值是相关的。剩余的23位与尾数M是相关的。

双精度浮点数double

10.PNG

浮点数的分类

分类是由阶码来决定的

  1. Normalized Values(规格化的值)

11.PNG

其中e的最小值为1,最大值为254。阶码真正的表示是E = e-bias

bias(float)=2811=127bias(double)=21111=1023bias(float) = 2^{8-1} - 1 = 127 \\ bias(double) = 2^{11-1} - 1 = 1023

尾数M被定义为1+f

M=1.f22f21f1f0=1+fM = 1.f_{22}f_{21} \cdots f_1 f_0 = 1 + f
  1. Denormalized Values(非规格化的值)

12.PNG

关于非规格化的数有两个用途,一是提供了表示数值0的方法。当符号位s等于0,阶码字段全为0,小数字段也全为0时,此时表示正零;而当s等于1时,表示负零。二是表示非常接近0的数,当阶码字段全为0时,阶码E的值等于1-bias,M=f,不包含隐藏的1。

  1. Special Values(特殊值)

特殊值分为两类,一类表示无穷大或者无穷小,另外一类表示“不是一个数”

无穷

13.PNG

s为0表示无穷大,为1表示无穷小

NaN

14.PNG

当运算结果不为实数或者用无穷也无法表示的情况,用NaN表示。

接下来,我们来看看之前的问题,12345和12345.0的二进制表示,为什么会有13位匹配序列。

6.PNG

首先,我们将整型数12345转换成浮点数12345.0。整型数12345的二进制表示为 11 0000 0011 1001,即

1.1000000111001×2131.1 0000 0011 1001 \times 2^{13}

根据IEEE的表示法,我们将首个1丢弃,E=127 + 13,最终的表示如下所示

15.PNG

由于表示方法的原因,限制了浮点数的范围和精度,所以浮点运算只能近似的表示实数运算。

对于值x,可能无法用浮点形式来精确的表示,因此我们希望可以找到“最接近的值” x'来代替x。

一个关键的问题就是在两个可能的值中间确定舍入方向。例如,x' = 1 or 2 ?

IEEE浮点格式定义了四种不同的舍入方式

  1. Round-to-even (向偶数舍入)
  2. Round-toward-zero(向零舍入)
  3. Round-down(向下舍入)
  4. Round-up(向上舍入)
Mode1.401.601.502.50-1.50
Round-down1112-2
Round-up2223-1

Round-toward-zero 把正数进行向下舍入,把负数进行向上舍入

第四种舍入方式就是向偶数舍入,也被称为向最接近的值进行舍入。

Mode1.401.601.502.50-1.50
Round-to-even12

当遇到两个可能结果的中间数值时,舍入结果应该如何计算。向偶数舍入的舍入结果要遵循,最低有效数字是偶数的规则。因此1.5的舍入规则究竟是1还是2,取决于1和2哪个数是偶数。

分析一下,如果总是采用向上舍入,会导致结果的平均值相对于真实值略高;如果总是采用向下舍入,会导致结果的平均值相对于真实值略低。向偶数舍入就避免了这种统计偏差,使得有一半的情况需要向上舍入,有一半的情况需要向下舍入。对于不想舍入到整数的情况,向偶数舍入的方法同样适用。我们只需要考虑最低有效位是偶数还是奇数即可。

来看个例子,1.2349999和1.2350001,由于两个数并不在1.23和1.24正中间,所以两个数的舍入结果分别是1.23和1.24,并不需要考虑百分位是否是偶数。

再来看另外一组数,1.2350000和1.2450000,由于1.235在1.23与1.24中间,这时我们需要考虑百分位是否是偶数的情况,舍入结果都是1.24。

类似的情况,也可以用在二进制小数上。

将最低有效位的值0认为是偶数,1认为是奇数。例如,10.11100保留小数点后两位的结果是11.00。

浮点数加法

  1. (3.14 + 1e10) - 1e10 = 0.0
  2. 3.14 + (1e10 - 1e10) = 3.14

这是由于表达式1在计算3.14与1e10相加时,对结果进行舍入,值3.14会丢失。因此,对于浮点数的加法是不具有结合性的。同样由于计算结果可能发生溢出,或者由于舍入而失去精度,导致浮点数的乘法也不具有结合性。

  1. (1e20 * 1e20) * 1e-20 = ++\infty
  2. 1e20 * (1e20 * 1e-20) = 1e20

此外,浮点数乘法在加法上不具备分配性

  1. 1e20 * (1e20 - 1e20) = 0.0
  2. 1e20 * 1e20 - 1e20 * 1e20 = NaN

对于从事科学计算的程序员以及编译器的开发人员来说,缺乏结合性和分配性是一个比较严重的问题。

数据类型之间的转换

  1. int -> float

    数字不会发生溢出,但是可能会被舍入。这是由于单精度浮点数的小数字段是23位,可能会出现无法保留精度的情况。

  2. int/float -> double

    由于double类型具有更大的范围,所以可以保留精确的数值。

  3. double -> float

    由于float类型所表示数值的范围更小,所以可能会发生溢出。此外,由于float类型的精度相对于double较小,转换后还可能被舍入。

  4. float/double -> int

    一种可能的情况是值会向零舍入,例如1.9将被转换成1,-1.9将被转换成-1。另外一种可能的情况是发生溢出。