csapp第二章 信息的表示和处理

我们研究三种最重要的数字表示:无符号编码,补码编码,浮点数编码。

可能我们会觉得二进制的编码,从低位开始加1,加1再加1是很正常的,但其实不是,为什么不能从高位开始加,向低位进位呢?习惯而已,前人脚印。

无符号编码,我们最能接受,觉得天经地义的编码方式。其实不是天经地义的,只是前人就这么定的。补码编码也是比较能接受的,但也是一种选择,前人经过考虑后的选择。浮点数编码也是。这三个其实在一个水平面上,都是人为选择和制定的产物。

需要知道,计算机的算术运算和数学世界的算术运算是不等价的,两者在很多特性上有显著的差别;但计算机算术运算希望得到正确的数学算术运算的结果,虽然有时候得不到。两者不同的根源在于:在计算机中,整数表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的;浮点数虽然可以编码一个较大的数值范围,但是这种表示是近似的。

大量计算机的安全漏洞都是由于计算机算术运算的微妙细节引发的。

2.1 信息存储

机器级程序将存储器视为一个非常大的字节数组,称为虚拟存储器,每个字节都有一个唯一的数字来标识,称为地址,所有可能地址的集合称为虚拟地址空间。这不就是第一章中说到的应用程序的虚拟地址空间吗。

接下来的几章,我们将讲述编译器和运行时系统是如何将存储器空间划分为更可管理的单元,以存放不同的程序对象,即程序数据、指令和控制信息。(应该是指虚拟地址空间中各种区的划分)。

可以用各种机制来分配和管理程序不同部分的存储。这种管理完全是在虚拟地址空间里完成的。

例如c语言的指针,其值都是某个存储块的第一个字节的虚拟地址。

实际的机器级代码是不包含数据类型的信息的,只是操作字节序列。

记住16进制中D表示13。

2n转化为16进制时就是将n=i+4j,比如211 可以是3+2*4,也就是0x800,2个0加上2的3次方(8)。

每台计算机都有一个字长,指明整数和指针数据的标称大小。虚拟地址是以这样一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的大小。

32位就是4G,一个程序,或者说一个进程的虚拟地址空间,4G应该足够了,但是,已经有很多大规模的科学应用需要更大的虚拟地址空间。这应该是说的主存吧,虚拟地址空间。

c语言不同数据类型分配的字节数不同,依赖于机器和编译器。但典型值是有的:32位机上,char1字节,shortcut2字节,int4字节,long4字节,longlong8字节,float4字节,double8字节。

可移植的一个方面就是使程序对不同数据类型的确切大小不敏感。

在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。这个是通用的原则,不管是大端还是小端都是这样的。

大端就是对象的地址处是最高字节然后顺序存储,小端就是对象的地址处是最小字节然后顺序存储。不管是大端还是小端,对象的地址都是所有字节的地址中最小的地址。

哪种合适,那种都合适,随便。这个其实并不重要,这是在存储器中存储时的不同选择,运算的时候,从存储器中读出来还是按照高位高位,低位低位的样子。就是:爱怎么存怎么存,反正我使用的时候高是高低是低。

字节顺序在大部分的时候是不可见的,对于应用程序员来说。但有3类情况变得可见。

  • 网络传送,现在的解决方法是网络传送时是网络传送的标准,大端和小端都要在传送时变成网络标准,接受时再变回去。
  • 阅读机器级代码的时候,汇编的阅读还是没有问题的,但机器语言的阅读就变成问题了。
  • 第三种就是类型强制转换的时候。这个好理解,类型转化就可以看作是地址不变,但取的字节书目变了。当然大端和小端就会显示出差别。大多数应用编程都强烈不推荐这种编码技巧,但是它们对系统级编程来说非常有用甚至是必需的。

sizeof(T)返回存储 一个类型为T的对象所需要的字节数。sizeof是运算符,比如+。

c语言中字符串被编码为一个以null字符结尾的字符数组,null字符在ASCII中是0,也就是说所有的c语言字符串最后一个字节都是0。也不是,带有汉字的字符串编码不会是ASCII,可能是UTF-8,那么,这个时候最后一个字节也是0,UTF-8是这样的,再其他的,我猜想也是0吧。(只要null字符的编码是0的字符集)

Unicode联合会的Unicode标准目前字库包括十万了字符。基本编码,也成为Unicode的“统一字符集“使用32位来表示字符。32位4G,除了100k的现在的字符外,都是空的吧。

Java使用Unicode来表示字符串,也就是一个字符4字节,浪费阿。

二进制代码是不兼容的,即使一样的程序,编译出来的机器级代码也是不一样的。二进制代码很少能在不同机器和操作系统组合之间移植。但如果是相同的机器硬件和相同的操作系统,那么编译出来的机器级代码是不是完全一样的?很可能是的吧。所以说,不可移植在于不同的机器硬件和操作系统的组合。

