作業系統學習筆記2 | 作業系統介面
這部分將講解上層應用軟體如何與作業系統交互,理解作業系統到底發生了什麼事情,理解作業系統工作原理,為以後擴充作業系統、設計作業系統鋪墊。
參考資料:
- 課程:哈工大作業系統(本部分對應 L4 && L5)
- 實驗:作業系統原理與實踐_Linux – 藍橋雲課 (lanqiao.cn)
- 筆記:作業系統學習導引 · 語雀 (yuque.com)
0815這部分聽的比較折磨,反覆聽了幾次,終於基本理解了整個過程。
1. 介面
- 生活中的介面有:電源插座、油門閥……
- 總結一下,
- 連接兩個東西;
- 進行訊號轉換、屏蔽細節;
- 特點:上層使用介面非常方便,不必在意介面背後做了什麼;而介面內部需要進行轉化。
學習作業系統介面,不僅要關注如何調用介面,還要理解介面內部的工作原理。
2. 作業系統介面
正如生活中的介面,對於上層來講,介面的存在是十分自然的,當我們有某項需求,才會使用響應介面
如使用電的需求,才會用到插座
我們如何使用作業系統呢?
— 比如
-
我們終端鍵入一個命令
-
作業系統內部進行處理
-
螢幕上就顯示出來相應內容
也不一定都是命令
-
作業系統介面大致有3種
-
-
命令行、圖形按鈕、應用程式

-
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管理)。
-
現在回頭看一下上圖的過程:
-
系統啟動到最後執行shell,如上面程式
-
shell 調用scanf 打出
cst:/home/lizhijun#正好20個字元
-
通過
fork()以及wait()申請CPU,讓其執行左上角的程式碼 -
通過
printf()打出ECHO:hello除了
fork()和wait()調用CPU 以外:scanf也是真正的作業系統介面,可以實現從鍵盤讀入資訊,調用了鍵盤輸入- 另外
printf也是,可以調用顯示器
-
可見命令行就是一些程式,通過一些函數實現對電腦硬體的使用。
-
2.2 圖形按鈕
圖形按鈕基於一套消息機制。
說明:
- linux0.11隻有命令行,而沒有圖形介面
- linux 有圖形介面是比較新的版本如ubuntu
- Windows也有
- 可以嘗試在linux0.11上實現圖形介面

如何實現?
-
當滑鼠點擊、鍵盤按下後,通過中斷,這一事件被放到消息隊列中
-
而應用程式需要寫一個系統調用
getmessage(),從作業系統內核中把消息隊列中的消息取出而應用程式是一個不斷從消息隊列中取消息的循環,這就是消息機制。
-
根據拿出的消息執行對應的函數
-
以上圖程式為例,做了一件什麼事情:
-
硬體輸入
-
放入消息隊列
-
應用程式的消息循環取出消息
這裡是應用程式調用了作業系統的介面
-
判斷消息類型(右側函數)
-
下方函數中,打開一個文件,寫入字元串
這裡使用了調用磁碟的函數
-
應用程式介面先不講。
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。作業系統現在基本不依靠段來進行許可權檢查 以頁保護為主進行許可權檢查

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 中斷指令
具體見下圖右側程式碼。

所以舉例whoami() 中的printf()很複雜,它的實現在軟體層面跨越了三個層次:
-
應用程式,也就是我們常見的C語言,printf() 調用
-
C函數庫 中
printf()執行具體程式碼,調用庫函數write(),所說的
write()見右側程式碼第一個框。之所以這麼做(中間隔了一層),是因為
printf()格式化輸出和write()的參數不很協調,所以加了一層。 -
在庫函數
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還沒有細說,現在看看這個指令是如何工作的。

這部分老師講的很多,如果基礎不牢,會感覺很暈,先捋一下思路:
-
3.3中題到系統調用的核心三步,進入系統的唯一方法就是 0x80,而庫函數
write()通過一個宏,內嵌了一段包含核心0x80的彙編程式碼 -
然後需要再用一個暫存器 (eax) 保存是因為什麼到達 0x80 這個入口的,方便作業系統來進行對應的操作。比方說這裡是 write 使用了0x80.。
-
暫存器會通過保存系統調用號的方式來做上面的記錄,write對應的系統調用號就是4。
-
C程式中 int 0x80 這句話,int指令需要查idt表,取出中斷處理函數來確定int 0x80到哪個地方執行。
處理結束後再返回,這時0x80就已經完成,接著進行
"=a"(__res)把 eax 賦給 res中斷:電腦科學很偉大的發明,停下來跳到另外一個地方去執行。
-
那麼,int0x80 使用什麼中斷程式來處理呢?系統也幫我們做好了。

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,它是如何做的呢?

關鍵程式碼:
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個位元組
- 一個記憶體地址對應8位就是一個記憶體地址存1個位元組,所以*4就是找4個位元組
- 中斷號為4,那從中斷向量表裡找中斷服務函數入口的時候就是4*4,
- 即從表的初始地址往下加16個地址,就正好是16個位元組,每四個位元組一個入口
可以理解/推測 sys_call_table,就是一個函數表
3.7 sys_call_table/sys_write理解

從上面程式碼得知,_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 系統調用總結
如上圖所示的鏈條:
-
用戶調用 printf;
-
printf 在庫函數中展開為 包含 int 0x80 的程式碼;
-
————–用戶態結束,內核態開始——————
這裡用一種特殊的方式開啟了後門(IDT表 將 DPL 置為3)
-
system_call 中斷處理;
-
查表 _sys_call_table;
-
根據__NR_write=4拿到對應函數;
-
調用 sys_write;
我們已經推進到了sys_write,也就是介面的邊界,再向內部,才能解釋sys_write 最後發生了什麼。
回到最開始whoami的例子,參考上面write的過程:

-
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)將字元串打出來。
實驗二可以做了。






