作業系統學習筆記2 | 作業系統介面

這部分將講解上層應用軟體如何與作業系統交互,理解作業系統到底發生了什麼事情,理解作業系統工作原理,為以後擴充作業系統、設計作業系統鋪墊。


參考資料:

0815這部分聽的比較折磨,反覆聽了幾次,終於基本理解了整個過程。


1. 介面

  • 生活中的介面有:電源插座、油門閥……
  • 總結一下,
    • 連接兩個東西;
    • 進行訊號轉換、屏蔽細節;
    • 特點:上層使用介面非常方便,不必在意介面背後做了什麼;而介面內部需要進行轉化。

學習作業系統介面,不僅要關注如何調用介面,還要理解介面內部的工作原理。

2. 作業系統介面

正如生活中的介面,對於上層來講,介面的存在是十分自然的,當我們有某項需求,才會使用響應介面

如使用電的需求,才會用到插座

我們如何使用作業系統呢?

— 比如

  1. 我們終端鍵入一個命令

  2. 作業系統內部進行處理

  3. 螢幕上就顯示出來相應內容

    也不一定都是命令

  • 作業系統介面大致有3種

    • 命令行、圖形按鈕、應用程式

      image.png

2.1 命令行

命令行是什麼?即輸入命令後發生了什麼?

  • 命令就是一段程式

  • 舉個例子,程式編譯後變成可執行程式,就可以在命令行以命令的方式執行(如下圖),這些程式中包含一些語句,就是對作業系統介面的調用

  • 作業系統啟動到最後,打開一個桌面 / shell,打開桌面和shell是一回事

    現在我們常見的是打開桌面。而一些伺服器啟動後就是shell,沒有桌面。

    shell也是一段程式,在main.c中一系列的初始化之後,會執行/bin/sh,這個文件可以自己寫。

  • shell 程式的主體:

    int main(int argc,char *argv[]){
    	char cmd[20];
        while(1){
            scanf("%s",cmd);
            if(!fork()){exec(cmd);}
            else{wait();}
        }//while(1)
    }
    

    可見shell 是一段死循環,會用if(!fork()){exec(cmd);} 來執行用戶輸入的命令。

    其中fork和exec是真正的作業系統介面,這涉及進程管理(CPU管理)。

  • 現在回頭看一下上圖的過程:

    1. 系統啟動到最後執行shell,如上面程式

    2. shell 調用scanf 打出cst:/home/lizhijun#

      正好20個字元

    3. 通過 fork()以及 wait() 申請CPU,讓其執行左上角的程式碼

    4. 通過printf() 打出 ECHO:hello

      除了 fork() wait() 調用CPU 以外:

      • scanf也是真正的作業系統介面,可以實現從鍵盤讀入資訊,調用了鍵盤輸入
      • 另外printf也是,可以調用顯示器
    5. 可見命令行就是一些程式,通過一些函數實現對電腦硬體的使用。

2.2 圖形按鈕

圖形按鈕基於一套消息機制。

說明:

  • linux0.11隻有命令行,而沒有圖形介面
  • linux 有圖形介面是比較新的版本如ubuntu
  • Windows也有
  • 可以嘗試在linux0.11上實現圖形介面

image.png

如何實現?

  • 當滑鼠點擊、鍵盤按下後,通過中斷,這一事件被放到消息隊列中

  • 而應用程式需要寫一個系統調用getmessage(),從作業系統內核中把消息隊列中的消息取出

    而應用程式是一個不斷從消息隊列中取消息的循環,這就是消息機制

  • 根據拿出的消息執行對應的函數

  • 以上圖程式為例,做了一件什麼事情:

    1. 硬體輸入

    2. 放入消息隊列

    3. 應用程式的消息循環取出消息

      這裡是應用程式調用了作業系統的介面

    4. 判斷消息類型(右側函數)

    5. 下方函數中,打開一個文件,寫入字元串

      這裡使用了調用磁碟的函數

應用程式介面先不講。

2.3 總結

從上面可以知道,命令行和圖形按鈕都是一些程式,就是普通的C程式,只是在C的基礎上使用了一些重要的函數,這些函數可以進入作業系統、使用硬體

