1. 程式人生 > >清華大學MOOC《作業系統》第4講:“實驗1-系統軟體啟動過程”總結(轉自張慕暉部落格)

清華大學MOOC《作業系統》第4講:“實驗1-系統軟體啟動過程”總結(轉自張慕暉部落格)

課程內容概述

這節課主要介紹了一些和Lab1相關的內容。

  • 系統啟動過程
    • BIOS
    • bootloader
      • 段機制
      • 作業系統的載入
  • C語言的一些相關知識
    • 函式呼叫的實現
    • GCC內聯彙編
  • x86架構下的中斷處理過程

系統啟動過程

BIOS

BIOS的工作過程已經在《作業系統》第3講:“啟動、中斷、異常和系統呼叫”總結中詳細說過了,在此不再重複。唯一值得注意的是,雖然真實模式下的定址方式是Base(16位暫存器CS)* 16 + Offset(16位暫存器IP)=線性地址(20位),但是這並不是段機制。

bootloader

BIOS將控制權轉交給bootloader(見lab1/boot

資料夾下的內容)。它的工作內容主要包括:

  • 使能保護模式(protection mode)和段機制(segment level protection),切換到32位4G的定址空間,對段機制進行初始化
  • 從硬碟上讀取ELF格式的ucore kernel(位於MBR後面的扇區)並放到記憶體中固定位置
  • 跳轉到ucore OS的入口點(entry point),將控制權轉交給ucore OS

使能保護模式

將系統暫存器CR0的第0個bit置為1,說明進入保護模式。當然,在此之前要開A20,並準備好GDT表,將基址載入到GDT基址暫存器中。

段機制

總的來說,段機制其實是一種對映關係。一個段指向的是線性地址空間中一段連續的記憶體,有基址和limit。段與段之間是可以重疊的。

設定段機制的方法是,建立一個數組來儲存段描述符表,稱為全域性描述符表GDT(也稱為段表,在ucore中是由bootloader建立的,因為開啟保護模式之前就需要設定好GDT),其中包括段描述符表的位置、大小等資訊;這樣CPU就可以找到段表了(用GDTR暫存器儲存段表資訊)。除了設定GDT之外,還要為CS、DS等段暫存器設定好對應的Index,使它們能夠指向全域性描述符表GDT對應的項,這可以在切換到保護模式之後進行。

硬體提供了一些段暫存器。這些段暫存器指向段描述符,比較重要的幾個段暫存器包括:

  • CS:程式碼段暫存器
  • DS:資料段暫存器
  • SS:堆疊段暫存器

段暫存器的結構是這樣的:

  • 高13位:GDT index
  • 1位:TI,一般設定為0,因為沒有用到LDT(本地描述符表)
  • 2位:RP,表明段優先順序,有4個特權級,一般應用程式放在3,作業系統放在0

每個段暫存器指向一個GDT或LDT中的段描述符。段描述符描述了一個段的起始地址和它的大小。(一個段描述符的大小是8位元組,具體內容比較複雜,不過知道這兩點差不多就夠了)

uCore中採用的應該是Intel手冊中提到的扁平保護模型。

扁平保護模型扁平保護模型

在設定完所需的表和暫存器之後,段機制就可以完成從邏輯地址到線性地址(在頁機制沒有開啟的時候,線性地址=實體地址)的翻譯了。具體的翻譯過程如下圖:

邏輯地址到線性地址的翻譯過程邏輯地址到線性地址的翻譯過程

  • 通過邏輯地址中的段選擇子查詢段描述符表項
  • 從表項中讀出段基址和段的大小
  • 檢查邏輯地址中的offset是否合法
  • 安全性檢查(這裡還沒有講到)
  • 段基址(Base Address)+段內偏移量(offset)=線性地址(linear address)

作業系統的載入

