'虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?'

"

本文翻譯並修改自:http://duartes.org/gustavo/blog/

微信公眾號:技術原理君

本文是Intel兼容計算機(x86)的內存與保護系列文章的第一篇,延續了啟動引導系列文章的主題,進一步分析操作系統內核的工作流程。與以前一樣,我將引用Linux內核的源代碼,但對Windows只給出示例(抱歉,我忽略了BSD,Mac等系統,但大部分的討論對它們一樣適用)。文中如果有錯誤,請不吝賜教。

在支持Intel的主板芯片組上,CPU對內存的訪問是通過連接著CPU和北橋芯片的前端總線來完成的。在前端總線上傳輸的內存地址都是物理內存地址,編號從0開始一直到可用物理內存的最高端。這些數字被北橋映射到實際的內存條上。物理地址是明確的、最終用在總線上的編號,不必轉換,不必分頁,也沒有特權級檢查。然而,在CPU內部,程序所使用的是邏輯內存地址,它必須被轉換成物理地址後,才能用於實際內存訪問。從概念上講,地址轉換的過程如下圖所示:

"

本文翻譯並修改自:http://duartes.org/gustavo/blog/

微信公眾號:技術原理君

本文是Intel兼容計算機(x86)的內存與保護系列文章的第一篇,延續了啟動引導系列文章的主題,進一步分析操作系統內核的工作流程。與以前一樣,我將引用Linux內核的源代碼,但對Windows只給出示例(抱歉,我忽略了BSD,Mac等系統,但大部分的討論對它們一樣適用)。文中如果有錯誤,請不吝賜教。

在支持Intel的主板芯片組上,CPU對內存的訪問是通過連接著CPU和北橋芯片的前端總線來完成的。在前端總線上傳輸的內存地址都是物理內存地址,編號從0開始一直到可用物理內存的最高端。這些數字被北橋映射到實際的內存條上。物理地址是明確的、最終用在總線上的編號,不必轉換,不必分頁,也沒有特權級檢查。然而,在CPU內部,程序所使用的是邏輯內存地址,它必須被轉換成物理地址後,才能用於實際內存訪問。從概念上講,地址轉換的過程如下圖所示:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

x86 CPU開啟分頁功能後的內存地址轉換過程

此圖並未指出詳實的轉換方式,它僅僅描述了在CPU的分頁功能開啟的情況下內存地址的轉換過程。如果CPU關閉了分頁功能,或運行於16位實模式,那麼從分段單元(segmentation unit)輸出的就是最終的物理地址了。當CPU要執行一條引用了內存地址的指令時,轉換過程就開始了。第一步是把邏輯地址轉換成線性地址。但是,為什麼不跳過這一步,而讓軟件直接使用線性地址(或物理地址呢?)其理由與:"人類為何要長有闌尾?它的主要作用僅僅是被感染髮炎而已"大致相同。這是進化過程中產生的奇特構造。要真正理解x86分段功能的設計,我們就必須回溯到1978年。

最初的8086處理器的寄存器是16位的,其指令集大多使用8位或16位的操作數。這使得代碼可以控制216個字節(或64KB)的內存。然而Intel的工程師們想要讓CPU可以使用更多的內存,而又不用擴展寄存器和指令的位寬。於是他們引入了段寄存器(segment register),用來告訴CPU一條程序指令將操作哪一個64K的內存區塊。一個合理的解決方案是:你先加載段寄存器,相當於說"這兒!我打算操作開始於X處的內存區塊";之後,再用16位的內存地址來表示相對於那個內存區塊(或段)的偏移量。總共有4個段寄存器:一個用於棧(ss),一個用於程序代碼(cs),兩個用於數據(ds,es)。在那個年代,大部分程序的棧、代碼、數據都可以塞進對應的段中,每段64KB長,所以分段功能經常是透明的。