可見,這些函數就是電源插座,就是作業系統的介面

  • 作業系統提供了這樣的重要函數,這就是系統介面。
  • 介面表現為函數調用,又由系統提供,稱為系統調用 System Call。

有哪些具體的系統介面呢?

  • printf,實際printf在庫中調用了write,後者是真正的介面
  • fork,創建一個進程
  • 系統調用太多了,但應當知道哪裡是系統調用,到哪裡去查系統調用。
  • 系統調用介面需要有統一的規範,以適配不同的實現
    • POSIX:Portable Operating System Interface of Unix;
    • 這是一個手冊,可以在這裡查系統調用;也可以從這裡得知設計一個作業系統應當提供的基本介面,這樣在Linux上、Windows上跑的應用也可以在我的系統上跑。
    • 這就為上層應用程式跨作業系統提供了可能,因為調用介面一樣。

3. 系統調用的實現

那麼,上面提到的重要函數是如何實現的呢?

  • 從一個直觀的例子開始–whoami()

    • whoami()是一個系統調用,進入作業系統拿到當前用戶名並列印

      用戶名這個字元串是在內核中,所以這個函數進入內核了。

3.1 為什麼不能直接訪問內核

這裡解釋一個事情:

  • 應用程式main()在記憶體中,作業系統whoami()也在記憶體中,為什麼不能直接訪問存放用戶名這個字元串的記憶體呢?

    在我的例子中,這個字元串放在100這個地方。

    能不能直接找到用戶名所在的記憶體,然後列印呢?例如彙編中的mov指令。

  • 不能!不能隨意調用數據,不能在指令層面隨意jmp;不能也不應該。

  • 如果上面的事情被允許,上層應用程式(可能來自於網路),就可以得知你的root用戶名和密碼,可以修改它。

  • 此外,任何一種輸出數據到外設的系統調用,在某個時刻,這些數據會在作業系統內核的緩衝區中,這個時候就可能被泄漏,比如可以通過緩衝區或者顯示記憶體看到word軟體裡面的內容;

  • 所以作業系統阻止直接訪問的發生。

  • 這是怎麼做到的呢?

3.2 如何實現內核態和用戶態隔離

處理器的硬體設計做到的,從硬體層面保證了這個機制生效。

處理器硬體將記憶體訪問權力(主要)分為了用戶態和核心態。對應的實際區域即用戶段和內核段。指令在兩段之間不能隨意跳轉。

內核態和用戶態隔離的具體實現:

幾個名詞概念:DPL、CPL、RPL,是基於硬體實現的。

  • 下圖左下角

  • DPL ≥ CPL這一句(DPL ≥ RPL有興趣自己查看)

    RPL說明的是進程對段訪問的請求許可權(Request Privilege Level)

    • DPL意思是目標記憶體段的特權級,destination privilege level也稱 descriptor privilege level,之所以稱為目標,是因為它描述程式將要跳往的地方的特權級;

    • CPL意思是當前記憶體段的特權級,current privilege level,CS:IP指向當前要執行的指令地址,當前程式處於內核態還是用戶態(CPL),用CS:IP的最低兩位來表示。

      • 特權級,特權級有一個數字,數字的含義可見下圖處理器保護環;數字越小,越接近內核。
      • 特權級是在作業系統初始化時就設置好了的;DPL就在GDT表中,GDT表中第45、46位就是DPL。head.s 初始化時全為0。在系統最後啟動用戶應用程式時,跳轉後cs中的CPL就置為3了。
      • 0是內核態,3是用戶態。
      • 用戶態不可以訪問內核數據,內核態可以訪問所有層次數據
    • 重點:CPL就是CS的最低兩位,DPL可以從GDT表中查到,在保護模式下指令地址的翻譯是查GDT表,那麼這個時候就可以查到目標指令的DPL,和當前態的特權級CPL比較,如果DPL>=CPL,那就說明當前態的特權級足以執行目標指令,否則就不允許執行

    • 回到例子。所以例子中的main()程式CPL=3,而目標whoami()DPL為0,所以不能跳轉,也即不能從用戶態直接訪問內核。

      參考資料:CPL\DPL\RPL

      更多可查:特權環、保護環。

      whoami在內核態載入,main在用戶態載入,main調用whoami相當於用戶態jmp到內核態

  • 疑問:1和2表示什麼?

    答:作業系統——特權級

  • 當然,上面的舉例基於linux0.11。作業系統現在基本不依靠段來進行許可權檢查 以頁保護為主進行許可權檢查