作業系統的載入過程其實就是把ELF檔案中的內容填到合適的位置。ELF檔案的開頭是一個ELF Header,結構如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* file header */
struct elfhdr {
    uint32_t e_magic;     // must equal ELF_MAGIC
    uint8_t e_elf[12];
    uint16_t e_type;      // 1=relocatable, 2=executable, 3=shared object, 4=core image
    uint16_t e_machine;   // 3=x86, 4=68K, etc.
    uint32_t e_version;   // file version, always 1
    uint32_t e_entry;     // entry point if executable
    uint32_t e_phoff;     // file position of program header or 0
    uint32_t e_shoff;     // file position of section header or 0
    uint32_t e_flags;     // architecture-specific flags, usually 0
    uint16_t e_ehsize;    // size of this elf header
    uint16_t e_phentsize; // size of an entry in program header
    uint16_t e_phnum;     // number of entries in program header or 0
    uint16_t e_shentsize; // size of an entry in section header
    uint16_t e_shnum;     // number of entries in section header or 0
    uint16_t e_shstrndx;  // section number that contains section name strings
};

其中比較重要的變數是e_phoff(第一個Program Header的地址),e_phnum(檔案中共有幾個Program Header)和e_magic(用來檢驗該Header是否合法)。bootmain.c中的程式碼首先讀一頁(主引導扇區之後的8個扇區),得到ELF Header;然後,通過上述變數,可以依次訪問各個Program Header。

1
2
3
4
5
6
7
8
9
10
11
/* program section header */
struct proghdr {
    uint32_t p_type;   // loadable code or data, dynamic linking info,etc.
    uint32_t p_offset; // file offset of segment
    uint32_t p_va;     // virtual address to map segment
    uint32_t p_pa;     // physical address, not used
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz;  // size of segment in memory (bigger if contains bss)
    uint32_t p_flags;  // read/write/execute bits
    uint32_t p_align;  // required alignment, invariably hardware page size
};

然後通過每個Progeam Header中的ph->p_memsz(一個程式碼段的大小)和ph->p_offset(一個程式碼段的虛地址),可以從磁碟中讀出對應內容,並存儲到記憶體的對應位置(虛地址處)。

C語言的一些相關知識

函式呼叫的實現

  • 將需要儲存的暫存器入棧,呼叫函式,然後將需要儲存的暫存器出棧
  • EBP指向的是棧底,其中儲存的是呼叫者的EBP;ESP指向的是棧頂;
  • 呼叫時會把返回地址也入棧,在EBP下面,再下面是引數

其他注意事項:

  • 引數和函式返回值可以通過暫存器或位於記憶體中的棧來傳遞
  • 不需要儲存/恢復(save/restore)所有暫存器(因為暫存器按傳統分為caller save和callee save兩類)

GCC內聯彙編

內聯彙編的完整格式如下:

1
2
3
4
5
asm (assembler template  // 字串
  : output operands (optional)  // 約束:把某個變數用某個暫存器來表示
  : input operands (optional)
  : clobbers (optional)
);

例1:不帶任何約束的簡單內聯彙編

內聯彙編:

1
asm("movl $0xffff, %%eax\n")

生成的彙編:

1
movl $0xffff, %eax

例2:使用特定暫存器和約束的內聯彙編

內聯彙編程式碼:

1
2
3
4
uint32_t cr0;
asm volatile("movl %%cr0, %0\n" : "=r"(cr0));  // %0暫存器對應的變數是cr0
cr0 |= 0x80000000;
asm volatile("movl %0, %%cr0\n" :: "r"(cr0));  // 把cr0變數中的值賦給cr0暫存器

生成的彙編程式碼:

1
2
3
4
5
movl %cr0, %ebx
movl %ebx, 12(%esp)
ori $-2147483648, 12(%esp)
movl 12(%esp), %eax
movl %eax, %cr0

其中關鍵字的含義如下:

  • volatile:不需要做進一步優化和調整順序
  • %0:第一個約束
  • r:約束:GCC可以使用任意暫存器

