十三. 實現系統調用

有人看的話就多說幾句。

文章主要為記錄自己linux 內核的學習過程,雖然這個內核很簡陋。所以內容會在我自己理解的層面上寫,有些內容可能不會過於詳細,有些地方是我想寫但能力上做不到。肯定也有地方是我理解有偏差的,忘大佬們不吝指教。如果有疑問的話可以在評論中問我,或者麻煩指出我的錯誤,感激不盡。我會在文章的末尾更新這些問題

系統調用簡介

關於系統調用前面有過簡單的介紹,這裡將真正實現系統調用

現代的操作系統中,用戶的許可權是有限的,它不能隨意的訪問系統中的資源。操作系統屏蔽了用戶直接訪問硬體的能力,這樣做的原因主要是為了安全考慮。

但是如果我們想控制顯卡列印字元怎麼呢,那就需要通過操作系統提供的介面來完成了,我們調用操作系統提供的介面,然後操作系統去操控硬體,比如說這裡的顯卡,列印出字元來。我們使用的c語言裡面的printf函數,其真實調用的是系統調用介面中的write,操作系統提供的這一系列介面就是系統調用介面。

總結來說,系統調用就是用戶進程申請操作系統的幫助,讓操作系統幫其完成某項工作,也就相當於用戶進程調用了操作系統的功能。

系統調用的實現原理

系統調用主要是通過中斷門實現的,通過軟中斷int發出中斷信號。由於要支持的系統功能很多,不可能每個系統調用就佔用一個中斷向量。所以規定了0x80為系統調用的中斷向量號,在進行系統調用之前,向eax中寫入系統調用的子功能號,再進行系統調用的時候,系統就會根據eax的值來決定調用哪個中斷處理常式。

linux中系統調用的實現

在完善我們的系統調用之前,先看看linux中是如何實現系統調用的。

通過man syscall查看系統調用的文檔

這裡面可以看到他的原型是

long syscall(long number, ...);

該函數接收一個系統調用號,由於不同的子功能所需的參數都是不同的,所以該函數需要支持可變參數。

這裡可以看一個例子

這裡調用了系統調用SYS_gettid,這個SYS_gettid定義在

繼續向後找,可以看到最終的定義就是一個數值,也就是SYS_gettid的子功能號。

syscall其實是一個間接的系統調用,它是一個庫函數,不是操作系統直接提供的。

linux中也有直接提供的系統調用,就是_syscall。

這種系統調用方式是通過宏機制來實現的,參數個數不同就對應不同的宏,但是最大支持的參數只有6個,而且會引發安全問題,所以它已經被廢棄了。

而我們的kernel就打算用這種方式來實現系統調用,因為相對來說簡單

實現系統調用

實現系統調用的流程如下

  1. 用中斷門實現系統調用,通過0x80中斷作為系統調用的入口
  2. 在IDT中安裝0x80號中斷對應的描述符,在該描述符中註冊系統調用對應的中斷處理常式
  3. 建立系統調用子功能表,利用eax寄存器中的子功能號在該表中索引相應的處理函數
  4. 用宏實現用戶空間系統調用介面_syscall,最大隻支持3個參數,ebx保存第一個參數,ecx保存第二個參數,edx保存第三個參數

就按照這個步驟一步步完成代碼

在idt中添加0x80的描述符,該描述符必須要在3特權級下能夠訪問,否則用戶就無法調用系統調用介面了