image.png

3.3 系統調用如何實現跨越特權級訪問

前面提到過了不能直接訪問內核,不應該直接訪問內核,電腦是如何做到這種隔離的,下面就來看看在這種隔離下,系統調用如何實現跨越特權級的訪問。

同樣,也是硬體提供了 “主動進入內核的方法”:

對於 Intel ×86 來說,進入內核的 唯一方法中斷指令int,其他如jmp和mov都不行。

特意設計了一些特殊中斷,可以進入內核。

還是以whoami()為例:

main(){
    whoami();
}
//用戶程式,CPL為3,運行到whoami()時檢測到DPL為0
----------------------
whoami(){
    printf(100,8);
}
//系統程式
----------------------
100:"lizhijun"
//存放用戶名的記憶體和字元串

系統調用的核心:

  • 用戶程式(上圖中的main程式)中包含了一段包含 int 指令的程式碼

    表面上是open()函數,展開後是由包含int指令的C語言庫函數做的。

    進入內核。

  • 作業系統寫中斷處理,獲取想調程式的編號

  • 作業系統根據編號執行相應程式碼

問:為什麼不能在普通程式碼里直接使用這個特殊中斷進入內核?

答:不使用封裝的庫函數,直接寫int中斷編譯不通過(可能是編譯器的設計)。

以C程式碼庫編寫的系統調用,在用戶程式調用後,會首先進入C程式碼庫函數,然後用彙編程式碼在約定的位置(棧或者暫存器)設置參數和系統調用編號,最後執行int指令

關於特殊中斷,作業系統也規定好了:int 0x80 中斷指令

具體見下圖右側程式碼。

image.png

所以舉例whoami() 中的printf()很複雜,它的實現在軟體層面跨越了三個層次:

  1. 應用程式,也就是我們常見的C語言,printf() 調用

  2. C函數庫 中printf()執行具體程式碼,調用庫函數write()

    所說的 write()見右側程式碼第一個框。

    之所以這麼做(中間隔了一層),是因為printf()格式化輸出和write()的參數不很協調,所以加了一層。

  3. 在庫函數write()中展開為一個包含0x80的中斷程式碼,通過系統調用進入作業系統

    見右側程式碼第二個框。

3.4 write 的完整理解

將關於write的故事完整的講完,看看int 0x80 到底做了什麼事情,以及是如何做到的。

對庫函數write()來說,內嵌了一個宏:_systemcall3展開為包含int0x80的彙編程式碼。

宏展開:C語言中的宏展開 ,可以簡單理解為文本替換,相比於C基礎中的宏定義,這個宏能夠替代一段程式。

