【學習xv6】從實模式到保護模式

前言

這是一系列 xv6 代碼學習的總結。對於還不知道 xv6 是什麼的同學,我這裏只簡單說一下:xv6 是一個教學用的操作系統,基於 unix v6,再具體的請大家自行 Google 一下 wiki 什麼的。

配合這個系列的文章,我在我的 GitHub上建立了一個叫xv6_learn的項目,這個項目就是在 clone 自 xv6 官方源代碼的基礎上在源碼文件中加入了我學習過程中的大量註釋。所以在大家看這一系列文章的同時結合着源代碼文件中我加入的註釋來看可能效果更好一些。下面言歸正傳講 xv6 之前先預習一下用到的一些知識。

預備知識

程序 = 數據 + 指令

無論是操作系統還是運行在操作系統上的軟件,對於計算機來說他們都是程序。而程序的組成我們可以簡單的理解爲:數據加上指令就是程序。當一個程序被從硬盤加載到內存後,CPU 從內存讀取程序中的指令執行,執行過程中需要從內存中讀取程序的數據,配合指令計算出結果之後還需要放回到內存中。這就是簡化後的程序執行過程。

如何從內存讀取指令和數據

x86 使用“段基址 + 偏移量”的方式來讀寫內存。這就好比問路,當你向一個人問路時,一般人們回這麼回答你:“從前面那個路口開始,往前再走三個路口就到了”。x86 CPU 對內存的尋址也是這個思路,“前面那個路口”就指的是“段基址”,“往前再走三個路口”指的就是“偏移量”,有了這兩個線索,CPU 也可以順利到達內存中的目的地寫入或取走數據或指令。

爲什麼有個“段”字

有人可能會問“段基址”裏面的“段”代表什麼呢?前面說了,程序是由數據和指令組成的,一個程序要運行就先要加載到內存中。而程序中的數據和指令是兩個相互獨立的部分,CPU 從內存讀取他們的時候也是將他們看作是不同的“段”。這裏還要插一句,程序中的數據還要分很多種類型,所以 CPU 針對一個程序的不同部分準備了 4 個寄存器來分別存儲他們的“段基址”。這 4 個寄存器分別是用於程序指令的 CS 代碼段寄存器、用於程序數據的 DS 數據段寄存器、用於程序堆棧(也是數據的一種)的 SS 堆棧段寄存器和 ES 附加段寄存器(也是數據的一種)。

有了這 4 個寄存器存儲“基地址”(數據的存放起始點),再配合“偏移量” CPU 就可以從內存讀寫數據和指令了。例如 CPU 在從內存中讀取一個程序的指令準備執行的時候就可以說:“從 CS 指向的地方開始向後讀取 2 個位置”,內存收到 CPU 給的“指路信息”後就會把相應位置的指令發給 CPU,CPU 拿到指令就可以開始執行了。

“段基址” + “偏移量” 尋址方式的由來

瞭解了 x86 的內存尋址方式,不禁要問:“爲什麼要這麼設計?”這得從英特爾的 8086 CPU 開始講起。我們有時說起計算機硬件配置的時候經常會說:“我的電腦是 32 位的”。這裏的 32 位起始指的是 CPU 內部的“數據總線”寬度,也叫 AUL ALU(算數邏輯單元,感謝 Zongren Zhang 同學找到錯誤並指正)的寬度。說白了就是 CPU 一次性傳遞數據的寬度。

英特爾的 8086 CPU 是 16 位的,如果直接用來表示內存地址的話,16 位最大可以表示的內存地址是 216 = 65536 個地址,每個地址代表一字節的內存數據的話,16 位最多隻能支持 64KB 內存,這顯然是不夠用的。於是英特爾在保持數據線寬爲 16 位的同時將地址線的寬度增大到 20 位,也就是說內存地址是 20 位的,這樣就可以擁有 220 = 1048576 個地址,最多支持 1MB 的內存,當時的人們認爲這樣就足夠了。

現在問題來了,16 位的數據線寬(寄存器自然也是 16 位的)如何能表示 20 位的地址呢?答案是用兩個 16 位的寄存器來表示。這就是“段基址” + “偏移量”尋址方式的由來。一個 16 位的寄存器來表示“段基址”(CS、DS、SS、ES四個寄存器),具體的做法是先將 16 位的“段基址”左移 4 位,然後加上 16 位的“偏移量”最終得到 20 位的內存地址送入地址線。

地址卷繞

用兩個 16 位的寄存器左移相加來得到 20 位的內存地址這裏還是有問題。那就是兩個 16 位數相加所得的最大結果是超過 20 位的。例如段基址 0xffff 左移變成 0xffff0 和偏移量 0xffff 相加得到 0x10ffef 這個內存地址是“溢出”的,怎麼辦?這裏 CPU 被設計出來一個“卷繞”機制,當內存地址超過 20 位則繞回來。舉個例子你拿 0x100001 來尋址,我就拿你當作 0x00001 。你超出終點我就把你繞回起點。

