官术网_书友最值得收藏!

2
學習C語言的預備知識

我們在第1章已經大致介紹了C語言的概念以及編譯、連接流程。我們知道C語言是高級語言中比較偏硬件底層的編程語言,因此對于用C語言的編程人員而言,了解一些關于處理器架構方面的知識是很有必要的,對于嵌入式系統開發的程序員而言更是如此了。

另外,C語言中有很多按位計算以及邏輯計算,所以對于初學者來說,如果對整數編碼方式等計算機基礎知識不熟悉,那么對這些操作的理解也會變得十分困難。因此,本章將主要給C語言初學者、同時也是計算機編程初學者,提供計算機編程中會涉及的基本知識,這樣,在本書后面講解到一系列相關概念時,初學者也不會感到陌生。

2.1 計算機體系結構簡介

圖2-1為一個簡單的計算機體系結構圖。

圖2-1 簡單的計算機體系結構圖

一個簡單的計算機系統包含了中央處理器(CPU)以及存儲器和其他外部設備。而在CPU內部則由計算單元、通用目的寄存器、程序序列器、數據地址生成器等部件構成。下面我們將從外到內分別簡單地介紹這些組件。

2.1.1 貯存器

貯存器(Storage)盡管在圖2-1中沒有表示出來,但我們對它一定不會陌生,比如我們在PC上使用的硬盤(Hard Disk)就是一種貯存器。貯存器是一種存儲器,不過它可用于持久保存數據而不丟失。因此我們通常把具有可持久保存的存儲器統稱為貯存器。現在PC上用得比較現代化的貯存器就是SSD(Solid-State Disk)了,俗稱固態硬盤。當然,貯存器就其存儲介質來說屬于ROM(Read-Only Memory),即只讀存儲器。這類存儲器的特點是數據能持久保留,比如我們PC上的文件,即便在關閉計算機之后也一直會保存在你的硬盤上,而且PC上的軟件往往也是以可執行文件的形式保存在硬盤上的。但是它的讀寫速度非常緩慢,尤其是老式的SATA磁盤,寫操作則更慢。因為通常對ROM的數據修改都要通過先讀取某段數據所在的扇區,然后對該數據進行修改,再擦除所涉及的扇區,最后把修改好的數據所包含的扇區再寫回去。而對于ROM來說,其扇區是有寫入次數限制的,所以寫入次數越多,損耗就越大。當我們發現一個硬盤訪問很慢的時候,通常就是其扇區(或磁道)已經破損嚴重了,這是在不斷糾錯并交換良好的扇區所引發的延遲。在嵌入式系統中,我們用的ROM一般是EPROM、EEPROM、Flash ROM等。這些硬件的詳細資料各位可以從網上輕易獲得,這里不再贅述。

2.1.2 存儲器

存儲器(Memory)一般是指我們通常所說的內存或主存(Main Memory)。其存儲介質屬于RAM(Random Access Memory),即隨機訪問存儲器。它的特點是訪問速度快,可對單個字節進行讀寫,這與ROM需要擦除整個扇區再對整個扇區寫入的方式有所不同,因此更高效、靈活。但是RAM的數據無法持久化,掉電之后就會消失。此外,RAM的成本也比ROM高昂得多,我們對比一下16GB的內存條與256GB SSD的價格就能知道。然而正因為RAM的訪問速度快,并且離CPU更近,所以在許多系統中都是將程序代碼與數據先讀取到RAM中之后再讓CPU去執行處理的。當然,在一些嵌入式系統中也有讓CPU直接執行ROM中的代碼并訪問讀ROM中常量數據的情況,因為這類系統中總線頻率以及CPU頻率都相對較低,并且ROM也是與CPU以SoC(System-On-Chip,系統級芯片)的方式整合在一塊芯片上的,所以訪問成本要低很多。而有些環境對ROM的讀取速度甚至比讀取RAM還更快些。

注意:在本書中所出現的“存儲器”均表示內存,即RAM。而將可持久保存數據的存儲器都一律稱為“貯存器”。了解了這些概念后,我們在國外網站購買Mac或PC時,看到相關的術語就不會手足無措了。這里提供Apple美國官網的Mac配置信息網頁,各位可以參考:www.apple.com/macbook-pro/specs/。

2.1.3 寄存器

寄存器是在CPU核心中的、用于暫存數據的存儲單元。一般處理器內部對數據的算術邏輯計算往往都需要通過寄存器(Register),而不是直接對外部存儲器進行操作。因此,如果我們要計算一個加法或乘法計算,需要先把相關數據從外部存儲器讀到處理器自己的通用目的寄存器中,然后對寄存器做計算操作,再將計算結果也放入寄存器,最后將結果寄存器中的數據再寫入外部存儲器。寄存器的訪問速度非常快,它是這三種存儲介質中速度最快的,但是數量也是最少的。像在傳統的32位x86處理器體系結構下,程序員一般能直接用的通用目的寄存器只有EAX、EBX、ECX、EDX、ESI、EDI、EBP這7個。還有一個ESP用于操作堆棧,往往無法用來處理通用計算。

2.1.4 計算單元