現今,分段功能依然存在,一直被x86處理器所使用著。每一條會訪問內存的指令都隱式的使用了段寄存器。比如,一條跳轉指令會用到代碼段寄存器(cs),一條壓棧指令(stack push instruction)會使用到堆棧段寄存器(ss)。在大部分情況下你可以使用指令明確的改寫段寄存器的值。段寄存器存儲了一個16位的段選擇符(segment selector);它們可以經由機器指令(比如MOV)被直接加載。唯一的例外是代碼段寄存器(cs),它只能被影響程序執行順序的指令所改變,比如CALL或JMP指令。雖然分段功能一直是開啟的,但其在實模式與保護模式下的運作方式並不相同的。

在實模式下,比如在引導啟動的初期,段選擇符是一個16位的數值,指示出一個段的開始處的物理內存地址。這個數值必須被以某種方式放大,否則它也會受限於64K當中,分段就沒有意義了。比如,CPU可能會把這個段選擇符當作物理內存地址的高16位(只需將之左移16位,也就是乘以216)。這個簡單的規則使得:可以按64K的段為單位,一塊塊的將4GB的內存都尋址到。遺憾的是,Intel做了一個很詭異的設計,讓段選擇符僅僅乘以24(或16),一舉將尋址範圍限制在了1MB,還引入了過度複雜的轉換過程。下述圖例顯示了一條跳轉指令,cs的值是0x1000:

"

本文翻譯並修改自:http://duartes.org/gustavo/blog/

微信公眾號:技術原理君

本文是Intel兼容計算機(x86)的內存與保護系列文章的第一篇,延續了啟動引導系列文章的主題,進一步分析操作系統內核的工作流程。與以前一樣,我將引用Linux內核的源代碼,但對Windows只給出示例(抱歉,我忽略了BSD,Mac等系統,但大部分的討論對它們一樣適用)。文中如果有錯誤,請不吝賜教。

在支持Intel的主板芯片組上,CPU對內存的訪問是通過連接著CPU和北橋芯片的前端總線來完成的。在前端總線上傳輸的內存地址都是物理內存地址,編號從0開始一直到可用物理內存的最高端。這些數字被北橋映射到實際的內存條上。物理地址是明確的、最終用在總線上的編號,不必轉換,不必分頁,也沒有特權級檢查。然而,在CPU內部,程序所使用的是邏輯內存地址,它必須被轉換成物理地址後,才能用於實際內存訪問。從概念上講,地址轉換的過程如下圖所示:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

x86 CPU開啟分頁功能後的內存地址轉換過程

此圖並未指出詳實的轉換方式,它僅僅描述了在CPU的分頁功能開啟的情況下內存地址的轉換過程。如果CPU關閉了分頁功能,或運行於16位實模式,那麼從分段單元(segmentation unit)輸出的就是最終的物理地址了。當CPU要執行一條引用了內存地址的指令時,轉換過程就開始了。第一步是把邏輯地址轉換成線性地址。但是,為什麼不跳過這一步,而讓軟件直接使用線性地址(或物理地址呢?)其理由與:"人類為何要長有闌尾?它的主要作用僅僅是被感染髮炎而已"大致相同。這是進化過程中產生的奇特構造。要真正理解x86分段功能的設計,我們就必須回溯到1978年。

最初的8086處理器的寄存器是16位的,其指令集大多使用8位或16位的操作數。這使得代碼可以控制216個字節(或64KB)的內存。然而Intel的工程師們想要讓CPU可以使用更多的內存,而又不用擴展寄存器和指令的位寬。於是他們引入了段寄存器(segment register),用來告訴CPU一條程序指令將操作哪一個64K的內存區塊。一個合理的解決方案是:你先加載段寄存器,相當於說"這兒!我打算操作開始於X處的內存區塊";之後,再用16位的內存地址來表示相對於那個內存區塊(或段)的偏移量。總共有4個段寄存器:一個用於棧(ss),一個用於程序代碼(cs),兩個用於數據(ds,es)。在那個年代,大部分程序的棧、代碼、數據都可以塞進對應的段中,每段64KB長,所以分段功能經常是透明的。

