輕松的開發(fā)一個操作系統(tǒng)(指導手冊)
標簽: 翻譯家 編程 操作系統(tǒng)
chapter 1
前言
我們都使用過操作系統(tǒng),又或者寫過某個系統(tǒng)上運行著的程序;但操作系統(tǒng)到底是來做什么的?我所看到的工作多少是硬件完成的又有多少是軟件完成的?電腦實際上是如何運作的?
我已故的老師,一位曾活躍在蘭開斯特大學的Doug教授曾在我因陷入惱人的程序問題而苦不堪言時提醒過我,以前他還沒能開始任何研究的時候,他用scratch寫了自己的操作系統(tǒng).所以看起來今天我們習以為常的神奇的機器實際上運行在所有的軟件層下,他們互相聯(lián)系互相依賴.
?
至此,專注于廣泛使用的x86架構CPU,拋開所有電腦上的軟件,跟隨著Doug教授的步伐,逐步學習:
?
- 電腦是如何啟動的
- 如何在不存在操作系統(tǒng)的情況下編寫低級程序
- 如何設置CPU來使用它的拓展功能
- 如何用高級語言編寫引導代碼,這樣就能真的開始以自己的系統(tǒng)為目標去編程
- 如何創(chuàng)造基礎操作系統(tǒng)服務,比如設備驅動,文件系統(tǒng),多任務處理
chapter 2
電腦架構和啟動進程
2.1 啟動進程
打起精神!準備離港了!
當我們重啟電腦時,電腦一定會乖乖重啟,最開始會以沒有操作系統(tǒng)這一概念開始初始化.然而還是還是必須以某種形式-無論從什么東西里-從一些插在電腦上的永久存儲設備中加載操作系統(tǒng)(閃盤,硬盤,或者U盤)
馬上我們就會發(fā)現(xiàn),你的電腦的預操作系統(tǒng)環(huán)境(pre-OS environment)提供不了豐富的服務:在這個預處理的階段一個簡單的文件系統(tǒng)會是很奢華的(比如從磁盤中讀寫邏輯文件),我們也不擁有這些功能.好在我們擁有基本輸入輸出軟件(BIOS),一系列軟件程序在電腦啟動的一開始就會從芯片載入到內存并完成初始化.BIOS提供自動檢測以及對你電腦的基本設備的基本控制,比如屏幕,鍵盤,硬盤.
在BIOS完成一些對你的硬件的低級別測試后--特別是你安裝的內存是否正常的工作--它就會選擇你的一個設備啟動操作系統(tǒng).但是,之前說過,BIOS不能簡單的從磁盤中加載一個代表著操作系統(tǒng)的文件,因為BIOS沒有文件系統(tǒng)的概念.BIOS一定要從磁盤中的特定物理位置中讀取特定扇區(qū)的數(shù)據(jù)(通常大小是512字節(jié)),比如2柱面(Cylinder),3磁頭(Head),5扇區(qū)(Sector)(詳細的磁盤地址之后會講解).
當然了,操作系統(tǒng)放在最容易被BIOS找到的地址,磁盤的第一個扇區(qū)(比如0柱面,0磁頭,0扇區(qū)),這就是所謂的引導扇區(qū).因為某些磁盤可能不包含操作系統(tǒng)(僅僅是作為存儲設備),所以BIOS可以決定特定磁盤的引導扇區(qū)是可以執(zhí)行的引導代碼還是僅僅用于數(shù)據(jù),這一點很重要.要注意CPU是不會區(qū)分數(shù)據(jù)和代碼的,兩者都可以被解釋成CPU指令,而代碼只是一些簡單的指令被程序員編寫成的有用的算法.
同樣的,BIOS用了一種樸實無華的方式來檢測引導扇區(qū),通過查看目標引導扇區(qū)的最后2個字節(jié)是否是魔法數(shù)字0xaa55來判斷.于是BIOS會循環(huán)遍歷每個存儲設備(軟盤,硬盤,CD),將引導扇區(qū)讀取至內存,指引CPU去執(zhí)行第一個發(fā)現(xiàn)最后兩位是魔法數(shù)字的引導扇區(qū).
這就會我們控制計算機的地方.
2.2 BIOS,啟動塊,魔法數(shù)字
e9 fd ff 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
*
00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa
圖2.1 一個機器碼引導扇區(qū),每一個字節(jié)由16進制表示
注意到,在圖2.1中,有3個重要特性:
- 最開始的三個字節(jié),用十六進制表示的
0xe9,oxfd和oxff,實際上是機器碼指令,由CPU制造商定義,該指令表示的是一個無限跳轉 - 最后兩個字節(jié),
0x55和0xaa組成了魔法數(shù)字,它告訴了BIOS這確實是一個引導塊,而不是恰好在硬盤引導扇區(qū)上的數(shù)據(jù). - 文件由0填充("*"為簡潔起見省略了的0),基本上是為了把BIOS的魔法數(shù)字擠到磁盤扇區(qū)的512個的字節(jié)的最后.
要注意一下字節(jié)順序(endianness).你可能會覺得奇怪,為什么之前說的BIOS魔法數(shù)字是0xaa55而圖2.1中的最后兩個字節(jié)是連續(xù)的0x55以及0xaa.這是因為x86架構以小端序來處理多字節(jié)值.這意味著較低的有效字節(jié)處理(表示)較高的有效字節(jié),跟我們熟悉的數(shù)字系統(tǒng)相反—如果我們的系統(tǒng)也反過來的話我可能只有00005元錢在我的銀行賬戶里了,那我就只能退休了,或許會捐幾毛給曾經的百萬富翁.
通過允許我們定義數(shù)據(jù)類型,編譯器已經匯編器可以隱藏許多由字節(jié)順序帶來的問題,比如說,一個16進制的值會以正確的字節(jié)順序被自動序列化為機器碼.然而,特別是找bug的時候,了解具體的單個字節(jié)是如何存儲在內存或者存儲設備中是很有效的,所以字節(jié)順序仍是非常重要的.
記住,是我們在為電腦編程,電腦僅僅是盲目的遵循著我們的命令,獲取指令然后執(zhí)行直到它關機;所以我們要確保它執(zhí)行的是我們編寫的代碼而不是內存中某處的隨機數(shù)據(jù).在目前的底層(At this low level)上,我們對電腦有無窮大的權利以及職責,所以需要我們學習去控制它.
2.3 CPU仿真
有一種不用不停的重啟你的電腦來測試底層程序的方法,就是使用CPU模擬器比如Bochs或者QEmu.與虛擬主機不同(比如VMware,VirtualBox),后者試圖通過直接在CPU上運行客戶指令來優(yōu)化性能,從而優(yōu)化托管操作系統(tǒng)的使用,模擬器包含了可表現(xiàn)的像指定CPU架構的程序,使用變量代表CPU寄存器,使用高級控制結構來模擬低級跳躍等等,所以它會慢一點但通常會更適合用來開發(fā)或者debug一個系統(tǒng).
請注意,為了讓模擬器跑起來,你需要以磁盤鏡像文件的形式提供一些代碼.一個鏡像文件就是原始數(shù)據(jù)(換句話說機器代碼和數(shù)據(jù))否則就是寫入到硬盤,軟盤,CDROM等介質中.事實上,一些模擬器會成功的從下載到的或者安裝光盤中的鏡像文件啟動一個真的操作系統(tǒng)--雖然虛擬主機更適合這種情況.
模擬器將低級顯示設備指令轉換為桌面窗口上的像素,所以你能確切的看到真實顯示器上呈現(xiàn)的內容.
一般來說,對于本文中出現(xiàn)的練習,所有在模擬器中能正常運行的機器碼都能在真正的CPU架構—顯然會更快--上運行.
Chapter 3
引導扇區(qū)編程(16位實模式)
3.1 引導扇區(qū)回顧
就算有示例代碼作為參照,你也會毫無疑問的在二進制編輯器里編寫機器代碼而掙扎.你必須記住,或者不停的翻閱,從繁多的機器代碼中找到一個來使CPU執(zhí)行某個功能.幸運的是,你不是一個人在作戰(zhàn),匯編器可以用來將人性化指令轉換成特定CPU能讀懂的機器代碼.
本章中我們會探索更復雜的引導扇區(qū)程序來使我們更加熟悉匯編,以及將要運行我們程序的荒蕪的預處理操作系統(tǒng)環(huán)境.
;
; 簡單的無限循環(huán)引導扇區(qū)程序
;
loop : ; NASM的語法,不必太深究.
; 大致就是循環(huán)了510次,每次定義一個0
jmp loop ; 最后2個字節(jié)定義魔法數(shù)字0xaa55
; 這樣CPU就知道了這段代碼是引導扇區(qū)代碼
;
;
times 510 -( $ - $$ ) db 0 ;
;
;
;
;
dw 0xaa55 ;
;
具體的執(zhí)行方法參考此處:https://zhuanlan.zhihu.com/p/51725653
3.2 16位實模式(16-bit Real Mode)
CPU制造商必須不遺余力的保持他們的所有CPU(換句話說,它們的特定指令集)都能兼容早期的CPU,保證古老的軟件,特別是古老的操作系統(tǒng),能在他們的最新的CPU上正常運行.
由因特爾實現(xiàn)的可兼容CPU的解決方案是去模擬CPU家族中最老的成員:intel8086,支持16位指令,沒有內存保護的概念.內存保護是現(xiàn)代操作系統(tǒng)能穩(wěn)定運行的關鍵,因為它允許操作系統(tǒng)拒絕用戶進程有意或無意的訪問所謂的核心內存(系統(tǒng)在使用中的),如果讓這樣一條進程繞過安全機制可能會讓整個系統(tǒng)崩塌.
所以,為了向后兼容,CPU以16位實模式啟動是很重要的,啟動后,現(xiàn)代操作系統(tǒng)會顯式的切換到32位(或64位)保護模式,而老系統(tǒng)則會沉浸在渾然不知身處現(xiàn)代CPU的幸福中而保持16位實模式繼續(xù)運行,之后我們會詳解系統(tǒng)從16位實模式切換到32位保護模式這一重要步驟.
通常來說,我門口中的16位CPU指的是它一次最多只能處理最大16位的指令.比如,一個16位的CPU有特定的指令在一個機器周期中來執(zhí)行相加2個16位的數(shù),如果一個進程需要將2個32位數(shù)相加,那么使用16位加法的CPU將耗費2個周期.
因為所有操作系統(tǒng)的起點都是16位實模式,所以我們將從此開始探索,之后會延伸到32位保護模式以及了解它所帶來的好處.
3.3 喂,有人在嗎?
現(xiàn)在我們要來寫一個簡單的看上去是引導扇區(qū)程序的代碼.它會在屏幕上打印一些數(shù)據(jù),借此來學習CPU工作的基本原理,以及如何通過BIOS來掌控屏幕設備.
首先,想想我們要做的事.我們想在屏幕上打印一些文字但是又不知道如何與屏幕交流,因為這個世界上有這么多不同的顯示器每個又有不同的接口.這就是我們需要BIOS的原因,因為BIOS在開機時就會做硬件檢查,并且顯然已經在屏幕上打印了許多檢測結果.就是它了,可以幫我們.
所以,第二步,我們想讓BIOS替我們打印一些文字,但是要如何讓BIOS聽話呢?能寫php的代碼echo一下嗎—想得美.我們能確認的事,無論如何在內存的某處一定會存在BIOS的機器碼可以在屏幕上打印文字.事實是雖然我們可能能在內存中找到BIOS的代碼然后執(zhí)行它,但這樣做是得不償失的,因為不同機器上的BIOS程序的不同而引發(fā)錯誤.
我們能用到的電腦的基本機制:中斷.
3.3.1 中斷
中斷是一種允許CPU臨時停下手中的任務轉而執(zhí)行優(yōu)先級更高的指令,結束后再回到原來任務的機制.中斷可由軟件指令(比如 int 0x10)或者高優(yōu)先級的硬件動作(比如從網絡中接受數(shù)據(jù)).
每種中斷用中斷向量中的唯一索引表示,中斷向量是由BIOS從內存的開始處(換句話說,物理地址0x0)進行初始設置,包含指向中斷服務程序(interrupt service routines,ISRs)的地址指針.ISR只是一系列的機器指令,就像我們的引導扇區(qū)代碼一樣,用于處理一個特定的中斷(比如從磁盤中或者網卡中讀取新數(shù)據(jù)).
所以簡而言之,BIOS在對電腦的中斷向量中添加了一些自己的中斷服務,比如中斷0x10會導致屏幕相關的中斷服務被觸發(fā);中斷0x13會觸發(fā)磁盤相關的IO中斷服務.
However, it would be wasteful to allocate an interrupt per BIOS routine, so BIOS multiplexes the ISRs by what we could imagine as a big switch statement, based usually on the value set in one of the CPUs general purpose registers, ax, prior to raising the interrupt.(大意是BIOS執(zhí)行中斷的步驟,可以想象成一個大的switch語句,通過ax寄存器中的值來決定執(zhí)行哪個中斷)
3.3.2 CPU寄存器
就像我們在高級語言中使用變量一樣,如果能在特定程序中存儲臨時數(shù)據(jù)那就好了.所有的x86CPU都有4個通用寄存器,ax,bx,cx,dx就是用來存儲臨時數(shù)據(jù)的.同時,這些寄存器每個都能存一個字(2字節(jié),16位)的數(shù)據(jù),能被CPU以相對于從內存中拿數(shù)據(jù)而可忽視的延遲來讀寫.在匯編語言中,最常見的操作就是在寄存器中移動(更準確地說,復制)數(shù)據(jù):
mov ax , 1234 ; 在ax中保存十進制數(shù)1234
mov cx , 0 x234 ; 在cx中保存16進制數(shù)
mov dx , ’t ’ ; 在dx中保存ASCII碼't'
mov bx , ax ; 將ax中的數(shù)據(jù)復制到bx中,現(xiàn)在bx=1234
注意到目的地是mov操作的第一個值而不是第二個,但并不是一定的,以編譯器的語法為準.
有時候操作單個字節(jié)更加方便,所以可以獨立的操作寄存器的高位以及低位:
mov ax , 0 ; ax -> 0x0000 , or in binary 0000000000000000
mov ah , 0 x56 ; ax -> 0 x5600
mov al , 0 x23 ; ax -> 0 x5623
mov ah , 0 x16 ; ax -> 0 x1623
3.3.3 一起來吧
回想起我們最初的目的吧,讓BIOS為我們在屏幕上打印字符.我們可以通過設定ax為某個BIOS定義好的值來觸發(fā)特定的BIOS程序來引發(fā)特定的中斷.那個特定的程序就是BIOS電傳打字機程序,能在屏幕上打印單個字符并且移動光標,為下個字符做準備.可以去查找一張展示所有中斷的BIOS程序并且如何通過寄存器來使用的清單.現(xiàn)在我們需要的是0x10中斷,同時設置ah為0x0e(特指電傳打字機模式),然后再ah中設置需要打印的ASCII碼.
;
; 通過BIOS程序打印字符的簡單引導扇區(qū)代碼
;
mov ah, 0x0e ;
mov al, 'H'
int 0x10
mov al, 'e'
int 0x10
mov al, 'l'
int 0x10
int 0x10 ; l還在al,記得嗎?
mov al, 'o'
int 0x11
jmp $ ; 跳轉當前地址=無限循環(huán)
; 填充0以及魔法數(shù)字
times 510 - ($-$$) db 0
dw 0xaa55
3.4 Hello,World!
3.4.1 內存,地址以及標簽
之前我們看到了CPU是如何從內存中獲取并執(zhí)行指令,以及BIOS是如何將512字節(jié)的引導扇區(qū)加載到內存然后完成初始化,告訴CPU跳轉到我們代碼的開頭,執(zhí)行我們的第一個指令,然后下一個指令,如此反復.
所以我們的引導扇區(qū)的代碼存在于內存的某個地方;哪里呢?我們可以把主要內存想象成一長串的字節(jié)序列,可以被一個地址單獨訪問(比如說通過索引),所以當我們要尋找內存中的第54個字節(jié)時,這個54就是對應的地址,用它的十六進制數(shù)來表示更為方便:0x36
所以我們引導扇區(qū)代碼的開始,第一個機器碼就在內存中的某處,是BIOS將它放在那的.你可能會覺得,BIOS把這段代碼放在內存的開頭 0x0.但并不是這么簡單,因為我們要知道,BIOS在載入我們的代碼之前就已經在做初始化的工作了,事實上,它還一直在為硬件中斷提供服務,比如時鐘,驅動等.所以這些BIOS程序(比如 中斷服務,屏幕打印等)在他們使用的時候也必須存在于內存中的某個地址,必須被保存起來(不能被覆蓋).同樣的,通過之前的學習,我們了解到(你有沒有仔細去了解中斷鴨)中斷向量位于內存的開始處,如果BIOS將我們的代碼加載在開始處(就會覆蓋掉中斷向量),我們的代碼就翻身做了主人,一旦下一個中斷觸發(fā)時,電腦就很有可能崩潰重啟:中斷號與中斷服務之間的映射實際上被切斷了.
事實是,BIOS會將引導扇區(qū)加載在0x7c00的地址上,一個絕對不會被重要程序占用的地址.下圖給出了當引導扇區(qū)被加載時一臺電腦典型的底層內存布局.所以雖然我們有能力指揮CPU將數(shù)據(jù)寫進任意的內存地址,但這種肆意妄為可能會引起意外,因為一些內存是被其他程序使用的.比如時間中斷或者驅動設備.

