深入理解计算机系统 -- 信息的表示和处理
1. 信息的存储
大多数计算机使用 8 位的块,或者字节,作为最小的寻址内存单位,而非访问内存中单独的位,机器级程序将内存视为一个非常大的字节数组,称为 虚拟内存 ,内存的每个字节都用一个唯一的数字标识,称为它的 地址 。以 C 语言的指针为例,指针使用时指向某一个存储块的首字节的 虚拟地址 ,C 编译器将指针和其类型信息结合起来,这样即可以根据指针的类型,生成不同的机器级代码来访问存储在指针所指向位置处的值。每个程序对象可以简单视为一个字节块,而程序本身就是一个字节序列。
1.1 十六进制表示法
一个字节由 8 位组成。用二进制表示即 00000000 ~ 11111111 。十进制表示为 0 ~ 255 。由于两者表示要么过于冗余,要么转换不遍,因此通常使用十六进制来表示一个字节。这几种进制的转换在此就不多说了。
1.2 字数据大小
每台计算机都会有一个字长(此处字长非字节长度),指明 指针数据的标称大小(nominal size),因为虚拟地址是以这样的一个字来进行编码的,所以字长决定的最重要的一个系统参数即是虚拟地址空间的最大大小。 对于一个字长为 w 位的机器而言,虚拟地址的范围为 0 ~ (2 ^w )- 1 ,程序最多访问 2 ^ w 个字节。以 32 位机器为例,32位字长限制虚拟地址空间为 (2 ^32) -1 ,程序最多访问 2 ^ 32 个字节,大约为 4 x 10^9 字节,即4 GB ( 根据 2 ^ 10 (1024) 约等于 10 ^ 3 (1000) ,可以得到 2 ^ 32 = 4 * 2^30 = 4 * 10 ^ 9 ) 。64位机器的限制虚拟地址空间为 16 EB。大约为 1.84 x 10 ^9 。
1.3 寻址和字节顺序
对于跨越多个字节的对象,我们必须建立两个规则:这个对象的地址是什么以及在内存中如何排列这些字节。在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为这个字节序列中最小的字节地址。以 int 类型为例,假定int 大小为32 位,有变量 int x = 0x01234567 。若 x 的地址为 0x100 ,则 x 的 4 个字节将被存储在 0x100 , 0x101 , 0x102, 0x103 的位置,此时 4个字节的值分别为 0x01, 0x23, 0x45, 0x67,那么在内存中的排列顺序有如下两种情况,
- 大端法:最高有效字节放在最前面的方式称为大端法,即将一个数字的最高位字节放在最小的字节地址。
- 小端法:最低有效字节放在最前面的方式称为小端法,即将一个数字的最低位字节放在最小的字节地址。
以上面的 x 为例,x 的最高位字节是 0x01 ,将其放在最小的字节地址即 0x100。x 的最低位字节为 0x67 ,将其放在最小的字节地址 0x100 。即大小端对应高低位字节。对于我们来说,机器的字节顺序是完全不可见的,我们大部分情况下也无需关心其字节顺序,但是在不同类型的机器之间通过网络传递二进制数据的时候,如小端法机器传送数据给大端法机器时,接受方接收到的字节序会变成反序,为了避免这种问题的产生,发送方和接收方都需要遵循一个网络规则,发送方将二进制数据转换成网络标准,接收方再将这个网络标准的字节序转换成自己的字节序。此外,我们在阅读机器级代码的时候,可能会出现如下的情况:
暂时忽略这条指令的意义,可以看到左边6个字节分别为 01 05 43 0b 20 00 ,而右边的指令中的地址为 0x200b43,可以看到从左边的第三个字节开始,43 0b 20 是右边指令地址的倒序,因此在阅读这种机器级代码的时候,也需要注意字节序的问题。此外还存在一种情况。如下图所示。
我们可以看到, show_bytes 这个函数可以打印出 start 指针指向的地址开始的 len 个字节内容,且不受字节序的影响,那么它是如何做到的呢?在 show_int 函数中,可以看到它将 参数 x 的地址强制类型转换为了 byte_pointer , 即 unsigned char * 。通过强制类型转换的 start 指针指向的仍是 x 的最低字节地址,但是其类型改变了,通过其类型编译器会认为该指针指向的对象大小为 1 个字节,此时将该指针进行 ++ 操作可以得到顺延下一个字节的内容,从而得到对应的整个对象的字节序列中每个字节的内容而不受字节序影响。
1.4 字符串
在C语言中,字符串被编码为一个以 null (其值为0 )字符结尾的字符数组。每个字符都有某个标准编码来表示,最常见的则是 ASCII 字符码。假如我们调用 show_bytes("12345", 6),那么会输出 31 32 33 34 35 00 。可以看到最后打印出了一个终止符,所以通常 C 字符串的长度为实际字符串长度 + 1。 在C 标准库中的 strlen 函数可以传入一个字符串得出其长度,这里的长度即是实际长度,不包含终止符。
2. 整数表示
在本章节中,介绍了编码整数的两种不同的方式,一种只能表示非负数,另一种则能够表示负数,正数和零。接下来逐一进行介绍。
2.1 整型数据类型
C语言中,整数有多种数据类型,如下图所示,此外可以通过加上 unsigned 符号来限定该数据类型为非负数。这些数据类型有的是根据机器的字长(32位和64位)决定其实际最大值和最小值的范围。我们可以看到,图中最小值和最大值的取值范围是不对称的,负数的取值范围比正数大一,当我们考虑如何表现负数时,会看到为什么会这样。
关于无符号整数的编码,其实与普通的十进制正数转换成二进制没有什么区别,假设字长 w = 32 位,转换后大于 32 位的数字将被舍去。这里主要介绍一下关于有符号数字的编码,通常计算机使用的编码表示方式为 补码 ,在这个表示方式中,将字的最高有效位(即符号位)表示为负权,权重为 - 2^(w-1) ,当 w 位的值为 1 时表示为负数,反之为正数。以 -1 为例,-1 的补码为1111 1111 .... .... 1111 ,即 -2^31 + 2^30 + ... + 2^0 = -1 ,通常我们看到一个负数想要直接将其使用补码表示还是有些不方便的,因此我们可以先使用原码表示,所谓原码和普通的十进制数转二进制数没有区别,只不过最高位用来表示符号位,然后再求其反码,即符号位不变,其余位取反加 1,就可以得到这个负数的补码了,还是以 -1 举例, -1 的原码为 1000 0000 .... 0001 ,其反码的值为 1111 1111 .... 1111 ,与 -1 的补码值是相同的。而正数的补码为其本身,不需要做这种转换。
那么为什么要使用补码这种表示方式呢,首先,二进制补码可以使正负数相加时仍然采用正常加法的逻辑,不需要做特殊的处理,此外,如果不采用补码表示,采用原码的表示方法,那么会出现几个问题,正负零的存在,以及提高了减法的计算复杂度,而补码可以十分简单的计算正负数相加,只需求出两者的补码对其进行加法,更多关于补码的解释可以参考
printf("x format d = %d , format u = %u \n", x, x);
x format d = -1 , format u = 4294967295
y format d = -1 , format u = 4294967295
我们可以看到,我们使用指示符控制了解释这些位的方式,得到的结果是一致的。