布尔代数有很多有用的结论,记住就可以了,最好的一个是a^(b^a)=b。不错。

位向量一个很有用的应用就是表示有限集合。

c语言中的位级运算,既然是位级运算,那就是对位的,不论操作数是什么类型,位级运算符是不care的,就是对位的。

掩码是一个位模式,表示从一个字中选出的位的集合。

c语言中的移位操作中,向左移位是很简单的,丢弃高位的,低位补0,始终如此。但向右移位有两种,一种是逻辑移位,一种是算术移位,逻辑移位高端补0,算术移位高端是1补1,是0补0。存在两种右移,归根结底是机器提供了这两种形式。

c语言标准没有明确定义使用那种右移。但有其他的限制:无符号数据必须逻辑右移,有符号数据则不能确定。但实际上,总存在典型值的,那就是几乎所有的编译器/机器组合对有符号数据都执行算术右移。

c语言标准也没有定义当移位(右移或者左移)的位数大于数据位数时该怎么做,典型值是取mod。比如32就变成了0,34变成了2。

2.2 整数表示

本节,描述用位来编码整数的两种不同的方式:一种只能表示非负数,而另一种能够表示负数、0和正数。这是两种最常用的,但当然还有其他的用位编码整数的方式,不说,不代表不存在,也不代表没人用,重点是这里,目前,最多的使用的编码整数的方式是这两种。

而且,这两种的数学属性和机器级实现方面密切关联。

32位机和64位机上的c语言整数数据类型的典型字节数除了long之外都是一致的,char1,short2,int4,longlong8。而long在32位是4字节,在64位是8字节。注意:这里是典型字节数。

无符号数编码:最正常的那种,符号是B2Uw 。这个编码有个双射的属性,意思应该就是一一对应。

有符号数编码中最正常的那种,就是补码,符号是B2Tw ,其计算方法是最高位按负的算,也就是加上一个负权重,如果最高位为0,后面的正常计算,如果最高位为1,后面的正常计算然后加上最高位表示的负数。这个编码也是一个双射。补码的范围是不对称的,|Tmin|=|Tmax|+1,这是因为,一半表示负数,一半表示的是正数和0。补码中的-1的位模式和UMax的位模式是相同的。0在补码和无符号编码中的位模式都是0。

c语言标准没有要求用补码形式来表示有符号整数,但是,还是典型的,几乎所有的机器都是这么做的。

c语言标准中定义了各种整数数据类型的最小范围,这个范围中正负是对称的。复合这个标准保证了可移植性。同时,典型值的范围,也就是补码的范围在很多机器和编译器上也具有可移植性,就是这样。可移植性,不应该去钻牛角尖吧。

ISO C99标准在文件stdint.h中引入了另一类整数类型,声明形如intN_t,uintN_t,N的具体值与实现有关,也就是在不同的机器和编译器组合下,N是不同的,但大多数编译器允许8、16、32、64。因此使用uint16_t我们可以无歧义的使用一个16位的无符号变量。这时,字节数是确定的,类型是确定的,可移植性的限制只是编译器支持与否,只要支持,就可以确定的编译成功,按照预期的给出程序的结果。c标准还给出了每个N的值对应的最小值和最大值,如INTN_MIN,INTN_MAX,UINTN_MAX。这里给出这些范围是十分有必要的,因为c标准并没有要求必须补码形式来表示有符号整数,所以补码形式给出的最大最小值不见得正确,如果实现使用的是其他的有符号数并编码,那么相应的最大最小值就变化了。c标准给出了最大最小值从另一方面说明了底层到底是使用的什么编码c是不关心的,c关心的只是到底是多少位的,最大最小是多少,然后就可以无差别的使用了。

c是怪异但强大的,java就确切的定义了所有的数据类型,并要求使用补码编码,这使得在所有的机器上,java程序表现的都一致。

值得强调的,现在几乎所有的机器都使用补码。

这里有一个需要考虑的地方,c语言中的语句不关心底层有符号的编码,在c中只有付给一个变量一个负值的概念,然后在补码机器上编译的时候,这个负数就被编译成为了补码的形式,也就是说,c中的正负就是正负,编译完了之后才会有有符号补码表示,以及无符号表示。

在c语言中的强制转换中的相同的字节数的无符号数和有符号数之间的转换,对于大多数c语言的实现来说,都是从位级考虑而不是数的角度。

什么是数的角度?就是理性的角度,比如-190转化为无符号的时候,那么就按最接近的转化为0。无符号太大超过有符号表示的范围的时候,转化位TMax。诸如此类。

什么是位级考虑?就是保持位模式不变。