現今,分段功能依然存在,一直被x86處理器所使用著。每一條會訪問內存的指令都隱式的使用了段寄存器。比如,一條跳轉指令會用到代碼段寄存器(cs),一條壓棧指令(stack push instruction)會使用到堆棧段寄存器(ss)。在大部分情況下你可以使用指令明確的改寫段寄存器的值。段寄存器存儲了一個16位的段選擇符(segment selector);它們可以經由機器指令(比如MOV)被直接加載。唯一的例外是代碼段寄存器(cs),它只能被影響程序執行順序的指令所改變,比如CALL或JMP指令。雖然分段功能一直是開啟的,但其在實模式與保護模式下的運作方式並不相同的。

在實模式下,比如在引導啟動的初期,段選擇符是一個16位的數值,指示出一個段的開始處的物理內存地址。這個數值必須被以某種方式放大,否則它也會受限於64K當中,分段就沒有意義了。比如,CPU可能會把這個段選擇符當作物理內存地址的高16位(只需將之左移16位,也就是乘以216)。這個簡單的規則使得:可以按64K的段為單位,一塊塊的將4GB的內存都尋址到。遺憾的是,Intel做了一個很詭異的設計,讓段選擇符僅僅乘以24(或16),一舉將尋址範圍限制在了1MB,還引入了過度複雜的轉換過程。下述圖例顯示了一條跳轉指令,cs的值是0x1000:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

實模式分段功能

實模式的段地址以16個字節為步長,從0開始編號一直到0xFFFF0(即1MB)。你可以將一個從0到0xFFFF的16位偏移量(邏輯地址)加在段地址上。在這個規則下,對於同一個內存地址,會有多個段地址/偏移量的組合與之對應,而且物理地址可以超過1MB的邊界,只要你的段地址足夠高(參見臭名昭著的A20線)。同樣的,在實模式的C語言代碼中,一個遠指針(far pointer)既包含了段選擇符又包含了邏輯地址,用於尋址1MB的內存範圍。真夠"遠"的啊。隨著程序變得越來越大,超出了64K的段,分段功能以及它古怪的處理方式,使得x86平臺的軟件開發變得非常複雜。這種設定可能聽起來有些詭異,但它卻把當時的程序員推進了令人崩潰的深淵。

在32位保護模式下,段選擇符不再是一個單純的數值,取而代之的是一個索引編號,用於引用段描述符表中的表項。這個表為一個簡單的數組,元素長度為8字節,每個元素描述一個段。看起來如下:

"

本文翻譯並修改自:http://duartes.org/gustavo/blog/

微信公眾號:技術原理君

本文是Intel兼容計算機(x86)的內存與保護系列文章的第一篇,延續了啟動引導系列文章的主題,進一步分析操作系統內核的工作流程。與以前一樣,我將引用Linux內核的源代碼,但對Windows只給出示例(抱歉,我忽略了BSD,Mac等系統,但大部分的討論對它們一樣適用)。文中如果有錯誤,請不吝賜教。

在支持Intel的主板芯片組上,CPU對內存的訪問是通過連接著CPU和北橋芯片的前端總線來完成的。在前端總線上傳輸的內存地址都是物理內存地址,編號從0開始一直到可用物理內存的最高端。這些數字被北橋映射到實際的內存條上。物理地址是明確的、最終用在總線上的編號,不必轉換,不必分頁,也沒有特權級檢查。然而,在CPU內部,程序所使用的是邏輯內存地址,它必須被轉換成物理地址後,才能用於實際內存訪問。從概念上講,地址轉換的過程如下圖所示:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

x86 CPU開啟分頁功能後的內存地址轉換過程

此圖並未指出詳實的轉換方式,它僅僅描述了在CPU的分頁功能開啟的情況下內存地址的轉換過程。如果CPU關閉了分頁功能,或運行於16位實模式,那麼從分段單元(segmentation unit)輸出的就是最終的物理地址了。當CPU要執行一條引用了內存地址的指令時,轉換過程就開始了。第一步是把邏輯地址轉換成線性地址。但是,為什麼不跳過這一步,而讓軟件直接使用線性地址(或物理地址呢?)其理由與:"人類為何要長有闌尾?它的主要作用僅僅是被感染髮炎而已"大致相同。這是進化過程中產生的奇特構造。要真正理解x86分段功能的設計,我們就必須回溯到1978年。

