JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

虛擬與實體:位址的幻象

你印出過的每一個指標,都是一句客氣的謊言。本篇要揭開每個程式賴以存在的幻象——它以為自己獨佔一整片廣闊、私有、而且和所有人都從同一個位址起頭的記憶體——並說明是誰在維持這個謊言、又為什麼它是計算機科學裡最棒的點子之一。

你一直信任的那個數字

到目前為止,你已經印過不少位址了。你用 &x 取了一個變數的位址,把它存進一個指標,然後跑類似 `printf("%p\n", (void *)&x)` 的東西,看著一個十六進位數字掉出來——也許是 0x7ffd3a1c4abc。你學會把記憶體想成一條長長的位元組陣列,每個位元組各有自己的位址,而那個數字精確地告訴你,你的變數住在哪一個位元組上。前面幾階裡的一切,都把那個位址當成老實的真相:一個真實的位置,機器的 RAM 裡一個真實的格子。

而本階整章建立在一件令人不安的事情上:那個數字,並不是你 RAM 裡一個真實的位置。它是一個虛擬位址(virtual address)——一個虛構的座標,只在你這個正在執行的行程裡頭才有意義。它所指的那個真正的位元組,在實體上可能幾乎落在 RAM 的任何地方、是個完全不同的數字,或者——如同後面一篇會說明的——目前根本還不在 RAM 裡。你印出的那個位址,對你的程式而言是真的,對其他任何東西而言都不是。它是一個虛擬位址,有別於實體位址——別於那個真正握著該位元組的晶片格子的位址,而這兩者之間的落差,正是本階整章的主題。

電腦為什麼要費這個事,對每一支程式撒謊、騙它自己的資料住在哪裡?因為這個謊言一次買到了三樣別的辦法很難拿到的東西:隱私(沒有任何行程能指名、更別說碰到另一個行程的位元組)、單純(每支程式都可以相信自己從同一個清爽的位址起頭、獨佔一片巨大而連續的空間),以及彈性(系統可以把真正的位元組搬來搬去,或把一部分擱在硬碟上,而程式完全不會察覺)。這整套安排有個名字——虛擬記憶體——而搞懂它怎麼運作,就是搞懂一個系統程式設計師在觸及矽晶本身之前、所能碰到的抽象堆疊裡最深的那一層。

兩個位址空間,與它們之間的翻譯者

想像兩個各自獨立的位址世界。第一個是虛擬位址空間(virtual address space):你的行程被允許談論的位址範圍,排布的方式就跟前面講位址空間配置那一篇所描述的一模一樣——程式碼擺在低處,接著是靜態資料、一塊往上長的堆積、一塊從高位址往下長的堆疊。在一台 64 位元機器上,這個空間大得驚人,遠遠大於任何真實的 RAM,而且每個行程都拿到它自己一份全新的副本。第二個世界是實體記憶體(physical memory):你機器裡真正那些 RAM 晶片的真正位址,一組小得多、也非常具體的格子,由同時在跑的一切共用。

在那兩個世界之間,坐著一個翻譯者。每一次——只要你的 CPU 讀或寫記憶體,每一次指令擷取、每一次載入、每一次儲存——從程式出來的虛擬位址,都會在抵達 RAM 之前被換算成一個實體位址。這個換算叫做位址轉譯,而它對每一次記憶體存取都會發生,一秒鐘數十億次,這意味著它絕對不能慢。所以它不是由作業系統在每次存取時用軟體去做的;它是由 CPU 上一塊專門的硬體去做的,那塊硬體叫做記憶體管理單元,或 MMU,下一篇會把它仔細拆開來看。現在你只要先握住它的形狀就好:

  program says:   *p   where p = 0x00007ffd3a1c4abc   (virtual)
                           |
                           v   [ MMU translates, every access ]
  RAM actually sees:           0x000000011e8c4abc       (physical)

  same virtual address 0x00007ffd3a1c4abc
    in process A  ->  physical 0x000000011e8c4abc
    in process B  ->  physical 0x0000000007240abc   (totally different byte)
一個虛擬位址,由 MMU 在每次存取時翻譯。同一個虛擬數字在兩個不同的行程裡,對應到兩個不同的實體位元組——這正是為什麼行程彼此看不見對方的記憶體。

為什麼同一個位址會代表不同的東西

好好盯著那張草圖的底部看,因為它解釋了一件你也許早就注意到、還覺得奇怪的事。把一支小程式跑兩次,印出同一個全域變數的位址,你兩次可能拿到相同的虛擬位址——然而這兩次執行從不相撞、從不弄壞彼此。同時跑兩支不同的程式,每一支都可以握著一個值為 0x00400000、指向它自己程式碼的指標,完全沒有衝突。如果位址是實體的,這是不可能的:兩樣東西不可能佔住同一個 RAM 格子。但當位址是虛擬的時候,這就完全沒問題了,因為每個行程都有它自己私有的一套翻譯,所以它的 0x00400000 落在一個和別人都不同的實體位元組上。

