前言
在程序中,我們經常會涉及到數值計算操作,比如從最簡單的數值的表示,到加、減、乘除,再到移位等等,而對於這些往往我們都信心十足,常常用直覺告訴自己:這麼做沒錯!但是,計算機並不是靠直覺來感知數值的,它可是個很嚴謹的傢伙,如果你只是用直覺告訴它你要做的事,那麼它也會告訴你:哼!想要我幫你做事情,那麼就按我的規則來吧! 想要知道計算機有哪些規則?那好,本篇就帶你瞭解計算機關於信息表示和處理內容,但我們絕不是簡單的瞭解,我同時還會揭開隱藏在數值計算中的陷阱,這樣,在以後的編碼過程中,我們就能避免可能的風險。
你可能會某一個瞬間矇頭寫下如下代碼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的加法逆元