最初的8086處理器的寄存器是16位的,其指令集大多使用8位或16位的操作數。這使得代碼可以控制216個字節(或64KB)的內存。然而Intel的工程師們想要讓CPU可以使用更多的內存,而又不用擴展寄存器和指令的位寬。於是他們引入了段寄存器(segment register),用來告訴CPU一條程序指令將操作哪一個64K的內存區塊。一個合理的解決方案是:你先加載段寄存器,相當於說"這兒!我打算操作開始於X處的內存區塊";之後,再用16位的內存地址來表示相對於那個內存區塊(或段)的偏移量。總共有4個段寄存器:一個用於棧(ss),一個用於程序代碼(cs),兩個用於數據(ds,es)。在那個年代,大部分程序的棧、代碼、數據都可以塞進對應的段中,每段64KB長,所以分段功能經常是透明的。

現今,分段功能依然存在,一直被x86處理器所使用著。每一條會訪問內存的指令都隱式的使用了段寄存器。比如,一條跳轉指令會用到代碼段寄存器(cs),一條壓棧指令(stack push instruction)會使用到堆棧段寄存器(ss)。在大部分情況下你可以使用指令明確的改寫段寄存器的值。段寄存器存儲了一個16位的段選擇符(segment selector);它們可以經由機器指令(比如MOV)被直接加載。唯一的例外是代碼段寄存器(cs),它只能被影響程序執行順序的指令所改變,比如CALL或JMP指令。雖然分段功能一直是開啟的,但其在實模式與保護模式下的運作方式並不相同的。

在實模式下,比如在引導啟動的初期,段選擇符是一個16位的數值,指示出一個段的開始處的物理內存地址。這個數值必須被以某種方式放大,否則它也會受限於64K當中,分段就沒有意義了。比如,CPU可能會把這個段選擇符當作物理內存地址的高16位(只需將之左移16位,也就是乘以216)。這個簡單的規則使得:可以按64K的段為單位,一塊塊的將4GB的內存都尋址到。遺憾的是,Intel做了一個很詭異的設計,讓段選擇符僅僅乘以24(或16),一舉將尋址範圍限制在了1MB,還引入了過度複雜的轉換過程。下述圖例顯示了一條跳轉指令,cs的值是0x1000:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

實模式分段功能

實模式的段地址以16個字節為步長,從0開始編號一直到0xFFFF0(即1MB)。你可以將一個從0到0xFFFF的16位偏移量(邏輯地址)加在段地址上。在這個規則下,對於同一個內存地址,會有多個段地址/偏移量的組合與之對應,而且物理地址可以超過1MB的邊界,只要你的段地址足夠高(參見臭名昭著的A20線)。同樣的,在實模式的C語言代碼中,一個遠指針(far pointer)既包含了段選擇符又包含了邏輯地址,用於尋址1MB的內存範圍。真夠"遠"的啊。隨著程序變得越來越大,超出了64K的段,分段功能以及它古怪的處理方式,使得x86平臺的軟件開發變得非常複雜。這種設定可能聽起來有些詭異,但它卻把當時的程序員推進了令人崩潰的深淵。

在32位保護模式下,段選擇符不再是一個單純的數值,取而代之的是一個索引編號,用於引用段描述符表中的表項。這個表為一個簡單的數組,元素長度為8字節,每個元素描述一個段。看起來如下:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

段描述符

有三種類型的段:代碼,數據,系統。為了簡潔明瞭,只有描述符的共有特徵被繪製出來。基地址(base address)是一個32位的線性地址,指向段的開始;段界限(limit)指出這個段有多大。將基地址加到邏輯地址上就形成了線性地址。DPL是描述符的特權級(privilege level),其值從0(最高特權,內核模式)到3(最低特權,用戶模式),用於控制對段的訪問。

這些段描述符被保存在兩個表中:全局描述符表(GDT)和局部描述符表(LDT)。電腦中的每一個CPU(或一個處理核心)都含有一個叫做gdtr的寄存器,用於保存GDT的首個字節所在的線性內存地址。為了選出一個段,你必須向段寄存器加載符合以下格式的段選擇符:

