第五章 更多的位與位元組
原文:Chapter 5 More bits and bytes
譯者:飛龍
5.1 整數的表示
你可能知道計算機以二進位制表示整數。對於正數,二進位制的表示法非常直接。例如,十進位制的5表示成二進位制是0b101
。
對於負數,最清晰的表示法使用符號位來表明一個數是正數還是負數。但是還有另一種表示法,叫做“補碼”(two`s complement),它更加普遍,因為它和硬體配合得更好。
為了尋找一個正數的補碼,-x
,需要找到x
的二進位制表示,將所有位反轉,之後加上1。例如,要表示十進位制的-5
,要先從十進位制的5開始,如果將其寫成8位的形式它是0b0000 0101
。將所有位反轉並加以會得到0b1111 1011
。
在補碼中,最左邊的位相當於符號位。正數中它是0,負數中它是1。
為了將8位的數值轉換為16位,我們需要對正數新增更多的0,對負數新增更多的1。實際上,我們需要將符號位複製到新的位上,這個過程叫做“符號擴充套件”。
在C語言中,除非你用unsigned
宣告它們,所有整數型別都是有符號的(能夠表示正數和負數)。它們之間的差異,以及這個宣告如此重要的原因,是無符號整數上的操作不使用符號擴充套件。
5.2 按位運算
學習C語言的人有時會對按位運算&
和|
感到困惑。這些運算子將整數看做位的向量,並且在相應的位上執行邏輯運算。
例如,&
執行“且”運算。如果兩個運算元都為1結果為1,否則為0。下面是一個在兩個4位數值上執行&
運算的例子:
1100
& 1010
----
1000
C語言中,這意味著表示式12 & 10
值為8。
與之相似,|
執行“或”運算,如果兩個運算元至少一個為1結果為1,否則為0。
1100
| 1010
----
1110
所以表示式12 | 10
值為14。
最後,^
運算子執行“異或”運算,如果兩個運算元其中有一個為1,而不是全部為1,結果為1。
1100
^ 1010
----
0110
所以表示式12 ^ 10
值為6。
通常,&
用於清除位向量中的一些位,|
用於設定位,^
用於反轉位。下面是一些細節:
清除位:對於任何x
,x & 0
值為0,x & 1
值為x
。所以如果你將一個向量和3做且運算,它只會保留最右邊的兩位,其餘位都置為0。
xxxx
& 0011
----
00xx
在這個語境中,3叫做“掩碼”,因為它選擇了一些位,並遮蔽了其餘的位。
設定位:與之相似,對於任何x
,x | 0
值為x
,x | 1
值為1。所以如果你將一個向量與3做或運算,它會設定右邊兩位,其餘位不變。
xxxx
| 0011
----
xx11
反轉位:最後,如果你將一個向量與3做異或運算,它會反轉右邊兩位,其餘位不變。作為一個練習,看看你能否使用^
計算出12的補碼。提示:-1的補碼錶示是什麼?
C語言同時提供了移位運算子,<<
和>>
,它可以將位向左或向右移。向左每移動一位會使數值加倍,所以5 << 1
為10,5 << 2
為20。向右每移動一位會使數值減半(向下取整),所以5 >> 1
為2,2 >> 1
為1。
5.3 浮點數的表示
浮點數使用科學計數法的二進位制形式來表示。在十進位制的形式中,較大的數字寫成係數與十的指數相乘的形式。例如,光速大約是2.998 * 10 ** 8
米每秒。
大多數計算機使用IEEE標準來執行浮點數運算。C語言的float
型別通常對應32位的IEEE標準,而double
通常對應64位的標準。
在32位的標準中,最左邊那位是符號位,s
。接下來的8位是指數q
,最後的23位是係數c
。浮點數的值為:
(-1) ** s * c * 2 ** q
這幾乎是正確的,但是有一點例外。浮點數通常為規格化的,所以小數點前方有一個數字。例如在10進位制中,我們通常使用2.998 * 10 ** 8
而不是2998 * 10 ** 5
,或者任何其它等價的表示。在二進位制中,規格化的浮點數通常在二進位制小數點前有一個數字1。由於這個位置上的數字永遠是1,我們可以將其從表示中去掉以節省空間。
例如,十進位制的13表示為0b1101
,在浮點數中,它就是1.011 * 2 ** 3
。所以指數為3,係數儲存為101(加上20個零)。
這幾乎是正確的,但是指數以“偏移”儲存。在32位的標準中,偏移是127,所以指數3應該儲存為130。
為了在C中對浮點數打包和解包,我們可以使用聯合體和按位運算,下面是一個例子:
union {
float f;
unsigned int u;
} p;
p.f = -13.0;
unsigned int sign = (p.u >> 31) & 1;
unsigned int exp = (p.u >> 23) & 0xff;
unsigned int coef_mask = (1 << 23) - 1;
unsigned int coef = p.u & coef_mask;
printf("%d
", sign);
printf("%d
", exp);
printf("0x%x
", coef);
這段程式碼位於這本書的倉庫的float.c
中。
聯合體可以讓我們使用p.f
儲存浮點數,之後將使用p.u
當做無符號整數來讀取。
為了獲取符號位,我們需要將其右移31位,之後使用1位的掩碼選擇最右邊的位。
為了獲取指數,我們需要將其右移23位,之後選擇最右邊的8位(十六進位制值0xff
含有8個1)。
為了獲取係數,我們需要解壓最右邊的23位,並且忽略掉其餘位,通過構造右邊23位是1並且其餘位是0的掩碼。最簡單的方式是將1左移23位之後減1。
程式的輸出如下:
1
130
0x500000
就像預期的那樣,負數的符號位為1。指數是130,包含了偏移。而且係數是101帶有20個零,我用十六進位制將其列印了出來。
作為一個練習,嘗試組裝或分解double
,它使用了64位的標準。請見IEEE浮點數的維基百科。
5.4 聯合體和記憶體錯誤
C的聯合體有兩個常見的用處。一個是就是在上一節看到的那樣,用於訪問資料的二進位制表示。另一個是儲存不同形式的資料。例如,你可以使用聯合體來表示一個可能為整數、浮點、複數或有理數的數值。
然而,聯合體是易於出錯的,這完全取決於你,作為一個程式設計師,需要跟蹤聯合體中的資料型別。如果你寫入了浮點數然後將其讀取為整數,結果通常是無意義的。
實際上,如果你錯誤地讀取記憶體的某個位置,也會發生相同的事情。其中一種可能的方式是越過陣列的尾部來讀取。
我會以這個函式作為開始來觀察所發生的事情。這個函式在棧上分配了一個陣列,並且以0到99填充它。
void f1() {
int i;
int array[100];
for (i=0; i<100; i++) {
array[i] = i;
}
}
接下來我會定義一個建立小型陣列的函式,並且故意訪問在開頭之前和末尾之後的元素:
void f2() {
int x = 17;
int array[10];
int y = 123;
printf("%d
", array[-2]);
printf("%d
", array[-1]);
printf("%d
", array[10]);
printf("%d
", array[11]);
}
如果我一次呼叫f1
和f2
,結果如下:
17
123
98
99
這裡的細節取決於編譯器,它會在棧上排列變數。從這些結果中我們可以推斷,編譯器將x
和y
放置到一起,並位於陣列“下方”(低地址處)。當我們越過陣列的邊界讀取時,似乎我們獲得了上一個函式呼叫遺留在棧上的資料。
這個例子中,所有變數都是整數,所以比較容易弄清楚其原理。但是通常當你對陣列越界讀取時,你可能會讀到任何型別的值。例如,如果我修改f1
來建立浮點陣列,結果就是:
17
123
1120141312
1120272384
最後兩個數值就是你將浮點數解釋為整數的結果。如果你在除錯時遇到這種輸出,你就很難弄清楚發生了什麼。
5.5 字串的表示
字串有時也會有相關的問題。首先,要記住C的字串是以空字元結尾的。當你為字串分配空間時,不要忘了末尾額外的位元組。
同樣,要記住C字串中的字母和數字都編碼為ASCII碼。數字0~9的ASCII碼是48~57,而不是0~9。ASCII碼的0是NUL
字元,用於標記字串的末尾。ASCII碼的1~9是用於一些通訊協議的特殊字元。ASCII碼的7是響鈴,在一些終端中,列印它們會發出聲音。
`A`
的ASCII碼是65,`a`
是97,下面是它們的二進位制形式:
65 = b0100 0001
97 = b0110 0001
細心的讀者會發現,它們只有一位的不同。這個規律對於其餘所有字元都適用。從右數第六位起到“大小寫”位的作用,0表示大寫字母,1表示小寫字母。
作為一個練習,編寫一個函式,接收字串並通過反轉第六位將小寫字元轉換成大寫字母。作為一個挑戰,你可以通過一次讀取字串的32位或64位而不是一個字元使它更快。如果字串的長度是4或8位元組的倍數,這個優化會容易實現一些。
如果你越過字串的末尾來讀取,你可能會看到奇怪的字元。反之,如果你建立了一個字串,之後無意中將其作為整數或浮點讀取,結果也難以解釋。
例如,如果你執行:
char array[] = "allen";
float *p = array;
printf("%f
", *p);
你會發現我的名字的前8個字元的ASCII表示,可以解釋為一個雙精度的浮點,它是69779713878800585457664。