寫作業系統之實現程式

東小夫發表於2021-10-17

C語言和組合語言混合程式設計

方法

本節的“混合程式設計”不是指在C語言中使用匯編語言,或在組合語言中使用C語言。它是指在C程式中使用匯編語言編寫的函式、變數等,或者反過來。

混合程式設計的核心技巧是兩個關鍵字:externglobal

有A、B兩個原始碼檔案,A是C語言原始碼檔案,B是組合語言原始碼檔案。在B中定義了變數、函式,要用global匯出才能在A中使用;在B中要使用A中的變數、函式,要用extern匯入。

用最簡單的話說:使用extern匯入,使用global匯出。

在A中使用B中的函式或變數,只需在B中匯出。

在B中使用A中的函式或變數,只需在B中匯入。

是這樣嗎?

請看例程。

例程

程式碼

bar.c是C語言寫的原始碼檔案,foo.asm是彙編寫的原始碼檔案。在foo.asm中使用bar.c中建立的函式,在bar.c中使用foo.asm提供的函式。foo.asm建立的函式用global匯出,使用bar.c中建立的函式前使用extern匯入。

foo.asm。

extern choose

[section .data]

GreaterNumber	equ	51
SmallerNumber equ	23

[section .text]

global _start
global _displayStr

_start:
	push	GreaterNumber
	push	SmallerNumber
	call choose
	add [esp+8]	; 人工清除引數佔用的棧空間
	
	; 必須呼叫 exit,否則會出現錯提示,程式能執行。
	mov eax, 1	; exit系統呼叫號為1
	mov ebx, 0	; 狀態碼0:正常退出
	int 0x80
	
	ret
	
; _displayStr(char *str, int len)	
_displayStr:
	mov eax, 4	; write系統呼叫號為4
	mov ebx, 1	; 檔案描述符1:標準輸出stdout
	; 按照C函式呼叫規則,最後一個引數最先入棧,它在棧中的地址最大。
	mov ecx, [ebp + 4]		; str
	mov edx, [ebp + 8]		; len。ebp + 0 是 cs:ip中的ip
	int 0x80
	
	ret	; 一定不能少

bar.c。

void choose(int a, int b)
{
  if(a > b){
    _displayStr("first", 5);
  }else{
    _displayStr("second", 6);
  }
  
  return;
}

程式碼講解

匯入匯出

extern choose,把choose函式匯入到foo.asm中。

global _displayStr,匯出foo.asm中的函式,提供給其他檔案例如bar.c使用。

系統呼叫

系統呼叫模板。

; code-A
mov eax, 4	; write系統呼叫號為4
mov ebx, 1	; 檔案描述符1:標準輸出stdout
; 按照C函式呼叫規則,最後一個引數最先入棧,它在棧中的地址最大。
mov ecx, [ebp + 4]		; str
mov edx, [ebp + 8]		; len。ebp + 0 是 cs:ip中的ip
int 0x80

; code-B
; 必須呼叫 exit,否則會出現錯提示,程式能執行。
mov eax, 1	; exit系統呼叫號為1
mov ebx, 0	; 狀態碼0:正常退出
int 0x80

兩段程式碼展示了系統呼叫int 0x80的用法。暫時不必理會“系統呼叫”這個概念。

使用int 0x80時,eax的值是希望執行的系統函式的編號。例如,exit的編號是1,當eax中的值是1時,int 0x80會呼叫exitwrite的編號是4。

exit和write的函式原型如下。

void exit(int status);
int write(int handle, void *buf, int nbyte);

ebxecxedx中的值分別是系統呼叫函式的第一個引數、第二個引數、第三個引數。

編譯執行

用下面的命令編譯然後執行。

nasm -f elf foo.o foo.asm
gcc -o bar.o bar.c -m32
ld -s -o kernel.bin foo.o bar.o -m elf_i386
# 執行
./kernel.bin

切換堆疊和GDT

是什麼

在《開發載入器》中,我們已經完成了一個簡單的核心。那個核心是用匯編語言寫的,使用的GDT在真實模式下建立。在以後的開發中,我們講主要使用C語言開發。能在C語言中使用匯編語言中的變數,例如GDT,可是,要往GDT中增加一個全域性描述符或修改全域性描述符的屬性,在C語言中,就非常不方便了。到了用C語言編寫的核心原始碼中,要想方便地修改GDT或其他在彙編中建立的變數,需要把用匯編原始碼建立的GDT中的資料複製到用C語言建立的變數中來。

切換GDT,簡單地說,就是,把彙編原始碼中的變數的值複製到C語言原始碼中的變數中來。目的是,在C語言原始碼中更方便地使用這些資料。

切換堆疊,除了使用更方便,還為了修改堆疊的地址。

切換堆疊的理由,我也不是特別明白這樣做的重要性。

怎麼做

  1. 彙編程式碼檔案kernel.asm,C語言程式碼檔案main.c。
  2. 在C語言程式碼中定義變數gdt_ptr
  3. 在kernel.asm中匯入gdt_ptr
  4. 在kernel.asm中使用sgdt [gdt_ptr]把暫存器gdtptr中的資料儲存到變數gdt_ptr中。
  5. 在main.c中把GDT複製到main.c中的新變數中,並且修改GDT的界限。
  6. 在kernel.asm中重新載入gdt_ptr中的GDT資訊到暫存器gdtptr中。

請在下面的程式碼中體會上面所寫的流程。

image-20211014140616238

程式碼講解

sgdt [gdt_ptr],把暫存器gdtptr中的資料儲存到外部變數gdt_ptr中。

暫存器gdtptr中儲存的資料的長度是6個位元組,前2個位元組儲存GDT的界限,後4個位元組儲存GDT的基地址。在《開發引導器》中詳細講解過這個暫存器的結構,不清楚的讀者可以翻翻那篇文章。

切換GDT

void Memcpy(void *dst, void *src, int size);
typedef struct{
        unsigned short seg_limit_below;
        unsigned short seg_base_below;
        unsigned char  seg_base_middle;
        unsigned char seg_attr1;
        unsigned char seg_limit_high_and_attr2;
        unsigned char seg_base_high;
}Descriptor;

Descriptor gdt[128];

Memcpy(&gdt,
                (void *)(*((int *)(&gdt_ptr[2]))),
                *((short *)(&gdt_ptr[0])) + 1
        );

把在真實模式下建立的GDT複製到新變數gdt中。

這段程式碼非常考驗對指標的掌握程度。我們一起來看看。

  1. gdt_ptr[2]是gdt_ptr的第3個位元組的資料。
  2. &gdt_ptr[2]是gdt_ptr的儲存第3個位元組的資料的記憶體空間的記憶體地址。
  3. (int *)(&gdt_ptr[2])),把記憶體地址的資料型別強制轉換成int *。一個記憶體地址,只能確定是一個指標型別,但不能確定是指向哪種資料的指標。強制轉換記憶體地址的資料型別為int *後,就明確告知編譯器這是一個指向int資料的指標。
  4. 指向int資料的指標意味著什麼?指標指向的資料佔用4個位元組。
  5. (*((int *)(&gdt_ptr[2])))是指標(int *)(&gdt_ptr[2]))指向的記憶體空間(4個位元組的記憶體空間)中的值。從暫存器gdtptr的資料結構看,這個值是GDT的基地址,也是一個記憶體地址。
  6. Memcpy的函式原型是void Memcpy(void *dst, void *src, int size);,第一個引數dst的型別是void *,是一個記憶體地址。(void *)(*((int *)(&gdt_ptr[2])))中最外層的(void *)把記憶體地址強制轉換成了void *型別。
  7. gdt_ptr[0])是gdt_ptr的第1個位元組的資料,&gdt_ptr[0])是儲存gdt_ptr的第1個位元組的資料的記憶體空間的記憶體地址。
  8. (short *)(&gdt_ptr[0]),把記憶體地址的資料型別強制轉換成short *short *ptr這種型別的指標指向兩個位元組的記憶體空間。假如,ptr的值是0x01,那麼,short *ptr指向的記憶體空間是記憶體地址為0x010x02的兩個位元組。
  9. 說得再透徹一些。short *ptr,指向一片記憶體空間,ptr的值是這片記憶體空間的初始地址,而short *告知這片記憶體空間的長度。short *表示這片記憶體空間有2個位元組,int *表示這片記憶體空間有4個位元組,char *表示這片記憶體空間有1個位元組。
  10. *((short *)(&gdt_ptr[0]))是記憶體空間中的值,是gdt_ptr[0]、gdt_ptr[1]兩個位元組中儲存的資料。從gdtptr的資料結構來看,這兩個位元組中儲存的是GDT的界限。
  11. GDT的長度 = GDT的界限 + 1。
  12. 現在應該能理解Memcpy這條語句了。從GDT的基地址開始,複製GDT長度那麼長的資料到變數gdt中。
  13. gdt是C程式碼中儲存GDT的變數,它的資料型別是Descriptor [128]
  14. Descriptor是表示描述符的結構體。gdt是包含128個描述符的陣列。這符合GDT的定義。
  15. 把GDT從彙編程式碼中的變數或者說某個記憶體空間複製到gdt所表示的記憶體空間後,就完成了GDT的切換。

