第45回 | 解析并執(zhí)行 shell 命令
新讀者看這里,老讀者直接跳過。
本系列會以一個讀小說的心態(tài),從開機啟動后的代碼執(zhí)行順序,帶著大家閱讀和賞析 Linux 0.11 全部核心代碼,了解操作系統(tǒng)的技術細節(jié)和設計思想。
本系列的 GitHub 地址如下,希望給個 star 以示鼓勵(文末閱讀原文可直接跳轉,也可以將下面的鏈接復制到瀏覽器里打開)
https://github.com/sunym1993/flash-linux0.11-talk
本回的內容屬于第五部分。

你會跟著我一起,看著一個操作系統(tǒng)從啥都沒有開始,一步一步最終實現(xiàn)它復雜又精巧的設計,讀完這個系列后希望你能發(fā)出感嘆,原來操作系統(tǒng)源碼就是這破玩意。
以下是已發(fā)布文章的列表,詳細了解本系列可以先從開篇詞看起。
第一部分 進入內核前的苦力活
第二部分 大戰(zhàn)前期的初始化工作
第三部分 一個新進程的誕生
第45回 | 解析并執(zhí)行 shell 命令(本文)
------- 正文開始 -------
新建一個非常簡單的 info.txt 文件。
name:flash
age:28
language:java在命令行輸入一條十分簡單的命令。
[[email protected]] cat info.txt | wc -l
3
這條命令的意思是讀取剛剛的 info.txt 文件,輸出它的行數(shù)。
在上一回中,我們講述了進程在讀取你的命令字符串時,可能經歷的進程的阻塞與喚醒,也即 Linux 0.11 中的 sleep_on 與 wake_up 函數(shù)。

接下來,shell 程序就該解析并執(zhí)行這條命令了。
// xv6-public sh.c
int main(void) {
static char buf[100];
// 讀取命令
while(getcmd(buf, sizeof(buf)) >= 0){
// 創(chuàng)建新進程
if(fork() == 0)
// 執(zhí)行命令
runcmd(parsecmd(buf));
// 等待進程退出
wait();
}
}
也就是上述函數(shù)中的 runcmd 命令。
首先 parsecmd 函數(shù)會將讀取到 buf 的字符串命令做解析,生成一個 cmd 結構的變量,傳入 runcmd 函數(shù)中。
// xv6-public sh.c
void runcmd(struct cmd *cmd) {
...
switch(cmd->type) {
...
case EXEC:
ecmd = (struct execcmd*)cmd;
...
exec(ecmd->argv[0], ecmd->argv);
...
break;
case REDIR: ...
case LIST: ...
case PIPE: ...
case BACK: ...
}
}
然后就如上述代碼所示,根據 cmd 的 type 字段,來判斷應該如何執(zhí)行這個命令。
比如最簡單的,就是直接執(zhí)行,也即 EXEC。
如果命令中有分號 ; 說明是多條命令的組合,那么就當作 LIST 拆分成多條命令依次執(zhí)行。
如果命令中有豎線 | 說明是管道命令,那么就當作 PIPE 拆分成兩個并發(fā)的命令,同時通過管道串聯(lián)起輸入端和輸出端,來執(zhí)行。
我們這個命令,很顯然就是個管道命令。
[root@linux0.11] cat info.txt | wc -l
管道理解起來非常簡單,但是實現(xiàn)細節(jié)卻是略微復雜。
所謂管道,也就是上述命令中的 |,實現(xiàn)的就是將 | 左邊的程序的輸出(stdout)作為 | 右邊的程序的輸入(stdin),就這么簡單。
那我們看看,它是如何實現(xiàn)的,我們走到 runcmd 方法中的 PIPE 這個分支里,也就是當解析出輸入的命令是一個管道命令時,所應該做的處理。
// xv6-public sh.c
void runcmd(struct cmd *cmd) {
...
int p[2];
...
case PIPE:
pcmd = (struct pipecmd*)cmd;
pipe(p);
if(fork() == 0) {
close(1);
dup(p[1]);
close(p[0]);
close(p[1]);
runcmd(pcmd->left);
}
if(fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right);
}
close(p[0]);
close(p[1]);
wait(0);
wait(0);
break;
...
}
首先,我們構造了一個大小為 2 的數(shù)組 p,然后作為 pipe 的參數(shù)傳了進去。
這個 pipe 函數(shù),最終會調用到系統(tǒng)調用的 sys_pipe,我們先不看代碼,通過 man page 查看 pipe 的用法與說明。