計算單元一般由算術邏輯單元(ALU)、乘法器、移位器構成。當然,像一般高級點的處理器還包含除法器,以及用于做浮點數計算的浮點處理單元(FPU)。它們一般都直接對寄存器進行操作。而涉及數據讀寫的指令會由專門的加載、存儲處理單元進行操作。

2.1.5 程序執行流程

處理器在執行一段程序時,通常先從外部存儲器取得指令,然后對指令進行譯碼處理,轉換為相關的一系列操作。這些操作可能是對寄存器的算術邏輯運算,也可能是對存儲器的讀寫操作,然后執行相關計算。最后把計算結果寫回寄存器或寫回到存儲器。不過處理器在執行一系列指令的時候并不是每條指令都必須先經過上面所描述的整個過程才能執行下一條,而是采用流水線的方式執行,如圖2-2所示。

圖2-2 處理器執行流水線

圖2-2體現了一個簡單的處理器執行完一條指令的完整過程。我們這里假設從第一個取指令階段到最后的寫回階段,這5個階段均花費1個周期,倘若不是采用流水線的方式,而是每完成一條指令的執行再執行下一條指令,那么每條指令的處理都需要5個周期。而一旦采用流水線方式處理,那么我們可以看到,在第一條指令執行到譯碼階段時,處理器可以對第二條指令做取指令操作;當第一條指令執行到執行階段時,第二條指令執行到了譯碼階段,此時第三條指令開始做取指令階段,然后以此類推。這樣,當整條流水線填充滿之后,即執行到了第5條指令,那么對于后續指令而言,處理每一條指令的時間均只需要一個周期。

這里需要注意的是,并不是每條指令都需要訪存操作,只有當需要對外部存儲器做讀寫操作時才會動用訪存執行單元。然而大部分指令都需要寫回寄存器操作,即便像一條用于比較大小的指令,或一條系統中斷指令,它們也會影響狀態寄存器。當然,很多處理器會有空操作(NOP)指令,它僅僅占用一個時鐘周期,而不會對除了指令指針寄存器以外的任何寄存器產生影響。

2.2 整數在計算機中的表示

我們日常用的整數都是十進制數(Decimal),也就是我們通常所說的逢十進一。因為我們人類有十根手指,所以自然而然地會想到采用十進制的計數和計算方式。然而,現在幾乎所有計算機都采用二進制數(Binary)編碼方式,所以我們日常所用到的整數如果要用計算機來表示的話,需要表示成二進制的方式。

二進制數則是逢二進一,所以在整串數中只有0和1兩種數字。比如,十進制數0,對應二進制為0;十進制數1,對應二進制數1;十進制數2,對應二進制數10;十進制數3,對應二進制數11。因此,對于非負整數而言,二進制數第n位(n從0開始計)如果是1,那么就對應十進制數的2n,然后每個位計算得到的十進制數再依次相加得到最終十進制數的值。比如,一個5位二進制數10010,最低位為最右邊的位,記為0號位,數值為0;最高位為最左邊的位,記為4號位,數值為1。那么它所對應的十進制數為:24+21=18。因為該二進制數除了4號位和1號位為1之外,其余位都是0,因此0乘以2n肯定為0。圖2-3為二進制數10010換算成十進制數的方法圖。

圖2-3 5位二進制數對應十進制的計算

在計算機術語中,把二進制數中的某一位數又稱為一個比特(bit)。比特這個單位對于計算機而言,在度量上是最小的單位。除了比特之外,還有字節(byte)這個術語。一個字節由8個比特構成。在某些單片機架構下還引入了半字節(nybble或nibble)這個概念,表示4個比特。然后,還有字(word)這個術語。字在不同計算機架構下表示的含義不同。在x86架構下,一個字為2個字節;而在ARM等眾多32位RISC體系結構下,一個字表示為4個字節。隨著計算機帶寬的提升,能被處理器一次處理的數據寬度也不斷提升,因此出現了雙字(double word)、四字(quad word)、八字(octa word)等概念。雙字的寬度為2個字,四字寬度為4個字,所以它們在不同處理器體系結構下所占用的字節個數也會不同。

我們上面介紹了非負整數的二進制表達方法,那么對于負數,二進制又該如何表達呢?在計算機中有原碼和補碼兩種表示方法,而最為常用的是補碼的表示方法。下面我們分別對原碼和補碼進行介紹。

2.2.1 原碼表示法

對于無正負符號的原碼,其二進制表達如上節所述。而對于含有正負符號的原碼,其二進制表示含有一位符號位,用于表示正負號。一般都是以二進制數的最高有效位(即最左邊的比特)作為符號位,其余各位比特表示該數的絕對值大小。比如,十進制數6用一個8位的原碼表示為00000110;如果是-6,則表示為10000110。二進制的原碼表示示例如圖2-4所示。

圖2-4 二進制數的原碼表示

原碼的表示非常直觀,但是對于計算機算術運算而言就帶來了許多麻煩。比如,我們用上述的6與-6相加,即00000110+10000110,結果為10001100,也就是十進制數-12,顯然不是我們想要的結果。所以,如果某個處理器用原碼表示二進制數,那么它參與加減法的時候必須對兩個操作數的正負符號加以判斷,然后再判定使用加法操作還是減法操作,最后還要判定結果的正負符號,可謂相當麻煩。所以,當前計算機的處理器往往采用補碼的方式來表達帶符號的二進制數。

