從進入內核態看內存管理

知乎上搜到一個比較有意思的話題:如何理解「進入內核態」,要回答好這個問題需要對內存管理及程序的運行機制有比較深刻的瞭解,比如你需要了解內存的分段,分頁,中斷等機制,信息量比較大,本文將會 Intel CPU 的發展歷史講起,循序漸近地幫助大家徹底掌握這一概念,相信大家看了肯定有幫助,本文目錄如下

  • CPU 運行機制
  • Intel CPU 歷史發展史
  • 分段
  • 保護模式
  • 特權級
  • 系統調用
  • 中斷
  • 分段內存的優缺點
  • 內存分頁
  • 總結

CPU 運行機制

我們先簡單地回顧一下 CPU 的工作機制,重新溫習一下一些基本概念,因爲我在查閱資料的過程發現一些網友對尋址,CPU 是幾位的概念理解得有些模糊,理解了這些概念再去看 CPU 的發展史就不會再困惑

CPU 是如何工作的呢?它是根據一條條的機器指令來執行的,而機器指令= 操作碼+操作數,操作數主要有三類:寄存器地址、內存地址或立即數(即常量)。

我們所熟悉的程序就是一堆指令和數據的集合,當打開程序時,裝載器把程序中的指令和數據加載到內存中,然後 CPU 到內存中一條條地取指令,然後再譯碼,執行。

在內存中是以字節爲基本單位來讀寫數據的,我們可以把內存看作是一個個的小格子(一般我們稱其爲內存單元),而每個小格子是一個字節,那麼對於 B8 0123H 這條指令來說,它在內存中佔三字節,如下,CPU 該怎麼找到這些格子呢,我們需要給這些格子編號,這些編號也就是我們說的內存地址,根據內存地址就是可以定位指令所在位置,從而取出裏面的數據

如圖示:內存被分成了一個個的格子,每個格子一個字節,20000~20002 分別爲對應格子的編號(即內存地址)

CPU 執行指令主要分爲以下幾個步驟

  1. 取指令,CPU 怎麼知道要去取哪條指令呢,它裏面有一個 IP 寄存器指向了對應要取的指令的內存地址, 然後這個內存地址會通過地址總線找到對應的格子,我們把這個過程稱爲尋址,不難發現尋址能力決定於地址總線的位寬,假設地址總線位數爲 20 位,那麼內存的可尋址空間爲 2^20 * 1Byte = 1M,將格子(內存單元)裏面的數據(指令)取出來後,再通過數據總線發往 CPU 中的指令緩存區(指令寄存器),那麼一次能傳多少數據呢,取決於數據總線的位寬,如果數據總線爲 16 位,那麼一次可以傳 16 bit 也就是兩個字節。

  2. 譯碼:指令緩衝區中的指令經過譯碼以確定該進行什麼操作

  3. 執行:譯碼後會由控制單元向運算器發送控制指令進行操作(比如執行加減乘除等),執行是由運算器操縱數據也就是操作數進行計算,而操作數保存在存儲單元(即片內的緩存和寄存器組)中,由於操作數有可能是內存地址,所以執行中可能需要到內存中獲取數據(這個過程稱爲訪存),執行後的結果保存在寄存器或寫回內存中

    以指令 mov ax, 0123H 爲例,它表示將數據 0123H 存到寄存器 AX 中,在此例中 AX 爲 16 位寄存器,一次可以操作 16 位也就是 2 Byte 的數據,所以我們將其稱爲 16 位 CPU,CPU 是多少位取決於它一次執行指令的數據帶寬,而數據帶寬又取決於通用寄存器的位寬

  4. 更新 IP:執行完一條指令後,更新 IP 中的值,將其指向下一條指令的起始地址,然後重複步驟 1

由以上總結可知尋址能力與寄存器位數有關

接下來我們以執行四條指令爲例再來仔細看下 CPU 是如何執行指令的,動圖如下:

