用 C 語言窺探記憶體

C 語言可以說是『最低階的高階語言』, 它提供許多語法讓人有機會窺探電腦的具體狀況。 以下提供程式原始碼,和作者寫此文件當時的執行狀況。 這裡不是 C 語言教材,所以不說明 C 的語法,只是用它來示範。 這些程式都是標準的 ANSI C 原始碼,可以用任何 C 編譯器製造可執行檔, 在讀者自己的機器上做實驗。

以下程式顯示三個變數 c, nstr 被配給的記憶體位址。


/* Program A */
#include <stdio.h>
main() {
    int c, n;
    char str[12];
    printf("c is at %u\n", &c);
    printf("n is at %u\n", &n);
    printf("str is at %u\n", &str);
}

在不同電腦上,甚至於在不同時間,執行這個程式,獲得的數據應當不同。 我的手邊有兩部電腦可以用, 一部的 CPU 是 Intel Pentium 並以 Linux 為作業系統(主機名是李白), 另一部的 CPU 是 Sun UltraSparc 並以 Solaris 為作業系統 (主機名是王維), 兩者都屬於 Unix 作業系統。 以後,稱前者為 Intel,稱後者為 Sun。 Program A 的執行結果為

IntelSun
c is at 3221223228
n is at 3221223224
str is at 3221223200
c is at 4026530332
n is at 4026530328
str is at 4026530264

不論 Intel 還是 PC,都看到:先宣告的變數,位址在後面。 這就是 data segment 『從後面』排起的效果。 以 Intel 為例,而且為了方便我們只寫記憶體位址的後三碼:

現在可以補充兩件書本上沒有仔細說明的現象。

第一,雖然 str[12] 需要存放 12 個 char 型態的數值, 而每個 char 的資料含量是 1 byte,所以總共需要 12 bytes, 但是 202..223 卻沒有用到,看來 Intel 浪費了 12 bytes。 觀察 Sun,更過份,我們只要 12 bytes,但是電腦卻配給了 64 bytes。 看來 Sun 浪費了 52 bytes。 這種現象是因為電腦為了傳輸的效率, 其實並非一次拿一個 byte 到 CPU 的 register 裡面, 而是一次拿一個『存取字元』,稱為 word。 而每個 word 通常是 2 bytes 或 4 bytes 或 8 bytes。 因此,為了配合電路的設計以提昇效率,硬體設計者會選擇浪費一些記憶體空間。 實際狀況如何,已經超出計概範圍,只是要讓讀者知道, 在如今大型 CPU 上面寫程式,並不需要刻意節省記憶體。

第二,讀者或許發現,似乎在同一部電腦上,甚至同一款作業系統與 CPU 的電腦上, 怎麼每次執行 Program A 的結果都一樣? 難道每次執行時,空閒記憶體的位址都一樣嗎?這也未免太巧了吧? 其實這是因為現在的作業系統都是多人多工的系統, 因此,像 Unix 這樣的系統,會分給每一個『同時』執行中的程式一個「虛擬」 (logical) 記憶體,使得每個程式都「感覺」自己是電腦中唯一的程式。 這種「抽象」設計其實化簡了整個電腦的管理, 只有真正的作業系統知道現在一共有幾個程式在執行, 每個執行中的程式分給一個『執行元件』(process), 一個 process 就好像一部完整的電腦:有自己的 CPU 和記憶體和周邊設備。 而由作業系統分配各 process 實際分配到的 CPU 時間和記憶體空間。 因此,前面看到的記憶體位址,其實是 process 內的虛擬位址, 而不是硬體上的實際位址。

以下這個實驗,顯示未經初始化的變數值,並不會『空』, 也不一定是 0,而是『垃圾』。


/* Program B */
#include <stdio.h>
main() {
    int c, n;
    char str[12];
    printf("c = %d\n", c);
    printf("n = %d\n", n);
    for (n=0; n<12; ++n)
	printf("str[%d] = %d\n", n, str[n]);
}

在不同電腦上,甚至於在不同時間,執行這個程式,獲得的數據應當不同。 就好像『垃圾』因時因地而不同。 Program B 的執行結果如下。

IntelSun
c = 134513713
n = -1073744040
str[0] = 88
str[1] = -9
str[2] = -1
str[3] = -65
str[4] = -32
str[5] = -44
str[6] = 0
str[7] = 64
str[8] = 0
str[9] = 7
str[10] = 6
str[11] = 64
c = -268436836
n = 4
str[0] = 0
str[1] = 0
str[2] = 0
str[3] = 0
str[4] = 0
str[5] = 0
str[6] = 0
str[7] = 0
str[8] = 0
str[9] = 0
str[10] = 0
str[11] = 0

雖然在 Sun 上面執行時,恰好 str 的 12 個元素都是 0, 那也不代表什麼,只是恰巧而已。

第三個實驗,是要表現如果程式需要太多的記憶體,那就可能無法執行。 這個程式每次向電腦索求 1MB 的記憶體,如果要到了, 就列印一句訊息。如果沒有要到,就輸出錯誤訊息。 因為如今常見的電腦最多也不過可以有 4GB 的記憶體, 每次索求 1MB 則最多可以成功 212 次,這就是 MAX 常數的來源。