2.2.2 補碼表示法

正由于原碼含有上述缺點,所以人們開發出了另一種帶符號的二進制碼表示法——補碼。補碼與原碼一樣,用最高位比特表示符號位,其余各位比特則表示數值大小。如果符號位為0,說明整個二進制數為正數或零;如果為1,那么表示整個二進制數為負數。當符號位為0時,二進制補碼表示法與原碼一模一樣,但是當符號位為負數時,情況就完全不同了。此時,對二進制數的補碼表示需要按以下步驟進行:

1)先將該二進制數以絕對值的原碼形式寫好;

2)對整個二進制數(包括符號位),每一個比特都取反。所謂取反就是說,原來一個比特的數值為0時,則要變1;為1時,則要變0。

變換好之后,將二進制數做加1計算,最終結果就是該負數的補碼值了。

下面我們還是用6來舉例,+6的二進制補碼跟原碼一樣,還是00000110。而-6的計算過程,按照上述流程如下:

1)先將-6用絕對值+6的形式表示:00000110;

2)對每個比特位取反,包括符號位在內,得到:11111001;

3)將變換好的數做加1計算,最終得到:11111010。

由于二進制補碼的表示與通常我們可直接讀懂的二進制數的表示有很大不同,所以給定一個二進制補碼,我們往往需要先獲得其絕對值大小才能知道它的具體數值。獲得其絕對值的過程為:先判定符號位,如果符號位為0,那么就以通常的二進制數表示法來讀即可。如果符號位為1,那么就以上述同樣的過程得到其對應的絕對值。比如,如果給定11111010這個二進制數,我們看到最高位符號位為1,說明是負數,我們就以上述過程來求解:

1)先將該二進制數每個比特做取反計算,得到:00000101;

2)然后將變換得到的值做加1計算,最終獲得:00000110。

所以11111010的絕對值為00000110,即6。

對于補碼表示,我們已經知道最高位比特表示符號位,其余的表示具體數值。但是這里有一個特殊情況,即符號位為1,其余位比特為都為0的情況。比如一個8位二進制補碼:10000000,此時它的值是多少?因為我們通過上述流程,求得其絕對值的大小也是10000000,所以當前大部分計算機處理器的實現將它作為-128,但估計仍然有一些處理器會把它作為-0。因為C語言標準中對于數值范圍的表示已經明確表示出8位帶符號的整數范圍可以是-128到+127,也可以是-127到+127,但最小值不得大于-127,最大值不得小于+127。第5章會有更詳細的描述。

補碼的這種表示法的優點就是可以無視符號位,隨意進行算術運算操作。比如,像我們上面所舉的例子:6+(-6),計算結果:

00000110+11111010=00000000

最后,上述計算結果的最高位符號位所產生的進位被丟棄(在處理器中可能會設置相應的進位標志位)。我們自己計算的話也非常方便,在計算過程中,無需關心兩個二進制補碼的正負數的情況,也無需關心符號位所產生的影響。我們只需要像計算普通二進制數一樣去計算即可。把最終的計算結果拿出來判斷,是正數還是負數。當然,二進制補碼會產生溢出情況,比如兩個8位二進制補碼加法:

120+50=01111000+00110010=10101010

然而,這個數并不是170,而是-86。首先,170已經超出了帶符號8位二進制數可表示的最大范圍了;其次,最高位變為1,用補碼表示來講就是負數表示形式。所以,這兩個正數的加法計算就產生了負數結果,這種現象稱為上溢。如果我們要避免在計算過程中出現上溢情況,需要用更高位寬的二進制數來表示,以提升精度。比如,如果我們將上述加法用16位二進制數表示,那么就不會有上溢問題了。

另外,在C語言標準中沒有明確規定C語言編譯器的實現以及運行時環境必須采用哪種二進制編碼方式,而是對整數類型標明最大可表示的數值范圍。目前大部分C語言實現都是對帶符號整數采用補碼的表示方式。這些會在第5章做進一步講解。

2.2.3 八進制數與十六進制數

上面我們對二進制數編碼形式做了比較詳細的介紹。我們在編寫程序或者查看一些計算機相關的技術文檔時常常還會碰到八進制數與十六進制數的表示,尤其是十六進制數用得非常多。下面我們就簡單介紹一下這兩種基數(radix)的表示方法。

這里跟各位再分享一個術語——基數。基數也就是我們通常所說的,某一個數用多少進制表達。對于像“01001000是幾進制數”這種話,如果用更專業的表達方式來說的話就是,“01001000的基數是幾”。基數為2就是二進制;基數為10則是十進制。

八進制數是逢八進一,因此每位數的范圍是從0~7。八進制數轉十進制數也很簡單,我們可以用二進制數轉十進制數類似的方法來炮制八進制數轉十進制數——以一個八進制數每位數值作為系數,然后乘以8n,然后計算得到的結果全都相加,最后得到相應的十進制數。其中,n表示當前該位所對應的位置索引(同樣以0開始計)。比如,八進制數5271對應的十進制數的計算過程如圖2-5所示。