向下兼容的現代 x86 計算機

8086 的年代已經遠去。現在的 x86 已早經是 32 位的了(目前 32 位基本已經沒有了,64 位是主流了)。但無論位數如何增加,尋址能力如何增大,x86 一直保持着向下兼容的良好傳統。即便是當初爲 8086 這種 16 位機器寫的軟件或操作系統(如 DOS)仍能夠在現在的 x86 計算機上順利運行。

那麼這種良好的向下兼容性是如何實現的呢?答案是:“開關”。現代的 x86 計算機,無論你是 32 位的還是 64 位的,在開機的那一刻 CPU 都是以模擬 16 位模式運行的,地址卷繞機制也是有效的,所以無論你的電腦內存有多大,開機的時候 CPU 的尋址能力只有 1MB,就好像回到 8086 時代一樣。

那麼什麼時候才結束 CPU 的 16 位模式運行呢?這由你(操作系統)說了算,現代的計算機都有個“開關”叫 A20 gate,開機的時候 A20 gate 是關閉的,CPU 以 16 位模式運行,當 A20 gate 打開的時候“卷繞”機制失效,內存尋址突破 1MB 限制,我們就可以切換到正常的模式下運行了。具體如何打開 A20 gate,下面分析 xv6 的源代碼時我會詳細說明。

再說說把程序加載到內存

我們編寫程序代碼,編譯器將我們的程序代碼變成 CPU 可以理解的指令(二進制可執行程序)。在運行我們寫的程序前,需要將程序先加載進內存,而我們的程序應該加載到內存的什麼位置這應該是由操作系統來負責的,我們程序本身是不能決定這一切的。

這裏就產生了一個“矛盾”。在程序真正運行前我們是不知道我們會被放在內存的什麼地方,但是我們的程序本身還有數據,代碼也含有對數據的訪問(例如我們的代碼中使用的各種變量),我們不知道我們的數據會被操作系統放在哪,但我們還要在代碼裏寫訪問這些數據的邏輯,這是一個矛盾,要怎麼辦?

解決上述矛盾的辦法就是使用相對地址訪問。我們的程序在運行前不知道會被操作系統放在內存的什麼地方,所以我們在編寫程序的時候會做個假設,假設我們的程序會被放在從內存地址 N 開始向後的地方。這個時候我們的程序在訪問我們的變量時都繼續這個假設,加入我們想要讀取我們的變量 a 時,我們就編寫指令說我們要訪問 N + X 的內存地址,那裏存放着我們的變量 a,當然這些假設和生成每個數據的相對訪問地址的工作都由編譯器代勞了,對於我們程序的編寫來說不用爲這些事情而煩惱。

所以我們的每一個程序都會基於一個統一的假設:“我們會被從內存地址 N 開始放置”,至於到真正運行時這個 N 對應的內存地址具體是多少無所謂,因爲我們對我們程序數據的訪問都是相對於 N 的偏移。這就好比說:“我在距離你左邊 20 米的地方”,無論你在哪,在火星上也罷,向左走 20 米,你總能找到我。

程序是“假設”的,操作系統要動“真格”的

上面說了,所有的程序都基於一個相同的“假設”,但是當程序真正運行的時候,操作系統將程序加載到內存時就不能對程序的這個“假設”聽之任之了。當操作系統把程序放置到真正的內存位置後,程序運行起來,程序基於假設 N + X 計算出的內存地址就需要操作系統“翻譯”成真正的內存地址後才能真的從內存中讀取到想要的數據,而這個“翻譯”的過程就需要操作系統和 CPU 來配合實現了。

程序基於“假設”計算出的地址叫做“虛擬地址”也叫做“邏輯地址”(他們是一樣的,只是叫法不同),與之對應的內存的真實地址叫做“物理地址”,從“虛擬地址”到“物理地址”的轉換是通過一個叫做 MMU(內存管理單元)的硬件實現的,當然這裏還少不了操作系統的配合。

從“虛擬地址”到“物理地址”,計算機硬件與操作系統的配合爲在操作系統上運行的各種程序提供了“智能”、“安全”、“高效”的運行環境,好處多多。比如程序通過這種假設,統一了虛擬的內存佈局,從程序開發層面屏蔽了內存規劃的複雜性,運行環境的差異性等,程序只需要關係自己的邏輯,內存佈局的事情交給操作系統來負責。另一方面,每個程序運行在各自的內存空間上,彼此處於相互隔離的狀態,程序之間無法操作自己內存空間以外的內存,這也增加了程序運行的安全性。

實模式與保護模式

羅馬不是一天建成的。上面所說的系統硬件和操作系統配合建立的“智能”、“安全”、“高效”的運行環境也是後來才逐漸完善的。所以爲了區分這兩種環境,在“智能”、“安全”、“高效”的運行環境建立之前計算機是運行在“實模式”下的,在“實模式”下沒有“虛擬地址”到“物理地址”的轉換,“虛擬地址”就相當於是“物理地址”,而想要這些特性就需要對應的把計算機的運行環境切換到“保護模式”下。

