1. 大致简介
程序结构篇
1. 信息表示与处理
程序运行篇
程序交互篇
本节主要介绍下计算机系统中的信息是以什么样的方式表示的,主要涉及无符号数、补码、以及浮点数。
《深入理解计算机系统》
现代计算机存储和处理的信息以二值信号表示。这些微不足道的二进制数字,或者称为位(bit) ,形成了数字革命的基础。
孤立地讲,单个的位不是非常有用。然而,当把位组合在一起,再加上某种解释(interpretation),即赋予不同的可能位模式以含意,我们就能够表示任何有限集合的元素。
我们研究三种最重要的数字表示。
计算机用这些不同的表示方法实现算术运算,例如加法和乘法,类似于对应的整数和实数运算。
由于表示的精度有限,浮点运算是不可结合的。
整数运算和浮点数运算会有不同的数学属性是因为它们处理数字表示有限性的方式不同整数的表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的;而浮点数虽然可以编码一个较大的数值范酣,但是这种表示只是近似的。
了解信息表示的重要性
为了使编写的程序能在全部数值范围内正确工作,而且具有可以跨越不同机器、操作系统和编译器组合的可移植性,了解这种属性是非常重要的。后面我们会讲到,大量计算机的安全涌洞都是由千计算机算术运算的微妙细节引发的。
大多数计算机使用8位的块,或者宇节(byte),作为最小的可寻址的内存单位。
机器级程序将内存视为一个非常大的字节数组,称为虚拟内存(virtual memory)。
内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间(virtualaddress space)。
存储器空间划分为更可管理的单元,来存放不同的程序对象(programobject),即程序数据、指令和控制信息。
可以用各种机制来分配和管理程序不同部分的存储。这种管理完全是在虚拟地址空间里完成的。
每台计算机都有一个字长(wordsize),指明指针数据的标称大小(nomnailsize)。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为w位的机器而言,虚拟地址的范围为0~2w-1,程序最多访问2w个字节。
因此,我们将程序称为“32位程序”或“64位程序”时,区别在于该程序是如何编译的,而不是其运行的机器类型。
整数或者为有符号的,即可以表示负数、零和正数;或者为无符号的,即只能表示非负数。C的数据类型char表示一个单独的字节。尽管”char"是由千它被用来存储文本串中的单个字符这一事实而得名,但它也能被用来存储整数值。数据类型short、int和long可以提供各种数据大小。
为了避免由于依赖"典型”大小和不同编译器设置带来的奇怪行为,ISOC99引入了一类数据类型,其数据大小是固定的,不随编译器和机器设置而变化。其中就有数据类型int32_t和int64_t,它们分别为4个字节和8个字节。使用确定大小的整数类型是程序员准确控制数据表示的最佳途径。
程序员应该力图使他们的程序在不同的机器和编译器上可移植。可移植性的一个方面就是使程序对不同数据类型的确切大小不敏感。
对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及 在内存中如何排列这些字节。
在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。例如,假设一个类型为int的变量x的地址为0x100,也就是说,地址表达式&x的值为0x100。那么,(假设数据类型1让为32位表示)x的4个字节将被存储在内存的0x100、0x101、0x102和0x103位置。
排列表示一个对象的字节有两个通用的规则。
考虑一个w位的整数,其位表示为[xw-1, …x0],其中xw-1是最高有效位,而x0是最低有效位。
假设w是8的倍数,这些位就能被分组成为字节,其中最高有效字节包含位[xw-1,… w-8],而最低有效字节包含位[x7,…x0],其他字节包含中间的位。
某些机器选择在内存中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效字节的顺序存储。
前一种规则——最低有效字节在最前面的方式,称为小端法(littleendian)。后一种规则—-最高有效字节在最前面的方式,称为大端法(bigendian)。
许多比较新的微处理器是双端法(bi-endian),也就是说可以把它们配置成作为大端或者小端的机器运行。然而,实际情况是:**一旦选择了特定操作系统,那么字节顺序也就固定下来。**比如,用于许多移动电话的ARM微处理器,其硬件可以按小端或大端两种模式操作,但是这些芯片上最常见的两种操作系统一—Andro过(来自Google)和IOS(来自Apple)--却只能运行于小端模式。
对于大多数应用程序员来说,其机器所使用的字节顺序是完全不可见的。无论为哪种类型的机器所编译的程序都会得到同样的结果。不过有时候,字节顺序会成为问题。
在使用ASCII码作为字符码的任何系统上都将得到相同的结果,与字节顺序和字大小规则无关。因而,文本数据比二进制数据具有更强的平台独立性。
不同的机器类型使用不同的且不兼容的指令和编码方式。即使是完全一样的进程,运行在不同的操作系统上也会有不同的编码规则,因此二进制代码是不兼容的。二进制代码很少能在不同机器和操作系统组合之间移植。
计算机系统的一个基本概念就是,从机器的角度来看,程序仅仅只是字节序列。机器没有关于原始源程序的任何信息,除了可能有些用来帮助调试的辅助表以外。
事实上,我们在布尔运算中使用的那些符号就是C语言所使用的:| 就是OR(或),&就是AND(与),~就是NOT(取反),而^就是EXCLUSIVE-OR(异或)。
位级运算的一个常见用法就是实现掩码运算,这里掩码是一个位模式,表示从一个字中选出的位的集合。
C语言还提供了一组逻辑运算符II、&&和!,分别对应千命题逻辑中的OR、AND和NOT运算。逻辑运算很容易和位级运算相混淆,但是它们的功能是完全不同的。逻辑运算认为所有非零的参数都表示TRUE,而参数0表示FALSE。它们返回1或者o,分别表示结果为TRUE或者为FALSE。
逻辑运算符 && 和 || 与它们对应的位级运算 & 和 | 之间第二个重要的区别是,如果对第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。因此,例如,表达式a && 5/a将不会造成被零除,而表达式p && *p++ 也不会导致间接引用空指针。
x向左移动K位,丢弃最高的K位,并在右端补K个0。
一般而言,机器支持两种形式的右移:逻辑右移和算术右移。
唯一一个与机器相关的取值范围是大小指示符long的。大多数64位机器使用8个字节的表示,比32位机器上使用的4个字节的表示的取值范围大很多。
假设有一个整数数据类型有w位。我们可以将位向量写成x→,表示整个向量,或者写成[xw-1, …x0]表示向量中的每一位。
把x→看做一个二进制表示的数,就获得了的无符号表示。
在这个编码中,每个位xi,都取值为0或1,后一种取值意味着数值2i应为数字值的一部分。
最常见的有符号数的计算机表示方式就是补码(two’s-complement)形式。
强制类型转换的结果保持位值不变,只是改变了解释这些位的方式。
对于大多数C语言的实现,处理同样字长的有符号数和无符号数之间相互转换的一般规则是:数值可能会改变,但是位模式不变。
printf首先将这个字当作一个无符号数输出,然后把它当作一个有符号数输出。
当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执行这个运算。
一个常见的运算是在不同字长的整数之间转换,同时又保持数值不变。
当然,当目标数据类型太小以至于不能表示想要的值时,这根本就是不可能的。
然而,从一个较小的数据类型转换到一个较大的类型,应该总是可能的。
要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加0。这种运算被称为零扩展(zeroextension),表示原理如下:
假设我们不用额外的位来扩展一个数值,而是减少表示一个数字的位数。
我们已经看到了许多无符号运算的细微特性,尤其是有符号数到无符号数的隐式转换,会导致错误或者漏洞的方式。
避免这类错误的一种方法就是 绝不使用无符号数。
实际上,除了C以外很少有语言支持无符号整数。很明显,这些语言的设计者认为它们带来的麻烦要比益处多得多。
当我们想要把字仅仅看做是位的集合而没有任何数字意义时,无符号数值是非常有用的。
例如,往一个字中放入描述各种布尔条件的标记(flag)时,就是这样。
地址自然地就是无符号的,所以系统程序员发现无符号类型是很有帮助的。
当实现模运算和多精度运算的数学包时,数字是由字的数组来表示的,无符号值也会非常有用。
这种持续的”字长膨胀”意味着,要想完整地表示算术运算的结果,我们不能对字长做任何限制。
说一个算术运算溢出,是指完整的整数结果不能放到数据类型的字长限制中去。当执行C程序时,不会将溢出作为错误而发信号。不过有的时候,我们可能希望判定是否发生了溢出。
以往,在大多数机器上,整数乘法指令相当慢,需要 10个或者更多的时钟周期,然而其他整数运算(例如加法、减法、位级运算和移位)只需要1个时钟周期。
即使在我们 的参考机器Intel Core i7 Haswell上,其整数乘法也需要3 个时钟周期。因此,编译器使用了一项重要的优化,试着用移位和加法运算的组合来代替乘以常数 因子的乘法。
首先,我们会考虑乘以2的幕的情况,然后再概括成乘以任意常数。
注意, 无论是无符号运算还是补码运算,乘以2的幂都可能会导致溢出。 结果表明,即使溢出的时候,我们通过移位得到的结果也是一样的。回到前面的例子,我们将4位模式[1011](数值为11)左移两位得到[101100](数值为44)。将这个值截断为4位得到[1100](数值为12=44mod16)。
由于整数乘法比移位和加法的代价要大得多,许多C语言编译器试图以移位、加法和减法的组合来消除很多整数乘以常数的情况。
在大多数机器上,整数除法要比整数乘法更慢需要30个或者更多的时钟周期。
除以2的幂也可以用移位运算来实现,只不过我们用的是右移,而不是左移。
无符号和补码数分别使用逻辑移位和算术移位来达到目的
正如我们看到的,计算机执行的“整数”运算实际上是一种模运算形式。
表示数字的有限字长限制了可能的值的取值范围,结果运算可能溢出。
我们还看到,补码表示提供了一种既能表示负数也能表示正数的灵活方法,同时使用了与执行无符号算术相同的位级实现,这些运算包括像加法、减法、乘法,甚至除法,无论运算数是以无符号形式还是以补码形式表示的,都有完全一样或者非常类似的位级行为。
我们看到了C语言中的某些规定可能会产生令人意想不到的结果,而这些结果可能是难以察觉或理解的缺陷的源头。
我们特别看到了unsigned数据类型,虽然它概念上很简单,但可能导致即使是资深程序员都意想不到的行为。
我们还看到这种数据类型会以出乎意料的方式出现,比如,当书写整数常数和当调用库函数时。
浮点表示对形如V=xX2y的有理数进行编码。它对执行涉及非常大的数字(|V| >> 0 )、 非常接近于 0 (|V| << 1)的数字,以及更普遍地作为实数运算的近似值的计算,是很有用的。
我们还将探讨舍入(rounding)的问题,即当一个数字不能被准确地表示为这种格式时,就必须向上调整或者向下调整。然后,我们将探讨加法、乘法和关系运算符的数学属性。许多程序员认为浮点数没意思,往坏了说,深奥难懂。我们将看到,因为IEEE格式是定义在一组小而一致的原则上的,所以它实际上是相当优雅和容易理解的。
不过,我们并不能把它准确地表示为一个二进制小数,我们只能近似地表示它,增加二进制表示的长度可以提高表示的精度:
定点表示法不能很有效地表示非常大的数字。
计算机将信息编码为位(比特),通常组织成字节序列。有不同的编码方式用来表示整数、实数和字符串。不同的计算机模型在编码数字和多字节数据中的字节顺序时使用不同的约定。
我们的重点就放在区分32位和64位程序,而不是机器本身。64位程序的优势是可以突破32位程序具有的4GB地址限制。
大多数机器对整数使用补码编码,而对浮点数使用IEEE标准754编码。
由千编码的长度有限,与传统整数和实数运算相比,计算机运算具有非常不同的属性。当超出表示范围时,有限长度能够引起数值溢出。当浮点数非常接近于0.0,从而转换成零时,也会下溢。
无符号数和补码的运算都满足整数运算的许多其他属性,包括结合律、交换律和分配律。这就允许编译器做很多的优化。
必须非常小心地使用浮点运算,因为浮点运算只有有限的范围和精度,而且并不遵守普遍的算术属性,比如结合性。