Linux系統調用講義

Linux系統調用講義

Linux系統調用講義

Linux下系統調用的實現
Linux中的系統調用
Linux中怎樣編譯和定製內核

  • Linux下系統調用的實現
  1. Unix/Linux操作系統的體系結構及系統調用介紹
    1. 什麼是操作系統和系統調用


          操作系統是從硬件抽象出來的虛擬機,在該虛擬機上用戶可以運行應用程序。它負責直接與硬件交互,向用戶程序提供公共服務,並使它們同硬件特性隔離。因爲程序不應該依賴於下層的硬件,只有這樣應用程序才能很方便的在各種不同的Unix系統之間移動。系統調用是Unix/Linux操作系統向用戶程序提供支持的接口,通過這些接口應用程序向操作系統請求服務,控制轉向操作系統,而操作系統在完成服務後,將控制和結果返回給用戶程序。
       

    2. Unix/Linux系統體系結構


      一個Unix/Linux系統分爲三個層次:用戶、核心以及硬件。

           其中系統調用是用戶程序與核心間的邊界,通過系統調用進程可由用戶模式轉入核心模式,在覈心模式下完成一定的服務請求後在返回用戶模式。

          系統調用接口看起來和C程序中的普通函數調用很相似,它們通常是通過庫把這些函數調用映射成進入操作系統所需要的原語。

          這些操作原語只是提供一個基本功能集,而通過庫對這些操作的引用和封裝,可以形成豐富而且強大的系統調用庫。這裏體現了機制與策略相分離的編程思想——系統調用只是提供訪問核心的基本機制,而策略是通過系統調用庫來體現。

      例:execv, execl, execlv, opendir , readdir...

       

    3. Unix/Linux運行模式,地址空間和上下文
       

      運行模式(運行態):

          一種計算機硬件要運行Unix/Linux系統,至少需要提供兩種運行模式:高優先級的核心模式和低優先級的用戶模式。

          實際上許多計算機都有兩種以上的執行模式。如:intel 80x86體系結構就有四層執行特權,內層特權最高。Unix只需要兩層即可以了:核心運行在高優先級,稱之爲核心態;其它外圍軟件包括shell,編輯程序,Xwindow等等都是在低優先級運行,稱之爲用戶態。之所以採取不同的執行模式主要原因時爲了保護,由於用戶進程在較低的特權級上運行,它們將不能意外或故意的破壞其它進程或內核。程序造成的破壞會被局部化而不影響系統中其它活動或者進程。當用戶進程需要完成特權模式下才能完成的某些功能時,必須嚴格按照系統調用提供接口才能進入特權模式,然後執行調用所提供的有限功能。

          每種運行態都應該有自己的堆棧。在Linux中,分爲用戶棧和核心棧。用戶棧包括在用戶態執行時函數調用的參數、局部變量和其它數據結構。有些系統中專門爲全局中斷處理提供了中斷棧,但是x86中並沒有中斷棧,中斷在當前進程的核心棧中處理。

      地址空間:

          採用特權模式進行保護的根本目的是對地址空間的保護,用戶進程不應該能夠訪問所有的地址空間:只有通過系統調用這種受嚴格限制的接口,進程才能進入核心態並訪問到受保護的那一部分地址空間的數據,這一部分通常是留給操作系統使用。另外,進程與進程之間的地址空間也不應該隨便互訪。這樣,就需要提供一種機制來在一片物理內存上實現同一進程不同地址空間上的保護,以及不同進程之間地址空間的保護。

          Unix/Linux中通過虛存管理機制很好的實現了這種保護,在虛存系統中,進程所使用的地址不直接對應物理的存儲單元。每個進程都有自己的虛存空間,每個進程有自己的虛擬地址空間,對虛擬地址的引用通過地址轉換機制轉換成爲物理地址的引用。正因爲所有進程共享物理內存資源,所以必須通過一定的方法來保護這種共享資源,通過虛存系統很好的實現了這種保護:每個進程的地址空間通過地址轉換機制映射到不同的物理存儲頁面上,這樣就保證了進程只能訪問自己的地址空間所對應的頁面而不能訪問或修改其它進程的地址空間對應的頁面。

          虛擬地址空間分爲兩個部分:用戶空間和系統空間。在用戶模式下只能訪問用戶空間而在覈心模式下可以訪問系統空間和用戶空間。系統空間在每個進程的虛擬地址空間中都是固定的,而且由於系統中只有一個內核實例在運行,因此所有進程都映射到單一內核地址空間。內核中維護全局數據結構和每個進程的一些對象信息,後者包括的信息使得內核可以訪問任何進程的地址空間。通過地址轉換機制進程可以直接訪問當前進程的地址空間(通過MMU),而通過一些特殊的方法也可以訪問到其它進程的地址空間。

          儘管所有進程都共享內核,但是系統空間是受保護的,進程在用戶態無法訪問。進程如果需要訪問內核,則必須通過系統調用接口。進程調用一個系統調用時,通過執行一組特殊的指令(這個指令是與平臺相關的,每種系統都提供了專門的trap命令,基於x86Linux中是使用int 指令)使系統進入內核態,並將控制權交給內核,由內核替代進程完成操作。當系統調用完成後,內核執行另一組特徵指令將系統返回到用戶態,控制權返回給進程。

      上下文:

          一個進程的上下文可以分爲三個部分:用戶級上下文、寄存器上下文以及系統級上下文。

          用戶級上下文:正文、數據、用戶棧以及共享存儲區;

          寄存器上下文:程序寄存器(IP),即CPU將執行的下條指令地址,處理機狀態寄存器(EFLAGS),棧指針,通用寄存器;

          系統級上下文:進程表項(proc結構)U區,在Linux中這兩個部分被合成task_struct,區表及頁表(mm_struct , vm_area_struct, pgd, pmd, pte),核心棧等。

          全部的上下文信息組成了一個進程的運行環境。當發生進程調度時,必須對全部上下文信息進行切換,新調度的進程才能運行。進程就是上下文的集合的一個抽象概念。

       

    4. 系統調用的功能和分類
    操作系統核心在運行期間的活動可以分爲兩個部分:上半部分(top half)和下半部分(bottom half),其中上半部分爲應用程序提供系統調用或自陷的服務,是同步服務,由當前執行的進程引起,在當前進程上下文中執行並允許直接訪問當前進程的數據結構;而下半部分則是由處理硬件中斷的子程序,是屬於異步活動,這些子程序的調用和執行與當前進程無關。上半部分允許被阻塞,因爲這樣阻塞的是當前進程;下半部分不允許被阻塞,因爲阻塞下半部分會引起阻塞一個無辜的進程甚至整個核心。

    系統調用可以看作是一個所有Unix/Linux進程共享的子程序庫,但是它是在特權方式下運行,可以存取核心數據結構和它所支持的用戶級數據。系統調用的主要功能是使用戶可以使用操作系統提供的有關設備管理、文件系統、進程控制進程通訊以及存儲管理方面的功能,而不必要了解操作系統的內部結構和有關硬件的細節問題,從而減輕用戶負擔和保護系統以及提高資源利用率。

    系統調用分爲兩個部分:與文件子系統交互的和進程子系統交互的兩個部分。其中和文件子系統交互的部分進一步由可以包括與設備文件的交互和與普通文件的交互的系統調用(open, close, ioctl, create, unlink, . . . );與進程相關的系統調用又包括進程控制系統調用(fork, exit, getpid, . . . ),進程間通訊,存儲管理,進程調度等方面的系統調用。

