博客 / 詳情

返回

Unix的催化劑·遊戲《太空旅行》代碼片段賞析:1.0要寫作“1;0200000;0”

1968 年,Ken Thompson 投入大量心血的項目被管理層終止,部門開始重組,開發環境一夜之間從“高速公路”變成了“泥濘小道”。想申請一台新機器繼續研發,被領導一口拒絕。可 Ken 並沒有因此停下手裏的活,還時刻惦記着那個親手編寫出來的宇宙飛行模擬遊戲:太空旅行(Space Travel

太空旅行(_Space Travel_)遊戲畫面

太空旅行(Space Travel)遊戲畫面

後來的故事,很多讀者或許已經聽過不止一次了。

Ken 注意到實驗室角落裏還躺着一台舊機器——PDP-7,於是他決定把《太空旅行》移植到這台 PDP-7 上,以便能隨時玩上一把。移植過程相當繁瑣,PDP-7 沒有像樣的開發工具,許多工作都得從零開始。但也正是在這個過程中,Ken 和他的同事們開始動手搭建自己的工具鏈,一點點在這台老機器上建立起全新的系統。這個新系統正是原始版本的 Unix。

這段經歷大家可能讀過不少次了。可 PDP-7 版《太空旅行》的代碼究竟長什麼樣,可能沒多少人看過。今天,我們就一起來鑑賞其中的一個小片段。

PDP-7版《太空旅行》的代碼片段

PDP-7版《太空旅行》的代碼片段

星球的屬性“prsq”

圖中這兩段代碼,算是整個程序裏最容易猜出用途的一部分了。左邊這段列出了太陽、地球、火星等 32 個星球的名字。右邊的代碼也是 32 行數據,每行 3 個數字,用分號分開。行數和名字列表的行數一致,所以很容易猜出這些數字是在描述星球的某種屬性,而前面的標籤 prsq: 也暗示了,這種屬性叫做“prsq”。

什麼是“prsq”呢?它其實是“Planet Radius Squared”的縮寫,也就是“星球半徑的平方”。不過這裏存的其實不是半徑的平方本身,而是各星球半徑的平方與地球半徑的平方之比值,即地球半徑的平方是基準單位。既然是單位,那值自然就是 1.0 了。

在左邊的 name 列表中,地球排第 2 位,那我們來看右邊 prsq 列表的第 2 項是什麼?

地球的prsq為什麼是1;0200000;0

地球的prsq為什麼是1;0200000;0

1;0200000;0——第 1 個數字倒是 1,可為什麼後面還有個 2 呢?不過無論如何,這一行數據是整個列表裏最整齊的,其他行的數據看起來就像亂碼。

下面,我們就試着來解密這組數字背後的秘密。

解密“1;0200000;0”

要破解這組古老的數據,首先得了解 PDP-7 是台怎樣的機器,以及它是怎麼表示小數的。

PDP-7 是 1960 年代的小型計算機,和今天的電腦在很多地方都不一樣。雖説是小型機,但看看照片吧,一台機器佔了多少地方。

1960年代的小型計算機PDP-7

1960年代的小型計算機PDP-7

PDP-7 的特性之一是 18 比特的內存存儲單元,即一個存儲單元的大小不是熟悉的 1 字節 8 比特,而是 18 比特。

你可能也注意到了, prsq 列表中的數字都是 0 開頭的八進制數。我們知道,1 個八進制數對應 3 個二進制數(比特),而 18 能整除 3,商是 6——也就是説,18 個二進制數,即 18 比特,剛好可以寫成 6 個八進制數。而如果用現在常見的十六進制數表示呢?1 個十六進制數對應 4 個比特,18 除以 4 得 4.5,這樣就不得不區別對待最左側和餘下的十六進制數了,既麻煩又容易出錯。

這組數據每行中 3 個其間用分號分隔的八進制數字的含義如下:

第 1 個八進制數最直接,表示指數,即 2 的多少次方(可正可負)。

第 2 個八進制數(共 6 位,對應 18 個比特)稍微複雜一點。轉換成二進制數後,最左邊的 1 位用來表示尾數正負號,為 1 時是負數,為 0 時是正數。剩下的 17 位則是尾數的前半部分。從左到右,每 1 位都對應着一個逐漸變小的權重:2⁻¹、2⁻²、2⁻³……,最後一位對應權重 2⁻¹⁷。

第 3 個八進制數也對應 18 個比特(不足 18 位時在左側補 0),是尾數的後半部分。從左到右對應着權重,2⁻¹⁸ 、2⁻¹⁹……、一直到 2⁻³⁵。

要把這 3 個八進制數還原成小數,需要先把尾數中的每一位都乘上對應的權重,然後全部加起來,接着乘上 1 或 -1 以帶上正負號,最後再乘上指數(2 的幾次方)。

我們先按照這個規則算一下地球與自身的半徑平方之比,口算就行:

1;0200000;0: +1 × 2¹ × 2⁻¹ = 2 × 0.5 = 1

還真是 1。接下來再計算一下太陽與地球的半徑平方之比:

手工計算太陽與地球的半徑平方之比

手工計算太陽與地球的半徑平方之比

最終計算出來的結果約等於 11924.6400,和直接用太陽的半徑 696000 km 和地球的半徑 6371 km 算出來的結果 696000² ÷ 6371² = 11934.4736 相比,有一些小誤差。

我們還可以把這個規則轉換成代碼,計算一下其他星球的 prsq(參考代碼在文末)。

有關 prsq 的代碼只是整個遊戲程序中非常小的一部分,而且還是相對“好讀”的部分。即便如此,乍看起來依然晦澀難懂。可見當年的程序員真的要“貼着硬件”去思考問題,技巧和耐心都不可或缺。

順帶一提,哪怕到了今天,現代彙編語言已經支持按日常書寫習慣寫小數了,可一旦落到內存裏,哪怕是個簡單的 1.0,也不再是一眼就能看懂的東西了。000001c0: 00 00 80 3f,能一眼看出內存地址 0x000001c0 上存了個 4 字節的 float 1.0 嗎?

Ken 看似“不務正業”的這段插曲,最終卻成了 Unix 誕生的催化劑。而《太空旅行》的真正遺產,或許不只是它的代碼,而是一個道理:偉大的創新可能誕生於看似沒有價值的探索中

🔚

PDP7_code = {
    "earth":    "1;0200000;0",
    "sun":      "016;0272245;075341",
    "mars":     "-01;0221530;0",
    "mercury":  "-02;0235142;0",
    "venus":    "0;0362406;0",
    "jupiter":  "07;0376733;0",
    "saturn":   "07;0263573;0",
}

# np.array stores 2^-1, 2^-2, ..., 2^-35
weights = np.array([2**-x for x in range(1, 36)])

for name, prsq in PDP7_code.items():
    nums = [int(x, 8) for x in prsq.split(";")]

    # check sign bit by masking the 2nd number(`nums[1]`) 17th bit
    sign = -1 if nums[1] & 0x20000 else 1
    mantissa = 0

    # convert the sign bit masked 2nd number to binary np.array
    # and multiply it with weights[:17]
    nums[1] = nums[1] & 0x1FFFF
    bin_array = [int(x) for x in list(format(nums[1], '017b'))]
    mantissa += np.array(bin_array).dot(weights[:17])

    # convert the 3rd number(`nums[2]`) to binary np.array
    # and multiply it with weights[17:]
    bin_array = [int(x) for x in list(format(nums[2], '018b'))]
    mantissa += np.array(bin_array).dot(weights[17:])

    exponent = 2 ** nums[0] if nums[0] > 0 else 1 / 2 ** (-nums[0])

    print(name, sign * mantissa * exponent)

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.