就像之前我們講到的 A20 gate 從 1MB 的內存尋址模式切換到更大的尋址能力一樣。x86 架構的計算機爲了向下兼容,開機的時候不僅運行在 1MB 內存尋址環境下,這時候也是運行在“實模式”環境下的。同樣有一個開關控制着從“實模式”到“保護模式”的切換,這個開關叫“控制寄存器”。

保護模式下的分段與分頁

前面說道“保護模式”是由硬件和操作系統配合來提供的。“保護模式”涉及的知識非常多,不僅僅只有對內存的管理,還有諸如進程管理、硬件管理等諸多方面,這裏只簡單介紹一下“保護模式”下的內存管理。“保護模式”實現的兩種內存管理方式:“分段式和分頁式”。分頁式是目前主流操作系統(Windows、Linux、FreeBSD等)所採取的內存管理方式。

“分頁式”技術的出現要比“分段式”晚一些,碰上 x86 這樣歷史悠久的硬件架構就不得不再提“向下兼容”了。所以 x86 的分頁式的實現是繼續分段式基礎上的。所以想要在 x86 上建立起分頁式的內存管理就先要建立分段式內存管理,分頁式我們暫且不說,先說說分段式。

分段式簡單來說就是將內存規劃出不同的“片段”來分配給不同的程序(也包含操作系統自己)使用。分頁式則是將內存規劃成大小相同的“頁”,再將這些頁分配給各個程序使用。

這裏有兩個“段”字非常讓人容易迷糊。分段式裏的段與之前講過的“段基址”完全是兩碼事兒。實模式下的段寄存器裏的“段基址”實際上還可以算作內存物理地址,它指向的是內存中的一個位置,而在分段式的保護模式下段寄存器裏的“段基址”的意義已經發生裏改變,它不再是內存的物理地址,而是指向一個內存分段的段索引。在分段模式下,內存被劃分爲很多個“片段”,程序數據以及指令就放在這些片段中,當要讀取內存中具體的數據時,首先要直到這個數據在哪個“片段”裏,這時段寄存器裏的“段基址”指向某一個內存片段的下標,而這時的“偏移量”則相應的表示爲具體的數據在它所在的內存“片段”裏的偏移量。

所以在分段模式下,內存裏會有一個“表”,這個“表”裏存放了每個內存“片段”的信息(如這個“片段”在內存中的地址,這個“片段”多長等),比如我們現在將內存分成 10 個片段,則這時我們有一個“表”,這個“表”有 10 項分別存放着對應這 10 個內存片段的描述信息。這時我有個數據存放在第 5 個片段中,在第 5 個片段的第 6 個位置上,所以當我們想要讀取這個數據的時候,我們的數據段寄存器裏存放的“段基址”是 5 這個數,代表要去第 5 個片段上找,對應的這時候的“偏移量”就是 6 這樣我們就可以順利的找到我們想要的數據裏。

而要想實現在分段式保護模式下成功的尋址,操作系統需要做的就是在內存中建立這個“表”,“表”裏放好內存分段的描述信息,然後把這個“表”在內存的什麼位置,以及這個“表”裏有多少個內存分段的描述信息告訴 CPU。這個“表”有個學名叫 GDT 全局描述符表,這個我們後面還會有介紹。

分段式的“段基址” + “偏移量”尋址方式

在“實模式”下我們講到內存的尋址方式是“段基址” + “偏移量”,他們生成的結果就是直接可用的內存物理地址,但是到了分段式的保護模式下我們有了 GDT,GDT 裏面有了段描述符,段描述符裏存儲的纔是真正的內存物理地址,所以這裏我們的“段基址”和“偏移量”的意義都發生了變化。

在分段式的保護模式下,16 位的“段基址”不再表示內存的物理地址,而是表示 GDT 表的下標,用來根據“段基址”從 GDT 表中取得對應下標的“段描述符”,從“段描述符”中取得真實的內存物理地址後在配合“偏移量”來計算出最終的內存地址。

一個簡單的比喻

說了那麼多內存尋址的事兒,說到底無論是程序還是操作系統(其實也是程序),最後到計算機那裏都會變成 CPU 從內存通過尋址讀取指令和數據執行而已。無論是實模式下的“段基址”+“偏移量”還是保護模式下的“段基址”+“偏移量”,尋址的過程都是十分類似的。爲了不讓大家腦子裏那麼亂,這裏我在打一個比喻來幫助大家理解“內存尋址”的過程。