修改gdtptr

先看程式碼,然後講解程式碼。下面的程式碼緊跟上面的程式碼。可在本節的開頭看全部程式碼。

short *pm_gdt_limit = (short *)(&gdt_ptr[0]);
int *pm_gdt_base = (int *)(&gdt_ptr[2]);

//*pm_gdt_limit = 128 * sizeof(Descriptor) * 64 - 1;
*pm_gdt_limit = 128 * sizeof(Descriptor) - 1;
*pm_gdt_base = (int)&gdt;
  1. 由於GDT已經被儲存到了新的記憶體空間中,以後將使用這片記憶體中的GDT,所以,需要更新暫存器gdtptr中儲存的GDT的基地址和GDT界限。
  2. 使用lgdt [gdt_ptr]更新gdtptr中的值。要更新gdtptr中的值,需要先更新gdt_ptr中的值。
  3. 更新gdt_ptr的過程,又是玩耍指標的過程。熟悉指標的讀者,能輕鬆看懂這幾條語句,不需要我囉裡囉嗦的講解。
  4. 在前面,我說過,指標,表示這個變數的值是一個記憶體地址;而指標的型別(或者說指標指向的資料的資料型別)告知從這個記憶體地址開始有多少個位元組的資料。再說得簡單一些:指標,告知記憶體的初始地址;指標的型別,告知記憶體的長度。
  5. pm_gdt_limit是一個short *指標。它的含義是是一段初始地址是&gdt_ptr[0]、長度是2個位元組的記憶體空間。這是什麼?聯想到暫存器gdtptr的資料結構,它是GDT的界限。
  6. 用通用的方法理解pm_gdt_base。它是GDT的基地址。
  7. 新的GDT表中有128個描述符,相應地,新GDT的界限是128個描述符*一個描述符的長度-1
  8. 更新gdt_ptr的前2個位元組的資料的語句是*pm_gdt_limit = 128 * sizeof(Descriptor) - 1
  9. 如果理解這種語句有困難,就理解它的等價語句:mov [eax], 54
  10. 新GDT的基址是多少?就是變數gdt的記憶體地址。

更新gdt_ptr的值後,在彙編程式碼中用lgdt [gdt_ptr]重新載入新GDT到暫存器gdtptr中。就這樣,完成了切換GDT。

切換堆疊

程式碼講解

esp的中的資料修改為進入核心後的一段記憶體空間的初始地址,這就是切換堆疊。

切換堆疊的相關程式碼如下。我們先看程式碼,然後理解關鍵語句。

[section .bss]
Stack   resb    1024*2
StackTop:


[section .text]
mov esp, StackTop

Stack resb 1024*2,從Stack所表示的記憶體地址開始,把2048個byte設定成0。

StackTop:是一個標號,表示一段2kb的記憶體空間的堆疊的棧頂。

mov esp, StackTop,把堆疊的棧頂設定成StackTop

[section .bss],表示後面的程式碼到下一個節標識前都是.bss節,一般放置未初始化或初始化為0的資料。

[section .text],表示後面的程式碼到下一個標識前都是.text節,一般放置指令。

在組合語言寫的原始檔中,這些節標識是給程式設計師看的,非要把資料初始化寫到.text節,編譯器也能給你正常編譯。

push 0
;push 0xFFFFFFFF
popfd

popfd把當前堆疊棧頂中的值更新到eflags中。

eflags

eflags是一個暫存器。先看看它的結構圖。

eflags

不用我多說,看一眼這張圖,就知道它有多複雜。

eflags有32個位元組,幾乎每個位元組都有不同的含義。要完全弄明白它很費時間。我只掌握下面這些。

  1. 在bochs中檢視eflags的值,使用info eflags。對檢視到的結果,大寫的位表示為1,小寫的表示為0;如:SF 表示1,zf 表示0。
  2. 使用popfdpopf能更新eflags的值。
    1. pof,將棧頂彈入 EFLAGS 的低 16 位。
    2. popfd,將棧頂彈入 EFLAGS的全部空間。
  3. 算術運算和eflags的狀態標誌(即CF、PF、AF、ZF、SF)這些有關係。例如,算術運算的結果的最高位進位或錯位,CF將被設定成1,反之被設定成0。

下面是在bochs中檢視到的eflags中的值。

# code-A
eflags 0x00000002: id vip vif ac vm rf nt IOPL=0 of df if tf sf zf af pf cf
<bochs:4> info eflags
id vip vif ac vm rf nt IOPL=0 of df if tf sf zf af pf cf
# code-B
eflags 0x00247fd7: ID vip vif AC vm rf NT IOPL=3 OF DF IF TF SF ZF AF PF CF
<bochs:9> info eflags
ID vip vif AC vm rf NT IOPL=3 OF DF IF TF SF ZF AF PF CF

錯位是什麼?

code-A。eflags 0x00000002的意思是,eflags的初始值是0x00000002。對照eflags的結構圖,第1位是保留位,值是1,其他位全是0,最終結果就是0x2。

code-B。eflags 0x00247fd7,eflags的初始值是0x00247fd7。為什麼會是這個結果?因為我入棧資料0xFFFFFFFF,然後執行了popfd。換言之,我把eflags的值設定成了0xFFFFFFFF。然而eflags中有些保留位的值總是0,有些非保留位又必須是某種值,0xFFFFFFFF和這些預設值(或保留值)綜合之後變成了0x00247fd7。

說了這麼多,最重要的一點是:解讀info eflags的結果。eflags 0x00000002中的0x00000002是eflags中的資料。

中斷

本篇的中心是實現程式,我想從本文第一個字開始就寫怎麼實現程式。可是實現程式需要用到GDT、堆疊,還要用到中斷。不先寫這些,先寫程式,寫程式時要用到這些再寫這些內容,那成了倒敘了。到時候,寫程式的實現不能連貫又要插入中斷這些內容。

是什麼

現代作業系統都支援多程式。我們的電腦上同時執行瀏覽器、聽歌軟體和編輯器,就是證明。同一時刻、同一個CPU只執行一個程式。CPU怎麼從正在執行的程式轉向執行另外一個程式呢?為程式切換提供契機的就是中斷。

CPU正在執行瀏覽器,我們敲擊鍵盤,會產生鍵盤中斷,CPU會執行鍵盤中斷例程。

CPU正在執行瀏覽器,即使我們沒有敲擊鍵盤,可是CPU已經被瀏覽器使用了很長時間,按一定頻率產生的時鐘中斷髮生,CPU會在執行時鐘中斷例程時切換到其他程式。

上面是幾個中斷的例子。看了這些例子,回答一下中斷是什麼?中斷,就是停止正在執行的指令,陷入作業系統指令,目的往往是切換到其他指令。

實現機制--通俗版