例3:使用內聯彙編觸發系統中斷

內聯彙編程式碼:

1
2
3
4
long __res, arg1=2, arg2=22, arg3=222, arg4=233;
__asm__ volatile("int $0x80"
  : "=a" (__res)
  : "0"(11), "b"(arg1), "c"(arg2), "d"(arg3), "S"(arg4));

生成的彙編程式碼:

1
2
3
4
5
6
7
movl $11, %eax
movl -28(%ebp), %ebx
movl -24(%ebp), %ecx
movl -20(%ebp), %edx
movl -16(%ebp), %esi
int $0x80  ## 產生軟中斷
movl %edi, -12(%ebp)

其中約束條件的含義如下:

  • a=%eax
  • b=%ebx
  • c=%ecx
  • d=%edx
  • S=%esi
  • D=%edi
  • 0=和第一個暫存器相同

x86架構下的中斷處理過程

此處的“中斷”包括兩類:

  • 中斷(Interrupts)
    • 外部中斷(External (hardware generated) interrupts):串列埠、硬碟、網絡卡、時鐘
    • 軟體產生的中斷(Software generated interrupts):INT n指令,通常用於系統呼叫
  • 異常(Exceptions)
    • 程式錯誤
    • 軟體產生的異常(Software generated exception):INTO,INT 3和BOUND
    • 機器檢查出的異常

通過中斷號確定中斷服務例程(ISR)

IDT是由作業系統分配的(Lab1的其中一個練習),在分配完空間並填充好IDT之後,需要用特權指令填充中斷描述符表暫存器IDTR。

一般來說,每個中斷或異常都有一箇中斷號,每個中斷號與一箇中斷服務例程(Interrupt Service Routine,ISR)關聯,其關聯關係儲存在中斷描述符表(Interrupt Descriptor Table,IDT)中。IDT的每一項稱為“中斷門”或“陷阱門”。IDT的起始地址和大小儲存在中斷描述符表暫存器IDTR中,地址的表示也需要用到GDT(地址與段相關)。

IDT表中一般可能會含有三種門:中斷門、陷阱門和任務門(目前沒有用到),每一種門的格式如下:

IDT門描述符的格式IDT門描述符的格式

可以看出,中斷門和陷阱門的格式類似,其中的核心內容包括:

  • 段選擇子:16位,表示ISR所在的段
  • 段內偏移量:32位,表示ISR地址在段內的偏移量
  • DPL:用於進行安全性檢查(現在還沒用到)

可以看出,IDT表項實質上就是儲存了很多ISR的邏輯地址。通過IDT和GDT(或LDT)訪問ISR的過程如下圖:

中斷服務例程呼叫過程中斷服務例程呼叫過程

呼叫ISR和從ISR返回的過程

中斷服務例程的呼叫和返回這個過程是非常重要的。因為它是使用者態程序獲得(或者強制進入)OS服務的唯一途徑,所以需要進行現場的儲存和特權級的切換。這個是非常重要的。

Intel手冊6.12.1節“Exception- or Interrupt-Handler Procedures”中說,處理器呼叫中斷服務例程的過程是這樣的:

  • 如果該中斷服務例程將執行在一個更高的特權級下,則會發生棧切換,過程如下:
    • 從當前執行任務的TSS中獲得ISR將會使用的段選擇子和棧指標,將被打斷的程式的堆疊段選擇子和棧指標壓入新棧中
    • 處理器隨後將EFLAGS、CS和EIP暫存器的當前值也壓入新棧中
    • 如果異常有錯誤碼,則將錯誤碼也壓入棧中,位於EIP暫存器的值之後
  • 如果該中斷服務例程的特權級不變:
    • 處理器直接將EFLAGS、CS和EIP暫存器的當前值壓入當前棧中
    • 如果異常有錯誤碼,則將錯誤碼也壓入棧中,位於EIP暫存器的值之後