看到上面這個動圖,細心地你可能會發現兩個問題

  1. 前文說指令地址是根據 IP 來獲取的嗎,但上圖顯示指令地址卻是由「CS 左移四位 + IP」計算而來的,與我們所闡述的指令保存在 IP 寄存器中似乎有些出入,這是怎麼回事呢?
  2. 動圖顯示的地址是真實物理地址,這樣進程之間可以互相訪問/改寫對方的物理地址,顯然是不安全的,那如何才能做到安全訪問或者說進程間內存的隔離呢

以上兩點其實只要我們瞭解一下 CPU 的發展歷史就明白解決方案了,有了以上的鋪墊,在明白了尋址16/32/64 位 CPU 等術語的含義後,再去了解 CPU 的發展故事會更容易得多,話不多說,發車

Intel CPU 歷史發展史

1971 年世界上第一塊 4 位 CPU-4004 微處理器橫空出世,1974 年 Intel 研發成功了 8 位 CPU-8080,這兩款 CPU 都是使用的絕對物理地址來尋址的,指令地址只存在於 IP 寄存器中(即只使用 IP 寄存器即可確定內存地址)。由於是使用絕對物理地址尋址,也就意味着進程之間的內存數據可能會互相覆蓋,很不安全,所以這兩者只支持單進程

分段

1978 年英特爾又研究成功了第一款 16 位 CPU - 8086,這款 CPU 可以說是 x86 系列的鼻祖了,設計了 16 位的寄存器和 20 位的地址總線,所以內存地址可以達到 2^20 Byte 即 1M,極大地擴展了地址空間,但是問題來了,由於寄存器只有 16 位,那麼 16 位的 IP 寄存器如何能尋址 20 位的地址呢,首先 Intel 工程師設計了一種分段的方法:1M 內存可以分爲 16 個大小爲 64 K 的段,那麼內存地址就可以由「段的起始地址(也叫段基址) + 段內偏移(IP 寄存器中的值)」組成,對於進程說只需要關心 4 個段 ,代碼段數據段堆棧段附加段,這幾個段的段基址分別保存在 CS,DS,SS,ES 這四個寄存器中

這四個寄存器也是 16 位,那怎麼訪問 20 位的內存地址呢,實現也很簡單,將每個寄存器的值左移四位,然後再加上段內偏移即爲尋址地址,CPU 都是取代碼段 中的指令來執行的,我們以代碼段內的尋址爲例來計算內存地址,指令的地址 = CS << 4 + IP ,這種方式做到了 20 位的尋址,只要改變 CS,IP 的值,即可實現在 0 到最大地址 0xFFFFF 全部 20 位地址的尋址

舉個例子:假設 CS 存的數據爲 0x2000,IP 爲 0x0003,那麼對應的指令地址爲

圖示爲真實的物理地址計算方式,從中可知, CS 其實保存的是真實物理地址的高 16 位

分段的初衷是爲了解決尋址問題,但本質上CS:IP 計算得到的還是真實物理地址,所以它也無法支持多進程,因爲使用絕對物理地址尋址意味着進程可以隨意修改 CS:IP,將其指向任意地址,很可能會覆蓋正在運行的其他進程的內存,造成災難性後果。

我們把這種使用真實物理地址且未加任何限制的尋址方式稱爲實模式(real mode,即實際地址模式)

保護模式

實模式上的物理地址由 段寄存器中的段基址:IP 計算而來,而段基址可由用戶隨意指定,顯然非常不安全,於是 Intel 在之後推出了 80286 中啓用了保護模式,這個保護是怎麼做的呢

首先段寄存器保存的不再是段基址了,而是段選擇子(Selector),其結構如下

其中第 3 到 15 位保存的是描述符索引,此索引會根據 TI 的值是 0 還是 1 來選擇是到 GDT(全局描述符表,一般也稱爲段表)還是 LDT 來找段描述符,段描述符保存的是段基址和段長度,找到段基址後再加上保存在 IP 寄存器中的段偏移量即爲物理地址,段描述符的長度統一爲 8 個字節,而 GDT/LDT 表的基地址保存在 gdtr/ldtr 寄存器中,以 GDT (此時 TI 值爲 0)爲例來看看此時 CPU 是如何尋址的

