系統調用

火星人 @ 2014-03-26 , reply:0
←手機掃碼閱讀


轉:http://linux.ccidnet.com

本文是Linux系統調用系列文章的第一篇,對Linux系統調用的定義、基本原理、使用方法和注意事項大概作了一個介紹,以便讀者對Linux系統調用建立一個大致的印象。

什麼是系統調用?

Linux內核中設置了一組用於實現各種系統功能的子程序,稱為系統調用。用戶可以通過系統調用命令在自己的應用程序中調用它們。從某種角度來看,系統調用和普通的函數調用非常相似。區別僅僅在於,系統調用由操作系統核心提供,運行於核心態;而普通的函數調用由函數庫或用戶自己提供,運行於用戶態。二者在使用方式上也有相似之處,在下面將會提到。

隨Linux核心還提供了一些C語言函數庫,這些庫對系統調用進行了一些包裝和擴展,因為這些庫函數與系統調用的關係非常緊密,所以習慣上把這些函數也稱為系統調用。

Linux中共有多少個系統調用?

這個問題可不太好回答,就算讓Linus Torvaldz本人也不見得一下子就能說清楚。

在2.4.4版內核中,狹義上的系統調用共有221個,你可以在<內核源碼目錄>/include/asm-i386/unistd.h中找到它們的原本,也可以通過命令"man 2 syscalls"察看它們的目錄(man pages的版本一般比較老,可能有很多最新的調用都沒有包含在內)。廣義上的系統調用,也就是以庫函數的形式實現的那些,它們的個數從來沒有人統計過,這是一件吃力不討好的活,新內核不斷地在推出,每一個新內核中函數數目的變化根本就沒有人在乎,至少連內核的修改者本人都不在乎,因為他們從來沒有發布過一個此類的聲明。

隨本文一起有一份經過整理的列表,它不可能非常全面,但常見的系統調用基本都已經包含在內,那裡面只有不多的一部分是你平時用得到的,本專欄將會有選擇的對它們進行介紹。

為什麼要用系統調用?

實際上,很多已經被我們習以為常的C語言標準函數,在Linux平台上的實現都是靠系統調用完成的,所以如果想對系統底層的原理作深入的了解,掌握各種系統調用是初步的要求。進一步,若想成為一名Linux下編程高手,也就是我們常說的 Hacker,其標誌之一也是能對各種系統調用有透徹的了解。

即使除去上面的原因,在平常的編程中你也會發現,在很多情況下,系統調用是實現你的想法的簡潔有效的途徑,所以有可能的話應該盡量多掌握一些系統調用,這會對你的程序設計過程帶來意想不到的幫助。

系統調用是怎麼工作的?

一般的,進程是不能訪問內核的。它不能訪問內核所佔內存空間也不能調用內核函數。CPU硬體決定了這些(這就是為什麼它被稱作"保護模式")。系統調用是這些規則的一個例外。其原理是進程先用適當的值填充寄存器,然後調用一個特殊的指令,這個指令會跳到一個事先定義的內核中的一個位置(當然,這個位置是用戶進程可讀但是不可寫的)。在Intel CPU中,這個由中斷0x80實現。硬體知道一旦你跳到這個位置,你就不是在限制模式下運行的用戶,而是作為操作系統的內核--所以你就可以為所欲為。

進程可以跳轉到的內核位置叫做sysem_call。這個過程檢查系統調用號,這個號碼告訴內核進程請求哪種服務。然後,它查看系統調用表(sys_call_table)找到所調用的內核函數入口地址。接著,就調用函數,等返回后,做一些系統檢查,最後返回到進程(或到其他進程,如果這個進程時間用盡)。如果你希望讀這段代碼,它在<內核源碼目錄>/kernel/entry.S,Entry(system_call)的下一行。

如何使用系統調用?

先來看一個例子:

#include /*定義宏_syscall1*/
#include /*定義類型time_t*/
_syscall1(time_t,time,time_t *,tloc) /*宏,展開后得到time()函數的原型*/
main()
{
time_t the_time;
the_time=time((time_t *)0); /*調用time系統調用*/
printf("The time is %ld\n",the_time);
}

系統調用time返回從格林尼治時間1970年1月1日0:00開始到現在的秒數。

這是最標準的系統調用的形式,宏_syscall1()展開來得到一個函數原型,稍後我會作詳細解釋。但事實上,如果把程序改成下面的樣子,程序也可以運行得同樣的結果。

#include
main()
{
time_t the_time;
the_time=time((time_t *)0); /*調用time系統調用*/
printf("The time is %ld\n",the_time);
}

這是因為在time.h中實際上已經用庫函數的形式實現了time這個系統調用,替我們省掉了調用_syscall1宏展開得到函數原型這一步。

大多數系統調用都在各種C語言函數庫中有所實現,所以在一般情況下,我們都可以像調用普通的庫函數那樣調用系統調用,只在極個別的情況下,我們才有機會用到_syscall*()這幾個宏。

_syscall*()是什麼?

在unistd.h里定義了7個宏,分別是

_syscall0(type,name)
_syscall1(type,name,type1,arg1)
_syscall2(type,name,type1,arg1,type2,arg2)
_syscall3(type,name,type1,arg1,type2,arg2,type3,arg3)
_syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4)
_syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5)
_syscall6(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5,type6,arg6)

它們看起來似乎不太像宏,但其實質和

#define MAXSIZE 100

裡面的MAXSIZE沒有任何區別。

它們的作用是形成相應的系統調用函數原型,供我們在程序中調用。我們很容易就能發現規律,_syscall後面的數字和typeN,argN的數目一樣多。事實上,_syscall後面跟的數字指明了展開后形成函數的參數的個數,讓我們看一個實例,就是剛剛用過的time系統調用:

_syscall1(time_t,time,time_t *,tloc)

展開后的情形是這樣:

time_t time(time_t * tloc)
{
long __res;
__asm__ volatile("int $0x80" : "=a" (__res) : "0" (13),"b" ((long)(tloc)));
do {
if ((unsigned long)(__res) >= (unsigned long)(-125)) {
errno = -(__res);
__res = -1;
}
return (time_t) (__res);
} while (0) ;
}

