引言
先說幾句屁話,覺得啰嗦可以忽略跳過這段屁話。
俗話說:眼看他起高樓,眼看他宴賓客,眼看他樓塌了。我想這句話放在我們做技術的,也很合適——基礎不牢,地動山搖。
盡管我們很多人不是做基礎開發的,但是操作系統、數據結構和算法、計算機網絡、設計模式……這些IT領域的基礎性學科,對于我們來說其實挺重要的,比如做優化、跨語言學習、工程框架搭建……。基礎雖然不能決定眼前,但是能夠決定我們在這條路上走多遠。框架那么多、特效那么多,真心沒有興趣一個個都摸一遍,所以偶爾回過頭去翻看這些基礎性的東西是挺有意思的。
- 是否還記得在學操作系統的時候,很困惑計算機為什么要用補碼存儲數據,而不是用我們人更容易理解的原碼來進行存儲呢?
關于這個問題,相信很多教這門課的老師以及工作多年的coder也解釋不清,甚至不知道這個概念。
- 本文將嘗試從理性結合感性的角度去說明計算機為什么用補碼存儲數據,當我們明白這個問題后,那么,我們就可以去理解另一個衍生問題——數據溢出。我們先來看一段關于數據溢出的Java代碼片:
/*char是無符號數,16位存儲,表示范圍是0~2^16-1(即0~65535)*/
char ch = Character.MAX_VALUE; // ch為65535
ch += (char) 1; // 加1后,引起數據溢出,則ch為0
/*int是有符號數,32位存儲,表示范圍是-2^31~2^31-1(即-2147483648~2147483647)*/
int i = Integer.MAX_VALUE; // i為2147483647
i += 1; // 加1后,引起數據溢出,則i為-2147483648
- 至于上述代碼片的執行結果為什么會這樣,暫時不解釋,希望通過文章循序漸進的過程來說明溢出的問題。
fuck概念
- 計算機用二進制來表示數據,這個大家應該都了解(不了解的找塊板磚拍死自己算了)。
- 沒有特殊說明,本文都以4位存儲單元來說明
- 下面幾個小節會提到一些關鍵概念,不要對這些概念恐慌,這些概念會結合例子或者對比的形式,盡量以通俗簡潔的文字來說明,保證人人都能看的懂
加法器
- 計算機只有加法器沒有減法器,兩個數的減法運算會被計算機轉換為加法運算。(先埋個伏筆——通過補碼進行表示,即可將減法運算轉換為加法運算)
模、補數
-
在日常生活中,有許多化減為加點例子。我們以最平常的鐘表為例,時針逆時針撥x(0<x<12)格和時針順時針撥12-x格,效果是相同的。比如,時針從10點調整到5點有以下兩種方法:
- 時針逆時針撥5格,相當于做減法:10 -5 = 5
- 時針順時針撥7(即12 - 5)格,相當于做加法:10 + 7 = 12 +5 = 5(MOD=12)
總結,x + (MOD - x) = MOD就是模,x和MOD - x就是一對“互補”的數,即原數x的補數為MOD - x或者原數MOD - x的補數為x。通過對鐘表撥時針的例子可以發現,用補數(7)代替原數(5),可把減法轉變為加法(出現的進位就是模,進位舍棄)。
二進制數的模,先來看下兩個個例子(此處我們忽略符號):
- 2位存儲所能表示的最大數是11(10進制:3 = 2^2 - 1),比他大1的是11 + 1 = 100(10進制:4 = 2^2),那么這個100則是2位存儲所能表示的所有數據的模。
- 4位存儲所能表示的最大數是1111(10進制:15 = 2^4 - 1),比他大1的數是1111 + 1 = 10000(10進制:16 = 2^4),那么這個10000則是4位存儲所能表示的所有數據的模。
通過對上面兩個例子可以推論:一個二進制數的最高位位數用n表示,那么該二進制數的模就是2^n。
原碼、反碼、補碼
- 先來看一張國內外教材對比的表(出自《計算機教育》2015年第10期的文章——《原碼、反碼和補碼的教學討論》)
注意下ones' complement和 two's complement的撇號(')的位置(學過英語的都知道,撇號(')在s前和s后的含義是不一樣的)
-
給定一個有符號數x,來對比下國外和國內教材對原碼、反碼、補碼的表示:
- 國外教材
- sign and magnitude representation(原碼):最高位位符號位(0表示正數,1表示負數),剩余位(數據位)為x的大小。
- ones' complement representation(反碼):如果x為正數,則是其二進制表示;如果x為負數,則是其對應正數的bit complement/bitwise NOT(按位取反)——執行每一位邏輯否定的一元操作。可用公式表示為:
- [x]反 = (2^n - 1) - |X|(其中n為將符號位算在內的位數,|X|為絕對值)
- two's complement representation(補碼):如果x為正數,則是其二進制表示;如果x為負數,則是其對應正數的二的補(所有位取反后加1)。可用公式表示為:
- [x]補 = (2^n) - |X| = [x]反 + 1(其中n為將符號位算在內的位數,|X|為絕對值)
- 國內教材
- 原碼:最高位為符號位,剩余位(數據位)為x的絕對值。
- 反碼:如果x為正數,則與原碼相同;如果x為負數,符號位保持不變,數據位取反。
- 補碼:如果x為正數,則與原碼相同;如果x為負數,符號位保持不變,數據位取反,然后加1(若符號位有進位,則舍棄進位)。
- 國外教材
-
對比國內外教材的表述,是否發現高下立現:
- 國內教材畫蛇添足,并且容易引起誤解:
原碼是反碼和補碼的基礎,反碼和補碼由原碼轉化而來原碼、反碼和補碼的符號位相同
- 國外教材,則非常通俗:
- 求解一個數的反碼和補碼,根本不需要知道原碼,直接通過它們的兩個對應公式即可,甚至可以說原碼與反碼和補碼沒有半毛錢關系,反倒是反碼和補碼存在關系——補碼 = 反碼 + 1
- 原碼的出發點是符號的表示(符號位),即用0表示正數,用1表示負數;反碼和補碼的出發點是減法的運算,即用兩個正數的加法取代兩個數的減法
- 國內教材畫蛇添足,并且容易引起誤解:
狗日的國內教材和翻譯,真是誤人子弟啊
計算機為什么用補碼存儲數據
- 上面鋪墊了這門久,終于要進入第一個正題——計算機為什么用補碼存儲數據。為了不引起混淆,我們就以國外教材對于原碼、反碼和補碼的表示法來進行說明。簡單起見,以4位存儲表示有符號數為例,通過原碼、反碼和補碼的表示法來生成一張表:
|有符號數(十進制)|sign and magnitude representation(原碼)|ones' complement representation(反碼),[x]反 = (2^n - 1) - \X|two's complement representation(補碼),[x]補 = (2^n) - \X|
|:-:|:-:|:-:|:-:|
|+7|0111|表示方式不變|表示方式不變|
|+6|0110|表示方式不變|表示方式不變|
|+5|0101|表示方式不變|表示方式不變|
|+4|0100|表示方式不變|表示方式不變|
|+3|0011|表示方式不變|表示方式不變|
|+2|0010|表示方式不變|表示方式不變|
|+1|0001|表示方式不變|表示方式不變|
|+0|0000|表示方式不變|表示方式不變|
|-0|1000|1111|0000(求解過程:[x]補 = 2^n - \x\ = 2^4 - \-0\ = 2^4 - (+0),使用二進制則為10000 - 0000 = 10000,超過4位(有進位),那么舍棄進位1,最終結果就是0000)|
|-1|1001|1110|1111|
|-2|1010|1101|1110|
|-3|1011|1100|1101|
|-4|1100|1011|1100|
|-5|1101|1010|1011|
|-6|1110|1001|1010|
|-7|1111|1000|1001|
|-8|超出4個bit所能表達范圍|超出4個bit所能表達范圍|1000|
|備注|零重碼,二進制存在兩種表示方法:0000和1000|零重碼,二進制存在兩種表示方法:0000和1111|零無重碼,同時解決了原碼和反碼不能表示-8的問題|
通過上述表格,可以很自然的總結出一個結論:補碼表示法(two's complement representation)可以防止0的機器數重碼,同時又解決了原碼和反碼無法表示-8的問題,這樣就極大的簡化了計算機的硬件設計。
結合之前提到的時鐘例子,我們把補碼表示法(two's complement representation)所表示的四位存儲單元,按照從0000到1111遞增的方式,均勻的分布在時鐘的表盤上。于是,我們就可以得到下面這張圖(圖片來自于這里):
-
OK,繼續以時鐘的方式來觀察上圖:
- 順時針方向位加法,逆時針方向為減法
- 模為2^n:在1111處順時針撥一格,就到了0000。用數學的方式,即1111 + 1 = 10000,進位舍棄則結果為0000,那么四位存儲的模就是10000(2^4)
-
減法轉換為加法:3 - 1 = 3 + (-1) = 0011 + 1111 = 0010,眼尖的人可能會說0011 + 1111明明等于10010,怎么會是0010?還記得之前提過的最高位進位舍棄嘛,因此對于4位存儲來說,進位舍棄后就是0010 = 2。
若減法不轉換為加法,那么3 -1 = 0011 - 0001 = 0010 = 2
-
數據溢出:當0111(7)加1時,按照我們人的思維來說,應該結果為8,但是對于機器來說則不是,因為0111(7)是四位存儲所能表示的最大有符號數,所以它是無法表示01000(8)的,這個時候我們就說數據溢出了。那么數據溢出該怎么辦呢?很簡單,機器的思考方式顯然和我們人腦不一樣,機器按照上面環形圖的方式,由于0111(7)加1是順時針造成的數據溢出,那么我們可以把機器的操作想象成在0111(7)處順時針撥了一格,我們再去對照下環形圖發現這時候指向了1000(-8)。
把這個過程想象成撥時針就OK了,對于1000(-8)減1也是同樣道理
-
至此,我們完全可以總結一下,并解答計算機為什么用補碼存儲數據:
- 計算機只有加法器沒有減法器,兩個數的減法運算會被計算機轉換為加法運算,而補碼正好能夠解決減法轉換為加法的問題
- 防止機器發生零重碼,同時解決了原碼和反碼不能表示-8的問題,這樣極大的簡化了計算機的硬件設計
- 以循環的方式解決數據溢出的問題
從補碼的角度解答代碼片中的數據溢出
既然已經知道了計算機為什么用補碼存儲數據,那我們就可以回過頭去消滅文章開頭的數據溢出的代碼片了。由于代碼片中ch和i的問題是一樣的,那我們就選擇ch來進行分析,另一個留給你們分析。
-
在Java中,char為無符號數,16位存儲,表示范圍是0~2^16-1(即0~65535)。
- 首先,我們按照0000 0000 0000 0000到1111 1111 1111 1111遞增的方式,均勻的分布在時鐘的表盤上,圖就不畫了,自己在腦中想象一下或者畫個草稿。
- 然后,找出數據溢出點,通過觀察char環形圖可以發現數據溢出點是0(0000 0000 0000 0000)和65535(1111 1111 1111 1111)
- 最后,我們的ch = 65535 + 1,那么很顯然發生了數據溢出,按照撥時針的方式就可以得出ch = 0
Perfect,是否解答了當初學操作系統和編程的時候,困擾你們很久的問題。送給大家一句話:有些概念可能當時不理解,但是隨著經驗多累積和回顧的多了,自然而然就理解了。
貼出我看的關于補碼的文章鏈接,有幾篇中文文章對于某些知識點可能說錯了,切記要帶著批判的觀點去看:
- 《深入理解計算機系統》第二章
- 《計算機教育》2015年第10期的文章——《原碼、反碼和補碼的教學討論》
- 補碼原理的個人理解
- 為什么計算機用補碼存儲數據?
- 原碼、反碼和補碼
- 補碼
- Class #7 - Signed Binary Numbers, Subtraction and Overflow