可以看到程序中的地址是由段選擇子:段內偏移量組成的,也叫邏輯地址,在只有分段內存管理的情況下它也被稱爲虛擬內存

GDT 及段描述符的分配都是由操作系統管理的,進程也無法更新 CS 等寄存器中值,這樣就避免了直接操作其他進程以及自身的物理地址,達到了保護內存的效果,從而爲多進程運行提供了可能,我們把這種尋址方式稱爲保護模式

那麼保護模式是如何實現的呢,細心的你可能發現了上圖中在段選擇子和段描述符中裏出現了 RPLDPL 這兩個新名詞,這兩個表示啥意思呢?這就涉及到一個概念:特權級

特權級

我們知道 CPU 是根據機器指令來執行的,但這些指令有些是非常危險的,比如清內存置時鐘分配系統資源等,這些指令顯然不能讓普通的進程隨意執行,應該始終控制在操作系統中執行,所以要把操作系統和普通的用戶進程區分開來

我們把一個進程的虛擬地址劃分爲兩個空間,用戶空間內核空間,用戶空間即普通進程所處空間,內核空間即操作系統所處空間

當 CPU 運行於用戶空間(執行用戶空間的指令)時,它處於用戶態,只能執行普通的 CPU 指令 ,當 CPU 運行於內核空間(執行內核空間的指令)時,它處於內核態,可以執行清內存,置時鐘,讀寫文件等特權指令,那怎麼區分 CPU 是在用戶態還是內核態呢,CPU 定義了四個特權等級,如下,從 0 到 3,特權等級依次遞減,當特權級爲 0 時,CPU 處於內核態,可以執行任何指令,當特權級爲 3 時,CPU 處於用戶態,在 Linux 中只用了 Ring 0,Ring 3 兩個特權等級

那麼問題來了,怎麼知道 CPU 處於哪一個特權等級呢,還記得上文中我們提到的段選擇子嗎

其中的 RPL 表示請求特權((Requested privilege level))我們把當前保存於 CS 段寄存器的段選擇子中的 RPL 稱爲 CPL(current priviledge level),即當前特權等級,可以看到 RPL 有兩位,剛好對應着 0,1,2,3 四個特權級,而上文提到的 DPL 表示段描述符中的特權等級(Descriptor privilege level)知道了這兩個概念也就知道保護模式的實現原理了,CPU 會在兩個關鍵點上對內存進行保護

  1. 目標段選擇子被加載時

  2. 當通過線性地址(在只有段式內存情況下,線性地址爲物理地址)訪問一個內存頁時。由此可見,保護也反映在內存地址轉換的過程之中,既包括分段又包括分頁(後文分提到分頁)

CPU 是怎麼保護內存的呢,它會對 CPL,RPL,DPL 進行如下檢查

只有 CPL <= DPL 且 RPL <= DPL(申請特權等級待以及當前特權等級必須比調用的目標代碼段的特權級更高,以防普通程序直接調用目標代碼段) 時,纔會加載目標代碼段執行,否則會報一般保護異常 (General-protection exception)

那麼特權等級(也就是 CPL)是怎麼變化的呢,我們之前說了 CPU 運行於用戶空間時,處於用戶態,特權等級爲 3,運行於內核空間時,處於內核態,特權等級爲 0,所以也可以換個問法 CPU 是如何從用戶空間切換到內核空間或者從內核空間切換到用戶空間的,這就涉及到一個概念:系統調用

系統調用

我們知道用戶進程雖然不能執行特權指令,但有時候也需要執行一些讀寫文件,發送網絡包等操作,而這些操作又只能讓操作系統來執行,那該怎麼辦呢,可以讓操作系統提供接口,讓用戶進程來調用即可,我們把這種方式叫做系統調用,系統調用可以直接由應用程序調用,或者通過調用一些公用函數庫或 shell(這些函數庫或 shell 都封裝了系統調用接口)等也可以達到間接調用系統調用的目的。通過系統調用,應用程序實現了陷入(trap)內核態的目的,這樣就從用戶態切換到了內核態中,如下

