CSI-II:信息的表示与处理-数值陷阱(一)


前言

         在程序中,我们经常会涉及到数值计算操作,比如从最简单的数值的表示,到加、减、乘除,再到移位等等,而对于这些往往我们都信心十足,常常用直觉告诉自己:这么做没错!但是,计算机并不是靠直觉来感知数值的,它可是个很严谨的家伙,如果你只是用直觉告诉它你要做的事,那么它也会告诉你:哼!想要我帮你做事情,那么就按我的规则来吧! 想要知道计算机有哪些规则?那好,本篇就带你了解计算机关于信息表示和处理内容,但我们绝不是简单的了解,我同时还会揭开隐藏在数值计算中的陷阱,这样,在以后的编码过程中,我们就能避免可能的风险。

     你可能会某一个瞬间蒙头写下如下代码intn =500*400*200*300;然后执行后发现结果居然是个负值,或者苦苦的等待循环退出。可能再回去看的时候才恍然大悟,哦,溢出了。亦或者你不明白这两个表达式的结果为什么不同?(3.14+1e20)-le20 ,3.14+(le20-le20).又或者意想类似这样的表达式会始终成立x*x>0,(x>0)||(x-1)<0, x<0 || -x>=0。噢,你可能会说从没遇到过这样的场景,或者说从来都是假设数据在一定的范围之内的,不会取到那么极端的数据。更何况那样的表达式不一定会用的到。我们会这么想,假设在数据取值的范围内,我们的计算没有任何差错,我们就以为程序是没有问题,而其实这离程序的”绝对正确”还差一步。你可能会反驳,追求绝对的正确有意义?我们始终能够满足业务要求就是正确的?更何况你那出现的情况几乎是不可能的?可能,我们还没遇到过由于数值计算的误差所带来的风险和损失,或者由于计算机某一程序的算术计算的微妙细节而产生的计算机安全漏洞,最终被黑客所利用。是的,这些情况出现的概率很低,但我们有必要深究保证程序的绝对正确,有必要深究数值计算的细节,因为关乎我们程序的正确性和安全性。

整数编码

         我们先看C语言中支持的整数类型和它对应的取值范围:

            

                            

说明:C语言标准只是定义了每种数据类型必须能够表示的最小的取值范围。

 

1. 无符号数的编码

假设一个整数数据类型有w位,位向量为x=[xw-1,xw-2,…..,x0]表示向量中的每一位,那么我们用下面的公式来表示无符号数:

                                   

   其中B2Uw的范围{0,…….2w-1},最大值为UMaxw= 2w-1.

2.   补码编码

在计算机中,最常见的有符号数的负数值就是通过补码来表示的。同样我们也能给出补码的公式:


公式中最高有效为xw-1称为符号位,它的权重为-2w-1.符号位为1表示为负,否则为正数,下面我们看几个例子:


明白了补码运算,那么,让我们考虑下w位补码所能表示的值得范围。它能表示的最小值为位向量[1000…..0],其整数值为TMinw=-2w-1,而最大值为位向量[011111….1],其整数值为TMaxw =2w-1-1.

 

3.有符号数和无符号数的转换

        

对于数值的转换,在计算机中的处理非常简单,假如声明一个int型的有符号整型变量x和一个无符号整型变量u,通过u=(unsigned)x表达式,将x转换为无符号整型。这时候x和u的位向量是完全相同的,只是对于不同类型的整型给予不同的解释。也就是转换的前提条件是保持位模式不变。

                                    


那么,对于相同的位模式x我们可以得到:

B2Uw(x)-B2Tw(x)=Xw-12w

-> B2Uw(x) = Xw-12w+ B2Tw(x)

如果令x = T2Bw(x),那么我们就可以得到如下公式:

B2Uw(T2Bw(x))=T2Uw(x)=xw-12w+x

在x的补码表示中,位xw-1表示x是否为负. 得到

                                        

注:x代表位向量,而x代表数值

映射关系如下:

    

         同样反过来我们也可以得到无符号转换为有符号数的公式U2Tw.

                                            

         映射关系如下:

 

 