再用通俗的例子,從中斷的實現機制,說明中斷是什麼。用陣列對比理解中斷的實現機制。

  1. 中斷髮生,產生一箇中斷向量號(陣列索引)。
  2. 在IDT(陣列)中,根據向量號(陣列索引),找到中斷例程(陣列元素)。
  3. 執行中斷例程。

簡單說,發生了一件事,CPU中止手頭的工作,去執行另一種工作。每件事都有對應的工作。

中斷分為內部中斷和外部中斷。

讀取軟盤使用的int 13是內部中斷,敲擊鍵盤產生的中斷是外部中斷,時鐘中斷也是外部中斷。

實現機制--嚴謹版

實現流程

  1. 建立中斷向量表。
  2. 建立變數IdtPtr,用GdtPtr類比理解。必須緊鄰中斷向量表。
  3. 建立中斷向量表中的向量對應的中斷例程。不確定是否需要緊鄰中斷向量表,我目前是這麼做的。
  4. lidt [IdtPtr]把中斷向量表的記憶體地址等資訊載入到暫存器IDTPtr。用GdtPtr暫存器類比理解這個暫存器。

工作流程

  1. 根據中斷向量號在IDT中找到中斷描述符。
  2. 中斷描述符中包含中斷例程所在程式碼段的選擇子和中斷例程在程式碼段中的偏移量。
  3. 根據選擇子和偏移量找到中斷例程。
  4. 執行中斷例程。

再看看下面這張圖,想必能很好理解上面的文字了。

image-20211015174541486

程式碼

前面講過的工作流程,會在下面的程式碼中一一體現。一起來看看。

建立IDT

IDT是中斷向量表。類似GDT,也是記憶體中的一塊區域。但它內部包含的全是"門描述符"。

IDT中的每個門描述符的名稱是中斷向量號,選擇子是中斷向量號對應的處理中斷的程式碼。

; 門
; 門描述符,四個引數,分別是:目的碼段的偏移量、目的碼段選擇子、門描述符的屬性、ParamCount
%macro Gate 4
        dw      (%1 & 0FFFFh)                           ; 偏移1
        dw      %2                                      ; 選擇子
        dw      (%4 & 1Fh) | ((%3 << 8) & 0FF00h)       ; 屬性
        dw      ((%1 >> 16) & 0FFFFh)                   ; 偏移2
%endmacro ; 共 8 位元組

; 門描述符,四個引數,分別是:目的碼段的偏移量、目的碼段選擇子、門描述符的屬性、ParamCount
; 屬性是 1110,或者是0111。不確定是小端法或大端法,所以有兩種順序的屬性。
; 屬性是 0111 0001。錯誤.
; 屬性應該是 1000 1110
[SECTION .idt]
ALIGN 32
[BITS 32]
LABEL_IDT:
%rep    32
        Gate    Superious_handle, SelectFlatX, 08Eh, 0 ; 屬性是 1110,或者是0111。不確定是小端法或大端法,所以有兩種順序的屬性。
%endrep
.20h:   Gate    ClockHandler,   SelectFlatX, 08Eh, 0
;.80h:  Gate    ClockHandler,   SelectFlatX, 08Eh, 0
%rep 222
        Gate    Superious_handle, SelectFlatX, 08Eh, 0
%endrep
IDT_LEN equ     $ - LABEL_IDT
IdtPtr  dw      IDT_LEN - 1
        dd      0
;END OF [SECTION .idt]

SelectFlatX是一個GDT選擇子,ClockHandler是中斷例程在這個選擇子指向的程式碼段中的偏移量。

IDT是門描述符(一種資料結構,類似GDT中的全域性描述符)組成的表,佔據一段記憶體空間。確定一段記憶體空間只需兩個要素:初始地址和界限大小。IdtPtr就提供了這兩個要素。

使用下面的語句把IDT的初始地址和界限大小載入到IDTPtr暫存器。

lidt [IdtPtr]

建立中斷例程

_SpuriousHandler:
SpuriousHandler	equ	_SpuriousHandler - $$
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd
	
_UserIntHandler:
UserIntHandler	equ	_UserIntHandler - $$
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd
	
_ClockHandler:
ClockHandler    equ     _ClockHandler - $$
        ; error: operation size not specified
        ;inc [gs:(80*20 + 21)*2]
        xchg bx, bx
        inc byte [gs:(80*20 + 21)*2]
        ; 傳送EOF
        mov al, 20h
        out 20h, al
        ; 不明白iretd和ret的區別
        iretd	

上面的程式碼和IDT在同一程式碼段。SpuriousHandlerUserIntHandler是段內的兩個偏移量。

_SpuriousHandler:
SpuriousHandler	equ	_SpuriousHandler - $$
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd

不能完全理解這種寫法。不過,可暫且當作一種語法規則去記住就行了。以後有類似功能需要實現,就用這種寫法。不過,我也試著分享一下自己的看法。

上面的程式碼其實等價於下面的程式碼。

SpuriousHandler	equ	_SpuriousHandler - $$

_SpuriousHandler:
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd

要了解這段程式碼,需瞭解一點彙編知識。

組合語言中有個概念“標號''。上面的SpuriousHandler_SpuriousHandler就是標號,前者是非指令的標號,後者是指令的標號。指令的標號必須在識別符號後面加上冒號。上面的中斷例程中的_SpuriousHandler後面確實有冒號。指令前的標號表示這塊指令中的第一條指令的記憶體地址。

使用巨集Gate建立中斷描述符,需要用到中斷例程在段內的偏移量。_SpuriousHandler是在記憶體中的記憶體地址,這個記憶體地址是相對於本段段首的記憶體地址還是相對於其他某個參照量呢?我也不知道。假如,這個記憶體地址是相對於其他某個參照量,它就不是在本段內的偏移量。為了確保在巨集中使用的是偏移量,因此我們使用_SpuriousHandler - $$也就是SpuriousHandler而不是_SpuriousHandler

當中斷向量號是在0~127(包括0和127)時,會執行中斷處理程式碼SpuriousHandler

當中斷向量號是080h時,會執行中斷處理程式碼UserIntHandler

呼叫中斷

呼叫中斷的語句很簡單,如下:

int 00h
int 01h
int 10h
int 20h

小結

  1. 中斷,由硬體或軟體觸發,迫使CPU停止執行當前指令,切換到其他指令,目的是讓計算機響應新工作。
  2. 提供中斷向量,在IDT中尋找中斷描述符,根據在中斷描述符中找到的段選擇子和偏移量,找到中斷例程並執行。
  3. 一句話,根據中斷向量找到中斷例程並執行。
  4. 為什麼能根據中斷向量找到中斷例程?這是更底層的硬體工作機制。我們只需為硬體提供IDT的初始地址和界限就行。
  5. 本小節介紹中斷省略了中斷髮生時儲存CPU的快照、堆疊和特權級的轉移。

外部中斷

時鐘中斷、鍵盤中斷、滑鼠中斷,還有其他中斷在同一時刻產生,CPU應該處理哪個中斷?事實上,CPU並不直接和這些產生中斷的硬體打交道,而是通過一個”代理''。代理會按照優先順序規則從眾多中斷中挑選一個再交給CPU處理。

處理中斷的這個代理就是8259A。它是一個硬體。看看8259A的示意圖。

image-20211016081331282

8259A只是處理可遮蔽中斷的代理。

8259A

我們使用兩個8259A,一個是主片,一個是從片,從片掛載在主片上。

8259A有8個引腳,每個引腳能掛載一個會產生中斷的硬體,例如硬碟;當然,每個引腳也能掛載另外一個8259A。主從兩個8259A能掛載15個硬體。

8259A是可程式設計硬體,通過向相應的埠寫入ICW和OCW資料實現程式設計。下面的程式碼展示瞭如何對它程式設計。

初始化8259A