可以看出,_syscall1(time_t,time,time_t *,tloc)展開成一個名為time的函數,原參數time_t就是函數的返回類型,原參數time_t *和tloc分別構成新函數的參數。事實上,程序中用到的time函數的原型就是它。

errno是什麼?

為防止和正常的返回值混淆,系統調用並不直接返回錯誤碼,而是將錯誤碼放入一個名為errno的全局變數中。如果一個系統調用失敗,你可以讀出errno的值來確定問題所在。

errno不同數值所代表的錯誤消息定義在errno.h中,你也可以通過命令"man 3 errno"來察看它們。

需要注意的是,errno的值只在函數發生錯誤時設置,如果函數不發生錯誤,errno的值就無定義,並不會被置為0。另外,在處理errno前最好先把它的值存入另一個變數,因為在錯誤處理過程中,即使像printf()這樣的函數出錯時也會改變errno的值。

系統調用兼容性好嗎?

很遺憾,答案是--不好。但這決不意味著你的程序會三天兩頭的導致系統崩潰,因為系統調用是Linux的內核提供的,所以它們工作起來非常穩定,對於此點無需絲毫懷疑,在絕大多數的情況下,系統調用要比你自己編寫的代碼可靠而高效的多。

但是,在Linux的各版本內核之間,系統調用的兼容性表現得並不像想象那麼好,這是由Linux本身的性質決定的。Linux是一群程序設計高手利用業餘時間開發出來的,他們中間的大部分人沒有把Linux當成一個嚴肅的商業軟體,(現在的情況有些不同了,隨著Linux商業公司和以Linux為生的人的增長,不少人的腦筋發生了變化。)結果就是,如果新的方案在效率和兼容性上發生了矛盾,他們往往捨棄兼容性而追求效率,就這樣,如果他們認為某個系統調用實現的比較糟糕,他們就會毫不猶豫的作出修改,有些時候甚至連介面也一起改掉了,更可怕的是,很多時候,他們對自己的修改連個招呼也不打,在任何文檔里都找不到關於修改的提示。這樣,每當新內核推出的時候,很可能都會悄悄的更新一些系統調用,用戶編製的應用程序也會跟著出錯。

說到這裡,你是不是感覺前途一片昏暗呢?呵呵,不用太緊張,如前面所說,隨著越來越多的人把Linux當成自己的飯碗,不兼容的情況也越來越罕見。從2.2版本以後的Linux內核已經非常穩定了,不過儘管如此,你還是有必要在每個新內核推出之後,對自己的應用程序進行兼容性測試,以防止意外的發生。

該如何學習使用Linux系統調用呢?

你可以用"man 2 系統調用名稱"的命令來查看各條系統調用的介紹,但這首先要求你要有不錯的英語基礎,其次還得有一定的程序設計和系統編程的功底,man pages不會涉及太多的應用細節,因為它只是一個手冊而非教程。如果man pages所提供的東西不能使你感到非常滿意,那就跟我來吧,本專欄將向你展示Linux系統調用編程的無窮魅力。

對讀者的兩點小小的要求:

1)讀者必須有一定的C語言編程經驗;

2)讀者必須有一定的Linux使用經驗。如果你能完全看懂本文從開頭到這裡所講的東西,你就合格了。收拾好行囊,準備出發吧!

本文介紹了Linux下的進程概念,並著重講解了與Linux進程管理相關的4個重要系統調用getpid,fork,exit和_exit,輔助一些常式說明了它們的特點和使用方法。

關於進程的一些必要知識

先看一下進程在大學課本里的標準定義:「進程是可併發執行的程序在一個數據集合上的運行過程。」這個定義非常嚴謹,而且難懂,如果你沒有一下子理解這句話,就不妨看看筆者自己的並不嚴謹的解釋。我們大家都知道,硬碟上的一個可執行文件經常被稱作程序,在Linux系統中,當一個程序開始執行后,在開始執行到執行完畢退出這段時間裡,它在內存中的部分就被稱作一個進程。

當然,這個解釋並不完善,但好處是容易理解,在以下的文章中,我們將會對進程作一些更全面的認識。

Linux進程簡介

Linux是一個多任務的操作系統,也就是說,在同一個時間內,可以有多個進程同時執行。如果讀者對計算機硬體體系有一定了解的話,會知道我們大家常用的單CPU計算機實際上在一個時間片斷內只能執行一條指令,那麼Linux是如何實現多進程同時執行的呢?原來Linux使用了一種稱為「進程調度(process scheduling)」的手段,首先,為每個進程指派一定的運行時間,這個時間通常很短,短到以毫秒為單位,然後依照某種規則,從眾多進程中挑選一個投入運行,其他的進程暫時等待,當正在運行的那個進程時間耗盡,或執行完畢退出,或因某種原因暫停,Linux就會重新進行調度,挑選下一個進程投入運行。因為每個進程佔用的時間片都很短,在我們使用者的角度來看,就好像多個進程同時運行一樣了。

在Linux中,每個進程在創建時都會被分配一個數據結構,稱為進程式控制制塊(Process Control Block,簡稱PCB)。PCB中包含了很多重要的信息,供系統調度和進程本身執行使用,其中最重要的莫過於進程ID(process ID)了,進程ID也被稱作進程標識符,是一個非負的整數,在Linux操作系統中唯一地標誌一個進程,在我們最常使用的I386架構(即PC使用的架構)上,一個非負的整數的變化範圍是0-32767,這也是我們所有可能取到的進程ID。其實從進程ID的名字就可以看出,它就是進程的身份證號碼,每個人的身份證號碼都不會相同,每個進程的進程ID也不會相同。

一個或多個進程可以合起來構成一個進程組(process group),一個或多個進程組可以合起來構成一個會話(session)。這樣我們就有了對進程進行批量操作的能力,比如通過向某個進程組發送信號來實現向該組中的每個進程發送信號。

最後,讓我們通過ps命令親眼看一看自己的系統中目前有多少進程在運行:

$ps -aux(以下是在我的計算機上的運行結果,你的結果很可能與這不同。)
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 0.4 1412 520 ? S May15 0:04 init [3]
root 2 0.0 0.0 0 0 ? SW May15 0:00 [keventd]
root 3 0.0 0.0 0 0 ? SW May15 0:00 [kapm-idled]
root 4 0.0 0.0 0 0 ? SWN May15 0:00 [ksoftirqd_CPU0]
root 5 0.0 0.0 0 0 ? SW May15 0:00 [kswapd]
root 6 0.0 0.0 0 0 ? SW May15 0:00 [kreclaimd]
root 7 0.0 0.0 0 0 ? SW May15 0:00 [bdflush]
root 8 0.0 0.0 0 0 ? SW May15 0:00 [kupdated]
root 9 0.0 0.0 0 0 ? SW< May15 0:00 [mdrecoveryd]
root 13 0.0 0.0 0 0 ? SW May15 0:00 [kjournald]
root 132 0.0 0.0 0 0 ? SW May15 0:00 [kjournald]
root 673 0.0 0.4 1472 592 ? S May15 0:00 syslogd -m 0
root 678 0.0 0.8 2084 1116 ? S May15 0:00 klogd -2
rpc 698 0.0 0.4 1552 588 ? S May15 0:00 portmap
rpcuser 726 0.0 0.6 1596 764 ? S May15 0:00 rpc.statd
root 839 0.0 0.4 1396 524 ? S May15 0:00 /usr/sbin/apmd -p
root 908 0.0 0.7 2264 1000 ? S May15 0:00 xinetd -stayalive
root 948 0.0 1.5 5296 1984 ? S May15 0:00 sendmail: accepti
root 967 0.0 0.3 1440 484 ? S May15 0:00 gpm -t ps/2 -m /d
wnn 987 0.0 2.7 4732 3440 ? S May15 0:00 /usr/bin/cserver
root 1005 0.0 0.5 1584 660 ? S May15 0:00 crond
wnn 1025 0.0 1.9 3720 2488 ? S May15 0:00 /usr/bin/tserver
xfs 1079 0.0 2.5 4592 3216 ? S May15 0:00 xfs -droppriv -da
daemon 1115 0.0 0.4 1444 568 ? S May15 0:00 /usr/sbin/atd
root 1130 0.0 0.3 1384 448 tty1 S May15 0:00 /sbin/mingetty tt
root 1131 0.0 0.3 1384 448 tty2 S May15 0:00 /sbin/mingetty tt
root 1132 0.0 0.3 1384 448 tty3 S May15 0:00 /sbin/mingetty tt
root 1133 0.0 0.3 1384 448 tty4 S May15 0:00 /sbin/mingetty tt
root 1134 0.0 0.3 1384 448 tty5 S May15 0:00 /sbin/mingetty tt
root 1135 0.0 0.3 1384 448 tty6 S May15 0:00 /sbin/mingetty tt
root 8769 0.0 0.6 1744 812 ? S 00:08 0:00 in.telnetd: 192.1
root 8770 0.0 0.9 2336 1184 pts/0 S 00:08 0:00 login -- lei
lei 8771 0.1 0.9 2432 1264 pts/0 S 00:08 0:00 -bash
lei 8809 0.0 0.6 2764 808 pts/0 R 00:09 0:00 ps -aux

以上除標題外,每一行都代表一個進程。在各列中,PID一列代表了各進程的進程ID,COMMAND一列代表了進程的名稱或在Shell中調用的命令行,對其他列的具體含義,我就不再作解釋,有興趣的讀者可以去參考相關書籍。

getpid

在2.4.4版內核中,getpid是第20號系統調用,其在Linux函數庫中的原型是:

#include /* 提供類型pid_t的定義 */
#include /* 提供函數的定義 */
pid_t getpid(void);

getpid的作用很簡單,就是返回當前進程的進程ID,請大家看以下的例子:

/* getpid_test.c */
#include
main()
{
printf("The current process ID is %d\n",getpid());
}

細心的讀者可能注意到了,這個程序的定義里並沒有包含頭文件 sys/types.h,這是因為我們在程序中沒有用到pid_t類型,pid_t類型即為進程ID的類型。事實上,在i386架構上(就是我們一般PC 計算機的架構),pid_t類型是和int類型完全兼容的,我們可以用處理整形數的方法去處理pid_t類型的數據,比如,用"%d"把它列印出來。

編譯並運行程序getpid_test.c:

$gcc getpid_test.c -o getpid_test
$./getpid_test
The current process ID is 1980
(你自己的運行結果很可能與這個數字不一樣,這是很正常的。)

再運行一遍:

$./getpid_test
The current process ID is 1981

正如我們所見,儘管是同一個應用程序,每一次運行的時候,所分配的進程標識符都不相同。

fork

在2.4.4版內核中,fork是第2號系統調用,其在Linux函數庫中的原型是:

#include /* 提供類型pid_t的定義 */
#include /* 提供函數的定義 */
pid_t fork(void);

只看fork的名字,可能難得有幾個人可以猜到它是做什麼用的。fork系統調用的作用是複製一個進程。當一個進程調用它,完成後就出現兩個幾乎一模一樣的進程,我們也由此得到了一個新進程。據說fork的名字就是來源於這個與叉子的形狀頗有幾分相似的工作流程。

在Linux中,創造新進程的方法只有一個,就是我們正在介紹的fork。其他一些庫函數,如system(),看起來似乎它們也能創建新的進程,如果能看一下它們的源碼就會明白,它們實際上也在內部調用了fork。包括我們在命令行下運行應用程序,新的進程也是由shell調用fork製造出來的。fork有一些很有意思的特徵,下面就讓我們通過一個小程序來對它有更多的了解。

/* fork_test.c */
#include
#inlcude
main()
{
pid_t pid;
/*此時僅有一個進程*/
pid=fork();
/*此時已經有兩個進程在同時運行*/
if(pid<0)
printf("error in fork!");
else if(pid==0)
printf("I am the child process, my process ID is %d\n",getpid());
else
printf("I am the parent process, my process ID is %d\n",getpid());
}

編譯並運行:

$gcc fork_test.c -o fork_test
$./fork_test
I am the parent process, my process ID is 1991
I am the child process, my process ID is 1992

看這個程序的時候,頭腦中必須首先了解一個概念:在語句pid=fork()之前,只有一個進程在執行這段代碼,但在這條語句之後,就變成兩個進程在執行了,這兩個進程的代碼部分完全相同,將要執行的下一條語句都是if(pid==0)……。

