實現流程介紹
Shell是命令行解釋器的一種,它的核心職責是作為用户與操作系統內核交互的中介,實現“用户指令接收→解析指令語法→調用內核執行→結果反饋”。
透過這些"高深"的終端命令交互,我們一定要"看清"shell實現的本質——所謂的shell就是一個進程,它能夠識別用户輸入,做分析,通過"進程控制管理與系統調用實現指定功能。
這篇博客,將從以下幾個模塊,帶你徹底拆解Shell的底層工作流程,用實戰視角還原簡易Shell的實現邏輯。
- 1.獲取指令
- 2.解析指令
- 3.執行指令
- 普通命令執行
- 內建命令的處理
- 4.重定向處理
注:文段內代碼只展現某個模塊的實現思路,完整代碼放在總結中。
一.獲取指令
實現命令行提示符以及用户輸入的獲取工作。
命令行是用户通過文本指令與操作系統進行交互的界面。
命令行包括命令行提示符與用户輸入的指令兩部分,它是我們能夠與OS交互的基礎,所以我們實現Shell的第一步要模擬命令行的實現。
1.命令行提示符
命名提示符:在每次啓動時輸出關於"用户名,主機名,工作目錄等信息"的字符串。
2.用户輸入命令
而命令,本身也是字符串,由用户輸入。後續只需讓程序分析字符串,調系統調用啓動命令的可執行文件,將結果反饋給用户。
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';
}
二.解析指令
識別用户輸入,將命令的"每一部分"提取出來。
對於字符串 "ls -a -l",需要將其分割為多個子串 "ls" "-a" "-l",我們根據子串去判斷應該執行什麼命令 —— 也很簡單,根據“ ”分割,將分割出的子串用char*數組儲存。
函數strtok
以空格為分界,分割用户輸入命令,並將分割好的子串放到字符串指針數組中。
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下普通命令(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 查看是否是內建
到這裏,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;
}