; /home/cg/os/pegasus-os/v25/kernel.asm
Init_8259A:
        ; ICW1
        mov al, 011h
        out 0x20, al
        call io_delay

        out 0xA0, al
        call io_delay

        ; ICW2
        mov al, 020h
        out 0x21, al
        call io_delay

        mov al, 028h
        out 0xA1, al
        call io_delay

        ; ICW3
        mov al, 004h
        out 0x21, al
        call io_delay

        mov al, 002h
        out 0xA1, al
        call io_delay

        ; ICW4
        mov al, 001h
        out 0x21, al
        call io_delay

        out 0xA1, al
        call io_delay
        
        ; OCW1
        ;mov al, 11111110b
        mov al, 11111101b
        out 0x21, al
        call io_delay

        mov al, 11111111b
        out 0xA1, al
        call io_delay
        
        ret

; 讓CPU空轉四次
io_delay:
				; 讓CPU空轉一次
        nop
        nop
        nop
        nop
        ret        

ICW和OCW的資料結構

ICW的全稱是Initialization Command Word,OCW的全稱是Operation Commnd Word。

OCW簡單一些,只需寫入ICW1就能滿足我們的需求。向主8259A的0x21寫入OCW1,向從8259A的0xA1寫入OCW1,作用是遮蔽或放行某個中斷,1表示遮蔽,0表示放行。

四個ICW都被用到了。必須按照下面的順序寫入ICW和OCW。

  1. 通過0x20埠向主8259A寫入ICW1,通過0xA0埠向從8259A寫入ICW1。
  2. 通過0x21埠向主8259A寫入ICW2,通過0xA1埠向從8259A寫入ICW2。
  3. 通過0x21埠向主8259A寫入ICW3,通過0xA1埠向從8259A寫入ICW3。
  4. 通過0x21埠向主8259A寫入ICW4,通過0xA1埠向從8259A寫入ICW4。
  5. 向主8259A的0x21寫入OCW1,向從8259A的0xA1寫入OCW1。

像這樣寫入資料,有點怪異。

寫入ICW2、ICW3、ICW4的埠相同,怎麼區分寫入的資料呢?我是這樣理解的。由8259A自己根據寫入的埠和順序來識別。對於主片,第一次向0x20埠寫入的是ICW1,第二次向0x21埠寫入的是ICW2,第三次向0X21埠寫入的是ICW3,第五次向0x21埠寫入的是OCW1。從片也像這樣根據埠和順序來識別接收到的資料。

為什麼要這麼寫程式碼?我猜測,是8259A提供的使用方法要求這麼做。不必糾結這個。就好比使用微信支付的SDK,不需要糾結為啥要求按某種方式傳參一樣。

需要我們仔細琢磨的是ICW和OCW的資料結構。先看它們的資料結構圖,再逐個分析它們的值。

image-20211016074508539

ICW1的值是011h,把011h轉換成二進位制資料是00010001b。對照ICW1的資料結構,第0位是1,表示需要寫入ICW4。第1位是0,表示主8259A掛載了從8259A。第4位是1,因為8259A規定ICW1的第4位必須是1。其他位都是0,對照上圖中的ICW1能很容易看明白。

ICW2表示8259A掛載的硬體對應的中斷向量號的初始值。主8259A的ICW2的值是020h,表示掛載在它上面的硬體對應的中斷向量號是020h到027h。主8259A的ICW2的值是028h,表示掛載在它上面的硬體對應的中斷向量號是028h到02Fh。

ICW3的資料結構比較特別,主片的ICW3的資料結構和從片的ICW3的格式不同。主片的ICW3用點陣圖表示序號,從片的ICW3用數值表示序號。主片的ICW3是004h,把004h轉化成二進位制數00000100b,第2個bit是1,表示主片的第2個引腳(引腳編號的初始值是0)掛載從片。從片的ICW3是002h,數值是2。

ICW3的工作機制是這樣的。從片認為自己掛載到了主片的第2個引腳上。主片向所有掛載在它上面的硬體傳送資料時,資料中會包含這些資料的接收方是掛載在第2個引腳上的硬體。每個硬體接收到資料後,檢查一下自己的ICW3中的值是不是2,如果是2,就認為這些資料是發給自己的;如果不是2,就認為這些資料不是發給自己的然後丟棄這些資料。

OCW1的結構很簡單。下圖右側的註釋是主片的,如果是從片,只需把IRQ0到IRQ7換成IRQ0到IRQ15。

image-20211016081257951

主片的OCW1是11111101b,表示開啟放行鍵盤中斷;如果是11111110b,表示放行時鐘中斷。

從片的OCW1是11111111b,表示遮蔽IRQ8到IRQ15這些中斷。

實現單程式

程式是一個執行中的程式實體,擁有獨立的邏輯控制流和地址空間。這是程式的標準定義。

我們實現程式,關注程式的三要素:程式體、程式表和堆疊。程式體所在的程式碼段和堆疊所在的堆疊段共同構成程式的地址空間。獨立的邏輯控制流是指每個程式好像擁有一個獨立的CPU。

什麼叫擁有獨立的CPU?我的理解是,程式在執行過程中,資料不被非自身修改,核心是:不受干擾,就像CPU只執行一個程式一樣不受非法修改資料、程式碼等。

下面詳細介紹程式的三要素。

程式三要素

程式體

用C語言編寫程式體。外觀上,程式體是一個C語言編寫的函式。例如下面的函式TestA

image-20211016100421769

程式表

CPU的快照

CPU並不是一直執行一個程式,而是總是執行多個程式。怎麼實現讓程式獨享CPU的效果呢?使用程式表實現。

CPU執行A、B程式。A程式執行1分鐘後,CPU選擇了B程式執行,B程式執行1分鐘後,CPU又開始執行A程式。假如A程式中有一個區域性變數num,num的初始值是0;第一次執行結束時,num的值變成了5;當A程式重新開始執行時,必須保證區域性變數的值是5,而不是0。不僅是num的值需要是第一次執行結束時的值,A的所有值都必須是第一次執行結束時的值。

說了這麼多,只為了引申出程式表的作用。程式表在程式初始化時提供程式的初始資訊,在程式被休眠時儲存當前CPU的值,換句話說,為CPU建立一張快照。

為CPU建立快照,就是把暫存器中的值儲存起來。儲存到哪裡?儲存到程式表。

程式表的主要元素是一系列暫存器的值,然後還有一些程式資訊,例如程式的名稱、優先順序等。

// 程式表 start
typedef struct{
        // 中斷處理程式壓棧,手工壓棧
        unsigned int gs;
        unsigned int fs;
        unsigned int es;
        unsigned int ds;
        // pushad壓棧,順序固定
        unsigned int edi;
        unsigned int esi;
        unsigned int ebp;
        // 在這裡消耗了很多時間。為啥需要在這裡補上一個值?這是因為popad依次出棧的資料中有這麼個值,
        // 如果不補上這一位,出棧時資料不能依次正確更新到暫存器中。
        unsigned int kernel_esp;
        unsigned int ebx;
        unsigned int edx;
        unsigned int ecx;
        unsigned int eax;
        // 中斷髮生時壓棧
        unsigned int eip;
        unsigned int cs;
        unsigned int eflags;
        unsigned int esp;       // 漏掉了這個。iretd會出棧更新esp。
        unsigned int ss;
}Regs;

typedef struct{
        Regs s_reg;
        // ldt選擇子
        unsigned short ldt_selector;
        // ldt
        Descriptor ldts[2];
        unsigned int pid;
}Proc;

Proc是程式表,有四部分組成:暫存器組、GDT選擇子ldt_selector、LDT、程式ID--pid。

暫存器組儲存CPU的快照資料,儲存在棧s_reg中。根據入棧操作的執行方式不同,分為三部分。

手工壓棧入棧的是和下列變數同名的暫存器的值。

// 中斷處理程式壓棧,手工壓棧
unsigned int gs;
unsigned int fs;
unsigned int es;
unsigned int ds;

用pushad入棧的是和下列變數同名的暫存器的值。