3. 拓展位表示

我们经常会遇到需要将较小位向量的数值转换为较大位向量的数值。而我们针对这样的情况只要记住两个概念:零拓展和符号拓展,关于这两个概念,请参考附录。

4. 截断

同样和拓展对应的也有一个操作就是截断,表示减少一个数值的位数。比如下面的代码段:

Int x =53191; /*00 00 cf c7*/

short sx = (short)x; /*-12345-----cf c7*/

int y =sx;/*-12345------ff ff cf c7*/

我们发现经过一次转化x的值变成了负值。而原因就在与中间的截断和符号拓展。

        

关于上面的介绍,可能你都足够熟悉了,只是觉得在日常的编码中不会有多大问题,好吧,为了引起对于符号转换的重视,我们看个例子:

 

Float sum(float a[],unsigned length){

  Int I;

  Float result = 0.0f;

  For(I=0;i<=length-1;i++){

         Result+=a[i];

  }

  Return result;

}

恩….,貌似没有问题,当length = 0时,结果理应为0.0,但却出人意料的得到一个存储器读取错误。细心的你可能一下就发现了问题所在,在for循环内length-1的值为UMAX,因为length为无符号整型。

 

可能这段代码的错误还算明显,可下面的呢?你想通过调用strlen函数来判断一个字符字符串是否比另一个更长。

Int strlonger(char*s,char* t){

         Returnstrlen(s)-strlen(t)>0;

}

当我们取一个比字符串t还短的字符串s时,我们发现结果居然是大于0,这让我们很吃惊。可当我们知道了strlen原型后才知道原来strlen计算返回的是一个size_t类型的值,即unsigned int 是一个无符号整型,因此原因也就很清楚。

 

可能你觉得上面的代码都还不够现实,或者心存侥幸(我们常常都这样L),那么看下面的一段代码:

#define KSIZE 1024

Char kbuf[KSIZE];

Int copy_from_kernel(void* user_dest,intmaxlen)

{

 Intlen = KSIZE<maxlen ? KSIZE:maxlen;

 Memcpy(user_dest,kbuf,len);

 Return len;

}

这段代码是函数getpeername实现中的一段代码,熟悉网络的朋友一定不陌生,这个库函数根据给定的socket来获取对端地址。函数copy_from_kernel是将系统内核维护的数据复制到用户可以访问的存储区。对于一般用户来说,内核维护的数据是不可读的,因为这可能包含其他用户的和系统运行得敏感信息,但现实的kbuf区域是用户可读的。参数maxlen给出了分配给用户区的缓冲区长度,这个缓冲区是用user_dest指示的。可是如果我们清楚memcpy的原型memcpy(void*dest,void*src,size_t size);就很快能反应上来,如果给maxlen传递一个负值,就可以访问未被授权的内核存储器区域,从而导致安全漏洞。

 

数值计算

1.  无符号加法

我们或许都曾惊奇的发现,两个正数x和y相加的值为负;表达式x<y与x-y<0的结果并不都是一致。对于两个非负整数0<=x,y<=2w-1,我们就有一个可能的范围0<=x+y<=2w+1-2,这个和可能需要w+1位。一般的,如果x+y<2w,那么w+1位为0,丢弃这个值并不影响结果,可如果2w<=x+y<2w+1,那么w+1位的值会为1,丢弃这个值就相当于减去了2w,因此得到下面的结论:

        

那么对于x+y=s的结果我们怎么判断s是否溢出?答案就是当且仅当s<x或者y<s时,发生了溢出。因为如果x+y溢出s就可以表示为s=x+y-2w,而y或者x满足x<2w,y<2w.

那么s=x+(y-2w)<x或者s=y+(x-2w)<y。

 

2.  补码加法

对于补码加法,在给定范围-2w-1<=x,y<=2w-1-1的整数x和y,他们的和就在范围-2w<=x+y<=2w-2。这可能也需要w+1位。

我们可以对补码加法运算得到下面的公式:

 

    