應用程序通過系統調用陷入內核態
應用程序通過系統調用陷入內核態

那麼系統調用又是怎麼實現的呢,主要是靠中斷實現的,接下來我們就來了解一下什麼是中斷

中斷

陷入內核態的系統調用主要是通過一種 trap gate(陷阱門)來實現的,它其實是軟件中斷的一種,由 CPU 主動觸發給自己一箇中斷向量號,然後 CPU 根據此中斷向量號就可以去中斷向量表找到對應的門描述符,門描述符與 GDT 中的段描述符相似,也是 8 個字節,門描述符中包含段選擇子,段內偏移,DPL 等字段 ,然後再根據段選擇子去 GDT(或者 LDT,下圖以 GDT 爲例) 中查找對應的段描述符,再找到段基地址,然後根據中斷描述符表的段內偏移即可找到中斷處理例程的入口點,整個中斷處理流程如下

畫外音:上圖中門描述符和段描述符只畫出了關鍵的幾個字段,省略了其它次要字段

當然了,不是隨便發一箇中斷向量都能被執行,只有滿足一定條件的中斷才允許被普通的應用程序調用,從發出軟件中斷再到執行中斷對應的代碼段會做如下的檢查

一般應用程序發出軟件中斷對應的向量號是大家熟悉的 int 0x80(int 代表 interrupt),它的門描述符中的 DPL 爲 3,所以能被所有的用戶程序調用,而它對應的目標代碼段描述符中的 DPL 爲 0,所以當通過中斷門檢查後(即 CPL <= 門描述符中的 DPL 成立),CPU 就會將 CS 寄存器中的 RPL(3) 替換爲目標代碼段描述符的 DPL(0),替換後的 CPL 也就變成了 0,通過這種方式完成了從用戶態到內核態的替換,當中斷代碼執行後執行 iret 指令又會切換回用戶態

另外當執行中斷程序時,還需要首先把當前用戶進程中對應的堆棧,返回地址等信息,以便切回到用戶態時能恢復現場

可以看到 int 80h 這種軟件中斷的執行又是檢查特權級,又是從用戶態切換到內核態,又是保存寄存器的值,可謂是非常的耗時,光看一下以下圖示就知道像 int 0x80 這樣的軟件中斷開銷是有多大了

系統調用
系統調用

所以後來又開發出了 SYSENTER/SYSCALL 這樣快速系統調用的指令,它們取消了權限檢查,也不需要在中斷描述表(Interrupt Descriptor Table、IDT)中查找系統調用對應的執行過程,也不需要保存堆棧和返回地址等信息,而是直接進入CPL 0,並將新值加載到與代碼和堆棧有關的寄存器當中(cs,eip,ss 和 esp),所以極大地提升了性能

分段內存的優缺點

使用了保護模式後,程序員就可以在代碼中使用了段選擇子:段偏移量的方式來尋址,這不僅讓多進程運行成爲了可能,而且也解放了程序員的生產力,我們完全可以認爲程序擁有所有的內存空間(虛擬空間),因爲段選擇子是由操作系統分配的,只要操作系統保證不同進程的段的虛擬空間映射到不同的物理空間上,不要重疊即可,也就是說雖然各個程序的虛擬空間是一樣的,但由於它們映射的物理地址是不同且不重疊的,所以是能正常工作的,但是爲了方便映射,一般要求在物理空間中分配的段是連續的(這樣只要維護映射關係的起始地址和對應的空間大小即可)

段式內存管理-虛擬空間與實際物理內存的映射
段式內存管理-虛擬空間與實際物理內存的映射

但段式內存管理缺點也很明顯:內存碎片可能很大,舉個例子