2.Linux下系統調用的實現
    (以i386爲例說明)
         A.在Linux中系統調用是怎樣陷入核心的?

    在每種平臺上,都有特定的指令可以使進程的執行由用戶態轉換爲核心態,這種指令稱作操作系統陷入(operating system trap)。進程通過執行陷入指令後,便可以在覈心態運行系統調用代碼。

    在Linux中是通過軟中斷來實現這種陷入的,在x86平臺上,這條指令是int 0x80。也就是說在Linux中,系統調用的接口是一箇中斷處理函數的特例。具體怎樣通過中斷處理函數來實現系統調用的入口將在後面詳細介紹。

    這樣,就需要在系統啓動時,對INT 0x80進行一定的初始化,下面將描述其過程:

1.使用匯編子程序setup_idtlinux/arch/i386/kernel/head.S)初始化idt表(中斷描述符表),這時所有的入口函數偏移地址都被設爲ignore_int


    1. ( setup_idt:

lea ignore_int,%edx

movl $(__KERNEL_CS << 16),%eax

movw %dx,%ax /* selector = 0x0010 = cs */

movw x8E00,%dx /* interrupt gate - dpl=0, present */

lea SYMBOL_NAME(idt_table),%edi

mov 6,%ecx

rp_sidt:

movl %eax,(%edi)

movl %edx,4(%edi)

addl ,%edi

dec %ecx

jne rp_sidt

ret

selector = __KERNEL_CS, DPL = 0, TYPE = E, P = 1;

2.Start_kernel()(linux/init/main.c)調用trap_init()(linux/arch/i386/kernel/trap.c)函數設置中斷描述符表。在該函數裏,實際上是通過調用函數set_system_gate(SYSCALL_VECTOR,&system_call)來完成該項的設置的。其中的SYSCALL_VECTOR就是0x80,而system_call則是一個彙編子函數,它即是中斷0x80的處理函數,主要完成兩項工作:a. 寄存器上下文的保存;b. 跳轉到系統調用處理函數。在後面會詳細介紹這些內容。

  (補充說明:門描述符

    set_system_gate()是在linux/arch/i386/kernel/trap.S中定義的,在該文件中還定義了幾個類似的函數set_intr_gate(), set_trap_gate, set_call_gate()。這些函數都調用了同一個彙編子函數__set_gate(),該函數的作用是設置門描述符。IDT中的每一項都是一個門描述符。

#define _set_gate(gate_addr,type,dpl,addr)

set_gate(idt_table+n,15,3,addr);

    門描述符的作用是用於控制轉移,其中會包括選擇子,這裏總是爲__KERNEL_CS(指向GDT中的一項段描述符)、入口函數偏移地址、門訪問特權級(DPL)以及類型標識(TYPE)。Set_system_gateDPL3,表示從特權級3(最低特權級)也可以訪問該門,type15,表示爲386中斷門。) 
 

B.與系統調用相關的數據結構

1.系統調用處理函數的函數名的約定

    函數名都以“sys_”開頭,後面跟該系統調用的名字。例如,系統調用fork()的處理函數名是sys_fork()

asmlinkage int sys_fork(struct pt_regs regs);

(補充關於asmlinkage的說明)

 
2.系統調用號(System Call Number

    核心中爲每個系統調用定義了一個唯一的編號,這個編號的定義在linux/include/asm/unistd.h中,編號的定義方式如下所示:

#define __NR_exit 1

#define __NR_fork 2

#define __NR_read 3

#define __NR_write 4

. . . . . .

    用戶在調用一個系統調用時,系統調用號號作爲參數傳遞給中斷0x80,而該標號實際上是後面將要提到的系統調用表(sys_call_table)的下標,通過該值可以找到相映系統調用的處理函數地址。

 
3.系統調用表

系統調用表的定義方式如下:(linux/arch/i386/kernel/entry.S

ENTRY(sys_call_table)

.long SYMBOL_NAME(sys_ni_syscall)

.long SYMBOL_NAME(sys_exit)

.long SYMBOL_NAME(sys_fork)

.long SYMBOL_NAME(sys_read)

.long SYMBOL_NAME(sys_write)

. . . . . .

系統調用表記錄了各個系統調用處理函數的入口地址,以系統調用號爲偏移量很容易的能夠在該表中找到對應處理函數地址。在linux/include/linux/sys.h中定義的NR_syscalls表示該表能容納的最大系統調用數,NR_syscalls = 256

 
C.系統調用函數接口是如何轉化爲陷入命令


    如前面提到的,系統調用是通過一條陷入指令進入核心態,然後根據傳給核心的系統調用號爲索引在系統調用表中找到相映的處理函數入口地址。這裏將詳細介紹這一過程。

    我們還是以x86爲例說明:

    由於陷入指令是一條特殊指令,而且依賴與操作系統實現的平臺,如在x86中,這條指令是int 0x80,這顯然不是用戶在編程時應該使用的語句,因爲這將使得用戶程序難於移植。所以在操作系統的上層需要實現一個對應的系統調用庫,每個系統調用都在該庫中包含了一個入口點(如我們看到的fork, open, close等等),這些函數對程序員是可見的,而這些庫函數的工作是以對應系統調用號作爲參數,執行陷入指令int 0x80,以陷入核心執行真正的系統調用處理函數。當一個進程調用一個特定的系統調用庫的入口點,正如同它調用任何函數一樣,對於庫函數也要創建一個棧幀。而當進程執行陷入指令時,它將處理機狀態轉換到核心態,並且在覈心棧執行核心代碼。

    這裏給出一個示例(linux/include/asm/unistd.h):

#define _syscallN(type, name, type1, arg1, type2, arg2, . . . ) \

type name(type1 arg1,type2 arg2) \

{ \

long __res; \

__asm__ volatile ("int x80" \

: "=a" (__res) \

: "" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2))); \

. . . . . .

__syscall_return(type,__res); \

}

    在執行一個系統調用庫中定義的系統調用入口函數時,實際執行的是類似如上的一段代碼。這裏牽涉到一些gcc的嵌入式彙編語言,不做詳細的介紹,只簡單說明其意義:

    其中__NR_##name是系統調用號,如name == ioctl,則爲__NR_ioctl,它將被放在寄存器eax中作爲參數傳遞給中斷0x80的處理函數。而系統調用的其它參數arg1, arg2, …則依次被放入ebx, ecx, . . .等通用寄存器中,並作爲系統調用處理函數的參數,這些參數是怎樣傳入核心的將會在後面介紹。

    下面將示例說明:

int func1()

{

int fd, retval;

fd = open(filename, ……);

……

ioctl(fd, cmd, arg);

. . .

}

 

func2()

{

int fd, retval;

fd = open(filename, ……);

……

__asm__ __volatile__(\

"int x80\n\t"\

:"=a"(retval)\

:"0"(__NR_ioctl),\

"b"(fd),\

"c"(cmd),\

"d"(arg));

}

    這兩個函數在Linux/x86上運行的結果應該是一樣的。

    若干個庫函數可以映射到同一個系統調用入口點。系統調用入口點對每個系統調用定義其真正的語法和語義,但庫函數通常提供一個更方便的接口。如系統調用exec有集中不同的調用方式:execl, execle,等,它們實際上只是同一系統調用的不同接口而已。對於這些調用,它們的庫函數對它們各自的參數加以處理,來實現各自的特點,但是最終都被映射到同一個核心入口點。

 D.系統調用陷入內核後作何初始化處理

    當進程執行系統調用時,先調用系統調用庫中定義某個函數,該函數通常被展開成前面提到的_syscallN的形式通過INT 0x80來陷入核心,其參數也將被通過寄存器傳往核心。

    在這一部分,我們將介紹INT 0x80的處理函數system_call

    思考一下就會發現,在調用前和調用後執行態完全不相同:前者是在用戶棧上執行用戶態程序,後者在覈心棧上執行核心態代碼。那麼,爲了保證在覈心內部執行完系統調用後能夠返回調用點繼續執行用戶代碼,必須在進入核心態時保存時往核心中壓入一個上下文層;在從核心返回時會彈出一個上下文層,這樣用戶進程就可以繼續運行。

    那麼,這些上下文信息是怎樣被保存的,被保存的又是那些上下文信息呢?這裏仍以x86爲例說明。

    在執行INT指令時,實際完成了以下幾條操作:

1.由於INT指令發生了不同優先級之間的控制轉移,所以首先從TSS(任務狀態段)中獲取高優先級的核心堆棧信息(SSESP);2.把低優先級堆棧信息(SSESP)保留到高優先級堆棧(即核心棧)中;
3.把EFLAGS,外層CSEIP推入高優先級堆棧(核心棧)中。
4.通過IDT加載CSEIP(控制轉移至中斷處理函數)

然後就進入了中斷0x80的處理函數system_call了,在該函數中首先使用了一個宏SAVE_ALL,該宏的定義如下所示:

#define SAVE_ALL \

cld; \

pushl %es; \

pushl %ds; \

pushl %eax; \

pushl %ebp; \

pushl %edi; \

pushl %esi; \

pushl %edx; \

pushl %ecx; \

pushl %ebx; \

movl $(__KERNEL_DS),%edx; \

movl %edx,%ds; \

movl %edx,%es;

    該宏的功能一方面是將寄存器上下文壓入到核心棧中,對於系統調用,同時也是系統調用參數的傳入過程,因爲在不同特權級之間控制轉換時,INT指令不同於CALL指令,它不會將外層堆棧的參數自動拷貝到內層堆棧中。所以在調用系統調用時,必須先象前面的例子裏提到的那樣,把參數指定到各個寄存器中,然後在陷入核心之後使用SAVE_ALL把這些保存在寄存器中的參數依次壓入核心棧,這樣核心才能使用用戶傳入的參數。下面給出system_call的源代碼:

ENTRY(system_call)

pushl %eax # save orig_eax

SAVE_ALL

GET_CURRENT(%ebx)

cmpl $(NR_syscalls),%eax

jae badsys

testb x20,flags(%ebx) # PF_TRACESYS

jne tracesys

call *SYMBOL_NAME(sys_call_table)(,%eax,4)

. . . . . .

          在這裏所做的所有工作是:
           1.保存EAX寄存器,因爲在SAVE_ALL中保存的EAX寄存器會被調用的返回值所覆蓋;
           2.調用SAVE_ALL保存寄存器上下文;
           3.判斷當前調用是否是合法系統調用(EAX是系統調用號,它應該小於NR_syscalls);
           4.如果設置了PF_TRACESYS標誌,則跳轉到syscall_trace,在那裏將會把當前進程掛起並向其父進程發送SIGTRAP,這主要是爲了設              置調試斷點而設計的;
           5.如果沒有設置PF_TRACESYS標誌,則跳轉到該系統調用的處理函數入口。這裏是以EAX(即前面提到的系統調用號)作爲偏移,在系             統調用表sys_call_table中查找處理函數入口地址,並跳轉到該入口地址。
 

補充說明:
1.GET_CURRENT

    1. #define GET_CURRENT(reg) \

    movl %esp, reg; \

    andl $-8192, reg;

        其作用是取得當前進程的task_struct結構的指針返回到reg中,因爲在Linux中核心棧的位置是task_struct之後的兩個頁面處(8192bytes),所以此處把棧指針與-8192則得到的是task_struct結構指針,而task_struct中偏移爲4的位置是成員flags,在這裏指令testb x20,flags(%ebx)檢測的就是task_struct->flags


    2.堆棧中的參數

        正如前面提到的,SAVE_ALL是系統調用參數的傳入過程,當執行完SAVE_ALL並且再由CALL指令調用其處理函數時,堆棧的結構應該如上圖所示。這時的堆棧結構看起來和執行一個普通帶參數的函數調用是一樣的,參數在堆棧中對應的順序是(arg1 ebx),(arg2, ecx,arg3, edx. . . . . .,這正是SAVE_ALL壓棧的反順序,這些參數正是用戶在使用系統調用時試圖傳送給核心的參數。下面是在覈心的調用處理函數中使用參數的兩種典型方法:

    asmlinkage int sys_fork(struct pt_regs regs)

    asmlinkage int sys_open(const char * filename, int flags, int mode)

        在sys_fork中,把整個堆棧中的內容視爲一個struct pt_regs類型的參數,該參數的結構和堆棧的結構是一致的,所以可以使用堆棧中的全部信息。而在sys_open中參數filename, flags, mode正好對應與堆棧中的ebx, ecx, edx的位置,而這些寄存器正是用戶在通過C庫調用系統調用時給這些參數指定的寄存器。

    __asm__ __volatile__(\

    "int x80\n\t"\

    :"=a"(retval)\

    :"0"(__NR_open),\

    "b"(filename),\

    "c"(flags),\

    "d"(mode));

     
    3.核心如何使用用戶空間的參數

  • 在使用系統調用時,有些參數是指針,這些指針所指向的是用戶空間DS寄存器的段選擇子所描述段中的地址,而在2.2之前的版本中,核心態的DS段寄存器的中的段選擇子和用戶態的段選擇子描述的段地址不同(前者爲0xC0000000, 後者爲0x00000000),這樣在使用這些參數時就不能讀取到正確的位置。所以需要通過特殊的核心函數(如:memcpy_fromfs, mencpy_tofs)來從用戶空間數據段讀取參數,在這些函數中,是使用FS寄存器來作爲讀取參數的段寄存器的,FS寄存器在系統調用進入核心態時被設成了USER_DSDS被設成了KERNEL_DS)。在2.2之後的版本用戶態和核心態使用的DS中段選擇子描述的段地址是一樣的(都是0x00000000),所以不需要再經過上面那樣煩瑣的過程而直接使用參數了。 2.2及以後的版本linux/arch/i386/head.S

    ENTRY(gdt_table)

    .quad 0x0000000000000000/* not used */

    .quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */

    .quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */

    .quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */

    .quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */

                               2.0 linux/arch/i386/head.S

    .quad 0x0000000000000000 /* not used */

    .quad 0xc0c39a000000ffff /* 0x10 kernel 1GB code at 0xC0000000 */

    .quad 0xc0c392000000ffff /* 0x18 kernel 1GB data at 0xC0000000 */

    .quad 0x00cbfa000000ffff /* 0x23 user 3GB code at 0x00000000 */

    .quad 0x00cbf2000000ffff /* 0x2b user 3GB data at 0x00000000 *

     

    2.0版的內核中SAVE_ALL宏定義還有這樣幾條語句:

    "movl $" STR(KERNEL_DS) ",%edx\n\t" \

    "mov %dx,%ds\n\t" \

    "mov %dx,%es\n\t" \

    "movl $" STR(USER_DS) ",%edx\n\t" \

    "mov %dx,%fs\n\t" \

    "movl ,%edx\n\t" \

     

    E.調用返回
    調用返回的過程要做的工作比其響應過程要多一些,這些工作幾乎是每次從核心態返回用戶態都需要做的,這裏將簡要的說明:

    1.判斷有沒有軟中斷,如果有則跳轉到軟中斷處理;
    2.判斷當前進程是否需要重新調度,如果需要則跳轉到調度處理;
    3.如果當前進程有掛起的信號還沒有處理,則跳轉到信號處理;
    4.使用用RESTORE_ALL來彈出所有被SAVE_ALL壓入核心棧的內容並且使用iret返回用戶態。

    F.實例介紹

        前面介紹了系統調用相關的數據結構以及在Linux中使用一個系統調用的過程中每一步是怎樣處理的,下面將把前面的所有概念串起來,說明怎樣在Linux中增加一個系統調用。

    這裏實現的系統調用hello僅僅是在控制檯上打印一條語句,沒有任何功能。

    1.修改linux/include/i386/unistd.h,在裏面增加一條語句:


    #define __NR_hello ???(這個數字可能因爲核心版本不同而不同)
    2.在某個合適的目錄中(如:linux/kernel)增加一個hello.c,修改該目錄下的Makefile(把相映的.o文件列入Makefile中就可以了)。
    3.編寫hello.c

    . . . . . .

    asmlinkage int sys_hello(char * str)

    {

    printk(“My syscall: hello, I know what you say to me: %s ! \n”, str);

    return 0;

    }

     
    4.修改linux/arch/i386/kernel/entry.S,在裏面增加一條語句:

    ENTRY(sys_call_table)

    . . . . . .

    .long SYMBOL_NAME(sys_hello)

    並且修改:

    .rept NR_syscalls-??? /* ??? = ??? +1 */

    .long SYMBOL_NAME(sys_ni_syscall)
    5.在linux/include/i386/中增加hello.h,裏面至少應包括這樣幾條語句:

    #include <linux/unistd.h>

     

    #ifdef __KERNEL

    #else

    inline _syscall1(int, hello, char *, str);

    #endif

    這樣就可以使用系統調用hello

    Linux中的系統調用

    1. 進程相關的系統調用
    Fork & vfork & clone

        進程是一個指令執行流及其執行環境,其執行環境是一個系統資源的集合,這些資源在Linux中被抽象成各種數據對象:進程控制塊、虛存空間、文件系統,文件I/O、信號處理函數。所以創建一個進程的過程就是這些數據對象的創建過程。

        在調用系統調用fork創建一個進程時,子進程只是完全複製父進程的資源,這樣得到的子進程獨立於父進程,具有良好的併發性,但是二者之間的通訊需要通過專門的通訊機制,如:pipefifoSystem V IPC機制等,另外通過fork創建子進程系統開銷很大,需要將上面描述的每種資源都複製一個副本。這樣看來,fork是一個開銷十分大的系統調用,這些開銷並不是所有的情況下都是必須的,比如某進程fork出一個子進程後,其子進程僅僅是爲了調用exec執行另一個執行文件,那麼在fork過程中對於虛存空間的複製將是一個多餘的過程(由於Linux中是採取了copy-on-write技術,所以這一步驟的所做的工作只是虛存管理部分的複製以及頁表的創建,而並沒有包括物理也面的拷貝);另外,有時一個進程中具有幾個獨立的計算單元,可以在相同的地址空間上基本無衝突進行運算,但是爲了把這些計算單元分配到不同的處理器上,需要創建幾個子進程,然後各個子進程分別計算最後通過一定的進程間通訊和同步機制把計算結果彙總,這樣做往往有許多格外的開銷,而且這種開銷有時足以抵消並行計算帶來的好處。 

        這說明了把計算單元抽象到進程上是不充分的,這也就是許多系統中都引入了線程的概念的原因。在講述線程前首先介紹以下vfork系統調用,vfork系統調用不同於fork,用vfork創建的子進程共享地址空間,也就是說子進程完全運行在父進程的地址空間上,子進程對虛擬地址空間任何數據的修改同樣爲父進程所見。但是用vfork創建子進程後,父進程會被阻塞直到子進程調用execexit。這樣的好處是在子進程被創建後僅僅是爲了調用exec執行另一個程序時,因爲它就不會對父進程的地址空間有任何引用,所以對地址空間的複製是多餘的,通過vfork可以減少不必要的開銷。

        在Linux中, forkvfork都是調用同一個核心函數

        do_fork(unsigned long clone_flag, unsigned long usp, struct pt_regs)

        其中clone_flag包括CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND, CLONE_PIDCLONE_VFORK等等標誌位,任何一位被置1了則表明創建的子進程和父進程共享該位對應的資源。所以在vfork的實現中,cloneflags = CLONE_VFORK | CLONE_VM | SIGCHLD,這表示子進程和父進程共享地址空間,同時do_fork會檢查CLONE_VFORK,如果該位被置1了,子進程會把父進程的地址空間鎖住,直到子進程退出或執行exec時才釋放該鎖。

     

        在講述clone系統調用前先簡單介紹線程的一些概念。

        線程是在進程的基礎上進一步的抽象,也就是說一個進程分爲兩個部分:線程集合和資源集合。線程是進程中的一個動態對象,它應該是一組獨立的指令流,進程中的所有線程將共享進程裏的資源。但是線程應該有自己的私有對象:比如程序計數器、堆棧和寄存器上下文。

        線程分爲三種類型:

        內核線程、輕量級進程和用戶線程。

    內核線程:

        它的創建和撤消是由內核的內部需求來決定的,用來負責執行一個指定的函數,一個內核線程不需要一個用戶進程聯繫起來。它共享內核的正文段核全局數據,具有自己的內核堆棧。它能夠單獨的被調度並且使用標準的內核同步機制,可以被單獨的分配到一個處理器上運行。內核線程的調度由於不需要經過態的轉換並進行地址空間的重新映射,因此在內核線程間做上下文切換比在進程間做上下文切換快得多。

    輕量級進程:

        輕量級進程是核心支持的用戶線程,它在一個單獨的進程中提供多線程控制。這些輕量級進程被單獨的調度,可以在多個處理器上運行,每一個輕量級進程都被綁定在一個內核線程上,而且在它的生命週期這種綁定都是有效的。輕量級進程被獨立調度並且共享地址空間和進程中的其它資源,但是每個LWP都應該有自己的程序計數器、寄存器集合、核心棧和用戶棧。

    用戶線程:

        用戶線程是通過線程庫實現的。它們可以在沒有內核參與下創建、釋放和管理。線程庫提供了同步和調度的方法。這樣進程可以使用大量的線程而不消耗內核資源,而且省去大量的系統開銷。用戶線程的實現是可能的,因爲用戶線程的上下文可以在沒有內核干預的情況下保存和恢復。每個用戶線程都可以有自己的用戶堆棧,一塊用來保存用戶級寄存器上下文以及如信號屏蔽等狀態信息的內存區。庫通過保存當前線程的堆棧和寄存器內容載入新調度線程的那些內容來實現用戶線程之間的調度和上下文切換。

        內核仍然負責進程的切換,因爲只有內核具有修改內存管理寄存器的權力。用戶線程不是真正的調度實體,內核對它們一無所知,而只是調度用戶線程下的進程或者輕量級進程,這些進程再通過線程庫函數來調度它們的線程。當一個進程被搶佔時,它的所有用戶線程都被搶佔,當一個用戶線程被阻塞時,它會阻塞下面的輕量級進程,如果進程只有一個輕量級進程,則它的所有用戶線程都會被阻塞。

     

        明確了這些概念後,來講述Linux的線程和clone系統調用。

        在許多實現了MT的操作系統中(如:SolarisDigital Unix等), 線程和進程通過兩種數據結構來抽象表示: 進程表項和線程表項,一個進程表項可以指向若干個線程表項, 調度器在進程的時間片內再調度線程。  但是在Linux中沒有做這種區分,  而是統一使用task_struct來管理所有進程/線程,只是線程與線程之間的資源是共享的,這些資源可是是前面提到過的:虛存、文件系統、文件I/O以及信號處理函數甚至PID中的幾種。


        也就是說Linux中,每個線程都有一個task_struct,所以線程和進程可以使用同一調度器調度。其實Linux核心中,輕量級進程和進程沒有質上的差別,因爲Linux中進程的概念已經被抽象成了計算狀態加資源的集合,這些資源在進程間可以共享。如果一個task獨佔所有的資源,則是一個HWP,如果一個task和其它task共享部分資源,則是LWP

        clone系統調用就是一個創建輕量級進程的系統調用:

        int clone(int (*fn)(void * arg), void *stack, int flags, void * arg);

        其中fn是輕量級進程所執行的過程,stack是輕量級進程所使用的堆棧,flags可以是前面提到的CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND,CLONE_PID的組合。Clone forkvfork在實現時都是調用核心函數do_fork

        do_fork(unsigned long clone_flag, unsigned long usp, struct pt_regs)

        和forkvfork不同的是,forkclone_flag = SIGCHLD

        vforkclone_flag = CLONE_VM | CLONE_VFORK | SIGCHLD

        而在clone中,clone_flag由用戶給出。

        下面給出一個使用clone的例子。

        Void * func(int arg)

        {

        . . . . . .

        }

        int main()

        {

    int clone_flag, arg;

    . . . . . .

    clone_flag = CLONE_VM | CLONE_SIGHAND | CLONE_FS |

    CLONE_FILES;

    stack = (char *)malloc(STACK_FRAME);

    stack += STACK_FRAME;

    retval = clone((void *)func, stack, clone_flag, arg);

    . . . . . .

    }

        看起來clone的用法和pthread_create有些相似,兩者的最根本的差別在於clone是創建一個LWP,對核心是可見的,由核心調度,而pthread_create通常只是創建一個用戶線程,對核心是不可見的,由線程庫調度。

     

    Nanosleep & sleep

        sleep和nanosleep都是使進程睡眠一段時間後被喚醒,但是二者的實現完全不同。

        Linux中並沒有提供系統調用sleepsleep是在庫函數中實現的,它是通過調用alarm來設定報警時間,調用sigsuspend將進程掛起在信號SIGALARM上,sleep只能精確到秒級上。

        nanosleep則是Linux中的系統調用,它是使用定時器來實現的,該調用使調用進程睡眠,並往定時器隊列上加入一個time_list型定時器,time_list結構裏包括喚醒時間以及喚醒後執行的函數,通過nanosleep加入的定時器的執行函數僅僅完成喚醒當前進程的功能。系統通過一定的機制定時檢查這些隊列(比如通過系統調用陷入核心後,從核心返回用戶態前,要檢查當前進程的時間片是否已經耗盡,如果是則調用schedule()函數重新調度,該函數中就會檢查定時器隊列,另外慢中斷返回前也會做此檢查),如果定時時間已超過,則執行定時器指定的函數喚醒調用進程。當然,由於系統時間片可能丟失,所以nanosleep精度也不是很高。

        alarm也是通過定時器實現的,但是其精度只精確到秒級,另外,它設置的定時器執行函數是在指定時間向當前進程發送SIGALRM信號。

     
    2.存儲相關的系統調用

    mmap:文件映射

        在講述文件映射的概念時,不可避免的要牽涉到虛存(SVR 4VM)。實際上,文件映射是虛存的中心概念,文件映射一方面給用戶提供了一組措施,似的用戶將文件映射到自己地址空間的某個部分,使用簡單的內存訪問指令讀寫文件;另一方面,它也可以用於內核的基本組織模式,在這種模式種,內核將整個地址空間視爲諸如文件之類的一組不同對象的映射。

        Unix中的傳統文件訪問方式是,首先用open系統調用打開文件,然後使用readwrite以及lseek等調用進行順序或者隨即的I/O。這種方式是非常低效的,每一次I/O操作都需要一次系統調用。另外,如果若干個進程訪問同一個文件,每個進程都要在自己的地址空間維護一個副本,浪費了內存空間。而如果能夠通過一定的機制將頁面映射到進程的地址空間中,也就是說首先通過簡單的產生某些內存管理數據結構完成映射的創建。當進程訪問頁面時產生一個缺頁中斷,內核將頁面讀入內存並且更新頁表指向該頁面。而且這種方式非常方便於同一副本的共享。

        下面給出以上兩種方式的對比圖:

        VM是面向對象的方法設計的,這裏的對象是指內存對象:內存對象是一個軟件抽象的概念,它描述內存區與後備存儲之間的映射。系統可以使用多種類型的後備存儲,比如交換空間,本地或者遠程文件以及幀緩存等等。VM系統對它們統一處理,採用同一操作集操作,比如讀取頁面或者回寫頁面等。每種不同的後備存儲都可以用不同的方法實現這些操作。這樣,系統定義了一套統一的接口,每種後備存儲給出自己的實現方法。

        這樣,進程的地址空間就被視爲一組映射到不同數據對象上的的映射組成。所有的有效地址就是那些映射到數據對象上的地址。這些對象爲映射它的頁面提供了持久性的後備存儲。映射使得用戶可以直接尋址這些對象。

        值得提出的是,VM體系結構獨立於Unix系統,所有的Unix系統語義,如正文,數據及堆棧區都可以建構在基本VM系統之上。同時,VM體系結構也是獨立於存儲管理的,存儲管理是由操作系統實施的,如:究竟採取什麼樣的對換和請求調頁算法,究竟是採取分段還是分頁機制進行存儲管理,究竟是如何將虛擬地址轉換成爲物理地址等等(Linux中是一種叫Three Level Page Table的機制),這些都與內存對象的概念無關。

        下面介紹LinuxVM的實現。

        如下圖所示,一個進程應該包括一個mm_structmemory manage struct),該結構是進程虛擬地址空間的抽象描述,裏面包括了進程虛擬空間的一些管理信息:start_code, end_code, start_data, end_data, start_brk, end_brk等等信息。另外,也有一個指向進程虛存區表(vm_area_struct virtual memory area)的指針,該鏈是按照虛擬地址的增長順序排列的。


        在Linux進程的地址空間被分作許多區(vma),每個區(vma)都對應虛擬地址空間上一段連續的區域,vma是可以被共享和保護的獨立實體,這裏的vma就是前面提到的內存對象。這裏給出vm_area_struct的結構,其中,前半部分是公共的,與類型無關的一些數據成員,如:指向mm_struct的指針,地址範圍等等,後半部分則是與類型相關的成員,其中最重要的是一個指向vm_operation_struct向量表的指針vm_opsvm_pos向量表是一組虛函數,定義了與vma類型無關的接口。每一個特定的子類,即每種vma類型都必須在向量表中實現這些操作。這裏包括了:open, close, unmap, protect, sync, nopage, wppage, swapout這些操作。

    struct vm_area_struct {

    /*公共的,與vma類型無關的 */

    struct mm_struct * vm_mm;

    unsigned long vm_start;

    unsigned long vm_end;

    struct vm_area_struct *vm_next;

    pgprot_t vm_page_prot;

    unsigned long vm_flags;

    short vm_avl_height;

    struct vm_area_struct * vm_avl_left;

    struct vm_area_struct * vm_avl_right;

    struct vm_area_struct *vm_next_share;

    struct vm_area_struct **vm_pprev_share;

    /* 與類型相關的 */

    struct vm_operations_struct * vm_ops;

    unsigned long vm_pgoff;

    struct file * vm_file;

    unsigned long vm_raend

    void * vm_private_data;

    };

    vm_ops: open, close, no_page, swapin, swapout . . . . . .

        介紹完VM的基本概念後,我們可以講述mmap, munmap系統調用了。mmap調用實際上就是一個內存對象vma的創建過程,mmap的調用格式是: void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset);其中start是映射地址,length是映射長度,如果flagsMAP_FIXED不被置位,則該參數通常被忽略,而查找進程地址空間中第一個長度符合的空閒區域;Fd是映射文件的文件句柄,offset是映射文件中的偏移地址;prot是映射保護權限,可以是PROT_EXEC, PROT_READ, PROT_WRITE, PROT_NONEflags則是指映射類型,可以是MAP_FIXED, MAP_PRIVATE, MAP_SHARED,該參數必須被指定爲MAP_PRIVATEMAP_SHARED其中之一,MAP_PRIVATE是創建一個寫時拷貝映射(copy-on-write),也就是說如果有多個進程同時映射到一個文件上,映射建立時只是共享同樣的存儲頁面,但是某進程企圖修改頁面內容,則複製一個副本給該進程私用,它的任何修改對其它進程都不可見。而MAP_SHARED則無論修改與否都使用同一副本,任何進程對頁面的修改對其它進程都是可見的。

    Mmap系統調用的實現過程是:

        1.先通過文件系統定位要映射的文件;
        2.權限檢查,映射的權限不會超過文件打開的方式,也就是說如果文件是以只讀方式打開,那麼則不允許建立一個可寫映射;
        3.創建一個vma對象,並對之進行初始化;
        4.調用映射文件的mmap函數,其主要工作是給vm_ops向量表賦值;
        5.把該vma鏈入該進程的vma鏈表中,如果可以和前後的vma合併則合併;
        6.如果是要求VM_LOCKED(映射區不被換出)方式映射,則發出缺頁請求,把映射頁面讀入內存中;

    munmap(void * start, size_t length)

        該調用可以看作是mmap的一個逆過程。它將進程中從start開始length長度的一段區域的映射關閉,如果該區域不是恰好對應一個vma,則有可能會分割幾個或幾個vma

    Msync(void * start, size_t length, int flags)

        把映射區域的修改回寫到後備存儲中。因爲munmap時並不保證頁面回寫,如果不調用msync,那麼有可能在munmap後丟失對映射區的修改。其中flags可以是MS_SYNC, MS_ASYNC, MS_INVALIDATEMS_SYNC要求回寫完成後才返回,MS_ASYNC發出回寫請求後立即返回,MS_INVALIDATE使用回寫的內容更新該文件的其它映射。

        該系統調用是通過調用映射文件的sync函數來完成工作的。

    brk(void * end_data_segement):

        將進程的數據段擴展到end_data_segement指定的地址,該系統調用和mmap的實現方式十分相似,同樣是產生一個vma,然後指定其屬性。不過在此之前需要做一些合法性檢查,比如該地址是否大於mm->end_codeend_data_segementmm->brk之間是否還存在其它vma等等。通過brk產生的vma映射的文件爲空,這和匿名映射產生的vma相似,關於匿名映射不做進一步介紹。我們使用的庫函數malloc就是通過brk實現的,通過下面這個例子很容易證實這點:

    main()

    {

    char * m, * n;

    int size;

    m = (char *)sbrk(0);

    printf("sbrk addr = %08lx\n", m);

    do {

    n = malloc(1024);

    printf("malloc addr = %08lx\n", n);

    }w hile(n < m);

    m = (char *)sbrk(0);

    printf("new sbrk addr = %08lx\n", m);

    }

           sbrk addr = 0804a000 malloc addr = 080497d8

    malloc addr = 08049be0

    malloc addr = 08049fe8

    malloc addr = 0804a3f0

    new sbrk addr = 0804b000

    3.進程間通信(IPC


    1.     進程間通訊可以通過很多種機制,包括
    signal, pipe, fifo, System V IPC, 以及socket等等,前幾種概念都比較好理解,這裏着重介紹關於System V IPC

        System V IPC包括三種機制:message(允許進程發送格式化的數據流到任意的進程)、shared memory(允許進程間共享它們虛擬地址空間的部分區域)和semaphore(允許進程間同步的執行)。

        操作系統核心中爲它們分別維護着一個表,這三個表是系統中所有這三種IPC對象的集合,表的索引是一個數值ID,進程通過這個ID可以查找到需要使用的IPC資源。進程每創建一個IPC對象,系統中都會在相應的表中增加一項。之後其它進程(具有許可權的進程)只要通過該IPC對象的ID則可以引用它。

        IPC對象必須使用IPC_RMID命令來顯示的釋放,否則這個對象就處於活動狀態,甚至所有的使用它的進程都已經終止。這種機制某些時候十分有用,但是也正因爲這種特徵,使得操作系統內核無法判斷IPC對象是被用戶故意遺留下來供將來其它進程使用還是被無意拋棄的。

        Linux中只提供了一個系統調用接口ipc()來完成所有System V IPC操作,我們常使用的是建立在該調用之上的庫函數接口。對於這三種IPC,都有很相似的三種調用:xxxget, (msgsnd, msgrcv)semopt | (shmat, shmdt), xxxctl

        Xxxget:獲取調用,在系統中申請或者查詢一個IPC資源,返回值是該IPC對象的ID,該調用類似於文件系統的open, create調用;

        Xxxctl:控制調用,至少包括三種操作:XXX_RMID(釋放IPC對象), XXX_STAT(查詢狀態), XXX_SET(設置狀態信息);

        (msgsnd, msgrcv) | Semopt | (shmat, shmdt)|:操作調用,這些調用的功能隨IPC對象的類型不同而有較大差異。

    4.文件系統相關的調用

        文件是用來保存數據的,而文件系統則可以讓用戶組織,操縱以及存取不同的文件。內核允許用戶通過一個嚴格定義的過程性接口與文件系統進行交互,這個接口對用戶屏蔽了文件系統的細節,同時指定了所有相關係統調用的行爲和語義。Linux支持許多中文件系統,如ext2msdos, ntfs, proc, dev, ufs, nfs等等,這些文件系統都實現了相同的接口,因此給應用程序提供了一致性的視圖。但每種文件系統在實現時可能對某個方面加以了一定的限制。如:文件名的長度,是否支持所有的文件系統接口調用。

        爲了支持多文件系統,sun提出了一種vnode/vfs接口,SVR4中將之實現成了一種工業標準。而Linux作爲一種Unixclone體,自然也實現了這種接口,只是它的接口定義和SVR4的稍有不同。Vnode/Vfs接口的設計體現了面向對象的思想,Vfs(虛擬文件系統)代表內核中的一個文件系統,Vnode(虛擬節點)代表內核中的一個文件,它們都可以被視爲抽象基類,並可以從中派生出不同的子類以實現不同的文件系統。

        由於篇幅原因,這裏只是大概的介紹一下怎樣通過Vnode/Vfs結構來實現文件系統和訪問文件。

        在Linux中支持的每種文件系統必須有一個file_system_type結構,此結構的核心是read_super函數,該函數將讀取文件系統的超級塊。Linux中支持的所有文件系統都會被註冊在一條file_system_type結構鏈中,註冊是在系統初始化時調用regsiter_filesystem()完成,如果文件系統是以模塊的方式實現,則是在調用init_module時完成。


        當mount某種塊設備時,將調用系統調用mount,該調用中將會首先檢查該類文件系統是否註冊在系統種中,如果註冊了則先給該文件系統分配一個super_block,並進行初始化,最後調用這種文件系統的read_super函數來完成super_block結構私有數據的賦值。其中最主要的工作是給super_blocks_ops賦值,s_ops是一個函數向量表,由文件系統各自實現了一組操作。

    struct super_operations {

    void (*read_inode) (struct inode *);

    void (*write_inode) (struct inode *);

    void (*put_inode) (struct inode *);

    void (*delete_inode) (struct inode *);

    void (*put_super) (struct super_block *);

    void (*write_super) (struct super_block *);

    int (*statfs) (struct super_block *, struct statfs *);

    int (*remount_fs) (struct super_block *, int *, char *);

    void (*clear_inode) (struct inode *);

    void (*umount_begin) (struct super_block *);

    };

        由於這組操作中定義了文件系統中對於inode的操作,所以是之後對於文件系統中文件所有操作的基礎。

        在給super_blocks_ops賦值後,再給該文件系統分配一個vfsmount結構,將該結構註冊到系統維護的另一條鏈vfsmntlist中,所

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章