"

本文翻譯並修改自:http://duartes.org/gustavo/blog/

微信公眾號:技術原理君

本文是Intel兼容計算機(x86)的內存與保護系列文章的第一篇,延續了啟動引導系列文章的主題,進一步分析操作系統內核的工作流程。與以前一樣,我將引用Linux內核的源代碼,但對Windows只給出示例(抱歉,我忽略了BSD,Mac等系統,但大部分的討論對它們一樣適用)。文中如果有錯誤,請不吝賜教。

在支持Intel的主板芯片組上,CPU對內存的訪問是通過連接著CPU和北橋芯片的前端總線來完成的。在前端總線上傳輸的內存地址都是物理內存地址,編號從0開始一直到可用物理內存的最高端。這些數字被北橋映射到實際的內存條上。物理地址是明確的、最終用在總線上的編號,不必轉換,不必分頁,也沒有特權級檢查。然而,在CPU內部,程序所使用的是邏輯內存地址,它必須被轉換成物理地址後,才能用於實際內存訪問。從概念上講,地址轉換的過程如下圖所示:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

x86 CPU開啟分頁功能後的內存地址轉換過程

此圖並未指出詳實的轉換方式,它僅僅描述了在CPU的分頁功能開啟的情況下內存地址的轉換過程。如果CPU關閉了分頁功能,或運行於16位實模式,那麼從分段單元(segmentation unit)輸出的就是最終的物理地址了。當CPU要執行一條引用了內存地址的指令時,轉換過程就開始了。第一步是把邏輯地址轉換成線性地址。但是,為什麼不跳過這一步,而讓軟件直接使用線性地址(或物理地址呢?)其理由與:"人類為何要長有闌尾?它的主要作用僅僅是被感染髮炎而已"大致相同。這是進化過程中產生的奇特構造。要真正理解x86分段功能的設計,我們就必須回溯到1978年。

最初的8086處理器的寄存器是16位的,其指令集大多使用8位或16位的操作數。這使得代碼可以控制216個字節(或64KB)的內存。然而Intel的工程師們想要讓CPU可以使用更多的內存,而又不用擴展寄存器和指令的位寬。於是他們引入了段寄存器(segment register),用來告訴CPU一條程序指令將操作哪一個64K的內存區塊。一個合理的解決方案是:你先加載段寄存器,相當於說"這兒!我打算操作開始於X處的內存區塊";之後,再用16位的內存地址來表示相對於那個內存區塊(或段)的偏移量。總共有4個段寄存器:一個用於棧(ss),一個用於程序代碼(cs),兩個用於數據(ds,es)。在那個年代,大部分程序的棧、代碼、數據都可以塞進對應的段中,每段64KB長,所以分段功能經常是透明的。

現今,分段功能依然存在,一直被x86處理器所使用著。每一條會訪問內存的指令都隱式的使用了段寄存器。比如,一條跳轉指令會用到代碼段寄存器(cs),一條壓棧指令(stack push instruction)會使用到堆棧段寄存器(ss)。在大部分情況下你可以使用指令明確的改寫段寄存器的值。段寄存器存儲了一個16位的段選擇符(segment selector);它們可以經由機器指令(比如MOV)被直接加載。唯一的例外是代碼段寄存器(cs),它只能被影響程序執行順序的指令所改變,比如CALL或JMP指令。雖然分段功能一直是開啟的,但其在實模式與保護模式下的運作方式並不相同的。

在實模式下,比如在引導啟動的初期,段選擇符是一個16位的數值,指示出一個段的開始處的物理內存地址。這個數值必須被以某種方式放大,否則它也會受限於64K當中,分段就沒有意義了。比如,CPU可能會把這個段選擇符當作物理內存地址的高16位(只需將之左移16位,也就是乘以216)。這個簡單的規則使得:可以按64K的段為單位,一塊塊的將4GB的內存都尋址到。遺憾的是,Intel做了一個很詭異的設計,讓段選擇符僅僅乘以24(或16),一舉將尋址範圍限制在了1MB,還引入了過度複雜的轉換過程。下述圖例顯示了一條跳轉指令,cs的值是0x1000:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