如上圖示,連續加載了三個程序到內存中,如果把 Chrome 關閉了,此時內存中有兩段 128 M的空閒內存,但如果此時要加載一個 192 M 的程序 X 卻有心無力了 ,因爲段式內存需要劃分出一塊連續的內存空間,此時你可以選擇把佔 256 M 的 Python 程序先 swap 到磁盤中,然後緊跟着 512 M 內存的後面劃分出 256 M 內存,再給 Python 程序 swap 到這塊物理內存中,這樣就騰出了連續的 256 M 內存,從而可以加載程序 X 了,但這種頻繁地將幾十上百兆內存與硬盤進行 swap 顯然會對性能造成嚴重的影響,畢竟誰都知道內存和硬盤的讀寫速度可是一個天上一個地上,如果一定要交換,能否每次 swap 得能少一點,比如只有幾 K,這樣就能滿足我們的需求,分頁內存管理就誕生了

內存分頁

1985 年 intel 推出了 32 位處理器 80386,也是首款支持分頁內存的 CPU

和分段這樣連續分配一整段的空間給程序相比,分頁是把整個物理空間切成一段段固定尺寸的大小,當然爲了映射,虛擬地址也需要切成一段段固定尺寸的大小,這種固定尺寸的大小我們一般稱其爲頁,在 LInux 中一般每頁的大小爲 4KB,這樣虛擬地址和物理地址就通過頁來映射起來了

當然了這種映射關係是需要一個映射表來記錄的,這樣才能把虛擬地址映射到物理內存中,給定一個虛擬地址,它最終肯定在某個物理頁內,所以虛擬地址一般由「頁號+頁內偏移」組成,而映射表項需要包含物理內存的頁號,這樣只要將頁號對應起來,再加上頁內偏移,即可獲取最終的物理內存

於是問題來了,映射表(也稱頁表)該怎麼設計呢,我們以 32 位虛擬地址位置來看看,假設頁大小爲 4K(2^12),那麼至少需要 2^20 也就是 100 多萬個頁表項才能完全覆蓋所有的虛擬地址,假設每一個頁表項 4 個字節,那就意味着爲一個進程的虛擬地址就需要準備 2^20 * 4 B = 4 M 的頁表大小,如果有 100 個進程,就意味着光是頁表就要佔用 400M 的空間了,這顯然是非常巨大的開銷,那該怎麼解決這個頁表空間佔用巨大的問題呢

我們注意到現在的做法是一次性爲進程分配了佔用其所有虛擬空間的頁表項,但實際上一個進程根本用不到這麼巨大的虛擬空間,所以這種分配方式無疑導致很多分配的頁表白白浪費了,那該怎麼辦,答案是分級管理,等真正需要分配物理空間的時候再分配,其實大家可以想想我們熟悉的 windows 是怎麼分配的,是不是一開始只分配了 C 盤,D盤,E盤,等要存儲的時候,先確定是哪個盤,再在這個盤下分配目錄,然後再把文件存到這個目錄下,並不會一開始就把所有盤的空間給分配完的

同樣的道理,以 32 位虛擬地址爲例,我們也可以對頁表進行分級管理, 頁表項 2^20 = 2^10 * 2^10 = 1024 * 1024,我們把一個頁表分成兩級頁表,第一級頁表 1024 項,每一項都指向一個包含有 1024 個頁表項的二級頁表

圖片來自《圖解系統》
圖片來自《圖解系統》

這樣只有在一級頁表中的頁表項被分配的時候纔會分配二級頁表,極大的節省了空間,我們簡單算下,假設 4G 的虛擬空間進程只用了 20%(已經很大了,大部分用不到這麼多),那麼由於一級頁表空間爲 1024 *4 = 4K,總的頁表空間爲 4K+ 0.2 * 4M = 0.804M,相比於原來的 4M 是個巨大的提升!

那麼對於分頁保護模式又是如何起作用的呢,同樣以 32 位爲例,它的二級頁表項(也稱 page table entry)其實是以下結構