/* Program C */
#include <stdio.h>
#define MB 1048576
#define MAX 4096
main() {
    int n=0;
    double *P[MAX];
    while (n < MAX) {
        P[n] = (double*) malloc(MB);
        if (P[n] != NULL)
            printf("I got %4dMB of memory\n", ++n);
        else {
            fprintf(stderr, "Out of Memory\n");
            break;
        }
    }
}

以下是作者剛才執行的結果。這種結果並不表示 Sun 的功能比 Intel 差勁, 實在是因為我們的『李白』安裝了比『王維』多得多的記憶體之故。 Program C 的執行結果如下 (... 是作者修改的,以免印出太多不重要的資料)。

IntelSun
...
I got 2930MB of memory
I got 2931MB of memory
I got 2932MB of memory
Out of Memory
...
I got   69MB of memory
I got   70MB of memory
I got   71MB of memory
Out of Memory

請注意 C 語言會知道作業系統已經沒有記憶體可以給了, 但是 C 語言並不會自動輸出任何錯誤訊息。那一句 Out of Memory 是我們自己寫在程式裡面的錯誤訊息。

如果打一開始,就企圖宣告太多記憶體,會怎樣呢? 像以下這個程式,它企圖宣告一個 DIM 乘 DIM 的雙精度浮點數方陣, 而 DIM 是 23171。這個數字看似不大,其實想想看, 如果方陣的維度是 23171,方陣內就要有

231712 = 536895241
個元素,每個元素需要 8 bytes,所以總共需要
536895241 * 8 = 4295161928
個記憶體。這個數:超過 42 億,超過目前大部分電腦最多所能擁有的記憶體量:4GB。 所謂 4GB 是
4 * 230 = 4294967296
比比看就知道,方陣 A 索求的記憶體甚至超過 4GB!
/* Program D */
#include <stdio.h>
#define DIM 23171
main() {
    double A[DIM][DIM];
}

Program D 根本不能編譯,更別說執行了。 編譯時看到的錯誤訊息如下。 看來 Sun 提供的錯誤訊息比較有意義。 其實這些錯誤訊息不只跟 CPU 與作業系統有關, 還跟使用的編譯器 (compiler) 有關。 李白 (Intel) 安裝的 compiler 是 gcc version 2.96, 王維 (Sun) 安裝的 compiler 是 gcc version 2.95.1

Program E 的目的是實驗, 如果程式想要將數值存進不容許這個 process 使用的記憶體, 會發生 Segmentation Fault 訊息。


/* Program E */
#include <stdio.h>
main() {
    char str[12];
    int n=0;
    printf("I claimed for 12 bytes of memory, but I try to assign "
        "str[n] for n=0...\n");
    while (1) {
        str[n]=0;
        printf("%5d", n++);
        if (!(n%16))
            putchar('\n');
    }
}

Program E 只宣告了 12 個字元給 str 序列,用來儲存 1-byte 整數資料。 但是它卻無止境地指派數值 0 給 str[0], str[1], str[2], str[3], ...。 C 語言不會檢查其實 str[12] 已經超過 CPU 配給的範圍。 但是程式也不一定立刻就會出錯。 它可以一直執行到超出記憶體容許範圍,然後才會出錯。 以下是執行 Program E 的結果 (... 是作者修改的, 以免印出太多不重要的資料)。

IntelSun
...
2208 ... 2219 2220 2221 2222 2223
2224 ... 2235 2236 2237 2238 2239
2240 ... 2251 2252 2253 2254 2255
Segmentation fault
...
1520 ... 1531 1532 1533 1534 1535
1536 ... 1547 1548 1549 1550 1551
1552 ... 1563 1564 1565 1566 1567
Segmentation fault

雖然 Intel 比 Sun 多跑了一陣子,但是都跑不遠。 在這個例子裡面,Segmentation fault 不是我們自己寫的錯誤訊息, 是作業系統告訴我們的。

現在我們試探電腦的記憶體設計屬於 Big-Endian 還是 Little-Endian。

如果令 nint 型態的變數, 令 n 的值為 2562 + 2*256 + 3, 則它的位元排列是

00000000000000010000001000000011
將這 32 bits 每 8 bits 組成一個 byte,共應分成四個字元:
00000000 和 00000001 和 00000010 和 00000011
若以無號整數解讀,這四個字元的值應該是
0 和 1 和 2 和 3

CPU 一定會配給連續四個記憶體給 n, 但是這四個記憶體,卻有兩種放置四個 byte 的可能順序。 如果我們一律按照記憶體位址從小到大的順序來講, 則四個記憶體放置的字元可能是

先 00000000 然後 00000001 然後 00000010 最後 00000011
按照這種順序放置的電腦,稱為 Big-Endian (大頭派); 也可能是
先 00000011 然後 00000010 然後 00000001 最後 00000000
按照這種順序放置的電腦,稱為 Little-Endian (小頭派)。

