當(dāng)我們在 Linux 下的命令行輸入一個命令之后,這背后發(fā)生了什么?
用戶使用計算機有兩種常見的方式,一種是圖形化的接口(GUI),另外一種則是命令行接口(CLI)。對于圖形化的接口,用戶點擊某個圖標就可啟動后臺的某個程序;對于命令行的接口,用戶鍵入某個程序的名字就可啟動某個程序。這兩者的基本過程是類似的,都需要查找程序文件在磁盤上的位置,加載到內(nèi)存并通過不同的解釋器進行解析和運行。下面以命令行為例來介紹程序執(zhí)行一剎那發(fā)生的一些事情。
首先來介紹什么是命令行?命令行就是 Command Line
,很直觀的概念就是系統(tǒng)啟動后的那個黑屏幕:有一個提示符,并有光標在閃爍的那樣一個終端,一般情況下可以用 CTRL+ALT+F1-6
切換到不同的終端;在 GUI 界面下也會有一些偽終端,看上去和系統(tǒng)啟動時的那個終端沒有什么區(qū)別,也會有一個提示符,并有一個光標在閃爍。就提示符和響應(yīng)用戶的鍵盤輸入而言,它們兩者在功能上是一樣的,實際上它們就是同一個東西,用下面的命令就可以把它們打印出來。
$ echo $SHELL # 打印當(dāng)前SHELL,當(dāng)前運行的命令行接口程序
/bin/bash
$ echo $$ # 該程序?qū)?yīng)進程ID,$$是個特殊的環(huán)境變量,它存放了當(dāng)前進程ID
1481
$ ps -C bash # 通過PS命令查看
PID TTY TIME CMD
1481 pts/0 00:00:00 bash
從上面的操作結(jié)果可以看出,當(dāng)前命令行接口實際上是一個程序,那就是 /bin/bash
,它是一個實實在在的程序,它打印提示符,接受用戶輸入的命令,分析命令序列并執(zhí)行然后返回結(jié)果。不過 /bin/bash
僅僅是當(dāng)前使用的命令行程序之一,還有很多具有類似功能的程序,比如 /bin/ash
, /bin/dash
等。不過這里主要來討論 bash
,討論它自己是怎么啟動的,它怎么樣處理用戶輸入的命令等后臺細節(jié)?
先通過 CTRL+ALT+F1
切換到一個普通終端下面,一般情況下看到的是 "XXX login: " 提示輸入用戶名,接著是提示輸入密碼,然后呢?就直接登錄到了我們的命令行接口。實際上正是你輸入正確的密碼后,那個程序才把 /bin/bash
給啟動了。那是什么東西提示 "XXX login:" 的呢?正是 /bin/login
程序,那 /bin/login
程序怎么知道要啟動 /bin/bash
,而不是其他的 /bin/dash
呢?
/bin/login
程序?qū)嶋H上會檢查我們的 /etc/passwd
文件,在這個文件里頭包含了用戶名、密碼和該用戶的登錄 Shell。密碼和用戶名匹配用戶的登錄,而登錄 Shell 則作為用戶登錄后的命令行程序??纯?/etc/passwd
中典型的這么一行:
$ cat /etc/passwd | grep falcon
falcon:x:1000:1000:falcon,,,:/home/falcon:/bin/bash
這個是我用的帳號的相關(guān)信息哦,看到最后一行沒?/bin/bash
,這正是我登錄用的命令行解釋程序。至于密碼呢,看到那個 x
沒?這個 x
說明我的密碼被保存在另外一個文件里頭 /etc/shadow
,而且密碼是經(jīng)過加密的。至于這兩個文件的更多細節(jié),看手冊吧。
我們怎么知道剛好是 /bin/login
打印了 "XXX login" 呢?現(xiàn)在回顧一下很早以前學(xué)習(xí)的那個 strace
命令。我們可以用 strace
命令來跟蹤 /bin/login
程序的執(zhí)行。
跟上面一樣,切換到一個普通終端,并切換到 Root 用戶,用下面的命令:
$ strace -f -o strace.out /bin/login
退出以后就可以打開 strace.out
文件,看看到底執(zhí)行了哪些文件,讀取了哪些文件。從中可以看到正是 /bin/login
程序用 execve
調(diào)用了 /bin/bash
命令。通過后面的演示,可以發(fā)現(xiàn) /bin/login
只是在子進程里頭用 execve
調(diào)用了 /bin/bash
,因為在啟動 /bin/bash
后,可以看到 /bin/login
并沒有退出。
那 /bin/login
又是怎么起來的呢?
下面再來看一個演示。先在一個可以登陸的終端下執(zhí)行下面的命令。
$ getty 38400 tty8 linux
getty
命令停留在那里,貌似等待用戶的什么操作,現(xiàn)在切回到第 8 個終端,是不是看到有 "XXX login:" 的提示了。輸入用戶名并登錄,之后退出,回到第一個終端,發(fā)現(xiàn) getty
命令已經(jīng)退出。
類似地,也可以用 strace
命令來跟蹤 getty
的執(zhí)行過程。在第一個終端下切換到 Root 用戶。執(zhí)行如下命令:
$ strace -f -o strace.out getty 38400 tty8 linux
同樣在 strace.out
命令中可以找到該命令的相關(guān)啟動細節(jié)。比如,可以看到正是 getty
程序用 execve
系統(tǒng)調(diào)用執(zhí)行了 /bin/login
程序。這個地方,getty
是在自己的主進程里頭直接執(zhí)行了 /bin/login
,這樣 /bin/login
將把 getty
的進程空間替換掉。
這里涉及到一個非常重要的東西:/sbin/init
,通過 man init
命令可以查看到該命令的作用,它可是“萬物之王”(init is the parent of all processes on the system)哦。它是 Linux 系統(tǒng)默認啟動的第一個程序,負責(zé)進行 Linux 系統(tǒng)的一些初始化工作,而這些初始化工作的配置則是通過 /etc/inittab
來做的。那么來看看 /etc/inittab
的一個簡單的例子吧,可以通過 man inittab
查看相關(guān)幫助。
需要注意的是,在較新版本的 Ubuntu 和 Fedora 等發(fā)行版中,一些新的 init
程序,比如 upstart
和 systemd
被開發(fā)出來用于取代 System V init
,它們可能放棄了對 /etc/inittab
的使用,例如 upstart
會讀取 /etc/init/
下的配置,比如 /etc/init/tty1.conf
,但是,基本的配置思路還是類似 /etc/inittab
,對于 upstart
的 init
配置,這里不做介紹,請通過 man 5 init
查看幫助。
配置文件 /etc/inittab
的語法非常簡單,就是下面一行的重復(fù),
id:runlevels:action:process
id
就是一個唯一的編號,不用管它,一個名字而言,無關(guān)緊要。
runlevels
是運行級別,這個還是比較重要的,理解運行級別的概念很有必要,它可以有如下的取值:
0 is halt.
1 is single-user.
2-5 are multi-user.
6 is reboot.
不過,真正在配置文件里頭用的是 1-5
了,而 0
和 6
非常特別,除了用它作為 init
命令的參數(shù)關(guān)機和重啟外,似乎沒有哪個“傻瓜”把它寫在系統(tǒng)的配置文件里頭,讓系統(tǒng)啟動以后就關(guān)機或者重啟。1
代表單用戶,而 2-5
則代表多用戶。對于 2-5
可能有不同的解釋,比如在 Slackware 12.0 上,2,3,5
被用來作為多用戶模式,但是默認不啟動 X windows (GUI接口),而 4
則作為啟動 X windows 的運行級別。
action
是動作,它也有很多選擇,我們關(guān)心幾個常用的
initdefault
:用來指定系統(tǒng)啟動后進入的運行級別,通常在 /etc/inittab
的第一條配置,如:
id:3:initdefault:
這個說明默認運行級別是 3,即多用戶模式,但是不啟動 X window 的那種。
sysinit
:指定那些在系統(tǒng)啟動時將被執(zhí)行的程序,例如:
si:S:sysinit:/etc/rc.d/rc.S
在 man inittab
中提到,對于 sysinit
,boot
等動作,runlevels
選項是不用管的,所以可以很容易解讀這條配置:它的意思是系統(tǒng)啟動時將默認執(zhí)行 /etc/rc.d/rc.S
文件,在這個文件里可直接或者間接地執(zhí)行想讓系統(tǒng)啟動時執(zhí)行的任何程序,完成系統(tǒng)的初始化。
wait
:當(dāng)進入某個特別的運行級別時,指定的程序?qū)⒈粓?zhí)行一次,init
將等到它執(zhí)行完成,例如:
rc:2345:wait:/etc/rc.d/rc.M
這個說明無論是進入運行級別 2,3,4,5 中哪一個,/etc/rc.d/rc.M
將被執(zhí)行一次,并且有 init
等待它執(zhí)行完成。
ctrlaltdel
,當(dāng) init
程序接收到 SIGINT
信號時,某個指定的程序?qū)⒈粓?zhí)行,我們通常通過按下 CTRL+ALT+DEL
,這個默認情況下將給 init
發(fā)送一個 SIGINT
信號。
如果我們想在按下這幾個鍵時,系統(tǒng)重啟,那么可以在 /etc/inittab
中寫入:
ca::ctrlaltdel:/sbin/shutdown -t5 -r now
respawn
:這個指定的進程將被重啟,任何時候當(dāng)它退出時。這意味著沒有辦法結(jié)束它,除非 init
自己結(jié)束了。例如:
c1:1235:respawn:/sbin/agetty 38400 tty1 linux
這一行的意思非常簡單,就是系統(tǒng)運行在級別 1,2,3,5 時,將默認執(zhí)行 /sbin/agetty
程序(這個類似于上面提到的 getty
程序),這個程序非常有意思,就是無論什么時候它退出,init
將再次啟動它。這個有幾個比較有意思的問題:
在 Slackware 12.0 下,當(dāng)默認運行級別為 4 時,只有第 6 個終端可以用。原因是什么呢?因為類似上面的配置,因為那里只有 1235
,而沒有 4
,這意味著當(dāng)系統(tǒng)運行在第 4
級別時,其他終端下的 /sbin/agetty
沒有啟動。所以,如果想讓其他終端都可以用,把 1235
修改為 12345
即可。
init
程序在讀取這個配置行以后啟動了 /sbin/agetty
,這就是 /sbin/agetty
的秘密。respawn
動作有趣的性質(zhì),因為它告訴 init
,無論 /sbin/agetty
什么時候退出,重新把它啟動起來,那跟 "XXX login:" 有什么關(guān)系呢?從前面的內(nèi)容,我們發(fā)現(xiàn)正是 /sbin/getty
(同 agetty
)啟動了 /bin/login
,而 /bin/login
又啟動了 /bin/bash
,即我們的命令行程序。而 init
程序作為“萬物之王”,它是所有進程的“父”(也可能是祖父……)進程,那意味著其他進程最多只能是它的兒子進程。而這個子進程是怎么創(chuàng)建的,fork
調(diào)用,而不是之前提到的 execve
調(diào)用。前者創(chuàng)建一個子進程,后者則會覆蓋當(dāng)前進程。因為我們發(fā)現(xiàn) /sbin/getty
運行時,init
并沒有退出,因此可以判斷是 fork
調(diào)用創(chuàng)建一個子進程后,才通過 execve
執(zhí)行了 /sbin/getty
。
因此,可以總結(jié)出這么一個調(diào)用過程:
fork execve execve fork execve
init --> init --> /sbin/getty --> /bin/login --> /bin/login --> /bin/bash
這里的 execve
調(diào)用以后,后者將直接替換前者,因此當(dāng)鍵入 exit
退出 /bin/bash
以后,也就相當(dāng)于 /sbin/getty
都已經(jīng)結(jié)束了,因此最前面的 init
程序判斷 /sbin/getty
退出了,又會創(chuàng)建一個子進程把 /sbin/getty
啟動,進而又啟動了 /bin/login
,又看到了那個 "XXX login:"。
通過 ps
和 pstree
命令看看實際情況是不是這樣,前者打印出進程的信息,后者則打印出調(diào)用關(guān)系。
$ ps -ef | egrep "/sbin/init|/sbin/getty|bash|/bin/login"
root 1 0 0 21:43 ? 00:00:01 /sbin/init
root 3957 1 0 21:43 tty4 00:00:00 /sbin/getty 38400 tty4
root 3958 1 0 21:43 tty5 00:00:00 /sbin/getty 38400 tty5
root 3963 1 0 21:43 tty3 00:00:00 /sbin/getty 38400 tty3
root 3965 1 0 21:43 tty6 00:00:00 /sbin/getty 38400 tty6
root 7023 1 0 22:48 tty1 00:00:00 /sbin/getty 38400 tty1
root 7081 1 0 22:51 tty2 00:00:00 /bin/login --
falcon 7092 7081 0 22:52 tty2 00:00:00 -bash
上面的結(jié)果已經(jīng)過濾了一些不相干的數(shù)據(jù)。從上面的結(jié)果可以看到,除了 tty2
被替換成 /bin/login
外,其他終端都運行著 /sbin/getty
,說明終端 2 上的進程是 /bin/login
,它已經(jīng)把 /sbin/getty
替換掉,另外,我們看到 -bash
進程的父進程是 7081
剛好是 /bin/login
程序,這說明 /bin/login
啟動了 -bash
,但是它并沒有替換掉 /bin/login
,而是成為了 /bin/login
的子進程,這說明 /bin/login
通過 fork
創(chuàng)建了一個子進程并通過 execve
執(zhí)行了 -bash
(后者通過 strace
跟蹤到)。而 init
呢,其進程 ID 是 1,是 /sbin/getty
和 /bin/login
的父進程,說明 init
啟動或者間接啟動了它們。下面通過 pstree
來查看調(diào)用樹,可以更清晰地看出上述關(guān)系。
$ pstree | egrep "init|getty|\-bash|login"
init-+-5*[getty]
|-login---bash
|-xfce4-terminal-+-bash-+-grep
結(jié)果顯示 init
是 5 個 getty
程序,login
程序和 xfce4-terminal
的父進程,而后兩者則是 bash
的父進程,另外我們執(zhí)行的 grep
命令則在 bash
上運行,是 bash
的子進程,這個將是我們后面關(guān)心的問題。
從上面的結(jié)果發(fā)現(xiàn),init
作為所有進程的父進程,它的父進程 ID 饒有興趣的是 0,它是怎么被啟動的呢?誰才是真正的“造物主”?
如果用過 Lilo
或者 Grub
這些操作系統(tǒng)引導(dǎo)程序,可能會用到 Linux 內(nèi)核的一個啟動參數(shù) init
,當(dāng)忘記密碼時,可能會把這個參數(shù)設(shè)置成 /bin/bash
,讓系統(tǒng)直接進入命令行,而無須輸入帳號和密碼,這樣就可以方便地把登錄密碼修改掉。
這個 init
參數(shù)是個什么東西呢?通過 man bootparam
會發(fā)現(xiàn)它的秘密,init
參數(shù)正好指定了內(nèi)核啟動后要啟動的第一個程序,而如果沒有指定該參數(shù),內(nèi)核將依次查找 /sbin/init
,/etc/init
,/bin/init
,/bin/sh
,如果找不到這幾個文件中的任何一個,內(nèi)核就要恐慌(panic)了,并掛(hang)在那里一動不動了(注:如果 panic=timeout
被傳遞給內(nèi)核并且 timeout
大于 0,那么就不會掛住而是重啟)。
因此 /sbin/init
就是 Linux 內(nèi)核啟動的。而 Linux 內(nèi)核呢?是通過 Lilo
或者 Grub
等引導(dǎo)程序啟動的,Lilo
和 Grub
都有相應(yīng)的配置文件,一般對應(yīng) /etc/lilo.conf
和 /boot/grub/menu.lst
,通過這些配置文件可以指定內(nèi)核映像文件、系統(tǒng)根目錄所在分區(qū)、啟動選項標簽等信息,從而能夠讓它們順利把內(nèi)核啟動起來。
那 Lilo
和 Grub
本身又是怎么被運行起來的呢?有了解 MBR 不?MBR 就是主引導(dǎo)扇區(qū),一般情況下這里存放著 Lilo
和 Grub
的代碼,而誰知道正好是這里存放了它們呢?BIOS,如果你用光盤安裝過操作系統(tǒng)的話,那么應(yīng)該修改過 BIOS
的默認啟動設(shè)置,通過設(shè)置可以讓系統(tǒng)從光盤、硬盤、U 盤甚至軟盤啟動。正是這里的設(shè)置讓 BIOS 知道了 MBR 處的代碼需要被執(zhí)行。
那 BIOS 又是什么時候被起來的呢?處理器加電后有一個默認的起始地址,一上電就執(zhí)行到了這里,再之前就是開機鍵按鍵后的上電時序。
更多系統(tǒng)啟動的細節(jié),看看 man boot-scripts
吧。
到這里,/bin/bash
的神秘面紗就被揭開了,它只是系統(tǒng)啟動后運行的一個程序而已,只不過這個程序可以響應(yīng)用戶的請求,那它到底是如何響應(yīng)用戶請求的呢?
在執(zhí)行磁盤上某個程序時,通常不會指定這個程序文件的絕對路徑,比如要執(zhí)行 echo
命令時,一般不會輸入 /bin/echo
,而僅僅是輸入 echo
。那為什么這樣 bash
也能夠找到 /bin/echo
呢?原因是 Linux 操作系統(tǒng)支持這樣一種策略:Shell 的一個環(huán)境變量 PATH
里頭存放了程序的一些路徑,當(dāng) Shell 執(zhí)行程序時有可能去這些目錄下查找。which
作為 Shell(這里特指 bash
)的一個內(nèi)置命令,如果用戶輸入的命令是磁盤上的某個程序,它會返回這個文件的全路徑。
有三個東西和終端的關(guān)系很大,那就是標準輸入、標準輸出和標準錯誤,它們是三個文件描述符,一般對應(yīng)描述符 0,1,2。在 C 語言程序里,我們可以把它們當(dāng)作文件描述符一樣進行操作。在命令行下,則可以使用重定向字符>,<
等對它們進行操作。對于標準輸出和標準錯誤,都默認輸出到終端,對于標準輸入,也同樣默認從終端輸入。
在 C 語言里頭要寫一段輸入字符串的命令很簡單,調(diào)用 scanf
或者 fgets
就可以。這個在 bash
里頭應(yīng)該是類似的。但是它獲取用戶的命令以后,如何分析命令,如何響應(yīng)不同的命令呢?
首先來看看 bash
下所謂的命令,用最常見的 test
來作測試。
字符串被解析成命令
隨便鍵入一個字符串 test1
, bash
發(fā)出響應(yīng),告知找不到這個程序:
$ test1
bash: test1: command not found
內(nèi)置命令
而當(dāng)鍵入 test
時,看不到任何輸出,唯一響應(yīng)是,新命令提示符被打印了:
$ test
$
查看 test
這個命令的類型,即查看 test
將被如何解釋, type
告訴我們 test
是一個內(nèi)置命令,如果沒有理解錯, test
應(yīng)該是利用諸如 case "test": do something;break;
這樣的機制實現(xiàn)的,具體如何實現(xiàn)可以查看 bash
源代碼。
$ type test
test is a shell builtin
外部命令
這里通過 which
查到 /usr/bin
下有一個 test
命令文件,在鍵入 test
時,到底哪一個被執(zhí)行了呢?
$ which test
/usr/bin/test
執(zhí)行這個呢?也沒什么反應(yīng),到底誰先被執(zhí)行了?
$ /usr/bin/test
從上述演示中發(fā)現(xiàn)一個問題?如果輸入一個命令,這個命令要么就不存在,要么可能同時是 Shell 的內(nèi)置命令、也有可能是磁盤上環(huán)境變量 PATH
所指定的目錄下的某個程序文件。
考慮到 test
內(nèi)置命令和 /usr/bin/test
命令的響應(yīng)結(jié)果一樣,我們無法知道哪一個先被執(zhí)行了,怎么辦呢?把 /usr/bin/test
替換成一個我們自己的命令,并讓它打印一些信息(比如 hello, world!
),這樣我們就知道到底誰被執(zhí)行了。寫完程序,編譯好,命名為 test
放到 /usr/bin
下(記得備份原來那個)。開始測試:
鍵入 test
,還是沒有效果:
$ test
$
而鍵入絕對路徑呢,則打印了 hello, world!
誒,那默認情況下肯定是內(nèi)置命令先被執(zhí)行了:
$ /usr/bin/test
hello, world!
由上述實驗結(jié)果可見,內(nèi)置命令比磁盤文件中的程序優(yōu)先被 bash
執(zhí)行。原因應(yīng)該是內(nèi)置命令避免了不必要的 fork/execve
調(diào)用,對于采用類似算法實現(xiàn)的功能,內(nèi)置命令理論上有更高運行效率。
下面看看更多有趣的內(nèi)容,鍵盤鍵入的命令還有可能是什么呢?因為 bash
支持別名(alias
)和函數(shù)(function
),所以還有可能是別名和函數(shù),另外,如果 PATH
環(huán)境變量指定的不同目錄下有相同名字的程序文件,那到底哪個被優(yōu)先找到呢?
下面再作一些實驗,
別名
把 test
命名為 ls -l
的別名,再執(zhí)行 test
,竟然執(zhí)行了 ls -l
,說明別名(alias
)比內(nèi)置命令(builtin
)更優(yōu)先:
$ alias test="ls -l"
$ test
total 9488
drwxr-xr-x 12 falcon falcon 4096 2008-02-21 23:43 bash-3.2
-rw-r--r-- 1 falcon falcon 2529838 2008-02-21 23:30 bash-3.2.tar.gz
函數(shù)
定義一個名叫 test
的函數(shù),執(zhí)行一下,發(fā)現(xiàn),還是執(zhí)行了 ls -l
,說明 function
沒有 alias
優(yōu)先級高:
$ function test { echo "hi, I'm a function"; }
$ test
total 9488
drwxr-xr-x 12 falcon falcon 4096 2008-02-21 23:43 bash-3.2
-rw-r--r-- 1 falcon falcon 2529838 2008-02-21 23:30 bash-3.2.tar.gz
把別名給去掉(unalias
),現(xiàn)在執(zhí)行的是函數(shù),說明函數(shù)的優(yōu)先級比內(nèi)置命令也要高:
$ unalias test
$ test
hi, I'm a function
如果在命令之前跟上 builtin
,那么將直接執(zhí)行內(nèi)置命令:
$ builtin test
要去掉某個函數(shù)的定義,這樣就可以:
$ unset test
通過這個實驗我們得到一個命令的別名(alias
)、函數(shù)(function
),內(nèi)置命令(builtin
)和程序(program
)的執(zhí)行優(yōu)先次序:
先 alias --> function --> builtin --> program 后
實際上, type
命令會告訴我們這些細節(jié), type -a
會按照 bash
解析的順序依次打印該命令的類型,而 type -t
則會給出第一個將被解析的命令的類型,之所以要做上面的實驗,是為了讓大家加印象。
$ type -a test
test is a shell builtin
test is /usr/bin/test
$ alias test="ls -l"
$ function test { echo "I'm a function"; }
$ type -a test
test is aliased to `ls -l'
test is a function
test ()
{
echo "I'm a function"
}
test is a shell builtin
test is /usr/bin/test
$ type -t test
alias
下面再看看 PATH
指定的多個目錄下有同名程序的情況。再寫一個程序,打印 hi, world!
,以示和 hello, world!
的區(qū)別,放到 PATH
指定的另外一個目錄 /bin
下,為了保證測試的說服力,再寫一個放到另外一個叫 /usr/local/sbin
的目錄下。
先看看 PATH
環(huán)境變量,確保它有 /usr/bin
,/bin
和 /usr/local/sbin
這幾個目錄,然后通過 type -P
(-P
參數(shù)強制到 PATH
下查找,而不管是別名還是內(nèi)置命令等,可以通過 help type
查看該參數(shù)的含義)查看,到底哪個先被執(zhí)行。
$ echo $PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games$ type -P test/usr/local/sbin/test
如上可以看到 /usr/local/sbin
下的先被找到。
把 /usr/local/sbin/test
下的給刪除掉,現(xiàn)在 /usr/bin
下的先被找到:
$ rm /usr/local/sbin/test
$ type -P test
/usr/bin/test
type -a
也顯示類似的結(jié)果:
$ type -a test
test is aliased to `ls -l'
test is a function
test ()
{
echo "I'm a function"
}
test is a shell builtin
test is /usr/bin/test
test is /bin/test
因此,可以找出這么一個規(guī)律: Shell 從 PATH
列出的路徑中依次查找用戶輸入的命令。考慮到程序的優(yōu)先級最低,如果想優(yōu)先執(zhí)行磁盤上的程序文件 test
呢?那么就可以用 test -P
找出這個文件并執(zhí)行就可以了。
補充:對于 Shell 的內(nèi)置命令,可以通過 help command
的方式獲得幫助,對于程序文件,可以查看用戶手冊(當(dāng)然,這個需要安裝,一般叫做 xxx-doc
), man command
。
|, >, <, &
在命令行上,除了輸入各種命令以及一些參數(shù)外,比如上面 type
命令的各種參數(shù) -a
,-P
等,對于這些參數(shù),是傳遞給程序本身的,非常好處理,比如 if
, else
條件分支或者 switch
,case
都可以處理。當(dāng)然,在 bash
里頭可能使用專門的參數(shù)處理函數(shù) getopt
和 getopt_long
來處理它們。
而 |
, >
, <
, &
等字符,則比較特別, Shell 是怎么處理它們的呢?它們也被傳遞給程序本身嗎?可我們的程序內(nèi)部一般都不處理這些字符的,所以應(yīng)該是 Shell 程序自己解析了它們。
先來看看這幾個字符在命令行的常見用法,
<
字符表示:把 test.c
文件重定向為標準輸入,作為 cat
命令輸入,而 cat
默認輸出到標準輸出:
$ cat < ./test.c
#include <stdio.h>
int main(void)
{
printf("hi, myself!\n");
return 0;
}
>
表示把標準輸出重定向為文件 test_new.c
,結(jié)果內(nèi)容輸出到 test_new.c
:
$ cat < ./test.c > test_new.c
對于 >
, <
, >>
, <<
, <>
我們都稱之為重定向(redirect
), Shell 到底是怎么進行所謂的“重定向”的呢?
這主要歸功于 dup/fcntl
等函數(shù),它們可以實現(xiàn):復(fù)制文件描述符,讓多個文件描述符共享同一個文件表項。比如,當(dāng)把文件 test.c
重定向為標準輸入時。假設(shè)之前用以打開 test.c
的文件描述符是 5 ,現(xiàn)在就把 5 復(fù)制為了 0 ,這樣當(dāng) cat
試圖從標準輸入讀出內(nèi)容時,也就訪問了文件描述符 5 指向的文件表項,接著讀出了文件內(nèi)容。輸出重定向與此類似。其他的重定向,諸如 >>
, <<
, <>
等雖然和 >
, <
的具體實現(xiàn)功能不太一樣,但本質(zhì)是一樣的,都是文件描述符的復(fù)制,只不過可能對文件操作有一些附加的限制,比如 >>
在輸出時追加到文件末尾,而 >
則會從頭開始寫入文件,前者意味著文件的大小會增長,而后者則意味文件被重寫。
那么 |
呢? |
被形象地稱為“管道”,實際上它就是通過 C 語言里頭的無名管道來實現(xiàn)的。先看一個例子,
$ cat < ./test.c | grep hi
printf("hi, myself!\n");
在這個例子中, cat
讀出了 test.c
文件中的內(nèi)容,并輸出到標準輸出上,但是實際上輸出的內(nèi)容卻只有一行,原因是這個標準輸出被“接到”了 grep
命令的標準輸入上,而 grep
命令只打印了包含 “hi” 字符串的一行。
這是怎么被“接”上的。 cat
和 grep
作為兩個單獨的命令,它們本身沒有辦法把兩者的輸入和輸出“接”起來。這正是 Shell 自己的“杰作”,它通過 C 語言里頭的 pipe
函數(shù)創(chuàng)建了一個管道(一個包含兩個文件描述符的整形數(shù)組,一個描述符用于寫入數(shù)據(jù),一個描述符用于讀入數(shù)據(jù)),并且通過 dup/fcntl
把 cat
的輸出復(fù)制到了管道的輸入,而把管道的輸出則復(fù)制到了 grep
的輸入。這真是一個奇妙的想法。
那 &
呢?當(dāng)你在程序的最后跟上這個奇妙的字符以后就可以接著做其他事情了,看看效果:
$ sleep 50 & #讓程序在后臺運行
[1] 8261
提示符被打印出來,可以輸入東西,讓程序到前臺運行,無法輸入東西了,按下 CTRL+Z
,再讓程序到后臺運行:
$ fg %1
sleep 50
[1]+ Stopped sleep 50
實際上 &
正是 Shell
支持作業(yè)控制的表征,通過作業(yè)控制,用戶在命令行上可以同時作幾個事情(把當(dāng)前不做的放到后臺,用 &
或者 CTRL+Z
或者 bg
)并且可以自由地選擇當(dāng)前需要執(zhí)行哪一個(用 fg
調(diào)到前臺)。這在實現(xiàn)時應(yīng)該涉及到很多東西,包括終端會話(session
)、終端信號、前臺進程、后臺進程等。而在命令的后面加上 &
后,該命令將被作為后臺進程執(zhí)行,后臺進程是什么呢?這類進程無法接收用戶發(fā)送給終端的信號(如 SIGHUP
,SIGQUIT
,SIGINT
),無法響應(yīng)鍵盤輸入(被前臺進程占用著),不過可以通過 fg
切換到前臺而享受作為前臺進程具有的特權(quán)。
因此,當(dāng)一個命令被加上 &
執(zhí)行后,Shell 必須讓它具有后臺進程的特征,讓它無法響應(yīng)鍵盤的輸入,無法響應(yīng)終端的信號(意味忽略這些信號),并且比較重要的是新的命令提示符得打印出來,并且讓命令行接口可以繼續(xù)執(zhí)行其他命令,這些就是 Shell 對 &
的執(zhí)行動作。
還有什么神秘的呢?你也可以寫自己的 Shell 了,并且可以讓內(nèi)核啟動后就執(zhí)行它 l
,在 lilo
或者 grub
的啟動參數(shù)上設(shè)置 init=/path/to/your/own/shell/program
就可以。當(dāng)然,也可以把它作為自己的登錄 Shell ,只需要放到 /etc/passwd
文件中相應(yīng)用戶名所在行的最后就可以。不過貌似到現(xiàn)在還沒介紹 Shell 是怎么執(zhí)行程序,是怎樣讓程序變成進程的,所以繼續(xù)。
當(dāng)我們從鍵盤鍵入一串命令,Shell 奇妙地響應(yīng)了,對于內(nèi)置命令和函數(shù),Shell 自身就可以解析了(通過 switch
,case
之類的 C 語言語句)。但是,如果這個命令是磁盤上的一個文件呢。它找到該文件以后,怎么執(zhí)行它的呢?
還是用 strace
來跟蹤一個命令的執(zhí)行過程看看。
$ strace -f -o strace.log /usr/bin/test
hello, world!
$ cat strace.log | sed -ne "1p" #我們對第一行很感興趣
8445 execve("/usr/bin/test", ["/usr/bin/test"], [/* 33 vars */]) = 0
從跟蹤到的結(jié)果的第一行可以看到 bash
通過 execve
調(diào)用了 /usr/bin/test
,并且給它傳了 33 個參數(shù)。這 33 個 vars
是什么呢?看看 declare -x
的結(jié)果(這個結(jié)果只有 32 個,原因是 vars
的最后一個變量需要是一個結(jié)束標志,即 NULL
)。
$ declare -x | wc -l #declare -x聲明的環(huán)境變量將被導(dǎo)出到子進程中
32
$ export TEST="just a test" #為了認證declare -x和之前的vars的個數(shù)的關(guān)系,再加一個
$ declare -x | wc -l
33
$ strace -f -o strace.log /usr/bin/test #再次跟蹤,看看這個關(guān)系
hello, world!
$ cat strace.log | sed -ne "1p"
8523 execve("/usr/bin/test", ["/usr/bin/test"], [/* 34 vars */]) = 0
通過這個演示發(fā)現(xiàn),當(dāng)前 Shell 的環(huán)境變量中被設(shè)置為 export
的變量被復(fù)制到了新的程序里頭。不過雖然我們認為 Shell 執(zhí)行新程序時是在一個新的進程里頭執(zhí)行的,但是 strace
并沒有跟蹤到諸如 fork
的系統(tǒng)調(diào)用(可能是 strace
自己設(shè)計的時候并沒有跟蹤 fork
,或者是在 fork
之后才跟蹤)。但是有一個事實我們不得不承認:當(dāng)前 Shell 并沒有被新程序的進程替換,所以說 Shell 肯定是先調(diào)用 fork
(也有可能是 vfork
)創(chuàng)建了一個子進程,然后再調(diào)用 execve
執(zhí)行新程序的。如果你還不相信,那么直接通過 exec
執(zhí)行新程序看看,這個可是直接把當(dāng)前 Shell 的進程替換掉的。
exec /usr/bin/test
該可以看到當(dāng)前 Shell “嘩”(聽不到,突然沒了而已)的一下就沒有了。
下面來模擬一下 Shell 執(zhí)行普通程序。 multiprocess
相當(dāng)于當(dāng)前 Shell ,而 /usr/bin/test
則相當(dāng)于通過命令行傳遞給 Shell 的一個程序。這里是代碼:
/* multiprocess.c */
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h> /* sleep, fork, _exit */
int main()
{
int child;
int status;
if( (child = fork()) == 0) { /* child */
printf("child: my pid is %d\n", getpid());
printf("child: my parent's pid is %d\n", getppid());
execlp("/usr/bin/test","/usr/bin/test",(char *)NULL);;
} else if(child < 0){ /* error */
printf("create child process error!\n");
_exit(0);
} /* parent */
printf("parent: my pid is %d\n", getpid());
if ( wait(&status) == child ) {
printf("parent: wait for my child exit successfully!\n");
}
}
運行看看,
$ make multiprocess
$ ./multiprocess
child: my pid is 2251
child: my parent's pid is 2250
hello, world!
parent: my pid is 2250
parent: wait for my child exit successfully!
從執(zhí)行結(jié)果可以看出,/usr/bin/test
在 multiprocess
的子進程中運行并不干擾父進程,因為父進程一直等到了 /usr/bin/test
執(zhí)行完成。
再回頭看看代碼,你會發(fā)現(xiàn) execlp
并沒有傳遞任何環(huán)境變量信息給 /usr/bin/test
,到底是怎么把環(huán)境變量傳送過去的呢?通過 man exec
我們可以看到一組 exec
的調(diào)用,在里頭并沒有發(fā)現(xiàn) execve
,但是通過 man execve
可以看到該系統(tǒng)調(diào)用。實際上 exec
的那一組調(diào)用都只是 libc
庫提供的,而 execve
才是真正的系統(tǒng)調(diào)用,也就是說無論使用 exec
調(diào)用中的哪一個,最終調(diào)用的都是 execve
,如果使用 execlp
,那么 execlp
將通過一定的處理把參數(shù)轉(zhuǎn)換為 execve
的參數(shù)。因此,雖然我們沒有傳遞任何環(huán)境變量給 execlp
,但是默認情況下,execlp
把父進程的環(huán)境變量復(fù)制給了子進程,而這個動作是在 execlp
函數(shù)內(nèi)部完成的。
現(xiàn)在,總結(jié)一下 execve
,它有有三個參數(shù),
-
第一個是程序本身的絕對路徑,對于剛才使用的 execlp
,我們沒有指定路徑,這意味著它會設(shè)法到 PATH
環(huán)境變量指定的路徑下去尋找程序的全路徑。 -
第二個參數(shù)是一個將傳遞給被它執(zhí)行的程序的參數(shù)數(shù)組指針。正是這個參數(shù)把我們從命令行上輸入的那些參數(shù),諸如 grep
命令的 -v
等傳遞給了新程序,可以通過 main
函數(shù)的第二個參數(shù) char
argv[]
獲得這些內(nèi)容。 -
第三個參數(shù)是一個將傳遞給被它執(zhí)行的程序的環(huán)境變量,這些環(huán)境變量也可以通過 main
函數(shù)的第三個變量獲取,只要定義一個 char
env[]
就可以了,只是通常不直接用它罷了,而是通過另外的方式,通過 extern char
** environ
全局變量(環(huán)境變量表的指針)或者 getenv
函數(shù)來獲取某個環(huán)境變量的值。
當(dāng)然,實際上,當(dāng)程序被 execve
執(zhí)行后,它被加載到了內(nèi)存里,包括程序的各種指令、數(shù)據(jù)以及傳遞給它的各種參數(shù)、環(huán)境變量等都被存放在系統(tǒng)分配給該程序的內(nèi)存空間中。
我們可以通過 /proc/<pid>/maps
把一個程序?qū)?yīng)的進程的內(nèi)存映象看個大概。
$ cat /proc/self/maps #查看cat程序自身加載后對應(yīng)進程的內(nèi)存映像
08048000-0804c000 r-xp 00000000 03:01 273716 /bin/cat
0804c000-0804d000 rw-p 00003000 03:01 273716 /bin/cat
0804d000-0806e000 rw-p 0804d000 00:00 0 [heap]
b7c46000-b7e46000 r--p 00000000 03:01 87528 /usr/lib/locale/locale-archive
b7e46000-b7e47000 rw-p b7e46000 00:00 0
b7e47000-b7f83000 r-xp 00000000 03:01 466875 /lib/libc-2.5.so
b7f83000-b7f84000 r--p 0013c000 03:01 466875 /lib/libc-2.5.so
b7f84000-b7f86000 rw-p 0013d000 03:01 466875 /lib/libc-2.5.so
b7f86000-b7f8a000 rw-p b7f86000 00:00 0
b7fa1000-b7fbc000 r-xp 00000000 03:01 402817 /lib/ld-2.5.so
b7fbc000-b7fbe000 rw-p 0001b000 03:01 402817 /lib/ld-2.5.so
bfcdf000-bfcf4000 rw-p bfcdf000 00:00 0 [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]
關(guān)于程序加載和進程內(nèi)存映像的更多細節(jié)請參考《C 語言程序緩沖區(qū)注入分析》。
到這里,關(guān)于命令行的秘密都被“曝光”了,可以開始寫自己的命令行解釋程序了。
關(guān)于進程的相關(guān)操作請參考《進程與進程的基本操作》。
補充:上面沒有討論到一個比較重要的內(nèi)容,那就是即使 execve
找到了某個可執(zhí)行文件,如果該文件屬主沒有運行該程序的權(quán)限,那么也沒有辦法運行程序。可通過 ls -l
查看程序的權(quán)限,通過 chmod
添加或者去掉可執(zhí)行權(quán)限。
文件屬主具有可執(zhí)行權(quán)限時才可以執(zhí)行某個程序:
$ whoami
falcon
$ ls -l hello #查看用戶權(quán)限(第一個x表示屬主對該程序具有可執(zhí)行權(quán)限
-rwxr-xr-x 1 falcon users 6383 2000-01-23 07:59 hello*
$ ./hello
Hello World
$ chmod -x hello #去掉屬主的可執(zhí)行權(quán)限
$ ls -l hello
-rw-r--r-- 1 falcon users 6383 2000-01-23 07:59 hello
$ ./hello
-bash: ./hello: Permission denied
man boot-scripts
man bootparam
man 5 passwd
man shadow
更多建議: