Linux從頭學16:作業系統在載入應用程式時,是如何把【頁目錄和頁表】當做普通物理頁進行操作的?

語言: CN / TW / HK

作 者:道哥,10+年嵌入式開發老兵,專注於:C/C++、嵌入式、Linux

x86 系統中,記憶體管理中的分頁機制是非常重要的,在Linux作業系統相關的各種書籍中,這部分內容也是重筆濃彩。

如果你看過 Linux 核心相關書籍,一定對下面這張圖又熟悉、又恐懼:

這是 Linux 系統中,頁處理單元的多級頁表查詢方式。

其中黃色背景部分:頁上級目錄索引 和 頁中間目錄索引,是 Linux 系統自己擴充套件的,在原本的 x86 處理器中是不存在的,這也是導致 Linux 中相關部分程式碼更加複雜的原因。

在上一篇文章中,我們主要對 x86 中的頁目錄和頁表的“反向構造”、“正向查詢”這兩個過程進行了圖文並茂的討論。文章連結在此:Linux從頭學15:【頁目錄和頁表】-理論 + 例項 + 圖文的最完全、最接地氣詳解,但是其中有一個環節被特意忽略過去了。

那就是:在作業系統構造頁目錄和頁表的時候,如何對它們自身進行定址和操作?

這部分內容,也是記憶體管理中比較複雜的地方,就好比一名醫生給病人做手術,但是病人卻是“醫生自己”

這篇文章,我們繼續通過圖片+例項的方式,一起來研究一下核心程式碼一般都是如何來進行這些“自操作”的。

把這裡面的操作機制研究透徹之後,再去看 Linux 核心程式碼時,就不會暈頭轉向了。

問題描述

在上一篇文章中,我們舉了這樣一個示例:

  1. 假設實際的實體記憶體是1 GB;

  2. 使用者程式檔案在硬碟上的長度是20 MB;

  3. 作業系統把使用者程式載入到記憶體中時,從 0x4000_0000 的虛擬記憶體地址處開始存放;

  4. 作業系統讀取程式結束後,為所有的地址構造好了頁目錄和頁表;

如下圖所示:

頁目錄和頁表的每一個有效表項中,儲存的地址都是一個個實實在在的物理頁的前 20 位(因為一個物理頁的長度固定是 4KB,在分配時都是對齊的,末尾的 12 位全部為 0)。

並且頁目錄和頁表“們”自身,都佔用一個物理頁的空間,所以它們都有自己的實體地址。

當頁目錄和頁表都構造妥當之後,處理器面對一個線性地址,例如:0x4100_1800,頁處理單元就會按照分級查表的方式,把這個線性地址轉換為一個物理地址:

  1. 拆分線性地址:0x4100_1800 = 0100_0001_0000_0000___0001_1000_0000_0000;

  2. 根據線性地址的前 10 位,找到頁目錄中的索引 260,從而確定頁表的實體地址是 0x0800_4000(表項中的值是 0x08004,還要補上低位的 12 個 0);

  3. 根據線性地址的中間 10 位,找到 0x0800_4000 這個頁表中的索引 1,從而確定普通物理頁的實體地址是 0x0210_1000(表項中的值是 0x02101,還要補上低位的 12 個 0);

  4. 根據線性地址的最後 12 位,確定普通頁內的偏移量是 2048,普通頁的開始地址加上這個偏移量,就得到了最終的實體地址 0x0210_1800。

詳細的討論過程,請參考上一篇文章:Linux從頭學15:【頁目錄和頁表】-理論 + 例項 + 圖文的最完全、最接地氣詳解

那麼,問題來了:

在頁處理單元開啟的情況下,處理器面對的是線性地址,那麼作業系統在構造頁目錄中的每一個表項的時候,如何對這個表項進行定址?

具體到上圖來說就是:作業系統想把第一個頁表的實體地址 0x0800_0000,填寫到頁目錄的第 256 個表項中時,那麼 CPU 就需要找到這個表項,這個表項肯定有實體地址的。

但是,我們不能把這個表項的物理地址直接告訴 CPU,因為 CPU 只接收線性地址,它會自動經過分頁單元的處理來得到對應的物理地址。

