我們之前都使用過操作系統(tǒng)(以下簡稱 OS)(例如 Windows XP,Linux等),甚至我們可以為其中的某個 OS 寫過程序。但 OS 到底是干嘛用的呢?我們所看到的有哪些是硬件處理的,哪些是軟件處理的?以及計算機到底如何工作的?
已故的蘭卡斯特大學(xué)的 Doug Shepherd 教授(我的老師)曾經(jīng)告訴我,他曾經(jīng)從無到有寫過一個 OS,之后就再也沒有碰到任何他感覺惱人的編程問題,以及無法進行下去的研究。今天,我們理所當(dāng)然的在我們的日常工作使用這些奇妙的機器,而無需了解任何底層的內(nèi)容,不需要了解軟件是如何與其進行交互的。
在這里,我們將專注與廣泛使用的 x86 架構(gòu)的 CPU,我們將拋開所有的軟件,遵循 Doug 早期的步伐,按照如下過程學(xué)習(xí):
計算機是如何啟動的
如何在沒有 OS 的情況下編寫底層程序
如何配置 CPU,使得我們能夠使用它的拓展功能
如何運行高級語言編寫的代碼,使得我們能夠加快自制 OS 的編寫進度
如何構(gòu)建基本的 OS 服務(wù),比如設(shè)備驅(qū)動,文件系統(tǒng),多任務(wù)處理
注意,從實用操作系統(tǒng)的角度,本指南并非旨在擴展,而是旨在將來自多個來源的信息片段匯集到一個完整且連貫的文檔中,從而為您提供底層編程實踐經(jīng)驗,比如 OS 是如何編寫的,以及在編寫的時候會遇到的問題。本指南采用的方法比較獨特,因為特定的語言和工具(例如匯編,C,Make等)不是重點,而是被視為達到目的的手段:我們將學(xué)會如何通過這些手段達到我們主要的目標(biāo)。
這項工作不是作為替代品,而是作為其他優(yōu)秀工作的墊腳石,例如 Minix 項目和一般的操作系統(tǒng)開發(fā)。
現(xiàn)在,開始我們的旅途!
當(dāng)我們啟動計算機時,它必須在沒有任何 OS 的幫助下完成初始化。然而,它必須從已經(jīng)加載的永久存儲器(比如硬盤等)中加載 OS
我們不久就會發(fā)現(xiàn),啟動階段,計算機提供的功能非常有限:在這個階段,甚至一個簡單的文件系統(tǒng)都非常奢侈(比如讀寫一個硬盤的問題),但是我們連這個都沒有。幸運的是,我們有 BIOS (the Basic Input/Output Software):一系列軟件列程,這些最初是從芯片加載到內(nèi)存,并在電源開啟的那一刻被初始化。 BIOS 提供對關(guān)鍵設(shè)備(比如屏幕、鍵盤和硬盤)的自動檢測和基本控制。
當(dāng) BIOS 完成對設(shè)備的底層測試(最主要的是檢測掛載的內(nèi)存是否工作正常)之后,它必須啟動在某個設(shè)備中存儲的 OS。這里,我們要注意,BIOS 不能簡單從硬盤掛載一個代表 OS 的文件,因為 BIOS 沒有文件系統(tǒng)。 BIOS 必須從物理設(shè)備的特定地址讀取特定區(qū)塊的數(shù)據(jù)(通常大小是 512 字節(jié))。
所以,BIOS 最初的階段是硬盤的第一個區(qū)塊找到 OS(比如,0頭0道0扇區(qū)),這一區(qū)塊被稱為啟動區(qū)塊。因為某些硬盤可能沒有包含 OS(可能這些硬盤存儲了另外一些內(nèi)容),所以對于 BIOS,檢測一個硬盤的區(qū)塊是否包含啟動代碼還是只是包含簡單數(shù)據(jù)是非常重要的。注意 CPU 并不區(qū)分?jǐn)?shù)據(jù)和代碼,兩者都會被解釋為 CPU 指令,只是代碼是一些對應(yīng) CPU 指令的有用算法實現(xiàn)。
對于 BIOS 簡單的理解是,啟動區(qū)塊的最后兩個字節(jié)內(nèi)容必須是 0xaa55
。所以,BIOS 循環(huán)檢測每個存儲設(shè)備,讀取啟動區(qū)塊到內(nèi)存中,然后指導(dǎo) CPU 去開始執(zhí)行在啟動區(qū)塊之后的第一個區(qū)塊內(nèi)容(也就是 0xaa55
結(jié)尾的區(qū)塊)。
從這里開始,我們開始控制計算機的執(zhí)行。
我們可以使用二進制編輯器,來寫原始字節(jié)值到文件中(一個標(biāo)準(zhǔn)的文本編輯器會轉(zhuǎn)換字符比如‘A‘成一個 ASCII 編碼的值)。因此我們可以制作一個簡單合法的啟動區(qū)塊。
啟動區(qū)塊的機器代碼,每個字節(jié)16進制展示:
e9 fd ff 00 00 00 00 00 00 00 00 00 00 00 00 0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00[ 29 more lines with sixteen zero-bytes each ]00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa
注意有三個重要的地方:
最初的三字節(jié),0xe9
,0xfd
和 0xff
。實際上是機器代碼指令,是每個 CPU 生廠商定義的,為了執(zhí)行無窮循環(huán)。
最后兩個字節(jié),0x55
和 0xaa
,組成了魔法數(shù)字,暗示 BIOS 它是一個啟動區(qū)塊,而不是碰巧存放在啟動區(qū)塊的數(shù)據(jù)。
中間有很多行零,只是為了簡單的把魔法 BIOS 數(shù)字放在 512 區(qū)塊的末端。
要十分關(guān)注大小端機制。你可能很奇怪為什么魔法數(shù)字 0xaa55
在我們的啟動區(qū)塊中被寫成連續(xù)的字節(jié) 0x55
和 0xaa
。這是因為 x86 架構(gòu)以小端格式處理多個字節(jié)值,也就是低位字節(jié)存放在高位地址。這和我們熟悉的方式不太一樣。比如經(jīng)過系統(tǒng)的處理,我有 $0000005 美元在我的銀行賬戶,現(xiàn)在我可以退休了(因為我有5百萬美元?。。?,甚至還能捐一點出來?。?/p>
編譯器和匯編器能幫助我們隱藏大小端的細(xì)節(jié),比如說一個 16 位的數(shù)據(jù)會被自動轉(zhuǎn)換成正確的格式。但是,有時候了解大小端很重要,比如說當(dāng)尋找 bug 的時候,想要知道一個字節(jié)是如何被存放在存儲設(shè)備上的。
這可能是計算機能運行的最小的程序,不管怎樣,它是合法的。我們可以通過兩種方式測試。第二種方式最安全也最適合我們的實驗?zāi)康模?/p>
使用任何可能的方式,將上述二進制代碼寫到不重要的存儲設(shè)備的第一個區(qū)塊(比如說 U 盤),然后重啟電腦。
使用虛擬機軟件,比如 VMWare 或者 VirtualBox,然后設(shè)置上述代碼到虛擬機中的磁盤,然后啟動虛擬機
如果計算機啟動以后一直處于等待狀態(tài),沒有“沒有找到 OS” 這樣的信息的話,說明上述代碼已經(jīng)被成功加載和執(zhí)行。這行代碼主要做的就是無限循環(huán),沒有這個循環(huán),CPU 就會去執(zhí)行內(nèi)存中的下一個指令,大部分情況下是隨機的未初始化的字節(jié)。這會導(dǎo)致 CPU 進入一些非法的狀態(tài),甚至有可能會令 BIOS 運行一些列程去格式化你的磁盤!
記住,是我們編程,然后計算機盲目的執(zhí)行我們的指令,直到斷電。所以我們要確保他執(zhí)行我們設(shè)計好的代碼而不是一些在內(nèi)存中隨機存在的字節(jié)。在底層,我們有很多權(quán)限和能力去控制計算機。我們來開始學(xué)習(xí)這些吧!
有許多方便的第三方工具,幫助我們測試這些底層代碼,而不需要不斷的啟動機器,或者冒著重要數(shù)據(jù)被丟失的風(fēng)險。比如說使用 CPU 仿真器,有 Bochs 和 Qemu。不像虛擬機(比如 VMWare 和 VirtualBox)會嘗試優(yōu)化性能,并且借助宿主計算機直接在 CPU 上執(zhí)行指令,仿真器包含一個模擬 CPU 架構(gòu)的程序,使用變量來表示 CPU 寄存器,并用高層的控制結(jié)構(gòu)來模擬底層調(diào)整等??偟膩碚f,它更慢,但是更適合開發(fā)和測試這樣的系統(tǒng)。
注意,為了讓仿真器做任何有用的事,需要編寫代碼并被編譯成磁盤鏡像文件來運行。一個鏡像文件就是原生數(shù)據(jù)(比如機器代碼和字節(jié)),并會被寫進硬盤、CD 或者 U 盤中。甚者有些仿真器能夠從下載或者 CD 中的鏡像文件中成功的啟動和運行一個真實的 OS(雖然這種情況最適合的方案還是虛擬機技術(shù))。
仿真器翻譯底層顯示設(shè)備的指令成像素,并在桌面上渲染,然后你就能在真實的顯示器上看到了。
總之,對于這篇文檔的練習(xí),在仿真器上能成功運行的機器代碼也能成功的在真實架構(gòu)的設(shè)備上運行,唯一的區(qū)別就是慢了點。
Bochs 需要我們在本地目錄配置一個文件 bochsrc
,描述真實設(shè)備(比如屏幕和鍵盤)是如何被仿真的,最重要的是,當(dāng)仿真計算機開始運行時,磁盤鏡像是如何被啟動的。
下面展示了一個我們將用到的 Bochs 的配置文件的樣子:
# Tell bochs to use our boot sector code as though it were # a floppy disk inserted into a computer at boot time. floppya: 1_44=boot_sect.bin, status=insertedboot: a
為了用 Bochs 測試我們的啟動代碼,只需要輸入
$bochs
一個簡單的實驗,試著改變 BIOS 的魔法數(shù)字然后重寫運行 Bochs。
因為 Bochs CPU 仿真器和真實的很接近,完成在 Bochs 上的測試之后,你可以在真實設(shè)備上啟動之前的代碼,你會發(fā)現(xiàn)它會運行的更快。
QEMU 和 Bochs 很像。但是它更加的高效,并且除了 x86 之外的其他 CPU 架構(gòu)。不過他的文檔沒有 Bochs 豐富。它不需要配置文件就可以運行,比如這樣:
$qemu <your-os-boot-disk-image-file>
我們前面已經(jīng)看過一個 16進制的例子。理解為什么16進制在底層編程經(jīng)常被使用到很重要。
首先,可能會問,我們理解10進制是那么自然,為什么不用10進制呢?我不是這方面的專家,不過很可能因為大部分人有十個手指,所以我們習(xí)慣用十進制。
十進制的基底是10,有十個不同的數(shù)字符號。16進制的基底是16,需要16個數(shù)字符號,所以需要額外6個符號,一個簡單的做法是使用字符,比如 1,2,...8,9,a,b,c,e,d,f,其中符號 d 表示 13。
為了區(qū)別不同的進制,16進制表示的時候會在前面加 0x
,或者有時候在尾部加 h
,碰巧不帶任何字符的16進制數(shù)字要比較注意,比如 0x50 不等于十進制中的 50,而是 80。
計算機表示一個數(shù)使用的是一系列位(二進制位),因為計算機基本只能區(qū)別兩個電路狀態(tài):0 和 1,就好像計算機只有兩個手指一樣。所以為了表示一個大于1的數(shù)字,計算機需要將一系列位給組合起來,就像我們表示大于9的數(shù)字用兩個或者更多的位(比如 456,23...)。
為了簡化期間,特定數(shù)量的一系列位會被稱為 byte
(字節(jié)),一個字節(jié)包含8位。其他的比如 short
、int
、long
相應(yīng)的表示 16位,32位,64位。我們也會使用術(shù)語 word
(字)表示 CPU 當(dāng)前模式的最大的處理單元大小:比如在 16 位模式下,一個字表示 16 位數(shù)值,在32位保護模式下,一個字表示32位數(shù)值,等等。
所以,16進制的優(yōu)勢在于,一系列位的表示會相當(dāng)?shù)拈L,很難書寫,但是很容易被轉(zhuǎn)換成更短的16進制表示,并且,我們將4位二進制數(shù)字表示成一位16進制數(shù)字,而不是將所有位表示成一個數(shù)字(不管是16進制還是32或者64進制),因為這樣更加簡單。下面的圖清楚的展示了這個:
將二進制數(shù)字轉(zhuǎn)換成十進制和16進制:
即使有樣例代碼,你也毫無疑問會覺得在二進制編輯器編寫機器代碼是很令人沮喪的。你必須記住或者經(jīng)常查閱,某些特定的機器碼在 CPU 上的不同功能。幸運的是,匯編語言可以更加用戶友好,同時能表達特定的機器碼在 CPU 上的作用。
在這一章,我們會研究引導(dǎo)扇區(qū)編程,讓我們能夠在熟悉匯編的同時又能夠在功能匱乏的引導(dǎo)階段把我們的程序運行起來。
現(xiàn)在,讓我們用匯編語言重新構(gòu)建一個扇區(qū)代碼(而不是之前那樣直接用機器代碼),因為用匯編可以很好的表達底層變量。
使用匯編器,我們可以將匯編代碼轉(zhuǎn)換成真實的機器代碼:
$nasm boot_sect.asm -f bin -o boot_sect.bin
boot_sect.asm
代碼如下:
;; A simple boot sector program that loops forever. ;loop: ; Define a label, 'loop', that will allow ; us to jump back to it, forever.jmp loop ; Use a simple CPU instruction that jumps ; to a new memory address to continue execution. ; In our case, jump to the address of the current ; instruction.times 510-($-$$) db 0 ; When compiled, our program must fit into 512 bytes, ; with the last two bytes being the magic number, ; so here, tell our assembly compiler to pad out our ; program with enough zero bytes (db 0) to bring us to the ; 510th byte.dw 0xaa55 ; Last two bytes (one word) form the magic number, ; so BIOS knows we are a boot sector.
boot_sect.bin
是匯編器生成的機器代碼,我們可以安裝到磁盤的一個引導(dǎo)扇區(qū)上。
注意,我們這里使用 -f bin
選項來指示 nasm
產(chǎn)生原始的機器代碼。如果不用該選項,會產(chǎn)生一些代碼包,這些代碼包包含額外的元信息,用于和其他資源鏈接的時候,比如說我們可能用于更典型的 OS 上會使用到。我們不需要這些,因為除了底層 BIOS 例程,我們是唯一的在計算機上運行的軟件。我們就是 OS!!雖然現(xiàn)在我們啥都沒有做,只是無窮的循環(huán),不過我們很快就會在此基礎(chǔ)上做些事情。
除了保存這個文件到引導(dǎo)扇區(qū)然后重啟機器,我們也可以很方便的用 Bochs 測試我們的程序:
$bochs
或者,我們也可以使用 QEMU:
$qemu boot_sect.bin
除此之外,也可以使用虛擬機加載該鏡像文件,或者將該鏡像文件寫入到可啟動的介質(zhì)(比如 U 盤),然后從真實的計算機上啟動它。注意將鏡像文件寫入介質(zhì),不是簡單的將它添加到介質(zhì)的文件系統(tǒng)中:你必須借助合適的工具將它從底層直接寫入扇區(qū)。
如果我們了解匯編器轉(zhuǎn)換的真是機器代碼,可以運行下面的命令,它會將二進制內(nèi)容轉(zhuǎn)換成16進制格式,方便閱讀:
$od -t x1 -A n boot_sect.bin
運行這個命令,你會看到之前熟悉的機器代碼。
祝賀你?。∧銊傆脜R編器寫了一個啟動代碼!我們將會知道,所有的 OS 必須用這種方式啟動,然后才能使用高層的抽象(比如高層語言,c/c++)。
CPU 廠商必須保證他們的產(chǎn)品能夠兼容以前的 CPU,這導(dǎo)致一些老的軟件,在特定的老的 OS 上,能夠運行在更現(xiàn)代的 CPU 上。
Intel 提供的兼容解決方案是模擬老的 CPU:Intel 8086。這款 CPU 支持模擬16位指令并且沒有內(nèi)存保護機制。內(nèi)存保護對于現(xiàn)代的 OS 的穩(wěn)定非常重要。因為它允許 OS 嚴(yán)格限制用戶進程訪問內(nèi)核內(nèi)存,無論是故意的還是有意的。因為這會令用戶進程規(guī)避 OS 的安全機制,甚者令整個系統(tǒng)面臨風(fēng)險。
所以,為了向后兼容,對于 CPU,支持現(xiàn)代 OS 的更高級的32或者64位保護模式的同時,又能通過16位初始化啟動,讓老的 OS 繼續(xù)運行,是非常重要的。在后面我們會詳細(xì)介紹如何從16位模式過度到32位保護模式。
通常,我們說 CPU 是16位的,指的是它一次只能執(zhí)行最長是16位的指令。比如,一個16位 CPU 有一個特別的指令能夠在一個 CPU 周期內(nèi)將兩個16位的數(shù)字加起來。如果一個進程需要將兩個32位數(shù)字相加的話,那么比起16位,它需要更多的 CPU 周期。
首先,我們會研究16位模式環(huán)境,因為所有的 OS 都是從此開始的。后面我們會學(xué)習(xí)32位保護模式,以及這樣的好處。
現(xiàn)在我們開始寫一個簡單的啟動代碼,只是簡單的打印信息到屏幕上。為此,我們需要學(xué)習(xí)一些基本的 CPU 工作概念和如何使用 BIOS 管理屏幕設(shè)備
首先,讓我們思考我們這里要做什么。我們想要在屏幕上打印一個字符。但是我們不知道如何使用屏幕設(shè)備,因為可能有很多不同種類的屏幕設(shè)備,并且有不同的接口。這就是為什么使用 BIOS 的原因。因為 BIOS 已經(jīng)做了自動檢測硬件機制,至少很明顯,啟動階段 BIOS 就能在屏幕上打印信息。或許這能幫我們一手。
所以,接下來,我們希望請求 BIOS 能為我們打印一些字符,但是怎么做?這里沒有 Java 庫幫助我們打印信息到屏幕,這簡直就是做夢。但是我們可以確定,在計算機內(nèi)存的某個地方, BIOS 機器代碼知道如何打印信息到屏幕。真相是,我們可以知道 BIOS 在內(nèi)存中的代碼,并用某種方式執(zhí)行它。但顯示很糟糕,因為不同的機器 BIOS 內(nèi)部的細(xì)節(jié)會有所不同。
在這里,我們使用基本的計算機機制:中斷。
中斷是一種讓 CPU 暫時停止當(dāng)前正在處理的任務(wù),并轉(zhuǎn)而去執(zhí)行更高優(yōu)先級的執(zhí)行,完成之后再返回處理原先的任務(wù)的機制。一個中斷可以通過軟件中斷觸發(fā)(比如 int 0x10),或者被一些更高優(yōu)先級任務(wù)的硬件設(shè)備觸發(fā)(比如讀取網(wǎng)絡(luò)設(shè)備的輸入數(shù)據(jù))。
每一種中斷被表示成在中斷向量表中的索引。中斷向量表是被 BIOS 初始化的,并位于內(nèi)存的其實地址(比如在物理內(nèi)存的 0x0 位置),包含的內(nèi)容是一些地址指針指向 ISR (中斷服務(wù)例程),一個 ISR 是一系列機器指令,很像我們的啟動代碼,不過處理的是一些獨特的中斷服務(wù)。(比如從磁盤或者網(wǎng)絡(luò)讀取數(shù)據(jù))
BIOS 會添加一些它自己的 ISR 到中斷向量表中,來處理計算機某些方面的任務(wù)。比如,中斷 0x10
引起屏幕相關(guān)的 ISR 被調(diào)用;中斷 0x13
則是處理磁盤相關(guān)的 I/O ISR。
不過,為 BIOS 的每一個例程分配一個中斷是很浪費的。所以 BIOS 比如使用 switch 語句,根據(jù)預(yù)先在某個 CPU 寄存器(ax
) 中的值來引發(fā)一個中斷路由到一個相應(yīng)的 ISR 中。
就像我們在高層語言中使用變量,如果在某個例程中能夠暫時存儲數(shù)據(jù)會很有用。所有的 x86 CPU 都有4中不同目的的寄存器,ax
、 bx
、 cx
和 dx
。并且,這些寄存器每個都能存放一個字的數(shù)據(jù)(16位,兩個字節(jié)大小),能被 CPU 讀寫,比起內(nèi)存訪問,幾乎沒有訪問延遲。在匯編程序中,其中一個最重要的通用操作是在寄存器之間移動(準(zhǔn)確的說是拷貝)數(shù)據(jù):
mov ax, 1234 ; store the decimal number 1234 in axmov cx, 0x234 ; store the hex number 0x234 in cxmov dx, ’t’ ; store the ASCII code for letter ’t’ in dxmov bx, ax ; copy the value of ax into bx, so now bx == 1234
注意 mov
指令的目的地址是第一個參數(shù),而不是第二個,不過不同的匯編器可能轉(zhuǎn)換會有所不同。
有時候,處理一個字節(jié)會更加方便,所以這些寄存器允許我們獨立設(shè)置它的高位和低位字節(jié):
mov ax, 0 ; ax -> 0x0000, or in binary 0000000000000000mov ah, 0x56 ; ax -> 0x5600 mov al, 0x23 ; ax -> 0x5623mov ah, 0x16 ; ax -> 0x1623
回憶一下,我們將要用 BIOS 為我們在屏幕上打印字符。通過設(shè)置 ax
寄存器為 BIOS 指定的值,我們會調(diào)用特殊的 BIOS 例程,然后它會幫助我們觸發(fā)一個特殊的中斷。這個特殊的 BIOS 例程是 BIOS 滾動輸入。它會在屏幕上打印一個字符,然后向前移動光標(biāo),準(zhǔn)備輸出下一個字符。有個完整的 BIOS 例程表,展示每個中斷以及如何預(yù)先設(shè)置相應(yīng)的寄存器值。 在這里,我們需要中斷 0x10
,設(shè)置 ah
為 0x0e
(表示需要 tty 模式),同時設(shè)置 al
為我們想要打印的字符的 ASCII。
下面的代碼展示了完成的啟動區(qū)塊代碼。注意,在這種情況下,只需要設(shè)置 ah
一次,然后每個不同的字符設(shè)置不同的 al
;; A simple boot sector that prints a message to the screen using a BIOS routine.;mov ah, 0x0e ; int 10/ah = 0eh -> scrolling teletype BIOS routinemov al, ’H’ int 0x10 mov al, ’e’ int 0x10 mov al, ’l’ int 0x10 mov al, ’l’ int 0x10 mov al, ’o’ int 0x10jmp $ ; Jump to the current address (i.e. forever).;; Padding and magic BIOS number. ;times 510-($-$$) db 0 ; Pad the boot sector out with zerosdw 0xaa55 ; Last two bytes form the magic number, ; so BIOS knows we are a boot sector.
下面的原生機器碼展示了上述匯編代碼經(jīng)過匯編處理的結(jié)果:
b4 0e b0 48 cd 10 b0 65 cd 10 b0 6c cd 10 b0 6c cd 10 b0 6f cd 10 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 55 aa
上述代碼是 CPU 真實執(zhí)行的代碼,如果你驚訝于你要付出如此多的努力和理解來完成這么一個程序,那么請記住,這些指令和 CPU 十分相關(guān),它們看起來簡單,但是執(zhí)行卻非??臁D阋呀?jīng)開始理解計算機的工作方式了,因為它就是這樣字的。
現(xiàn)在我們開始嘗試寫一個稍微有點不同的但更高級一點的“你好”程序。這會引入一點其他的知識,比如基本的 CPU 知識和內(nèi)存布局。
我們之前提到過 CPU 是如何獲得以及執(zhí)行內(nèi)存中的指令的,也知道 BIOS 是如何加載 512 字節(jié)的啟動代碼到內(nèi)存中的,并且也完成了它的初始化,讓 CPU 無限循環(huán)的執(zhí)行第一條指令。
所以,我們的啟動區(qū)塊代碼在內(nèi)存的某個地方,那么具體哪里呢?我們可以想象主內(nèi)存是一個很長的字節(jié)序列,能夠被通過地址(比如索引)被訪問到。如果我們想知道內(nèi)存中第 54 字節(jié)的內(nèi)容,那么 54 是我們的地址,為了簡單方便起見,經(jīng)常被表示成16進制格式: 0x36
。
我們啟動代碼的開始處,也就是機器碼的最開始在內(nèi)存的某個地址處,并且是 BIOS 幫我們放在那兒的。我們可以假設(shè),除非我們知道,那么 BIOS 應(yīng)該在內(nèi)存的開始處加載我們的代碼,即地址 0x0
。這不是很直觀,因為我們知道 BIOS 在加載我們的代碼很久之前就已經(jīng)為計算機做了初始化的工作,并會不斷的為硬件中斷服務(wù),比如時鐘,磁盤驅(qū)動等等。所以,這些 BIOS 例程(比如, ISR,專門處理屏幕打印的,等等),他們自己必須被預(yù)先存放在內(nèi)存的某處,并且在使用的時候需要被保護(就是不能被覆蓋)。我們前面也說到,中斷向量表處于內(nèi)存的開始處,這是 BIOS 為我們做的。我們的代碼會不斷的使用這張表,在下一個中斷發(fā)生的時候,計算機可能奔潰也可能重啟,但中斷和中斷的編號會有效的對應(yīng)起來。
BIOS 一般總是加載啟動代碼到地址 0x7c00
,并能保證這個地址沒有被重要的 BIOS 例程占用。下圖展示了當(dāng)啟動代碼被加載時計算機一個經(jīng)典的底層內(nèi)存布局。我們讓 CPU 往任何地址寫數(shù)據(jù),這會導(dǎo)致不好的事情發(fā)送,因為我們要寫的內(nèi)存地址處可能恰好存放了其他例程,比如說時鐘中斷和磁盤驅(qū)動。
現(xiàn)在我們要開始玩一個游戲叫做“找到這個字節(jié)”,通過這個,我們會描述內(nèi)存映射、匯編代碼中的標(biāo)簽,以及知道 BIOS 加載到哪里了。我們將會寫一個匯編程序,會持有一個字節(jié)的字符數(shù)據(jù),然后我們會嘗試在屏幕上打印改字符。為此,我們需要知道絕對內(nèi)存地址,這樣我們才能加載它到 al
然后讓 BIOS 打印它。
;; A simple boot sector program that demonstrates addressing. ; mov ah, 0x0e ; int 10/ah = 0eh -> scrolling teletype BIOS routine; First attempt mov al, the_secret int 0x10 ; Does this print an X?; Second attemptmov al, [the_secret] int 0x10 ; Does this print an X?; Third attempt mov bx, the_secret add bx, 0x7c00mov al, [bx]int 0x10 ; Does this print an X?; Fourth attemptmov al, [0x7c1e]int 0x10 ; Does this print an X?jmp $ ; Jump forever. the_secret: db 'X'; Padding and magic BIOS number.times 510-($-$$) db 0 dw 0xaa55
首先,當(dāng)我們在程序中定義數(shù)據(jù)的時候,我們用前置標(biāo)簽(the_secret
)。我們可以在程序的任意地方放置標(biāo)簽,它們唯一的目的是方便的給我們從代碼開始位置到某條指令的偏差。
b4 0e b0 1e cd 10 a0 1e 00 cd 10 bb 1e 00 81 c3 00 7c 8a 07 cd 10 a0 1e 7c cd 10 e9 fd ff 58 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
上述是匯編器生成的機器代碼,可以發(fā)現(xiàn)程序中的 X,它的16進制表示是0x58
,并且在距離代碼開始30(0x1e
)的偏移地址處。就在我們用零填充對齊的前面。
如果我們運行程序,會發(fā)現(xiàn),只有后面兩種方式成功打印了 X
第一次嘗試的問題是,它試圖把偏移地址加載到 al
中打印。但實際上我們要打印的是在偏移地址處的字符,而不是偏移地址本身。第二次嘗試中的方括號那一行指令意思是想存這個地址的內(nèi)容。
那么為什么第二次嘗試也失敗了呢?問題是,CPU 對待偏移地址是認(rèn)為它是距離內(nèi)存的起始位置的偏移。而不是我們被加載代碼開始的偏移。第二次嘗試實際上會導(dǎo)致它訪問中斷向量表。
第三次嘗試中,我們相信 BIOS 是把我們的代碼加載到了內(nèi)存地址 0x7c00
處,然后加上 the_secrect
偏移,用的是 CPU 的 add
指令。我們可以認(rèn)為 add
指令做的是類似高層語言中這樣子的事:bx = bx + 0x7c00
。這里,成功的計算處了字符 ‘X’ 的地址,并將它的內(nèi)容存到 al
中。
第四次嘗試,做的有點“小聰明”,通過預(yù)先計算 BIOS 加載啟動代碼中 “X” 的地址,得到 0x7c1e
這個值(可以看前面的原生機器碼,X 字符相應(yīng)的距離代碼開始位置的偏差地址是 0x1e
也就是 30 字節(jié)?)。
第四次嘗試提醒我們?yōu)槭裁礃?biāo)簽很有用。如果沒有標(biāo)簽的話,我們需要手動計算匯編器生成的機器碼中的地址,然后再去更新代碼中的相應(yīng)內(nèi)容,再重新用匯編器生成代碼。
現(xiàn)在我們看到了 BIOS 實際上將我們的啟動代碼加載到了地址 0x7c00
處,并且我們也知道了,地址和匯編代碼的標(biāo)簽是什么關(guān)系。
在代碼中總是手動計算標(biāo)簽的內(nèi)存偏差地址是很不方便的。所以,如果你在代碼的起始處添加下列代碼的話,很多匯編器會在匯編的時候自動糾正標(biāo)簽的引用地址,該代碼告訴匯編器你希望代碼被加載到內(nèi)存的何處。
[org 0x7c00]
問題1:
當(dāng)添加 org
指令到啟動代碼中時,你覺得現(xiàn)在會打印什么?解釋一下為什么。
假設(shè)你想打印預(yù)定義的信息,(比如,“正在啟動中...”),你要如何在匯編代碼中定義這樣字符呢?我們要記住,我們的計算機不知道什么是字符串,一個字符串只是內(nèi)存中一系列的數(shù)據(jù)單元(比如字節(jié)、字等)。
匯編中,我們可以如下方式定義字符串:
my_string: db ’Booting OS’
我們之前看到過 db
指令,翻譯過來的意思是定義一些或者一個字節(jié)的數(shù)據(jù)(declare byte(s) of data),這會告訴匯編器直接將這這一系列字節(jié)寫到二機制輸出文件中(并且,不能被解釋成處理器的指令)。因為我們用單引號把數(shù)據(jù)給包含起來了,匯編器知道將這些字符轉(zhuǎn)換成 ASCII 的字節(jié)表示。注意,我們經(jīng)常使用標(biāo)簽(比如 my_string
)表示我們數(shù)據(jù)的開始。不然我們沒有什么簡單的方法在代碼中表示他們。
有一件事我們要知道,知道一個字符串能多長的重要性不亞于它存放在哪兒。因為我們要編寫處理這些字符串的代碼,所以了解如何得知字符串的長度是很重要的。有幾種可能的方式,但是實際上匯編器,會將字符串定義為非空終結(jié)符。這里的意思是,字符串的最后一個字節(jié)是0:
my_string: db ’Booting OS’,0
后面遍歷一個字符串的時候,可能會打印每個字符,我們可以很容易的知道我們時候到達字符串的末尾了。
當(dāng)面臨底層計算的時候,我們經(jīng)常聽到很多人會討論棧,好像這個東西很特殊一樣。棧其實只是為了解決下面這個不便:CPU 只有有限的寄存器用于暫時的變量存儲,但我們經(jīng)常需要比寄存器數(shù)量更多的臨時存儲。我們當(dāng)然可以使用內(nèi)存,不過通過內(nèi)存地址讀寫是不方便的,尤其當(dāng)我們不在乎數(shù)據(jù)被存放的真實地方。不久我們很看到,在函數(shù)調(diào)用中的參數(shù)傳遞中是非常有用的。
CPU 提供兩個指令,允許我們存取棧頂?shù)臄?shù)據(jù):push
和 pop
,并不需要關(guān)系它們實際被存放的地方。但是注意在16位模式下,我們不能 push 或者 pop 一個字節(jié)數(shù)據(jù),我們此時只能以16位為單元存取。
棧是通過兩個特殊的 CPU 寄存器實現(xiàn)的:bp
和 sp
,分別存放棧底和棧頂?shù)牡刂?。因為我們?jīng)常 push 數(shù)據(jù)到棧中,通常棧底十分遠(yuǎn)離內(nèi)存其他區(qū)域(比如 BIOS 代碼和我們的代碼),這樣當(dāng)棧變得很大的時候,復(fù)寫的可能就比較底了。一個迷惑的事實是,當(dāng) push 的時候,棧是相對于 bp 向低地址增長的。所以對于 bp
,sp
等于它減去值的大小。
下面的啟動代碼展示了棧的使用
問題2:
下面的啟動代碼將會以什么順序打??? 'C' 這個字符會存放在哪個絕對內(nèi)存地址?你可以改代碼來驗證你的想法,不過一定要解釋為什么是這樣。
;; A simple boot sector program that demonstrates the stack. ;mov ah, 0x0e ; int 10/ah = 0eh -> scrolling teletype BIOS routinemov bp, 0x8000 ; Set the base of the stack a little above where BIOS mov sp, bp ; loads our boot sector - so it won’t overwrite us.push ’A’ ; Push some characters on the stack for later push ’B’ ; retreival. Note, these are pushed on aspush ’C’ ; 16-bit values, so the most significant byte ; will be added by our assembler as 0x00.pop bx ; Note, we can only pop 16-bits, so pop to bx mov al, bl ; then copy bl (i.e. 8-bit char) to alint 0x10 ; print(al)pop bx ; Pop the next value mov al, bl int 0x10 ; print(al)mov al, [0x7ffe] ; To prove our stack grows downwards from bp, ; fetch the char at 0x8000 - 0x2 (i.e. 16-bits) int 0x10 ; print(al)jmp $ ; Jump forever. ; Padding and magic BIOS number.times 510-($-$$) db 0 dw 0xaa55
如果我們不知道如何寫基本的控制代碼,比如 if..then..elseif..else
, for
和 while
,我們會無所適從的!這些語句允許可選的執(zhí)行分支。
這些高層的控制語句最終會被轉(zhuǎn)換成 jump 語句。事實上,我們前面已經(jīng)看過最簡單的代碼了:
some_label: jmp some_label ; jump to address of label
或者上述的等價的代碼:
jmp $ ; jump to address of current instruction
這個指令提供了一個無條件轉(zhuǎn)移功能(它總是 jump),不過我們更希望根據(jù)某個條件跳轉(zhuǎn)(比如不斷循環(huán)直到循環(huán)十次等等)。
在匯編語言中實現(xiàn)條件跳轉(zhuǎn)是這樣的:首先執(zhí)行一個比較指令,然后執(zhí)行一個特殊的條件轉(zhuǎn)移指令
cmp ax, 4 ; compare the value in ax to 4je then_block ; jump to then_block if they were equal mov bx , 45 ; otherwise , execute this codejmp the_end ; important: jump over the ’then’ block, ; so we don’t also execute that code.then_block: mov bx , 23the_end:
在 C 或者 Java 語言中,看起來像這樣:
if(ax == 4) { bx = 23;} else { bx = 45;}
我們可以從上面的匯編代碼中看到,在幕后,cmp
和 je
指令應(yīng)該有一定關(guān)系在。事實是,CPU 有一個 flags
寄存器用于存放 cmp
指令的結(jié)果,然后隨后的條件跳轉(zhuǎn)指令可以決定是否跳轉(zhuǎn)到相應(yīng)的地址。
基于 cmp x, y
指令的結(jié)果,有下列跳轉(zhuǎn)指令可用:
je target ; jump if equal (i.e. x == y)jne target ; jump if not equal (i.e. x != y)jl target ; jump if less than (i.e. x < y)jle target ; jump if less than or equal (i.e. x <= y)jg target ; jump if greater than (i.e. x > y)jge target ; jump if greater than or equal (i.e. x >= y)
問題3:
從高層語言的角度規(guī)劃跳轉(zhuǎn)代碼,然后用匯編語言替換會很有用。試一下轉(zhuǎn)換下列的偽匯編代碼為真實的匯編代碼,使用 cmp
和相關(guān)的跳轉(zhuǎn)指令。用不同的 bx
值測試。并給每行代碼添加注釋。
mov bx , 30if (bx <= 4) { mov al, ’A’} else if (bx < 40) { mov al, ’B’} else { mov al, ’C’}mov ah, 0x0e ; int=10/ah=0x0e -> BIOS tele-type output int 0x10 ; print the character in aljmp $; Padding and magic number. times 510-($-$$) db 0dw 0xaa55
在高層語言中,我們會將一個大問題寫成一個通用目的函數(shù)(比如打印信息,寫文件等等),然后我們會在代碼中不斷的使用它,一般是通過改變傳遞給函數(shù)的參數(shù)來獲取不同的輸出。從 CPU 角度,函數(shù)就是跳轉(zhuǎn)到某個有用的例程的地址處,然后再跳轉(zhuǎn)回到跳轉(zhuǎn)之前的下一條指令。
我們可以模擬一個函數(shù)的調(diào)用像這樣:
......mov al, ’H’ ; Store ’H’ in al so our function will print it.jmp my_print_functionreturn_to_here: ; This label is our life-line so we can get back.......my_print_function: mov ah, 0x0e ; int=10/ah=0x0e -> BIOS tele-type output int 0x10 ; print the character in al jmp return_to_here ; return from the function call.
首先,注意我們是如何使用 al
作為參數(shù)的,預(yù)先為相應(yīng)的函數(shù)設(shè)置它。這就是高層語言中參數(shù)轉(zhuǎn)遞的實現(xiàn)的基礎(chǔ),同時調(diào)用方和被調(diào)用者必須要對參數(shù)個數(shù)和參數(shù)存放地址達成一致。
不幸的是,上面這種方式我們需要明確的告訴它當(dāng)結(jié)束的時候要返回到哪里,這樣的話,就不能從任意的地方調(diào)用這個函數(shù)了(它總是返回到同樣的地址,在這里就是 return_to_here
)。
從參數(shù)傳遞的方式借鑒一下,調(diào)用者代碼可用存放一個準(zhǔn)確的返回地址(比如,調(diào)用之后的那行代碼)在某個公認(rèn)的地方。然后被調(diào)用者可以跳轉(zhuǎn)回那個地址。 CPU 會用 ip
(instruction pointer) 寄存器追蹤現(xiàn)在正在被執(zhí)行的指令的地址。不過,很不幸,我們不能直接訪問它。不過 CPU 提供了一對指令 call
和 ret
,它們的工作方式正是我們想要的:call
的行為像 jmp
,不過在跳轉(zhuǎn)之前,它會把返回地址 push 到棧中。ret
會 從棧上 pop 返回地址,然后跳轉(zhuǎn)到那,像下面這樣:
......mov al, ’H’ ; Store ’H’ in al so our function will print it. call my_print_function......my_print_function: mov ah, 0x0e ; int=10/ah=0x0e -> BIOS tele-type output int 0x10 ; print the character in al ret
我們的函數(shù)基本上很完美了,不過有一個很丑陋的問題,盡早的認(rèn)識到會很有幫助。當(dāng)我們在匯編代碼中調(diào)用一個函數(shù)的時候,比如一個打印函數(shù),這個函數(shù)的內(nèi)部很可能會用幾個寄存器去幫助它的執(zhí)行的實現(xiàn)(事實上,由于寄存器資源稀有,它幾乎一定會這樣做的),所以當(dāng)我們的代碼從函數(shù)調(diào)用返回的時候,我們之前存放在 dx
中的值很可能已經(jīng)不在了。
一個明智的守規(guī)矩的函數(shù)會立刻將任何它想要使用的寄存器的內(nèi)容 push 到棧中,然后在它要返回的時候馬上 pop(重新恢復(fù)寄存器在調(diào)用之前的值) 它們。因為一個函數(shù)很可能會使用許多通用寄存器,CPU 實現(xiàn)了兩個方便的指令,pusha
和 popa
,這對指令會向棧中 push 和 pop 所有寄存器的內(nèi)容。比如:
some_function: pusha ; Push all register values to the stack mov bx, 10 add bx, 20 mov ah, 0x0e ; int=10/ah=0x0e -> BIOS tele-type output int 0x10 ; print the character in al popa ; Restore original register values ret
有時候,你可能會想在多個程序中復(fù)用你的代碼。 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
我們已經(jīng)了解了一下 CPU 和匯編相關(guān)的知識,現(xiàn)在可用開始編寫一個稍微復(fù)雜有點的 “Hello, word” 啟動程序了。
問題4:
將這一節(jié)學(xué)到的內(nèi)容利用起來,來寫一個函數(shù)打印以0結(jié)尾的字符串,這個函數(shù)可以用如下的方式使用:
;; A boot sector that prints a string using our function.;[org 0x7c00] ; Tell the assembler where this code will be loadedmov bx, HELLO_MSG ; Use BX as a parameter to our function, socall print_string ; we can specify the address of a string.mov bx, GOODBYE_MSG call print_stringjmp $%include 'print_string.asm'; DataHELLO_MSG: db ’Hello, World!’, 0 ; <-- The zero on the end tells our routine ; when to stop printing characters.GOODBYE_MSG: db ’Goodbye!’, 0; Padding and magic number. times 510-($-$$) db 0 dw 0xaa55
為了好的分?jǐn)?shù),請注意函數(shù)要小心處理寄存器,并且最好每行代碼都有相應(yīng)的注釋來闡述你的理解。
我們好像仍然沒有什么很大的進展。不過,因為我們要工作的環(huán)境比較特殊,所以這還好,也很正常。如果你到現(xiàn)在為止的理解的話,我們的進展就很順利。
目前,我們已經(jīng)成功的讓計算機打印我們加載到內(nèi)存中的字符和字符串,很快,我們會試著從磁盤加載數(shù)據(jù)。如果我們能夠展示存儲在任意內(nèi)存地址處的16進制的格式的數(shù)據(jù)的話,這對我們實際想要加載的東西很有幫助。記住,我們沒有奢侈的好用的開發(fā)環(huán)境,也沒有調(diào)試器幫助我們一行行調(diào)試觀察代碼。當(dāng)我們犯錯的時候,計算機給我們唯一的最好的反饋是什么也沒有發(fā)生,所以我們要仔細(xì)。
我們已經(jīng)完成了一個例程來打印字符串?,F(xiàn)在我們要拓展那個想法,一個打印16進制格式的例程,這對于我們在底層環(huán)境工作會很有用。
我們仔細(xì)想想要怎么做,首先思考一下我們會怎樣用這個例程呢?在高級語言中,我們可能會像這樣:print_hex(0x1fb6)
,這個會打印0x1fb6
。在前面的章節(jié)中,我們已經(jīng)看到,匯編是如何調(diào)用函數(shù),以及我們?nèi)绻眉拇嫫髯鳛閰?shù)?,F(xiàn)在讓我們用 dx
作為存儲我們 print_hex 函數(shù)參數(shù)的地方:
mov dx, 0x1fb6 ; store the value to print in dx call print_hex ; call the function; prints the value of DX as hex.print_hex:......ret
既然我們想要屏幕上打印字符串,我們可能可以復(fù)用我們之前的打印函數(shù)去做真正的打印工作,所以我們主要的工作是如何轉(zhuǎn)換在 dx
中的字符串參數(shù)。在匯編中,我們肯定不想引入太多不比較的東西,所以讓我們以下列的想法開始我們的函數(shù)。首先,我們定義完整的16進制字符串為代碼中的模版變量,就像我們之前定義 “Hello, World” 一樣,然后我們可以讓打印函數(shù)打印它。 print_hex
例程的主要任務(wù)是將模版字符串中的每一個組成部分轉(zhuǎn)換成16進制的 ASCII 碼值。
mov dx, 0x1fb6 ; store the value to print in dx call print_hex ; call the function; prints the value of DX as hex.print_hex:; TODO: manipulate chars at HEX_OUT to reflect DXmov bx, HEX_OUT ; print the string pointed to call print_string ; by BXret; global variables HEX_OUT: db ’0x0000’,0
實現(xiàn) print_hex
函數(shù),你可能會發(fā)現(xiàn) CPU 指令 and
和 shr
很有用,你可以網(wǎng)上搜索找到相關(guān)資料。確保給每行代碼添加注釋。
我們已經(jīng)介紹了 BIOS,以及嘗試了一下計算機底層的開發(fā),但是有個小問題擋在我們開發(fā) OS 的路上:BIOS從磁盤的第一個扇區(qū)加載我們的啟動代碼,但這幾乎是它能加載的所有了。如果我們的 OS 代碼很龐大怎么辦?比如說大于 512 字節(jié)?
OS 通常不會是一個 512 字節(jié)大小的。所以,第一件要做的事就是,將它們剩余的代碼從磁盤引導(dǎo)到內(nèi)存中,然后開始執(zhí)行。幸運的是,就像之前提示的一樣,BIOS 提供了一些例程允許我們管理在磁盤上的數(shù)據(jù)。
當(dāng) CPU 運行在16位真實環(huán)境中時,寄存器最大的大小是16位,這意味著,我們能引用的最大的內(nèi)存地址是 0xfff
,以現(xiàn)在的標(biāo)準(zhǔn)就是說大約 64KB (65536 字節(jié)),我們將要完成的 OS 不太可能會超過這個限制,不過這么小的一個空間,現(xiàn)代的 OS 可能就不太舒服了。所以了解這個問題的解決方案很重要:段切割
為了繞過這個限制,CPU 設(shè)計者添加了一些特殊的寄存器,cs
、ds
、ss
和 es
,這些被叫做段寄存器。我們可以想象內(nèi)存被劃分為好幾段,并通過這些段寄存器被索引。這樣子,當(dāng)我們指定一個16位地址,CPU 會通過合適的段開始地址加上我們指定的偏移地址自動計算絕對地址。比如:mov ax, [0x45ef]
中使用的地址默認(rèn)情況下會根據(jù)數(shù)據(jù)寄存器發(fā)生偏移也就是 ds
(data segment)。同樣的棧的段寄存器 ss
用于計算棧底指針 bp
的絕對地址。
關(guān)于段地址最惱人的一件事是:相鄰的段總是會發(fā)生16字節(jié)的重疊,所以不同的段和偏移計算出來的絕對地址有時候會一樣。但是,在遇到這個問題之前,我們暫時了解到這里。
為了計算絕對地址,CPU 會將段寄存器中的值乘以16,然后加上你提供的偏移地址。因為我們用的是16進制,當(dāng)將一個數(shù)乘16時,我們只需要簡單的將兩個0添加到左邊(原文有誤,說一個0,應(yīng)該是兩個0),比如 0x42 * 16 = 0x4200.所以如果我們設(shè)置 ds
為 0x4d
然后執(zhí)行 mov ax, [0x20]
,ax 中的結(jié)果將會是地址 0x4d20
的內(nèi)容(16 * 0x4d + 0x20)。
下面展示了一個等價于我們使用 [org 0x7c00]
指令的代碼,我們通過設(shè)置 ds
來完成類似的標(biāo)簽地址糾正。
;; A simple boot sector program that demonstrates segment offsetting ;mov ah, 0x0e ; int 10/ah = 0eh -> scrolling teletype BIOS routinemov al, [the_secret]int 0x10 ; Does this print an X?mov bx, 0x7c0 ; Can ’t set ds directly , so set bxmov ds, bx ;then copy bx to ds.mov al, [the_secret] int 0x10 ; Does this print an X?mov al, [es:the_secret] ; Tell the CPU to use the es (not ds) segment.int 0x10 ; Does this print an X?mov bx, 0x7c0mov es, bxmov al, [es:the_secret]int 0x10 ; Does this print an X?jmp $ ; Jump forever.the_secret: db 'X'; Padding and magic BIOS number. times 510-($-$$) db 0dw 0xaa55
由于這里我們沒有時候 org
指令,當(dāng)我們通過 BIOS 加載到地址 0x7c00
時,匯編器不會幫我們偏移標(biāo)簽到正確的內(nèi)存地址。所以第一次嘗試打印 'X' 失敗了。然而,如果我們設(shè)置數(shù)據(jù)段寄存器為 0x7c
(原文有誤,為 0x7c0 應(yīng)該是 0x7c),CPU 會幫我們做這個偏移計算(比如 0x7c * 16 + the_secret),所以第二次嘗試成功的打印了 ‘X’。在第三四次嘗試中,我們做法一樣,結(jié)果也是成功的,不過我們沒有使用 段寄存器,而是使用通用的目的段寄存器 es
。
注意至少在16位模式下,CPU 的一個限制,一個看起對的指令 mov ds, 0x1234
并不會被成功執(zhí)行:我們能往通用目的寄存器中用字面量存值,并不意味著我們可以對每種類型的寄存器都這樣做,比如說段寄存器就不行。必須多一步先將數(shù)據(jù)存到通用目的寄存器中。
所以,基于段的地址允許我們訪問到更多的內(nèi)存,大于1MB(0xffff * 16 + 0xffff)。后面當(dāng)我們轉(zhuǎn)到32位保護模式時,我們會了解到如何訪問更多的內(nèi)存。目前對于16位模式,了解這些已經(jīng)夠了。
硬盤驅(qū)動包含一個或多個堆疊起來的盤,盤下面有個讀寫頭,就像老式播放器,(為了增加容量,所以將幾個盤堆疊起來)訪問磁頭會從某個特定的盤的表面經(jīng)過。因為某個特定的盤可以在它兩個表面都被讀寫,一個讀寫磁頭會有一個在盤上面,一個在盤下面。下圖展示了經(jīng)典的硬盤驅(qū)動的內(nèi)部結(jié)構(gòu),并展示了堆疊的磁盤和暴露出來的磁頭。
注意這里的描述的內(nèi)容在軟磁盤上也一樣適用,不過沒有堆疊的磁盤,只有一個磁盤。
金屬外表的磁盤使得它們表面的特定區(qū)域可以被磁頭磁化,或反磁化,所以能夠有效的永久存儲任何狀態(tài)。因此如何描述將要被讀寫的數(shù)據(jù)在磁盤表面的精確地址很重要。目前使用 CHS (Cylinder-Head-Sector)來表示磁盤的數(shù)據(jù)地址。這是一個有效的 3D 坐標(biāo)系統(tǒng):
Cylinder(柱體):柱體描述了與磁頭不相關(guān)的相對于磁盤的外邊緣的距離,也是因此得名的。當(dāng)多個磁盤堆疊在一起時,你可以想象,在每個磁盤上的所有的磁頭定義了一個柱體。
Head(頭):頭描述了哪個 track (即某個特定磁盤的表面) 是我們關(guān)心的。
Sector:每一個 track 被劃分成了幾個扇區(qū),通常是 512 字節(jié)大小,可以通過扇區(qū)索引來引用。
如下圖所示:
不久我們會知道,不同的設(shè)備需要使用不同的例程。比如,軟盤在使用之前我們需要手動為磁盤下的讀寫磁頭開啟關(guān)閉發(fā)動裝置。大部分的硬盤設(shè)備有很多實用的自動化本地芯片,不過設(shè)備是如何連接 CPU 的總線技術(shù)(比如 ATA/IDE,SATA,SCSI,USB 等)影響了我們?nèi)绾问褂盟鼈?。幸運的是,BIOS 提供了幾種磁盤例程將所有這些不同抽象化為一般的磁盤設(shè)備。
我們想要使用的這個 BIOS 例程就是通過 0x13
引發(fā)的中斷(預(yù)先設(shè)置 al
為 0x02)。這個例程要求我們設(shè)置幾個寄存器,告訴它要使用哪個磁盤的哪一塊,并且要讀到內(nèi)存的哪里。使用這個例程最難的地方是我們必須使用 CHS 地址模式指定第一個被讀的塊。如下代碼所示:
mov ah, 0x02 ; BIOS read sector functionmov dl, 0 ; Read drive 0 (i.e. first floppy drive)mov ch, 3 ; Select cylinder 3mov dh, 1 ; Select the track on 2nd side of floppy ; disk, since this count has a base of 0mov cl, 4 ; Select the 4th sector on the track - not ; the 5th, since this has a base of 1.mov al, 5 ; Read 5 sectors from the start point; Lastly, set the address that we'd like BIOS to read the; sectors to, which BIOS expects to find in ES:BX; (i.e. segment ES with offset BX).mov bx, 0xa000 ; Indirectly set ESmov es, bxmov bx, 0x1234 ; Set BX to 0x1234; In our case, data will be read to 0xa000:0x1234, which the; CPU will translate to physical address 0xa1234int 0x13 ; Now issue the BIOS interrupt to do the actual read.
注意,出于某種原因(比如,我們索引一個扇區(qū)但是沒有考慮磁盤的限制,嘗試讀一個不存在的扇區(qū),軟盤沒有被考慮在內(nèi)),BIOS 可能會讀取磁盤失敗,所以知道如何檢測這種情況很重要,不然,我們可能覺得我們已經(jīng)讀了一些數(shù)據(jù),但事實上,目的地址的內(nèi)存仍然包含的是一些隨機的字節(jié)數(shù)據(jù)。幸運的是,BIOS 會更新某些寄存器讓我們知道這些失敗的情況:falgs
寄存器的 CF
(carry flag)值表示一個通用的錯誤,同時,al
被設(shè)置為實際讀取的扇區(qū)數(shù)量。在觸發(fā) BIOS 的磁盤讀取中斷之后,我們可以執(zhí)行一個簡單的測試:
......int 0x13 ; Issue the BIOS interrupt to do the actual read.jc disk_error ; jc is another jumping instruction, that jumps ; only if the carry flag was set.; This jumps if what BIOS reported as the number of sectors; actually read in AL is not equal to the number we expected.cmp al, <no. sectors expected >jne disk_errordisk_error : mov bx, DISK_ERROR_MSG call print_string jmp $; Global variables DISK_ERROR_MSG: db 'Disk read error!', 0
像早前解釋的,能夠從磁盤讀取很多字節(jié)對于啟動我們的 OS 是很重要的。所以這里,我們使用這一節(jié)學(xué)到的內(nèi)容來實現(xiàn)了一個有用的例程,這個例程的作用是從磁盤簡單的讀取緊隨啟動代碼之后的前面n個扇區(qū)內(nèi)容:
; load DH sectors to ES:BX from drive DLdisk_load: push dx ; Store DX on stack so later we can recall ; how many sectors were request to be read, ; even if it is altered in the meantime mov ah, 0x02 ; BIOS read sector function mov al, dh ; Read DH sectors mov ch, 0x00 ; Select cylinder 0 mov dh, 0x00 ; Select head 0 mov cl, 0x02 ; Start reading from second sector (i.e. after the boot sector) int 0x13 ; BIOS interrupt jc disk_error ; Jump if error (i.e. carry flag set) pop dx ; Restore dx from the stack cmp dh, al ; if AL (sector read) != DH (sectors expected) jne disk_error ; display error messagedisk_error : mov bx, DISK_ERROR_MSG call print_string jmp $; VariablesDISK_ERROR_MSG db 'Disk read error!', 0
我們可以寫一個啟動代碼測試上述代碼:
; Read some sectors from the boot disk using our disk_read function[org 0x7c00]mov [BOOT_DRIVE], dl ; BIOS stores our boot drive in DL, so it’s ; best to remember this for later.mov bp, 0x8000 ; Here we set our stack safely out of the mov sp, bp ; way, at 0x8000mov bx, 0x9000 ; Load 5 sectors to 0x0000(ES):0x9000(BX)mov dh, 5 ; from the boot disk.mov dl, [BOOT_DRIVE] call disk_loadmov dx, [0x9000] ; Print out the first loaded word, whichcall print_hex ; we expect to be 0xdada , stored ; we expect to be 0xdada , storedmov dx , [0 x9000 + 512 ; Also, print the first word from thecall print_hex ; 2nd loaded sector: should be 0xfacejmp $%include '../print/print_string.asm' ; Re-use our print_string function%include '../hex/print_hex.asm' ; Re-use our print_hex function%include 'disk_load.asm'; Include our new disk_load function; Global variables BOOT_DRIVE: db 0; Bootsector padding times 510-($-$$) db 0 dw 0xaa55; We know that BIOS will load only the first 512-byte sector from the disk, ; so if we purposely add a few more sectors to our code by repeating some; familiar numbers, we can prove to ourselfs that we actually loaded those ; additional two sectors from the disk we booted from.times 256 dw 0xdada times 256 dw 0xface
聯(lián)系客服