兩個進程中,原先就存在的那個被稱作「父進程」,新出現的那個被稱作「子進程」。父子進程的區別除了進程標誌符(process ID)不同外,變數pid的值也不相同,pid存放的是fork的返回值。fork調用的一個奇妙之處就是它僅僅被調用一次,卻能夠返回兩次,它可能有三種不同的返回值:

在父進程中,fork返回新創建子進程的進程ID;

在子進程中,fork返回0;

如果出現錯誤,fork返回一個負值;

fork出錯可能有兩種原因:

(1)當前的進程數已經達到了系統規定的上限,這時errno的值被設置為EAGAIN。(2)系統內存不足,這時errno的值被設置為ENOMEM。(關於errno的意義,請參考本系列的第一篇文章。)

fork系統調用出錯的可能性很小,而且如果出錯,一般都為第一種錯誤。如果出現第二種錯誤,說明系統已經沒有可分配的內存,正處於崩潰的邊緣,這種情況對Linux來說是很罕見的。

說到這裡,聰明的讀者可能已經完全看懂剩下的代碼了,如果pid小於0,說明出現了錯誤;pid==0,就說明fork返回了0,也就說明當前進程是子進程,就去執行printf("I am the child!"),否則(else),當前進程就是父進程,執行printf("I am the parent!")。完美主義者會覺得這很冗餘,因為兩個進程里都各有一條它們永遠執行不到的語句。不必過於為此耿耿於懷,畢竟很多年以前,UNIX的鼻祖們在當時內存小得無法想象的計算機上就是這樣寫程序的,以我們如今的「海量」內存,完全可以把這幾個位元組的顧慮拋到九霄雲外。

說到這裡,可能有些讀者還有疑問:如果fork后子進程和父進程幾乎完全一樣,而系統中產生新進程唯一的方法就是fork,那豈不是系統中所有的進程都要一模一樣嗎?那我們要執行新的應用程序時候怎麼辦呢?從對Linux系統的經驗中,我們知道這種問題並不存在。至於採用了什麼方法,我們把這個問題留到後面具體討論。

exit

在2.4.4版內核中,exit是第1號調用,其在Linux函數庫中的原型是:

#include
void exit(int status);

不像fork那麼難理解,從exit的名字就能看出,這個系統調用是用來終止一個進程的。無論在程序中的什麼位置,只要執行到exit系統調用,進程就會停止剩下的所有操作,清除包括PCB在內的各種數據結構,並終止本進程的運行。請看下面的程序:

/* exit_test1.c */
#include
main()
{
printf("this process will exit!\n");
exit(0);
printf("never be displayed!\n");
}

編譯后運行:

$gcc exit_test1.c -o exit_test1
$./exit_test1
this process will exit!

我們可以看到,程序並沒有列印後面的「never be displayed!\n」,因為在此之前,在執行到exit(0)時,進程就已經終止了。

exit系統調用帶有一個整數類型的參數status,我們可以利用這個參數傳遞進程結束時的狀態,比如說,該進程是正常結束的,還是出現某種意外而結束的,一般來說,0表示沒有意外的正常結束;其他的數值表示出現了錯誤,進程非正常結束。我們在實際編程時,可以用wait系統調用接收子進程的返回值,從而針對不同的情況進行不同的處理。關於wait的詳細情況,我們將在以後的篇幅中進行介紹。

exit和_exit

作為系統調用而言,_exit和exit是一對孿生兄弟,它們究竟相似到什麼程度,我們可以從Linux的源碼中找到答案:

#define __NR__exit __NR_exit /* 摘自文件include/asm-i386/unistd.h第334行 */

「__NR_」是在Linux的源碼中為每個系統調用加上的前綴,請注意第一個exit前有2條下劃線,第二個exit前只有1條下劃線。

這時隨便一個懂得C語言並且頭腦清醒的人都會說,_exit和exit沒有任何區別,但我們還要講一下這兩者之間的區別,這種區別主要體現在它們在函數庫中的定義。_exit在Linux函數庫中的原型是:

#include
void _exit(int status);

和exit比較一下,exit()函數定義在stdlib.h中,而_exit() 定義在unistd.h中,從名字上看,stdlib.h似乎比unistd.h高級一點,那麼,它們之間到底有什麼區別呢?讓我們先來看流程圖,通過下圖,我們會對這兩個系統調用的執行過程產生一個較為直觀的認識。

從圖中可以看出,_exit()函數的作用最為簡單:直接使進程停止運行,清除其使用的內存空間,並銷毀其在內核中的各種數據結構;exit()函數則在這些基礎上作了一些包裝,在執行退出之前加了若干道工序,也是因為這個原因,有些人認為 exit已經不能算是純粹的系統調用。

exit()函數與_exit()函數最大的區別就在於exit()函數在調用exit系統調用之前要檢查文件的打開情況,把文件緩衝區中的內容寫迴文件,就是圖中的「清理I/O緩衝」一項。

在Linux的標準函數庫中,有一套稱作「高級I/O」的函數,我們熟知的 printf()、fopen()、fread()、fwrite()都在此列,它們也被稱作「緩衝I/O(buffered I/O)」,其特徵是對應每一個打開的文件,在內存中都有一片緩衝區,每次讀文件時,會多讀出若干條記錄,這樣下次讀文件時就可以直接從內存的緩衝區中讀取,每次寫文件的時候,也僅僅是寫入內存中的緩衝區,等滿足了一定的條件(達到一定數量,或遇到特定字元,如換行符\n和文件結束符EOF),再將緩衝區中的內容一次性寫入文件,這樣就大大增加了文件讀寫的速度,但也為我們編程帶來了一點點麻煩。如果有一些數據,我們認為已經寫入了文件,實際上因為沒有滿足特定的條件,它們還只是保存在緩衝區內,這時我們用_exit()函數直接將進程關閉,緩衝區中的數據就會丟失,反之,如果想保證數據的完整性,就一定要使用exit()函數。

請看以下常式:

/* exit2.c */
#include
main()
{
printf("output begin\n");
printf("content in buffer");
exit(0);
}

編譯並運行:

$gcc exit2.c -o exit2
$./exit2
output begin
content in buffer
/* _exit1.c */
#include
main()
{
printf("output begin\n");
printf("content in buffer");
_exit(0);
}

編譯並運行:

$gcc _exit1.c -o _exit1
$./_exit1
output begin