這個宏做了什麼事情?

  • 如上圖程式碼:

    //linux/include/unistd.h
    #define _syscall3(type,name,atype,a,btype,b,ctype,c)
    //參數就對應上面3.3 最後圖的int,write,int ,fd,const,char*buf,off_t,count
    type name(atype a, bytpe b,ctype c)
    {
    	long __res;
        __asm__ volatile("int 0x80":"=a"(__res):""(__NR_##name),"b"((long)(a)),"c"((long)(b)),"d"((long)(c)));
    	if(__res>=0)return (type)__res;
    	errno=-__res;
    	return -1l
    }
    

    這裡給大家解釋一下type,這被用來定義宏參數,也就說參數類型可以被替換,這樣就使得宏函數的定義變得非常靈活,這算是linus早期編程時使用的一個trick

  • 這是一段C語言內嵌彙編程式碼

    • 內嵌彙編共四個部分:彙編語句模板:輸出部分:輸入部分:破壞描述部分;
    • 各部分使用”:”格開,彙編語句模板必不可少,其他三部分可選;
    • 使用了後面的部分而前面部分為空,也需要用”:”格開,相應部分內容為空
    • 進階學習:內嵌彙編 – 阿加 – 部落格園 (cnblogs.com)
  • 核心程式碼就是0x80,"=a"(__res)時將a置給eax,後面引號為空,默認還是將NR_name置給eax….後面以此類推,

  • 這段程式碼的意思就是,獲取__NR_write,這是write的系統調用編號,將它放在%eax暫存器中方便後續系統使用。

    後面的b、c、d參數放在ebx、ecx、edx中,接著執行最前面的int 0x80

    __NR_write 是系統調用號,區分使用int 0x80 的函數,比如 open()write()。write對應的是4。

  • 執行int 0x80指令,這個中斷執行後,執行"=a"(__res),會把 eax 暫存器置給res,最後return res,就在C語言層面返回了write 對應的系統調用號。

    這裡劃重點,這裡只講了 “執行 int 0x80指令”,而沒有講如何執行,下一部分就會再詳細說這個。

上面的_syscall3的3的意思就是:有三個參數。只要都是3個參數都可以使用這部分程式碼的套路。

初始化一個描述 int 0x80 中斷的門描述符,並添加到IDT表,門描述符中的段選擇符是0x0008,可以定位到GDT表的第二個表項,即內核程式碼段

3.5 int 0x80 執行理解

上部分大致講了write庫函數的展開與實現過程,其中int 0x80還沒有細說,現在看看這個指令是如何工作的。

image.png

這部分老師講的很多,如果基礎不牢,會感覺很暈,先捋一下思路:

  • 3.3中題到系統調用的核心三步,進入系統的唯一方法就是 0x80,而庫函數write()通過一個宏,內嵌了一段包含核心0x80的彙編程式碼

  • 然後需要再用一個暫存器 (eax) 保存是因為什麼到達 0x80 這個入口的,方便作業系統來進行對應的操作。比方說這裡是 write 使用了0x80.。

  • 暫存器會通過保存系統調用號的方式來做上面的記錄,write對應的系統調用號就是4。

  • C程式中 int 0x80 這句話,int指令需要查idt表,取出中斷處理函數來確定int 0x80到哪個地方執行。

    處理結束後再返回,這時0x80就已經完成,接著進行"=a"(__res)把 eax 賦給 res

    中斷:電腦科學很偉大的發明,停下來跳到另外一個地方去執行。

  • 那麼,int0x80 使用什麼中斷程式來處理呢?系統也幫我們做好了。

image.png

void sched_init(void){
    set_system_gate(0x80,&system_call);
}
---------------------------------------------

//linux/include/asm/system.h中
#define set_system_gate(n,addr)
//n為中斷處理號,addr是中斷處理號。
_set_gate(&idt[n],15,3,addr);
//idt是中斷向量表基址,傳向gate_addr,15傳向type,3傳向dpl
//到這裡應當明白dpl的設置過程,在這裡目標態被設為了用戶態
#define _set_gate(gate_addr, type, dpl, addr)
//又是一段C內嵌彙編的程式碼
__asm__("movw %%dx,%%ax\n\\t""movw %0,%%dx\n\t"
       "mov1 %%eax,%1\n\t""mov1 %%edx %2":
       :"i"((short)(ox8000+(dpl<<13)+type<<8))),"o"(*((char*)(gate_addr))),"o"(*(4+(char*)(gate_addr))),
"d"((char*)(addr),"a"(0x00080000));
//意思就是將表中的高四位和第四位分別貼到edx和eax

從上面的init()函數可知,系統初始化時就已經做了:int 0x80 通過system_call 來進行處理.

  • 設置 set_system_gate 中斷處理門來實現從 0x80 到 system_call 的連接。

    實際上每個表項都是中斷處理門,set_system_gate 這個函數核心就是設置IDT表,遇到80中斷,就從表中取出相應中斷處理函數,跳轉執行。

  • 上面C程式的功能對應就是填充下面的表格:

    • addr 填充 處理函數入口點偏移

    • 3 放到表中 DPL

    • 把上面程式"a"(0x00080000)中 的 0008 (16位)放到 段選擇符

      即段選擇符為8

    • 另外一點,PPT中的type域不對,應為01111


再來詳細說說DPL=3的操作。

  • 回憶一下例子:

  • 在main中 CPL為3,而當whoami()展開、執行 int 0x80 時,需要查IDT表時來進行,IDT表中的 DPL 特意設置為3

    相當於特意為這次調用開了個後門

  • 這樣 CPL = DPL,能夠跳到80號中斷.


如何跳到80號中斷?

  • 上面的IDT表中已經有了偏移,還需要基址才能組成PC

  • cs=0x0008, ip=&system_call

    回憶一下,上一講中提到的jmpi 0,8,兩個8完全一樣。

    這樣我們以相似的方式,找到表項,再找到system_call 的地址,就實現了跳轉。

  • 跳轉之後,cs的最後兩位,就==0,這就正是CPL=0;意味著特權級置為0.意味著最高許可權什麼事情都可以做了。

    這樣,在內核中執行時,特權級就是0.

  • 中斷再返回的時候,會再執行一條指令,cs 最後兩位就又變成了3。

3.6 system_call 理解

上面講到使用system_call 來處理 int 0x80,它是如何做的呢?

image.png

關鍵程式碼:

mov1 $0x10,%edx 
mov %dx,%ds
mov %dx,%es
## 內核數據
###ds=es=0x10
###8是內核程式碼段,16(十進位)是內核數據段
###意味著從現在開始真正執行內核程式碼
###這裡有疑問,老師說,既是內核程式碼段也是數據段?

## 跳到一個表裡取執行內核程式碼
call _sys_call_table(,%eax,4)#a(,%eax,4)=a+4*eax
### eax正是前面的__NR_write,系統調用號
### _sys_call_table+4*%eax就是響應的調用處理函數入口

為什麼要乘4呢?

eax是4(表示write的系統調用號為4,為第四系統調用)

而前面的4是,每個系統調用佔4個位元組。

再具體一點說,每個系統調用函數指針是4個位元組

  1. 一個記憶體地址對應8位就是一個記憶體地址存1個位元組,所以*4就是找4個位元組
  2. 中斷號為4,那從中斷向量表裡找中斷服務函數入口的時候就是4*4,
  3. 即從表的初始地址往下加16個地址,就正好是16個位元組,每四個位元組一個入口

可以理解/推測 sys_call_table,就是一個函數表

3.7 sys_call_table/sys_write理解

image.png

從上面程式碼得知,_sys_call_table果然是一個函數指針數組,第4個位置上放的是sys_write。

從0開始數。

根據上面的4*4,最終計算得到的入口是sys_write,所以3.6圖中的call _sys_call_table(,%eax,4),實際上就是call sys_write

sys_write 要做什麼呢?

  • 就要實現向顯示記憶體寫的功能
  • 至於sys_write的內部實現,要等到講了文件讀寫、IO驅動後再來看

3.8 系統調用總結

如上圖所示的鏈條:

  1. 用戶調用 printf;

  2. printf 在庫函數中展開為 包含 int 0x80 的程式碼;

  3. ————–用戶態結束,內核態開始——————

    這裡用一種特殊的方式開啟了後門(IDT表 將 DPL 置為3)

  4. system_call 中斷處理;

  5. 查表 _sys_call_table;

  6. 根據__NR_write=4拿到對應函數;

  7. 調用 sys_write;

我們已經推進到了sys_write,也就是介面的邊界,再向內部,才能解釋sys_write 最後發生了什麼。


回到最開始whoami的例子,參考上面write的過程:

image.png

  • eax=72,表示whoami系統調用編號為72;

  • 通過 int 0x80 指令進入中斷處理函數_system_call

    這裡經歷了CPL和DPL的變化;

  • _sys_call_table找到第72項,是_sys_whoami(應該需要修改作業系統初始化的程式碼,在_sys_call_table中加入sys_whoami表項)

  • 最終執行的就是sys_whoami的函數體,現在就有許可權訪問內核段了

    在內核中,使用printk(100,8)將字元串打出來。

實驗二可以做了。