這個翻譯不只是換個標籤——它同時也是一道閘門。對一個行程虛擬空間裡的每一塊區域,系統都記錄著那裡被允許做什麼:可不可以讀、可不可以寫、可不可以裝著 CPU 將要執行的指令。這就是記憶體保護,而它就搭在同一套翻譯機制上一起跑。當你的程式去解參考一個沒有有效翻譯的虛擬位址,或者去寫一個被標成唯讀的位址(像是裝著字串字面值的那一頁)時,硬體會拒絕,而核心會把那個拒絕變成你之前見過的那個區段錯誤。從這裡看回去,一次區段錯誤,就是那個翻譯者在回報:你用的那個虛擬位址,並不對應到任何你被允許去碰的東西。

它不可能一個位元組一個位元組來:分頁登場

這一切之中,藏著一個明顯的問題。如果虛擬記憶體的每一個位元組,都得在某張查找表裡有它自己私有的一筆條目、記著它對應到哪一個實體位元組,那這張表就會跟它所描述的記憶體一樣大——毫無用處。解法是整套方案的核心把戲:位址空間被切成固定大小的區塊,叫做分頁(page),幾乎總是一塊 4 KiB(4096 位元組),而翻譯是一整一整頁地做的,不是一個位元組一個位元組地做。一個虛擬分頁對應到一塊同樣大小的實體區塊,叫做頁框(frame),而系統只需要記住每一頁的對應關係,不必記住每一個位元組的——大約少了四千倍的條目。

一旦你把位址看成「頁加偏移」,翻譯就變得很整齊。把一個虛擬位址拆成兩部分:高位的位元挑出是哪一頁,而低位的位元是偏移量(offset)——也就是那個位元組落在這一頁裡多深的地方。因為一頁 4 KiB 剛好是 0x1000 位元組,最低的 12 個位元(因為 2^12 = 4096)就是偏移量,而它們之上的一切就是頁碼。翻譯永遠只動到頁碼那一部分:它查出那個虛擬分頁對應到哪一個實體頁框,然後把偏移量原封不動地保留。那個位元組在它那一頁之內的位置,在兩個世界裡是一模一樣的;被重新安置的,只有分頁本身。

那個「頁對頁框」的對應關係存在哪裡?存在一個每個行程各有一份的結構裡,叫做分頁表——每個行程各自擁有一張表,每一頁各有一筆條目,記著它目前住在哪一個實體頁框,外加那些把它閘住的保護位元。MMU 在每次存取時都查詢這張表,而為了讓這件事快起來,它把最近用過的翻譯結果快取在一塊晶片上極小的查找緩衝裡(下一篇你會以 TLB 之名認識它)。分頁表,正是把那個「每個行程一份」的幻象變成具體的東西:行程 A 與行程 B 各有自己的一張表,所以同一個虛擬頁碼,在每一個裡頭都解析到不同的頁框。我們在這裡只是把這些零件點個名而已;下一篇會從最根本處把真正的分頁表建起來,並帶著一次翻譯走過它。

這個幻象解開了什麼,以及本階要往哪走

一旦虛擬與實體之間坐著一層翻譯,一整族威力強大的把戲就變得可能了,而它們就是本階接下來的內容。因為分頁表可以說「這個虛擬分頁目前還不對應到任何頁框」,系統就能交給一支程式一片巨大的位址空間,卻只在程式真正第一次去碰某一頁的那一刻,才替它找一塊真實的 RAM——這就是需求分頁,而碰到一個不在場分頁的那一刻,是一次核心會悄悄解決掉的分頁錯誤。因為一頁可以被標成唯讀且共享,兩個行程就能指向同一份實體副本,只在其中一個去寫的時候才各自拿到一份私有的複本——這就是寫入時複製,也是 fork() 之所以能保持便宜的原因。而因為一個檔案可以被整片滑進位址空間當成分頁,你就能單純靠解參考指標來讀一個檔案——這就是 mmap()

所以在我們推近之前,先握住整幅圖。你的程式整個活在虛擬位址裡;MMU 在每次存取時把每一個虛擬位址翻譯成一個實體位址,一次一頁,透過一張每個行程各有一份、同時也執行保護的分頁表。就這一個機制,給了每個行程隱私、一份乾淨而一致的配置,以及讓核心去擺放、搬移、共享或暫時扣住真實位元組的自由。〈分頁、分頁表與 MMU〉會把那個翻譯者本身建起來。〈分頁錯誤與需求分頁〉跟著看一頁不在場時會發生什麼。接著〈寫入時複製,以及 fork 如何保持便宜〉與〈mmap 與記憶體映射檔案〉,會把這個幻象兌現成你這輩子會用到的兩件最有用的工具。你現在知道那個謊言是什麼了;本階剩下的,就是它是怎麼被維持住的。