本部分主要記錄了計算機開機過程中作業系統的工作流程,並以此理解作業系統的程式碼結構。
參考資料:
作業系統是計算機硬體和應用軟體之間的一層軟體,方便我們使用硬體(比如視訊記憶體)、高效地使用硬體(如開啟多個終端和視窗):
管理的硬體:
- CPU管理、記憶體管理
- 終端管理、磁碟管理
- 檔案管理、網路管理
- 電源管理、多核管理
而組成一個作業系統最基本的是前五個。
學習作業系統可以有很多層次:
大部分人停留在第一層,即使用作業系統的介面。而計算機專業學生應當能夠掌控計算機系統,真正理解作業系統的工作原理。
探討一個問題。開啟計算機電源後,計算機的開機過程中發生了什麼?
這也是實驗一的內容。
要了解這個問題,首先要了解計算機的工作原理。
計算機是如何工作的?
首先是 圖靈機。之前做過記錄:計算機系統3-> 現代計算機基石 | 圖靈機理論 - climerecho - 部落格園 (cnblogs.com)
但是這樣的圖靈機還是太菜啦,一個圖靈機只能做特定的一件事(因為控制邏輯是寫死了的)
而 通用圖靈機 可以看碟下菜,成為大廚。紙帶上對控制器的控制邏輯進行編碼,而控制器識別這樣的編碼,就能夠完成我們需要執行的操作。
通用圖靈機的功能就已經很像一個應用程式(程式)了。
接下來的馮諾依曼 儲存程式 思想,將程式存入記憶體,按照需求將程式載入CPU(上圖中的控制器)進行解釋執行。
經典的 「取值執行」。
這樣一個計算機就算搭建完成了,就像是大廚能夠按照客人需求選擇菜譜進行烹飪。
再回到開機過程的理解,計算機的工作歸結於 「取指執行」,而所有的程式(包括作業系統),在開機前都放在磁碟上,如何取指執行呢?
以×86 PC 為例,
剛開機,會執行 BIOS 中的程式碼
BIOS,ROM BIOS對映區,是Basic Input Output System 的縮寫。意思是計算機的記憶體裡總要有一個基本的輸入輸出程式,否則記憶體空白一片,就無法開啟馮諾依曼的"取值執行"。
而這段程式碼固化在 0xFFFF0 處
開機時,CS = 0xFFFF, IP= 0x0000
和保護模式對應,真真實模式的定址CS:IP(CS左移4位元+IP),這樣 CS << 4 + IP 就正好是 0xFFFF0,正是記憶體剛上電時唯一有程式碼的地方,接著進行取值執行。
這段程式碼主要用於 檢查 RAM,鍵盤,顯示器,軟硬磁碟
如果這段程式碼過不去,表示硬體出問題了。
將磁碟0磁軌0磁區讀入記憶體0x7c00處
1磁區 512位元組
0磁軌0磁區就正是作業系統的引導磁區,這個磁區中存放作業系統的第一段程式碼
開機時按住相關熱鍵(不同裝置不同)即可進入啟動裝置設定介面(俗稱BIOS介面),可以設定為光碟啟動,也可以從U盤等裝置進入某個作業系統。
啟動時裝置資訊被設定在 CMOS 中,CMOS是互補金氧半導體64~128B,用來儲存實時鐘和硬體設定資訊。
設定 CS=0x07c0, ip=0x0000,進行真真實模式定址後跳到引導磁區的程式碼執行。
引導磁區的程式碼做了什麼事情呢?
bootsect.s 就是上面所說的引導磁區的程式碼,是組合程式碼。
因為高階語言(如C)無法具體指定硬體,特別是記憶體位置;而組合則可以對硬體進行完整的控制。
這段程式碼經過組合後得到機器程式碼,放在引導磁區。
劃重點:所以bootsect.s的起始位置是0x7c00。後續理解會用到。
首先是固化的bootsect,需要把後續的程式碼引匯出來。
mov ax, #BOOTSEG
mov ds, ax
# 將ds置為 07c0
mov ax, #INITSEG
mov es, ax
# 將es置為 9000
# 這是兩個段暫存器,還需要偏移才能定址
mov cx, #256
sub si, si
sub di, di
# 加入偏移,偏移通過自減產生,為0
# 根據上面提到過的真真實模式的CS:IP定址
# 此時ds:si=7c00,es:di= 90000
rep movw
# rep:重複執行,直到cx=0
# 意思是移動字,一共移動256個字(cx處有說明,也正好是512位元組)
# movw: 將DS:SI內容複製到ES:DI中即從7c00
# DS和ES一個是源資料段暫存器,另一個是目的資料段暫存器
jmpi go, INITSEG
# 段間跳轉指令,cs=INITSEG,IP=go
# go是一個標記,替代一個具體的地址,編譯後就會分配到我們指定的地址
# 這一點具體計算機組成原理有提到過,但我還沒整理出來,就是從組合程式碼起始的地址,到這個標號處,標號標記了此處的地址。比如說到go這個標籤處,go是200地址
# INITSEG 上面提到過,是0x9000
# 這樣根據定址,90000+200
# bootsect.s現在就挪到了 900200,在這裡相當於順序向下執行。
# 但是必須寫這句話,因為程式碼在那個地方。
這段程式碼要決定接下來setup的讀入情況。
# 接下來的程式碼略講,看一些重點的
#
# 這段程式碼是用於讀入setup區的(分割區圖見上圖)
go:mov ax,cs
#cs是0x9000
mov es,ax
mov ss,ax
mov sp, #0xff00
# 為call準備(具體後面會使用這一塊的內容)
load_setup:
mov dx,#0x0000
mov cx,#0x0002
mov bx,#0x0200
mov ax,#0x0200+SETUPLEN
# 0x13是BIOS讀磁碟磁區的中斷:ah=0x02-讀磁碟,al=磁區數量(SETUPLEN=4) ch=柱面號,dh=磁頭號,dl=驅動器號,es:bx=記憶體地址
int 0x13
# 現在只是讀入了引導磁區用十三號中斷讀入作業系統其他的內容
# 需要知道從哪裡讀:cl開始磁區,即mov cx,#0x0002,讀取cx的低8位元是2。
# 理解一下,boot磁區佔了第1個磁區,所以從第2個磁區讀。
# 需要知道讀多少磁區:ax,0x0200,高八位作為ah,低八位作為al
# 所以是從第二個磁區開始讀4個磁區
# 需要直到讀到哪裡,es:bx告知讀到哪裡
# 從go標籤處得知,cs賦值給了ax,ax賦值給了es,cs是0x9000,而bx是0200
# 所以基址是0x9000,偏移是0x0200.意思就是把setup的四個磁區讀進來
jnc ok_load_setup
mov dx,#0x0000
mov ax,#0x0000
int 0x13
j load_setup
ok_load_setup:
mov dl,#0x00
mov ax,#0x0800
int 0x13
mov ch,#0x00
mov sectors,cx
mov ah,#0x03
xor bh,bh
int ox10
# 這句是這段程式碼的關鍵,進行BIOS的10號中斷,是一個顯示字元的BIOS中斷,用於在螢幕上輸出。
# 具體引數不再介紹,回頭單獨介紹吧。
mov cx,#24
# 顯示的字元數為24
mov bx,#0x0007
# 7是顯示屬性
mov bp,#msg1
#msg1是用於顯示的內容所在的記憶體地址,見下面的data段
# 意思就是把下面的msg1段顯示到遊標位置
# Windows開機時的logo就是這一段程式碼的作用(好看了一些)
mov ax,#1301
int 0x10
mov ax,#SYSSEG
mov es,ax
call read_it
# 讀入 system 模組
jmpi 0,SETUPSEG
# 轉入0x9020:0x0000,接下來執行setup.s
bootsect.s 中的data段/資料:
sectors: .word 0
msg1:.byte 13,10
.asscii "Loading system"
.byte 13,10,13,10
# 根據這一段就可以修改開機顯示的內容,比如改為:"CliviaOS is loading"
# 不過要記得修改顯示的字元長度,在上面的mov cx,#24的地方修改一下
# 修改之後這個系統重新編譯,再開機就可以看到更改效果
這也是實驗二的內容,回頭會把實驗二整理出來。
下面讀入 system 模組。
read_it: mov ax,es
cmp ax,#ENDSEG
#ENDSEG=SYSSEG+SYSSIZE,
#SYSSIZE=0x8000,這個變數可以根據image的大小設定(編譯作業系統的時候)
jb ok1_read
ret
ok1_read:
mov ax,sectors
sub ax,sread
# sread是當前磁軌已讀磁區數,ax是未讀磁區數
call read_track
值得注意的是,除了函數read_it
,讀入 system 模組為什麼還要定義一個函數ok1_read
?
因為 system 模組可能很大,要跨越磁軌,所以要處理這個問題。
在引導磁區的末尾,BIOS需要這段程式碼識別引導磁區
.org 510
.word oxAA55
#磁區的最後兩個位元組,否則會打出非引導裝置
接下來需要將控制權交給 setup.s,怎麼交接呢?
使用跳轉,即修改PC;
setup模組放在 0x90200 處,所以cs=9020,ip=0
SETUPSEG在上面的圖中就正是9020
這樣就實現了跳轉
jmpi 0,SETUPSEG
# ip=0,cs=SETUPSEG,
綜上,開機的圖示背後做了什麼事情,大概上理解一下:
其實不同系統不同版本的bootsect.s都會有差別,所以上面的程式碼不必死記,但是這是我接觸到的與作業系統相關的第一段程式碼,所以認真整理了下。
摘自一些我覺得有用的彈幕。
可以參考《Linux核心完全註釋V3.0》203頁,有詳細註釋。
老師沒講但很重要的幾個點結合linux0.11 和教材解釋一下
為什麼要從07c0 轉移到90000:因為如果不轉移,system 資料從10000-90000轉移到00000的時候會覆蓋07c0的程式,司令命令部隊把自己司令部給趟平了,所以提前搬走
這一點看了視訊L3以及下面setup.s的程式碼理解就可以明白。
為什麼system 資料要開始放在10000,因為bios 中斷程式在00000開始存放,把system從磁碟讀到記憶體之後的命令還是要用bios的中斷的,所以要移動
bios的終端每次啟動都會又BIO rom的程式初始化一次,不用擔心下次啟動的時候沒有
setup模組依然是setup.s組合程式碼,依然對啟動過程進行精細控制,相當於程式設計中的初始化,底層硬體的引數初始化作業系統。
start:mov ax,#INITSEG
mov ds,ax
mov ah,#0x03
xor bh,bh
int 0x10
mov [0],dx
mov ah,#0x88
int 0x15
# 本段程式碼重點,是一個BIOS中斷,獲取實體記憶體的大小
# 使用#0x88作為引數,獲取的值放入ax中,ax 賦給 [2]
mov [2],ax
# 這是間接定址,段暫存器左移 4 位再 +2,即0x90002
# 而段暫存器現在指向9000
# 將 ax 中內容傳遞至記憶體地址 ds:[2] 處 即 0x90002 處,
# ax 中儲存的值為呼叫 int15 中斷後獲取的擴充套件記憶體大小
# 作業系統是要管理記憶體的,所以有必要知道記憶體的大小。
# 這就是setup的意義,要讓作業系統知道計算機底層的模樣。
# 作業系統會形成很多資料結構來管理上圖表格中這些引數。
cli
# 不允許中斷
mov ax,#0x0000
cld
#########################重點提醒###########################
# 下面還是做一個移動
# 移動system模組到0x0000的位置,共計0x8000的地址空間,將來作業系統的程式碼將一直放在這個位置
# 此前 5.bootsect.s 補充解釋中提到的也是這裡
# 回顧前一小節中,bootsect程式碼首先會將自身從0x07c0:0x0000處移動到0x9000:0x0000處,接下來讀入的setup模組也緊跟在移動後的bootsect程式碼後,這麼做就是為了給此時將system放在0x0000~0x8000騰出空間
#########################重點提醒###########################
do_move:mov es,ax
#ax=0,賦值給了es
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax
sub di,di
sub si,si
mov cx,#0x8000
rep
movsw
jmp do_move
# 此後,記憶體從0開始的地址存放的都是作業系統,在此之上的是應用程式。
擴充套件記憶體:
擴充套件記憶體是 ram 中高於 1MB 的部分。Intel 剛出來的時候是1MB,後來把大於這部分的記憶體都稱為擴充套件記憶體。
拓展閱讀:《Linux0.11核心剖析》,能夠對作業系統的全貌有所瞭解。
setup.s 到此做了兩件事,1 是把作業系統進行挪動,2 是初始化作業系統,使其能夠管理底層硬體。
接下來,作業系統應當繼續向下執行,setup 還做了一件重要的事:
call empty_8042
mov al,#0xD1
out #0x64,a1
call empty_8042
mov a1,#0xDF
out #ox60,a1
mov ax,#0x0001
mov cr0,ax
jmpi 0,8
empty_8042:
.word 0x00eb,0x00eb
in al,#0x64
test al,#2
jnz empty_8042
jnz empty_8042
ret
來看這一句:
jmpi 0,8
這兩句:
mov ax,#0x0001
mov cr0,ax
提到了一個暫存器cr0,這個暫存器的最後一位PE,如果是0,則為真真實模式;如果是1,則為保護模式。
可見,這兩句程式碼就是把末位賦1,走了另一條電路
而這個電路如何解釋執行,涉及一個著名概念 gdt.
這個功能由硬體實現,目的就是快。
cs此時被稱為 選擇子(selector) ,存放查表的索引,真正的地址放在表項裡。
cs=8是要選擇表中的項,再從表中取出基址,與ip相加得到地址,這時得到的就是32位元地址。
這個表就是著名的 gdt表(global description table)
表中的內容從何而來呢?是由setup.s來做的。
#這段程式碼簡單講
end_move:mov ax,#SETUPSEG
mov ds,ax
lidt idt_48
lgdt gdt 48
idt_48:.word 0
.word 0,0
gdt_48:.word 0x800
.word 512+gdt,0x9
## 這一段就是初始化表
gdt:.word 0,0,0,0
# 注意,一個word16位元,所以一行作為一個表項就是64位元
.word 0x07FF,0x0000,0x9A00,0x00C0
# 定址的時候以位元組做索引,1個位元組8位元,所以cs=8就是這個第二行起始處
.word ox07FF,0x0000,0x9200,0x00C0
通過上面程式碼幾個指令的組合(相對固定),就能得到這個gdt表,再結合上面那一段更改定址方式的指令,就能夠實現jmpi 0,8
現在來談中斷,中斷處理也與上面類似。
也是以上面的方式去尋找中斷函數的入口地址。
這一點再下一部分作業系統介面會再提到。
上面我們得知,jmpi 0,8
使用gdt 查表,查到的是下面程式碼中的第2行word,而怎麼解釋這個表項則是由硬體規定的。
.word的四個地址是如何體現在GDT表項中的呢?
也就是大端定址。
這裡存放的不連續是因為硬體設計的歷史原因。
前面提到過的bootsect模組和setup模組都是由其相應的.s檔案編譯過來的,而system模組一定有很多檔案,我們要保證接下來進行的是system的第一段程式碼;也就是head.s,
- 作業系統的程式碼最後必須是:boot、setup、system這樣的過程
- 這些過程的嚴絲合縫,才能保證作業系統順利開機,否則就宕機了。
所以我們要編寫編譯作業系統的控制程式碼——Makefile。
Makefile可以控制最終生成的程式碼的組織結構,然後按照前述的順序放在硬碟的前面幾個磁區中。
我們通常把作業系統編譯後的樣子稱為 image,image 中就是上面所說的程式碼結構,指定放在0磁軌0磁區。
Makefile是一種樹狀結構
image 是依賴於上圖中的 boot/bootsect tools/system tools/build ... 產生
相當於父節點依賴於子節點,每個子結點完成了,最終整個樹才能建立。
而image 的這些子節點還會依賴於它的子節點,比如tools/system依賴於boot/head.o init/main,o $(DRIVERS) 等等
上面提到過的這些子節點還會依賴於 head.s等檔案。
當所有子節點完成後,通過(LD)boot/head.o init/main.o $DRIVERS ... -o tools/system
連結起來,來構建父節點
再向上建立 image
使用build,具體參見 Linux 原始碼
而在整個樹中,head.s 是第一個。
這就達到了目的。
每個子節點的依賴關係在下方會像
tools/system
這樣寫出來,通過這種書寫方式建立一整個樹。資料結構的後根遍歷。
system模組的第一部分程式碼是head.s,head.s執行完後再執行main.c
再次初始化IDT和GDT表
之前的IDT 和 GDT 被建立起來只是為了臨時完成jmpi 0,8 這條指令,而之後作業系統要開始真正工作。
其他如開啟20號地址線 ,就不再探討細節。
上圖中,head.s的程式碼和之前看到的bootsect.s和setup的程式碼不太一樣,多了很多%eax,而不再是ax。
這是因為head.s是執行在保護模式(32位元模式)下的,是32位元的組合程式碼,而bootsect.s和setup的程式碼是16位元的組合程式碼。
- as86組合:16位元的Intel 8086組合
- GNU as組合:產生32位元程式碼,採用 AT&T系統V語法。
- 另外在c程式碼中,可以內嵌組合,達到精細控制的目的,這又是另外一種組合
接下來會跳轉到 main.c。從組合跳到C,如何做到?
與C語言之間的跳轉沒有區別,C語言本身還是組合,通過棧來完成。
複習C語言壓棧
執行時棧是從高地址拓展到低地址的,是從上(頂)到下(底)壓棧。
下圖中的左上角棧圖中下面是棧頂,並不衝突,倒著看就可以了,下圖程式碼就是:先壓入p3,p2,p1(3個0),返回地址(L6),main
after_page_tables:
#壓棧
push1 $0
push1 $0
push1 $0
push1 $L6
push1 $_main
#壓棧結束後跳轉到set_paging
jmp set_paging
L6: jmp L6
setup_paging:#設定頁表程式碼#
ret
## 設定頁面setup_paging的具體程式碼這裡省略,後面再講
總結一下head.s功能:
下面就開始C語言程式了。
chr_dev_init()
,tty_init()
等等init 函數舉例mem_init()
函數:
按照 4K 為單位對記憶體進行劃分割區域(頁),mem_map陣列是表示記憶體區域是否被使用的一個表格;
2的12次方也就是4K,這就是 頁 的初始化
end_mem其實就是總記憶體大小,那這個引數是從哪裡來的呢?我們之前講setup的時候,說了會讀取記憶體大小放在0x90002的位置,就是從這裡來的。
妙蛙。
下面的程式碼,首先將mem_map全部初始化為USED,然後將start_mem到end_mem之間的記憶體區域設定為0,即未被使用
mem_map前面的部分是USED,這是作業系統程式碼和一些管理硬體的資料結構所佔用的記憶體
前面程式碼部分,分析了bootsect.s、setup、head.s、main.c、mem_init()
bootsect.s 將作業系統從磁碟讀入,setup.s 獲得引數,啟動保護模式,head.s 初始化頁表,main.c 初始化硬體管理器。
籠統的說,這些步驟,就是做了兩件事情
後面我們還會回過頭來看這裡準備的這些資料結構