可以看到,pipe 就是創(chuàng)建一個管道,將傳入數(shù)組 p 的 p[0] 指向這個管道的讀口,p[1] 指向這個管道的寫口,畫圖就是這樣子的。

當然,這個管道的本質是一個文件,但是是屬于管道類型的文件,所以它的本質的本質實際上是一塊內存。
這塊內存被當作管道文件對上層提供了像訪問文件一樣的讀寫接口,只不過其中一個進程只能讀,另一個進程只能寫,所以再次抽象一下就像一個管道一樣,數(shù)據從一端流向了另一段。
你說它是內存也行,說它是文件也行,說它是管道也行,看你抽象到那一層了,這個之后再展開細講,先讓你迷糊迷糊。
回過頭看程序。
// xv6-public sh.c
void runcmd(struct cmd *cmd) {
...
int p[2];
...
case PIPE:
pcmd = (struct pipecmd*)cmd;
pipe(p);
if(fork() == 0) {
close(1);
dup(p[1]);
close(p[0]);
close(p[1]);
runcmd(pcmd->left);
}
if(fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right);
}
close(p[0]);
close(p[1]);
wait(0);
wait(0);
break;
...
}
在調用完 pipe 搞出了這樣一個管道并綁定了 p[0] 和 p[1] 之后,又分別通過 fork 創(chuàng)建了兩個進程,其中第一個進程執(zhí)行了管道左邊的程序,第二個進程執(zhí)行了管道右邊的程序。
由于 fork 出的子進程會原封不動復制父進程打開的文件描述符,所以目前的狀況如下圖所示。

當然,由于每個進程,一開始都打開了 0 號標準輸入文件描述符,1 號標準輸出文件描述符和 2 號標準錯誤輸出文件描述符,所以目前把文件描述符都展開就是這個樣子。(父進程的我就省略了)

現(xiàn)在這個線條很亂,不過沒關系,看代碼。左邊進程隨后進行了如下操作。
// fs/pipe.c
...
if(fork() == 0) {
close(1);
dup(p[1]);
close(p[0]);
close(p[1]);
runcmd(pcmd->left);
}
...
即關閉(close)了 1 號標準輸出文件描述符,復制(dup)了 p[1] 并填充在了 1 號文件描述符上(因為剛剛關閉后空缺出來了),然后又把 p[0] 和 p[1] 都關閉(close)了。
你再讀讀這段話,最終的效果就是,將 1 號文件描述符,也就是標準輸出,指向了 p[1] 管道的寫口,也就是 p[1] 原來所指向的地方。

同理,右邊進程也進行了類似的操作。
// fs/pipe.c
...
if(fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right);
}
...
只不過,最終是將 0 號標準輸入指向了管道的讀口。

這是兩個子進程的操作,再看父進程。
// xv6-public sh.c
void runcmd(struct cmd *cmd) {
...
pipe(p);
if(fork() == 0) {...}
if(fork() == 0) {...}
// 父進程
close(p[0]);
close(p[1]);
...
}
你沒有看錯,父進程僅僅是將 p[0] 和 p[1] 都關閉掉了,也就是說,父進程執(zhí)行的 pipe,僅僅是為兩個子進程申請的文件描述符,對于自己來說并沒有用處。
那么我們忽略父進程,最終,其實就是創(chuàng)建了兩個進程,左邊的進程的標準輸出指向了管道(寫),右邊的進程的標準輸入指向了同一個管道(讀),看起來就是下面的樣子。

而管道的本質就是一個文件,只不過是管道類型的文件,再本質就是一塊內存。所以這一頓操作,其實就是把兩個進程的文件描述符,指向了一個文件罷了,就這么點事情。
那么此時,再讓我們看看 sys_pipe 函數(shù)的細節(jié)。
// fs/pipe.c
int sys_pipe(unsigned long * fildes) {
struct m_inode * inode;
struct file * f[2];
int fd[2];
for(int i=0,j=0; j<2 && i<NR_FILE; i++)
if (!file_table[i].f_count)
(f[j++]=i+file_table)->f_count++;
...
for(int i=0,j=0; j<2 && i<NR_OPEN; i++)
if (!current->filp[i]) {
current->filp[ fd[j]=i ] = f[j];
j++;
}
...
if (!(inode=get_pipe_inode())) {
current->filp[fd[0]] = current->filp[fd[1]] = NULL;
f[0]->f_count = f[1]->f_count = 0;
return -1;
}
f[0]->f_inode = f[1]->f_inode = inode;
f[0]->f_pos = f[1]->f_pos = 0;
f[0]->f_mode = 1; /* read */
f[1]->f_mode = 2; /* write */
put_fs_long(fd[0],0+fildes);
put_fs_long(fd[1],1+fildes);
return 0;
}
不出我們所料,和進程打開一個文件的步驟是差不多的,可以回顧下 第33回 | 打開終端設備文件 這一回,下圖是進程打開一個文件時的步驟。