对于大多数c语言的实现而言,处理同样字长的有符号数和无符号数之间相互转换的一般规则是:数值可能会改变,但是位模式不变。这里要注意:大多数c语言的实现,而不是全部,所以,这是一种人为的选择而已。

十六进制的0xcfc7的16位位模式既是-12345的补码表示,也是53191的无符号表示。

其实,c语言中无符号和有符号数之间的强制转换既然位模式保持不便,重新位模式计算就可以了。(大多数c语言实现下,之后就默认为c语言实现吧)

但单单从c语言的级别来说,有符号到无符号就是如果正,不变;如果负,就加上一个2w 就可以了。无符号到有符号转化,如果比较小,就不变,如果较大就剪掉一个2w 。

在c语言中,当执行一个运算时,如果一个是有符号,一个是无符号,那么有符号会转化为无符号的。

无符号数转换为更大的数据类型,只是简单的在高位补0,称为0扩展。有符号数转换为更大的数据类型时,高位添加开始最高位,称为符号扩展。

从无符号或者有符号转换位更大的有符号或者无符号时,先改变大小,再改变符号。换句话说,还是上面一句话概括了,因为,相同字长改变符号是不会变动位模式的。

先大小再符号是c语言标准要求的。

截断,也就是减小位数,这个就是直接截断,不管有符号还是无符号。截断之后,是有符号就有符号,无符号就无符号,截剩下的位模式在那里,不会变。解释不同而已。

2.3 整数运算

无符号运算可以被视为一种模运算形式。

无符号加法当溢出的时候,直接去掉溢出的值就可以了。

一个算法运算溢出,是指完整的整数结果不能放到数据类型的字长限制中去。当执行c程序时不会将溢出作为错误而发信号。

这里有一点要注意就是:无符号加法运算是c语言里面的,其结果就是要么不溢出,要么溢出后减去2w ,这对应于实际的c运算结果,但是不是数学上的结果。

无符号加法的溢出判断就是结果是否小于加的数,如果溢出了肯定小于执行加法的2个数。

补码的加法也是截断,位级的运算和无符号是相同的,但其结果有4种:负溢出——就是两个负数的补码相加之后最高位变成0(如果第二高位相加不进一的话,必然负溢出),正溢出——就是两个正数的补码相加之后最高位为1,正负相加是不可能溢出的。其他两种结果位负或者结果位正,没有溢出。

对应的,无符号加法只有一种溢出。

无符号减法:这里使用了叫做加法逆元的元素,就是位级取反+1,0的话保持。这么说吧x-y,xy都是无符号数,那么就可以看作是x+(-y),这个-y就是y的加法逆元,如果y是0则-y位级就是0,如果y不是0,则-y就是y的位级取反+1。 然后就是无符号加法了。这其实就是正常的无符号减法,只是可以向前借一。不管一从那里借的,但就是可以借。

所以无符号减法就是不需要就不需要借,需要就直接借的二进制减法。

补码减法也是一样的,就是借,需要就借,不需要就不借。

总结起来:不管是无符号数的加法减法,还是有符号数(补码)的加法减法,都是二进制的加法减法,只不过,进位不要,借位随便借。

但是当需要进位的时候就是溢出了。

回过头想想:补码的表示方法,-4其实就是0减去4的补码表示:-4是1100,4是0100,0-0100=1100。这就是补码本身阿。c语言中的-4,可以看作c语言中的0-4,可以看作是二进制中的0-0100得到的结果就是1100,而这正是补码的表示方法。

无符号乘法是截断,可以看作是c语言中xy4位,xy相乘得到5678位的数,然后截断位4位,就是这样,数学上的乘法,位级的截断。好理解的一类。

补码的乘法也是一样的,数学上的乘法,位级的截断。

同一组位模式的数进行无符号乘法和补码的乘法,数学上的乘法,位级的截断,截断后的位模式是一致的。

乘以常数,不论是无符号数还是补码,都可以通过左移的组合来替代。

除以常数,无符号使用逻辑右移,补码使用算术右移。

无符号的逻辑右移没问题,补码的算术右移:(x<0?(x+(1<<k)-1):x)>>k,这么复杂貌似是由于舍入的问题。

2.4 浮点数

 IEEE浮点——如何表示浮点数,如何执行运算。表示和运算是标准的核心。

二进制小数到十进制的转换,就是正常的计算,小数点左边是2的正幂,右边是负幂。

有限长度的编码,十进制和二进制都不能完全的表示小数,十进制比如1/3,二进制比如1/5。

IEEE浮点标准用3部分表示一个浮点数,最高位——符号位,接着是k位的阶码,接着是n位的小数字段。

