程序執(zhí)行的一剎那

2018-02-24 15:41 更新

程序執(zhí)行的一剎那

當(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é)?

/bin/bash 是什么時(shí)候啟動(dòng)的

/bin/login

先通過 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/getty

/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)程空間替換掉。

/sbin/init

這里涉及到一個(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 程序,比如 upstartsystemd 被開發(fā)出來用于取代 System V init,它們可能放棄了對(duì) /etc/inittab 的使用,例如 upstart 會(huì)讀取 /etc/init/ 下的配置,比如 /etc/init/tty1.conf,但是,基本的配置思路還是類似 /etc/inittab,對(duì)于 upstartinit 配置,這里不做介紹,請(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 了,而 06 非常特別,除了用它作為 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 即可。

  • 另外一個(gè)有趣的問題就是:正是 init 程序在讀取這個(gè)配置行以后啟動(dòng)了 /sbin/agetty,這就是 /sbin/agetty 的秘密。
  • 還有一個(gè)問題:無論退出哪個(gè)終端,那個(gè) "XXX login:" 總是會(huì)被打印,原因是 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,即我們的命令行程序。

命令啟動(dòng)過程追本溯源

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:"。

通過 pspstree 命令看看實(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)的呢?誰才是真正的“造物主”?

誰啟動(dòng)了 /sbin/init

如果用過 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)的,LiloGrub 都有相應(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)起來。

LiloGrub 本身又是怎么被運(yùn)行起來的呢?有了解 MBR 不?MBR 就是主引導(dǎo)扇區(qū),一般情況下這里存放著 LiloGrub 的代碼,而誰知道正好是這里存放了它們呢?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)求的呢?

/bin/bash 如何處理用戶鍵入的命令

預(yù)備知識(shí)

在執(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)從終端輸入。

哪種命令先被執(zhí)行

在 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 條件分支或者 switchcase 都可以處理。當(dāng)然,在 bash 里頭可能使用專門的參數(shù)處理函數(shù) getoptgetopt_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” 字符串的一行。

這是怎么被“接”上的。 catgrep 作為兩個(gè)單獨(dú)的命令,它們本身沒有辦法把兩者的輸入和輸出“接”起來。這正是 Shell 自己的“杰作”,它通過 C 語言里頭的 pipe 函數(shù)創(chuàng)建了一個(gè)管道(一個(gè)包含兩個(gè)文件描述符的整形數(shù)組,一個(gè)描述符用于寫入數(shù)據(jù),一個(gè)描述符用于讀入數(shù)據(jù)),并且通過 dup/fcntlcat 的輸出復(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ù)。

/bin/bash 用什么魔法讓一個(gè)普通程序變成了進(jìn)程

當(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/testmultiprocess 的子進(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ù) charargv[] 獲得這些內(nèi)容。 - 第三個(gè)參數(shù)是一個(gè)將傳遞給被它執(zhí)行的程序的環(huán)境變量,這些環(huán)境變量也可以通過 main 函數(shù)的第三個(gè)變量獲取,只要定義一個(gè) charenv[] 就可以了,只是通常不直接用它罷了,而是通過另外的方式,通過 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

參考資料

  • Linux 啟動(dòng)過程:man boot-scripts
  • Linux 內(nèi)核啟動(dòng)參數(shù):man bootparam
  • man 5 passwd
  • man shadow
  • 《UNIX 環(huán)境高級(jí)編程》,進(jìn)程關(guān)系一章
以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)