作業系統---在核心中重新載入GDT和堆疊

東垂小夫發表於2021-03-04

摘要

用BIOS方式啟動計算機後,BIOS先讀取引導扇區,引導扇區再從外部儲存裝置中讀取載入器,載入器讀取核心。進入核心後,把載入器中建立的GDT複製到核心中。

這篇文章的最大價值也許在末尾,對C語言指標的新理解。

是什麼

在BOOT(引導扇區)載入LOADER(載入器)。

在LOADER中初始化GDT、堆疊,把Knernel(核心)讀取到記憶體,然後開啟保護模式,最後進入Knernel並開始執行。作業系統正式開始執行了。

GDT是CPU在保護模式下記憶體定址必定會使用的元素,在Kernel執行過程中也需要用到。

在核心中重新載入GDT和堆疊,是指,把儲存於LOADER所使用的記憶體中的GDT資料和堆疊中的資料複製到Kernel所使用的記憶體中。關鍵點不是Kernel和LOADER所使用的記憶體,而是變數。換句話說,把儲存在LOADER中的變數中的GDT資料和堆疊中的資料複製到Kernel變數中的GDT和堆疊。

LOADER是用匯編寫的,“彙編中的變數”,不知道這種表述是否準確。

為什麼

理由很簡單。LOADER是用匯編語言寫的,Kernel主要用C語言開發。在Kernel中使用GDT,若使用LOADER中定義的那個GDT變數(或者叫標號),光想一想就覺得很混亂。

用一句解釋:C語言中使用C語言中的變數更方便。

怎麼做

流程

  1. 在kernel中宣告變數unsigned short gdt_ptr,儲存GDT的記憶體地址。
  2. 使用sgdt指令把GDT的內地址複製到gdt_ptr中。
  3. 在kernel中建立結構體gdt,儲存GDT。
  4. 使用記憶體複製函式把GDT從LOADER中設定的記憶體位置複製到kernel中的變數gdt表示的記憶體中。

memcpy

它是記憶體複製函式。

這樣實現它:

  1. 原型是:memcpy(void *dst, void *src, int size)
  2. 核心是,把資料從[ds:esi]移動到[es:edi]
  3. 以位元組為單位來複制資料,複製size次。
  4. jmp實現迴圈,不用loop
  5. 迴圈終止條件是:size = 0。
memcpy:
		push	ebp
		mov		ebp,	esp
		
		push	edi
		push	esi
		push	ecx
		push	eax
		push	ds
		push	es
		
		mov		es,		[ebp + 12] ;dst
		mov		ds,		[ebp + 8]	; src
		mov		size,	[ebp + 4]	; size
		
		mov		edi,	0
		mov		esi,	0
		mov		ecx,	size
		
.1:
		cmp		ecx,	0
		jz		.2
		mov		al,		[ds:esi]
		mov		[es:edi],		al
		inc		esi
		inc		edi
		dec		ecx
.2:	
		pop		es
		pop		ds
		pop		eax
		pop		ecx
		pop		esi
		pop		edi
		pop		ebp
		ret

gdt

typedef struct {
  	unsigned  short  limitLow;
  	unsigned  short  baseAddressLow;
  	unsigned	char	 baseAddressMid;
  	unsigned	char	 attribute1;
  	unsigned	char	 attribute_limit;
  	unsigned	char	 baseAddressHigh;
}Descriptor;

Descriptor gdt[128];

堆疊

[SECTION .bss]
StackSpace		resb			2 * 1024
StackTop:

mov		esp,	StackTop

不理解。

程式碼

C語言

// 宣告一個char陣列,儲存GDT的記憶體地址
unsigned	char	gdt_ptr[6];

nasm彙編

; 使用C語言中宣告的變數gdt_ptr
extern gdt_ptr
; 把暫存器gdtr中的資料複製到變數gdt_ptr中
sgdt	[gdt_ptr]

然後在C語言中把LOADER中的GDT複製到C語言中的gdt變數中。

memcpy(&gdt, 
       (void *)((*)(int *)(&gdt_ptr[2])), 
       (*)((int *)(&gdt_ptr[0]))
      );
short *gdt_limit = &gdt_ptr[0];
int	*gdt_base = &gdt_ptr[2];

*gdt_limit = 128 * sizeof(Descriptor) - 1;
*gdt_base = (int) &gdt;

難點解讀

memcpy的引數

上面的那段程式碼,理解起來難度不小。

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

