者:boreholehu
對于一個C++程序員來說,可能更多是是每天都在跟各種上層語義、設計模式、軟件方法等等在打交道。但對于「一個C++程序是如何運行在機器上的」這件事可能會比較陌生。有時,遇到一些問題,在宏觀角度看起來可能比較難以解釋,但其實從底層出發,就能發現這個問題其實根本不算問題。類似的問題有:
上面這些疑問,有一些是被讀者問到的,還有一些是筆者曾經思考過,但沒有很快解決的。與此同時,筆者發現,中層、通用性的教程比比皆是,但高層和底層的、專精型的教程卻是少之又少。很多問題可能其實很簡單,但就是搜不到相關的教程。筆者也曾嘗試到一些系統講解底層的書籍中尋找答案,但也發現,它們在各自突出的領域中講解地很詳細,但對于上下層串聯的部分卻總是有缺失,導致各個領域的知識是破碎的,難以關聯在一起,以建立一個更加宏觀的體系。
于是在經過了一系列研究和實驗之后,筆者決定起筆這一篇文章。在這篇文章中將會介紹:
關于本文,有以下幾點說明:
如果你準備好了的話,我們馬上開始!
相信讀者對x86這個詞肯定不陌生,那么它到底指的是什么呢?
對于一個CPU來說,其實就是一個高集成的邏輯電路。如果你玩過數字電路的話,一定會知道所謂的「與」「或」「非」門電路,用這些門電路組合起來,我們就可以實現更多更復雜的功能。
不過邏輯電路再復雜,無非也就是把「一組輸入的電信號」轉換為「一組輸出的電信號」,這就是它最基本的功能。比如說,某一個芯片有3個輸入引腳,2個輸出引腳,當我給輸入引腳分別給「高電平,高電平,低電平」的時候,它能在輸出引腳給我「低電平,高電平」這樣的信號。在剛才這段描述中,「芯片的輸入、輸出引腳個數」稱為「芯片的接口規模」,而「當給XXX輸入信號的時候,能給我YYY輸出信號」則稱為「芯片的邏輯功能」。
因此,我們把那些「可以用來輸出的信號」就稱作「指令」,而這個芯片能夠支持的所有「指令」的集合,就稱為「指令集」。因此,一個CPU的指令集直接決定了它的原始功能。
而x86體系架構使用的這種指令集,我們就可以叫他x86指令集,用來描述所有x86體系架構的CPU能夠支持哪些指令。
當然,除了最核心的指令集以外,「體系架構」自然還包括CPU的其他部件要有哪些,以及跟外部硬件應當如何交互。總之,我們可以認為這是一套協議標準,當我們使用了x86體系的CPU以后,它一定會含有哪些部件、怎么給它指令它就能正常運行、外部的硬件應當如何布局等等這些問題就已經確定了。我們只需要按照它所規定的協議來編寫程序,就可以在這個體系上正常運行了。
解釋完x86是什么了以后,相信一定會有讀者好奇,這種架構為什么叫這個名字?它和我們現在市面上主流的硬件設備是什么樣的關系?
故事要從1978年開始說起。1978年,Intel公司推出了一款CPU,型號叫8086(至于為啥叫這個數字,估計只能問Intel了……)。其實在當年,這款CPU也沒激起多大的浪花,我們現在大家都去研究它,也不過是幸存者偏差罷了。所以我們只需要知道,20世紀70年代末,一個姓英的公司(英特爾)發布了一款芯片,型號為8086。
8086芯片沒有太大的動靜,這有一個非常關鍵的問題,就是它太貴了!因為它要賣360美元一個。注意!這僅僅是CPU的價錢,沒有算其他的硬件。所以能用得起的一般都是極個別的企業,個人用戶可謂望塵莫及了。而真正讓這個系列的芯片火起來的是8088。
8088我們可以認為是8086的一個精簡版,或者我們可以戲稱為「8086 SE」~。1981年,IBM使用了8088芯片,生產了面相個人的PC,價格親民,因此在全球范圍內火了起來,也就帶動了這個系列的芯片的銷量。
此后,Intel就開始了這個架構的CPU的研發迭代,后續又推出了80186、80286、80386。它們都兼容8086的工作模式,但在這個過程中還是出現了一些小插曲(或者可以理解為小bug,這個后續章節會涉及)。
由于這個系列都以86結尾,因此就管這個系列叫做「x86」系列。但注意,「x86架構」則是專指80386以及以后的芯片,而不包括8086、80186和80286,原因我們會在后續章節解釋。
直到1992年,本應叫「80586」的CPU誕生之前,Intel因為一些商標版權的問題,使得這個系列不得不改名,當時的80586上市時,名為「Pentium」,中文譯作「奔騰」。
后續Intel又發布了「Celeron(賽揚)」系列,還有「Core(酷睿)」系列,以及「Xeon(至強)」系列,都沿用了x86架構,Intel將其稱為「IA-32架構」,它們都保持著向下兼容。
故事的轉折點在2001年,那個時候有人覺得x86架構有缺陷,不應該繼續沿用,于是推出了一款全新的架構,稱之為「IA-64」架構,并推出了這個架構的處理器——「Itanium(安騰)」系列。
這里的IA指的是Intel Architecture,而64表示它的指令字長(后續會重點解釋)。本來這個命名的目的也很明確,曾經的是「IA-32」,現在重新設計以后叫做「IA-64」。但是因為它并沒有向下兼容IA-32,并且價格昂貴,因此在個人PC領域并沒有濺起水花。而它主打的服務器領域則是沒有拼過IBM的PowerPC,所以也沒有太多市場。這也導致了安騰系列的CPU至今都不是很出名。
IA-64不成功,但另一個64位架構卻火了,這就是AMD公司在1999年首次推出的AMD64架構。后續AMD64架構被廣泛用于個人PC上。那么,AMD64的魅力在哪?其實就在于,它兼容了IA-32架構,并在此之上進行了擴展。因此,AMD64架構也被稱為「x86-64」架構,也就是擴展64位的x86架構。
所以這里就有一個很有意思的現象,IA-64作為IA-32的繼承者,并沒有兼容IA-32,并且沒落了。反而是AMD64奪得了王冠,向下兼容IA-32。由于AMD64架構的成功,后續也被Intel所使用,并將其命名為Intel 64。
其實Intel 64和AMD64基本沒有區別,主要還是商業競爭中刻意區分了它們。但是硬件廠商的這些商業競爭,對于這些軟件公司來說無足輕重,他們只關心,我的軟件適配哪種架構,就夠了。因此,他們無論描述為「AMD64」還是「Intel 64」,都似乎有站隊的嫌疑,而又因為Intel 64和AMD64其實就是同一套架構,因此這些軟件廠商又把這種架構稱為「x64架構」,其中「x」你自己腦補把,Intel也行,AMD也行。
因此我們總結一下:
值得注意的是,由于x64是向下兼容x86的,因此在很多人口中,并不會區分它們,又因為x86架構已經過時很久了,現在很少有設備會去使用。因此有時我們聽到「x86」其實指的就是x64架構,尤其是跟ARM架構放在一起描述的時候(比如我們經常會說,蘋果從x86轉向了ARM,但其實這里的x86指的是x64,而非真正的IA-32架構)。
所以為了避免混淆,筆者在本系列文章中,統一用「IA-32架構」和「AMD64」架構的名稱,而不使用「x86」這種可能有二義性的詞匯。
因為這是當前市面上使用最多的架構。隨處可見的Intel Core處理器,AMD Ryzen處理器使用的都是AMD64架構。并且,最常用作服務器的Intel Xeon處理器也是這個架構的,所以我們了解最主流的架構自然是不虧的。
另一方面,也正是因為這是目前的主流架構,因此它的相關資料也是最全、最好找的,黑盒較少,比較透明,所以學習門檻較低。計算機底層專業課程的各主流教材也都是選用了這個架構為例進行講解的。
既然我們是為了理清程序的構建和運行相關知識,那么架構這里就不要讓它成為我們的極大困難點,于是,筆者「毅然決然地」選擇了它。(偷笑,其實是因為別無選擇~)
了解完這個架構的情況以后,我們接下來要做的就是找機器,然后進行開發了。
既然是要給AMD64架構的設備進行開發,那么首先,我們得先有一個AMD64架構的硬件設備才行。首先最容易想到的,就是真實地搞一臺AMD64架構的電腦。
這方法最直接,但是成本有點高,而且裝載程序可能沒那么方便。當然了,如果你手邊正好有空閑的設備,或者已經不用的老設備,那自然無可厚非。你可以把程序直接運行在真機上,也會有一個不一樣的體會,而且滿滿的儀式感,很酷!
如果沒有,那也沒關系,因為我們可以用虛擬機。關于虛擬機的運行,通常有兩種方式:
這里~翻譯必須出來背個鍋了!Virtualization和Simulation是完全不同的兩種虛擬技術,但這里的翻譯似乎完全沒有把它們區分開,「虛擬化」和「模擬」到底什么區別?反正,從字面上……我是區別不開…………
那么這兩者究竟指什么呢?首先我們要知道,要想通過軟件的方式模擬一臺硬件設備,那這個「軟件」應當是運行在已經良好運行的操作系統上了。換句話說,我們要用操作系統開啟一個應用程序,然后在這個應用程序中,模擬出硬件設備的各種部件,再利用這種模擬出的部件來執行指令。
那么最容易想到的就是用「純軟件」的方式來模擬。比如說我設置一個變量,用來表示rax寄存器,設置另一個變量來表示rip寄存器。再設置一片內存空間來表示模擬器的內存空間。之后,當我接收到類似于「把0x10內存空間的值寫到rax寄存器中」這樣的指令時,就把對應內存空間中,偏移量是0x10的值,賦值給用于表示rax寄存器的變量中。大致上用簡單的代碼來表示就是:
uint64_t rax; // 用于模擬rax寄存器
std::byte mem[1024 * 1024]; // 用于模擬1MB的內存
// 執行將內存數據讀取到rax中的指令
void load_mem_to_rax(std::ptrdiff_t address) {
rax=*reinterpret_cast<uint64_t *>(mem + address);
}
由此方法,模擬出所有硬件部件和所有指令集中的指令,那么自然就可以模擬出硬件設備的運行情況。
上面這種模擬方式就稱為「Simulation」方式,或者叫「軟件模擬」方式。
這種方式的優點非常明顯:
當然,它的缺點也非常明顯,那就是性能底下。試想,一條軟件模擬的「內存讀入寄存器」的指令,被軟件模擬成了不同變量之間的賦值,這過程還有不少程序邏輯,還有本身OS的調度算法等等。中間隔了這么多層,CPU真實運行的指令早都不知道被擴大成多少條了。因此,這種方式的模擬器,它的性能下降幅度是指數型的。
隨著虛擬機的使用越來越普遍,市面上主流的OS都開始重視了這個問題。因此,從OS層就已經包裝了用于虛擬化的API。然后,「虛擬機」這個APP直接調用OS提供的虛擬化API來完成模擬。
這種技術并不是再完全使用軟件模擬硬件情況了,而是會「盡可能多地」直接使用硬件。例如虛擬機中要執行「內存0x10數據讀取到rax寄存器中」這樣的指令,通過虛擬化API,CPU會真實地執行一條從內存中讀取數據放到寄存器中的指令。只不過這片內存空間并非0x10(OS會做一層映射),這個寄存器也可能不是rax。
因此,通過虛擬化API運行的虛擬機軟件,會被OS認為是一種特殊的進程,對內部執行的指令僅僅做簡單的映射,就直接交給硬件去執行。但所以一條指令對于CPU來說可能只是會變成幾條指令而已。它的性能下降幅度是線性的,如果優化的好的話,這種下降幅度可能會非常小。
由于這種方式依賴于OS所提供的「虛擬化API」,因此這種方式被稱為「虛擬化」方式。
對比軟件模擬方式,虛擬化方式的優點非常明顯,那就是性能顯著提升。但與之相對的就都是它的劣勢了,比如說它不能跨架構模擬,也不容易直接觀測到硬件的狀態。
其實還有一種模擬方式,它介于前面介紹的兩種之間,適用于跨架構模擬。更準確地來說,并不是「模擬」,而是「轉義」。
舉例來說,我希望在ARM架構上運行AMD64架構的程序。那么在運行之前,我先讀一遍原程序,比如說當它出現「把數據加載到rax寄存器中」指令的時候,我就想,嗯……雖然我的ARM架構中沒有rax寄存器,但是,我可以用其他的寄存器來代替,比如說x0。那我就把所有要給rax中寫數據的指令,都翻譯成給x0寄存器中寫數據。
形象點來說,就是在運行一個程序之前,先「讀懂」這個程序,然后翻譯成當前架構的新程序,然后再去運行。
這種模擬方式,性能損耗在「模擬」和「虛擬化」之間,如果優化的好也可以獲得不錯的性能。但它最大的缺點就在于,對這個「翻譯軟件」的要求太高了!通常只適用于運行APP,而不能用于運行OS。并且「翻譯軟件」不僅要對翻譯前后的架構指令非常清楚,還要對OS的調度方式了如指掌才行。
這種方式有一個非常典型的例子,就是蘋果公司的Rosetta,也只有蘋果公司能夠同時對新舊指令架構和macOS都了如指掌,所以他能做出Rosetta也就不足為奇了。
了解完虛擬環境之后,試問讀者,我們應當用哪一種呢?
首先,咱們只是寫一些非常簡單的程序,目的是學習和梳理底層的知識,所以遠到不了考慮虛擬機性能折損的情況。
其次,咱們需要觀測到硬件的執行情況,需要隨時了解寄存器和內存當中的數據。
最后,我相信還有不少小伙伴跟我一樣,用的是蘋果自研芯片的Mac,這玩意本身就不是AMD64架構或者IA-32架構的機器,必須進行跨架構模擬。
那么結論就顯而易見了,我們將會使用「軟件模擬」方式。用到的軟件是bochs,這是一款AMD64模擬器,并且支持非常強大的調試指令,非常適合我們當前的訴求。接下來就為大家介紹如何在macOS和Windows系統上配置bochs。
bochs是一個AMD64模擬器,我們可以在它上面運行AMD64架構、IA-32架構、80286架構甚至是8086架構的程序。但bochs本身是跨平臺的軟件,因此,無論你用的是Intel芯片的Mac還是蘋果自研芯片的Mac,都可以安裝bochs。
雖然我們也可以從bochs官網下載源工程然后構建安裝,但是環境配置以及各種依賴軟件搞起來太麻煩了,所以我們選取一種最簡便的方式,使用Home Brew。
Home Brew是Mac上的開源軟件管理器,類似于Debian中的apt-get和RedHat中的yum。但它并沒有集成在macOS中,所以我們需要先安裝它。
這是Home Brew的官網,但由于眾所周知的原因,它的默認資源在中國大陸是訪問不到的,所以我們需要使用鏡像資源。有一個國內的大神制作了一個安裝Home Brew,并將資源庫替換為鏡像資源的一個腳本,我們可以直接使用。
打開終端,執行下列命令:
/bin/zsh -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)"
會自動下載這個腳本,然后依據提示指定一個鏡像源,輸入系統密碼,然后安裝Home Brew。
由于Home Brew所在github的DNS有過一起污染事件,所以如果當你使用時出現類似于下面的這種報錯時:
Warning: No remote 'origin' in /opt/homebrew/Library/Taps/homebrew/homebrew-cask, skipping update!
這時我們可以執行下面的指令來解決:
git config --global --add safe.directory /opt/homebrew/Library/Taps/homebrew/homebrew-core
git config --global --add safe.directory /opt/homebrew/Library/Taps/homebrew/homebrew-cask
git config --global --add safe.directory /opt/homebrew/Library/Taps/homebrew/homebrew-services
當安裝好Home Brew以后,就可以通過下面的指令來安裝bochs:
brew install bochs
安裝完畢之后,我們執行:
bochs --help
如果能順利打印出幫助信息,那么恭喜,bochs已經安裝成功!
同樣地,由于bochs是跨架構、跨平臺軟件,因此也可以在Windows上正常運行,也包括了ARM架構的Windows(例如搭載了驍龍8cx芯片的電腦,就是ARM架構的)。下面介紹在Windows上安裝bochs的方法。
首先在SourceForge網站上下載bochs的安裝包。
下載完畢后雙擊進行安裝。
安裝過程中的選項保持默認即可。等安裝完畢后,可以在開始菜單中找到bochs,這里我們不要直接運行,而是選擇下面的Folder文件夾打開。
打開后我們選擇bochsdbg.exe打開。注意,在Windows中的bochs默認是不帶調試功能的,必須要運行bochsdbg才可以進行調試。本文后續所有要求運行bochs的地方,對于使用Windows的讀者,都要換成bochsdbg。(包括命令行、makefile中填寫的也應當是bochsdbg而不是bochs,請讀者一定要注意!)
打開之后,可以選擇左側Load按鈕加載bochsrc配置文件運行,后續如果我們用命令行加-f參數后則無需手動加載。現在暫時也可以不用加載配置文件,直接用默認方式執行,點擊右側的Start即可看到運行效果。
之后便可以看到bochs的運行狀態,左側是用于調試的命令行,右側是虛擬機的顯示效果。
既然硬件環境已經就緒了,那接下來,就要想辦法讓它運行我們的程序了。不過在此之前,我們必須要了解一下8086的主要架構,以及執行程序的方式。
話說,我們不是要研究AMD64架構嘛,干嘛要扯這幾十年前的這款胡子都老白了的這款CPU爺爺呢?其實我們在前面介紹AMD64歷史的時候就提到過,IA-32也好,AMD64也好,它本質上并不是完全新的架構,而是保持著向下兼容的。
一方面來說,IA-32和AMD64都是從8086模式開始啟動的,在開機的那一瞬間,你的電腦其實就是8086,然后再通過一些配置,切換到286模式、386模式、AMD64模式等等的。因此,要想在AMD64架構的裸機開始加載程序,8086的工作方式我們是避不開的。
另一方面來說,從IA-32和AMD64架構中來看,其實它還是有很濃重的8086風格,主干框架并沒有大的變動,因此,了解了8086以后,自然而然也就了解了AMD64的其中一部分了。
因此,我們有必要在那些額外擴展的環節之前,先來了解一下8086。
我們要了解8086體系的計算機中的幾大硬件,它們是:
CPU是核心,我們放后面來講,先講講內存、硬盤(外存)和顯卡。
「內存」這個詞感覺在近年來,已經被移動設備行業的術語給“污染”了。因為我們常說的「手機內存」其實指的并不是計算機領域術語中的「內存」。
內存,全稱「內部存儲器」,英文名稱是「Internal Memory」,又被稱為「主存」。之所以叫「內」,這也是有歷史原因的。因為早年,內存并不是一個獨立的硬件,而是直接將內存顆粒焊死在主板上的。
所以,以這個核心的元器件作為邊界,在「里面」的存儲器就叫內存,然后在這個體系外部的就叫做了「外存」。
還有一個原因在于,內存是可以直接和CPU交互的,而外存則不可以,它必須通過I/O接口,將數據先通過內存,然后才可以被CPU處理。
內存一般使用的是電路方式存儲,比如說由晶體管組成的雙穩態電路,通過電路的電壓來表示比特位的信號。這種存儲方式的優點就是讀寫速度會很快(畢竟是電路實現),而缺點就是,依賴持續的電力。換句話說,如果斷電了,數據就會丟失,重新上電以后,里面的數據是什么是不一定的(隨緣,非常的薛定諤),得重新寫入以后才會可用。
所以,移動設備行業里所謂的「手機內存」,指的顯然不是這個意義上的內存。這其實也是劃界的問題,因為手機內存中的「內」是相對于SD卡而言的,手機里自帶的存儲就叫了個「內存」。但計算機專業領域中的「內存」則是體系結構的內部。(后來也是因為手機內存這個稱呼已經有了,再想提及手機里真正意義上的「內存」的時候,又不得不加定語,叫了個「運行內存」。所以用計算機專業領域的概念來說,「手機內存」其實是「外存」,「手機運行內存」才是「內存」)。
我們再來說說外存。外存自然就是前面說的那一套之外的存儲設備咯,像是早期的軟盤。你想想啊,機器里其實只有一個軟驅的,要用的時候,把軟盤插到軟驅里,再來讀取數據。所以,這個「軟盤」不就是「計算機外部」的存儲設備嗎?這樣解釋可能更容易被接受。
當然,像是硬盤、光盤、U盤等等這些,也都屬于外存,雖然硬盤一般是放在機箱里面的,不會頻繁插拔,但不影響它在體系結構中的角色。
外存一般用非電路方式存儲,像是軟盤、機械硬盤采用的就是磁性存儲,通過磁頭去感應某一個位置磁粉的N極或S極來識別比特位。而光盤則是采用光返性質存儲,驅動器來識別某一位置的反光性來識別比特位。再像是U盤(閃存盤)、固態硬盤這些則是用浮柵層來存儲,通過柵格中的電子數來識別這一位置的比特位數據。
既然是非電路方式,那么它就不怕掉電,數據將會更長久地保存。不過相對地,它的讀寫速度就會慢很多。
顯卡,全稱「顯示適配器」,英文是「Graphics Adapter」。顧名思義,就是用來把信號變成畫面,呈現在顯示器上的硬件。
在早期,顯卡的作用僅僅是用來做信號轉換,在內存當中會分配一片專屬區域,供顯卡來使用。顯卡就是不斷地讀取這片內存區域的數據,然后把它按照一定的協議方式,轉換成顯示器上的圖像。當需要變換顯示的東西的時候,CPU就會改寫這片內存空間,這樣在下一幀的時候,顯卡就會按照對應的要求,變換顯示的圖像。
在這套體系當中,圖形的處理完全是由CPU來承擔的,而用于顯示輸出的數據,也是由內存的一部分來承擔的,我們把這片用于顯示畫面的內存區域叫做「顯存」。
然而后來,隨著人們對圖形質量的要求越來越高,因此就想到專門搞一個用來處理圖像數據的處理器,也就是GPU,GPU也需要自己的主存,也叫做「獨立顯存」。
稍微多扯幾句,現在我們再說「顯卡」,默認都是包含了GPU的顯卡,而不再是單純的顯示適配器了。隨著現代顯卡的性能不斷發展,在一些對圖形性能要求不是那么高的設備上,就考慮不使用獨立顯卡,而是將顯卡(包括GPU)繼承在其他部件上,這種顯卡也被稱為「集成顯卡」。將GPU集成在主板上的叫做「板載顯卡」,將GPU集成在CPU中的叫做「核心顯卡」。不過板載顯卡已經被淘汰了,目前如果你的電腦中沒有獨顯的話,那一定是核顯。注意,這種情況只是GPU集成在了「CPU這個芯片」當中,但早已不是早期那種,沒有GPU的情況了。
前面我們介紹了內存和外存的特性,不知道讀者有沒有這樣一個疑問:既然CPU只能操作內存,而內存又是斷電后數據就消掉了,外存雖然可以長久保存,但是剛開機的時候,CPU又執行不到這里來。那么,開機后CPU到底要執行哪里的指令呢?
這確實是個很嚴重的問題,所以說,計算機需要一個「固化」下來的啟動程序,做一些硬件自檢的功能,然后把一份指令從外存讀到內存中,再開始執行。承擔這個任務的就是BIOS,全稱Basic Input/Output System,中文譯作「基本輸入輸出系統」。一般會用一種類似于FPGA的這種ROM,隨著新機器的發型,直接固化在主板上了,當然后來也出了一些可升級固件的BIOS。
硬件的問題解決了,還有另一個問題,照理說,BIOS也不屬于內存,那CPU要怎么執行到BIOS中的指令呢?Intel解決這個問題的方法叫做「統一編址」,簡單來說,就是把一部分內存地址,映射給內存之外的部件,比如說BIOS。對于CPU來說,它會「認為」自己是在通過內存數據線來操作內存,但其實中間的一部分鏈接到了BIOS中。
因此,當計算機啟動的時候,它會先執行BIOS中的指令,BIOS里會把一份代碼從外存加載到內存中,然后再來執行它。由于這份代碼是程序員完全可控的,因此接下來的事情就由這份代碼來完成了。我們把BIOS加載的第一段程序叫做「MBR(Master Boot Record)」。
另外多啰嗦幾句,前面介紹的BIOS也是計算機專業領域當中「BIOS」的概念,而現代我們常說的「BIOS」,里面有豐富圖形界面,多種功能(甚至可以超頻的那種),其實已經不是傳統的BIOS了,而是UEFI(Unified Extensible Firmware Interface)。只不過因為它承擔著與BIOS類似的作用,所以大家仍然習慣稱之為「BIOS」,這一點希望讀者悉知。筆者在后續描述中的「BIOS」特指計算機專業領域術語的BIOS,而對于UEFI則會單獨稱為「UEFI」。
終于講到了核心的部件——CPU。CPU,全稱「Central Processing Unit」,中文譯為「中央處理單元」或「中央處理器」,但這個中文名用得不多,一般還是直接叫它CPU。
【注:為了簡化問題,幫助讀者快速上手,下面的CPU框架結構是簡化版的,想知道完整、規范地8086CPU內部結構的讀者可以在網上自行搜索。】
CPU有三個重要的部分:運算器(CU, Calculation Unit)、執行器(EU, Execution Unit)和寄存器(Register)。其他類似于緩存(Cache)之類的東西先不講,因為我們暫時感知不到。
運算器,簡單來說就是CPU的原子功能,比如說能做加減法運算之類的。它能做哪些運算取決于它的指令集。
執行器,由它來負責,當前要使用運算器的哪個功能,執行什么樣的指令。
寄存器,則是CPU內部用來存放數據的地方,對于軟件層面來說,我們主要操作的就是寄存器,因為其他部件都是按照自己的規則去執行的,我們只需要控制寄存器,就可以完成我們希望CPU執行的指令。
照理說,這個時候我應該介紹一下8086的14個寄存器的,但是筆者覺得,前面的鋪墊有點太多了,讀者可能已經迫不及待想寫點程序運行運行了,所以,這些內容,等用到的時候再說吧~
啰里八嗦了那么多,總算是可以開始運行程序了!現在就請打開bochs,我們用debug模式來裸機運行一下,看看會發生什么。
對于Windows系統來說,直接運行bochsdbg.exe就可以了,暫時還不用加載配置文件,對于macOS來說,需要指定一下顯示的配置。我們找一個工作路徑(以后項目的代碼都可以放到這個里面),例如~/code,再里面創建一個文件名為bochsrc,這是虛擬機的配置文件,然后編輯里面的內容如下:
display_library: sdl2
主要是因為,bochs的顯示輸出,默認用的并不是sdl2,這在macOS上是顯示不出來的,所以我們需要指定到這個庫。
如果你的機器上還沒有安裝,那么可以用brew install sdl2來安裝。
保存完畢以后,在工作路徑上通過這個配置文件來運行虛擬機:
bochs -qf bochsrc
即可啟動虛擬機,命令行會保持在調試狀態:
這時候我們可以輸入c,回車,表示繼續執行,不出意外的話,會彈出虛擬機的顯示窗口:
可以看到,BIOS中的指令已經運行完畢了,但是由于它沒有搜索到外存,所以最終停在了這里。
很好!接下來,我們只需要把指令給它加載到外存里就OK了吧!你可以想象,現在我們把程序寫好了,放到一張軟盤中,然后把軟盤插到軟驅里,再重啟電腦,這樣的話,BIOS就應當能檢測到軟盤中的內容,并自動加載到內存里了。
不過對于虛擬機來說,上面這套動作得靠配置文件來完成。打開我們剛才的bochsrc(如果你用Windows,之前沒有建立的話,現在就該建立了!),加入以下內容(注意,macOS的話不可以刪除sdl2的配置項哈!):
boot: floppy # 設置軟盤啟動
floppy_bootsig_check: disabled=0 # 打開自檢
floppya: type=1_44, 1_44="a.img", status=inserted, write_protected=0 # 使用1.44MB的3.5英寸軟盤,取鏡像為a.img,開機默認已插入軟驅,不開啟寫保護
這樣再開機的時候,就可以讀取軟盤鏡像了。那么接下來,我們只需要把要執行的指令,寫成這個名為a.img的軟盤鏡像里就大功告成了。
那怎么創建軟盤鏡像呢?需要用到二進制編輯器。二進制編輯器很多,macOS上推薦使用Hex Fiend,可以直接在App Store中下載到:
HexFiend
對于Windows來說,可以使用ultra edit,請讀者自行安裝,如果你實在找不到合適的也無妨,因為我們不可能一直用編輯二進制的方式來寫程序,下一章開始我們就改用其他方式了,可以看一下筆者的操作,領悟精神即可。
為了能看到執行效果,我們就把一個數寫到一個寄存器里,然后通過bochs的調試指令來看看寄存器里的值,如果生效了,那么就證明我們的MBR已經加載并執行成功了。比如說,我們給ax寄存器中放一個數值6。關于ax寄存器是什么后面章節會講,反正當前只要知道它是一個8086中的寄存器就好了。
那么,把6寫入ax寄存器的命令是什么?這個可以通過查Intel手冊知道,應當是:
B8 06 00
B8是指令碼,表示給ax寄存器中存入數據。后面的06 00是操作數,因為ax是一個十六位寄存器,所以給它應該要放一個16位的操作數。那為什么是06 00而不是00 06呢?這是因為,8086體系使用小端序,也就是低字節放數的低位。但是在書寫數據的時候,我們又習慣從低到高來寫,所以就變成了06 00,看上去可能有點不適應,但是還是需要大家適應一下~
那是不是這樣就OK了?并不是!雖然BIOS會自動加載數據,但是,BIOS有一個約定,它會檢測這段數據的最后兩個字節是否是55 AA,是才會認為這是一段合法的MBR,才會加載。至于為啥是這倆魔數……emmm……估計沒人曉得~
由于BIOS只會加載512字節(也就是對于軟盤來說的第一個扇區),又對后兩個字節有標志檢測,所以,MBR應當是不多不少正好512字節,并且要在軟盤的第一個扇區,這樣才能正確被加載。所以,我們補全到512字節,并且把后兩個字節設置為55 AA,如下圖:
MBR
保存成a.img,就可以使用了!
然后我們再執行bochs -qf bochsrc,(Windows可以先打開bochsdbg.exe,然后選擇Load按鈕加載bochsrc),注意,現在還不能無腦按c,因為我們的MBR里只有一條指令,黑著往下執行的話會觀察不到。所以,我們需要打一個斷點,讓bochs執行到這個位置的時候停一下。
那么另一個問題來了,斷點應該打在哪?這取決于,BIOS會把MBR加載到內存的哪一個位置。這里的約定是0x7c00的位置(同樣,至于為什么是這個地址估計也沒人知道了~總之是作為一種約定),那么我們就要在0x7c00的位置打斷點,所以執行下面的調試指令:
pb 0x7c00
然后再按c,這樣執行到這一位置的時候就會停下來:
打斷點
停下來的時候,調試頁面會顯示這樣的情況:
調試
注意最下面一行,中括號里的就是當先執行指令的內存地址,也就是0x7c00,證明這個斷點位置是對的,在繼續執行之前,我們先來看一下當前ax寄存器的情況,輸入r指令,回車可以看到通用寄存器的狀態:
寄存器狀態
這里需要解釋一下,由于bochs是AMD64架構的模擬器,所以這里的寄存器都是按64位顯示的,它們的擴展情況將會在后續章節來介紹,目前我們只需要知道,要看ax寄存器的值,其實就是看rax的最后16位(也就是最后4位十六進制位),如上圖紅框里的,就是ax的值,現在是aa55。
然后,我們往下執行一條指令就好了,s命令是單條執行,只會向下執行一句指令。所以我們輸入s,回車,再輸入r來打印一下寄存器的情況:
執行一步
OK,ax寄存器真的被改寫成0006了,說明我們的指令已經成功運行了!
不知道會不會有讀者跟筆者一樣,第一次在裸機上運行一句指令以后會無比興奮,仿佛打開了新世界的大門,恨不得現在就著手寫一片江山上去!但是先別急!因為這種用二進制機器碼直接編程的難度也忒大了。我得去記住所有的指令碼和指令格式,萬一錯一個數字那就整個都不對了,況且它可讀性也很差呀!誰能一眼看出來B80600是什么鬼?
當然了,要是退回到8086的年代,可能程序員真的是這么干的,但是現在,我們有了更方便的工具,這種仿古式的編程方法,稍微體驗一下就OK啦。回到上面的指令,既然B80600是「給ax寄存器寫入0006這個數」的含義,那么,能否有一個翻譯器,把我的這種表意,轉換成機器指令呢?
當然有!這就是匯編器,它可以把匯編語言轉換成機器碼。比如說:
mov ax, 0x06
表明給ax寄存器中傳入0x06這個十六進制數,然后交由匯編器將其轉換為B80600。這樣的語言就叫做匯編語言,匯編語言看起來是比機器碼要友好得多了吧?
不過成熟的匯編器除了做指令翻譯以外,可能還會有一些更方便的功能,類似于編譯器的預處理,做一些靜態的數值轉換之類的工作,但是不同的匯編器支持的匯編語言也會略有不同,業界比較常用的有兩個:nasm和gas。
gas也就是GNU的asmmbly(匯編語言),之所以比較常用,是因為gcc只能將C代碼編譯成gas格式,后續本篇的示例中,也會使用gcc編譯器,編譯后的就是gas格式。
nasm是一個比較被普遍認可的匯編器,全稱Netwide Assembler。它的優點在于語法簡潔易用。在本篇的示例中,對于需要直接手動開發的匯編語言部分,將會使用nasm。
接下來就來介紹如何安裝nasm。
首先,登錄nasm官網,點擊當前最新的穩定版本(讀者看到的時候有可能已經是高于截圖的版本了,不過沒關系,選擇最新的穩定版即可)。
nasm官網
接下來,根據自己所使用的OS選擇對應的文件夾,如果你用macOS,就選macosx,如果你用Windows,就選win64。注意,這里只區分操作系統,不區分你的實際硬件架構,即便你使用蘋果自研芯片的Mac,或者搭載驍龍芯片的Windows,這里的軟件也同樣適用。
下載對應OS版本的nasm
接下來Windows和macOS的步驟會有不同,筆者分別來介紹。
由于Windows版本中提供了安裝包,因此,比較方法的做法是下載這個installer,然后通過自帶的安裝程序安裝到電腦中。當然,如果你對搭建環境比較熟的話,也可以直接下載下面的zip,解壓縮后得到的直接是nasm程序本身。
選擇安裝器
如果你選擇了安裝器的版本,那么直接運行安裝器,安裝選項全部默認即可。
nasm安裝器
不過這里要注意一下安裝路徑,默認情況是C:\Program Files\NASM,Windows默認這個帶空格的路徑確實是一個飽含詬病的歷史遺留問題,不過對于nasm來說影響不大,安裝在默認路徑下也是OK的,只不過我們要記住這個路徑,保證能找到它。如果你沒有用安裝器,而是直接下載的zip然后解壓縮的話,也請把整個文件夾放在一個合適的路徑下,保證自己找得到。
安裝路徑
等安裝完畢后,nasm就已經躺在剛才的安裝路徑下了。但是每次都指定絕對路徑去運行著實麻煩了一些,也不方便我們進行項目的遷移,因此,我們還要把它配置到環境變量里。按Win+R組合鍵,彈出「運行」窗口,輸入sysdm.cpl,回車,即可打開系統屬性設置。
運行
在「系統屬性」設置中,選擇「高級」標簽頁,再點擊下面的「環境變量」按鈕。
系統屬性
接著,在環境變量中找到用戶變量里的Path,這個變量決定了,如果你不指定絕對路徑,而是直接輸入一個命令的時候,系統會去哪些路徑中找程序。我們希望的效果是,當我們想運行nasm的時候,直接輸「nasm」就好了,而不是每次都要輸「C:\Program Files\NASM\nasm」,因此,就要把這個路徑也配置到環境變量中。
選擇Path后點擊「編輯」,或者直接雙擊Path也可以,就可以編輯環境變量了。
環境變量
在「編輯環境變量」的窗口中點擊「新建」,然后把nams的安裝路徑寫進去。注意,要寫全路徑,并且只需要寫到NASM這層路徑就好了,確保這個路徑下有nasm.exe這個可執行程序。
編輯環境變量
環境變量設置好以后,我們就可以嘗試運行一下nasm了。按Win+R打開「運行」,輸入cmd,回車,即可調出控制臺。
運行
在控制臺中輸入nasm -v,如果能夠看到打印出的nasm版本號信息,就說明我們已經安裝配置完畢了!
運行nasm
由于macOS版本的nasm沒有安裝包,所以我們只能下載源程序的壓縮包。
下載nasm
解壓縮之后,就已經是可以執行的程序了,不過一般情況下瀏覽器默認會把文件下到「下載」這個路徑中,這里自然不合適放一個經常要用到的程序,所以請手動把它挪到一個妥當的位置。
我這里選擇的是用戶根路徑,也就是~/。文件夾它默認帶版本號,你可以改個名字,也可以不管它,只要確保里面有nasm這個可執行程序就好了。我這里的路徑是~/nasm-2.16.01。
同樣地,為了讓我們使用時可以只輸入nasm,而不是~/nasm-2.16.01/nasm,我們還需要把這個路徑放入環境變量。
macOS最早默認使用的bash,后來換成了zsh,因為這個切換已經很久了,所以筆者介紹zsh的情況,如果你用的是其他版本的shell,就請自行解決環境變量的配置問題。
執行下面的命令,編輯zsh的配置文件:
vim ~/.zprofile
注意,即便你當前沒有.zprofile這個文件也沒關系,上面的命令執行會以新建文件的方式。
然后再編輯界面按「i」鍵,進入編輯模式,此時左下角會顯示「INSERT」,表示在編輯模式。如果里面已經有一些配置了,無視就好,我們在文件最后加上:
PATH=$PATH:/Users/xxx/nasm-2.16.01
注意,由于我是放在~/里的,但這里要寫全路徑,所以你需要看一下全路徑是什么,用波浪線有時可能會失效。
那一句的意思就是,在PATH這個變量后面,加上一個nasm的路徑,所以這里要填寫你的nasm所在路徑。
由于.zprofile會在每次運行終端的時候自動執行,因此我們把命令寫在這個文件里就不用每次手動配置了,但由于現在還沒生效,所以你還需要執行一句:
source ~/.zprofile
或者干脆把終端關了,重新開一下,就生效了。
然后我們在控制臺輸入
nasm -v
如果能夠看到版本信息,那么說明nasm已經安裝配置成功。
nasm版本
上一章我們已經成功地在8086上運行了指令,同時也介紹了nasm匯編語言。那么接下來這一章,我們就來看看如何寫BIOS自檢后的第一道程序——MBR。
既然咱們已經決定要在8086上運行程序了,那么自然,現在是逃不過要了解一下8086 CPU的一些詳細情況了。
值得注意的是,8086并不是只有14個寄存器,只不過這14個寄存器是對于程序來說直接打交道的。CPU內部自然還有一些用于體系自身運行的,對外不透明的寄存器,不過這些我們就不需要了解了(其實很多更詳細的那些也屬于Intel的商業機密,咱也沒法了解)。
我先把要關注的這14個寄存器的名稱列出來,然后再來解釋:
需要強調一點,除了IP和FLAG以外,上面寄存器的名稱所描述的本意,只是這個寄存器「通常」或「默認」用做的事情,并不是說該寄存器只可以用做這一種情況。寄存器是很珍貴的資源,因此實際操作的使用用法是靈活多樣的,所以筆者并不想拿這些寄存器名稱本身的含義去大書特書。大家其實需要知道,我們要關注這14個寄存器,記住它們的符號(因為匯編語言里要用到)就好了,在一些必須指定寄存器的場景,我們再單獨去記憶就好了。
另外,上面這些寄存器都是16位的,這也就意味著,8086每個節拍處理的數據都是16位的,在8086這塊CPU里,數據處理和傳遞的基本單位就是16bit,我們也稱「8086的字長為16位」,也稱「8086是16位CPU」。
前面我們說,8086是16位CPU,這個僅僅是指它的字長,但并不對應它的最大尋址空間。一個CPU的最大尋址空間并不取決于它的字長,而是取決于它對外的地址總線的個數。
如果你玩過數字邏輯器件的話,應該知道有一種器件叫做「譯碼器」,例如下圖展示的是74138,三線-八線譯碼器:
74138
它的輸入端(A~0~、A~1~、A~2~)就是地址總線,我們可以想象,這三根線接到了CPU上。后面的輸出端(Y~0~~Y~7~)就是數據線,我們可以想象,這8跟線接到了內存的存儲單元上。
當A~0~A~1~A~2~輸入為010時,表示需要控制第2號地址,那么Y~2~會輸出1。同理,當A~0~A~1~A~2~輸入為101時,表示需要控制52號地址,那么Y~5~會輸出1。依次類推
在上面所述的這種結構中,我們認為CPU有3根地址總線,那么尋址空間就是2^3^=8,地址從000到111。
而在計算機體系中,存儲單元一般不會按二進制位(bit)來編址,而是按照字節(Byte),也就是說,每8個bits為一組,編一個地址。那么地址總線是3的CPU,就可以訪問8字節的內存空間。
而對于8086來說,它含有20根地址總線(注意,并不是16根!),那么,8086的尋址空間就是:
所以,8086最多支持1MB的內存空間,地址從20個0到20個1,不過用二進制表示會比較冗長,所以我們通常用十六進制表示內存地址,也就是0x00000到0xfffff。
前面我們提到過,類似于BIOS這樣的部件,隨不屬于內存,但使用了統一編址的方式,因此,BIOS里的數據仍然會被包含在這1MB當中,因此實際可用的物理內存,是不足1MB的,但這件事對于CPU來說是無感知的,它會按照同樣的方式,通過地址總線來操作外部硬件,無論它是內存還是BIOS。也正是由于這種編址方式,就是為了讓CPU不去區分實質硬件,因此,對于統一編址的硬件來說,我們仍然稱其地址為「內存地址」,雖然它壓根不是內存。
那么另一個很嚴重的問題就出現了,8086是16位CPU,它的寄存器也都是16位的,但卻有20根地址總線,那我們怎么表示一個內存地址呢?8086采用的方式是,用兩個16位寄存器來拼成一個20位內存地址,示意圖如下:
8086地址拼接
也就是說,把其中一個寄存器作為「段寄存器」,它的0~15地址線接給全加器的4~19位,作為第一個加數。再把另一個寄存器作為「地址寄存器」,它的0~15地址線接給全加器的0~15位,作為另一個加數。
上面的和作為輸出地址。(當然,實際8086內部邏輯器件比這復雜的多,筆者僅僅是做一個示意)
那么用公式來表示就是:
其中表示段寄存器中的值,表示地址寄存器中的值。左移4位是指二進制位,效果相當于十進制中的,相當于十六進制中的末尾補0。
舉個簡單的例子,如果是0xf055,是0xa003那么地址怎么來算呢?首先給末尾補0(因為是十六進制的),然后跟相加即可,也就是0xf0550 + 0xa003,等于0xfa553。
在8086中,可以用做段寄存器的有cs、ds、es和ss,而可以用做地址寄存器的有bx、di、si、bp和sp。如果你要問,為什么其他寄存器不可以呢?那也很好解釋,因為只有這幾個寄存器,有連接到譯碼器之前那個全加器上的電路,其他寄存器沒有這個電路,自然也就不能直接用做此目的。
由于一個二十位的內存地址需要兩個十六位操作數來表示,在匯編語言中,會采用冒號隔開,也就是s:d的方式。例如0xf055:0xa003表示了0xfa553這個地址。當然,我們也發現了,這種方式下,地址表示是不唯一的,例如0xfa00:0x0553也同樣表示0xfa553這個地址。所以由于這個特性也會導致一些有趣的問題,我們將會在后面的章節來詳細解釋。
前面我們已經體驗過一次8086的啟動了,不過那會筆者為了讓大家能先快速有一個感性的認知,就沒有介紹過多的內容。在繼續編寫MBR之前,我們還是有必要詳細理解一下8086啟動過程。
CPU在啟動上電的瞬間之后,它只會機械性地做一件事,就是每個時鐘周期,把指令讀進來,執行,然后再讀下一條指令,執行……如此循環往復。
那么,究竟要從哪個位置讀指令呢?這是IP寄存器決定的,IP寄存器指向哪里,CPU就會讀取哪里的指令。等指令結束后,IP會自動增加指令長度的數值,這樣CPU就可以執行下一條指令了。
由于8086指令集屬于CISC指令集(Complex Instruction Set Computer),它的指令長度是不同的,因此,每次執行指令后,IP的偏移數也不盡相同,這取決于剛才執行的那條指令的長度。不過我們不需要過多擔心,指令長度這件事CPU會自己處理好。
這里還有一個問題,IP也是一個16位寄存器,它自己沒法完整表示內存地址,還需要一個棧寄存器跟它組團。那么這個棧寄存器就是CS。
換句話說,CPU永遠都會執行CS:IP處的指令,只要設置好這兩個寄存器,CPU就能正常執行指令。
在8086上電的時候,CS寄存器被初始化為0xf000,而IP寄存器被初始化為0xfff0,所以自然,CPU執行的第一條執行在0xffff0這個位置。為了保證機器上電自檢,以及MBR加載的事項能夠順利完成,那么這個位置已經會被映射到BIOS當中,這樣保證機器上電后,可以自然而然地執行BIOS中的內容。
在8086中,BIOS會被映射到0xf0000到0xfffff的位置,這64KB的地址由BIOS來控制。
BIOS內部會具體執行哪些指令我們不得而知(雖然通過bochs確實能看到,但它用的BIOS也只是一個開源版本的固件罷了,真機上的BIOS內容并不開源,我們也沒法知道),但BIOS一定會做一些約定好的事情,方便下一步的OS內核可以正常加載。比如說,BIOS會檢測外存、I/O設備是否正常,并且如果發現了MBR(也就是外存中,第一個扇區的數據,以0xaa55結尾的),就會把這一扇區(512字節)的內容,加載到0x7c00的位置,然后把CS:IP設置為0x0000:0x7c00,保證下一條指令就是0x7c00處的指令。
回想一下前面章節中,我們給軟盤的第一個扇區的第一行寫了一個B80600,然后在0x7c00出打了斷點,就可以看到ax寄存器確實變成了6,這就是因為,這一扇區的數據,被BIOS加載到了0x7c00的地方,然后把CS:IP設置為0x0000:0x7c00,這樣,B80600就成了BIOS之后執行的首條指令了。
有了這些理論基礎,我們就可以繼續來編寫MBR了。相信大家首先想做的,應該就是在屏幕上輸出點東西吧!接下來我們就按照國際慣例,在屏幕上輸出Hello World!。
在已經安裝好nasm的前提下,我們在項目路徑下新建一個文件,叫做mbr.nas,然后輸入下面內容:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
hlt
times 510-($-$$) db 0
dw 0xaa55
稍后我們再來解釋代碼,咱們現來看看效果。
首先,要把匯編代碼轉換為機器碼,輸入下面指令,通過nasm來進行匯編:
nasm mbr.nas -o mbr.bin
得到mbr.bin文件,然后將其重命名為a.img(可以直接用圖形界面操作,也可以執行命令cp mbr.bin a.img),再啟動bochs。(注意,這里復用了前面章節的工程路徑,因此需要前面bochrc的配置文件,詳情可以查看前面章節)
bochs -qf bochsrc
然后按c命令,即可看到輸出結果。如果你也跟我一開始一樣,盯著下面的Booting from Floppy...沒反應,然后認為程序沒有生效的話,那請你往最開頭來看:
bochs輸出
可以看到,這里原本應該是「Bochs」,但是第一個字母被我們改成了「H」,所以輸出是成功了。這主要是因為BIOS在屏幕上輸出了一些東西,然后并沒有清屏,導致我們自己的輸出被「淹沒」在里面了。不過要清屏需要額外解釋一些其他東西,為了循序漸進,所以咱們暫時先忍忍,知道要在這些亂七八糟的信息里去尋找我們的輸出就可以了。
接下來我們聚焦到這幾行匯編語句上,解釋一下我們都做了什么。
mov ax, 0xb800
這一句,是給ax寄存器中賦值0xb800,mov指令其實更準確應該是「copy」,它會把右邊的操作數賦值給左邊,移動之后后面的操作數不會消失。后面一句
mov ds, ax
則是把ax的值賦值給ds寄存器,這樣ds寄存器中也是0xb800了。
相信讀者在這里一定會有疑惑,為什么我不能直接mov ds, 0xb800呢?何苦勞煩ax這樣節外生枝?這就是我們編寫匯編語言的時候必須要考慮的問題。匯編語言僅僅是把二進制的機器碼,換了一種更加接近人類語言的方式展示而已,但它本質沒有變,匯編器會把它轉換成對應的機器碼。所以,我們寫的每一條匯編指令,都應該要有對應的機器指令才對,也就是機器能夠支持的指令。而8086中的段寄存器并不可以直接通過立即數來賦值,因為8086體系根本沒有這樣的機器指令。
所以,在編寫匯編語言的時候,我們要以CPU硬件的思維來思考,書寫「指令」本身,而不是高層的抽象語義。用前面的例子來說,我們要達成「把0xb800這個數賦值給ds寄存器」的這個需求,要使用「mov ax, 0xb800和mov ds, ax」這兩條指令來完成。當然,你換成bx、cx或者dx做中間量也是OK的,因為這幾個寄存器都可以通過立即數來賦值。
這兩行代碼的含義已經清楚了,我們來解釋一下目的。在前面的章節中筆者曾經介紹過「顯存」的概念,顯卡會按照每個刷新周期,讀取某一片內存空間,然后按照一定的規則解析,并輸出給顯示器,這片內存空間就是「顯存」。
在8086機器初始化時,會默認使用標準VGA協議,并且是80×25×16的文字模式。也就是說,在這種模式下,顯示器可以顯示25行,每行80個字符(ASCII字符),并且支持最多16種顏色。在這種模式下,對應的顯存是0xb8000~0xb8f9f,一共4000字節的位置。每兩字節對應一個字符顯示位,低字節表示ASCII碼,高字節表示顏色信息。
因此,0xb8000這個內存地址,對應的就是屏幕上第一行第一個字符對應的ASCII碼,0xb8001對應的是它的顏色信息。同理,0xb8002對應第一行第二個字符的ASCII,0xb8003對應它的顏色……0xb80a0對應第二行第一個字符的ASCII,0xb80a1對應它的顏色……0xb8f9e對應第25行(最后一行)第80個字符(最后一個字符)的ASCII,0xb8f9f對應它的顏色。通過給顯存中寫入數據,就可以控制屏幕上的字符。
那么,顏色信息是怎樣的呢?顏色信息的字節中,0~2位表示文字顏色的RGB,第3位表示是否高亮,4~6位表示背景色RGB,第7位表示是否閃爍。我們可以把顏色總結如下表:
配合上I位,前景色可以有16種顏色,分別是:
而背景色沒有高亮位,因此只支持8種:
最后配合K位,表示是否閃爍。
這里建議大家想看那種顏色,可以做一些嘗試,還可以配合一下位置來編寫代碼,比如說,我想在屏幕第一排第一個、第二排第二個、第三排第三個分別顯示ABC,然后隨便用上點顏色看看效果,就可以寫成:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'A'
mov [0x0001], byte 0xF0
mov [0x00A2], byte 'B'
mov [0x00A3], byte 0x46
mov [0x0144], byte 'C'
mov [0x0145], byte 0x32
hlt
times 510-($-$$) db 0
dw 0xaa55
效果如下(注意,A是閃爍的,但截圖顯示不出來):
文字顏色
我們繼續來解釋代碼,中括號表示取內存地址,所以這里的[0x0000]表示取地址是0x0000的內存地址,在mov指令下,表示給內存寫入數據。我們知道,一個完整的內存地址應該有兩部分,而對于立即數尋址的方式來說,默認段寄存器是ds,也就是說,[0x0000]其實等價于[ds:0x0000],這就是剛才我們之所以要先設置ds的原因。由于ds已經被設置為0xb800,因此[0x0000]就是[0xb800:0x0000],自然也就表示了0xb8000的地址,也就是顯存的第一個字節。
那為什么要寫那個byte呢?當我們操作寄存器的時候,會按照寄存器的大小來識別操作數,比如說mov ax, 0x5,由于ax是16位的,因此,后面的0x5會自動補全為0x0005。但是,當我們操作內存的時候,就需要手動指定操作數的長度了。長度描述符有byte、word、dword和qword,分別表示1字節、2字節、4字節和8字節。注意,如果使用word或以上的形式,將會按照小端序來處理,例如mov [0], word 0xabcd則會在ds:0的位置寫入0xcd,然后在ds:1的位置寫入0xab。再多啰嗦一句,如果不寫0x前綴或h后綴的話,將會按照十進制類解讀。
綜合一下,前三行代碼:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
表示的就是,在屏幕的最左上角的位置顯示一個字母'H',由于之前BIOS已經寫入部分顯存數據了,所以它的顏色會保持不變,當然,我們可以通過類似于mov [0x0001], byte 0x0f的語句把它的顏色變成白色。
大家可以嘗試用這種方法在屏幕上輸出各種各樣的內容。
后面有一句
hlt
這是掛起指令,可以讓CPU暫時先不要向下繼續執行,直到響應中斷(關于中斷會在后續章節介紹)。這里寫這行語句的目的在于,每次都給bochs打斷點有點麻煩,而使用hlt指令就可以讓CPU懸停再此處,方便我們觀察輸出,所以就不用打斷點了。
最后一行的dw 0xaa55,這里的dw是偽指令,也就是說,它并不會翻譯成機器指令,而是用于指導編譯器做預處理用的,有點類似與C/C++中以#開頭的語句。dw的意思就是按字面寫2個字節,內容是后面的數,也就是0xaa55。前面我們說過,BIOS只有在檢測第一個扇區的后兩個字節是0x55和0xaa的時候,才認為是合法MBR,并加載。所以,這行語句就是干這件事的,我們可以看到匯編之后的二進制中,最后2個字符被寫入成功了:
mbr.bin
dw表示寫2個字節,對應的還有db寫1個字節,dd寫4個字節,dw寫8個字節,注意,都是小端序。所以上面的偽指令其實還可以改成db 0x55 0xaa,效果是一樣的。
最后一個問題就是,0xaa55是這512字節的最后兩個字節,但我們剛才也沒寫幾句指令,這中間的部分咋整?可以補0,但得補多少0呢?這主要取決于,剛才我們寫的所有指令占了多少字節。注意,匯編語言中的行號是沒有執行層的含義的,因為對于CISC指令集來說,每條指令的長度都可能不一樣,所以行數跟指令的字節數沒有直接關系。
所以,計算指令長度的這件事也就交給匯編器了,times也是偽指令,表示后面緊跟的指令執行幾次,比如說times 5 db 0就等價于db 0 0 0 0 0。而$和$$符號則是指令的偏移數,$表示當前位置的偏移數,$$表示首行的偏移數。注意,之所以首行也會有偏移數,這是有一種情況,就是當前文件的第一條指令并不一定加載到內存0的位置,雖然在本代碼中$$就是0,但我們還是用$-$$來計算一下偏移量,而不直接用$。
所以,這一行的意義就很明確了,times 510-($-$$) db 0,就是從當前位置,一直補到第510字節,都補0。然后最后兩個字節留給0x55和0xaa。
由于本系列文章并不是專業的8086匯編教程 ,因此不會過分糾結匯編語言的指令和編程技巧。但距離我們的目標——運行一個C++程序還有挺遠的距離,就比如,BIOS只負責加載512字節的MBR,多的部分怎么辦?另外還有一個非常令人困擾的問題,就是如何清屏?
當然了,顯存的位置都已經清楚了,把他們全搞成空格符,自然也就相當于清屏了。只不過這種功能還不需要我們自己來寫,用軟中斷的方式就可以解決。
要解決這些問題,首先我們需要了解一下軟中斷,在此之前,需要先了解一下中斷。
簡單來說,中斷機制解決的就是CPU和外部設備速度嚴重不匹配的問題。比如說,當你在鍵盤上按下一個按鈕的時候,CPU是需要響應的,但是,CPU怎么知道你按沒按下鍵盤呢?
一種方式就是主動監聽,用大白話來解釋就是,CPU要隔三差五去看一下,鍵盤有沒有被按下,如果有,就響應,如果沒有,就回來繼續干活。
但這種主動監聽的方式有一個非常嚴重的問題,就是速率不匹配。當代CPU的主頻基本都是3GHz數量級,即便是最早的8086,主頻也有4.77MHz。再想想你敲擊鍵盤的速度,根據吉尼斯官方記錄,世界冠軍的打字速度也不過是每分鐘807個字符,這個換算下來也就是13Hz左右。換句話說,你敲一下鍵盤,CPU已經干了50萬次以上的工作了,由于這種速率不匹配,因此選用主動監聽方式對資源是一種極大的浪費。
因此,人們就想了一個辦法,設計了一個中斷控制器,用來監聽外部事項(例如鍵盤敲擊信號),當需要CPU響應的時候,中斷控制器再去「通知」CPU,“你把手上的活先停一下,有個事情要處理。”這種機制就叫中斷機制。
對于中斷信號,CPU要做出對應的處理,那么自然就要有一些用于處理中斷的指令,當CPU收到對應的中斷時,就去執行對應的指令即可。這種機制有點像Qt中的signal-slot機制,也有點類似于Vue中的@click綁定觸發事件。總之,都是將一個事件(或者信號)跟一個函數相綁定,當收到事件信號時,執行對應的函數。
不過既然中斷的處理過程就相當于一個函數的話,它自然也可以當做一個普通的函數直接調用,這種方式就被稱為「軟中斷」。換句話說,軟中斷其實跟原本的中斷機制沒什么關系,它只不過利用了中斷號,直接去執行了對應的中斷響應函數罷了。
所以,軟中斷本質上就是函數調用。
在BIOS內部,會實現存一些中斷響應的流程指令,所以我們可以通過軟中斷調用方式,去執行BIOS所提供的一些功能。這些BIOS提供的功能也稱為「BIOS中斷」。
BIOS中斷可以提供很多功能,詳細的情況只能去查BIOS手冊了,這里筆者只介紹咱們用得上的。首先,就來解決清屏的問題。
中斷的調用需要配合固定的寄存器傳入參數,之前我們說過,默認情況下顯卡使用的是文字模式,那么只要重新再進入一次文字模式就可以自動清屏功能,需要al傳入0x03,ah傳入0x0,然后使用0x10號中斷即可實現清屏(如果是其他顯示模式,則會切換至文字模式)。
等等,al和ah寄存器是哪冒出來的?其實是這樣的,對于ax、bx、cx和dx這4個寄存器來說,可以拆成高8位和低8位兩個8位寄存器來使用。al就是ax的低8位,bh就是bx的高8位,以此類推。
所以,al=0x03,ah=0x0,效果跟ax=0x0003是一樣的。
我們修改一下MBR的代碼,首先清屏,然后再打印Hello,World!來看看效果:
mov al, 0x03
mov ah, 0x00
; 也可以寫作 mov ax, 0x0003
int 0x10 ; 調用0x10號BIOS中斷,清屏
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
mov [0x0001], byte 0x0f ; 黑底白字
mov [0x0002], byte 'e'
mov [0x0003], byte 0x0f
mov [0x0004], byte 'l'
mov [0x0005], byte 0x0f
mov [0x0006], byte 'l'
mov [0x0007], byte 0x0f
mov [0x0008], byte 'o'
mov [0x0009], byte 0x0f
mov [0x000a], byte ','
mov [0x000b], byte 0x0f
mov [0x000c], byte 'W'
mov [0x000d], byte 0x70 ; 淺灰底黑字
mov [0x000e], byte 'o'
mov [0x000f], byte 0x70
mov [0x0010], byte 'r'
mov [0x0011], byte 0x70
mov [0x0012], byte 'd'
mov [0x0013], byte 0x70
mov [0x0014], byte '!'
mov [0x0015], byte 0x70
hlt
times 510-($-$$) db 0
dw 0xaa55
效果如下:
清屏后顯示
這樣看上去是不是順眼多了?
前面我們介紹過,8086CPU總是在執行CS:IP所對應的內存位置的指令,一般情況下,會按照順序一條一條執行。除非一種特殊情況——跳轉指令。
所謂「跳轉」,顧名思義,就是不要再繼續向下執行,而是跳到某一個位置開始執行。因此,跳轉指令就是要改變CS:IP的指向。
跳轉指令主要分為兩種,分別是「近跳」和「遠跳」。不過筆者認為,這兩個名字也起得不是特別恰當,其實他們跟遠近并沒有直接關系。
所謂「近跳」,我們可以理解為CS不變,IP做一個偏移,它的操作數是一個偏移量,比如說-3就表示向前跳轉3字節、5就表示向后偏移5字節。
然而在匯編語言里,我們也不好手動去計算偏移量,因此這種時候就需要用到強大的匯編器預處理功能——標簽。我們來看一個例子:
L1:
mov ax, 1
jmp L2
mov bx, 2
L2:
mov cx, 8
其中的L1:和L2:就是標簽,它也是偽指令,并不會生成對應的機器碼,而是會影響匯編器的預處理。標簽名可以隨便起,只要不跟匯編關鍵字沖突即可,后面的冒號也可以省略。
上面例程中的近跳指令是:
jmp L2
預處理時,匯編器會根據L2標簽到當前位置(跳轉指令的位置)之前的偏移量來給近跳指令添加操作數。以上面例程來說,實際的操作數正好是mov bx, 2這條指令的長度,也就是3,那么jmp L2就相當于jmp +3。
當CPU執行到近跳指令時,則會將IP寄存器與近跳指令的操作數相加,然后去執行對應位置的指令,進而達到跳轉的目的。
所謂「遠跳」,其實是給CS和IP都給一個絕對值,它的操作數是一個絕對的內存地址,而不是偏移量。例如:
jmp 0x0820:0x0000
這條指令執行完后,CS會賦值為0x0820,IP會賦值為0x0000,接著就會執行0x08200位置的指令。
這里需要強調的是,匯編語言指導的是機器指令,它不具備高等語義,因此,匯編器不會去檢查0x08200這個地址在不在你當前操作的源文件里,也不會去管那個位置到底會不會加載合法的指令,這一切都應該由程序員自行負責。
當然,使用遠跳指令時也可以使用標簽,只不過此時的標簽會使用「相對于文件頭」的偏移量。比如說:
mov ax, 0
mov bx, 1
L1:
mov cx, 2
jmp 0x0000:L1
上面例程中jmp 0x0000:L1就是遠跳指令,這時的L1就會解析為這個標簽相對于文件頭的偏移量,實際上也就是mov ax, 0和mov bx, 1的指令長度和,也就是6。那么這條指令其實應該是jmp 0x0000:0x0006。
這里再次強調重點:近跳指令不改變CS,操作數是偏移量;遠跳指令會改變CS,操作數是絕對數。這一點在8086模式下可能看上去沒那么重要,但當后面我們切換到286模式時,這一點會非常重要,所以請讀者一定要記住。
到目前為止,我們的程序都擠在軟盤的第一個扇區里,指望BIOS自動加載。不過顯然這區區512字節的空間很容易捉襟見肘,那么如何把軟盤中的其他扇區內容也加載到內存中呢?在8086模式下,BIOS中斷可以替我們搞定。
; 加載一個扇區到0x08000的位置
mov ax, 0x0800
mov es, ax
mov bx, 0 ; 軟盤中的內容會加載到es:bx的位置
mov ah, 2 ; ah=2, 使用讀盤功能
mov al, 2 ; ah表示需要讀取連續的幾個扇區(讀2個就是1KB的大小)
mov ch, 0 ; ch表示第幾柱面
mov dh, 0 ; dh表示第幾磁頭
mov cl, 2 ; cl表示第幾扇區
mov dl, 0 ; dl表示驅動器號,軟盤會在0x00~0x7F,硬盤會在0x80~0xFF
int 0x13 ; 執行0x13號中斷的2號功能(讀盤功能)
對于老式機械硬盤、軟盤來說,它們都屬于「磁盤」的一種。根據其機械結構分為柱面(Cylinder)、磁頭(Head)、扇區(Sector),一般表示為CHS,柱面和磁頭從0開始,扇區從1開始標號。
BIOS如果設置為軟盤啟動,就會加載0號驅動器的C0-H0-S1到內存的0x07c00的位置。如果設置為硬盤啟動,就會加載0x80號驅動器的C0-H0-S1到內存的0x07c00的位置。
那么現在,我們承擔MBR角色的程序,就需要再把其他數據也加載到內存中。不過這時的內存選址就由我們隨意了,并不一定要緊接著MBR加載的位置,上面例程中選擇了0x08000的位置,你也可以選擇其他位置,但要主要,不能占用BIOS預留的位置,也不能占用顯存位置。通常8086的內存布局如下:
從上表可知,0x00500到0x9fbff這638.75KB的空間都是可用的,但是由于MBR占用了其中的512B,剩下的部分我們可以自由支配。
下面我們就編寫一個程序,前512B作為MBR,加載兩個扇區(1KB)的數據到0x08000的位置,然后再跳轉至該位置,執行指令:
; C0H0S1
; 調用0x10號BIOS中斷,清屏
mov al, 0x03
mov ah, 0x00
int 0x10
; 加載一個扇區到0x08000的位置
mov ax, 0x0800
mov es, ax
mov bx, 0 ; 軟盤中的內容會加載到es:bx的位置
mov ah, 2 ; ah=2, 使用讀盤功能
mov al, 2 ; ah表示需要讀取連續的幾個扇區(讀2個就是1KB的大小)
mov ch, 0 ; ch表示第幾柱面
mov dh, 0 ; dh表示第幾磁頭
mov cl, 2 ; cl表示第幾扇區
mov dl, 0 ; dl表示驅動器號,軟盤會在0x00~0x7F,硬盤會在0x80~0xFF
int 0x13 ; 執行0x13號中斷的2號功能(讀盤功能)
jmp 0x0800:0x0000 ; 這里寫成0x0000:0x8000也OK,只是CS和IP的值會不同,但CS:IP是相同的
times 510-($-$$) db 0 ; MBR剩余部分用0填充
dw 0xaa55
; 現在已經是C0H0S2的內容了
begin:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
mov [0x0001], byte 0x0f
mov [0x0002], byte 'e'
mov [0x0003], byte 0x0f
mov [0x0004], byte 'l'
mov [0x0005], byte 0x0f
mov [0x0006], byte 'l'
mov [0x0007], byte 0x0f
mov [0x0008], byte 'o'
mov [0x0009], byte 0x0f
hlt
times 1024-($-begin) db 0 ; 補滿2個扇區
可以確認一下,此時的mbr.bin變成了1536B,當然,它現在叫「MBR」已經不太合適了,它應當是包含了MBR和內核程序的一個總包。暫時我們先忽略這個叫法的問題,稍后再來看如何將MBR和內核程序分離。
同樣,將其重命名為a.img,然后打開bochs看運行效果:
執行結果
這證明,后面扇區的內容也加載成功了,跳轉指令也完成了正確的跳轉。
另外,當我們程序有稍微的規模了的時候,大家可以考慮用單步執行命令來做調試。例如啟動后,我們先在0x7c00處打斷點,然后c執行BIOS的指令,然后按n開始跳過調用流程的單步調試(s是單純的單步調試,但是會把BIOS中斷中的指令也顯示出來,按n則不會)。大概效果如下:
調試
而在經歷一些加載數據功能后,我們還可以用x命令來查看對應內存位置,例如當執行完0x13中斷后,我可以看一下0x08000位置的內存,到底有沒有寫入數據:
內存數據
也可通過r和sreg指令查看寄存器的值,比如在跳轉指令前后,查看CS和IP的值。跳轉前:
寄存器1
寄存器2
然后執行n,完成跳轉指令后,再看一下CS和IP的值:
寄存器3
寄存器4
大家可以根據需要進行調試觀察自己的程序。
照理說,按照前面一節的方法,利用BIOS中斷加載軟盤中的數據到內存中再去執行,在8086下貌似是沒什么問題的。但這不是長久之際,8086下只有640KB不到的內存空間供我們支配,自然用當前的這種方式沒什么問題,但畢竟8086模式只是過渡,后續我們要切換到32位模式以支持4GB內存,還要切換到64位模式支持更大的內存。
雖然BIOS中斷是很方便的工具,相當于基礎系統提供了一些庫函數供我們使用,但它畢竟依賴BIOS,BIOS中提供的指令都是16位實模式(8086模式)的指令,一旦后續我們切換為i286模式、i386模式后,這些BIOS中斷就無法使用了(因為指令集不匹配)。
其實,向顯存寫入數據的這種需求,也是可以通過BIOS中斷來完成的,但筆者并沒有介紹這種方法,而是使用直接操作顯存的方式,目的也就在此,因為我們不可能一直停留在8086模式。同理,加載外存中的數據這種需求,也應當有它原始方法。
前面我們介紹過I/O,有一些是統一編址的(比如顯存),也有一些是獨立編址的,CPU會通過專用的指令,控制I/O控制器(或者也可以叫南橋芯片)來管理這些I/O設備。
I/O設備會映射成一個端口號,CPU向對應的端口號發送或讀取數據,間接通過I/O控制器來控制外圍的I/O設備。軟驅也是其中的一員,我們可以控制幾個軟驅控制器(例如DOR、FDC)來讀取和寫入軟盤中的內容。不過軟驅的控制方法比較麻煩(只支持CHS模式,不支持LBA模式。LBA模式在后面章節詳細介紹),又因為3.5英寸軟盤只有1440KB的限制,遲早不夠使,因此,我們姑且就不去詳細研究軟驅的控制方法了。接下來,我們要將我們的模擬器環境,改為用硬盤啟動。
配置硬盤,需要修改bochsrc的內容,我們將軟盤啟動相關配置注釋掉或刪除掉,改為以下內容:
# boot: floppy # 設置軟盤啟動
# floppy_bootsig_check: disabled=0 # 打開自檢
# floppya: type=1_44, 1_44="a.img", status=inserted, write_protected=0 # 使用1.44MB的3.5英寸軟盤,取鏡像為a.img,開機默認已插入軟驅,不開啟寫保護
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14 # 主盤端口映射為1f0,從盤映射為3f0,中斷號設置為14(雖然這幾個參數都可以定制化,但這個參數是業界標準的,不建議更改)
ata0-master: type=disk, mode=flat, path=a.img, cylinders=1, heads=1, spt=1 # 主盤位置加載一塊規格為C1H1S1的硬盤,鏡像使用a.img
boot: disk # 設置為硬盤啟動
這里需要注意一下,硬盤的規格我們暫時設置的是1柱面1磁頭1扇區,也就是只有512字節的硬盤,那么對于a.img來說,超過512B的部分是不會加載進去的。(暫時這樣設置一下,后面肯定會改的。)
首先先來測試一下MBR能否正常加載,所有我們把之前MBR中寫的那些跳轉語句、還有512B后面的部分都先刪除,打印幾個文字來驗一驗效果:
; 調用0x10號BIOS中斷,清屏
mov al, 0x03
mov ah, 0x00
int 0x10
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
mov [0x0001], byte 0x0f
mov [0x0002], byte 'e'
mov [0x0003], byte 0x0f
mov [0x0004], byte 'l'
mov [0x0005], byte 0x0f
mov [0x0006], byte 'l'
mov [0x0007], byte 0x0f
mov [0x0008], byte 'o'
mov [0x0009], byte 0x0f
hlt
times 510-($-$$) db 0 ; MBR剩余部分用0填充
dw 0xaa55
將其編譯為mbr.bin,確認一下它的大小是512字節:
mbr.bin
然后把它復制為a.img,再啟動一下看看效果:
硬盤啟動后
能看到輸出,說明我們已經成功切換成硬盤啟動了。那么接下來就是如何加載后面扇區的數據的問題了。
前面我們用了CHS方式來編號硬盤,但除了CHS以外,還有另外一種方式,叫做LBA,也就是Logical Block Address。這種方式下,硬盤會直接按照連續的扇區進行編號,對磁頭和柱面不再感知。
LBA28是一種比較原始的方式,28表示用28位編號,也就是0x0000000~0xFFFFFFF的扇區號,注意,0號是預留位,真正的扇區是從1號開始的。
用于控制硬盤的設備會有對應的端口號,在前面我們bochsrc中也有對應的配置,比如當前使用了默認值,也就是0x01f0,從這個端口向后的若干端口都是用來操作硬盤的。因此,我們要按照一定的順序,向對應的端口中寫入數據,來指導硬盤控制器讀取硬盤數據。
首先要配置的是需要讀取的端口數,這個數據要寫入0x01f2端口中:
; 設置讀取扇區的數量
mov dx, 0x01f2
mov al, 2 ; 讀取連續的幾個扇區,每讀取一個al就會減1
out dx, al
然后我們來配置起始扇區號。1號扇區就是MBR,已經加載進來了,所以我們從第2號扇區開始加載。雖然只是一個簡單的2號,但其實LBA28模式下扇區號是有28位的,因此我們要拆分成4次,分別寫入不同的端口中。0x01f3需要傳入扇區號的0~7位,0x01f4需要傳入扇區號的8~15位,0x01f5需要傳入扇區號的16~23位,0x01f5則拆分為3部分,低4位是扇區號的24~27位,第4位表示主從盤,高3位表示扇區編號的模式。
這部分的代碼如下,筆者已經加入了詳細的注釋,請讀者仔細閱讀:
; 設置起始扇區號,28位需要拆開
mov dx, 0x01f3
mov al, 0x02 ; 從第2個扇區開始讀(1起始,0留空),扇區號0~7位
out dx, al
mov dx, 0x01f4 ; 扇區號8~15位
mov al, 0
out dx, al
mov dx, 0x01f5 ; 扇區號16~23位
mov al, 0
out dx, al
mov dx, 0x01f6
mov al, 111_0_0000b ; 低4位是扇區號24~27位,第4位是主從盤(0主1從),高3位表示磁盤模式(111表示LBA模式)
接下來要配置操作命令,我們要做「讀盤」操作,對應的命令號是0x20,它要寫入0x01f7端口:
; 配置命令
mov dx, 0x01f7
mov al, 0x20 ; 0x20命令表示讀盤
out dx, al
一切就緒之后,控制器就會開始讀盤了,但這需要一定的時間,所以此時程序要等待驅動器工作完成。0x01f7端口如果使用in命令,讀取到的是硬盤控制器的狀態數據,其中第7位表示是否忙碌,第3位表示是否就緒。那么也就是說,當第7位是0且第3位是1的話,說明驅動器已經完成,否則就要持續等待:
wait_finish:
; 檢測狀態,是否讀取完畢
mov dx, 0x01f7
in al, dx ; 通過該端口讀取狀態數據
and al, 1000_1000b ; 保留第7位和第3位
cmp al, 0000_1000b ; 要檢測第7位為0(表示不在忙碌狀態)和第3位是否是1(表示已經讀取完畢)
jne wait_finish ; 如果不滿足則循環等待
當驅動器就緒后,我們就可以通過0x01f0端口來加載數據到內存了。這個端口是個16位端口,因此每次可以讀2字節。這里我們用一個循環語句來完成,循環語句的循環次數要寫在cx中,每次循環時cx會自動減1,直到cx為0則跳出循環。
所以,如果我們需要加載2個扇區的數據,那么就是1024字節的內容,而循環次數就是512,所以把這個數配到cx中:
mov cx, 512 ; 一共要讀的字節除以2(表示次數,因為每次會讀2字節所以要除以2)
還是按照一開始的規劃,我們把屏幕打印的部分放到第二扇區,然后把它加載到0x08000的內存位置:
mov dx, 0x01f0
mov ax, 0x0800
mov ds, ax
xor bx, bx ; [ds:bx]=0x08000
read:
in ax, dx ; 16位端口,所以要用16位寄存器
mov [bx], ax
add bx, 2 ; 因為ax是16位,所以一次會寫2字節
loop read
最后通過跳轉指令跳轉過去,查看是否加載成功。下面給出完整代碼:
; C0H0S1
; 調用0x10號BIOS中斷,清屏
mov al, 0x03
mov ah, 0x00
int 0x10
; LBA28模式,邏輯扇區號28位,從0x0000000到0xFFFFFFF
; 設置讀取扇區的數量
mov dx, 0x01f2
mov al, 2 ; 讀取連續的幾個扇區,每讀取一個al就會減1
out dx, al
; 設置起始扇區號,28位需要拆開
mov dx, 0x01f3
mov al, 0x02 ; 從第2個扇區開始讀(1起始,0留空),扇區號0~7位
out dx, al
mov dx, 0x01f4 ; 扇區號8~15位
mov al, 0
out dx, al
mov dx, 0x01f5 ; 扇區號16~23位
mov al, 0
out dx, al
mov dx, 0x01f6
mov al, 111_0_0000b ; 低4位是扇區號24~27位,第4位是主從盤(0主1從),高3位表示磁盤模式(111表示LBA)
; 配置命令
mov dx, 0x01f7
mov al, 0x20 ; 0x20命令表示讀盤
out dx, al
wait_finish:
; 檢測狀態,是否讀取完畢
mov dx, 0x01f7
in al, dx ; 通過該端口讀取狀態數據
and al, 1000_1000b ; 保留第7位和第3位
cmp al, 0000_1000b ; 要檢測第7位為0(表示不在忙碌狀態)和第3位是否是1(表示已經讀取完畢)
jne wait_finish ; 如果不滿足則循環等待
; 從端口加載數據到內存
mov cx, 512 ; 一共要讀的字節除以2(表示次數,因為每次會讀2字節所以要除以2)
mov dx, 0x01f0
mov ax, 0x0800
mov ds, ax
xor bx, bx ; [ds:bx]=0x08000
read:
in ax, dx ; 16位端口,所以要用16位寄存器
mov [bx], ax
add bx, 2 ; 因為ax是16位,所以一次會寫2字節
loop read
jmp 0x0800:0x0000 ; 這里寫成0x0000:0x8000也OK,只是CS和IP的值會不同,但CS:IP是相同的
times 510-($-$$) db 0 ; MBR剩余部分用0填充
dw 0xaa55
; 現在已經是C0H0S2的內容了
begin:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
mov [0x0001], byte 0x0f
mov [0x0002], byte 'e'
mov [0x0003], byte 0x0f
mov [0x0004], byte 'l'
mov [0x0005], byte 0x0f
mov [0x0006], byte 'l'
mov [0x0007], byte 0x0f
mov [0x0008], byte 'o'
mov [0x0009], byte 0x0f
hlt
times 1024-($-begin) db 0 ; 補滿2個扇區
注意,由于我們已經把扇區擴展到了3個,因此bochsrc里面 也需要修改一下硬盤的規模:
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14 # 主盤端口映射為1f0,從盤映射為3f0,中斷號設置為14(雖然這幾個參數都可以定制化,但這個參數是業界標準的,不建議更改)
ata0-master: type=disk, mode=flat, path=a.img, cylinders=1, heads=1, spt=3 # 主盤位置加載一塊規格為C1H1S3的硬盤,鏡像使用a.img
boot: disk # 設置為硬盤啟動
最后通過匯編生成mbr.bin,復制為a.img,再啟動bochs就可以看到執行效果:
執行效果
此時也可以通過調試指令來驗證0x8000的內存中確實加載了對應的指令:
指令
由此殊途同歸,我們沒有使用BIOS中斷,也同樣完成了硬盤加載的工作。
==============================