C 語言可以說是『最低階的高階語言』, 它提供許多語法讓人有機會窺探電腦的具體狀況。 以下提供程式原始碼,和作者寫此文件當時的執行狀況。 這裡不是 C 語言教材,所以不說明 C 的語法,只是用它來示範。 這些程式都是標準的 ANSI C 原始碼,可以用任何 C 編譯器製造可執行檔, 在讀者自己的機器上做實驗。
以下程式顯示三個變數 c, n 和 str 被配給的記憶體位址。
/* 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 的執行結果為
Intel | Sun |
---|---|
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 的執行結果如下。
Intel | Sun |
---|---|
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 的執行結果如下 (... 是作者修改的,以免印出太多不重要的資料)。
Intel | Sun |
---|---|
... 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 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 的結果 (... 是作者修改的, 以免印出太多不重要的資料)。
Intel | Sun |
---|---|
... 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。
如果令 n 是 int 型態的變數, 令 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) ---