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