在Linux中,標準輸入和標準輸出都是作為文件處理的,雖然是一類特殊的文件,但從程序員的角度來看,它們和硬碟上存儲數據的普通文件並沒有任何區別。與所有其他文件一樣,它們在打開后也有自己的緩衝區。

請讀者結合前面的敘述,思考一下為什麼這兩個程序會得出不同的結果。相信如果您理解了我前面所講的內容,會很容易的得出結論。

在這篇文章中,我們對Linux的進程管理作了初步的了解,並在此基礎上學習了getpid、fork、exit和_exit四個系統調用。在下一篇文章中,我們將學習與Linux進程管理相關的其他系統調用,並將作一些更深入的探討。

在前面的文章中,我們已經了解了父進程和子進程的概念,並已經掌握了系統調用exit的用法,但可能很少有人意識到,在一個進程調用了exit之後,該進程並非馬上就消失掉,而是留下一個稱為殭屍進程(Zombie)的數據結構。在Linux進程的5種狀態中,殭屍進程是非常特殊的一種,它已經放棄了幾乎所有內存空間,沒有任何可執行代碼,也不能被調度,僅僅在進程列表中保留一個位置,記載該進程的退出狀態等信息供其他進程收集,除此之外,殭屍進程不再佔有任何內存空間。從這點來看,殭屍進程雖然有一個很酷的名字,但它的影響力遠遠抵不上那些真正的殭屍兄弟,真正的殭屍總能令人感到恐怖,而殭屍進程卻除了留下一些供人憑弔的信息,對系統毫無作用。

也許讀者們還對這個新概念比較好奇,那就讓我們來看一眼Linux里的殭屍進程究竟長什麼樣子。

當一個進程已退出,但其父進程還沒有調用系統調用wait(稍後介紹)對其進行收集之前的這段時間裡,它會一直保持殭屍狀態,利用這個特點,我們來寫一個簡單的小程序:

/* zombie.c */
#include
#include
main()
{
pid_t pid;

pid=fork();

if(pid<0) /* 如果出錯 */
printf("error occurred!\n");
else if(pid==0) /* 如果是子進程 */
exit(0);
else /* 如果是父進程 */
sleep(60); /* 休眠60秒,這段時間裡,父進程什麼也幹不了 */
wait(NULL); /* 收集殭屍進程 */
}

sleep的作用是讓進程休眠指定的秒數,在這60秒內,子進程已經退出,而父進程正忙著睡覺,不可能對它進行收集,這樣,我們就能保持子進程60秒的殭屍狀態。

編譯這個程序:

$ cc zombie.c -o zombie

後台運行程序,以使我們能夠執行下一條命令:

$ ./zombie &
[1] 1577

列一下系統內的進程:

$ ps -ax
... ...
1177 pts/0 S 0:00 -bash
1577 pts/0 S 0:00 ./zombie
1578 pts/0 Z 0:00 [zombie ]
1579 pts/0 R 0:00 ps -ax

看到中間的「Z」了嗎?那就是殭屍進程的標誌,它表示1578號進程現在就是一個殭屍進程。

我們已經學習了系統調用exit,它的作用是使進程退出,但也僅僅限於將一個正常的進程變成一個殭屍進程,並不能將其完全銷毀。殭屍進程雖然對其他進程幾乎沒有什麼影響,不佔用CPU時間,消耗的內存也幾乎可以忽略不計,但有它在那裡呆著,還是讓人覺得心裡很不舒服。而且Linux系統中進程數目是有限制的,在一些特殊的情況下,如果存在太多的殭屍進程,也會影響到新進程的產生。那麼,我們該如何來消滅這些殭屍進程呢?

先來了解一下殭屍進程的來由,我們知道,Linux和UNIX總有著剪不斷理還亂的親緣關係,殭屍進程的概念也是從UNIX上繼承來的,而UNIX的先驅們設計這個東西並非是因為閑來無聊想煩煩其他的程序員。殭屍進程中保存著很多對程序員和系統管理員非常重要的信息,首先,這個進程是怎麼死亡的?是正常退出呢,還是出現了錯誤,還是被其它進程強迫退出的?其次,這個進程佔用的總系統 CPU時間和總用戶CPU時間分別是多少?發生頁錯誤的數目和收到信號的數目。這些信息都被存儲在殭屍進程中,試想如果沒有殭屍進程,進程一退出,所有與之相關的信息都立刻歸於無形,而此時程序員或系統管理員需要用到,就只好乾瞪眼了。

那麼,我們如何收集這些信息,並終結這些殭屍進程呢?就要靠我們下面要講到的waitpid調用和wait調用。這兩者的作用都是收集殭屍進程留下的信息,同時使這個進程徹底消失。下面就對這兩個調用分別作詳細介紹。

wait

簡介

wait的函數原型是:

#include /* 提供類型pid_t的定義 */
#include
pid_t wait(int *status)

進程一旦調用了wait,就立即阻塞自己,由wait自動分析是否當前進程的某個子進程已經退出,如果讓它找到了這樣一個已經變成殭屍的子進程,wait就會收集這個子進程的信息,並把它徹底銷毀后返回;如果沒有找到這樣一個子進程, wait就會一直阻塞在這裡,直到有一個出現為止。

參數status用來保存被收集進程退出時的一些狀態,它是一個指向int類型的指針。但如果我們對這個子進程是如何死掉的毫不在意,只想把這個殭屍進程消滅掉,(事實上絕大多數情況下,我們都會這樣想),我們就可以設定這個參數為NULL,就象下面這樣:

pid = wait(NULL);

如果成功,wait會返回被收集的子進程的進程ID,如果調用進程沒有子進程,調用就會失敗,此時wait返回-1,同時errno被置為ECHILD。

實戰

下面就讓我們用一個例子來實戰應用一下wait調用,程序中用到了系統調用fork,如果你對此不大熟悉或已經忘記了,請參考上一篇文章進程管理相關的系統調用(1)。

/* wait1.c */
#include
#include
#include
#include
main()
{
pid_t pc,pr;

pc=fork();

if(pc<0) /* 如果出錯 */
printf("error ocurred!\n");
else if(pc==0){ /* 如果是子進程 */
printf("This is child process with pid of %d\n",getpid());
sleep(10); /* 睡眠10秒鐘 */
}
else{ /* 如果是父進程 */
pr=wait(NULL); /* 在這裡等待 */
printf("I catched a child process with pid of %d\n"),pr);
}
exit(0);
}