圖2-5 八進制數轉十進制數

八進制數對應于二進制數的話正好占用3個比特(范圍從000~111),一般在通信領域以及信息加密等領域會用到八進制編碼方式。而十六進制數比八進制數用得更多,因為十六進制數正好占用4個比特,即4位二進制數(范圍從0000~1111)。4個比特相當于半個字節。所以,無論是開發工具還是程序調試工具,一般都會用十六進制數來表示計算機內部的二進制數據,這樣更易讀,而且也更省顯示空間(因為一個字節原本需要8位二進制數,而十六進制數只要兩位即可表示)。下面就介紹一下十六機制數的表示方法。

十六進制數逢十六進一,因此每一位數的范圍是從0到15。由于我們通常在數學上所用的十進制數無法用一位來表示10~15這6個數,因而在計算機領域中,我們通常用英文字母A(或小寫a)來表示10; B(或小寫b)來表示11; C(或小寫c)來表示12;D(或小寫d)來表示13; E(或小寫e)來表示14; F(或小寫f)來表示15。十六機制數轉十進制數的方式與八進制數轉十進制數類似——以一個十六進制數每位數值作為系數,然后乘以16n,然后計算得到的結果全都相加,最后得到相應的十進制數。其中,n表示當前位所對應的位置索引(同樣以0開始計)。比如,一個4位十六進制數C0DE的計算過程如圖2-6所示:

圖2-6 十六進制數轉十進制數

上述4位十六進制數C0DE,倘若用二進制數表示,則為:1100000011011110。可見,用十六進制數表示要簡潔得多,而且換算成十進制數也相對比較容易,尤其對于一個字節長度的整數來說。為了能更快速地換算二進制數、十進制數與十六進制數,請各位讀者務必熟記下表:

表2-1 二進制數、十進制數與十六進制數的換算表

習慣上,用0或0o打頭的數表示八進制數,0x打頭的數表示十六進制數。比如,0123、0777表示八進制數;0x123,0xABCD表示十六進制數。

2.3 浮點數在計算機中的表示

當前主流處理器一般都能支持32位的單精度浮點數與64位的雙精度浮點數的表示和計算,并且能遵循IEEE754-1985工業標準。現在此標準最新的版本是2008,其中增加了對16位半精度浮點數以及128位四精度浮點數的描述。C語言標準引入了一個浮點模型,可用來表達任意精度的浮點數,盡管當前主流C語言編譯器尚未很好地支持半精度浮點數與四精度浮點數的表示和計算。關于C語言標準對浮點數的描述,我們稍后將在5.2節做更詳細的介紹。

為了更好地理解IEEE754-1985中規格化(normalized)浮點數的表示法,我們先來介紹一下浮點數用一般二進制數的表示方法。一個浮點數包含了整數部分和尾數(即小數)部分。整數部分的表示與我們之前所討論過的一樣,第n位就表示2n, n從0開始計。而尾數部分則是第m位表示2-m, m從1開始計。對于一個0101.1010的二進制浮點數對應十進制數的計算如圖2-7所示:

圖2-7 二進制浮點數轉十進制數

圖2-7中,整i位即表示第i位整數;尾i位即表示第i位尾數。其中,第3位整數為最高位整數;第4位尾數表示最低位尾數。對二進制浮點數的表示有了概念之后,我們就可以看IEEE754-1985標準中對規格化浮點數的描述了。IEEE754-1985對32位單精度與64位雙精度兩種精度的浮點數進行描述。32位單精度浮點可表示的數值范圍在±1.18×10-38到±3.4×1038,大約含有7位十進制有效數;64位雙精度浮點可表示的數值范圍在±2.23×10-308到±1.80×10308,大約含有15位十進制有效數。我們看到IEEE定義的浮點數的絕對值范圍可以是一個遠大于1的數,也可以是一個大于零但遠小于1的數,即它的小數精度是可浮動的,所以稱之為浮點數。如果說是定點數的話,它也可表示一個小數,但是其整數位數與小數位數的精度都是固定的。比如一個16.16的定點數表示整數部分采用16個比特,尾數部分也采用16個比特。而對于一個32位浮點數來說,既能使用16.16的格式,也能使用30.2的格式(即30個比特表示整數,2個比特表示尾數)或其他各種形式。而IEEE754-1985對規格化單精度浮點數的格式如下定義:

1)1位符號位,一般是最高位(31位),表示正負號。0表示正數,1表示負數。

2)8位指數位,又稱階碼,位于23到30位。(階碼的計算后面會詳細介紹。)

3)23位尾數,位于0到22位。

我們下面舉一個實際的例子來詳細說明一個十進制小數5.625如何表示成IEEE754標準的規格化32位單精度浮點數。

1)5.625是一個正數,所以符號位為0,即第31位為0。

2)我們將5.625依照圖2-7那樣寫成一般小數的表示法——0101.101。

3)我們將此二進制浮點數用科學計數法來表示,使得二進制整數位為最高位的1。這里最高位為1的比特是從左往右數是第二個比特,所以將小數點就放到該比特的后面,得到1.01101×22。二進制數的科學記數法,底數的值顯然就是2。