3.4.2 'X' 標記地址
現(xiàn)在我們來玩一個用來演示內存引用的叫做"找到那個字節(jié)"的游戲,會讓你接觸到匯編代碼中標簽的使用,以及了解BIOS在何處載入代碼.我們會用匯編寫一個保存一個字節(jié)數(shù)據(jù),然后打印出來的程序.要做這個需要算出它的絕對內存地址,然后將數(shù)據(jù)放入al寄存器中讓BIOS打印,像上次那樣的練習一樣.
;
; 演示地址的簡易代碼
;
mov ah,0x0e;
; 第一次嘗試
mov al , the_secret
int 0x10 ;
; 第二次嘗試
mov al , [ the_secret ]
int 0x10 ;
; 第三次嘗試
mov bx , the_secret
add bx , 0x7c00
mov al , [bx]
int 0x10 ;
; 第四次嘗試
mov al , [0x7c1d]
int 0x10 ;
jmp $ ;
the_secret :
db "X "
; 填充
times 510 -( $ - $$ ) db 0
dw 0xaa55
首選,我們在程序中定義了一些數(shù)據(jù),并且給了它一個標簽(the_secret).可以將標簽放在程序的任何地方(本代碼中則放在了填充前),它的作用是為獲取特定的指令或者數(shù)據(jù)的偏移地址帶來了方便.
b40e b01d cd10 a01d 00cd 10bb 1d00 81c3
007c 8a07 cd10 a01d 7ccd 10eb fe58 2000
*
0000 0000 0000 0000 0000 0000 0000 55aa
圖3.5 之前程序的機器碼
看一下圖3.5的機器碼,能看到我們的'X'用16進制的ASCII碼表示為0x58,偏移位置在第29個(0x1d)個字節(jié)處,就在引導扇區(qū)填充0的代碼之前.
可用sublime等能查看二進制文件的編輯器打開編譯后的bin文件來查看機器碼,原文中的機器碼與本文中的并不相同,可能是編譯器版本問題,本文使用的nasm為2.13.03版本.原文中的機器碼為:
b4 0 e b0 1e cd 10 a0 1e 00 cd 10 bb 1e 00 81 c3 00 7 c 8a 07 cd 10 a0 1e 7c cd 10 e9 fd ff 58 00'X'的偏移值是30,則代碼也要改成[0x7c1e]
執(zhí)行上面的代碼,你會發(fā)現(xiàn)只有后面2次成功打印出了'X'.
第一次嘗試的問題是代碼試圖直接將載入的偏移地址進行打印,而實際上我們想要打印的是在這個偏移地址上的字符而不是這個偏移地址本身.在下次嘗試中,將地址放在方括號中才是我們想要CPU做的事-存儲該地址對應的內容.
那么為什么第二次嘗試也失敗了呢?問題是,CPU講偏移量理解成以代碼開始的地方的偏移量,而不是相對于我們載入代碼的偏移量,也就是說CPU會從中斷向量的地方開始計算偏移量.在第三次嘗試中,我們在the_secret的偏移量上加上了之前BIOS載入我們引導扇區(qū)代碼的地址0x7c00,使用add指令.也就是bx = bx + 0x7c00,這樣就能計算得到'X'的地址并且將其對應的內容保存在寄存器al中,為BIOS打印而做準備,通過mov al,[bx]指令
可能有點拗口,其實就是標簽的偏移地址是以這段代碼開始計算,而CPU理解的偏移地址是從
0x00開始計算.偏移地址永遠是個相對而不是絕對的值.
在第四次嘗試中我們耍了個小聰明,直接計算出'X'在引導扇區(qū)代碼中的位置.通過檢查之前的機器碼發(fā)現(xiàn)'X'在0x7c1d的位置,于是直接將該地址的值讀取出來.最后一個嘗試告訴了我們標簽的重要性,如果沒有標簽我們得從編譯后的二進制機器碼文件中去數(shù)出需要的偏移地址,并且在每次更新代碼后都要重新數(shù)一次,因為偏移地址也會發(fā)生改變.
現(xiàn)在能確認BIOS確實是從0x7c00處開始載入我們的引導扇區(qū)代碼,也看到了匯編代碼的標簽是如何尋址的.
每次都要計算標簽在內存中的偏移值是很不方便的,所以當你用了下面這條指令后匯編器將在匯編時根據(jù)你的設定修改標簽偏移值的參考值.下面這條指令將明確的告訴CPU在何處載入代碼.
[org 0x7c00]
CPU會默認在
0x00處載入代碼,標簽偏移值也會以此為參考,指令org將修改載入地址,同時也修改了標簽的參考地址.
問題1
當在之前的引導扇區(qū)代碼中加上org指令后打印的結果如何?最好解釋清楚原因.
當指定在
0x7c00處載入代碼后,CPU將不再0x00處開始載入代碼.標簽偏移地址也從0x7c00開始計算.所以執(zhí)行add bx,0c7c00的第二次嘗試能成功的打印出,反之加上了地址的第三次則會打印失敗.使用了絕對地址的第四次嘗試也是成功的,當然前提是你得看看編譯后的機器碼文件'X'的位置變了沒有.
3.4.3 定義字符串
假設你想在屏幕上的某處打印一條預定義的消息(比如"Booting OS");你講如何在匯編程序中定義這條字符串呢?要知道電腦是沒有字符串的概念的,對于電腦來說只是內存某處的一連串數(shù)據(jù)單位(比如字節(jié),字符等)
匯編中我們可以這樣定義字符串:
my_string:
db 'Booting OS'
我們已經見過db了,它的含義是"聲明一個字節(jié)數(shù)據(jù)(“declare byte(s) of data)",匯編器會將它聲明的數(shù)據(jù)直接寫入二進制輸出文件中(進一步說,不會當做一條預處理指令來解釋).因為我們將字符串放在引號中,所以匯編器知道它應該把每個字符轉成ASCII字節(jié)代碼.注意到,我們經常使用標簽(比如my_string)來標記數(shù)據(jù)的開始,否則沒有好的辦法在代碼中找到之前定義的字符串.
有一件和字符串在哪里一樣重要的被忽視的事情就是了解字符串的長度.因為是我們來編寫處理字符串的所有代碼,對于字符串長度通過一個一致的策略是很重要的.最方便的是定義字符串為空終結(null-terminating),意味著總是要定義字符串的最后一個字節(jié)為0比如:
my_string :
db ’ Booting OS’,0
以后遍歷字符串時,或許是打印字符串,能很容易的決定何時結束遍歷.
3.4.4 棧的使用
在計算機底層的話題中,我們經常聽到人們談論棧,好像是很高深的東西一樣.然而棧只是一個很簡單的解決方案:CPU用來存儲例程局部變量的寄存器是有限的,但是通常需要用到的臨時存儲量比擁有的多很多;現(xiàn)在,顯然我們能使用主要內存,但是當讀寫時指定特定的內存地址是很麻煩的,特別是當我們不關心數(shù)據(jù)在何處存儲,只關心能方便的回收時.同時,以后會看到棧對于函數(shù)調用中的傳參是很有用的.
于是,CPU提供了2個指令:push以及pop允許我們分別從棧的頂部存儲以及回收數(shù)據(jù),而不用擔心數(shù)據(jù)存儲在哪.注意,我們不能在棧中push,pop單個字節(jié)數(shù)據(jù),因為在16位實模式中,棧只能16位工作.
棧由2個特殊CPU寄存器實現(xiàn),bp以及sp,她們分別維護著棧的底部(棧底)以及棧的頭部(棧頂).因為棧的大小會隨著我們插入數(shù)據(jù)而擴張,所以通常將棧底放在離內存中重要代碼很遠的地方(比如BIOS代碼或者我們的代碼)來避免棧太大而覆蓋代碼的風險.有一個很容易讓人誤會的事情就是棧其實是從基址指針向下增長的,所以當我們執(zhí)行push時,數(shù)據(jù)實際上存儲在相對dp更低的地址--而不是更高的地址—而sp則是隨著數(shù)據(jù)的大小而減小.
其實就是棧底是不變的,而棧頂會隨著棧變大而變小,確實有點奇怪.
下圖程序中,字符'A','B','C'在ASCII碼中是用
0x21,0x22,0x23來表示,對應二進制00100001(一個字符占用8位),但是push,pop又要求16位數(shù)據(jù),所以為了補充到16位,匯編器會在數(shù)據(jù)上附上0x00同時注意匯編是在高位補上
0x00,可以試著將程序修改成push 'AG',push 'BG'然后修改bl成bh,來體驗內存高低位的存儲.
;
; 演示棧的簡單程序
;
mov ah,0x0e
mov bp,0x8000 ; 將棧底設置在離BIOS遠一點的地方
mov sp,bp ; 這樣就不會復寫我們的程序
push 'A' ; push一些數(shù)據(jù).,準備被我們回收
push 'B' ; 注意它們是以16位數(shù)據(jù)的形式push的
push 'C' ; 所以會被匯編器補上0x00
;
pop bx ; 只能pop 16位數(shù)據(jù),
mov al,bl ; 所以pop數(shù)據(jù)到bx 之后復制bl到al (8位數(shù)據(jù))
int 0x10 ;
pop bx ;
mov al,bl
int 0x10 ;
mov al,[0x7ffe] ; 為了證明棧區(qū)是向下增長的,
; 在此地址獲取數(shù)據(jù): 0x8000 - 0x2 ( i.e. 16位 )
int 0x10 ;
jmp $ ;
;
times 510-($-$$) db 0
dw 0xaa55
圖 3.6 通過push以及pop操作棧
問題2
圖3.6的代碼會按什么順序打印字符?'C'的絕對內存地址會在哪?通過修改代碼來證實你的猜想會很棒,但是要解釋清楚為什么是這個答案.
3.4.5 控制指令
沒有例如if..then..elseif..else,for和while這樣的基本控制語句來編程是很蛋疼的,這些指令允許程序中分支的存在并且構成了例程的基礎.
在編譯后,這些高級控制指令減少了簡單的跳轉聲明.實際上之前就已經見過循環(huán)的示例了:
some_label :
jmp some_label ; 跳轉到標簽的地址
或者可以這樣來達到同樣的效果:
jmp $ ; 跳轉到當前指令
這個指令讓我們能夠無條件跳轉(一定會跳);實際上我們最需要的是在某種條件下的跳轉(比如循環(huán)直到10次等)
條件跳轉通過前置一個比較指令來實現(xiàn),然后執(zhí)行一個特定的跳轉指令.
cmp ax , 4 ; 比較ax與4
je then_block ; 相等的話跳轉到then_block標簽
mov bx , 45 ; 否則執(zhí)行此代碼
jmp the_end ; 重點:跳過中間不需要的代碼
; 這樣就不會中間的代碼了
then_block :
mov bx , 23
the_end :
在C或者Java中,實現(xiàn)代碼類似:
if( ax == 4) {
bx = 23;
} else {
bx = 45;
}
從匯編示例中可以看到冥冥之中cmp指令與je指令執(zhí)行的代碼有某種聯(lián)系.這是一個CPU特殊標志寄存器捕獲cmp指令結果的示例,這樣后續(xù)的條件跳轉指令就能決定是否要跳轉到特殊地址了.
以下是可用跳轉指令,基于cmp x,y指令的結果:
je target ; jump if equal ( 相等時)
jne target ; jump if not equal ( 不相等時)
jl target ; jump if less than ( 當x<y時)
jle target ; jump if less than or equal ( 當x<=y時)
jg target ; jump if greater than ( 當x>y時)
jge target ; jump if greater than or equal ( 當x>=y時)
問題3
先用高級語言設計好條件跳轉代碼再轉換成匯編指令是很好的辦法.試一試將下方的偽匯編代碼轉換成完整的匯編代碼,使用cmp指令.測試不同的bx值.用你自己的語言完整的注釋你的代碼.
mov bx , 30
if (bx <= 4) {
mov al , ’A ’
} else if (bx < 40) {
mov al , ’B ’
} else {
mov al , ’C ’
}
mov ah , 0 x0e ; int =10/ ah =0 x0e -> BIOS tele - type output
int 0 x10 ; print the character in al
jmp $
; Padding and magic number.
times 510 -( $ - $$ ) db 0
dw 0 xaa55
mov bx , 50 ;設置bx為50 cmp bx , 45 ;比較bx與45 jle jle_block ;小于等于,則跳轉jle_block jl jl_block ;小于,則跳轉jl_block mov al,'C' ;否則設置al為'C' jmp the_end ;不重復執(zhí)行 jle_block: mov al,'A' jmp the_end jl_block: mov al,'B' jmp the_end the_end: mov ah , 0x0e int 0x10 jmp $ times 510 -( $ - $$ ) db 0 dw 0xaa55要注意的是
jmp the_end指令的用法,可以去掉這些指令來體驗一下CPU運行代碼的流程.
3.4.6 調用函數(shù)
在高級語言中,我們會將大需求拆分成一些本質上是通用程序的函數(shù)(打印信息,寫入文件等)以方便之后的復用,通過傳入不同的參數(shù)來改變輸出.在CPU層級上一個方法無非就是跳轉到一個存儲了可用程序的地址執(zhí)行完再跳轉到之前跳轉地址的位置之后繼續(xù)執(zhí)行代碼.
我們能這樣模擬方法:
...
...
mov al , ’H ’ ; 保存我們將要打印的字符
jmp my_print_function
return_to_here : ; 時間線在此,要跳回之前跳轉的地方之后
...
...
my_print_function :
mov ah , 0 x0e ;
int 0 x10 ;
jmp return_to_here ;
首先,主要帶我們使用了al寄存器作為參數(shù),提前設置好它的值以便調用.在高級語言中傳參也是如此,調用方與被調用方一定要有某種對如何傳參的約定.
不幸的是,這種方法的最大缺陷就是我們需要明白的告訴CPU執(zhí)行玩函數(shù)的跳回的地址,這樣就沒辦法在程序的任意地方調用這個函數(shù)—因為它總會回到一個相同的標簽return_to_here
借用傳參的想法,調用放可以明確的保存正確的返回地址(調用后要立即執(zhí)行的地址),以便于被調用方能返回保存的地址.CPU會將當前的執(zhí)行指令保存于特殊寄存器ip(指令指針)中,不幸的是我們沒法直接獲取它.但是不用怕,CPU提供了一對指令,call以及ret能滿足我們的需求:call跟jmp一樣,但是在跳轉前會將返回地址推入棧中;ret則會在執(zhí)行完函數(shù)后先pop拿到返回地址再跳轉,如下:
...
...
mov al , ’H ’ ;
call my_print_function
...
...
my_print_function :
mov ah , 0x0e
int 0x10
ret
我們的函數(shù)幾乎是自包含(self-contained)的了,然而還是有個丑陋的問題需要考慮,之后的你會感謝我們提前考慮到了這個問題.當我們調用一個函數(shù),比如打印函數(shù)時,通過匯編代碼,函數(shù)的內部會改變一些寄存器的值來完成它的任務(事實上,由于寄存器的稀缺性,它一定會這樣做),當我們程序返回之后就不那么安全了,比如,dx的值被改變了,跟之前不一樣了.
自包含是指在組件重用時不需要包含其他的可重用組件
這樣做就很友好了:在函數(shù)之行前對于可能被函數(shù)改變的寄存器,先將他們推入棧中,返回之后再將他們拿出來(還原寄存器的值).因為一個函數(shù)可能用到許多通用寄存器,所以CPU實現(xiàn)了兩個方便的指令:pusha以及popa(push all,pop all),這讓入棧以及出棧所有寄存器的值變得很方便了,比如:
some_function :
pusha ; 所有寄存器值入棧
mov bx , 10
add bx , 20
mov ah , 0x0e
int 0x10
popa ; 還原所有寄存器值
ret
3.4.7 引入文件
你一定會想在多個項目中重復使用你的代碼吧?nasm允許你引入拓展代碼:
% include " my_print_function.asm " ; this will simply get replaced by
; the contents of the file
...
mov al , ’H ’ ; Store ’H’ in al so our function will print it.
call my_print_function
3.4.8 一起來吧
通過這幾章的學習我們已經有足夠的關于CPU以及匯編的只是來寫一些比"Hello Wrold"更加復雜的引導扇區(qū)程序了
問題4
匯集你的智慧,來完成一個自包含的函數(shù)來打印空終結的字符串,可以這樣被使用:
;
; 打印字符串的引導扇區(qū)
;
[ org 0x7c00 ] ; 告訴匯編器代碼在此處被載入
mov bx,HELLO_MSG ; 使用bx作為函數(shù)的入參
call print_string ; 所以能指定字符串你的地址
mov bx , GOODBYE_MSG
call print_string
jmp $ ; 掛起
%include "print_string.asm"
; 一些數(shù)據(jù)
HELLO_MSG :
db 'Hello , World !',0 ; <-- 結尾的0告訴你的程序何時停止
GOODBYE_MSG :
db ' Goodbye ! ',0
times 510 -( $ - $$ ) db 0
dw 0xaa55
代碼如下:
print_string: pusha start: mov al,[bx] cmp al,0 je done call print add bx,1 jmp start done: popa ret print: pusha mov ah, 0x0e int 0x10 popa ret
保證你掌握了學習的知識,小心對待寄存器哦,代碼要附上你所理解的注釋
bx寄存器中存儲的是字符串的地址,也就是頭部地址.比如'H',然后根據(jù)每個字符占用1個字節(jié),于是讓bx+1來遍歷字符串,當遍歷到0時停止就好了
3.4.9 總結
似乎仍然沒有進行的太遠.沒問題,也很正常,為我們的大業(yè)準備了原始環(huán)境.如果之前的內容你全都理解了,其實我們進行的很順利.
3.5 護士姐姐,把我的聽診器拿來
目前為止我們已經能讓電腦打印我們放在內存里的字符或字符串,之后我們會試著從磁盤中載入數(shù)據(jù),如果確定了我們確實能從內存的任意地址載入數(shù)據(jù),這會是極好的.要記住,我們沒有豐富的開發(fā)界面,完整詳細的調試工具來幫助我們檢查代碼,電腦能提供給我們最好的反饋就是當我們犯錯時什么反應都沒有,我們得靠自己!
現(xiàn)在來想想如何編寫打印十六進制的例程—在這個底層又無情的世界中讓人憐愛的例程.
想想要如何實現(xiàn).高級語言中,我們可以像這樣:print_hex(0x1fb6),就能打印0x1fb6.之前的章節(jié)中有學到如何將寄存器的值作為參數(shù)在函數(shù)中使用,現(xiàn)在來使用dx寄存器作為參數(shù)來保存我們想用print_hex來打印的值:
mov dx , 0 x1fb6 ; 在dx中保存值
call print_hex ; 調用方法
; 打印數(shù)據(jù)
print_hex :
...
...
ret