作業系統學習筆記1 | 初識作業系統

2022-08-13 12:00:18

本部分主要記錄了計算機開機過程中作業系統的工作流程,並以此理解作業系統的程式碼結構。

參考資料:


1. 什麼是作業系統

作業系統是計算機硬體和應用軟體之間的一層軟體,方便我們使用硬體(比如視訊記憶體)、高效地使用硬體(如開啟多個終端和視窗):

管理的硬體:

  • CPU管理、記憶體管理
  • 終端管理、磁碟管理
  • 檔案管理、網路管理
  • 電源管理、多核管理

而組成一個作業系統最基本的是前五個。

學習作業系統可以有很多層次:

大部分人停留在第一層,即使用作業系統的介面。而計算機專業學生應當能夠掌控計算機系統,真正理解作業系統的工作原理。

2. 計算機工作原理

探討一個問題。開啟計算機電源後,計算機的開機過程中發生了什麼?

這也是實驗一的內容。

要了解這個問題,首先要了解計算機的工作原理。

計算機是如何工作的?

首先是 圖靈機。之前做過記錄:計算機系統3-> 現代計算機基石 | 圖靈機理論 - climerecho - 部落格園 (cnblogs.com)

但是這樣的圖靈機還是太菜啦,一個圖靈機只能做特定的一件事(因為控制邏輯是寫死了的)

通用圖靈機 可以看碟下菜,成為大廚。紙帶上對控制器的控制邏輯進行編碼,而控制器識別這樣的編碼,就能夠完成我們需要執行的操作。

通用圖靈機的功能就已經很像一個應用程式(程式)了。

接下來的馮諾依曼 儲存程式 思想,將程式存入記憶體,按照需求將程式載入CPU(上圖中的控制器)進行解釋執行。

經典的 「取值執行」。

這樣一個計算機就算搭建完成了,就像是大廚能夠按照客人需求選擇菜譜進行烹飪。

3. 開機過程理解

再回到開機過程的理解,計算機的工作歸結於 「取指執行」,而所有的程式(包括作業系統),在開機前都放在磁碟上,如何取指執行呢?

  • 開啟電源,計算機執行的第一句指令是什麼?即第一條指令對應的PC暫存器裡的地址是多少?

以×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,進行真真實模式定址後跳到引導磁區的程式碼執行。

    引導磁區的程式碼做了什麼事情呢?

4. bootsect.s 程式碼理解

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,
    

綜上,開機的圖示背後做了什麼事情,大概上理解一下:

  • 打出Logo
  • 把 setup 和system 區的程式碼讀進來。
  • 一些別的事情...

5. bootsect.s 補充解釋

其實不同系統不同版本的bootsect.s都會有差別,所以上面的程式碼不必死記,但是這是我接觸到的與作業系統相關的第一段程式碼,所以認真整理了下。

摘自一些我覺得有用的彈幕。

可以參考《Linux核心完全註釋V3.0》203頁,有詳細註釋。

老師沒講但很重要的幾個點結合linux0.11 和教材解釋一下

  1. 為什麼要從07c0 轉移到90000:因為如果不轉移,system 資料從10000-90000轉移到00000的時候會覆蓋07c0的程式,司令命令部隊把自己司令部給趟平了,所以提前搬走

    這一點看了視訊L3以及下面setup.s的程式碼理解就可以明白。

  2. 為什麼system 資料要開始放在10000,因為bios 中斷程式在00000開始存放,把system從磁碟讀到記憶體之後的命令還是要用bios的中斷的,所以要移動

  3. bios的終端每次啟動都會又BIO rom的程式初始化一次,不用擔心下次啟動的時候沒有

6. setup.s 程式碼理解

setup模組依然是setup.s組合程式碼,依然對啟動過程進行精細控制,相當於程式設計中的初始化,底層硬體的引數初始化作業系統。

6.1 初始化作業系統並移動

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 是初始化作業系統,使其能夠管理底層硬體。

6.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
  • 這句程式碼是重點,也是作業系統中的理解重點
  • 如果還按照上面的定址模式,則會跳轉到0x80,屬於system模組,會宕機
  • 所以實際上並不是上面的定址模式,cs<<4+ip,cs和ip都是16位元暫存器,這種定址最多隻能達到20位地址 = 1M,不適用動輒記憶體4G的計算機。
  • 所以需要從16/20位(1M)切換到32位元(4G),後者就是保護模式
  • 那麼如何做到這種切換呢?即切換定址模式,切換CPU的解釋方式,切換為另一條電路?

這兩句:

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

現在來談中斷,中斷處理也與上面類似。

也是以上面的方式去尋找中斷函數的入口地址。

這一點再下一部分作業系統介面會再提到。

6.3 gdt 查表方式理解

上面我們得知,jmpi 0,8使用gdt 查表,查到的是下面程式碼中的第2行word,而怎麼解釋這個表項則是由硬體規定的。