下圖展示了這一過程(注意棧是向下增長的):
呼叫中斷服務例程時棧的使用情況呼叫中斷服務例程時棧的使用情況

從中斷服務例程返回時,必須使用IRET(或IRETD)指令。IRET指令類似於RET指令,但是它會對儲存的暫存器和EFLAGS進行恢復(EFLAGS可能會進行一些修改)。如果呼叫中斷服務例程時發生了棧切換,則IRET指令會在返回時切換回被打斷的程式的棧。

系統呼叫

系統呼叫的實現方法是:

  • 指定中斷號
  • 使用Trap
  • 或使用特殊指令(SYSENTER/SYSEXIT)

練習

選擇填空題

80386機器加電啟動後,CPU立刻跳轉到()執行

  • ucore第一條指令
  • bootloader第一條指令
  • BIOS的第一條指令
  • GRUB的第一條指令

加電後的第一條指令是長跳轉指令,跳到BIOS去執行。

應用程式中的C函式呼叫中不需要用到()指令

  • push
  • ret
  • iret
  • call

iret用於中斷服務例程返回。應用程式編寫不需要用到IRET。

GCC內聯彙編 asm("movl %ecx, %eax"); 的含義是()

  • 把 ecx 內容移動到 eax
  • 把 eax 內容移動到 ecx

答案是把 ecx 內容移動到 eax。這是顯然的AT&T彙編語法。

為了讓系統正確完成80386的中斷處理過程,作業系統需要正確設定()

  • 全域性描述符表
  • 中斷描述符表
  • 中斷服務例程
  • 核心堆疊

在ucore處理中,上述幾個都是要設定好的。顯然ISR是必須準備好的。因為中斷服務例程會使用核心棧,所以核心堆疊也要設定。發生中斷時,硬體通過IDT找到中斷號對應的中斷描述符,再根據其中的ISR的邏輯地址,通過GDT或LDT得到ISR的線性地址。

簡答題

段暫存器的欄位含義和功能有哪些?

  • 程式碼段暫存器 CS(Code Segment)存放當前正在執行的程式程式碼所在段的段基址,表示當前使用的指令程式碼可以從該段暫存器指定的儲存器段中取得,相應的偏移量則由IP提供
  • 資料段暫存器 DS(Data Segment)指出當前程式使用的資料所存放段的最低地址,即存放資料段的段基址
  • 堆疊段暫存器 SS(Stack Segment)指出當前堆疊的底部地址,即存放堆疊段的段基址
  • 附加段暫存器 ES(Extra Segment)指出當前程式使用附加資料段的段基址,該段是串操作指令中目的串所在的段
  • 附加段暫存器 FS
  • 附加段暫存器 GS

常用的就是CS、DS和SS。

描述符特權級DPL、當前特權級CPL和請求特權級RPL的含義是什麼?在哪些暫存器中存在這些欄位?對應的訪問條件是什麼?

  • CPL是當前程序的許可權級別(Current Privilege Level),是當前正在執行的程式碼所在的段的特權級,存在於cs暫存器的低兩位。
  • RPL說明的是程序對段訪問的請求許可權(Request Privilege Level),是對於段選擇子而言的,每個段選擇子有自己的RPL,它說明的是程序對段訪問的請求許可權,有點像函式引數。而且RPL對每個段來說不是固定的,兩次訪問同一段時的RPL可以不同。RPL可能會削弱CPL的作用,例如當前CPL=0的程序要訪問一個數據段,它把段選擇符中的RPL設為3,這樣雖然它的CPL=0對該段仍然只有特權為3的訪問許可權。
  • DPL儲存在段描述符中,規定訪問該段的許可權級別(Descriptor Privilege Level),每個段的DPL固定。