而 pipe 方法與之相同的是,都是從進程中的文件描述符表 filp 數(shù)組和系統(tǒng)的文件系統(tǒng)表 file_table 數(shù)組中尋找空閑項并綁定。
不同的是,打開一個文件的前提是文件已經存在了,根據文件名找到這個文件,并提取出它的 inode 信息,填充好 file 數(shù)據。
而 pipe 方法中并不是打開一個已存在的文件,而是創(chuàng)建一個新的管道類型的文件,具體是通過 get_pipe_inode 方法,返回一個 inode 結構。然后,填充了兩個 file 結構的數(shù)據,都指向了這個 inode,其中一個的 f_mode 為 1 也就是寫,另一個是 2 也就是讀。(f_mode 為文件的操作模式屬性,也就是 RW 位的值)
創(chuàng)建管道的方法 get_pipe_inode 方法如下。
// fs.h
#define PIPE_HEAD(inode) ((inode).i_zone[0])
#define PIPE_TAIL(inode) ((inode).i_zone[1])
// inode.c
struct m_inode * get_pipe_inode(void) {
struct m_inode *inode = get_empty_inode();
inode->i_size=get_free_page();
inode->i_count = 2; /* sum of readers/writers */
PIPE_HEAD(*inode) = PIPE_TAIL(*inode) = 0;
inode->i_pipe = 1;
return inode;
}
可以看出,正常文件的 inode 中的 i_size 表示文件大小,而管道類型文件的 i_size 表示供管道使用的這一頁內存的起始地址。
OK,管道的原理在這里就說完了,最終我們就是實現(xiàn)了一個進程的輸出指向了另一個進程的輸入。

回到最開始的 runcmd 方法。
// xv6-public sh.c
void runcmd(struct cmd *cmd) {
...
switch(cmd->type) {
...
case EXEC:
ecmd = (struct execcmd*)cmd;
...
exec(ecmd->argv[0], ecmd->argv);
...
break;
case REDIR: ...
case LIST: ...
case PIPE: ...
case BACK: ...
}
}
如果展開每個 switch 分支你會發(fā)現(xiàn),不論是更換當前目錄的 REDIR 也就是 cd 命令,還是用分號分隔開的 LIST 命令,還是我們上面講到的 PIPE 命令,最終都會被拆解成一個個可以被解析為 EXEC 類型的命令。
EXEC 類型會執(zhí)行到 exec 這個方法,在 Linux 0.11 中,最終會通過系統(tǒng)調用執(zhí)行到 sys_execve 方法。
這個方法就是最終加載并執(zhí)行具體程序的過程,在 第35回 | execve 加載并執(zhí)行 shell 程序 和 第36回 | 缺頁中斷,我們已經講過如何通過 execve 加載并執(zhí)行 shell 程序了,并且在加載 shell 程序時,并不會立即將磁盤中的數(shù)據加載到內存,而是會在真正執(zhí)行 shell 程序時,引發(fā)缺頁中斷,從而按需將磁盤中的數(shù)據加載到內存。
這個流程在本回我們就不再贅述了,不過當初在講這塊流程以及其它需要將數(shù)據從硬盤加載到內存的邏輯時,總是跳過這一步的細節(jié)。
那么我們下一回,就徹底把這個硬盤到內存的流程拆開了講解!
欲知后事如何,且聽下回分解。
------- 關于本系列 -------
本系列的開篇詞看這,開篇詞
本系列的番外故事看這,讓我們一起來寫本書?也可以直接無腦加入星球,共同參與這場旅行。
最后,本系列完全免費,希望大家能多多傳播給同樣喜歡的人,同時給我的 GitHub 項目點個 star,就在閱讀原文處,這些就足夠讓我堅持寫下去了!我們下回見。