實模式分段功能

實模式的段地址以16個字節為步長,從0開始編號一直到0xFFFF0(即1MB)。你可以將一個從0到0xFFFF的16位偏移量(邏輯地址)加在段地址上。在這個規則下,對於同一個內存地址,會有多個段地址/偏移量的組合與之對應,而且物理地址可以超過1MB的邊界,只要你的段地址足夠高(參見臭名昭著的A20線)。同樣的,在實模式的C語言代碼中,一個遠指針(far pointer)既包含了段選擇符又包含了邏輯地址,用於尋址1MB的內存範圍。真夠"遠"的啊。隨著程序變得越來越大,超出了64K的段,分段功能以及它古怪的處理方式,使得x86平臺的軟件開發變得非常複雜。這種設定可能聽起來有些詭異,但它卻把當時的程序員推進了令人崩潰的深淵。

在32位保護模式下,段選擇符不再是一個單純的數值,取而代之的是一個索引編號,用於引用段描述符表中的表項。這個表為一個簡單的數組,元素長度為8字節,每個元素描述一個段。看起來如下:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

段描述符

有三種類型的段:代碼,數據,系統。為了簡潔明瞭,只有描述符的共有特徵被繪製出來。基地址(base address)是一個32位的線性地址,指向段的開始;段界限(limit)指出這個段有多大。將基地址加到邏輯地址上就形成了線性地址。DPL是描述符的特權級(privilege level),其值從0(最高特權,內核模式)到3(最低特權,用戶模式),用於控制對段的訪問。

這些段描述符被保存在兩個表中:全局描述符表(GDT)和局部描述符表(LDT)。電腦中的每一個CPU(或一個處理核心)都含有一個叫做gdtr的寄存器,用於保存GDT的首個字節所在的線性內存地址。為了選出一個段,你必須向段寄存器加載符合以下格式的段選擇符:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

段選擇符

對GDT,TI位為0;對LDT,TI位為1;index指出想要表中哪一個段描述符(譯註:原文是段選擇符,應該是筆誤)。對於RPL,請求特權級(Requested Privilege Level),以後我們還會詳細討論。現在,需要好好想想了。當CPU運行於32位模式時,不管怎樣,寄存器和指令都可以尋址整個線性地址空間,所以根本就不需要再去使用基地址或其他什麼鬼東西。那為什麼不乾脆將基地址設成0,好讓邏輯地址與線性地址一致呢?Intel的文檔將之稱為"扁平模型"(flat model),而且在現代的x86系統內核中就是這麼做的(特別指出,它們使用的是基本扁平模型)。基本扁平模型(basic flat model)等價於在轉換地址時關閉了分段功能。如此一來多麼美好啊。就讓我們來看看32位保護模式下執行一個跳轉指令的例子,其中的數值來自一個實際的Linux用戶模式應用程序:

"

本文翻譯並修改自:http://duartes.org/gustavo/blog/

微信公眾號:技術原理君

本文是Intel兼容計算機(x86)的內存與保護系列文章的第一篇,延續了啟動引導系列文章的主題,進一步分析操作系統內核的工作流程。與以前一樣,我將引用Linux內核的源代碼,但對Windows只給出示例(抱歉,我忽略了BSD,Mac等系統,但大部分的討論對它們一樣適用)。文中如果有錯誤,請不吝賜教。

在支持Intel的主板芯片組上,CPU對內存的訪問是通過連接著CPU和北橋芯片的前端總線來完成的。在前端總線上傳輸的內存地址都是物理內存地址,編號從0開始一直到可用物理內存的最高端。這些數字被北橋映射到實際的內存條上。物理地址是明確的、最終用在總線上的編號,不必轉換,不必分頁,也沒有特權級檢查。然而,在CPU內部,程序所使用的是邏輯內存地址,它必須被轉換成物理地址後,才能用於實際內存訪問。從概念上講,地址轉換的過程如下圖所示:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

x86 CPU開啟分頁功能後的內存地址轉換過程