// pushad壓棧,順序固定
unsigned int edi;
unsigned int esi;
unsigned int ebp;
// 在這裡消耗了很多時間。為啥需要在這裡補上一個值?這是因為popad依次出棧的資料中有這麼個值,
// 如果不補上這一位,出棧時資料不能依次正確更新到暫存器中。
unsigned int kernel_esp;
unsigned int ebx;
unsigned int edx;
unsigned int ecx;
unsigned int eax;

中斷髮生時自動入棧的是和下列變數同名的暫存器的值。

 // 中斷髮生時壓棧
 unsigned int eip;
 unsigned int cs;
 unsigned int eflags;
 unsigned int esp;       // 漏掉了這個。iretd會出棧更新esp。
 unsigned int ss;

入棧的順序是這樣的:中斷髮生時自動入棧------>pushad入棧------>手工入棧。

出棧的順序是這樣的:手工出棧------>popad出棧------>iretd出棧。

暫存器中的資料的入棧順序和s_reg的成員的上下順序相反。

s_reg的成員的上下順序和暫存器中的資料出棧的順序一致。

IA-32的pushad指令在堆疊中按順序壓入這些暫存器的值:EAX,ECX,EDX,EBX,ESP,EBP,ESI和EDI。

IA-32的popad指令從堆疊中按順序彈出棧元素到這些暫存器:EDI,ESI,EBP,ESP,EBX,EDX,ECX,EAX。

可以看出,pushad和popad正好是相反的運算。

需要注意,CPU的esp中的值並不是由pushad壓入堆疊,而是在中斷髮生時自動壓入堆疊的。這麼說還不準確。pushad入棧的資料雖然也是esp中的值,卻不是為程式建立快照時需要儲存的值。發生中斷前,堆疊是程式的堆疊;發生中斷進入中斷例程後,堆疊已經變成了核心棧。為程式建立快照需要儲存的是程式的堆疊棧頂而不是核心堆疊的棧頂。esp的值有兩次被壓入s_reg,但是,CPU恢復之前的程式時使用的是中斷髮生時自動壓入堆疊的esp中的值。

恢復之前的程式時,先把之前由pushad入棧的esp的值更新到esp中,最後被iretd把中斷髮生時自動壓入堆疊的esp中的值更新到esp中。這正是我們期望的結果。

能不能刪除s_reg的kernel_esp成員?不能。pushad、popad總是操作8個資料和對應的暫存器,如果缺少一個資料,建立快照或從快照中恢復時會出現資料錯誤。

有一個疑問需要解釋:s_reg明明是一個結構體,怎麼說它是棧,還對它進行壓棧出棧操作呢?

我認為這是一個疑問,是受高階程式語言中的堆疊影響。在潛意識中,我認為,先進後出的結構才是堆疊;具備這種特性還不夠,似乎還必須是一個結構體。完全不是這樣的。只要按照“先進後出”的原則對一段儲存空間的資料進行操作,這段儲存空間就能被稱之為堆疊。

其他

ldt_selector這個名字不是很好,因為,它是GDT的選擇子,卻容易讓人誤以為是LDT的選擇子。

LDT是一段記憶體空間,和區域性描述符的集合。GDT是全域性描述符的集合。區域性描述符和全域性描述符的資料結構一樣。通過ldt_selector在GDT中找到對應的全域性描述符。這個全域性描述符記錄LDT的資訊:初始地址和界限大小。

ldts就是LDT,只包含兩個描述符,一個是程式碼段描述符,一個是資料段、堆疊段等描述符。它被儲存在程式表中。

pid是程式ID。

堆疊

int proc_stack[128];
// proc 是程式表
proc->s_reg.esp = (int)(proc_stack + 128);

在前面已經說過,堆疊也好,佇列也好,都是一段儲存空間,只需按照特定的原則讀寫這段儲存空間,例如“先進後出”,就是堆疊或佇列。不要以為只有像那些資料結構書中那樣定義的資料結構才是堆疊或佇列。

把esp設定成proc_stack + 128,體現了堆疊從高地址向低地址向下生長。堆疊的資料型別是int [128],對應esp是一個32位暫存器。

proc_stck是堆疊的初始地址,加128後,已經到了堆疊之外。把初始棧頂的值設定成堆疊之外,有沒有問題?

沒有問題。push指令的執行過程是:先移動棧頂指標,再儲存資料。初始狀態,往堆疊中入棧4,過程是這樣的:

  1. 堆疊指標減去1,esp的值是 proc_stack + 128 - 1proc_stack + 127在堆疊proc_stack內部,從陣列角度看,是最後一個元素。
  2. 再儲存資料,proc_stack[127] = 4

啟動程式

準備好程式三要素後,怎麼啟動程式?啟動程式包括程式“第一次”啟動和程式“恢復”啟動。先探討程式“第一次啟動”。

程式“第一次啟動”,是特殊的“恢復”啟動。是不是這樣呢?是可以的。在一個程式死亡前,將會經歷很多次“恢復”啟動,第一次啟動只是從初始狀態“恢復”啟動而已。CPU不關注程式是第一次啟動還是“恢復”啟動。程式計數器指向什麼指令、暫存器中有什麼資料,它就執行什麼指令。我們只需把程式的初始快照放入CPU就能啟動程式。快照在程式表中,要把程式表中的值放入CPU。下面的restart把程式快照放入CPU並啟動程式。

; 啟動程式
restart:
        mov eax, proc_table
        mov esp, eax
        ; 載入ldt
        lldt [proc_table + 68]
        ; 設定tss.esp0
        lea eax, [proc_table + 68]
        mov [tss + 4], eax
        ; 出棧
        pop gs
        pop fs
        pop es
        pop ds
        
        popad
        iretd

這個函式的每行程式碼都大有玄機,需要琢磨一番。

restart做了兩件事:恢復快照,把程式計數器指向程式體。

恢復快照分為設定LDTPtr暫存器、設定tss.esp0、設定其他暫存器的值。

lldt [proc_table + 68],設定LDTR暫存器的值。LDTR的資料結構和選擇子一樣。sizeof(Regs)的值是68,所以proc_table + 68是程式表的成員ldt_selector的記憶體地址。

在這裡耗費了不少時間,原因是我誤以為LDTR的結構和GDTR的結構相同。二者的結構並不相同。

mov [tss + 4], eax,設定tss.esp0。tss是個比較麻煩的東西,在後面專門講解。

pop gsiretd,設定其他暫存器的值。