內存就好比一個大倉庫,這個倉庫裏有好多好多貨架用於存放貨物(指令和數據)。我們的操作系統就是這個倉庫的管理員,而 CPU 就是這個倉庫的小工,這時我們送來一個貨物(程序),這個貨物有兩個大箱子,一個箱子貼着“代碼”的標籤,另一個貼着“數據”的標籤。貼着“代碼”標籤的箱子裏按順序放着一張一張寫着字的紙條(指令),另一個貼着“數據”標籤的箱子裏放着我們自己按照自己想要的順序碼放好的物品(數據)。這時我們把這個貨物(程序)交給倉庫管理員(操作系統),看看會發生什麼。

管理員(操作系統)拿到我們的貨物(程序),先將貼着“代碼”標籤的箱子放到倉庫的某一個貨架上,比如放在了 3 號貨架上,並在小本本上(代碼段寄存器)記下這個箱子放在了 3 號貨架上。然後又將貼着“數據”標籤的箱子放到 5 號貨架上,並在小本本上(數據段寄存器)記錄下這個箱子放在了 5 號貨架上。接下來就該倉庫小工(CPU)工作了。

倉庫小工按照小本本上(代碼段寄存器)記錄的地址跑到 3 號貨架上找到那個貼着“代碼”標籤的箱子, 按順序先抽出了箱子裏的第一章小紙條(指令),上面寫着“我要貼着數據箱子裏的第 6 個物品”,這時倉庫小工跑去看了一眼量外一個小本本(數據段寄存器),直到貼着“數據”標籤的箱子是放在 5 號貨櫃的,於是倉庫小工到了 5 號貨櫃找到了那個箱子,從箱子裏數到第 6 個物品(偏移量)把它拿了出來。

這就是一次內存尋址的過程。我們在寫程序的時候,也就是我們準備我們的貨物時,我們可以按照我們想要的順序來碼放我們的物品到箱子裏(只關心偏移量),當我們把我們的程序寫好準備真正去執行的時候,也就是貨物準備好交給倉庫管理員的時候,倉庫管理員按照他自己的想法把我們的貨物放在貨櫃上,並記下我們的箱子都放在哪個貨櫃(只關心段寄存器裏的段基址),等到倉庫小工忙活起來的時候拿着貨櫃號和我們想要的物品在箱子裏的相對位置就能夠順利找到我們想要的東西了,這就是“段基址”+“偏移量”的尋址方式。

而什麼保護模式之流無非是倉庫小工在按照“段基址”+“偏移量”取貨的前額外的驗證了一下要去的東西到底是不是你的(程序要讀取的數據是否屬於該程序),你說要箱子裏的第 6 個物品,取貨前在額外看看你箱子裏到底是不是真的有 6 個以上的物品,而取貨的流程本質上是沒有發生變化的。

物理地址、線性地址、邏輯地址(虛擬地址)、虛擬內存

關於內存尋址和內存管理方式已經說了一大堆裏,這裏通過幫助大家徹底理清上面這四個概念來讓大家對內存這塊有個整體的認識。

  • 物理地址

這個沒什麼可說的,非常好理解,物理地址就是內存從硬件角度上真正的地址。所有對內存的尋址最終都要轉換到物理地址上才能被識別。

  • 邏輯地址(虛擬地址)

這兩種叫法說的是一種東西。就是我們上面講的程序基於統一的“假設”通過 N + X 計算出的內存地址。

  • 線性地址

線性地址的概念是保護模式下才有的,在實模式下邏輯地址就是物理地址,在保護模式下還要根據分段和分頁分開說。在分段模式下邏輯地址通過 GDT 轉換成線性地址,此時如果沒有分頁機制那麼線性地址就是物理地址,如果有分頁機制,那麼線性地址要通過 MMU 再一次轉換之後才能變成物理地址。

  • 虛擬內存

我們以 32 位計算機爲例,在 32 位計算機上支持的最大內存尋址是 4GB,但是每個計算機上真正有多少內存卻是不一定的。同樣的 32 位計算機,有的可能只有 1GB 內存,有的只有 2GB 內存,而對於程序來說不應該收到這種硬件配置的影響,無論有多少內存,程序都應該正常運行。這就提出裏虛擬內存的概念,就像我們之前說的程序的統一假設一樣,對於每個程序來說,我們都統一假設只要你的尋址位寬是 32 位,那我就假設我有 4GB 內存可以利用。而具體有多少內存,如何和邏輯地址對應,這是操作系統需要考慮的事情了。

這裏多說兩句。有的人會有疑問:“我的32位計算機確實只有 1BG 內存,而你說程序當我是 4GB 內存,多餘的 3GB 從哪來?”。其實很簡單,還是從你的 1GB 物理內存上來。首先要確定的是在你的程序運行的某一時刻你不可能把 1BG 內存完全佔用,即便你的程序真的把 1BG 物理內存全部佔用裏,在某一時刻你需要再向內存寫入數據的時候,CPU 會先去內存中找到一個你這一刻不會用到的數據,將這個數據從內存換出到硬盤上,然後將你要寫入的數據放入內存中。等之後的某一個時刻你的程序又想從內存中讀取剛剛傳出到硬盤的那個數據時,CPU 會再次通過同樣的辦法把一個不用的數據換出到硬盤再把你要的數據換回到內存中來。