此圖並未指出詳實的轉換方式,它僅僅描述了在CPU的分頁功能開啟的情況下內存地址的轉換過程。如果CPU關閉了分頁功能,或運行於16位實模式,那麼從分段單元(segmentation unit)輸出的就是最終的物理地址了。當CPU要執行一條引用了內存地址的指令時,轉換過程就開始了。第一步是把邏輯地址轉換成線性地址。但是,為什麼不跳過這一步,而讓軟件直接使用線性地址(或物理地址呢?)其理由與:"人類為何要長有闌尾?它的主要作用僅僅是被感染髮炎而已"大致相同。這是進化過程中產生的奇特構造。要真正理解x86分段功能的設計,我們就必須回溯到1978年。

最初的8086處理器的寄存器是16位的,其指令集大多使用8位或16位的操作數。這使得代碼可以控制216個字節(或64KB)的內存。然而Intel的工程師們想要讓CPU可以使用更多的內存,而又不用擴展寄存器和指令的位寬。於是他們引入了段寄存器(segment register),用來告訴CPU一條程序指令將操作哪一個64K的內存區塊。一個合理的解決方案是:你先加載段寄存器,相當於說"這兒!我打算操作開始於X處的內存區塊";之後,再用16位的內存地址來表示相對於那個內存區塊(或段)的偏移量。總共有4個段寄存器:一個用於棧(ss),一個用於程序代碼(cs),兩個用於數據(ds,es)。在那個年代,大部分程序的棧、代碼、數據都可以塞進對應的段中,每段64KB長,所以分段功能經常是透明的。

現今,分段功能依然存在,一直被x86處理器所使用著。每一條會訪問內存的指令都隱式的使用了段寄存器。比如,一條跳轉指令會用到代碼段寄存器(cs),一條壓棧指令(stack push instruction)會使用到堆棧段寄存器(ss)。在大部分情況下你可以使用指令明確的改寫段寄存器的值。段寄存器存儲了一個16位的段選擇符(segment selector);它們可以經由機器指令(比如MOV)被直接加載。唯一的例外是代碼段寄存器(cs),它只能被影響程序執行順序的指令所改變,比如CALL或JMP指令。雖然分段功能一直是開啟的,但其在實模式與保護模式下的運作方式並不相同的。

在實模式下,比如在引導啟動的初期,段選擇符是一個16位的數值,指示出一個段的開始處的物理內存地址。這個數值必須被以某種方式放大,否則它也會受限於64K當中,分段就沒有意義了。比如,CPU可能會把這個段選擇符當作物理內存地址的高16位(只需將之左移16位,也就是乘以216)。這個簡單的規則使得:可以按64K的段為單位,一塊塊的將4GB的內存都尋址到。遺憾的是,Intel做了一個很詭異的設計,讓段選擇符僅僅乘以24(或16),一舉將尋址範圍限制在了1MB,還引入了過度複雜的轉換過程。下述圖例顯示了一條跳轉指令,cs的值是0x1000:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

實模式分段功能

實模式的段地址以16個字節為步長,從0開始編號一直到0xFFFF0(即1MB)。你可以將一個從0到0xFFFF的16位偏移量(邏輯地址)加在段地址上。在這個規則下,對於同一個內存地址,會有多個段地址/偏移量的組合與之對應,而且物理地址可以超過1MB的邊界,只要你的段地址足夠高(參見臭名昭著的A20線)。同樣的,在實模式的C語言代碼中,一個遠指針(far pointer)既包含了段選擇符又包含了邏輯地址,用於尋址1MB的內存範圍。真夠"遠"的啊。隨著程序變得越來越大,超出了64K的段,分段功能以及它古怪的處理方式,使得x86平臺的軟件開發變得非常複雜。這種設定可能聽起來有些詭異,但它卻把當時的程序員推進了令人崩潰的深淵。

在32位保護模式下,段選擇符不再是一個單純的數值,取而代之的是一個索引編號,用於引用段描述符表中的表項。這個表為一個簡單的數組,元素長度為8字節,每個元素描述一個段。看起來如下:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

段描述符