4)此時,我們能看到尾數部分是小數點后面的那串二進制數,即01101,而指數為2。現在我們來求階碼。階碼用的是中經指數偏差(exponent bias)處理后的指數,即用上述得到的指數加上偏差值所求得的和。IEEE754在單精度浮點中規定,偏差值為127。所以本例中,階碼部分為2+127=129,用二進制數表示就是10000001。

5)尾數部分從大到小照抄,低位的用0填充即可,所以這里的尾數部分二進制數為:01101000000000000000000。

6)將整個處理完的二進制數串起來獲得:0(符號位)10000001(階碼)01101000000000000000000(尾數),用十六進制數表達就是:40B40000。

十進制小數轉64位雙精度浮點數的方法與上述雷同,只不過階碼用11位比特來表示,尾數則用52位比特表示,而偏差值則規定為1023。

2.4 地址與字節對齊

由于C語言是一門接近底層硬件的編程語言,它能直接對存儲器地址進行訪問(當前大部分處理器在操作系統的應用層所訪問到的邏輯地址,而部分嵌入式系統由于不含帶存儲器管理單元,因此可直接訪問物理地址)。在計算機中,所謂“地址”就是用來標識存儲單元的一個編號,就好比我們住房的門牌號。沒有門牌號,快遞就沒法發貨;如果門牌號記錯了,那么快遞就會把貨物送錯地方。計算機中的地址也是一樣,我們為了要訪問存儲器中特定單元的一個數據,那么我們首先要獲悉該數據所在的地址,然后我們通過這個地址來訪問它。訪問存儲器,我們也簡稱為“訪存”(Memory Access)。訪問地址,我們也簡稱為“尋址”(Addressing)。我們在圖2-1中也看到,一般計算機架構中都會有地址總線和數據總線。CPU先通過地址總線發送尋址信號,以指定所要訪問存儲器單元的地址。然后再通過數據總線向該地址讀寫數據,這樣就完成了一次訪存操作。這好比于快遞送貨,我們先打電話告訴快遞通信地址,然后快遞員把貨送到該地址(寫數據),或者去該地址拿貨(讀數據)送到別家。

一般對于32位系統來說,處理器一次可訪問1個(8比特)字節、2個字節或4個字節。當訪問單個字節時,對CPU不做對齊限制;而當訪問多個字節時,比如要訪問N個字節,由于計算機總線設計等諸多因素,要求CPU所訪問的起始地址滿足N個字節的倍數來訪問存儲器。如果在訪問存儲器時沒有按照特定要求做字節對齊,那么可能會引發訪存性能問題,甚至直接導致尋址錯誤而引發異常(引發異常后通常會導致當前應用意外退出,在嵌入式系統中可能就直接死機或復位)。

下面我們給出一張圖2-8來描述,看看一般對32位系統而言如何正確地做到訪存字節對齊。

圖2-8展示了如何正確對齊訪問1個字節、2個字節和4個字節的情況。圖中畫出了6個存儲單元內容,地址低16位從0x1000到0x1005,每個存儲單元為1個字節。對于僅訪問1個字節的情況,圖2-8所有地址都能直接訪問并滿足字節對齊的情況。對于一次訪問2個字節的情況,要滿足對齊要求,只能訪問0x1000、0x1002、0x1004等必須要能被2整除的地址。對于一次訪問4字節的情況,要滿足對齊要求,則只能訪問0x1000、0x1004等必須要能被4整除的地址。

圖2-8 字節對齊

然而,并不是說要訪問多少字節,就必須要保證訪問能被多少整除的地址才能滿足對齊要求。如果一次訪問8字節,對于32位系統而言,通過32位通用目的寄存器來讀寫存儲器的話,某些CPU會自動將8字節的訪存分為兩次進行操作,每次為4字節,因此只要保證4字節對齊就能滿足對齊要求。這些都根據特定的處理器來做具體處理。

就筆者用過的一些處理器而言,像x86、ARM等處理器,當訪存不滿足對齊要求時并不會引發總線異常,但是訪問性能會降低很多。因為原本可一次通信的數據傳輸可能需要拆分為多次,并且前后還要保證數據的一致性,所以還可能會有鎖步之類的操作。而像Blackf in DSP則會直接引發總線異常,導致整個系統的崩潰(如果不對此異常做處理的話)。另外,像ARMv5或更低版本的處理器,在對非對齊的存儲器地址進行訪問時,CPU會先自動向下定位到對齊地址,然后通過向右循環移位的方式處理數據,這就使得傳輸數據并不是原本想一次傳輸的數據內容,也就是說寫入的或讀出的數據是失真的。比如,根據圖2-8所示內容,如果我們要對一款ARM7EJ-S處理器(ARMv5TEJ架構)從地址0x1002讀4字節內容,那么實際獲取到的數據為0x02010403;而在x86架構或ARMv7架構的處理器下,則能獲得0x06050403。

2.5 字符編碼