memcpy的第一個引數是目標記憶體地址,是一個指標型別變數,賦值應該是一個記憶體地址,所以用&取得變數gdt的記憶體地址。

  1. 理解(void *)((*)(int *)(&gdt_ptr[2]))
    1. 第二個引數是源資料的記憶體地址,是GDT的實體地址。
    2. 它儲存在gdt_ptr的後6個位元組中。
    3. &gdt_ptr[2]獲取gdt_ptr的第3個元素gdt_ptr[2]的實體地址。
    4. 前面的(int *)將這段實體地址強制型別轉換為一個指標,這個指標的資料型別是int *
    5. 資料型別是int *有三層含義:
      1. 這個資料是一個指標。
      2. 這個資料的值是一個記憶體地址。
      3. 這個記憶體地址是一個4位元組(int型別佔用4位元組)記憶體區域的初始地址。
    6. &gdt_ptr[2]是一個記憶體地址,用(int *)將它包裝成或強制轉換成指標型別。
    7. 再用*運算子,是獲取這個記憶體地址指向的記憶體區域中的資料。
    8. 這個資料是int型別,佔用4個位元組。這4個位元組的初始地址是&gdt_ptr[2]。這是最關鍵的一句。
    9. 為什麼最後還要用void *
      1. 因為,這4個位元組中儲存的那個int資料又是一個記憶體地址,因此,需要再次包裝成一個指標。
      2. 因為,memcpy對引數的資料型別要求是void *
      3. 究竟是哪個原因,我也不知道。
  2. 理解:(*)((short *)(&gdt_ptr[0]))+1
    1. 為什麼要加1?gdt_ptr的低2位儲存的是GDT的位元組偏移量的最大值,是GDT的長度減1。
    2. &gdt_ptr[0])gdt_ptr[0])的記憶體地址AD。
    3. (short *)&gdt_ptr[0])用AD建立一個指標變數。
      1. 這個指標變數指向一塊記憶體。
      2. 這塊記憶體佔用2個位元組。
      3. 這塊記憶體的初始地址是&gdt_ptr[0]),即gdt_ptr[0])的記憶體地址。
      4. (short *)&gdt_ptr[0])實質是指代&gdt_ptr[0]、&gdt_ptr[1]這兩小塊記憶體。
    4. (*)((short *)(&gdt_ptr[0]))&gdt_ptr[0]、&gdt_ptr[1]這兩小塊記憶體中的值,即gdt_ptr[0]、gdt_ptr[1]
    5. 為什麼不需要像第二個引數一樣在前面再加上一個(void *)
      1. 因為,第4步的結果是一個short型別的整型數(short能稱之為整型嗎?),不是記憶體地址,不需要強制型別轉換。

其他

short *gdt_limit = &gdt_ptr[0];
int	*gdt_base = &gdt_ptr[2];

*gdt_limit = 128 * sizeof(Descriptor) - 1;
*gdt_base = (int) &gdt;
short *gdt_limit = &gdt_ptr[0];
int	*gdt_base = &gdt_ptr[2];

這段程式碼建立了兩個變數並賦值,獲取了GDT的界限和地址。可是緊接著又有下面兩句,是對GDT的界限重新賦值。

*gdt_limit = 128 * sizeof(Descriptor) - 1;
*gdt_base = (int) &gdt;

這兩段程式碼的功能重複了嗎?

讓我們先看另外一段程式碼。

#include <stdio.h>

int main(int argc, char **argv)
{
        int b = 8;
        printf("b = %d\n", b);
        int *a = &b;
        *a = 9;
        printf("b = %d\n", b);

        return 0;
}

執行結果是:

MacBook-Pro:my-note-book cg$ ./test
b = 8
b = 9

第8行*a = 9;修改*a的值,同時也修改了b的值,因為第7行int *a = &b;

再回頭看

short *gdt_limit = &gdt_ptr[0];
int	*gdt_base = &gdt_ptr[2];

*gdt_limit = 128 * sizeof(Descriptor) - 1;
*gdt_base = (int) &gdt;

*gdt_base指向gdt_ptr[2]為初始地址的4個位元組的連續的記憶體空間AD,修改*gdt_base,實質是修改AD中的資料。int *gdt_base = &gdt_ptr[2];的作用是讓*gdt_base指向AD;*gdt_base = (int) &gdt;是修改AD中的資料,從業務邏輯的角度看,是把gdt的記憶體地址寫入AD中。為什麼要這樣做?回憶一下我們的目的是什麼?把儲存了GDT的C語言中變數的記憶體地址儲存到gdt_ptr中。

意外收穫

在理解上面那個比較複雜的指標引數的過程中,我對指標有了新的理解。

int a;,要求CPU(不知道執行者是CPU還是作業系統)為a分配四個位元組的記憶體空間,儲存資料。

int *a;,要求CPU為a分配四個位元組(第一片四位元組記憶體空間,記作A),在這四個位元組中儲存一個記憶體地址,這個記憶體地址指向另外一個四位元組的記憶體區域(記作B)。int *a的含義是,指向B中的int型別資料。

char days[5];
(short *)(&days[2]);

(short *)(&days[2]);的含義是:

  1. 指向一片記憶體區域addr,這片記憶體區域的長度是連續的2個位元組(short是2個位元組)。
  2. addr的初始地址是days[2]的記憶體地址,所以,這片記憶體是&days[2],&days[3]

相關文章