實現流程介紹

Shell是命令行解釋器的一種,它的核心職責是作為用户與操作系統內核交互的中介,實現“用户指令接收→解析指令語法→調用內核執行→結果反饋”。

透過這些"高深"的終端命令交互,我們一定要"看清"shell實現的本質——所謂的shell就是一個進程,它能夠識別用户輸入,做分析,通過"進程控制管理與系統調用實現指定功能。

這篇博客,將從以下幾個模塊,帶你徹底拆解Shell的底層工作流程,用實戰視角還原簡易Shell的實現邏輯。

  • 1.獲取指令
  • 2.解析指令
  • 3.執行指令
  • 普通命令執行
  • 內建命令的處理
  • 4.重定向處理

注:文段內代碼只展現某個模塊的實現思路,完整代碼放在總結中。

一.獲取指令

實現命令行提示符以及用户輸入的獲取工作。

命令行是用户通過文本指令與操作系統進行交互的界面

命令行包括命令行提示符與用户輸入的指令兩部分,它是我們能夠與OS交互的基礎,所以我們實現Shell的第一步要模擬命令行的實現。

1.命令行提示符

[Linux]命令行解釋器為什麼能執行命令?百行代碼實現Shell_重定向

命名提示符:在每次啓動時輸出關於"用户名,主機名,工作目錄等信息"的字符串。

2.用户輸入命令

而命令,本身也是字符串,由用户輸入。後續只需讓程序分析字符串,調系統調用啓動命令的可執行文件,將結果反饋給用户。

[Linux]命令行解釋器為什麼能執行命令?百行代碼實現Shell_Linux_02

3.實現Interact

// 獲取用户名
const char *Getusername()
{
    return getenv("USER");
}
// 獲取主機名
const char *Gethostname()
{
    return getenv("HOSTNAME");
}
void Getpwd()
{
    getcwd(pwd, sizeof(pwd));//由於工作目錄可能變化較大,所以用getcwd讀取
}

void Interact(char *cline, int size)
{
    Getpwd();
    printf(LEFT "%s@%s %s" RIGHT "" LABLE, Getusername(), Gethostname(), pwd);
    char *s = fgets(cline, size, stdin); // 不用scanf,否則到空格處停止ls -a -l只能讀到ls
    assert(s != NULL);
    (void)s; // 避免只定義不使用引起warning

    cline[strlen(cline) - 1] = '\0';
}

[Linux]命令行解釋器為什麼能執行命令?百行代碼實現Shell_Linux_03

二.解析指令

識別用户輸入,將命令的"每一部分"提取出來。

對於字符串 "ls -a -l",需要將其分割為多個子串 "ls" "-a" "-l",我們根據子串去判斷應該執行什麼命令 —— 也很簡單,根據“ ”分割,將分割出的子串用char*數組儲存。

函數strtok

以空格為分界,分割用户輸入命令,並將分割好的子串放到字符串指針數組中。

[Linux]命令行解釋器為什麼能執行命令?百行代碼實現Shell_C_04

int splitstring(char *cline, char *_argv[])
{
    int i = 0;
    _argv[i++] = strtok(cline, DELIM);
    while (_argv[i++] = strtok(NULL, DELIM)); // 有就截,直到空串,s數組寫入NULL
    return i - 1; // 一共有幾個命令行參數
}

[Linux]命令行解釋器為什麼能執行命令?百行代碼實現Shell_命令行解釋器_05

三.執行指令

找到對應可執行程序(進程替換)或執行代碼。

普通命令

對於Linux下普通命令(ls、mv等),系統已存在相應的可執行文件,我們輸入命令就是要運行這些可執行文件。進程控制一文中已經介紹到,普通命令是通過子進程來執行的,在用命令對應的文件替換掉我們的子進程;通過進程替換 exec系列函數 實現。

void NormalExcute(char* _argv[])
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return;
    }
    else if (id == 0)
    {
        // child - 讓子進程執行命令
        execvp(_argv[0], _argv);
          //_argv中是已經被分割好的命令,用execvp調用系統可執行程序,執行命令
        exit(EXIT_CODE);
    }
    else
    {
        // parent
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if (rid == id)
        {
            lastcode = WEXITSTATUS(status); // 從wait()返回的狀態碼中,提取子進程的退出狀態
            //status是複合狀態碼
        }
    }
}

