作業系統思考 第五章 更多的位與位元組

飛龍發表於2019-05-14

第五章 更多的位與位元組

作者:Allen B. Downey

原文:Chapter 5 More bits and bytes

譯者:飛龍

協議:CC BY-NC-SA 4.0

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。

通常,&用於清除位向量中的一些位,|用於設定位,^用於反轉位。下面是一些細節:

清除位:對於任何xx & 0值為0,x & 1值為x。所以如果你將一個向量和3做且運算,它只會保留最右邊的兩位,其餘位都置為0。

  xxxx
& 0011
  ----
  00xx

在這個語境中,3叫做“掩碼”,因為它選擇了一些位,並遮蔽了其餘的位。

設定位:與之相似,對於任何xx | 0值為xx | 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]);
}

如果我一次呼叫f1f2,結果如下:

17
123
98
99

這裡的細節取決於編譯器,它會在棧上排列變數。從這些結果中我們可以推斷,編譯器將xy放置到一起,並位於陣列“下方”(低地址處)。當我們越過陣列的邊界讀取時,似乎我們獲得了上一個函式呼叫遺留在棧上的資料。

這個例子中,所有變數都是整數,所以比較容易弄清楚其原理。但是通常當你對陣列越界讀取時,你可能會讀到任何型別的值。例如,如果我修改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。

相關文章