.word的四個地址是如何體現在GDT表項中的呢?

  • 注意看GDT表項的四個段標註
    • 段基址31...24對應0x00C0的高位00
    • 段基址23...16對應0x9A00的低位00
    • 段基址15...0對應0x0000
    • 段限長15...0對應0x07FF
  • 合起來,段基址就是全零。
  • 所以這個表項的意思是,jmp 到記憶體0x0000處,接下來去0地址處執行,也就是前面移動過的system模組。

也就是大端定址。

這裡存放的不連續是因為硬體設計的歷史原因。

前面提到過的bootsect模組和setup模組都是由其相應的.s檔案編譯過來的,而system模組一定有很多檔案,我們要保證接下來進行的是system的第一段程式碼;也就是head.s

  • 作業系統的程式碼最後必須是:boot、setup、system這樣的過程
  • 這些過程的嚴絲合縫,才能保證作業系統順利開機,否則就宕機了。

所以我們要編寫編譯作業系統的控制程式碼——Makefile

6.4 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 是第一個。

    這就達到了目的。

  1. 每個子節點的依賴關係在下方會像 tools/system這樣寫出來,通過這種書寫方式建立一整個樹。

  2. 資料結構的後根遍歷。

  3. system模組的第一部分程式碼是head.s,head.s執行完後再執行main.c

7. head.s 程式碼理解

  • 再次初始化IDT和GDT表

    之前的IDT 和 GDT 被建立起來只是為了臨時完成jmpi 0,8 這條指令,而之後作業系統要開始真正工作。

  • 其他如開啟20號地址線 ,就不再探討細節。

  • 上圖中,head.s的程式碼和之前看到的bootsect.s和setup的程式碼不太一樣,多了很多%eax,而不再是ax。

    這是因為head.s是執行在保護模式(32位元模式)下的,是32位元的組合程式碼,而bootsect.s和setup的程式碼是16位元的組合程式碼。

  1. as86組合:16位元的Intel 8086組合
  2. GNU as組合:產生32位元程式碼,採用 AT&T系統V語法。
  3. 另外在c程式碼中,可以內嵌組合,達到精細控制的目的,這又是另外一種組合

接下來會跳轉到 main.c。從組合跳到C,如何做到?

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也是在壓棧,所以從head.s跳轉到main.c實際上很簡單,就是把引數和main.c的地址壓入棧中
  • 在設定頁表的程式碼(即setup_paging模組)執行完後,會執行 ret返回指令,那麼就把棧中 main.c 的地址作為返回地址,達到了跳轉到main.c的效果
  • 接下來就是執行上面壓棧的內容,即執行main.c。
  • 如果main.c執行結束,會跳轉到L6,L6是一個死迴圈;實際上,正常情況下,main.c就會一直執行下去,不會執行結束,如果main.c結束了,就會跳到L6,表現的結果就是計算機宕機了

總結一下head.s功能:

  1. 初始化idt以及gdt,表示用各種資料結構管理硬體引數
  2. 向後交接給main.c

8. main.c 程式碼理解

下面就開始C語言程式了。

  • 傳給main函數的三個形參(上一部分的p1~p3),分別是envp、argv、argc,但是main函數並沒有使用;main如果返回,就會跳轉到L6,但作業系統正常情況下一直在工作,永遠不會退出;
  • main的工作就是xx_init:記憶體、中斷、裝置、時鐘、CPU等內容的初始化,這裡就可以看到熟悉的chr_dev_init()tty_init()等等

init 函數舉例mem_init()函數:

  • mem_init()就是記憶體的初始化,如下圖

  • 按照 4K 為單位對記憶體進行劃分割區域(頁),mem_map陣列是表示記憶體區域是否被使用的一個表格;

    2的12次方也就是4K,這就是 的初始化

  • end_mem其實就是總記憶體大小,那這個引數是從哪裡來的呢?我們之前講setup的時候,說了會讀取記憶體大小放在0x90002的位置,就是從這裡來的。

    妙蛙。

  • 下面的程式碼,首先將mem_map全部初始化為USED,然後將start_mem到end_mem之間的記憶體區域設定為0,即未被使用

  • mem_map前面的部分是USED,這是作業系統程式碼和一些管理硬體的資料結構所佔用的記憶體

9. 總結

  • 前面程式碼部分,分析了bootsect.s、setup、head.s、main.c、mem_init()

    bootsect.s 將作業系統從磁碟讀入,setup.s 獲得引數,啟動保護模式,head.s 初始化頁表,main.c 初始化硬體管理器。

  • 籠統的說,這些步驟,就是做了兩件事情

    1. 把作業系統程式碼讀到記憶體中,讀到記憶體中,CPU才可以取指執行
    2. 初始化工作,準備一些用於管理硬體裝置的資料結構
  • 後面我們還會回過頭來看這裡準備的這些資料結構