extern uint32_t syscall_handler();static void idt_desc_init(void){ int lastindex = IDT_DESC_CNT - 1; for (int i = 0; i < IDT_DESC_CNT - 1; i++) { make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]); } make_idt_desc(&idt[lastindex], IDT_DESC_ATTR_DPL3, syscall_handler); put_str(" idt_desc_init done
");}

系統調用宏的實現

/* 無參數的系統調用 */#define _syscall0(NUMBER) ({ int retval; asm volatile( "int $0x80" : "=a"(retval) : "a"(NUMBER) : "memory"); retval; })/* 一個參數的系統調用 */#define _syscall1(NUMBER, ARG1) ({ int retval; asm volatile( "int $0x80" : "=a"(retval) : "a"(NUMBER), "b"(ARG1) : "memory"); retval; })/* 兩個參數的系統調用 */#define _syscall2(NUMBER, ARG1, ARG2) ({ int retval; asm volatile( "int $0x80" : "=a"(retval) : "a"(NUMBER), "b"(ARG1), "c"(ARG2) : "memory"); retval; })/* 三個參數的系統調用 */#define _syscall3(NUMBER, ARG1, ARG2, ARG3) ({ int retval; asm volatile( "int $0x80" : "=a"(retval) : "a"(NUMBER), "b"(ARG1), "c"(ARG2), "d"(ARG3) : "memory"); retval; })

完成0x80的處理常式

[bits 32]extern syscall_tablesection .textglobal syscall_handlersyscall_handler: ; 保存上下文環境 push 0 push ds push es push fs push gs pushad push 0x80 push edx push ecx push ebx // 調用相應的處理程序 call [syscall_table + 4 * eax] add esp, 12 ; 跨過上面的三個參數 ; 將 call 調用後的返回值存入當前內核棧中 eax 的位置 mov [esp + 4 * 8], eax jmp intr_exit

初始化系統調用子功能對應的處理程序

#define syscall_nr 32typedef void *syscall;syscall syscall_table[syscall_nr];/* 返回當前任務的pid */uint32_t sys_getpid(void){ return running_thread()->pid;}/* 初始化系統調用 */void syscall_init(void){ put_str("syscall_init start
"); syscall_table[SYS_GETPID] = sys_getpid; put_str("syscall_init done
");}

提供用戶使用的系統調用介面

/* 返回當前任務pid */uint32_t getpid(){ return _syscall0(SYS_GETPID);}

當前就有了一個getpid的系統調用介面

系統調用write以及printf函數的實現

目前這個write只是一個簡易版,它的主要功能是為printf函數提供支持。哈哈,終於要實現自己的printf函數了。有了前面的基礎,再添加一個系統調用就很簡單了。

添加SYS_WRITE的處理程序

uint32_t sys_write(char *str){ console_put_str(str); return strlen(str);}/* 初始化系統調用 */void syscall_init(void){ put_str("syscall_init start
"); syscall_table[SYS_GETPID] = sys_getpid; syscall_table[SYS_WRITE] = sys_write; put_str("syscall_init done
");}

提供用戶調用介面

uint32_t write(char *str){ return _syscall1(SYS_WRITE, str);}

可變參數的原理

我們平時使用的函數中,大多數參數的個數都是已知的。函數佔用的是靜態內存,也就是說再編譯期就要確定為其分配多大的空間。這個空間的大小在函數聲明的時候就已經確定了。比如

int func(int,char);

編譯器會自動的根據函數參數的類型在棧中分配好空間。那麼問題來了,對於這種參數確定的函數,編譯器能夠知道為其分配多少空間。那麼在參數可變的情況下,編譯器有是如何為其分配空間的呢

int printf(const char *format, ...);

printf函數的原型如上,通常我們是這樣調用它的

printf("aaa%s %c", str, ch);

從這種調用方式來看,雖然說他的參數是可變的,同樣也可以說他的參數是固定的。因為在調用它的時候這個format是固定的,format就是指 aaa%s %c這段內容,每一個 % 後面就帶表需要一個參數,這裡就固定了它有兩個參數。每個 % 便是在棧中尋找可變參數的依據。

gcc對於可變參數的支持是通過 va_start, va_end, va_arg 這三個宏來實現的。

// 這個宏的作用相當於初始化ap指針,使其執行v的地址va_start(ap,v) //ap是指向可變參數指針變數,t是可變參數的類型,該函數的作用是使ap指向棧中下一個參數的地址並返回其值 va_arg(ap,t)//清空apva_end(ap)

我們通過對printf函數中format的解析,每找到一個 % 通過 % 後面接的字元來判斷參數的類型,知道該參數的類型之後,就可以調用va_arg找到該參數在棧中的地址,也就可以順利的實現printf了。下面看一下具體的解析format的過程

/* 將參數ap按照格式format輸出到字元串str,並返回替換後str長度 */uint32_t vsprintf(char *str, const char *format, va_list ap){ char *buf_ptr = str; const char *index_ptr = format; char index_char = *index_ptr; int32_t arg_int; char *arg_str; while (index_char) { if (index_char != %) { *(buf_ptr++) = index_char; index_char = *(++index_ptr); continue; } index_char = *(++index_ptr); // 得到%後面的字元 switch (index_char) { case s: arg_str = va_arg(ap, char *); strcpy(buf_ptr, arg_str); buf_ptr += strlen(arg_str); index_char = *(++index_ptr); break; case c: *(buf_ptr++) = va_arg(ap, char); index_char = *(++index_ptr); break; case d: arg_int = va_arg(ap, int); /* 若是負數, 將其轉為正數後,再正數前面輸出個負號-. */ if (arg_int < 0) { arg_int = 0 - arg_int; *buf_ptr++ = -; } itoa(arg_int, &buf_ptr, 10); index_char = *(++index_ptr); break; case x: arg_int = va_arg(ap, int); itoa(arg_int, &buf_ptr, 16); index_char = *(++index_ptr); // 跳過格式字元並更新index_char break; } } return strlen(str);}

這個函數的作用就是對format進行解析,這裡只處理了 % 後面接單個字元的情況,總共解析了字元串,字元,整數,16進位整數這些類型。

解析的過程其實就是將原先%x替換成棧中的數據。

比如

char *str = "bbb";printf("aaa %s", str);

在找到%s後,知道了參數的類型為char*,就調用va_arg得到該參數在棧中的地址,然後將%s替換成棧中的數據。

這三個宏的實現如下。

typedef char* va_list;#define va_start(ap, v) ap = (va_list)&v // 把ap指向第一個固定參數v#define va_arg(ap, t) *((t *)(ap += 4)) // ap指向下一個參數並返回其值#define va_end(ap) ap = NULL // 清除ap

解析完了之後就可以直接通過printf函數調用輸出了,順帶也罷sprintf實現了

/* 同printf不同的地方就是字元串不是寫到終端,而是寫到buf中 */uint32_t sprintf(char *buf, const char *format, ...){ va_list args; uint32_t retval; va_start(args, format); retval = vsprintf(buf, format, args); va_end(args); return retval;}/* 格式化輸出字元串format */uint32_t printf(const char *format, ...){ va_list args; va_start(args, format); // 使args指向format char buf[1024] = {0}; // 用於存儲拼接後的字元串 vsprintf(buf, format, args); va_end(args); return write(buf);}

可以看到printf函數確實是通過系統調用write實現的。


問題一:沒看懂vsprintf函數中是怎麼根據%來取到對應的數據的,不是應該通過獲取printf中的動態參數來獲取對應的%s,%c對應的數據的地址么,可是怎麼獲取動態參數的我沒看懂

函數調用過程中參數都是存放在棧中的..就算是這種動態參數.它存放在棧中這點是不會改變的..首先va_start這個宏在調用的時候會指向format這個參數在棧中的位置..這點你可以看一下調用代碼..其次,c語言的函數調用約定中,入參順序是從右向左。也就是說,format是最接近棧頂的位置。那麼想找到下一個參數在棧中的地址,是不是只需要根據這個參數的類型從format的位置開始偏移就行了。這個偏移就是通過va_arg實現的。va_arg中的參數每次+4是因為一個指針大小為32位,也可以把它當成棧頂上移。此時棧的情況大致是這樣的

到這裡不知道大家會不會有疑問,為什麼不是 arg_n 在format的上面而是arg_1,不是說從右向左入棧么,看一下printf的函數原型就知道了

printf(const char *format, ...);

... 中的內容就代表了arg_1到arg_n這所有的參數,它是一個整體,所以它入棧的順序自然不會倒著來

typedef char* va_list;#define va_start(ap, v) ap = (va_list)&v // 把ap指向第一個固定參數v#define va_arg(ap, t) *((t *)(ap += 4)) // ap指向下一個參數並返回其值#define va_end(ap) ap = NULL // 清除ap

這三個宏的代碼放在這裡。

首先調用va_start,使ap指向format在棧中的地址

在printf中,每遇到一個%號便代表遇到了一個參數,需要去棧中找到這個參數的位置,從而知道該參數的值。va_arg每次上移一個指針大小,因為棧中的數據都是32位的,所以+4之後ap就會指向arg_1所在的位置,從這個位置取出arg_1這個參數類型大小的數據即為所需的數據。

通過printf中的動態參數來獲取對應%s這些數據的地址是無法直接做到的。你在調用printf的時候,是給它傳遞了一些變數,形如printf("%s",str);

但是在printf的函數聲明裡面是沒有這個形參的。str的地址是沒法直接獲取的。普通的函數可以通過形參直接獲取該參數在棧中的地址,這裡做不到


推薦閱讀:

蘋果操作系統適用什麼人群?
主存管理 | 分區存儲管理
文件系統 | 文件的物理結構
CSAPP Lab -- Cache Lab
進程與進程管理 | 經典進程同步問題

TAG:Linux內核 | 操作系統 | Linux |