那麼,這個線性地址的值應該是多少呢?

繼續用例項來說明,這樣容易理解。

假設頁目錄所處的物理頁開始地址0x0100_0000,那麼第256個表項的實體地址就是 0x0100_0400

有些小夥伴可能會說:直接把物理地址 0x0100_0400 告訴處理器,不就可以了嗎?

這是不對的

處理器接收的是線性地址,不是實體地址

因為現在已經開啟了分頁處理單元0x0100_0400 是我們最後想得到的實體地址,而處理器只接受線性地址,雖然我們知道這是一個實體地址,但是處理器不知道啊!

當我們給處理器一個地址的時候,處理器會按部就班的對這個地址進行[段轉換],再進行[頁轉換],這時才得到它認為的實體地址。

由於使用的是“平坦型”的段結構,所以這裡就忽略了段處理過程,直接討論頁處理過程。

所以,我們應該使用某些方法,構造出一個線性地址 addr,讓這個地址經過頁處理單元之後,得到 0x0100_0400 這個物理地址:

這裡有點遞迴的味道,又有點像一個醫生給他自己做一個外科手術!

現在,應該明白麵對的問題了吧?

目標就是:通過某種方法,構造出一個線性地址addr,並且通過頁處理單元轉換之後,得到物理地址 0x0100_0400

對頁目錄進行操作

重新梳理一下思路:如果對一個普通物理頁(下文簡稱為:普通頁)裡的一個地址處的資料進行操作,需要經過3次查表操作:

從頁表的某個表項中,找到的那個實體地址,就是最後要操作的普通物理頁。

現在我們的問題是:需要把頁目錄作為最終的操作物件。

也就是說,從頁表中找到的“普通頁”的實體地址,應該等於頁目錄的實體地址!

作為一名軟體開發人員,遞迴思想都是有的。

我們就來構造一個線性地址 addr,讓它經過3次查表操作之後,能夠指向頁目錄的實體地址

一級查表:構造線性地址的前 10 位,來確定頁表的實體地址

一級查表:查詢的物件是頁目錄

線性地址addr的前10位,決定了頁目錄內的索引。

很顯然,需要讓這個索引對應的那個表項中所登記的地址,必須是指向頁目錄自己才可以。

常用的解決方案是:利用頁目錄中的最後一個表項,讓這個表項中記錄的地址,指向頁目錄自己,如下圖所示:

也就是說,預先在頁目錄的最後一個表項中,填入頁目錄自己的實體地址,然後只要線性地址addr10位的值為1023,就能夠得到這個表項。

很容易就能得到addr的前10位應該是:0x3FF(二進位制:1111_1111_11)

由於這個表項中儲存的地址是頁目錄自己的開始地址(0x0100_0000, 最後的120是自動補上的),這樣就相當於:下面進入第二級查詢時,頁目錄即將被當做“頁表”來使用

如下圖所示:

這裡紅色虛線的“頁表”其實就是頁目錄自己,只是一個影子而已。

二級查表:構造線性地址的中間 10 位,來確定“普通頁”的實體地址

二級查表:查詢的物件是頁表,也就是一級查表得到的那個“頁表”

雖然一級查表的結果是頁目錄自己,但是處理器不管這些,它會把這個表當做頁表來使用。

現在,來考慮線性地址addr的中間10位,它決定了頁表中的索引號。

很顯然,需要繼續讓這個索引號對應的那個表項中,記錄的地址必須繼續指向頁目錄自己

那就繼續利用這個“頁表”(其實它是頁目錄)中的最後一個表項唄,就是index = 1023的這個表項。

這個表項中儲存的實體地址,即將是最終查表得到的“普通頁”的實體地址了。

由於這個表項中,被預先填寫了0x01000,補上尾部的120之後就是 0x0100_0000,仍然指向頁目錄自己,完美!

於是,就得到了中間10位的結果:0x3FF(二進位制:11_1111_1111)

如下圖所示:

最右面紅色虛線的“物理頁”,就是二級查詢的結果,它本質上仍然是頁目錄本身,只不過它即將被當做一個普通物理頁來使用

三級查表:構造線性地址的最後 12 位,來確定頁“普通頁”的頁內偏移量

