前序文章請看:
從裸機啟動開始運行一個C++程序(十二)
從裸機啟動開始運行一個C++程序(十一)
從裸機啟動開始運行一個C++程序(十)
從裸機啟動開始運行一個C++程序(九)
從裸機啟動開始運行一個C++程序(八)
從裸機啟動開始運行一個C++程序(七)
從裸機啟動開始運行一個C++程序(六)
從裸機啟動開始運行一個C++程序(五)
從裸機啟動開始運行一個C++程序(四)
從裸機啟動開始運行一個C++程序(三)
從裸機啟動開始運行一個C++程序(二)
從裸機啟動開始運行一個C++程序(一)
圖形模式
我們前面章節所有的指令,都是在顯卡的文字模式下運行的,通過給顯存中直寫字符的方式來輸出文字。
照理說,保持著文字模式我們也可以進入IA-32e模式并執行64位指令,但我們最終的目標是運行C++程序,為了可以更好地發揮C++的作用,筆者打算后續用C++實現一套簡單的UI繪制API,因此,咱們需要使用圖形模式。
回到MBR
我們在MBR中首先有這么一段指令:
; 調用0x10號BIOS中斷,清屏
mov al, 0x03
mov ah, 0x00
int 0x10
當時筆者一直將它解釋位清屏,其實0x10
中斷的威力遠不如此,al
中配置的0x03
其實是讓顯卡進入文字模式。由于我們發起此終端是讓顯卡重新進入了一次文字模式,因此顯存會被重新初始化,進而達到的清屏的目的。
那么我們此時還可以調用這個中斷,讓顯卡進入圖形模式。當然,圖形模式中有不同的分辨率、色域等等,這些需要顯卡的支持。但它們有一套VGA標準模式,是所有顯卡都會支持的,我們這里選擇通用模式中配置最高模式,這個模式下支持320×200分辨率,256色。開啟此模式的方法很簡單,只需要讓al
為0x13
,然后調用0x10
中斷即可開啟。我們把MBR最前面的清屏指令改成下面這樣:
; 開啟320*200分辨率256色圖形模式(此時顯存0xa0000~0xaf9ff)
mov al, 0x13
mov ah, 0x00
int 0x10
由于此模式下,顯存也發生了改變,所以我們把GDT中,顯存對應的2號段也做對應的更改:
; 2號段
; 基址0xa0000,上限0xaf9ff,覆蓋所有顯存
mov [es:0x10], word 0xf9ff ; Limit=0x00f9ff,這是低16位
mov [es:0x12], word 0x0000 ; Base=0x0a0000,這是低16位
mov [es:0x14], byte 0x0a ; 這是Base的高8位
mov [es:0x15], byte 1_00_1_001_0b ; P=1, DPL=0, S=1, Type=001b, A=0
mov [es:0x16], byte 0_1_00_0000b ; G=0, D/B=1, AVL=00, Limit的高4位是0000
mov [es:0x17], byte 0 ; 這是Base的高8位
圖形模式的像素點陣
當然,在此模式下,顯存不再被解釋為ASCII了,而是每個字節表示一個像素點的顏色。既然是一個字節,自然也就支持一共256種顏色。這256種顏色理論上可以自行通過調色盤來進行更改的,但我們為了簡化問題,就使用默認的這256種顏色。
這256種顏色如下:
上圖是按16×16來排布的,因此用十六進制很好找,比如說0x46
就是上圖中第五行的第七個顏色(0起始)。
當進入了圖形模式后,原本我們寫的一些g_cursor_info
之類的東西肯定是都不適用了,而且也沒辦法直接輸出文字。不過在此之前,我們還是先來驗證一下是否正常啟用了圖形模式,咱們在主函數中設置一些像素點的顏色看看效果:
void Entry() {for (int i = 0; i < 200; i++) {for (int j = 0; j < 320; j++) {int offset = i * 320 + j;SetVMem(offset, j & 0x0f); // 縱向條紋}}
}
執行效果如下:
雖然看著有點暈,但至少說明我們圖形模式是OK了。趁熱打鐵,咱們寫一些工具,用于在圖形模式上繪制點和矩形:
#include <stdint.h>
extern void SetVMem(long addr, uint8_t data);// 設置畫布(背景色)
void SetBackground(uint8_t color) {for (int i = 0; i < 320 * 200; i++) {SetVMem(i, color);}
}// 畫一個點
void DrawPoint(int x, int y, uint8_t color) {SetVMem(y * 320 + x, color);
}// 畫一個矩形
void DrawRect(int x, int y, int width, int length, uint8_t color) {for (int i = 0; i < width; i++) {for (int j = 0; j < length; j++) {DrawPoint(x + i, y + j, color);}}
}void Entry() {// 背景設置為白色SetBackground(0x0f);// 畫兩個矩形DrawRect(50, 30, 40, 40, 0x28); // 紅色正方形DrawRect(85, 20, 10, 30, 0x30); // 綠色條狀
}
運行效果如下:
可以看到,圖形之間的遮蓋關系也是符合我們預期的,綠色的會覆蓋紅色的部分,因為它的代碼更靠后。
字體文件
那么,圖形模式下我們要想輸出文字該怎么辦?這時候就需要將文字轉換為點陣集,也就是繪制字體。
舉例來說,對于'A'
字符,我們可以繪制一個8×16的點陣:
{0b00000000,0b00011000,0b00011000,0b00011000,0b00011000,0b00100100,0b00100100,0b00100100,0b00100100,0b01111110,0b01000010,0b01000010,0b01000010,0b11100111,0b00000000,0b00000000,
}
上面字體配置中,按照二進制位來表示當前這個像素要不要渲染。比如我們可以嘗試一下:
// 繪制字符
void DrawCharacter(int x, int y, char ch, uint8_t color) {// 目前無視ch,只繪制'A'(void)ch;uint8_t font[] = {0b00000000,0b00011000,0b00011000,0b00011000,0b00011000,0b00100100,0b00100100,0b00100100,0b00100100,0b01111110,0b01000010,0b01000010,0b01000010,0b11100111,0b00000000,0b00000000,};// 開始繪制for (int i = 0; i < 16; i++) {for (int j = 0; j < 8; j++) {// 如果當前位置是否需要渲染,則設置顏色if (font[i] & (1 << (7 - j))) {SetVMem(x + j + (y + i) * 320, color);}}}
}void Entry() {// 背景設置為白色SetBackground(0x0f);// 寫個字符DrawCharacter(50, 50, 'A', 0x30); // 綠色的A
}
運行結果如下:
因此,如法炮制,我們只需要設置其他的字符的字體即可。這里采用這樣的方法,我們在工程中添加font.c
,專門用于保存所有ASCII對應的繪制字體,然后再根據此方法去改造putchar
函數,即可實現在圖形模式中輸出文本。
這里的相關代碼會放在附件中(13-1),讀者可以自行取用,正文中則不再贅述。不過這里需要注意,因為字體文件比較大,所以讀盤的時候要多讀幾個扇區,否則字體文件裝載不全,已經在附件中呈現,讀者請自行檢驗。下面是運行效果:
分頁機制
由于咱們現在一直都是在內核態上運行程序的,并沒有任何用戶態進程的存在,所以可能大家并沒有這種體會。但是,我們設想一下,假如我們真的寫了一個成熟的操作系統,這個操作系統不可能說把自己加載完了就hlt
在那里了。我們肯定是要去操作它,讓它執行其他的應用程序。
但只要你去執行一個應用程序,那么就要給這個應用程序去分配必要的內存。應用程序只能訪問它這一片空間,而不能夠越界。我們能想到最簡單的辦法就是給每一個進程分一個段,等它結束時再回收這個段,以便用于以后繼續分配。
但這樣一來有一個問題,隨著越來越多的進程運行然后釋放,我們的內存可能就被劃分為了許多碎片,而段的分配是連續的,假如說明明此時可用空間是夠的,但是連續的可用空間不足,那就沒辦法分段。
所以為了解決這個問題,386引入了「頁機制」,簡單來說,就是讓程序使用「虛擬地址」,由對應的頁地址部件將其轉化為物理地址后再讀入內存。而這種訪問方式的最小單位是4KB,被稱為「頁」。換句話說,物理內存中只有4KB內是連續的,頁之間可能是不連續的,但在用戶程序看來,卻是完全連續的,因為用戶程序看到的是虛擬地址。
不過研究分頁機制會跟本文的主題偏離,因此我們在這里不做過于詳細的展開,讀者只需要知道這一機制引入的原因,以及配置方法即可,我們的目的是進入IA-32e模式,然后執行64位的指令而已,還沒必要大動干戈去調度用戶程序。因此這里只會簡單配置一個頁表,然后讓程序正常運行即可。
一個頁是4KB,而且要求必須是4KB對其的,也就是說頁的起始位置不可以是任意的,而必須是4KB的整數倍,也就是說地址的低12位都是0。而且,既然我們要管理這些頁,內核至少要知道,當前分配了哪些頁,他們的物理地址在哪里,一些內部配置是什么樣的。這就需要我們來維護一張「頁表」。
頁表
頁表中應當包含頁的物理地址,以及一些其他的配置項。在IA-32模式中,一個頁表項占4字節,詳情如下:
二進制位 | 符號 | 意義 |
---|---|---|
0 | P | 存在位 |
1 | RW | 只讀or可寫 |
2 | US | 權限級別 |
3 | PWT | 通寫 |
4 | PCD | 高速緩存禁止 |
5 | A | 訪問 |
6 | D | 臟頁 |
7 | PS | 頁尺寸 |
8 | G | 全局 |
9-11 | AVL | 保留位 |
12-31 | Base(12-31) | 頁首地址的12-31位 |
由于頁地址要求低12位必須為0,所以頁表項中也只需要記錄高20位即可,剩下的那12位留給了頁的配置。由于我們不涉及內核調度的問題,因此很多配置項我們都可以忽略,只需要關注P
和RW
即可,其它項暫且都置0就好了。
但是這里有另一個問題,32位尋址空間最多有4GB,一個頁是4KB,也就是說最多我們可以有1048576個頁,而每個頁表項占4字節,那么光是頁表都要有4194304字節,也就是4MB的空間。
而實際情況是,計算機的物理內存可能根本沒有這么大,很多頁是虛擬的,在不用的時候可能被操作系統寫入了硬盤中,等需要時再做換頁,但它的頁表項卻是必須在內存中的,這就活活占用了4MB的死空間。在386那個年代可能物理內存也就幾MB吧,這顯然是不能接受的。
于是乎,我們想了一個辦法,就是把頁表進行分層,首先,我只占用4KB的大小,先做一個頁目錄,然后頁目錄項在指向一個子頁表,頁表中再去配置實際的頁。這樣一來有兩個好處,第一,如果我只用到一少部分的頁配置,我就不用占用太多空間,因為我可以選擇只配置其中一部分頁表。第二,頁表本身也可以作為活躍的頁,跟硬盤進行交換,所以內存中只有頁目錄這4KB的死空間被占用而已,這個大小是比較能接受的。
另一個問題就是,一但CPU開啟分頁模式后,通過段寄存器和偏移地址計算出的線性地址就不再是物理地址,而是要通過頁的轉換成為物理地址。但咱們現在內核已經加載進去了,開啟分頁模式之后,我們得保證后續指令能夠正常運行,就得讓這時的線性地址正好等于物理地址才行,否則經過轉換后地址飛了,就不能正常執行后續指令了。因此,我們至少得把低1MB的空間都分好頁,并且保證這些頁是連續的,正好映射到物理內存中。而1MB的空間按照每頁4KB來算的話,咱們得配256個頁。
頁配置
接下來,我們將在kernel.nas
中配置頁表,至于頁表的位置無所謂,你選一個沒有被占用的就好了,這里我們以0x20000
為例。
不過既然要在0x20000
這個位置寫東西,咱們就得有個段來支持才行,索性我們就配一個0x0
地址起始的輔助段,方便我們操作這部分空間。在MBR中添加:
; 4號段-輔助段
; 基址0x0000,上限0xfffff
mov [es:0x20], word 0x00ff ; Limit=0x00ff,這是低16位
mov [es:0x22], word 0x0000 ; Base=0x0a0000,這是低16位
mov [es:0x24], byte 0x00 ; 這是Base的高8位
mov [es:0x25], byte 1_00_1_001_0b ; P=1, DPL=0, S=1, Type=001b, A=0
mov [es:0x26], byte 1_1_00_0000b ; G=1, D/B=1, AVL=00, Limit的高4位是0000
mov [es:0x27], byte 0 ; 這是Base的高8位
同時記得修改GDT的長度:
; 下面是gdt信息的配置(暫且放在0x07f00的位置)
mov ax, 0x07f0
mov es, ax
mov [es:0x00], word 39 ; 因為目前配了5個段,長度為40,所以limit為39
mov [es:0x02], dword 0x7e00 ; GDT配置表的首地址
回到kernel.nas,按照之前的規劃,0x20000
~0x20fff
的位置就是我們的頁目錄。由于咱們只需要配256個頁,所以只需要用一張頁表即可覆蓋,那我們就選0x21000
~0x21fff
作為這個頁表。
那么我們在頁目錄的起始配一個指向0x21000
位置頁表的頁目錄項,代碼如下:
; 選取0x20000作為頁目錄的地址
; 從0x20000開始的4096字節都是頁目錄
; 頁目錄的第一項指向一個頁表
; 頁表范圍是0x21_000~0x21_fff
mov [es:0x20000], word 00100001_000_0_0_0_0_0_0_0_1_1b ; P=1, RW=1, US=0, PWT=0, PCD=0, A=0, D=0, G=0, PAT=0, AVL=000, Base=0x00021_000(取前20位)
然后我們再來配置這張頁表,由于要分256個頁,所以我們通過一個循環來計算頁首地址以及寫入頁表項,每次循環時,頁的基址應該加4KB
,配置項保持不變。讓低1MB的內存空間正好映射到前256個頁表項中,也就是把0x0
~0xfff
分給0
號頁,0x1000
~0x1fff
分給1
號頁,以此類推。代碼如下:
; 接下來要把0x21000~0x213ff范圍里的256個頁表項進行配置(正好對應低1MB)
mov ecx, 256
mov ebx, 0x21000 ; 頁表項地址
mov edx, 0x00000_003 ; 頁表配置項值,從0地址開始.l1:mov [es:ebx], edxadd ebx, 4add edx, 0x0001_000 ; 相當于頁物理地址+4KBloop .l1
配置好頁表項后,還需要告訴CPU我們的頁目錄配置在哪里了,IA-32架構的CR3寄存器就是做這件事的,它用來保存頁目錄首地址。
; 將頁目錄首地址寫入CR3寄存器中
mov eax, 00100000_00000000_0_0_00b ; PCD=0, PWT=0, BASE=0x00020_000
mov cr3, eax
做好一切準備后,我們就可以開啟分頁模式了,開啟分頁模式的方法是更改CR0的第31位(也就是最高位),將其置1
,然后CPU就會立即進入分頁模式。
; 開啟CR0的第31位(最高位)以開啟分頁機制
mov eax, cr0
or eax, 0x80000000
mov cr0, eax
我們保持后續所有流程不變,如果還能正常看到輸出,證明我們的分頁是沒問題的。
到此步為止的實例代碼,筆者會放在附件中(13-2),讀者可自行參考。
AMD64模式
目前咱們已經做好了萬全的準備,接下來我們就是要進軍64位了。在此之前我們來介紹一下相關背景知識。
IA-32e架構
筆者在前面章節曾經介紹過IA-64和AMD64的愛恨情仇,AMD64架構是由AMD最先提出,在IA-32架構的基礎上進行擴展的64位指令集,向下兼容IA-32模式,但IA-64并不兼容IA-32。由于它是由IA-32模式擴展來的,并且兼容IA-32,因此這種工作模式也被稱作IA-32e(IA-32 Extension)模式,或IA-32擴展模式。
首先是硬件擴充,原本的一些寄存器被重新擴展至64位,并以r開頭(re-extend,再次擴展的意思),例如rax
,它的低32位就是eax
。除此之外,該架構還額外提供了8個通用寄存器,分別命名為r8
~r15
。它們也可以拆出32位寄存器來用,例如r8
的低32位是r8d
,低16位是r8w
,低8位是r8b
。
rax
,rbx
,rcx
,rdx
寄存器結構示意圖如下:
rsp
,rbp
,rsi
,rdi
寄存器結構示意圖如下:
r8
~r15
寄存器結構示意圖如下:
相信大家用起來都不會太陌生。與此同時,ip
也擴展位rip
,用于加載64位指令。
接下來的問題就是,如何使CPU進入IA-32e模式,并執行64位指令?這里還有一段路程要走,大家稍安勿躁。
4層分頁
我們知道IA-32e模式是要支持64位指令的,同時也擁有理論64位的尋址空間,最大支持16EB的內存空間。倘若說這玩意我們還是按4KB來分頁,頁表又要炸掉了。由于要支持64位地址,因此這時的頁表項也被擴充到8字節,低12位的含義不變,只是向上多擴展了32位用于表示頁地址的。也正因如此,現在一個頁表里最多只能配512個頁了,頁表更加得炸。
所以,之前的2層分頁就不滿足要求了,IA-32e模式提供了4層分頁的方式,顧名思義就是從最初的頁目錄到最后的頁表最多有4級。這4層分別被叫做PML4
,PDPT
,PDT
,PT
。
剛才我用了「最多」這個詞,也就是說,我們不一定真的分4層,也可以在2層或者3層就截止,再哪層截止決定了頁的大小。
這里的邏輯是,假如我們真的配置到了PT
層,那么它指向的頁就是4KB的,正常來說PDT
層的項應當指向一個PT
層表才對,但如果這時我們不讓他再繼續分級,而是直接指向頁的話,這個頁的大小就是2MB(相當于這2MB不再細分成512個4KB的頁了,而是直接作為一個頁)。
同理,如果我們直接在PDT
層就直接指向頁的話,那么這一個頁就是1GB,相當于1GB不再細分成512個2MB的頁。
那么如何表示當前頁表項是指向實際頁呢,還是指向下一個層級的頁表呢?這就用到了PS
位,當PS
為1時,表示它指向下一級頁表,為0
時表示直接指向頁。
由于我們當前就利用前1MB的空間就夠了,所以,咱們就選擇按2MB大小分頁,這樣只需要分1個頁就夠用了,所以,我們配置頁表只到PDT
層,并且這一層里只需要配一個頁表項,讓它的物理地址是0x0
起始的就夠了。代碼如下:
; IA-32e模式下使用4級分頁,PML4-PDPT-PDT-PT
; 這種模式下的頁目錄項、頁表項都是8字節,高52位是物理地址
; 如果在PDPT上就設PS=1的話,實際上只分2層,每頁1GB
; 如果在PDT上就設PS=1的話,實際上只分3層,每頁2MB
; 到了PT上PS必須設為1(因為此模式最多支持4層),每頁4KB
; 這里就將0x00000~0x1FFFFF這2MB的空間放到首個頁結構項中; PML4
mov [es:0x20000], dword 0x21003 ; P=1, RW=1, PS=0(表示有下級頁表), Base=0x21_000
mov [es:0x20004], dword 0; PDPT
mov [es:0x21000], dword 0x22003 ; P=1, RW=1, PS=0(表示有下級頁表), Base=0x22_000
mov [es:0x21004], dword 0; PDT
mov [es:0x22000], dword 0x83 ; P=1, RW=1, PS=1(不再有下級頁表,所以直接按2MB分頁), Base=0x0,大小2MB
mov [es:0x22004], dword 0; 將頁目錄首地址寫入CR3寄存器中
mov eax, 00100000_00000000_0_0_00b ; PCD=0, PWT=0, BASE=0x00020_000
mov cr3, eax
與此同時,我們現在的頁表項是按8字節一項來配置的,這件事得讓CPU知道,不然它還是按4字節作為一項來解析的話就麻煩了。這時我們需要將CR4的第5位置1,表示「開啟64位地址擴展的分頁模式」,代碼如下:
; 開啟CR4的第5位,開啟64位地址擴展的分頁模式
mov eax, cr4
or eax, 0x20
mov cr4, eax
進入IA-32e模式
在操作CR0開啟分頁模式之前,我們還需要通知CPU開啟IA-32e模式,畢竟咱們剛才配置的這種4層分頁模式在i386模式下是不支持的,所以開啟分頁模式之前,還要先開啟IA-32e模式的標記位,這樣CPU才能知道,開啟分頁模式的同時,改用IA-32e模式。
在IA-32e架構下,有一些擴展的寄存器,它們并沒有像CR0這樣直接集成到指令集中(這就是它區別于IA-64的地方,雖然擴充到了64位,但仍舊保持對IA-32的高度兼容),而是通過獨立編址的方式,利用特殊的指令來操作,這些寄存器稱為MSR(Modelspecific Register, 模式指定寄存器)。我們現在要操作的寄存器叫做EFER(Extended FeatureEnable Register, 擴展功能開啟寄存器),它的第8位表示開啟長模式(long mode,大家姑且認為這個跟IA-32e模式等價)。代碼如下:
; 讀取EFER(Extended FeatureEnable Register)
mov ecx, 0xc0000080 ; rdmsr指令要求將需要讀取的寄存器地址放在ecx中
rdmsr ; 讀取結果會放在eax中
or eax, 0x100 ; 設置第8位,LME(Long Mode Enable)
wrmsr ; 將eax的值寫入ecx對應地址的寄存器
準備工作做完以后,我們就可以操作CR0來開啟分頁模式了,與此同時,CPU也會進入IA-32e模式:
; 開啟CR0的第31位(最高位)以開啟分頁機制
mov eax, cr0
or eax, 0x80000000
mov cr0, eax ; 此時也會進入IA-32e模式
我們同樣保持后續邏輯不變,來運行一下看看效果:
沒有問題!我們成功在IA-32e模式中配置了頁表,并運行了內核程序。
到此的項目源碼會放到附件(13-3)中。
等等……為什么這里能運行成功呢?進入IA-32e模式后難道不應該切換到64位指令集嗎?為什么我們32位的Kernel還能正常運行?不知道讀者到這里會不會有這樣的疑問。
這就是IA-32e模式的偉大之處了,因為它可以完全兼容原本的IA-32指令,所以即使我們已經進入了IA-32e模式,它也能正常執行后續所有的IA-32指令。
那究竟如何真正地運行64位指令呢?這是我們下一章要研究的內容。
小結
本篇我們為進入64位模式做足了前戰準備。首先講解了進入圖形模式的方法并在這個模式下輸出圖像和文字;之后講解了分頁的概念和配置方法,并針對IA-32e模式的4層分頁方式做了講解;最后成功進入了IA-32e模式,同時也體驗了在IA-32e模式下無感知運行IA-32架構的指令。
本篇所有的項目源碼將會通過附件的方式(demo_code_13)上傳,供讀者參考。
下一篇,我們會介紹IA-32e架構的獨有64位指令,和執行指令的方法,還會把之前寫的C語言代碼改用64位方式編譯,并與內核進行適配。