1. 程式人生 > >UNIX管道應用及Shell實現(一)-主體框架

UNIX管道應用及Shell實現(一)-主體框架


作業系統的第一個大作業是做一個簡單的Shell,實現重定向、管道等功能。奮戰了好幾天終於基本搞定了= =

基本要求

Shell能夠解析的命令列法如下:

  1. 帶引數的程式執行功能。

    program arg1 arg2 … argN

  2. 重定向功能,將檔案作為程式的輸入/輸出。
    a)“>”表示覆蓋寫

    program arg1 arg2 … argN > output-file

    b)“>>”表示追加寫

    program arg1 arg2 … argN >> output-file

    c)“<”表示檔案輸入

    program arg1 arg2 … argN < input-file

  3. 管道符號“|”,在程式間傳遞資料(最後也可用重定向符號)

    programA arg1 … argN | programB arg1 … argN | programC …

  4. 後臺符號& ,表示此命令將以後臺執行的方式執行

    program arg1 arg2 … argN &

  5. 工作路徑移動命令cd

  6. Shell退出命令exit

  7. history顯示開始任務後執行的命令;history n顯示最近執行的n條指令

基本思路

很明顯本次實驗主要是以考察Shell基本功能以及管道的實現為主。之前已經詳細講解了管道的實現,可以參考這篇文章

熟悉命令

首先我們先在UNIX自帶的Shell下實現重定向和管道功能,示例命令可以參考如下:

# ps &
# cat numbers.txt | sort > temp.txt
# sort < numbers.txt | grep 1 > a.txt
# ps -ef | grep -sh
# cd ..

我們不難發現:
1. “>”,“>>”重定向命令只能在命令中出現一次,一旦出現後,之後還有什麼命令也是無效的。
2. “<”命令也只能出現一次,但是後面可以接管道命令。
2. “|”管道命令可以出現多次,且管道之後還可以使用重定向符號。
3. 實際上所有命令進入程式之後都是一串字串,因此對字串的解析是最重要的。
4. 對於ps

lscd等命令,可以使用exceve命令進行操作,並不需要我們自己實現。
5. 如果注意,可以發現系統Shell在實現後臺程序時,可能會出現如下情況:
p1

我們讓ls的結果在後臺執行,但為什麼會在結果前多一個“#”呢?
原因是因為後臺執行的子程序和前臺執行的父程序同時進行,誰先誰後不能確定,圖中就是父程序先執行,列印了“#”,子程序再列印ls的結果,因此出現了這種情況。

難點

  1. 管道的實現以及fork()的使用。
  2. 子父程序進行訊號互動,以及回收殭屍程序。
  3. 多檔案的協調和編譯。

大體框架

主函式入口

由於我們在Windows下寫這個Shell無法編譯,每次必須在UNIX下編譯,因此必須在寫之前就想好模組佈局,不然很難debug和進行單元測試。
一個Shell其實就是一個while(1)的死迴圈,每次輸出提示符到螢幕,然後執行輸入的字串命令。因此不難寫出大體框架:

    int main() {
    /*Command line*/
    while (1) {

        printf("cmd >");
        /*set buf to empty*/
        memset(buf, 0, sizeof(buf));
        /*Read from keyboard*/
        fgets(buf, MAXLINE, stdin);

        /*The function feof() tests the end-of-file indicator
        for the stream pointed to by stream,
        returning non-zero if it is set. */
        if (feof(stdin))
            exit(0);
        /*update the command history*/
        UpdateHistory();
        /*command exceve*/
        command();
    }
    return 0;
}

主程式的確很簡單,就是每次用buf讀取輸入的字串,然後更新輸入列表(為了 history功能的實現),然後再解析命令(command)即可。

字串命令儲存方式

Shell主要就是對得到的命令進行操作,因此命令如何儲存是至關重要的。最簡單的想法就是用一個char*[]字串陣列儲存,但是我們後面對命令解析需要 命令的下標等其他資訊,因此這裡選擇用struct進行儲存更為方便。
定義結構體如下:

struct CommandInfomation {
    char* argv[512]; /*store the command after Parsing*/
    int argc;        /*the number of argv,split with space*/
    int index;       /*store the index of special character*/
    int background;  /*whether it is a background command*/
    enum specify type[50];
    int override; /* in case after < command has muti pipes */
    char* file;
};

初始化函式為:

void initStruct(struct CommandInfomation* a) {
    a->argc = 0;
    a->index = 0;
    a->background = 0;
    a->override = 0;
    a->file = NULL;
    memset(a->type, 0, sizeof(a->type));
}

特殊字元命令

對於重定向”>”,管道”|”等特殊命令,我們需要使用額外的標識來註明,方便後面的操作。這裡使用eunm實現。