注意第三位(也就是 2 對應的位置)有個 U/S,它其實就是代表特權級,表示的是用戶/超級用戶標誌。爲 1 時,允許所有特權級別的程序訪問;爲 0 時,僅允許特權級爲0、1、2(Linux 中沒有 1,2)的程序(也就是內核)訪問。頁目錄中的這個位對其所映射的所有頁面起作用

既然分頁這麼好,那麼分段是不是可以去掉了呢,理論上確實可以,但 Intel 的 CPU 嚴格執行了 backward compatibility(回溯兼容),也就是說最新的 CPU 永遠可以運行鍼對早期 CPU 開發的程序,否則早期的程序就得針對新 CPU 架構重新開發了(早期程序針對的是 CPU 的段式管理進行開發),這無論對用戶還是開發者都是不能接受的(別忘了安騰死亡的一大原因就是由於不兼容之前版本的指令),兼容性雖然意味着每款新的 CPU 都得兼容老的指令,所背的歷史包袱越來越重,但對程序來說能運行肯定比重新開發好,所以既然早期的 CPU 支持段,那麼自從 80386 開始的所有 CPU 也都得支持段,而分頁反而是可選的,也就意味着這些 CPU 的內存管理都是段頁式管理,邏輯地址要先經過段式管理單元轉成線性地址(也稱虛擬地址),然後再經過頁式管理單元轉成物理內存,如下

分頁是可選項
分頁是可選項

在 Linux 中,雖然也是段頁式內存管理,但它統一把 CS,DS,SS,ES 的段基址設置爲了 0,段界限也設置爲了整個虛擬內存的長度,所有段都分佈在同一個地址空間,這種內存模式也叫平坦內存模型(flat memory model)

平坦內存模型
平坦內存模型

我們知道邏輯地址由段選擇子:段內偏移地址組成,既然段選擇子指向的段基地址爲 0,那也就意味着段內偏移地址即爲即爲線性地址(也就是虛擬地址),由此可知 Linux 中所有程序的代碼都使用了虛擬地址,通過這種方式巧妙地繞開了分段管理,分段只起到了訪問控制和權限的作用(別忘了各種權限檢查依賴 DPL,RPL 等特權字段,特權極轉移也依賴於段選擇子中的 DPL 來切換的)

總結

看完本文相信大家對實模式,保護模式,特權級轉換,分段,分頁等概念應該有了比較清晰的認識。

我們簡單總結一下,CPU 誕生之間,使用的絕對物理內存來尋址(也就是實模式),隨後隨着 8086 的誕生,由於工藝的原因,雖然地址總線是 20 位,但寄存器卻只有 16 位,一個難題出現了,16 位的寄存器該怎麼尋址 20 位的內存地址呢,於是段的概念被提出了,段的出現雖然解決了尋址問題,但本質上 CS << 4 + IP 的尋址方式依然還是絕對物理地址,這樣的話由於地址會互相覆蓋,顯然無法做到多進程運行,於是保護模式被提出了,保護就是爲了物理內存免受非法訪問,於是用戶空間,內核空間,特權級也被提出來了,段寄存器裏保存的不再是段基址,而是段選擇子,由操作系統分配,用戶也無法隨意修改段選擇子,必須通過中斷的形式才能從用戶態陷入內核態,中斷執行的過程也需要經歷特權級的檢查,檢查通過之後特權級從 3 切換到了 0,於是就可以放心合法的執行特權指令了。可以看到,通過操作系統分配段選擇子+中斷的方式內存得到了有效保護,但是分段可能造成內存碎片過大以倒頻繁 swap 會影響性能的問題,於是分頁出現了,保護模式+分頁終於可以讓多進程,高效調度成爲了可能

參考

  • CPU 是怎麼執行指令的 https://z.itpub.net/article/detail/1468ED259C713472E41638CE8890DA5C
  • 好傢伙!原來硬中斷就是這樣的:(https://mp.weixin.qq.com/s/OWrw6VNTNVZRj5lJqEgY3w)
  • RPL 的故事 https://string.quest/read/15152681
  • RPL,DPL 區別 https://stackoverflow.com/questions/36617718/difference-between-dpl-and-rpl-in-x86
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章