編譯並運行:

$ cc wait1.c -o wait1
$ ./wait1
This is child process with pid of 1508
I catched a child process with pid of 1508

可以明顯注意到,在第2行結果列印出來前有10秒鐘的等待時間,這就是我們設定的讓子進程睡眠的時間,只有子進程從睡眠中蘇醒過來,它才能正常退出,也就才能被父進程捕捉到。其實這裡我們不管設定子進程睡眠的時間有多長,父進程都會一直等待下去,讀者如果有興趣的話,可以試著自己修改一下這個數值,看看會出現怎樣的結果。

參數status

如果參數status的值不是NULL,wait就會把子進程退出時的狀態取出並存入其中,這是一個整數值(int),指出了子進程是正常退出還是被非正常結束的(一個進程也可以被其他進程用信號結束,我們將在以後的文章中介紹),以及正常結束時的返回值,或被哪一個信號結束的等信息。由於這些信息被存放在一個整數的不同二進位位中,所以用常規的方法讀取會非常麻煩,人們就設計了一套專門的宏(macro)來完成這項工作,下面我們來學習一下其中最常用的兩個:

● WIFEXITED(status)

這個宏用來指齣子進程是否為正常退出的,如果是,它會返回一個非零值。

(請注意,雖然名字一樣,這裡的參數status並不同於wait唯一的參數--指向整數的指針status,而是那個指針所指向的整數,切記不要搞混了。)

● WEXITSTATUS(status)

當WIFEXITED返回非零值時,我們可以用這個宏來提取子進程的返回值,如果子進程調用exit(5)退出,WEXITSTATUS(status)就會返回5;如果子進程調用exit(7),WEXITSTATUS (status)就會返回7。請注意,如果進程不是正常退出的,也就是說,WIFEXITED返回0,這個值就毫無意義。

下面通過例子來實戰一下我們剛剛學到的內容:

/* wait2.c */
#include
#include
#include
main()
{
int status;
pid_t pc,pr;
pc=fork();
if(pc<0) /* 如果出錯 */
printf("error ocurred!\n");
else if(pc==0){ /* 子進程 */
printf("This is child process with pid of %d.\n",getpid());
exit(3); /* 子進程返回3 */
}
else{ /* 父進程 */
pr=wait(&status);

if(WIFEXITED(status)){ /* 如果WIFEXITED返回非零值 */
printf("the child process %d exit normally.\n",pr);
printf("the return code is %d.\n",WEXITSTATUS(status));
}else /* 如果WIFEXITED返回零 */
printf("the child process %d exit abnormally.\n",pr);
}

}

編譯並運行:

$ cc wait2.c -o wait2
$ ./wait2
This is child process with pid of 1538.
the child process 1538 exit normally.
the return code is 3.

父進程準確捕捉到了子進程的返回值3,並把它列印了出來。

當然,處理進程退出狀態的宏並不止這兩個,但它們當中的絕大部分在平時的編程中很少用到,就也不在這裡浪費篇幅介紹了,有興趣的讀者可以自己參閱Linux man pages去了解它們的用法。

進程同步

有時候,父進程要求子進程的運算結果進行下一步的運算,或者子進程的功能是為父進程提供了下一步執行的先決條件(如:子進程建立文件,而父進程寫入數據),此時父進程就必須在某一個位置停下來,等待子進程運行結束,而如果父進程不等待而直接執行下去的話,可以想見,會出現極大的混亂。這種情況稱為進程之間的同步,更準確地說,這是進程同步的一種特例。進程同步就是要協調好2個以上的進程,使之以安排好地次序依次執行。解決進程同步問題有更通用的方法,我們將在以後介紹,但對於我們假設的這種情況,則完全可以用wait系統調用簡單的予以解決。請看下面這段程序:

#include
#include
main()
{
pid_t pc, pr;
int status;
pc=fork();
if(pc<0)
printf("Error occured on forking.\n");
else if(pc==0){
/* 子進程的工作 */
exit(0);
}else{
/* 父進程的工作 */
pr=wait(&status);
/* 利用子進程的結果 */
}
}

這段程序只是個例子,不能真正拿來執行,但它卻說明了一些問題,首先,當fork調用成功后,父子進程各做各的事情,但當父進程的工作告一段落,需要用到子進程的結果時,它就停下來調用wait,一直等到子進程運行結束,然後利用子進程的結果繼續執行,這樣就圓滿地解決了我們提出的進程同步問題。

waitpid

簡介

waitpid系統調用在Linux函數庫中的原型是:

#include /* 提供類型pid_t的定義 */
#include
pid_t waitpid(pid_t pid,int *status,int options)

從本質上講,系統調用waitpid和wait的作用是完全相同的,但waitpid多出了兩個可由用戶控制的參數pid和options,從而為我們編程提供了另一種更靈活的方式。下面我們就來詳細介紹一下這兩個參數:

● pid

從參數的名字pid和類型pid_t中就可以看出,這裡需要的是一個進程ID。但當pid取不同的值時,在這裡有不同的意義。

pid>0時,只等待進程ID等於pid的子進程,不管其它已經有多少子進程運行結束退出了,只要指定的子進程還沒有結束,waitpid就會一直等下去。

pid=-1時,等待任何一個子進程退出,沒有任何限制,此時waitpid和wait的作用一模一樣。

pid=0時,等待同一個進程組中的任何子進程,如果子進程已經加入了別的進程組,waitpid不會對它做任何理睬。

pid<-1時,等待一個指定進程組中的任何子進程,這個進程組的ID等於pid的絕對值。

● options

options提供了一些額外的選項來控制waitpid,目前在Linux中只支持WNOHANG和WUNTRACED兩個選項,這是兩個常數,可以用"|"運算符把它們連接起來使用,比如:

ret=waitpid(-1,NULL,WNOHANG | WUNTRACED);

如果我們不想使用它們,也可以把options設為0,如:

ret=waitpid(-1,NULL,0);

如果使用了WNOHANG參數調用waitpid,即使沒有子進程退出,它也會立即返回,不會像wait那樣永遠等下去。