我們從2.2節到2.4節講述的都是數值信息(整數與浮點數),本小節我們將討論字符信息。在計算機中我們所處理的字符信息,即文本信息(包括數字、字母、文字、標點符號等)是以一種特定編碼格式來定義的。為了使世界各國的文本信息能夠通用,就需要對字符編碼做標準化。我們現在最常用也最基本的字符編碼系統是ASCII碼(American Standard Code for Information Interchange,美國信息交換標準碼)。ASCII碼定義每個字符僅占一個字節,可表示阿拉伯數字0~9、26個大小寫英文字母,以及我們現在在標準鍵盤上能看到的所有標點符號、一些控制字符(比如換行、回車、換頁、振鈴等)。ASCII碼最高位是奇偶校驗位,用于通信校驗,所以真正有編碼意義的是低7個比特,因此只能用于表示128個字符(值從0~127)。由于ASCII是美國國家標準,所以后來國際化標準組織將它進行國際標準化,定義為了ISO/IEC 646標準。兩者所定義的內容是等價的。

ISO/IEC 646對于英文系國家而言是基本夠用了,但是對于拉丁語系、希臘等國家來說就不夠用了。所以后來ISO組織就把原先ISO/IEC 646所定義字符的最高位也用上了,這樣就又能增加128個不同的字符,發布了ISO/IEC 8859標準。然而,歐洲大陸雖小,但國家卻有數百個,128種擴展字符仍然不夠用。因此后來就在8859的基礎上,引入了8859-n, n從1~16,每一種都支持了一定數量的不同的字母,這樣基本能滿足歐美國家的文字表示需求。當然,有些國家之間仍然需要切換編碼格式,比如ISO/IEC8859-1的語言環境看8859-2的就可能顯示亂碼,所以,還得切換到8859-2的字符編碼格式下才能正常顯示。

而在中國大陸,我們自己也定義了一套用于顯示簡體中文的字符集——GB2312。它在1981年5月1日開始實施,是中國國家標準的簡體中文字符集,全稱為《信息交換用漢字編碼字符集·基本集》。它收錄了6763個漢字,包括拉丁字母、希臘字母、日語假名、俄語和蒙古語用的西里爾字母在內的682個全角字符。然后又出現了GBK字符集,GBK1.0收錄了21886個符號,其中漢字就包含了21003個。GBK字符集主要擴展了繁體中文字。由于像GB2312與GBK能表示成千上萬種字符,因此這已經遠超1個字節所能表示的范圍。它們所采用的是動態變長字節編碼,并且與ASCII碼兼容。如果表示ASCII碼部分,那么僅1個字節即可,并且該字節最高位為0。如果要表示漢字等擴展字符,那么頭1個字節的最高位為1,然后再增加一個字節(即用兩個字節)進行表示。所以,理論上,除了第1個字節的最高位不能動之外,其余比特都能表示具體的字符信息,因而最多可表示27+215=32896種字符。

當然,正由于GB2312與GBK主要用于亞洲國家,所以當歐美國家的人看到這些字符信息時顯示的是亂碼,他們必須切換到相應的漢字編碼環境下看才能看到正確的文本信息。為了能真正將全球各國語言進行互換通信,出現了Unicode(Universal Character Set, UCS)標準。它對應于編碼標準ISO/IEC 10646。Unicode前后也出現了多個版本。早先的UCS-2采用固定的雙字節編碼方式,理論上可表示216=65536種字符,因此極大地涵蓋了各種語言的文字符號。

不過后來,標準委員會意識到,對于像希伯來字母、拉丁字母等壓根就不需要用兩個字節表示,而且定長的雙字節表示與原有的ASCII碼又不兼容,因此后來出現了現在用得更多的UTF-8編碼標準。UTF-8屬于變長的編碼方式,它最少可用1個字節表示1個字符,最多用4個字節表示1個字符,判別依據就是看第1個字節的最高位有多少個1。如果第1個字節的最高位是0,那么該字符用1個字節表示;最高3位是110,那么用2個字節表示;最高4位是1110,那么用3個字節表示;最高位是11110,那么該字符由4個字節來表示。所以UTF-8現在大量用于網絡通信的字符編碼格式,包括大多數網頁用的默認字符編碼也都是UTF-8編碼。盡管UTF-8更為靈活,而且也與ASCII碼完全兼容,但不利于程序解析。所以現在很多編程語言的編譯器以及運行時庫用得更多的是UTF-16編碼來處理源代碼解析以及各類文本解析,它與之前的UCS-2編碼完全兼容,但也是變長編碼方式,可用雙字節或四字節來表示一個字符。如果用雙字節表示UTF-16編碼的話,范圍從0x0000到0xD7FF,以及從0xE000到0xFFFF。這里留出0xD800到0xDFFF,不作為具體字符的編碼表示,而是用于四字節編碼時的編碼替換。當UTF-16表示0x10000到0x10FFFF之間的字符時,先將該范圍內的值減去0x10000,使得結果落在0x00000到0xFFFFF范圍內。然后將結果劃分為高10位與低10位兩組。將低10位的值與0xDC00相加,獲得低16位;高10位與0xD800相加,獲得高16位。比如,一個Unicode定義的碼點(code point)為0x10437的字符,用UTF-16編碼表示的步驟如下。

1)先將它減去0x10000——0x10437-0x10000=0x0437。

