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

分段:記憶體的邏輯視角

一支程式並不把自己想成一條平坦的位元組長帶——它是分部分在想的:這是我的程式碼、那是我的堆疊、那邊是我用來查東西的名稱表。分段就是作業系統終於同意了程式設計師心中那幅圖像,並給每個部分一個自己的、受保護的房間。

一支程式從不相信的平坦記憶體謊言

這個階段到目前為止,都把一個行程的記憶體當成一條又長又無特徵的長帶——一塊從邏輯位址 0 跑到某個上限的單一區塊,用一個基底和一個界限來對應。但程式當初根本不是那樣寫的。程式設計師是分成不同的部分在想的:這裡是函式的程式碼、這裡是會長大縮小的呼叫堆疊、這裡是執行期才配置物件的堆積、這裡是全域變數的表。這些部分大小不同、成長方向不同,也理當受到不同的保護。把它們硬塞進同一塊平坦區塊,是給硬體的方便,而不是關於程式的真相。

分段就是認真看待程式設計師這套視角的記憶體方案。它不是給每個行程一個位址空間,而是給這個行程一群彼此獨立的位址空間——每個邏輯部分各一個——每一個都叫做一個分段。可能有程式碼的分段、堆疊的分段、堆積的分段、全域資料的分段,或許每個共享程式庫也各一個。每個分段都有編號、各自從自己的偏移量 0 起算,也各有自己的長度。程式不再說「位址 4200 那個位元組」;它說「堆疊分段的第 8 個位元組」或「程式碼分段的第 300 個位元組」。記憶體成了一個邏輯視角,而不是一個物理視角。

一個二維的位址

在平坦方案下,一個邏輯位址就只是一個數字。在分段下它變成了兩個:一個分段編號說明是哪個部分,以及一個偏移量說明在那個部分裡走了多遠。你可以把它寫成 (分段編號, 偏移量) 這一對。這是貨真價實的二維——就像圖書館的索書號同時指明了一個書架,以及在那個書架上的位置——而平坦位址則是一維的。硬體現在需要一個辦法,把每個 (分段, 偏移) 對轉換成 RAM 裡真正的實體位址,而這正是分段表的工作。

分段表是這個機制的核心。它是每個行程一個小小的陣列,每個分段一列,而每一列裝著兩樣你在MMU 那篇導覽裡早已熟透的東西:一個基底(這個分段在 RAM 裡起始的實體位址)以及一個界限(這個分段的長度)。換句話說,分段不過就是把基底與界限的想法重複用了很多次——把整個行程一對基底/界限,變成每個分段各一對基底/界限。分段編號只是用來索引這張表的索引值。

Logical address:  (segment = 2, offset = 88)

Segment table for this process:
   seg | base    | limit
   ----+---------+------
    0  |  4000   |  300     (code)
    1  |  9200   |  150     (stack)
    2  |  6100   |  120     (heap)   <-- segment 2

Step 1  look up row 2:  base = 6100, limit = 120
Step 2  is offset 88 < limit 120 ?   yes  -> legal
Step 3  physical address = base + offset = 6100 + 88 = 6188

(if offset had been 200:  200 < 120 ? NO -> trap, segmentation fault)
翻譯 (分段 2, 偏移 88):以編號索引表、把偏移量對著界限做檢查,再加上基底。

一個分段位址是怎麼被翻譯的

這個翻譯是硬體在每一次記憶體存取時都會跑的一小段固定流程——快到能無形中完成,就像你先前看到的重定位一樣。把它走過一遍,分段就不再神秘了;它不過就是「先挑出要用表中哪一列」之後,再做那個基底與界限的檢查罷了。

  1. CPU 產生一個邏輯位址,並把它拆成一對 (分段編號, 偏移量)。
  2. 用分段編號去索引這個行程的分段表,讀出該分段的基底與界限。
  3. 把偏移量對著界限檢查:如果偏移量大於或等於界限,這次存取就落在分段之外——硬體會引發一個陷阱(一個「分段錯誤」),由作業系統介入處理。
  4. 如果偏移量合法,實體位址就只是基底 + 偏移量,真正的記憶體存取便接著進行。

請注意界限檢查是「逐分段」做的,而這正是分段在保護上悄悄賺到價值的地方。因為每個分段各有自己的長度,一個失控、走過了堆疊尾端的迴圈,不可能無聲無息地塗寫到程式碼裡——它會超出堆疊分段的界限,當場就被逮住。這正是你在基底與界限那裡遇過的、那種在位址翻譯時刻設下的防護,如今被加倍了,讓每個邏輯部分各自被圍起來。

分段為你買到什麼

除了天然契合程式被組織的方式之外,「逐分段一列」這件事還解鎖了兩項強大而實用的好處。第一是以邏輯部分為粒度的保護。每一列表格都可以攜帶權限位元——讀、寫、執行——以契合該部分真正的本質。程式碼分段可以標成可讀可執行但不可寫(這樣一個臭蟲或一個攻擊者就無法改寫指令),而資料分段是可讀可寫但不可執行。這是檔案權限如「rwxr-xr-x」在記憶體上的對應物:權利掛在一個有意義的單位上,而不是掛在隨機的位元組上。

第二項好處是共享。如果兩個行程正在執行同一支程式,或兩者都使用同一個共享程式庫,它們各有一列,把這個程式庫的分段指向同一個實體基底。RAM 裡只有一份程式碼,卻被許多行程使用——這正是上一篇導覽在介紹動態連結時所許下的回報。共享之所以乾淨俐落,恰恰是因為一個分段是個自成一體的邏輯單位,有自己的基底、界限與權限;你共享的是整個有意義的東西,標成唯讀,沒有一個行程的寫入會滲漏進另一個行程的風險。

誠實的侷限,以及通往分頁的路

分段是個實在的進步,但要誠實面對它沒能修好的東西。每個分段仍然是一塊大小可變的單一連續區塊,由先前同一套適配政策來安置,也染上和先前一樣的毛病。所以分段緩解了外部碎裂卻沒有根治它:作業系統現在要安置的不再是每個行程一塊大區塊,而是好幾塊較小的區塊,它們散裂成較小的空洞——裝得下的機率更高,但分段之間的空洞仍然是被浪費掉的空間。連續配置那個本質性的弱點還活著,只是型態溫和了些。

還有第二個麻煩:大小可變的分段仍然需要檢查並加上它的基底與界限,而一個有很多分段的行程需要一張大表或一次代價高昂的查找。又因為一個分段可以很大,作業系統有時找不到一個夠大的單一空洞來容納,比方說,一個長大了的巨大堆積——即使空閒記憶體總量很充裕也一樣。根本原因和這個階段一開始並無二致:堅持每個邏輯區塊都得是一段不間斷的實體記憶體。

那唯一一條堅持,是最後剩下還能放掉的東西,而放掉它就是躍入下一個階段。分頁把記憶體和每個行程都拆成許多細小、固定大小的片,於是一個分段不再需要一個連續的家——它可以被打散,這裡一片、那裡一片,散落在空閒片剛好所在的任何地方。片的大小一致,每個空洞就都裝得下,外部碎裂便成了不可能。一如既往,誠實的交換是最後一片裡的一點點內部碎裂,以及一張更繁複、用來追蹤所有片的表。現代系統保留了分段那套邏輯的、帶權限的記憶體視角,再在它底下鋪上分頁來做安置——兩者之長兼得,而這正是這條學習階梯接下來要去的地方。