對資料段和堆疊段訪問時的特權級控制:要求訪問資料段或堆疊段的程式的CPL≤待訪問的資料段或堆疊段的DPL,同時選擇子的RPL≤待訪問的資料段或堆疊段的DPL,即程式訪問資料段或堆疊段要遵循一個準則:只有相同或更高特權級的程式碼才能訪問相應的資料段。這裡,RPL可能會削弱CPL的作用,訪問資料段或堆疊段時,預設用CPU和RPL中的最小特權級去訪問資料段,所以要求max{CPL, RPL} ≤ DPL,否則訪問失敗。

分析可執行檔案格式elf的格式(無需回答)

中斷處理中硬體壓棧內容?使用者態中斷和核心態中斷的硬體壓棧有什麼不同?

參見實驗指導書中斷與異常部分。當然,上面已經講得非常詳細了。

為什麼在使用者態的中斷響應要使用核心堆疊?
保護中斷服務例程程式碼的安全。

粗略地思考一下,雖然ISR需要在核心態下執行,使用使用者態堆疊也不是不可以。程式碼安全這個我現在是想不清楚了。

trap型別的中斷門與interrupt型別的中斷門有啥設定上的差別?如果在設定中斷門上不做區分,會有什麼可能的後果?

呼叫Interrupt Gate時,Interrupt會被CPU自動禁止
呼叫Trap Gate時,CPU則不會去禁止或開啟中斷,而是保留它原來的樣子
如果在設定上不做區分,會導致重複觸發中斷。

硬體中斷是可以巢狀的,但指的並不是在處理一個硬體中斷的過程中把這個過程打斷,而是先關掉中斷,處理完當前中斷之後再順序處理下一個。

kdebug.c檔案中用到的函式read_ebp是內聯的,而函式read_eip不是內聯的。為什麼要設計成這樣?

ebp可以直接獲得,若不內聯,則會因為函式呼叫對棧的修改而得到錯誤的ebp值。而由於沒有直接獲取eip值的指令,我們需要利用call指令將eip壓棧的特性,通過呼叫read_eip函式來讀出壓在棧上的eip的值。若將read_eip內聯,則不會有函式呼叫存在,無法獲得eip的值。

1
2
3
4
5
6
static __noinline uint32_t
read_eip(void) {
    uint32_t eip;
    asm volatile("movl 4(%%ebp), %0" : "=r" (eip));
    return eip;
}

CPU加電初始化後中斷是使能的嗎?為什麼?
不是。CPU啟動後,BIOS會在POST自檢完成後在記憶體中建立中斷向量表和中斷服務程式。

主要還是因為這個時候還在真實模式下,根本沒有處理普通中斷的能力。不過,在真實模式下,需要通過某些中斷的手段來實現輸入輸出,這些方法都不能在保護模式下使用。

如何修改lab1, 實現在出現除零異常時顯示一個字串的異常服務例程?

在lab1/bin目錄下,通過objcopy -O binary kernel kernel.bin可以把elf格式的ucore kernel轉變成體積更小巧的binary格式的ucore kernel。為此,需要如何修改lab1的bootloader, 能夠實現正確載入binary格式的ucore OS? (hard)

GRUB是一個通用的bootloader,被用於載入多種作業系統。如果放棄lab1的bootloader,採用GRUB來載入ucore OS,請問需要如何修改lab1, 能夠實現此需求? (hard)

--

如果沒有中斷,作業系統設計會有哪些問題或困難?在這種情況下,能否完成對外設驅動和對程序的切換等作業系統核心功能?

課堂實踐

在Linux系統的應用程式中寫一個函式print_stackframe(),用於獲取當前位置的函式呼叫棧資訊。實現如下一種或多種功能:函式入口地址、函式名資訊、引數呼叫引數資訊、返回值資訊。

在ucore核心中寫一個函式print_stackframe(),用於獲取當前位置的函式呼叫棧資訊。實現如下一種或多種功能:函式入口地址、函式名資訊、引數呼叫引數資訊、返回值資訊。