有三種類型的段:代碼,數據,系統。為了簡潔明瞭,只有描述符的共有特徵被繪製出來。基地址(base address)是一個32位的線性地址,指向段的開始;段界限(limit)指出這個段有多大。將基地址加到邏輯地址上就形成了線性地址。DPL是描述符的特權級(privilege level),其值從0(最高特權,內核模式)到3(最低特權,用戶模式),用於控制對段的訪問。

這些段描述符被保存在兩個表中:全局描述符表(GDT)和局部描述符表(LDT)。電腦中的每一個CPU(或一個處理核心)都含有一個叫做gdtr的寄存器,用於保存GDT的首個字節所在的線性內存地址。為了選出一個段,你必須向段寄存器加載符合以下格式的段選擇符:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

段選擇符

對GDT,TI位為0;對LDT,TI位為1;index指出想要表中哪一個段描述符(譯註:原文是段選擇符,應該是筆誤)。對於RPL,請求特權級(Requested Privilege Level),以後我們還會詳細討論。現在,需要好好想想了。當CPU運行於32位模式時,不管怎樣,寄存器和指令都可以尋址整個線性地址空間,所以根本就不需要再去使用基地址或其他什麼鬼東西。那為什麼不乾脆將基地址設成0,好讓邏輯地址與線性地址一致呢?Intel的文檔將之稱為"扁平模型"(flat model),而且在現代的x86系統內核中就是這麼做的(特別指出,它們使用的是基本扁平模型)。基本扁平模型(basic flat model)等價於在轉換地址時關閉了分段功能。如此一來多麼美好啊。就讓我們來看看32位保護模式下執行一個跳轉指令的例子,其中的數值來自一個實際的Linux用戶模式應用程序:

虛擬地址,邏輯地址,線性地址,他們之間怎麼轉換?

保護模式的分段

段描述符的內容一旦被訪問,就會被cache(緩存),所以在隨後的訪問中,就不再需要去實際讀取GDT了,否則會有損性能。每個段寄存器都有一個隱藏部分用於緩存段選擇符所對應的那個段描述符。如果你想了解更多細節,包括關於LDT的更多信息,請參閱《Intel System Programming Guide》3A卷的第三章。2A和2B卷講述了每一個x86指令,同時也指明瞭x86尋址時所使用的各種類型的操作數:16位,16位加段描述符(可被用於實現遠指針),32位,等等。

在Linux上,只有3個段描述符在引導啟動過程被使用。他們使用GDT_ENTRY宏來定義並存儲在boot_gdt數組中。其中兩個段是扁平的,可對整個32位空間尋址:一個是代碼段,加載到cs中,一個是數據段,加載到其他段寄存器中。第三個段是系統段,稱為任務狀態段(Task State Segment)。在完成引導啟動以後,每一個CPU都擁有一份屬於自己的GDT。其中大部分內容是相同的,只有少數表項依賴於正在運行的進程。你可以從segment.h看到Linux GDT的佈局以及其實際的樣子。這裡有4個主要的GDT表項:2個是扁平的,用於內核模式的代碼和數據,另兩個用於用戶模式。在看這個Linux GDT時,請留意那些用於確保數據與CPU緩存線對齊的填充字節——目的是克服馮·諾依曼瓶頸。最後要說說,那個經典的Unix錯誤信息"Segmentation fault"(分段錯誤)並不是由x86風格的段所引起的,而是由於分頁單元檢測到了非法的內存地址。唉呀,下次再討論這個話題吧。

Intel巧妙的繞過了他們原先設計的那個拼拼湊湊的分段方法,而是提供了一種富於彈性的方式來讓我們選擇是使用段還是使用扁平模型。由於很容易將邏輯地址與線性地址合二為一,於是這成為了標準,比如現在在64位模式中就強制使用扁平的線性地址空間了。但是即使是在扁平模型中,段對於x86的保護機制也十分重要。保護機制用於抵禦用戶模式進程對系統內核的非法內存訪問,或各個進程之間的非法內存訪問,否則系統將會進入一個狗咬狗的世界!在下一篇文章中,我們將窺視保護級別以及如何用段來實現這些保護功能。

"

相關推薦

推薦中...