2)將該結果分為低10位與高10位,0x0437用20位二進制表示為00000000010000110111,因此高10位是00000000 01=0x01;低10位則是0000110111,即0x037。

3)將高10位與0xD800相加,得到0xD801;將低10位與0xDC00相加,獲得0xDC37。因此最終UTF-16編碼為0xD801DC37。

我們看到,盡管UTF-16也是變長編碼表示,但是僅低16位就能表示很多字符符號,況且即便要表示更廣范圍的字符,也只是第二種四字節的表示方法,這遠比UTF-8四種不同的編碼方式要簡潔很多。因此,UTF-16用在很多編程語言運行時系統字符編碼的場合比較多。像現在的Java、Objective-C等編程語言環境內部系統所表示的字符都是UTF-16編碼方式。

另外,現在還有UTF-32編碼方式,這一開始也是Unicode標準搞出來的UCS-4標準,它與UCS-2一樣,是定長編碼方式,但每個字符用固定的4字節來表示。不過現在此格式用得很少,而且HTML5標準組織也公開聲明開發者應當盡量避免在頁面中使用UTF-32編碼格式,因為在HTML5規范中所描述的編碼偵測算法,故意不對它與UTF-16編碼做區分。

2.6 大端與小端

現代計算機系統中含有兩種存放數據的字節序:大端(Big-endian)和小端(Little-endian)。所謂大端字節序是指在讀寫一個大于1個字節的數據時,其數據的最高字節存放在起始地址單元處,數據的最低字節存放在最高地址單元處。所謂小端字節序是指在讀寫一個大于1個字節的數據時,其數據的最低字節存放在起始地址單元處,而數據的最高字節存放在最高地址單元處。比如,我們要在地址0x00001000處存放一個0x04030201的32位整數,其大端、小端存放情況如圖2-9所示。

圖2-9 大端與小端

當前,通用桌面處理器以及智能移動設備的處理器一般都用小端字節序。通信設備中用大端字節序比較普遍。

本書后續所要敘述的內容中,若無特殊說明,都是基于小端字節序進行描述。

2.7 按位邏輯運算

按位邏輯運算在計算機編程中會經常涉及,這些運算都是針對二進制比特進行操作的。所謂的“按位”計算就是指對一組數據的每個比特逐位進行計算,并且對每個比特的計算結果不會影響其他位。常用的按位邏輯運算包括“按位與”、“按位或”、“按位異或”以及“按位取反”四種。下面將分別介紹這4種運算方式。

1)按位與:它是一個雙目操作,需要兩個操作數,在C語言中用&表示。兩個比特的按位與結果如下:

0 & 0=0; 0 & 1=0; 1 & 0=0; 1 & 1=1

也就是說,兩個比特中如果有一個比特是0,那么按位與的結果就是0,只有當兩個比特都為1的時候,按位與的結果才為1。比如,對兩個字節01001010和11110011進行按位與的結果為01000010。按位與一般可用于判定某個標志位是否被設置。比如,我們假定處理一個游戲手柄的按鍵事件,用一個字節來存放按鍵被按下的標志,前4個比特分別表示“上”、“下”、“左”、“右”。比特4表示按下了“A”鍵,比特5表示按下了“B”鍵,比特6表示按下了“X”鍵,比特7表示按下了“Y”鍵。那么當我們接收到二進制數01010100時,說明用戶同時按下了“左”方向鍵、“A”鍵和“X”鍵。那么我們判定按鍵標志時可以通過按位與二進制數1來判定是否按下了“上”鍵,按位與二進制數10做按位與操作來判定是否按下了“下”鍵,跟二進制數100做與操作來判定是否按下了“左”鍵,以此類推。如果按位與的結果是0,說明當前此按鍵沒有被按下,如果結果不為零,說明此按鍵被按下。

2)按位或:它是一個雙目操作符,需要兩個操作數,在C語言中用“|”表示。兩個比特的按位或結果如下:

0 | 0=0; 0 | 1=1; 1 | 0=1; 1 | 1=1

也就是說,只要有一個比特的值是1,那么按位或的結果就是1,只有當兩個比特的值都為0的時候,按位或的結果才是0。比如,對于兩個字節01001010和11110011進行按位或的結果為11111011。按位或一般可用于設置標志位。就如同上述例子,如果用戶按下了“上”鍵,那么系統底層會將最低位設置為1;如果用戶按下了“Y”鍵,那么系統底層會將最高位設置為1。隨后系統會將這串信息發送到應用UI層。

3)按位異或:它是一個雙目操作,需要兩個操作數,在C語言中用^表示。兩個比特的按位異或結果如下

0^0=0; 0^1=1; 1^0=1; 1^1=0

也就是說,如果兩個比特的值相同,那么按位異或的結果為0,不同為1。比如,對于兩個字節01001010和11110011進行按位或的結果為10111001。按位異或適用于多種場景,比如我們用一個輸入比特與1進行異或就可以反轉該輸入比特的值,輸入為0,那么結果為1;輸入為1,那么結果為0。任一比特與0異或,那么結果還是原比特的值。按位異或跟按位與和按位或不同,它可以對數據信息進行疊加組合。因為給定任一比特,對于另外一個比特的輸入,不同的輸入值對應不同的輸出,所以我們通過異或能還原信息。比如,我們有兩個整數a和b,我們設c=a ^ b。對于c,我們可以通過c ^ a重新得到b,也可以通過c ^ b來重新得到a。所以異或在信息編碼、數據加密等技術上應用得非常多。