典型值始终存在的,(什么叫典型值,就是基本上都是这样的,不是这样的是特例,可以认为以后遇到的都是这样的),float是4B的,32位,1,8,23,如此分割。double64位的1,11,52。

IEEE浮点标准的阶位将浮点表示分为3种:

  • 正常情况:阶位,float的8位,double的11位,既不全为1,也不全为0;那么取值范围是1-254,1-2046。此时,阶位表示的值是x-127和x-1023。也就是-126-127,-1022-1023.对于小数字段,表示为1.fn-1fn-2...f0 。
  • 阶位都为0的情况,这个是阶位表示为1-127和1-1023,也就是-126和-1022。小数字段表示为0.fn-1fn-2...f0 。这其实正好和正常情况衔接上,上一种阶位最小时,也就是-126和-1022,和本行对应,同时上行中的小数字段最小是1,而本行最大是f全为1的情况,刚好小那么一点点。值得注意的是0在这里定义,上行中由于是1.f所以总不可能是0的,而本行,是0.f,f为0,也就是说f都是0的时候,就是0了,不管阶位是多少都是0,但符号位很奇怪,符号位1和0两种情况都是0,这里认为是+0和-0,。
  • 阶位都为1的情况:这个时候,小数域为0时,对应于符号位出现+无穷和-无穷。如果小数域不为0,那么就是NaN,表示无解或者未定义的运算结果,有时也作为初始值。比如根号负数,数学上得到复数,但浮点数表示不了就得到一个NaN。

关于3种情况的记忆:记住正常情况,什么叫正常情况,就是阶位不是全0和不是全1的情况,记住不正常和正常之间是平滑过渡的,正常情况的最小阶位总是1,他还要减去一个偏置,所以就是1-bianzhi,小数域则最小是1,更小的情况就是不正常的情况了,为了平滑,阶位还是1-pianzhi,但小数域就是0-1之间了。而正常情况的最大情况是阶位的最低位为0,其他位为1,小数域全1,这就是最大值了,然后再大就是无穷了。然后再记住一个NaN就可以。

正常情况中的最小值到最大值之间的变化不是保持统一步长的,而是不断变大的步长。

而最有趣的,从0到正无穷大的过程的位级变化就是无符号整数的加一过程。从0到负无穷大其实也是这一过程。

这里给出典型的float和double的取值范围:float——+3.4*1038,double——+1.88*10308。这是最大的值和最小的值,而不是精度。

舍入,找到可以用浮点形式表示的最接近的匹配值。IEEE浮点格式定义了四种不同的舍入方式:向偶数舍入,向0舍入,向下舍入,向上舍入,后三种比较好理解,就是一般的字面意思。

向偶数舍入就怪一点,首先是1.4和1.6,这个简单就是普通的舍入,1.4到1,1.6到2,关键是1.5,结果是舍入到2,为什么因为2是偶数,那3.5呢?到4,那2.5呢,到2!为什么?因为2是偶数,而3不是,就是这样。

向偶数舍入和平常数学上的舍入有一点区别,数学1.5总是向上的。而偶数舍入的好处是平均了统计误差。这也好理解,1.5,2.5,3.5,4.5,如果数学上舍入,就成了2,3,4,5,平均值是3.5,而向偶数舍入是2,2,4,4,平均是3,实际的平均呢?就是3.

二进制中的向偶数舍入,0是偶数,1是奇数。换句话说就是,倾向于使最低有效位为0.

这里只需记住一点,1.4和1.6的舍入还是正常的数学舍入,因为不是正中间,1.5这种才需要向偶数舍入。

IEEE标准指定了一些浮点数的运算标准,这些标准是制定的,是标准,也就是说不同的硬件厂商之间会给出这些标准,硬件的实现可能不一样,但是最终使有这些属性的。

浮点加法:有交换性,但没有结合性,有单调性。

浮点乘法:有交换性,但没有结合性,不具备分配性,有单调性。

c语言标准不要求机器使用IEEE浮点,所以没有标准的方法得到-0,正负无穷,也没办法改变舍入方式。舍入方式由机器决定,如果机器使用IEEE浮点,那么就是向偶数舍入。

在int,float和double之间进行强制转换时,原则如下:

int——>float,不会溢出,这是肯定的,但是可能会舍入,这也是显然的,典型的int32位,而float的小数段是23位,如果int用到了23位以上就产生了舍入。

int或者float——>double,完全不会有问题,52位小数字段,比int和float都多很多。

double——>float,可能为正负无穷,也可能舍入。

float或者double——>int,向零舍入,这里值得注意,如果说上面的都是显然的,这里就是规定了。这还是可以转换的情况,如果溢出,c标准没有给出固定的结果,机器决定了。

2.5 小结

(over)

posted @ 2012-04-19 10:35  ray hill  阅读(581)  评论(0编辑  收藏  举报