大小端模式

在準備往下看的時候你會發現我在下面放了幾個表格用來表示數據在寄存器或內存中的存儲結構,這些表格都是按位來排列的。看這些表格的時候你可能會奇怪這些表格的位序號爲什麼都是從高到低的,這是因爲 x86 是“小端模式”。

我們直到計算機中的數據就按照“字節”位單位存放的,就好像我們寫字,當你寫一個字的時候沒什麼問題,但是當你要寫一句話的時候就有是“從做往右”還是“從右往左”寫的問題。而計算機也一樣,當內存或寄存器存儲的數據超過 1 字節的時候也會有一個數據擺放順序的問題。這就是所謂的大小端模式。

  • 大端模式 : 地址的增長順序與值的增長順序相同
  • 小端模式 : 地址的增長順序與值的增長順序相反

比如我們有一個 16 位(兩字節)的數據 0x2345,要存放在內存地址 0x00000010 這個位置上,如果按照大端模式存儲就是下面這個樣子

內存地址 0x00000010 0x00000011
數據 0x23 0x45


 

如果是小端模式則是

內存地址 0x00000010 0x00000011
數據 0x45 0x23


 

而我們書寫代碼的習慣是從左往右寫,則 x86 的小端模式如果按照內存地址位從高到低的方式來看,數據就是從左往右的正常順序,這樣我們看上去比較直觀。

預備知識總結

說了一大堆,該鋪墊的知識基本準備的差不多裏,接下來我們就要具體分析 xv6 的代碼實現裏。這裏我們總結一下上面介紹的預備知識,來說說作爲一個操作系統在計算機啓動後到底應該做些什麼:

  • 計算機開機,運行環境爲 1MB 尋址限制帶“卷繞”機制
  • 打開 A20 gate 讓計算機突破 1MB 尋址限制
  • 在內存中建立 GDT 全局描述符表,並將建立好的 GDT 表的位置和大小告訴 CPU
  • 設置控制寄存器,進入保護模式
  • 按照保護模式的內存尋址方式繼續執行

好了,下面我們正式進入 xv6 啓動階段的代碼學習。

從 Makefile 開始

從一個操作系統的角度來說,xv6 的代碼量並不大,總共不到一萬行,分散在衆多的源文件中。一上來可能覺得很迷茫,這麼多文件,該從哪個開始看起?Makefile 則是這些文件的“目錄”,通過它可以很容易找到頭緒。

什麼是 Makefile?如果你問起這個,那你還不適合看這個系列的文章,還是那句話,多 Google 吧。繼續言歸正傳。

上一篇《【學習 Xv6 】在 Mac OSX 下運行 Xv6》中說道 xv6 編譯成功後會生成兩個文件:xv6.img 和 fs.img 我們從 xv6.img 開始。

從 Makefile 中可以看到 xv6.img 的生成條件:

1
2
3
4
xv6.img: bootblock kernel fs.img
  dd if=/dev/zero of=xv6.img count=10000
  dd if=bootblock of=xv6.img conv=notrunc
  dd if=kernel of=xv6.img seek=1 conv=notrunc

fs.img 這裏暫且不說,通過字面不難看出 bootblock 應該是系統一開始引導階段的邏輯,kernel 當然就是內核了。所以第一步先研究 bootblock。我們接着在 Makefile 裏找 bootblock 的生成條件:

1
2
3
4
5
6
7
bootblock: bootasm.S bootmain.c
  $(CC) $(CFLAGS) -fno-pic -O -nostdinc -I. -c bootmain.c
  $(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c bootasm.S
  $(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o
  $(OBJDUMP) -S bootblock.o > bootblock.asm
  $(OBJCOPY) -S -O binary -j .text bootblock.o bootblock
  ./sign.pl bootblock

bootblock 的生成只需要兩個文件,一個彙編一個 C 源碼。那我們就準備講講 bootasm.S 文件了。

x86 的啓動

看具體的代碼前先說說 x86 架構開機引導的相關知識。從給 x86 通電的一刻開始,CPU 執行的第一段指令是 BIOS 固化在 ROM 上的代碼,這個過程是硬件定死的規矩,就是這樣。

而 BIOS 在硬件自檢完成後(你會聽到“滴”的一聲)會根據你在 BIOS 裏設置的啓動順序(硬盤、光驅、USB)讀取每個引導設備的第一個扇區 512字節的內容,並判斷這段內容的最後 2 字節是否爲 0xAA55,如果是說明這個設備是可引導的,於是就將這 512 字節的內容放到內存的 0x7C00 位置,然後告訴 CPU 去執行這個位置的指令。這個過程同樣是硬件定死的規矩,就是這樣。

有了上面的介紹我們再回到 xv6 如果你看一下編譯生成的 bootblock 二進制文件,你會驚喜的發現它的文件大小剛好是 512 字節。用十六進制編輯器(我在 Mac OSX 下用的是 0xED 這個軟件)打開 bootblock 這個二進制文件,你又會發現這個 512 字節的文件的最後兩字節正好是 0xAA55。

再回過頭看上面 Makefile 中 xv6.img 生成條件的代碼中也可以看出 xv6.img 就是通過 dd 命令講編譯好的 bootblock 和 kernel 拼接而成,這也再一次印證了 bootblock 是負責引導邏輯的結論。

有了這個結論,我們可以開始“放心大膽”的開始看 bootasm.S 這個彙編源文件的代碼了。

bootasm.S 文件

看 bootasm.S 文件需要你有一定的彙編基礎。沒有也沒關係,我儘量解釋的清楚一些。

還是再看一眼 Makefile 裏 bootblock 生成那段有這麼一句

1
$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o

這段說明 bootblock 的代碼段加載到內存 0x7C00 處,代碼從 start 處開始執行(可以理解爲相當於 C 中的 main 這樣的入口函數)。所以 bootasm.S 一上來就是入口 start

1
2
3
4
5
6
7
8
.code16
.global start
start:
    cli
    xorw    %ax,%ax
    movw    %ax,%ds
    movw    %ax,%es
    movw    %ax,%ss

先講 start:下面的這 5 行代碼。

cli 指令關閉了中斷響應,意味着從這一刻開始你的計算機將不再響應任何中斷事件(比如這時候你敲個鍵盤點個鼠標啥的,CPU 就不再理你了)。之所以要關閉中斷響應是因爲要保證引導代碼的順利執行(總不能執行到一半被 CPU 給中斷了吧,那直接就掛了)。

接下來的 4 行代碼顯示用異或將 %ax 寄存器的值置成 0,然後在用 %ax 寄存器的值將 %ds、%es、%ss 三個寄存器的值全部置 0,相當於初始化了。

然後我們再看 .code16 這句。這告訴 CPU 我們目前是在 16 位模式下執行代碼,此時內存尋址能力只有 1MB,並且是“實模式”下。

打開 A20 gate

在預備知識那段我們講裏要想計算機突破 1MB 內存尋址的限制我們要把 A20 gate 打開,我們接着往下看 xv6 bootasm.S 的代碼。在初始化好寄存器後,xv6 bootasm.S 接下來要做的事情就是打開 A20 gate 突破 1MB 內存尋址的限制。

控制 A20 gate 的方法有 3 種:

  • 804x 鍵盤控制器法
  • Fast A20 法
  • BIOS 中斷法

xv6 用了第一種 804x 鍵盤控制器法,這也是最古老且效率最慢的一種。當然因爲硬件的不同,這三種方法可能不會被硬件都支持,正確的做法應該是這三種都嘗試一下,每嘗試一個就驗證一下 A20 gate 是否被正確打開以保證兼容各種硬件。但是 xv6 作爲一款教學用的操作系統就沒必要做的這麼複雜裏。只用了一種最古老的方法(保證兼容大多數硬件)而且沒有對打開成功與否做驗證。像諸如 Linux 這樣的操作系統就把三種方法的實現都做好裏,並且加上了驗證機制。

我們具體來看 xv6 的實現代碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
seta20.1:  
  inb     $0x64,%al
  testb   $0x2,%al
  jnz     seta20.1 

  movb    $0xd1,%al
  outb    %al,$0x64

seta20.2:
  inb     $0x64,%al
  testb   $0x2,%al
  jnz     seta20.2 

  movb    $0xdf,%al
  outb    %al,$0x60

這裏 bootasm.S 用了兩個方法 seta20.1 和 seta20.2 來實現通過 804x 鍵盤控制器打開 A20 gate。 這個辦法確實是分兩步來搞的:

第一步是向 804x 鍵盤控制器的 0x64 端口發送命令。這裏傳送的命令是 0xd1,這個命令的意思是要向鍵盤控制器的 P2 寫入數據。這就是 seta20.1 代碼段所做的工作(具體的解釋可以參看我在代碼中寫的註釋)。

第二步就是向鍵盤控制器的 P2 端口寫數據了。寫數據的方法是把數據通過鍵盤控制器的 0x60 端口寫進去。寫入的數據是 0xdf,因爲 A20 gate 就包含在鍵盤控制器的 P2 端口中,隨着 0xdf 的寫入,A20 gate 就被打開了。

接下來要做的就是進入“保護模式”了。

xv6 準備 GDT

在進入保護模式前需要將 GDT 準備好。什麼是 GDT ?它的中文名稱叫“全局描述符表”,前面的“預備知識”裏已經做裏介紹,想要在“保護模式”下對內存進行尋址就先要有 GDT,GDT 表裏的每一項叫做“段描述符”,用來記錄每個內存分段的一些屬性信息,每個“段描述符”佔 8 字節,我們先來看一眼這個段描述符的具體結構:

31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
基地址 G DB XX AA Limit P DPL S E ED RW A 基地址
基地址 Limit


 

三塊“基地址”組裝起來正好就是 32 位的段起始內存地址,兩塊 Limit 組成該內存分段的長度,接下來依次解釋一下其他位所代表的意義:

  • P:       0 本段不在內存中
  • DPL:     訪問該段內存所需權限等級 00 — 11,0爲最大權限級別
  • S:       1 代表數據段、代碼段或堆棧段,0 代表系統段如中斷門或調用門
  • E:       1 代表代碼段,可執行標記,0 代表數據段
  • ED:      0 代表忽略特權級,1 代表遵守特權級
  • RW:      如果是數據段(E=0)則1 代表可寫入,0 代表只讀;
             如果是代碼段(E=1)則1 代表可讀取,0 代表不可讀取
  • A:       1 表示該段內存訪問過,0 表示沒有被訪問過
  • G:       1 表示 20 位段界限單位是 4KB,最大長度 4GB;
             0 表示 20 位段界限單位是 1 字節,最大長度 1MB
  • DB:      1 表示地址和操作數是 32 位,0 表示地址和操作數是 16 位
  • XX:      保留位永遠是 0
  • AA:      給系統提供的保留位

有了上述的解釋,我們再來看看 xv6 是怎樣準備自己的 GDT 的,代碼在 bootasm.S 文件最底部:

1
2
3
4
gdt:
  SEG_NULLASM                             # 空
  SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)   # 代碼段
  SEG_ASM(STA_W, 0x0, 0xffffffff)         # 數據(堆棧)段

這裏用到了幾個宏,具體的宏定義在 asm.h 文件中,爲了方便大家直觀的感受一下 xv6 的 GDT 我把宏計算出來的值直接翻譯過來,代碼應該是下面這個樣子:

1
2
3
4
5
6
7
gdt:
  .word 0, 0;
  .byte 0, 0, 0, 0                             # 空
  .word 0xffff, 0x0000;
  .byte 0x00, 0x9a, 0xcf, 0x00                 # 代碼段
  .word 0xffff, 0x0000;
  .byte 0x00, 0x92, 0xcf, 0x00                 # 數據段

然後我們再把代碼段和數據段的段描述符具體每一位的對應值表展示出來,首先是代碼段:

31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
基地址 G DB XX AA Limit P DPL S E ED RW A 基地址
0x00 1 1 0 0 0xf 1 00 1 1 0 1 0 0x00
基地址 Limit
0x0000 0xffff


 

然後是數據段:

31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
基地址 G DB XX AA Limit P DPL S E ED RW A 基地址
0x00 1 1 0 0 0xf 1 00 1 0 0 1 0 0x00
基地址 Limit
0x0000 0xffff


 

我們來一步步解釋一下。首先說說這兩個內存段的共同點,DB = 1,G = 1,基地址都是 0x00000000,內存分段長度都是 0xfffff,這說明他們都是用於 32 位尋址,所使用的內存是從 0 開始到 4GB 結束(全部內存)。這裏是這麼算出來的,段長度是 0xfffff = 220,G = 1 表示段界限單位是 4k,所以 4k * 220 = 4GB。

再說說他們的不同點,代碼段的 E = 1 而數據段的 E = 0 這表名了他們的身份,身份不同 RW 的值雖然相同,但代表的意義也就不相同了,代碼段的 RW = 1 代表可讀取,數據段的 RW = 1 表示可讀可寫。這也和我們上面解釋的保護模式所能夠達到的目的相吻合。

當然作爲一款教學爲目的的操作系統,xv6 這裏的 GDT 設置還是以簡單容易理解爲目的。諸如“權限位”這樣的安全機制就直接被忽略了,而對內存的規劃也沒有做到真正的“分段”,而是代碼段和數據段都啓用了從 0 到 4GB 的全部內存尋址。其實這種內存規劃方法叫做“平坦內存模型”,即便是 Linux 也是用的這樣的方式規劃內存的,並沒有做到真正的“分段”。這是因爲 x86 的分頁機制是基於分段的,Linux 選用了更先進的分頁機制來管理內存,所以在分段這裏只是走一個必要的形式罷了。而 xv6 後面到底是否也啓用了分頁機制,我們目前還不得而知。

xv6 正式進入保護模式

GDT 也搞定了,接下來我們就要把我們剛剛在內存中設定好的 GDT 的位置告訴 CPU,然後就“萬事俱備,只欠東風”了。CPU 單獨爲我們準備了一個寄存器叫做 GDTR 用來保存我們 GDT 在內存中的位置和我們 GDT 的長度。GDTR 寄存器一共 48 位,其中高 32 位用來存儲我們的 GDT 在內存中的位置,其餘的低 16 位用來存我們的 GDT 有多少個段描述符。 16 位最大可以表示 65536 個數,這裏我們把單位換成字節,而一個段描述符是 8 字節,所以 GDT 最多可以有 8192 個段描述符。不僅 CPU 用了一個單獨的寄存器 GDTR 來存儲我們的 GDT,而且還專門提供了一個指令用來讓我們把 GDT 的地址和長度傳給 GDTR 寄存器,來看 xv6 的代碼:

1
lgdt   gdtdesc

而這個 gdtdesc 和 gdt 一起放在了 bootasm.S 文件的最底部,我們看一眼:

1
2
3
gdtdesc:
  .word   (gdtdesc - gdt - 1)             # 16 位的 gdt 大小sizeof(gdt) - 1
  .long   gdt                             # 32 位的 gdt 所在物理地址

不多不少,正好 48 位傳給了 GDTR 寄存器,到此 GDT 就準備好了,接下來我們進入保護模式!

前面預備知識中講到,就如同 A20 gate 這個開關負責打開 1MB 以上內存尋址一樣,想要進入“保護模式”我們也需要打開一個開關,這個開關叫“控制寄存器”,x86 的控制寄存器一共有 4 個分別是 CR0、CR1、CR2、CR3,而控制進入“保護模式”的開關在 CR0 上,這四個寄存器都是 32 位的,我們看一下 CR0 上和保護模式有關的位

31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
PG 其他控制位 PE




 

  • PG    爲 0 時代表只使用分段式,不使用分頁式
             爲 1 是啓用分頁式

  • PE    爲 0 時代表關閉保護模式,運行在實模式下
             爲 1 則開啓保護模式

然後我們繼續看 xv6 打開保護模式的代碼:

1
2
3
movl    %cr0, %eax
orl     $CR0_PE, %eax
movl    %eax, %cr0

因爲我們無法直接操作 CR0,所以我們首先要用一個通用寄存器來保存當前 CR0 寄存器的值,這裏第一行就是用通用寄存器 eax 來保存 cr0 寄存器的值;然後 CR0_PE 這個宏的定義在 mmu.h 文件中,是個數值 0x00000001,將這個數值與 eax 中的 cr0 寄存器的值做“或”運算後,就保證將 cr0 的第 0 位設置成了 1 即 PE = 1 保證打開了保護模式的開關。而 cr0 的第 31 位 PG = 0 表示我們只使用分段式,不使用分頁,這時再將新的計算後的 eax 寄存器中的值寫回到 cr0 寄存器中就完成了到保護模式的切換。

準備迎接 .code32

到這裏我們關於 xv6 從實模式到保護模式的講解就接近尾聲了。我們已經進入到保護模式了,接下來可以將代碼徹底以 32 位的保護模式來運行了。所以這時我們的 xv6 也要準備跳轉了,再來看一行代碼:

1
ljmp  $(SEG_KCODE<<3) $start32

這是一個跳轉語句,通知 CPU 跳轉到指定位置繼續執行指令。 xv6 在這時就準備跳轉到用 C 寫成的代碼處去繼續運行了。這個跳轉語句的兩個參數就是我們之前一直再講的典型的“基地址” + “偏移量”的方式告訴 CPU 要跳轉到內存的什麼位置去繼續執行指令。

而這時我們已經在分段式的保護模式下了,所以我們通過這句跳轉語句來直觀的感受一下分段式保護模式下的內存尋址。

前面預備知識裏說道在分段式保護模式下“段基址”(基地址)不再是內存地址,而是 GDT 表的下標。上面我們也說過 GDT 表最大可以有 8192 個表項(段描述符),213 = 8192,所以保存着“段基址”的 16 位段寄存器只需要其中的 13 位就可以表示一個 GDT 表的下標,其餘的 3 位可用作他用。

按照這個思路我們看看這個 $(SEG_KCODE<<3) 生成的“段基址”是什麼?SEG_KCODE 是個宏定義,具體的定義在 mmu.h 文件中,我們翻譯過來就是 $(1<<3),再將它運算出來得到


 

這裏這個 16 位的“段基址”的高 13 位代表 GDT 表的下標(學名應該叫“段選擇子”),這裏高 13 位剛好是 1,而我們的 GDT 裏下標位 1 的內存段正好是我們的“代碼段”,而“代碼段”我們在 GDT 的“段描述符”中設置了它的其實內存地址是 0x00000000 ,內存段長度是 0xfffff,這是完整的 4GB 內存。

所以這裏的跳轉語句選擇了“代碼段”,由於“代碼段”的起始內存地址是 0x00000000 ,長度是完整的 4GB,所以後面的“偏移量”仍然相當於是實際的內存地址,所以這裏“偏移量”直接用了 $start32,也就是 start32 直接對應的代碼位置。通過這個跳轉實際上 CPU 就會跳轉到 bootasm.S 文件的 start32 標識符處繼續執行了。

 

轉載:http://leenjewel.github.io/blog/2014/07/29/%5B%28xue-xi-xv6%29%5D-cong-shi-mo-shi-dao-bao-hu-mo-shi/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章