4)按位取反:它是一個單目操作,只需要一個操作數,在C語言中用~表示。一個比特的按位取反結果如下:~0=1; ~1=0。比如,對一個字節01001010進行按位取反的結果為10110101。

2.8 移位操作

現代處理器的計算單元中一般都會包含移位器。移位器往往能執行算術左移(Arithmetic Shift Left)、算術右移(Arithmetic Shift Right)、邏輯左移(Logical Shift Left)、邏輯右移(Logical Shift Right)、循環右移(Rotational Shift Right)這些操作。

下面我們將分別介紹這些移位操作,這里需要提醒各位的是,移位操作一般總是對整數數據進行操作,并且移入移出的都是二進制比特。然而,不同的處理器架構對移位操作的實現可能會有一些不同。比如,如果對一個32位寄存器做移位操作,倘若指定要移動的比特數超過了31,那么在x86處理器中是將指定的比特移動位數做模32處理(也就是求除以32的余數,比如左移32位相當于左移0位、右移33位相當于右移1位);而在ARM、AVR等處理器中,對一個32位的整數做左移和邏輯右移超出31位的結果都將是零。

2.8.1 算術左移與邏輯左移

由于算術左移與邏輯左移操作基本是相同的,僅僅對標志位的影響有些區別,所以合并在一起講。左移的操作步驟十分簡單,假設我們要左移N位,那么先將整數的每個比特向左移動N位,然后空出的低N位填零。圖2-10展示了對一個8位整數分別做左移1位與左移2位的過程。

圖2-10 算術左移與邏輯左移

圖2-10中間由小寫字母a~h構成的方格圖即表示一個8位二進制整數,每個小寫字母表示一比特,并且字母a作為最高位比特,字母h作為最低位比特。左移1位后,原來的8位二進制數就變成了bcdefgh0;左移2位后,原來的8位二進制數就變成了cdefgh00。

2.8.2 邏輯右移

邏輯右移的操作步序是:先將整數的每一個比特向右移動N位,然后高N位用零來填補。圖2-11展示了一個8位二進制整數分別邏輯右移1位和2位的過程。

圖2-11 邏輯右移

圖2-11中間由小寫字母a~h構成的方格圖即表示一個8位二進制整數,每個小寫字母表示一位比特,并且字母a作為最高位比特,字母h作為最低位比特。將原始二進制8位數據邏輯右移1位后,二進制數據變為0abcdefg;邏輯右移2位后,二進制數據變為00abcdef。

2.8.3 算術右移

算術右移與邏輯右移類似,只不過移出N位之后,高N位不是用零來填充,而是根據原始整數的最高位,如果原始整數的最高位為1,那么移位后的高N位用1來填充;如果是0,則用0來填充。圖2-12展示了一個8位二進制整數分別算術右移1位和2位的過程。

圖2-12 算術右移

圖2-12中間由小寫字母a~h構成的方格圖,即表示一個8位二進制整數,每個小寫字母表示一位比特,并且字母a作為最高位比特,字母h作為最低位比特。將原始8位二進制整數算術右移1位之后,該二進制數變為aabcdefg;將它算術右移2位之后,變為aaabcdef。

2.8.4 循環右移

循環右移的步序是:先將原始二進制整數右移N位,移出的N位依次放入到高N位。圖2-13展示了將一個8位二進制整數分別循環右移1位和2位的過程。

圖2-13 循環右移

圖2-13中間由小寫字母a~h構成的方格圖,即表示一個8位二進制整數,每個小寫字母表示一位比特,并且字母a作為最高位比特,字母h作為最低位比特。將原始8位二進制整數循環右移1位之后,該二進制整數變為habcdefg;將它循環右移2位之后,該二進制整數變為ghabcdef。

2.9 本章小結

本章大致介紹了計算機體系結構以及程序執行的大致流程,然后描述了整數以及浮點數在計算機中的存儲方式,之后還介紹了地址與字節對齊、字符編碼、處理器大端與小端字節序,以及按位邏輯運算和移位操作。由于這些知識都是學習C語言必備的,C語言中有相關語法與這些概念對應,所以各位最好能先理解、掌握這些基本知識,這樣對后續學習C語言將有很大幫助。

主站蜘蛛池模板: 新兴县| 开封市| 绵竹市| 华安县| 平顶山市| 沁水县| 寻乌县| 咸丰县| 聂拉木县| 宝坻区| 阿鲁科尔沁旗| 合阳县| 同心县| 南宫市| 甘孜| 博湖县| 武邑县| 彰化市| 威远县| 仁布县| 和平县| 呼和浩特市| 太原市| 理塘县| 香格里拉县| 南溪县| 万年县| 张北县| 会泽县| 天津市| 平果县| 奉贤区| 子洲县| 安庆市| 泾阳县| 格尔木市| 清流县| 田阳县| 府谷县| 兴宁市| 达日县|