/*the enum stand for different command*/
enum specify {NORMAL, OUT_REDIRECT, IN_REDIRECT, OUT_ADD, PIPE};

主要函式詳解

pipe(fd2)

此函式用於實現無名管道,將fd2陣列中的兩個檔案描述符分別標記為管道讀(fd[0])和管道寫(fd[1])。

dup(fd)

為複製檔案操作符的系統函式,可以定向目前未被使用的最小檔案操作符到fd所指的檔案。相類似的函式還有dup2[fd1,fd2],意思是 未被使用的檔案描述符fd2
作為fd1的副本,進過此函式後,fd1fd2都可訪問同一個檔案。

execlp(const char *file, const char *arg, …)

屬於exec()函式族,會從PATH環境變數所指的目錄中查詢符合引數file的檔名,找到後便執行該檔案,然後將第二個以後的引數當做該檔案的argv[0]、argv1……,最後一個引數必須用空指標(NULL)作結束。
命令中的lsps等內建系統命令都可以由此函式進行解析。要注意,此函式一經呼叫就不會再返回。

chdir(const char * path)

改變當前的工作路徑以引數path所指的目錄,使用比較簡單,支援常用的改變路徑的方式,例如退回上一級:cd .. ,也支援絕對路徑。

執行命令

由主函式可知,我們得到了命令需要進行解析,由於我們知道exceve函式一旦呼叫就不會返回,因此要使用fork()函式對其子程序進行處理。
這裡需要注意的是,由於子程序一定要比父程序先結束,因此我們需要將執行的命令放到子程序中,父程序進行等待或者執行後面的命令,否則會出現父程序結束子程序還在執行的錯誤。

父子程序進行通訊

需要注意的是,Shell支援後臺程式執行,因此,父程序不一定要等待子程序執行結束才做後面的事情,但這就涉及到子程序結束後,父程序需要回收殭屍程序。那麼,如何做到這一點呢?

Linux上進行訊號遮蔽

在Linux系統上,我們可以使用signal(int signum, sighandler_t handler)函式來設定某一類的訊號處理或者遮蔽。我們知道,子程序要exit()之前,會發送SIGCHLD訊號給父程序,提醒父程序來回收子程序的退出狀態和其他資訊。
在這裡,我們可以使用一個特殊的技巧:

signal(SIGCHLD, SIG_IGN)

這裡是讓父程序遮蔽子程序的訊號,為什麼這樣就可以做到回收殭屍程序的作用呢?原來是因為在Linux中,當我們忽略SIGCHLD訊號時,核心將把殭屍程序交由init程序去處理,能夠省去大量殭屍程序佔用系統資源。因此,遮蔽了子訊號後,子程式在要結束時傳送訊號沒人應答,核心就會認為這是一個孤兒程序,因此被init程序去回收,可以很好的解決我們面臨的問題。

BSD系統上的訊號處理

而筆者使用的是Minix3.3的系統,經過實測,核心並不會在父程序遮蔽訊號後主動回收孤兒程序,因此不能使用這種方法。
那怎麼辦呢?因此只能自己寫一個handler,規定父程序在收到子程序結束的訊號後再wait,這樣也可以實現此功能。但缺點就是wait函式需要阻塞父程序直到子程序結束為止,對於併發要求較高的併發伺服器,可能就不是很適用。
我們使用這種方法完成後臺程式的執行:

    void SIG_IGN_handler(int sig)
    {
        waitpid(-1, NULL, 0);
        return;
    }

在主程式中install這個handler:

signal(SIGCHLD, SIG_IGN_handler);

這樣就完成了後臺程序的功能。

history功能實現

查詢前n個命令是比較簡單的功能,我們可以使用佇列進行實現,在這裡筆者就稍微偷懶一點,直接使用定長的字串陣列進行。

/*update command history*/
void UpdateHistory()
{
    char *temp;
    if (strcmp(buf, "\n") == 0)
        return;
    if (HistoryIndex > MAXLINE)
        HistoryIndex = 0;
    temp = (char *)malloc(sizeof(buf));
    strcpy(temp, buf);
    CommandHistory[HistoryIndex++] = temp;
    return;
}


/*print the command with n lines*/
void PrintCommand(int n)
{
    int i,j=0;
    if (n == -1) {
        for (i = 0 ; i < HistoryIndex; i++)
            printf("the %d command: %s\n", i, CommandHistory[i]);
    }
    else {
        if (n > HistoryIndex) {
            printf("Warning: the argument is too large.\n");
            return;
        }
        for (i = HistoryIndex - n; i < HistoryIndex; i++)
            printf("the %d command: %s\n", ++j, CommandHistory[i]);
    }
}

參考資料

  1. Operating System:Design and Implementation,Third Edition
  2. Computer Systems: A Programmer’s Perspective, 3/E

歡迎關注我的個人部落格