而WUNTRACED參數,由於涉及到一些跟蹤調試方面的知識,加之極少用到,這裡就不多費筆墨了,有興趣的讀者可以自行查閱相關材料。

看到這裡,聰明的讀者可能已經看出端倪了--wait不就是經過包裝的waitpid嗎?沒錯,察看<內核源碼目錄>/include/unistd.h文件349-352行就會發現以下程序段:

static inline pid_t wait(int * wait_stat)
{
return waitpid(-1,wait_stat,0);
}

返回值和錯誤

waitpid的返回值比wait稍微複雜一些,一共有3種情況:

● 當正常返回的時候,waitpid返回收集到的子進程的進程ID;

● 如果設置了選項WNOHANG,而調用中waitpid發現沒有已退出的子進程可收集,則返回0;

● 如果調用中出錯,則返回-1,這時errno會被設置成相應的值以指示錯誤所在;

當pid所指示的子進程不存在,或此進程存在,但不是調用進程的子進程,waitpid就會出錯返回,這時errno被設置為ECHILD;

/* waitpid.c */
#include
#include
#include
main()
{
pid_t pc, pr;
pc=fork();
if(pc<0) /* 如果fork出錯 */
printf("Error occured on forking.\n");
else if(pc==0){ /* 如果是子進程 */
sleep(10); /* 睡眠10秒 */
exit(0);
}

/* 如果是父進程 */
do{
pr=waitpid(pc, NULL, WNOHANG); /* 使用了WNOHANG參數,waitpid不會在這裡等待 */
if(pr==0){ /* 如果沒有收集到子進程 */
printf("No child exited\n");
sleep(1);
}
}while(pr==0); /* 沒有收集到子進程,就回去繼續嘗試 */
if(pr==pc)
printf("successfully get child %d\n", pr);
else
printf("some error occured\n");
}

編譯並運行:

$ cc waitpid.c -o waitpid
$ ./waitpid
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
successfully get child 1526

父進程經過10次失敗的嘗試之後,終於收集到了退出的子進程。

因為這只是一個例子程序,不便寫得太複雜,所以我們就讓父進程和子進程分別睡眠了10秒鐘和1秒鐘,代表它們分別作了10秒鐘和1秒鐘的工作。父子進程都有工作要做,父進程利用工作的簡短間歇察看子進程的是否退出,如退出就收集它。

exec

也許有不少讀者從本系列文章一推出就開始讀,一直到這裡還有一個很大的疑惑:既然所有新進程都是由fork產生的,而且由fork產生的子進程和父進程幾乎完全一樣,那豈不是意味著系統中所有的進程都應該一模一樣了嗎?而且,就我們的常識來說,當我們執行一個程序的時候,新產生的進程的內容應就是程序的內容才對。是我們理解錯了嗎?顯然不是,要解決這些疑惑,就必須提到我們下面要介紹的exec 系統調用。

簡介

說是exec系統調用,實際上在Linux中,並不存在一個exec()的函數形式,exec指的是一組函數,一共有6個,分別是:

#include
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

其中只有execve是真正意義上的系統調用,其它都是在此基礎上經過包裝的庫函數。

exec函數族的作用是根據指定的文件名找到可執行文件,並用它來取代調用進程的內容,換句話說,就是在調用進程內部執行一個可執行文件。這裡的可執行文件既可以是二進位文件,也可以是任何Linux下可執行的腳本文件。

與一般情況不同,exec函數族的函數執行成功后不會返回,因為調用進程的實體,包括代碼段,數據段和堆棧等都已經被新的內容取代,只留下進程ID等一些表面上的信息仍保持原樣,頗有些神似"三十六計"中的"金蟬脫殼"。看上去還是舊的軀殼,卻已經注入了新的靈魂。只有調用失敗了,它們才會返回一個-1,從原程序的調用點接著往下執行。

現在我們應該明白了,Linux下是如何執行新程序的,每當有進程認為自己不能為系統和擁護做出任何貢獻了,他就可以發揮最後一點餘熱,調用任何一個exec,讓自己以新的面貌重生;或者,更普遍的情況是,如果一個進程想執行另一個程序,它就可以fork出一個新進程,然後調用任何一個exec,這樣看起來就好像通過執行應用程序而產生了一個新進程一樣。

事實上第二種情況被應用得如此普遍,以至於Linux專門為其作了優化,我們已經知道,fork會將調用進程的所有內容原封不動的拷貝到新產生的子進程中去,這些拷貝的動作很消耗時間,而如果fork完之後我們馬上就調用exec,這些辛辛苦苦拷貝來的東西又會被立刻抹掉,這看起來非常不划算,於是人們設計了一種"寫時拷貝(copy-on-write)"技術,使得fork結束后並不立刻複製父進程的內容,而是到了真正實用的時候才複製,這樣如果下一條語句是exec,它就不會白白作無用功了,也就提高了效率。

稍稍深入

上面6條函數看起來似乎很複雜,但實際上無論是作用還是用法都非常相似,只有很微小的差別。在學習它們之前,先來了解一下我們習以為常的main函數。

下面這個main函數的形式可能有些出乎我們的意料:

int main(int argc, char *argv[], char *envp[])

它可能與絕大多數教科書上描述的都不一樣,但實際上,這才是main函數真正完整的形式。

參數argc指出了運行該程序時命令行參數的個數,數組argv存放了所有的命令行參數,數組envp存放了所有的環境變數。環境變數指的是一組值,從用戶登錄后就一直存在,很多應用程序需要依靠它來確定系統的一些細節,我們最常見的環境變數是PATH,它指出了應到哪裡去搜索應用程序,如/bin;HOME也是比較常見的環境變數,它指出了我們在系統中的個人目錄。環境變數一般以字元串"XXX=xxx"的形式存在,XXX表示變數名,xxx表示變數的值。

值得一提的是,argv數組和envp數組存放的都是指向字元串的指針,這兩個數組都以一個NULL元素表示數組的結尾。

我們可以通過以下這個程序來觀看傳到argc、argv和envp里的都是什麼東西:

/* main.c */
int main(int argc, char *argv[], char *envp[])
{
printf("\n### ARGC ###\n%d\n", argc);
printf("\n### ARGV ###\n");
while(*argv)
printf("%s\n", *(argv++));
printf("\n### ENVP ###\n");
while(*envp)
printf("%s\n", *(envp++));
return 0;
}