同样我们怎么判断x+y是否溢出呢?

Int tadd_ok(int x,int y){

           Int sum =x+y;

           Intneg_over = x<0 && y<0 && sum>=0;

           Intpos_over = x>=0&&y>=0&&sum<0

           Return    !neg_over&&!pos_over;

}

没错,上面的代码就是按照补码加法的溢出规则写出来的,即两个负数相加得到非负值,两个非负值相加得到一个负值,当然,你从上面的映射图看到的更加明显。可能你会觉得上面的代码太过冗余,那么还有另一个可行的方法就是将通过对两个待相加数进行异或,并判断结果最高位的是否和其中一个的加数的最高位符号一致。

 

3.  补码的非

范围在-2w-1<=x<2w-1中的每个数字x都有一个加法逆元(见附录),首先对于x!=-2w-1,我们可以看到它的加法逆元就是-x,而对于x =-2w-1=TMinw,-x=2w-1不能表示为一个w位的数。

而其位模式是和-2w-1相同的,所以他的加法逆元实际上就是他本身。所以对于范围

-2w-1<=x<2w-1内的x,补码的非运算公式为:

 

4.  无符号乘法

范围在0<=x,y<=2w-1内的整数x和y可以表示 为w位的无符号数,但它们的乘积x*y的取值范围为0到(2w-1)=22w-2w+1+1之间。C语言中无符号乘法被定义为产生w位的值,就是2w位的整数乘积的低w位表示的值。因此无符号乘法运算的结果为:

X*y =(x*y) mod 2w

 

5. 补码乘法

范围在-2w-1<=x,y<2w-1-1内的整数x和y可以表示为w位的补码数字,但是他们的乘积

X*y的取值范围在-2w-1*(2w-1-1)=-22w-2+2w-1和-2w-1*-2w-1=-22w-2之间。要用补码表示这个乘积可能需要2w位。W位的补码乘法运算的结果为:

X*y = U2Tw((x*y)mod2w)

 

6. 乘以常数

大多数机器上,整数乘法指令相当慢,需要10个或者更多的时钟周期,然而其他整型运算(加法、减法、位级运算和移位)只需要1个时钟周期。因此,编译器使用了一项重要的优化,试着使用移位和加法运算的组合来替代乘以常数因子。如一个程序包含x*14

编译器会将乘法重写为(x<<3)+(x<<2)+(x<<1).

 

7.  除以2的幂

在大多数机器上,整数除法要比整数乘法更慢-需要30个或者更多地时钟周期,除以2的幂也可以用移位运算来实现,只不过用的是右移。无符号和补码数分别使用逻辑移位和算术移位来达到目的。

小结

           通过本篇我们了解了整数及其运算的规则,并从中看到了由于疏忽可能在编程中带来的风险。最后我们以几个常见的跟数值有关的逻辑判断题结尾。

设有整型变量int x = val();

1>     (x>0) || ((x-1)<0)false,当x等于TMin32(-2147483648)时,那么x-1等于TMax32(2147483647)

2>      x*x>=0false,当x=65535(0xffff)时,x*x=-131071(0xFFFE0001)

3>      x<0||-x<=0true.对于TMin我们知道它的加法逆元就是它本身

4>      x>0 ||-x>=0 false,当x等于Tmin(-2147483648).那么x和-x都为负数

 

下一篇介绍浮点及其浮点运算的规则。

附录

字,计算机系统中字的的概念是用来表示整数和指针数据的标称大小。虚拟地址就是使用字来进行编码的,也就是我们通常谈论的32位机,64位机。

小端法—最低有效字节存储在最前面(即数值中的低字节存储在内存中的低字节)

大端法—最高有效字节存储在最前面

(我们可以自己写个简单的程序查看自己的系统是哪种表示法)

零扩展:当一个较小的数转换为较大的数时,在较小数的位数前添加0

符号拓展:当一个较小的数转换为较大的数时,根据较小数的最高位的值来添加。

加法逆元:对于一个数n,如果数m和其相加为0,那么m就叫做n的加法逆元


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章