內建命令

當然還有一部分命令,比如cd /,這一類命令要更改當前shell的內部狀態(目錄,環境變量,別名等),而子進程是無法影響父進程的。所以作對於內建命令而言,不能通過子進程執行,當然也不能在父進程內直接調用其他程序;所以這些命令必須由shell自己實現:在shell程序內部直接調用函數去完成命令。

函數中,我們用if、else if.....列舉不同命令,對用户可能輸入的每種命令分析處理。

//內建命令處理只是不創建子進程,需要在當前程序中調用系統調用實現,不能用程序替換直接處理。
//我們寫一個函數,用strcmp對argv[0]和內建命令比較--特殊處理
int BuiltCommand(char *_argv[], int _argc)
{
    // 1.cd命令
    if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
    {
        chdir(_argv[1]);
        // 更改環境變量PWD
        Getpwd();
        sprintf(getenv("PWD"), "%s", pwd);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"export") == 0)
    {
        // putenv((char*)_argv[1]);//× - 導入環境只是將字符串地址寫進環境變量表裏(char* env[])
        //這樣只是讓環境變量表的一個指針指向_argv[1]那個字符串,當下次使用指針數組的內容就被我們改了
        
        //所以我們可以在全局創建數組 myenv - 具體看完整代碼
        //這隻舉例導入一個環境變量
        //想導入多個用二維數組即可
        strcpy(myenv,_argv[1]);//malloc也行
        putenv(myenv);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"echo")==0)
    {
        if(*_argv[1] == '$')
        {
            char *val = getenv(_argv[1]+1);
            if(val !=  NULL) printf("%s\n",val);
        }
        else
        {
            printf("%s\n",_argv[1]);
        }
        return 1;
    }
    //......

    return 0;
}

常見的內建命令

  • cd 切換目錄
  • pwd 顯示當前工作目錄
  • echo 打印
  • export 環境變量
  • source/. 執行腳本
  • alias 命令別名
  • exit 退出
  • type 查看是否是內建

[Linux]命令行解釋器為什麼能執行命令?百行代碼實現Shell_重定向_06

到這裏,Shell的基礎框架已經完成了,對於其他命令特殊處理以及管道、通配符等更復雜的實現,本文不再展開,建議作為練習自行實現。

四.擴展:重定向的處理

先介紹一個概念,數據流。在OS中,因為數據是按順序、連續地一點點讀入或寫出的,前面沒讀過的不能跳過,也不能回頭任意的跳躍讀取。打個比方:數據是河裏的水,程序就像是從源頭出發的船,讀取數據只能順着船不斷向下,不能想要到哪裏就瞬移到哪裏。那麼,程序將通過統一接口從“數據流”中獲取數據,不關心數據從哪裏來(不關心數據到底來自鍵盤、文件還是網絡),只需要從“流”的開始能夠向後不斷獲取數據就夠了。

而對於重定向: 把程序默認的輸入來源或輸出目標(通常是鍵盤/屏幕)改成別的地方(比如文件、管道)的操作。

正是因為所有數據都將被放到"數據流"裏,我們寫入數據時也是先被寫入“數據流”中(在數據流裏之後才被分發到特定地方),這種、“不關心數據從哪裏來”的程序設計,操作系統和Shell才能自由地重定向數據源,而程序無序做任何修改。

  • 數據的流入/流出又是什麼含義?
  • 對於標準輸入:數據從外部 “流進” 程序 ;
  • 標準輸出:數據從程序 “流出” 到外部。
  • 那麼我們可以用符號(箭頭)來表示這種"流入/流出"關係:
  •  < :數據從文件“流向”程序 → 輸入重定向
  • cmd < in.txt   可以理解為:in.txt 文件內容 輸入到 cmd 程序
  • > :數據從程序“流向”文件 → 輸出重定向
  • cmd > out.txt  可以理解為:cmd 程序數據 寫入 out.txt 文件
  • >>:以追加的方式 將標準輸出內容寫入文件末尾(不覆蓋原有內容)

所以我們如果想要處理命令行當中的重定向操作,需要對用户輸入字符串進一步劃分:

// 重定向檢測
//把命令分成兩部分,左部分是命令,右部分是文件
void check_redir(char *cmd)
{
    //考慮重定向,命令可能有兩種結構
    // 1. ls -a -l
    // 2. ls -a -l >/</>> myfile.txt
    char *pos = cmd;
    while (*pos)
    {
        if (*pos == '<')//輸入重定向
        {
            *pos = '\0'; // 這樣處理之後commandline中就是"正常的"命令了
            pos++;
            while (isspace(*pos)) pos++; // 把空格跳過去
            rdirfilename = pos; // 保存文件名,暫時不考慮異常情況了
            rdir = IN_RDIR;     // 保存方式-追加(宏-看最後代碼)
            break;
        }
        else if (*pos == '>')//輸出重定向
        {
            if (*(pos + 1) == '>')
            {
                // 追加
                *pos++ = '\0';
                *pos = '\0';//把兩個大於符號清零
                pos++;
                while (isspace(*pos)) pos++;
                rdirfilename = pos;
                rdir = APPEND_RDIR;
            }
            else
            {
                *pos = '\0';//把兩個大於符號清零
                pos++;
                while (isspace(*pos)) pos++;
                rdirfilename = pos;
                rdir = OUT_RDIR;
            }
        }
        pos++;
    }
}

五.總結

所以,Shell 是用户和操作系統之間的命令行解釋器,用户登錄時系統啓動 Shell 進程,它會讀取用户目錄下的配置文件(如 .bash_profile)來加載環境變量,比如 PATH,這樣 Shell 才知道去哪裏找命令。之後 Shell程序 進入交互循環:讀取用户輸入,解析命令,調用系統調用完成用户需求,完成後再等待下一條命令。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/wait.h>
#include<fcntl.h>
#include<ctype.h>

#define LEFT "["
#define RIGHT "]"
#define LABLE "#"

#define LINE_SIZE 1024
char commandline[LINE_SIZE];
int quit = 0;
// 分割
#define ARGC_SIZE 32
#define DELIM " \t"
// 普通命令執行
extern char **environ;
#define EXIT_CODE -1
int lastcode = 0; // 儲存退出碼
// 內建命令
// pwd
char pwd[LINE_SIZE];
// export
char myenv[LINE_SIZE];

// 擴展 - 文件部分
// 三種重定向方式
#define IN_RDIR 0     // 輸入
#define OUT_RDIR 1    // 輸出
#define APPEND_RDIR 2 // 追加
#define NONE -1       // 沒有重定向

char *rdirfilename = NULL;
int rdir = NONE;

// 獲取用户名
const char *Getusername()
{
    return getenv("USER");
}
// 獲取主機名
const char *Gethostname()
{
    return getenv("HOSTNAME");
}
void Getpwd()
{
    getcwd(pwd, sizeof(pwd));
}

// 重定向檢測
//把命令分成兩部分,左部分是命令,右部分是文件
void check_redir(char *cmd)
{
    // ls -a -l
    // ls -a -l >/</>> myfile.txt
    char *pos = cmd;
    while (*pos)
    {
        if (*pos == '<')//輸入重定向
        {
            *pos = '\0'; // 這樣commandline中就是"正常的"命令了
            pos++;
            while (isspace(*pos)) pos++; // 把空格跳過去

            rdirfilename = pos; // 不考慮異常情況了
            rdir = IN_RDIR;     // 輸入重定向
            break;
        }
        else if (*pos == '>')//輸出重定向
        {
            if (*(pos + 1) == '>')
            {
                // 追加
                *pos++ = '\0';
                *pos = '\0';//把兩個大於符號清零
                pos++;
                while (isspace(*pos))  pos++;

                rdirfilename = pos;
                rdir = APPEND_RDIR;
            }
            else
            {
                *pos = '\0';//把兩個大於符號清零
                pos++;
                while (isspace(*pos))  pos++;

                rdirfilename = pos;
                rdir = OUT_RDIR;
            }
        }
        pos++;
    }
}