編譯它:

$ cc main.c -o main

運行時,我們故意加幾個沒有任何作用的命令行參數:

$ ./main -xx 000
### ARGC ###
3
### ARGV ###
./main
-xx
000
### ENVP ###
PWD=/home/lei
REMOTEHOST=dt.laser.com
HOSTNAME=localhost.localdomain
QTDIR=/usr/lib/qt-2.3.1
LESSOPEN=|/usr/bin/lesspipe.sh %s
KDEDIR=/usr
USER=lei
LS_COLORS=
MACHTYPE=i386-redhat-linux-gnu
MAIL=/var/spool/mail/lei
INPUTRC=/etc/inputrc
LANG=en_US
LOGNAME=lei
SHLVL=1
SHELL=/bin/bash
HOSTTYPE=i386
OSTYPE=linux-gnu
HISTSIZE=1000
TERM=ansi
HOME=/home/lei
PATH=/usr/local/bin:/bin:/usr/bin:/usr/X11R6/bin:/home/lei/bin
_=./main

我們看到,程序將「./main」作為第1個命令行參數,所以我們一共有3個命令行參數。這可能與大家平時習慣的說法有些不同,小心不要搞錯了。

現在回過頭來看一下exec函數族,先把注意力集中在execve上:

int execve(const char *path, char *const argv[], char *const envp[]);

對比一下main函數的完整形式,看出問題了嗎?是的,這兩個函數里的argv和 envp是完全一一對應的關係。execve第1個參數path是被執行應用程序的完整路徑,第2個參數argv就是傳給被執行應用程序的命令行參數,第 3個參數envp是傳給被執行應用程序的環境變數。

留心看一下這6個函數還可以發現,前3個函數都是以execl開頭的,后3個都是以execv開頭的,它們的區別在於,execv開頭的函數是以「char *argv[]」這樣的形式傳遞命令行參數,而execl開頭的函數採用了我們更容易習慣的方式,把參數一個一個列出來,然後以一個NULL表示結束。這裡的NULL的作用和argv數組裡的NULL作用是一樣的。

在全部6個函數中,只有execle和execve使用了char *envp[]傳遞環境變數,其它的4個函數都沒有這個參數,這並不意味著它們不傳遞環境變數,這4個函數將把默認的環境變數不做任何修改地傳給被執行的應用程序。而execle和execve會用指定的環境變數去替代默認的那些。

還有2個以p結尾的函數execlp和execvp,咋看起來,它們和execl 與execv的差別很小,事實也確是如此,除execlp和execvp之外的4個函數都要求,它們的第1個參數path必須是一個完整的路徑,如 "/bin/ls";而execlp和execvp的第1個參數file可以簡單到僅僅是一個文件名,如"ls",這兩個函數可以自動到環境變數PATH 制定的目錄里去尋找。

實戰

知識介紹得差不多了,接下來我們看看實際的應用:

/* exec.c */
#include
main()
{
char *envp[]={"PATH=/tmp",
"USER=lei",
"STATUS=testing",
NULL};
char *argv_execv[]={"echo", "excuted by execv", NULL};
char *argv_execvp[]={"echo", "executed by execvp", NULL};
char *argv_execve[]={"env", NULL};
if(fork()==0)
if(execl("/bin/echo", "echo", "executed by execl", NULL)<0)
perror("Err on execl");
if(fork()==0)
if(execlp("echo", "echo", "executed by execlp", NULL)<0)
perror("Err on execlp");
if(fork()==0)
if(execle("/usr/bin/env", "env", NULL, envp)<0)
perror("Err on execle");
if(fork()==0)
if(execv("/bin/echo", argv_execv)<0)
perror("Err on execv");
if(fork()==0)
if(execvp("echo", argv_execvp)<0)
perror("Err on execvp");
if(fork()==0)
if(execve("/usr/bin/env", argv_execve, envp)<0)
perror("Err on execve");
}

程序里調用了2個Linux常用的系統命令,echo和env。echo會把後面跟的命令行參數原封不動的列印出來,env用來列出所有環境變數。

由於各個子進程執行的順序無法控制,所以有可能出現一個比較混亂的輸出--各子進程列印的結果交雜在一起,而不是嚴格按照程序中列出的次序。

編譯並運行:

$ cc exec.c -o exec
$ ./exec
executed by execl
PATH=/tmp
USER=lei
STATUS=testing
executed by execlp
excuted by execv
executed by execvp
PATH=/tmp
USER=lei
STATUS=testing

果然不出所料,execle輸出的結果跑到了execlp前面。

大家在平時的編程中,如果用到了exec函數族,一定記得要加錯誤判斷語句。因為與其他系統調用比起來,exec很容易受傷,被執行文件的位置,許可權等很多因素都能導致該調用的失敗。最常見的錯誤是:

找不到文件或路徑,此時errno被設置為ENOENT;

數組argv和envp忘記用NULL結束,此時errno被設置為EFAULT;

沒有對要執行文件的運行許可權,此時errno被設置為EACCES。

進程的一生

下面就讓我用一些形象的比喻,來對進程短暫的一生作一個小小的總結:

隨著一句fork,一個新進程呱呱落地,但它這時只是老進程的一個克隆。

然後隨著exec,新進程脫胎換骨,離家獨立,開始了為人民服務的職業生涯。

人有生老病死,進程也一樣,它可以是自然死亡,即運行到main函數的最後一個"} ",從容地離我們而去;也可以是自殺,自殺有2種方式,一種是調用exit函數,一種是在main函數內使用return,無論哪一種方式,它都可以留下遺書,放在返回值里保留下來;它還甚至能可被謀殺,被其它進程通過另外一些方式結束他的生命。

進程死掉以後,會留下一具殭屍,wait和waitpid充當了殮屍工,把殭屍推去火化,使其最終歸於無形。

這就是進程完整的一生。

小結

本文重點介紹了系統調用wait、waitpid和exec函數族,對與進程管理相關的系統調用的介紹就在這裡告一段落,在下一篇文章,也是與進程管理相關的系統調用的最後一篇文章中,我們會通過兩個很酷的實際例子,來重溫一下最近學過的知識。




[火星人 via ] 系統調用已經有326次圍觀

http://www.coctec.com/docs/linux/show-post-187750.html