為什麼叫『大頭』派?因為電腦把『大』的位數放在『前面』 (記憶體編號小的就是前面)。 就好像我們寫十進制數字 123 的意思一百二十三 (100 + 20 + 3), 也就是最大位--百位,寫在前面。因此,我們自己屬於『大頭派』。

相反地,如果把『小』的位數放在『前面』,那就是『小頭』派了。 如果有一個民族的文字,把 123 解釋成三百二十一 (1 + 20 + 300), 那他們就是『小頭派』。 阿拉伯文的文字書寫,是從右向左橫寫,但是遇到數字的時候, 卻是跟我們一樣從左向右寫。如果阿拉伯人讀文字與數字的時候, 都是從右向左讀,則他們會先讀到數字的最小位。 在這個意義之下,阿拉伯人是『小頭派』。

以下程式執行上述實驗。它印出變數 n 佔據的四個記憶體, 以及它們分別的值 (以 0 到 255 的無號整數表示)。


/* Program F */
#include <stdio.h>
main() {
    int n = 256*256+2*256+3;
    unsigned char *c;
    printf("n = %d\n", n);
    c = (unsigned char*) (void*) &n;
    printf("n is allocated at\n%11u\t%11u\t%11u\t%11u\n", c, c+1, c+2, c+3);
    printf("%11u\t", *c++);
    printf("%11u\t", *c++);
    printf("%11u\t", *c++);
    printf("%11u\n", *c++);
}

執行的報表如下。

Intel
n = 66051
n is allocated at
 3221223236      3221223237      3221223238      3221223239
          3               2               1               0
Sun
n = 66051
n is allocated at
 4026530332      4026530333      4026530334      4026530335
          0               1               2               3

我們看到,Intel 的 Pentium CPU 屬於小頭派, Sun 的 Sparc CPU 屬於大頭派。 與 Intel 相容的 AMD CPU 也是小頭派,而大部分其他廠牌的 CPU 都是大頭派。

最後我們講一則有趣的故事。 為什麼字元的排序設計,要叫做大頭或小頭呢? 雖然從前面的解釋,我們看得出意義,但是這背後其實有一個故事。 Big-Endian 和 Little-Endian 並不是計算機工程師定的名稱, 而是英文作家 Jonathan Swift 在將近 300 年前創造的名詞! 這個名詞出現於 Swift 創作的著名小說 "Gulliver's Travels", 中文通常翻譯作《格利佛遊記》或者《大小人國歷險記》或者《小人國歷險記》之類的, 許多讀者大概在童年時期讀過這本書的童話版節譯本。 這部故事書裡,有一個虛構的『小人國』,稱為 Lilliput。 格利佛意外抵達 Lilliput 的時候,該國正在內戰。 內戰分成兩大派系 (沒有派系就沒有內戰):Big-Endian 和 Little-Endian。

Big-Endian 和 Little-Endian 為了一件很可笑的小事而分成派系: Big-Endian (保守派) 堅持要從雞蛋比較大的那一頭敲開蛋殼 (大頭開蛋), 而 Little-Endian (改革派) 堅持要從雞蛋比較小的那一頭敲開蛋殼 (小頭開蛋)。 雞蛋比較大的那一頭叫做 big-end,因此支持大頭開蛋者就叫做 big-endian; 同理,另一派就叫做 little-endian 了。 作者其實可能要藉用這個情節,來諷刺當時在英國的政治與宗教時事。 後來,計算機科學家也在爭吵關於 byte order 的問題: 究竟是把高位的字元放在前面比較好、還是放在後面比較好? 一位當時在美國南加大的計算機科學家 Danny Cohen 在 1980 年 4 月 1 日,發表了標題為 "On Holy Wars and a Plea for Peace" 的文章 (後來在 1981 年刊登於 IEEE 的 Computer 期刊), 把這場計算機科學家的論戰比喻成格利佛在小人國遇見的 Big-Endian 和 Little-Endian 兩派之內戰。 這是非常有趣的譬喻,一直流傳至今,成為這兩種硬體設計理念的正式代名詞。 可見,如果童話書讀得透徹,長大後可以應用在偉大的論證上。

Jonathan Swift (1667--1745) 出生於愛爾蘭, 跟牛頓 (Issac Newton) 一樣是個遺腹子; Swift (通常譯為『斯威夫特』) 比牛頓年輕 25 歲, 雖然是當時英格蘭社會的重要知識份子之一, 但是一生的工作主要在神學與文學方面,不曉得他知不知道牛頓的偉大創作。 Swift 傳世的作品主要有三件,1726 年 (59 歲) 出版了《格利佛遊記》。 航海、發現、內戰、理性主義與宗教意識的對峙, 這些 Swift 時代的主要社會事件, 全都以幻想、諷刺的方式,寫在他的小說裡, 為他的時代留下幽默的見證。

課外讀物:
[1] "Gulliver's Travels"《格利佛遊記》專門網站 http://www.jaffebros.com/lee/gulliver
[2] Danny Cohen 的原始文件 "On Holy Wars and a Plea for Peace" http://khavrinen.lcs.mit.edu/wollman/ien-137.txt, [mirror]

[ 發表感想或意見 ] ‧ [ 讀者推薦課外讀物 ]
單維彰 (2004/06/04) ---