void Interact(char *cline, int size)
{
    Getpwd();
    printf(LEFT "%s@%s %s" RIGHT "" LABLE, Getusername(), Gethostname(), pwd);
    char *s = fgets(cline, size, stdin); // 不用scanf,否則到空格處停止ls -a -l只能讀到ls
    assert(s != NULL);
    (void)s; // 避免只定義不使用引起warning
    cline[strlen(cline) - 1] = '\0';
    // 有了文件操作之後-支持重定向 >
    // ls -a -l > myfile.txt
    // 可從右向左逆向掃描 無大小於符號,就是純粹的指令,直接就返回了,如果有就設置
    check_redir(cline);//處理重定向
}

int splitstring(char *cline, char *_argv[])
{
    int i = 0;
    _argv[i++] = strtok(cline, DELIM);
    while (_argv[i++] = strtok(NULL, DELIM))
        ; // 有就截,直到空串,s數組寫入NULL

    return i - 1; // 一共有幾個命令行參數
}

void NormalExcute(char *_argv[])
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return;
    }
    else if (id == 0)
    {
        int fd = 0;
        //重定向
        if(rdir == IN_RDIR)//輸入重定向 - 只讀
        {   
            fd = open(rdirfilename,O_RDONLY);
            dup2(fd,0);
        }
        else if(rdir == OUT_RDIR)
        {
            fd = open(rdirfilename,O_CREAT | O_WRONLY | O_TRUNC,0666);
            dup2(fd,1);
        }
        else if(rdir == APPEND_RDIR)
        {
            fd = open(rdirfilename,O_CREAT | O_WRONLY | O_APPEND,0666);
            dup2(fd,1);
        }
        //程序替換不影響重定向工作:文件描述符表不被替換

        // child - 讓子進程執行命令
        execvp(_argv[0], _argv);
        //_argv中是已經被分割好的命令,用execvp調用系統可執行程序,執行命令
        exit(EXIT_CODE);
    }
    else
    {
        // parent
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if (rid == id)
        {
            lastcode = WEXITSTATUS(status); // 從wait()返回的狀態碼中,提取子進程的退出狀態
            // status是複合狀態碼
        }
    }
}

int BuiltCommand(char *_argv[], int _argc)
{
    // 1.cd命令
    if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
    {
        chdir(_argv[1]);
        // 更改環境變量PWD
        Getpwd();
        sprintf(getenv("PWD"), "%s", pwd);
        return 1;
    }
    else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        // putenv((char*)_argv[1]);//× - 導入環境只是將字符串地址寫進環境變量表裏(char* env[])
        // 這樣只是讓環境變量表的一個指針指向_argv[1]那個字符串,當下次使用指針數組的內容就被我們改了

        // 這隻舉例導入一個環境變量
        // 想導入多個用二維數組即可
        strcpy(myenv, _argv[1]); // malloc也行
        putenv(myenv);
        return 1;
    }
    else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
    {
        if (strcmp(_argv[1], "$?") == 0)
        {
            printf("%d\n", lastcode); // 退出碼
            lastcode = 0;
        }
        if (*_argv[1] == '$')
        {
            char *val = getenv(_argv[1] + 1);
            if (val != NULL)
                printf("%s\n", val);
        }
        else
        {
            printf("%s\n", _argv[1]);
        }
        return 1;
    }

    // 顏色處理
    if (strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color"; // 帶上顏色選項
        _argv[_argc] = NULL;
    }

    return 0;
}
int main()
{

    // 1.清空流-I/O操作
    // 2.交互問題,獲取命令-命令行參數
    // 3.字符串分割
    // 4.指令判斷-內建命令和普通命令
    char *argv[ARGC_SIZE];
    while (!quit)
    {
        //4.重定向問題 - 清空
        rdirfilename = NULL;
        rdir = NONE;

        // 1.交互問題,獲取命令-命令行參數
        Interact(commandline, sizeof(commandline));

        // printf("%s\n", commandline);
        // 2.字符串分割
        int argc = splitstring(commandline, argv);
        if (argc == 0) continue;
        // debug
        //  for(int i = 0;argv[i];i++)
        //  {
        //      printf("[%d]:%s\n",i,argv[i]);
        //  }

        // 3.指令判斷-內建命令和普通命令

        // 3.1 內建命令
        int n = BuiltCommand(argv, argc);

        // 3.2 普通命令的執行 - 創建子進程執行
        if (!n) NormalExcute(argv);
    }
    return 0;
}