現在,已經構造出了線性地址addr(這是我們的最終目標)的前20位,並且經過頁表的前兩級查表,成功的定位到了頁目錄自己!

就差最後一步了!

我們知道,從線性地址到物理地址的轉換過程中,最後的12位表示頁內偏移,是直接從線性地址中取過來的。

也就是說:線性地址 與 實體地址 的最後12位偏移量,值是一樣的

所以,我們就反過來倒推一下:

我們最終想操作的是頁目錄中第256個表項,它的物理地址是 0x0100_0400,這個實體地址距離這個頁目錄開始位地址的偏移量是:0x400(0x0100_0400 減去 0x0100_0000)。

因此,線性地址addr中的最後12位的值也應該是0x400

三個地址段合體

把上面三個步驟中,得到的地址聚合在一起:

0xFFFF_F400 就是最終想得到的線性地址!

也就是說,我們只要把這個線性地址 0xFFFF_F400 告訴處理器,它就會經過頁處理單元的轉換,最終查詢到頁目錄這個物理頁中的第 256個表項,也就是實體地址 0x0100_0400

例如:mov [0xFFFF_4000], xxxx

以上就是作業系統在操作頁目錄自身時,所採取的策略。

具體到每個作業系統來說,可能稍微有差別,但是其中的道理都是差不多的。

例如本文開頭的第一張圖中,Linux 使用了4級表格來查詢,並且中間的兩個表格還可以省略不用。

如何跨過中間的這兩個表格,Linux核心程式碼中的程式碼更復雜一些,但是策略都是一樣的。

對頁表進行操作

既然已經弄明白了作業系統是如何操作頁目錄的,那麼對頁表的操作就不是什麼大問題了。

比如下面這張圖:

目標:把最右面的普通物理頁地址 0x0200_0000,放入 0x0800_0000 這個頁表的第一個表項中(只需要儲存前20位),那麼應該傳遞什麼樣的線性地址給處理器?

思路是完全一樣的。

一級查表

按照正常的分頁查詢流程,從頁目錄的某個表項中,查詢我們想操作的那個頁表

頁目錄中的這個表項位於索引值256的地方,因此可以構造出線性地址的前10位是:0100_0000_00(0x100)

所以,經過一級查表得到的這個頁表的實體地址是 0x0800_0000

二級查表

利用這個頁表的最後一個表項(index = 1023),預先填寫一個地址(0x08000),讓它指向這個頁表自己的開始實體地址。

於是,可以構造出線性地址的中間10位是:11_1111_1111(0x3FF)

由於這個表項中儲存的地址是0x0800_0000,指向的正是頁表自己,只不過馬上它就被當作普通物理頁被使用。

三級查表

此時,已經找到最後的普通物理頁了(其實它是一個頁表,被當作普通物理頁使用)。

線性地址的最後12位,可以直接從最後想操作的那個目標物理地址中最後12位直接拿過來。

我們的目標是:操作頁表中的第 0 個表項,這個表項的實體地址是 0x0800_0000,最後的12位偏移量是 0000_0000_0000

把以上3個地址段合體,即可得到正確的線性地址


------ End ------

這裡討論的方法,並不是處理頁目錄和頁表的唯一方式

當處理邏輯更加複雜時,可能需要對頁目錄或頁表中更多的表項,進行一些特殊的預處理。

如果你想挑戰一下,可以看一下Linux核心中的相關文件或程式碼!

在這個系列中,關於頁目錄和頁表的知識點就介紹結束了。

如果文中有錯誤或者誤導的地方,非常期待與您一起探討、學習!

寫這篇文章真不容易,讓我深深的體會到那句話:

寫作就是:將網狀的思考-通過樹狀的結構-用線性的語言清晰的表達出來。

如果您覺得還不錯,請點個贊,鼓勵一下,轉發給身邊的技術小夥伴,真心的感謝!

推薦閱讀

【1】《Linux 從頭學》系列文章

【2】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹

【3】原來gdb的底層除錯原理這麼簡單

【4】內聯彙編很可怕嗎?看完這篇文章,終結它!

其他系列專輯:精選文章應用程式設計物聯網C語言