信息的存储
通常情况下,程序将内存视为一个非常大的数组,数组的元素是由一个个的字节组成,每个字节都由一个唯一的数字来表示,我们称为地址。这些所有的地址集合就称为虚拟地址空间。
Byte
字节,信息存储的基本单位。1个字节由8个位组成,在二进制表示法中,每一个位的值可能有两种状态,0或者1。当这8个位全为0,表示一个字节的最小值。当这8个位全为1时,表示最大值。用十进制来表示,一个字节的取值范围就在0~255之间。我们把这种按照一位一位表示数据的方式称为位模式。使用二进制表示法比较冗长,而十进制表示法与位模式之间的转换又比较麻烦。因此,我们引入十六进制数来表示位模式。
我们熟悉的十进制,是由数字0~9组成的。对于十六进制数,则是由数字0~9和字母A~F来表示16个可能的数值。在C语言中,十六进制是以0x开头,这个X可以是小写,也可以是大写。
二进制转十六进制,具体请查阅原书。
我们看一下如何将形如2的N次方的数快速转成十六进制数。
其中 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语言中,支持整数和浮点数多种数据格式。
| signed | unsigned | 32-bit | 64-bit |
|---|---|---|---|
| [signed]char | unsigned char | 1 | 1 |
| short | unsigned short | 2 | 2 |
| int | unsigned | 4 | 4 |
| long | unsigned long | 4 | 8 |
| int32_t | uint32_t | 4 | 4 |
| int64_t | uint64_t | 8 | 8 |
| char* | 4 | 8 | |
| float | 4 | 4 | |
| double | 8 | 8 |
Addressing and Byte Ordering
以0x01234567为例
大端法
小端法
此外,虽然整型和浮点数都是对数值12345进行编码,但是它们却有着完全不同的字节模式。
| Type | Value | Hex |
|---|---|---|
| int | 12345 | 0x00003039 |
| float | 12345.0 | 0x00e44046 |
用二进制的形式来表示
0x00003039
0000 0000 0000 0000 0011 0000 0011 1001
0100 0110 0100 0000 1110 0100 0000 0000
0x00e44046
对其进行移位,我们会发现有一个13位的匹配序列。这是不是一个巧合呢?后续在浮点数部分会进行解答。
字符串表示
C语言中字符串被编码为以NULL字符串结尾的字符数组。例如,
const char *s = "abcde";
这个字符串虽然只有5个字符,但是长度却为6,就是因为结尾字符的存在。用十六进制表示就是
61 62 63 64 65 00
使用ASCII码来表示字符,在任何系统上都会得到相同的结果。因此,文本数据比二进制数据具有更强的平台独立性。
布尔运算
| ~ | |
|---|---|
| 0 | 1 |
| 1 | 0 |
| & | 0 | 1 |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 0 | 1 |
| | | 0 | 1 |
|---|---|---|
| 0 | 0 | 1 |
| 1 | 1 | 1 |
| ^ | 0 | 1 |
|---|---|---|
| 0 | 0 | 1 |
| 1 | 1 | 0 |
C语言中的一个特性就是支持按位进行布尔运算。
掩码操作
通过位运算可以得到特定的位序列。例如,通过&0xFF,就可以得到最低有效字节0x0000 00EF。
移位操作
左移
将最高位丢弃移动位数,同时在右侧填充相应位数的0。例如,8位二进制数0110 0011,左移一位就是丢弃最高的1位,并在右端补一个0。
右移
分为逻辑右移和算术右移。逻辑右移就是和左移方向相反,将右侧丢弃移动位数,在左侧用0进行填充。算术右移,当算术右移的操作对象的最高位等于0时,算术右移和逻辑右移是一样的,没有任何差别。但是当操作数的最高位为1时,算术右移之后,左端需要补1,而不是补0。
虽然C语言中并没有明确的规定有符号数应该使用哪一种类型的右移方式。但是实际上,几乎所有的编译器以及机器的组合都是对有符号数使用算术右移。对于无符号数,右移一定是逻辑右移。
整数的表示
| C data type | Minimum | Maximum | Bytes |
|---|---|---|---|
| [signed] char | -2^7 | 2^7-1 | 1 |
| [unsigned] char | 0 | 2^8-1 | 1 |
| short | -2^15 | 2^15-1 | 2 |
| unsigned short | 0 | 2^16-1 | 2 |
| int | -2^31 | 2^31-1 | 4 |
| unsigned | 0 | 2^32-1 | 4 |
| long | -2^63 | 2^63-1 | 8 |
| unsigned | 0 | 2^64-1 | 8 |
| int32_t | -2^31 | 2^31-1 | 4 |
| uint32_t | 0 | 2^32-1 | 4 |
| int64_t | -2^63 | 2^63-1 | 8 |
| uint64_t | 0 | 2^64-1 | 8 |
long类型的大小需要注意一下,这个类型的取值范围是与机器字长相关的。
在64位机器上,long类型占8个字节。在32位机器上,long类型只占4个字节。
无符号数的编码方式
有符号数的编码
补码(two's-complement)
这里需要注意最高位的权重是-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语言的实现,有符号和无符号之间的转换规则是:
位模式不变,但是解释这些位的方式改变了。
接下来,我们看一下,在位数相同的情况下无符号数与有符号之间的转换:
在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
对于无符号整数判断溢出
int uadd_ok(unsigned x, unsigned y)
{
unsigned sum = x + y;
if(sum >= x)
return 1;
else
return 0;
}
数学证明
可以证明,当发生溢出时,得到的和小于其中任意一个数(x或者y)。
有符号数加法
溢出判断
减法
对于x, 0 <= x < 2^w
x‘为x的加法逆元。
对于无符号数,0 <= x < 2^w,0 <= x' < 2^w
对于有符号数, -2^{w-1} <= x < 2^{w-1} - 1
无符号数乘法
w位的x和y
补码的乘法
w位的x和y
假设x和y表示有符号数,x'和y'表示无符号数,x与x‘的二进制表示相同,y与y’的二进制表示相同。
虽然无符号数和补码两种乘法乘积的完整位表示不同,但是截断之后结果的位级表示却相同。
由于乘法指令的执行需要多个时钟周期,很多C语言的编译器试图用移位、加法以及减法来代替整数乘法的操作。
举个例子
对于除法,无符号数采用的是逻辑右移,而有符号数采用的是算术右移。
无符号整数的除法,还会遇到除不尽的情况,总是朝向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
浮点数
含有小数值得二进制数
但是对于这种定点表示方法,并不能很有效的表示非常大的数。
IEEE浮点数的表示
单精度浮点数float
其中最高位31位表示符号位s,当s=0时,表示正数;s=1时,则表示负数。从第23位到第30位,这8个二进制位与阶码的值是相关的。剩余的23位与尾数M是相关的。
双精度浮点数double
浮点数的分类
分类是由阶码来决定的
- Normalized Values(规格化的值)
其中e的最小值为1,最大值为254。阶码真正的表示是E = e-bias
尾数M被定义为1+f
- Denormalized Values(非规格化的值)
关于非规格化的数有两个用途,一是提供了表示数值0的方法。当符号位s等于0,阶码字段全为0,小数字段也全为0时,此时表示正零;而当s等于1时,表示负零。二是表示非常接近0的数,当阶码字段全为0时,阶码E的值等于1-bias,M=f,不包含隐藏的1。
- Special Values(特殊值)
特殊值分为两类,一类表示无穷大或者无穷小,另外一类表示“不是一个数”
无穷
s为0表示无穷大,为1表示无穷小
NaN
当运算结果不为实数或者用无穷也无法表示的情况,用NaN表示。
接下来,我们来看看之前的问题,12345和12345.0的二进制表示,为什么会有13位匹配序列。
首先,我们将整型数12345转换成浮点数12345.0。整型数12345的二进制表示为 11 0000 0011 1001,即
根据IEEE的表示法,我们将首个1丢弃,E=127 + 13,最终的表示如下所示
由于表示方法的原因,限制了浮点数的范围和精度,所以浮点运算只能近似的表示实数运算。
对于值x,可能无法用浮点形式来精确的表示,因此我们希望可以找到“最接近的值” x'来代替x。
一个关键的问题就是在两个可能的值中间确定舍入方向。例如,x' = 1 or 2 ?
IEEE浮点格式定义了四种不同的舍入方式
- Round-to-even (向偶数舍入)
- Round-toward-zero(向零舍入)
- Round-down(向下舍入)
- Round-up(向上舍入)
| Mode | 1.40 | 1.60 | 1.50 | 2.50 | -1.50 |
|---|---|---|---|---|---|
| Round-down | 1 | 1 | 1 | 2 | -2 |
| Round-up | 2 | 2 | 2 | 3 | -1 |
Round-toward-zero 把正数进行向下舍入,把负数进行向上舍入
第四种舍入方式就是向偶数舍入,也被称为向最接近的值进行舍入。
| Mode | 1.40 | 1.60 | 1.50 | 2.50 | -1.50 |
|---|---|---|---|---|---|
| Round-to-even | 1 | 2 |
当遇到两个可能结果的中间数值时,舍入结果应该如何计算。向偶数舍入的舍入结果要遵循,最低有效数字是偶数的规则。因此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。
浮点数加法
- (3.14 + 1e10) - 1e10 = 0.0
- 3.14 + (1e10 - 1e10) = 3.14
这是由于表达式1在计算3.14与1e10相加时,对结果进行舍入,值3.14会丢失。因此,对于浮点数的加法是不具有结合性的。同样由于计算结果可能发生溢出,或者由于舍入而失去精度,导致浮点数的乘法也不具有结合性。
- (1e20 * 1e20) * 1e-20 =
- 1e20 * (1e20 * 1e-20) = 1e20
此外,浮点数乘法在加法上不具备分配性
- 1e20 * (1e20 - 1e20) = 0.0
- 1e20 * 1e20 - 1e20 * 1e20 = NaN
对于从事科学计算的程序员以及编译器的开发人员来说,缺乏结合性和分配性是一个比较严重的问题。
数据类型之间的转换
-
int -> float
数字不会发生溢出,但是可能会被舍入。这是由于单精度浮点数的小数字段是23位,可能会出现无法保留精度的情况。
-
int/float -> double
由于double类型具有更大的范围,所以可以保留精确的数值。
-
double -> float
由于float类型所表示数值的范围更小,所以可能会发生溢出。此外,由于float类型的精度相对于double较小,转换后还可能被舍入。
-
float/double -> int
一种可能的情况是值会向零舍入,例如1.9将被转换成1,-1.9将被转换成-1。另外一种可能的情况是发生溢出。