iretd,依次出棧資料並更新ss、esp、eflags、cs、eip`這些暫存器的值。

cs、eip被更新為程式的程式碼段和程式體的入口地址後,程式就啟動了。

程式碼

void kernel_main()
{
	Proc *proc = proc_table;
	
	proc->ldt_selector = LDT_FIRST_SELECTOR;
	
	Memcpy(&proc->ldts[0], &gdt[CS_SELECTOR_INDEX], sizeof(Descriptor));
	// 修改ldt描述符的屬性。全域性cs的屬性是 0c9ah。
	proc->ldts[0].seg_attr1 = 0xba;
	Memcpy(&proc->ldts[1], &gdt[DS_SELECTOR_INDEX], sizeof(Descriptor));
	// 修改ldt描述符的屬性。全域性ds的屬性是 0c92h
	proc->ldts[1].seg_attr1 = 0xb2;

	// 初始化程式表的段暫存器
	proc->s_reg.cs = 0x05;	// 000 0101		
	proc->s_reg.ds = 0x0D;	// 000 1101		
	proc->s_reg.fs = 0x0D;	// 000 1101		
	proc->s_reg.es = 0x0D;	// 000 1101		
	//proc->s_reg.ss = 0x0D;	// 000 1101	
	proc->s_reg.ss = 0x0D;	// 000 1100	
	// 0x3b--> 0011 1011 --> 0011 1 001
	// 1001
	proc->s_reg.gs = GS_SELECTOR & (0xFFF9);
	// proc->s_reg.gs = 0x0D;
	// 初始化程式表的通用暫存器
	proc->s_reg.eip = (int)TestA;
	proc->s_reg.esp = (int)(proc_stack + 128);
	// 抄的於上神的。需要自己弄清楚。我已經理解了。
	// IOPL = 1, IF = 1
	// IOPL 控制I/O許可權的特權級,IF控制中斷的開啟和關閉
	proc->s_reg.eflags = 0x1202;	
	// 啟動程式,扣動扳機,砰!		
	
	dis_pos = 0;
	// 清屏
	for(int i = 0; i < 80 * 25 * 2; i++){
		disp_str(" ");
	}	

	restart();

	while(1){}
}

程式碼的條理很清晰,做了下面幾件事:

  1. 選擇程式表。
  2. 填充程式表。在restart中,填充的是CPU。
  3. 清除螢幕上的字串。
  4. 啟動程式。
  5. 讓CPU永遠執行指令。

程式碼詳解

填充程式表是主要部分,詳細講解一下。

ldt_selector

proc->ldt_selector = LDT_FIRST_SELECTOR;,設定指向程式的LDT的選擇子。這是GDT選擇子,通過這個選擇子在GDT中找到對應的全域性描述符,然後通過這個全域性描述符找到LDT。

LDT_FIRST_SELECTOR的值是0x48。為什麼是0x48

因為,我把GDT設計成了下列表格這個樣子。每個選擇子的值是由它在GDT中的位置也就是索引決定的。為什麼這些描述符在GDT中的索引(位置)不連貫呢?這是因為我設計的GDT中還有許多為了進行其他測試而填充的描述符。

選擇子的高13位是描述符在描述符表中的偏移量,當然,偏移量的單位是一個描述符的長度;低13位儲存TI和CPL。

全域性描述符 索引 選擇子
空描述符 0 高13位是0,第3位是0,最終結果是0b,0x0
程式碼段描述符 1 高13位是1,第3位是0,最終結果是1000b,0x8
資料段描述符 6 高13位是6,第3位是0,最終結果是110 000b,0x30
視訊段描述符 7 高13位是7,第3位是0,最終結果是111 000b,0x38
TSS段描述符 8 高13位是8,第3位是0,最終結果是1000 000b,0x40
LDT段描述符 9 高13位是9,第3位是0,最終結果是1001 000b,0x48

LDT

根據ldt_selector找到LDT,LDT的記憶體地址已經確定了,就在程式表中,不能更改。要讓ldt_sector間接指向程式表中的LDT,只能在指向LDT的全域性描述符下功夫。

// 初始化LDT
// 對應ldts只有兩個元素。
int ldt_size = 2 * sizeof(Descriptor);
// int ldt_attribute = 0x0c92;          // todo ldt的屬性怎麼確定?
int ldt_attribute = 0x82;          // todo ldt的屬性怎麼確定?
// proc_table[0].ldts 只是在程式碼段中的偏移量,需要加上所在程式碼段的基地址。
int ldt_base = VirAddr2PhyAddr(ds_phy_addr, proc_table[0].ldts);
// 全域性描述符的基地址、界限、屬性都已經知道,足以建立一個全域性描述符。
// ldt_base 是段基值,不是偏移量。
InitDescriptor(&gdt[LDT_FIRST_SELECTOR_INDEX], ldt_base, ldt_size - 1, ldt_attribute);

唯一要特別著墨的是ldt_attribute為什麼是0x82

指向LDT的全域性描述符的特徵有:可讀、程式碼段、粒度是1bit、32位。翻翻《寫作業系統之開發載入器》,搬過來下面的表格。

A W E X S DPL DPL P AVL L D/B G
0 1 0 0 0 0 0 1 0 0 0 0

在上面的表格中填入LDT的全域性描述符的屬性。G位是0而不是1。LDT只佔用16個bit,不需要使用4KB來統計長度。W位是1,表示LDT是可讀可寫的。我們會看到,確實需要對LDT進行寫操作。

要特別注意,S位是0,因為,LDT是一個系統段,不是程式碼段也不是資料段。TSS也是系統段。

從右往左排列表格的第二行的每個單元格,得到屬性值。它是0000 1000 0010b,轉換成十六進位制數0x82

我以前提出的問題在今天被我自己解答了,這就叫“溫故而知新”。就是不知道做這些事有沒有用。

填充程式體的LDT。

Memcpy(&proc->ldts[0], &gdt[CS_SELECTOR_INDEX], sizeof(Descriptor));
// 修改ldt描述符的屬性。全域性cs的屬性是 0c9ah。
proc->ldts[0].seg_attr1 = 0xba;
Memcpy(&proc->ldts[1], &gdt[DS_SELECTOR_INDEX], sizeof(Descriptor));
// 修改ldt描述符的屬性。全域性ds的屬性是 0c92h
proc->ldts[1].seg_attr1 = 0xb2;

上面的程式碼的思路是:複製GDT中的程式碼段描述符、資料段描述符到LDT中,然後修改LDT中的描述符的屬性。

段暫存器的值

s_reg.cs是LDT中第一個描述符的選擇子。這個選擇子高13位是索引0,低3位是TI和RPL,TI是1,RPL是1,綜合得到選擇子的值是0x5。按同樣的方法計算出其他段暫存器的值是0xD。

gs的值比較特別。在上面的程式碼中,我為了把gs的RPL設定得和其他段選擇子的RPL相同,把視訊段選擇子的RPL也設定成了1,這不是必要的。視訊段描述符依然是GDT中的描述符。proc->s_reg.gs = GS_SELECTOR & (0xFFF9);,體現了前面所說的一切。

由於程式碼中錯誤的註釋,再加上我在一瞬間弄錯了C語言運算子&的運算規則,在此浪費了不少時間。我誤把&當成組合語言中的運算子。

eip的值

eip儲存程式計數器。程式計數器是下一條要執行的指令的地址,在作業系統中,是指令在程式碼段中的偏移量。

要在啟動程式前把eip的值設定成程式體的入口地址。函式名就是程式體的入口地址,C語言中的函式和組合語言中的函式都是如此。入口地址是函式的第一條指令的地址。

proc->s_reg.eip = (int)TestA;,正如我們看到的,直接使用TestA就能把函式的入口地址賦值給s_reg.eip。前面的(int)為了消除編譯器的警告資訊。

如果TestA是一個變數,使用變數名得到的是變數中儲存的值而不是變數的儲存地址。為什麼?編譯器就是這麼做的。我的理解,函式名和陣列名類似。

eflags的值

eflags

image-20211017144413829

又見到eflags了。上次看過eflags後,我到現在還心有餘悸。這一次,我們關注eflags中的IOPL。

IOPL控制使用者程式對敏感指令的使用許可權。敏感指令是in、ins、out、outs、cli、sti這些指令。

IOPL佔用2個bit。它儲存在eflags中,只能在0特權級下通過popfiretd修改。

使用IOPL設定一個特權級的使用者程式對所有埠的訪問許可權,使用I/O點陣圖對一個特權級的使用者程式設定個性化的埠訪問許可權(能訪問部分埠、不能訪問另外的埠)。

使用者程式的CPL<IOPL,使用者程式能訪問所有埠。否則,從I/O點陣圖中查詢使用者程式對埠的訪問許可權。

為什麼把eflags的值設定成0x1202?我們把IOPL設定成1,把IF設定成1,綜合而成,最終結果是0x1202。

IF是1,在restart中執行iretd後,開啟了中斷。恢復程式後一定要開啟中斷,才能在程式執行過程中處理其他中斷。

IOPL的值如果設定為0,CPL是1的程式就不能執行敏感指令。

IF和IOPL的值設定成當前資料,還比較好理解。讓我感覺困難的是eflags冗長的結構和計算最終數值的繁瑣方法。不過,我找到一個表格(見下面的小節“eflags"結構表格),大大減少了麻煩。

我怎麼使用這個表格?IOPL是1,那麼,eflags的值是0x1000;IF的值是1,那麼,eflags的值是0x0200;第1個bit是保留位,值是1,那麼,eflags的值是0x0002。把這些eflags進行|運算,最終結果是0x1202。正好和程式碼中的值相同,這是一個巧合。但表格提供了一種方法,就是隻計算相應元素時eflags的值是多少。用這種方法,能避免每次都需要畫一個32位的表格然後逐個單元格填充資料。計算段屬性也可以用這種方式簡化計算方法。

計算eflags的唯一難點是弄清楚每個位的含義。

有空再看這個eflags,此刻只想儘快離開這個知識點。

eflags結構表格

image-20211017150338862

剩餘

剩下的程式碼的功能是清屏、啟動程式、讓CPU永遠執行。在前面已經講過,不再贅述。

實現多程式

簡單改造上面的單程式程式碼,就能啟動多個程式。真的是這樣嗎?很遺憾,真的不是這樣。

準備好多個程式三要素很容易,可是,單程式程式碼缺少切換到其他程式的機制。一旦執行準備好的第一個程式,就會一直執行這個程式,沒有契機執行其他程式。需加入中斷例程、建立快照的程式碼,再加上多個程式需要的材料,就能實現多程式。現在,我們開始把單程式程式碼改造為多程式程式碼。

多個程式要素

程式體

void TestA()
{
        while(1){
                disp_str("A");
                disp_int(2);
                disp_str(".");
                delay(1);
        }
}

void TestB()
{
        while(1){
                disp_str("B");
                disp_int(2);
                disp_str(".");
                delay(1);
        }
}

void TestC()
{
        while(1){
                disp_str("C");
                disp_int(3);
                disp_str(".");
                delay(1);
        }
}

三個函式分別是程式A、B、C的程式體。不能再像前面那樣直接把函式名賦值給s_reg.eip了,因為在迴圈中不能使用固定值。新方法是建立一個程式體入口地址的陣列。這個陣列中的元素的資料型別是函式指標。

先了解一下函式指標。

#include <stdio.h>
 
int max(int x, int y)
{
    return x > y ? x : y;
}
 
int main(void)
{
    /* p 是函式指標 */
    int (* p)(int, int) = & max; // &可以省略
    int a, b, c, d;
 
    printf("請輸入三個數字:");
    scanf("%d %d %d", & a, & b, & c);
 
    /* 與直接呼叫函式等價,d = max(max(a, b), c) */
    d = p(p(a, b), c); 
 
    printf("最大的數字是: %d\n", d);
 
    return 0;
}

使用函式指標的程式碼是:

// code-A
/* p 是函式指標 */
int (* p)(int, int) = & max; // &可以省略
d = p(p(a, b), c); 

上面的程式碼等價於下面的程式碼。

// code-B
typedef int (* Func)(int, int);
Func p = & max; // &可以省略
d = p(p(a, b), c); 

code-A在宣告變數的同時初始化變數。code-B先建立了資料型別,然後初始化一個這種型別的變數並初始化。我們等會使用code-B風格的方式建立函式指標。

函式指標語法似乎有點怪異,和普通指標語法差別很大。其實它們的差別不大。

int *p,宣告一個指標,指標名稱是p,資料型別是int *

int (*p)(int, int),宣告一個函式指標,指標名稱是p,資料型別是int *(int, int)

把這種表面風格不一樣的指標宣告語句看成”資料型別+變數名“,就會覺得它們的風格其實是一樣的。

typedef int (* Func)(int, int);,實質是typedef int *(int, int) Func;

好了,函式指標語法這個障礙已經被消除了,我們繼續實現多程式。

前面,我說到,把程式體的入口放到陣列中,儲存程式體的入口的資料型別需要是函式指標。程式碼如下。

typedef void (*Func)();

typedef struct{
        Func func_name;
        unsigned short stack_size;
}Task;

Task task_table[3] = {
        {TestA, A_STACK_SIZE},
        {TestB, B_STACK_SIZE},
        {TestC, C_STACK_SIZE},
};

設定eip的值的程式碼是proc->s_reg.eip = (int)task_table[i].func_name;

eip的值是程式體的入口,是一個記憶體地址;而函式名例如TestA就是記憶體地址,Task的成員func_name是一個指標,能夠把TestA等函式名賦值給它。把記憶體地址賦值給指標,這符合語法。

堆疊

// i是程式體在程式表陣列中的索引;堆疊地址從高地址往低地址生長。
proc->s_reg.esp = (int)(proc_stack + 128 * (i+1));

程式表

#define PROC_NUM 3
Proc proc_table[PROC_NUM];

單程式程式碼中,只有一個程式,因此只需要一個程式表。現在,有多個程式,要用一個程式表陣列儲存程式表。填充程式表的大概思路是遍歷程式表陣列,填充每個程式表。全部程式碼如下。

void kernel_main()
{
	counter = 0;
	Proc *proc = proc_table;
	for(int i = 0; i < PROC_NUM; i++){	
		proc->ldt_selector = LDT_FIRST_SELECTOR + 8 * i;
		proc->pid = i;	
		Memcpy(&proc->ldts[0], &gdt[CS_SELECTOR_INDEX], sizeof(Descriptor));
		// 修改ldt描述符的屬性。全域性cs的屬性是 0c9ah。
		proc->ldts[0].seg_attr1 = 0xba;
		Memcpy(&proc->ldts[1], &gdt[DS_SELECTOR_INDEX], sizeof(Descriptor));
		// 修改ldt描述符的屬性。全域性ds的屬性是 0c92h
		proc->ldts[1].seg_attr1 = 0xb2;

		// 初始化程式表的段暫存器
		proc->s_reg.cs = 0x05;	// 000 0101		
		proc->s_reg.ds = 0x0D;	// 000 1101		
		proc->s_reg.fs = 0x0D;	// 000 1101		
		proc->s_reg.es = 0x0D;	// 000 1101		
		proc->s_reg.ss = 0x0D;	// 000 1100	
		// 需要修改gs的TI和RPL	
		// proc->s_reg.gs = GS_SELECTOR;	
		// proc->s_reg.gs = GS_SELECTOR | (0x101);
		//proc->s_reg.gs = GS_SELECTOR;
		//proc->s_reg.gs = GS_SELECTOR & (0x001);
		// 0x3b--> 0011 1011 --> 0011 1 011
		// 1001
		proc->s_reg.gs = GS_SELECTOR & (0xFFF9);
		proc->s_reg.eip = (int)task_table[i].func_name;
		proc->s_reg.esp = (int)(proc_stack + 128 * (i+1));
		// IOPL = 1, IF = 1
		// IOPL 控制I/O許可權的特權級,IF控制中斷的開啟和關閉
		proc->s_reg.eflags = 0x1202;	
		
		proc++;
	}
	
	proc_ready_table = proc_table;	
	dis_pos = 0;
	// 清屏
	for(int i = 0; i < 80 * 25 * 2; i++){
		disp_str(" ");
	}	
	dis_pos = 2;
	restart();

	while(1){}
}

大部分程式碼和單程式程式碼相同,只是在後者的基礎上增加了一個迴圈。此外,還有下面這些細微差別。

proc->ldt_selector = LDT_FIRST_SELECTOR + 8 * i;。指向程式的LDT的全域性描述符在GDT中依次相鄰,相鄰描述符之間的差值是1,反映到指向LDT的選擇子的差別就是8。

proc->s_reg.eip = (int)task_table[i].func_name;,在前面專門解釋過。

proc->s_reg.esp = (int)(proc_stack + 128 * (i+1));

proc_stack是堆疊空間,每個程式的堆疊是128個位元組。

第一個程式的初始堆疊棧頂是proc_stack + 128,第一個程式的初始堆疊棧頂是proc_stack + 128 + 128,第一個程式的初始堆疊棧頂是proc_stack + 128 + 128 + 128

程式快照

多程式模型中,程式切換的契機是時鐘中斷。時鐘中斷每隔一段時間發生一次。在時鐘中斷例程中,根據某種策略,選擇時鐘中斷結束後要執行的程式是時鐘中斷髮生前的程式還是另外一個程式。選擇執行哪個程式,這就是程式排程。

程式在時鐘中斷前的資料、下一條要執行的指令等都需要儲存起來,以便恢復程式時在前面的基礎上繼續執行,而不是另起爐灶。

程式快照是CPU在某個時刻的狀態,通過把CPU的狀態儲存到程式表。在前面的小節“CPU快照”已經詳細介紹了方法。在此基礎上,一起來看看建立快照的程式碼。

;中斷髮生時會依次入棧ss、esp、eflags、cs、eip
; 建立快照
pushad
push ds
push es
push fs
push gs

時鐘中斷例程

程式碼

hwint0:
        ; 建立快照
        pushad
        push ds
        push es
        push fs
        push gs

        mov dx, ss
        mov ds, dx
        mov es, dx
        
        mov esp, StackTop
        sti
        push ax
        ; 程式排程
        call schedule_process
        ; 置EOI位
        mov al, 20h
        out 20h, al
        pop ax
        cli
        ; 啟動程式
        jmp restart

堆疊變化

中斷髮生時,esp的值是TSS中的esp0,ss是TSS中的ss0。

在hwint0中,mov esp, StackTop把esp設定成核心棧StackTop,ss依然是TSS中的ss0。

在restart中,把esp設定成從中斷中要恢復執行的程式的程式表proc_table_ready,ss還是TSS中的ss0。

程式恢復後,esp是程式表中的esp,ss是程式表中的ss。

在這些堆疊變化中,只有第一次變化,需要解釋。其他的變化,都很好懂。

中斷髮生時,CPU會自動從TSS中獲取ss和esp的值。欲知詳情,請看下一個小節。

TSS

TSS的全稱是task state segment,中文全稱是”任務狀態段“。它是硬體廠商提供給作業系統的一個硬體,功能是儲存CPU的快照。但主流作業系統都沒有使用TSS儲存CPU的快照,而是使用我們在上面見過的那種方式儲存CPU的快照。

結構圖

image-20211017191504714

結構程式碼
typedef struct{
        // 上一個任務的TSS指標
        unsigned int last_tss_ptr;
        unsigned int esp0;
        unsigned int ss0;
        unsigned int esp1;
        unsigned int ss1;
        unsigned int esp2;
        unsigned int ss2;
        unsigned int cr3;
        unsigned int eip;
        unsigned int eflags;
        unsigned int eax;
        unsigned int ecx;
        unsigned int edx;
        unsigned int ebx;
        unsigned int esp;
        unsigned int ebp;
        unsigned int esi;
        unsigned int edi;
        unsigned int es;
        unsigned int cs;
        unsigned int ss;
        unsigned int ds;
        unsigned int fs;
        unsigned int gs;
        unsigned int ldt;
        unsigned int trace;
        unsigned int iobase;
}TSS;

結構圖中的ss0、ss1這類高16位是保留位的成員也用32位來儲存。iobase也是。這麼多成員,我們只使用了ss0、esp0、iobase。

填充

程式初次啟動前,往程式表中填入了要更新到TSS中的資料。看程式碼。

tss.ss0 = DS_SELECTOR;

為什麼沒有填充esp0?其實有填充,在restart中。

; 設定tss.esp0
lea eax, [proc_table + 68]
mov [tss + 4], eax

從TSS的結構圖,能計算出tss + 4是esp0的地址,所以上面的程式碼是把程式表中的s_reg的棧頂(最高地址)存到了TSS的esp0中。

中斷髮生時,CPL是0,CPU會自動從TSS中選擇對應的ss0和esp0;如果CPL是1,CPU會選擇ss1和esp1。

程式初次啟動前,TSS中的ss0和esp0分別被設定成程式的ss和程式表的s_reg的最高地址。中斷髮生時,CPU從TSS中取出ss0和esp0,然後壓棧。注意,此時的ss0和esp0分別是我們在前面設定的被中斷的程式的ss和程式表的s_reg的最高地址。就這樣,CPU發生中斷時的狀態被儲存到了該程式的程式表中。

在中斷例程的末尾,TSS中的ss和esp0被設定成即將從中斷中恢復的程式的ss和s_reg的最高地址。當中斷再次發生時,CPU狀態會被儲存到這個程式的程式表中。

中斷重入

時鐘中斷髮生後,執行時鐘中斷例程,未執行完時鐘中斷例程前又發生了鍵盤中斷,這種現象就叫中斷重入。

處理思路

發生中斷重入時,怎麼處理?迅速結束重入的中斷例程,不完成正常情況下應該完成的功能,然後接著執行被重入的中斷例程。具體思路是:

  1. 設定一個變數k_reenter,初始值是2。當然也能把初始值設定成3、4或其他值。
  2. 當中斷髮生時,k_reenter減去1。
  3. 在中斷例程中,執行完成特定功能例如程式排程程式碼前,檢查k_reenter的值是不是1。
    1. 是1,執行程式排程程式碼,更換要被恢復執行的程式。
    2. 不是1,跳過程式排程程式碼,不更換要被恢復執行的程式,也就是說,繼續執行被中斷的程式。
  4. 在中斷例程的末尾,k_reenter加1。

程式碼

; k_reenter 的初始值是0。
hwint0:
        ; 建立快照
        pushad
        push ds
        push es
        push fs
        push gs
        
				mov dx, ss
        mov ds, dx
        mov es, dx
        mov fs, dx

        mov al, 11111001b
        out 21h, al
        ; 置EOI位 start
        mov al, 20h
        out 20h, al
        ; 置EOI位 end
        inc dword [k_reenter]
        cmp dword [k_reenter], 0
        jne .2
.1:
        mov esp, StackTop
.2:
        sti
        call clock_handler
        mov al, 11111000b
        out 21h, al
        cli
        cmp dword [k_reenter], 0
        jne reenter_restore
        jmp restore
        
; 恢復程式
restore:
        ; 能放到前dword 面,和其他函式在形式上比較相似
        mov esp, [proc_ready_table]
        lldt [esp + 68]
        ; 設定tss.esp0
        lea eax, [esp + 68]
        mov dword [tss + 4], eax
reenter_restore:
        dec dword [k_reenter]
        ; 出棧
        pop gs
        pop fs
        pop es
        pop ds
        
        popad
        iretd

k_reenter的初始值是0,中斷髮生時,執行到第一條cmp dword [k_reenter], 0語句,當這個中斷是非重入中斷時,k_reenter的值是0;當這個中斷是重入中斷時,k_reenter的值是2,總之k_reenter的值大於0。

上面的判斷需要琢磨一番。

程式碼中,對重入中斷的處理是:

  1. 跳過mov esp, StackTop。沒有更改esp的值。
  2. 執行reenter_restore。沒有更換要被恢復的程式。也就是說,恢復被重入中斷打斷的程式。

在上面的程式碼中,無論是重入中斷還是非重入中斷,都執行了完成特定功能的程式碼。不應該如此。以後找機會優化。

原子操作

在中斷例程中,建立快照結束後,執行了sti開啟中斷。為什麼要開啟中斷?因為發生中斷時,(什麼?CPU嗎?)會自動關閉中斷。在完成特定功能的程式碼執行完後、恢復快照到CPU前,又會關閉中斷。執行iretd後,中斷又被開啟。

建立快照前、恢復快照前,都關閉了中斷。為什麼?為了確保CPU能一氣呵成、不受其他與建立或恢復快照無關的指令修改快照或CPU的狀態。想象一下,在建立快照的過程中,正要執行push eax時,又發生了其他中斷,在新中斷的中斷例程中無法根據k_reenter識別新中斷是不是重入中斷。為了避免這種混亂的狀況,必須保證建立CPU快照的過程不被打斷。恢復程式的過程也是一樣。

我無法模擬這種混亂的情況。這個解釋似乎有點牽強,擱置,看我以後能不能想出更好的例子。

特權級

CPU有四個特權級,分別是0特權級、1特權級、2特權級、3特權級。特權級從0特權級到3特權級的許可權依次遞減。作業系統執行在0特權級,使用者程式執行在3特權級,系統任務執行在1特權級。

參考資料

《一個作業系統的實現》

《作業系統真相還原》

x86 and amd64 instruction reference

FLAGS register

相關文章