1. 信息的表示和处理
深入理解计算机系统,是从计算机的底层往上看,从下到上一层一层的分析。
在计算机中,使用二进制来表示最基本的单位,原因是二进制在自然界可以低成本的获得,例如,早期的电路开关,纸带打孔和没打孔,都能够用来表示0和1,而在现在的技术下,一小片集成电路上可以有几亿甚至几十亿这样的电路。和八进制,十进制以及十六进制相比,二进制很容易表示、存储和传输,且非常简单可靠。
一个能够表示0和1的开关电路,被称为一个位。通过把位组合到一起,给与一定的解释,就可以用它来表示有限范围集合里的所有元素。这种方式即二进制编码,引申出了编码的概念。例如早期的ASCII,就是使用7个位来表示的128个基础字符。
在可以编码基础元素(字母、数字、基本符号)了以后,引申出计算机中正整数、负数、浮点数编码以及后面的算术运算。
- 正整数对应无符号编码
- 负数对应补码编码
- 浮点数对应浮点数编码(只能表示有限精度)
在了解这些编码的原理后,程序员就知道如何去避免一些因为精度问题导致的程序bug,甚至主动测试程序中出现的这种问题(例如内存溢出漏洞的排查)
以上就是这一章节的重点内容
信息存储
进制表示法
信息是按照什么样的形式存储到计算机中
- 二进制表示法(0和1)
- 八进制表示法(0-7)
- 十进制表示法(0-9)
- 十六进制表示法(0-9,A-F)
上面四种表示法是最常见的4种表示法,可以互相转换。常见的转换规则是
- 二进制、八进制、十六进制转十进制(十进制是人类最熟悉的数字表示方式)
- 二进制到八进制和十六进制的转换以及反向转换(从机器级到机器级的转换)
最常见的是二进制到十六进制的转换,最简单。4个二进制位对应一个16进制位,反过来就是一个16进制对应4个二进制。二进制到八进制的转换则是一个八进制位对应3个二进制位。
字长
计算机在硬件上一次能够处理的字节位数被称为字长,目前的主流计算机都是64位字长(即8个字节),而之前的旧计算机则是32位字长(即4个字节)。因此为了保证对之前旧计算机的兼容,一些编程语言里还是针对不同字长的计算机,用不同长度的字节数来表示相同的类型。例如C语言里有差异的变量类型有:
有符号 | 无符号 | 32位计算机 | 64位计算机 |
---|---|---|---|
long | unsigned long | 4 | 8 |
char * | 4 | 8 |
因此在进行程序设计时,针对这两种类型的变量,在不同字长的硬件平台上,需要考虑变量能够表示的范围。(这些信息都是动态变化的,很可能过了两年,32位和64位都直接用8字节的长度来表示)
多字节对象的寻址和顺序
多字节对象在内存中一般是存放在连续的内存空间中,使用起始内存地址作为对象的地址,获取到起始内存地址和对象的长度后,就可以在内存中读取到整个多字节对象。
大小端表示法
多字节对象在内存中存放的时候,如果按照字节高位到低位的顺序存放,则是大端模式。如果是从低位到高位存放,则是小端模式。例如一个多字节对象0x1234567,大端存放方式就是:
01 23 45 67
小端存放顺序则是:
67 45 23 01
在编程的时候大小端模式和多种因素相关,有:
- 硬件平台的模式
- 操作系统的模式
一般以操作系统的模式为准,操作系统会屏蔽底层硬件的差异。但是还可能遇到的问题是,当两个不同模式的操作系统之间通信时,小端模式发送的信息到大端模式的机器里读取的时候,会发现顺序是相反的,因此提出了标准的网络模式来解决这个问题。小端模式发送时,需要先转为网络模式。大端接收到数据后,需要先从网络模式转为自己内部格式。
如何获取平台的大小端模式
在知道多字节对象的起始地址,多字节对象的长度。将多字节对象按字节长度依次从内存中读取出来,以十六进制输出每个字节的内容,与多字节对象的16进制表示法对比。如果顺序一致,就是大端表示法,如果刚好相反,就是小端表示法。根据这种方式写出来的C语言代码如下所示:
#include <stdio.h>
typedef unsigned char *byte_pointer;
void show_byte(byte_pointer start, size_t len) {
size_t i;
for (i=0; i < len; i++)
printf("%.2x", start[i])
printf("\n")
}
void show_int(int x){
show_bytes((byte_pointer) &x, sizeof(int));
}
void show_float(float x){
show_bytes((byte_pointer) &x, sizeof(float));
}
void show_pointer(void *x){
show_bytes((byte_pointer) &x, sizeof(void *));
}
在这个示例里,给show_bytes函数传入的两个参数分别是对象'x'的地址'&x',以及这个类型的对象长度。对象长度在每种语言编译器里一般都是固定值。然后将地址'&x'的类型转换为unsigned char
,这个转换是告诉编译器,要把指针指向一个字节序列,而不是一个完整的对象(有点像把多字节对象拆分成了组成它的多个字节组成的数组)。然后就可以通过数组索引的方式(start[i]
)依次读取出每个字节的16进制表示的内容(%.2x
表示以2位16进制的方式输出对象)。
通过使用上面这个函数读取出的结果,和对象实际的16进制表示进行对比。就可以判断出操作系统使用的是哪种模式。
整数和浮点数二进制表示重合位数
目前只知道这两种表示方式有13位重合,但是原因还不知道。
二进制表示字符
引出了ASCII编码,字符编码都是固定的,例如字符'a'
-'z'
它的十六进制表示就是0x61-0x7A
。ASCII能表示的字符数量有限(2^7=128个),因此又引出了Unicode编码,Unicode编码使用4个字节(4*8=32位)来表示字符,可以表示地球上出现的所有语言字符还有剩下的。在Unicode编码中,每个字符或者文字都是使用4个字节来编码,但是Unicode只是定义的标准,具体的实现有UTF-8,UTF-16和UTF-32,目前使用最广泛的就是UTF-8标准,它是一个变长编码规范。部分字符使用1个字节表示,部分字节使用2个或者4个字节表示。
从上面这些东西来看,几乎可以把现实世界里的所有语言、文字和计算符号都使用二进制的方式在计算机中表示出来。而事实也确实是这样,每个程序最终都会转换为二进制序列表示的机器编码。计算机芯片上操作的对象也是对这些二进制序列。但是根据上面的知识,我们也知道,在不同的系统平台上同一个数字或者字符,它转换为二进制后,会因为大端模式和小端模式的差异,生成的二进制序列也不一样。因此不同平台上的二进制程序基本上都不能互相兼容(例如windows平台上以.exe结尾的程序在linux上就无法运行)。
布尔代数
四种布尔代数操作类型:
- 与(&)
- 或(| )
- 非(~)
- 异或(^)
这4种操作符都是用于二进制的0和1之间的运算,基本规则是:
# 与
0&0 = 0
0&1 = 0
1&0 = 0
1&1 = 1
# 或
0 | 0 = 0
0 | 1 = 1
1 | 0 = 1
1 | 1 = 1
# 非
~0 = 1
~1 = 0
# 异或
0^0 = 0
0^1 = 1
1^0 = 1
1^1 = 0
上面的0和1之间的这些计算方式,是乔治·布尔(1815-1864)在1850年左右所作的工作,因此被称为布尔代数。而这些计算方式由创建信息论领域的Claude Shannon
建立和和数字逻辑之间的联系,进而用来设计继电器,即计算机CPU最早的基本运算单元(继电器的开和关表示0和1)。
在知道了单个计算单元的计算规律后,就可以推广到多位字节表示的对象计算上,例如:
0000
& 1111
-------
0000
0000
| 1111
-------
1111
~ 0000
-------
1111
0000
^ 1111
-------
1111
上面演示的是4位,实际上不管扩展到多少位,它的计算规律依然是这样。
四种计算的特殊性
- 优先级
a | ( b & c ) = ( a | b ) & ( a | c )
a & ( b | c ) = ( a & b ) | ( a & c )
从上面的示例代码来看,这一点和数学计算里的加减乘除优先级是不一样的。
- 相反数
在数学里,一个正整数必然有一个相反数,和它的和为0,即x + (-x) = 0
,而在二进制里也有这个概念。即异或。
0 ^ 0 = 0
1 ^ 1 = 0
那么根据这个规律,一个元素对它自身进行异或操作,结果一定为0,示例如下:
a = 10101010
a^a
10101010
^ 10101010
---------------
00000000
这两个规律在后面的整数计算里会使用到。
位向量表示有限集合(这个地方知道怎么表示的,但是它背后的理论还很模糊)
异或运算的应用:掩码
C语言中的位级运算、移位运算和逻辑运算
位运算
在知道了计算机是怎么表示数字以后,就能够将对应的数学运算转换为二进制运算,二进制运算也有对应的规则。
- 一个数的二进制表示和对应长度全为1的二进制数进行与运算(&),这个数不变
- 一个数的二进制表示和对应长度全为0的二进制数进行与运算(&),这个数变为0
- 一个数的二进制表示和对应长度全为1的二进制数进行或运算(|),这个数变成对应类型的最大值或最小值
- 一个数的二进制表示和对应长度全为0的二进制数进行或运算(|),这个数不变
- 一个数的二进制表示和对应长度全为1的二进制数进行异或运算(^),变为对应类型最大值减去这个数的差值(正整数类型)或相反数(有符号类型)
- 一个数的二进制表示和对应长度全为0的二进制数进行异或运算(^),这个数不变
- 一个数的二进制表示进行非运算(~),变为对应类型最大值减去这个数的差值(正整数类型)或相反数(有符号类型)
光看概念可能非常绕,这个时候需要通过示例来理解,假设一个数是int8
类型,对应的二进制表示是0x01010101
。那么根据上面的规则,我们来依次进行各种运算:
# 1. 和8位全为1的二进制数进行与计算
01010101
& 11111111
----------
01010101 -> 没变
# 2. 和8位全为0的二进制数进行与运算
01010101
& 00000000
----------
00000000 -> 变为0
# 3. 和8位全为1的二进制数进行或计算
01010101
| 11111111
----------
11111111 ->二进制为全为1,变成8位长度能表示的最大正整数(无符号数)或最小值(有符号数)
# 4. 和8位全为0的二进制数进行或运算
01010101
| 00000000
----------
01010101 -> 不变
# 5. 和8位全为0的二进制数进行异或运算
01010101
| 11111111
----------
10101010 -> 变成8位长度能表示的最大正整数减去这个数的差值
# 6. 和8位全为0的二进制数进行异或运算
01010101
| 00000000
----------
01010101 -> 不变
# 7. 进行非运算
~ 01010101
----------
10101010 ->变成8位长度能表示的最大正整数减去这个数的差值
从上面的例子来看,最特殊的就是第3,5,7种。其他几种都比较好理解,但是第3,5,7种涉及到了有符号数和无符号数的表示。因此更复杂一点。
关联的题目bis和bic函数的原理还没理解
逻辑运算
逻辑运算不是机器运算符,是编程语言里的运算符,C语言里有3个:
- &&,表示与运算
- || ,表示或运算
- !,表示非运算
这三个运算符在使用的时候,需要将两边或者右边的数先变为逻辑值。任何非0的值都认为是True,任何0值都认为是False。然后再使用逻辑运算符进行True和False之间的计算。