🔥本文專欄:Linux Linux實踐項目
🌸博主主頁:努力努力再努力wz
那么今天我們就要進入Linux的實踐環節,那么我們之前學習了進程控制相關的幾個知識點,比如進程的終止以及進程的等待和進程的替換,那么我們接下來就要結合前面所講的進程控制相關的接口比如fork以及waitpid和execl等來自己實現一個命令行解釋器,那么廢話不多說,讓我們進入正文
★★★ 本文前置知識:
進程的替換
進程的終止與等待
進程的概念
shell實現的框架
那么在用c語言真正上手實操我們的shell外殼程序時,那么我們腦海里得有一個大體的實現框架,也就是所謂的一個整體思路,在有了整體思路后我們再去談具體每個模塊的細節,那么我們首先就先從shell本身的工作原理作為切入口入手
那么我們的shell也就是我們的命令行解釋器,那么它的工作就是獲取用戶輸入的指令,然后來執行用戶輸入的指令,那么我們知道用戶輸入的指令的本質上就是一個字符串,所以shell首先就得讀取到用戶輸入的字符串,然后保存在一個字符數組中,讀取到用戶輸入的字符串之后,那么緊接著下一步便是解析用戶輸入的字符串,那么我們用戶輸入的指令無非可以分成兩大部分,分別是指令部分以及參數部分,那么這里我們就需要定義一個字符指針數組,那么數組的每一個元素就是一個指針,那么指針指向的就是一個字符串,那么我們用戶輸入的字符串的指令部分就保存在字符指針數組的第一個元素也就是下標為0的位置,那么參數部分則依次保存在之后的位置,比如我們用戶輸入的指令是ls -l -a,那么此時我們就要解析為三部分,分別是是指令部分的“ls”字符串以及兩個參數部分的字符串"-l”和“-a”,將這三個字符串則是依次保存到我們的字符指針數組下標為0和1和2的位置當中
而具體的解析這三部分字符串則需要用到我們c語言的strtok函數,那么具體細節我們下文再說,那么這里我們討論的是大的框架與思路,所以我們可以專門定義一個函數來完成這個字符串解析的模塊,它的工作就是解析用戶輸入的字符串將其指令部分以及參數部分的各個字符串分別保存到字符指針數組不同位置中,并且返回命令行的個數,比如用戶輸入的是ls -l,那么將其保存在字符指針數組char* argv[]并返回的個數就是2,,而如果是pwd,將其保存在字符指針數組char* argv[]并返回的個數就是1
那么接下來解析完用戶輸入的字符串之后,那么我們就可以來執行用戶輸入的指令了,那么這里我們知道我們用戶輸入的各種指令本質上就是在特定路徑下保存的一個可執行文件,那么指令的執行本質上就是創建一個進程,那么我們shell執行這些指令就得利用fork函數來創建一個子進程,然后我們利用fork函數的返回值,將父子進程分成不同的執行流,那么在子進程的執行流代碼片段中,我們就可以利用進程的替換,那么將我們的子進程的內容替換為我們要執行指令所對應的進程的上下文,那么我們父進程的執行流代碼片段則是等待我們子進程的退出結果,那么我們就需要用waitpid函數來獲取子進程的退出碼
最后獲取完子進程的退出碼,如果子進程沒有正常終止,那么就得將情況返回給用戶,也就是將錯誤信息打印到終端,如果子進程正常終止然后下一步就是重復我們之前上文的環節,那么重復也就意味著我們實現的時候最后這些邏輯的代碼都要封裝到一個死循環當中。
那么這就是我們實現shell外殼程序的一個大框將,那么我們可以簡單將其分為幾個模塊,分別是獲取用戶輸入->解析用戶輸入->創建子進程->子進程的替換->父進程等待獲取子進程的退出情況->重復上述步驟
那么看到這些模塊,想必你一定還有一些疑問,那么接下來我就會在下文補充每個模塊的代碼實現以及注意的一些細節,和其他的模塊的補充,那么有了大框架之后,那么接下來就讓我們具體實現每一個模塊了
shell各個模塊的實現
1.獲取用戶輸入
那么我們的shell首先得獲取用戶輸入的字符串,那么我們知道在c語言中,我們獲取用戶輸入的字符串常見就是使用我們的scanf函數來獲取用戶的輸入,但是scanf函數有一個缺陷就是一旦讀取到空格的時候,那么scanf便停止讀取輸入,而我們用戶在輸入字符串的時候,會手動用空格隔開指令部分與參數部分,所以我們就不能采取scanf函數來獲取輸入,所以這里我們需要用fgets函數,那么fgets函數則是將從標準輸入流中讀取用戶的輸入,遇到換行符停止,那么我們可以指定其在輸入流中讀取的字符串的長度也就是fgets的第二個參數,那么將其保存到一個臨時字符數組中,如果讀取失敗,那么fgets則會返回NULL,讀取成功fgets則會返回保存數組的地址
-
fgets
頭文件:<string.h>
-
功能:獲取用戶輸入的字符串,末尾自動添加\0
:函數原型
char *fgets(char *str, int n, FILE *stream);
而我們知道用戶在輸入之前,我們終端都會顯示一個命令行提示符,會顯示我們當前登錄的用戶名以及所處的工作目錄和運行的主機名稱,所以我們在獲取用戶輸入之前,我們得先打印一個字符串也就是命令行提示符,而切記,我們的shell命令行解釋器本質也是一個進程,所以這命令行提示符的每一個信息就保存在我們當前進程的環境變量中,我們需要通過我們的系統調用接口getenv來獲取其中特定字段的環境變量,這里就需要獲取到我們的USER以及HOSTNMAE以及PWD這三個字段,那么我們只需要向getenv函數傳遞這三個字符串的指針,那么他會依次匹配各個字段的名稱所對應的字符串并返回對應的值,也就是字符串的起始地址
代碼實現:
printf("[%s@%s %s]$",getenv("USER"),getenv("HOSTNAME"),getenv("PWD"));if(fgets(temp,sizeof(temp),stdin)==NULL){perror("fgets");continue;}
2.解析命令行
那么現在我們獲取了我們的用戶輸入的字符串之后,那么我們是將用戶輸入的字符串保存在一個臨時字符數組里,那么接下來我們就要將這個字符串給分割,將其指令部分以及參數部分的各個字符串給分割保存到我們的字符指針數組當中,那么我們這里就專門可以定義一個函數來完成字符串解析模塊,并返回命令行參數的個數,那么我們知道我們用戶輸入的字符串會手動以空格分割,那么這里我們就需要調用我們的字符串函數也就是strtok函數來分割我們的字符串按照空格作為分隔符。但是在分割之前,我們又得注意一個細節,也就是我們用戶輸入完一個字符串,那么它會敲一個回車鍵來表示輸入的結束,而回車則是對應的一個換行符\n,他會被我們的fegts給讀取到,那么意味著在我們的字符串的末尾可能會有一個回車換行符
而回車換行符并不是我們一個有效的字符信息,所以我們在解析之前得去掉這個換行符,所以我們就利用我們的strlen函數首先獲取到我們這個字符串包括空格以及換行符的總長度,那么如果我判斷用戶輸入的字符串的len-1位置處的字符處是換行符,那么我們就將len-1位置用\0來覆蓋,而\0是標記字符串結尾的標志,那么這樣我們就可以消去末尾的回車換行符,這里是其中一個關鍵的實現細節
那么第二個細節就是我們的strtok函數的使用,那么我們strtok函數第一次調用的時候要傳遞我們要分割的字符串的首元素的地址,那么strtok內部會訪問到一個靜態的全局變量,這個靜態變量是用來保存下一次分割的位置,那么我們每次調用strtok函數的時候,會從分割的起始位置處往后掃描直到遇到分隔符,然后將分隔符的位置修改為\0,然后返回該分割起始位置的指針,而我們知道\0是標記字符串的結尾,所以返回分割起始位置的指針就達到了一個分割子串的一個效果
而下一次調用strtok函數的時候,那么我們就不用傳要分割的字符串的首元素的地址,因為上文說過strtok內部能訪問到一個記錄下一次分割位置的全局變量,那么之后的調用我們只需要傳遞一個NULL即可,它內部會繼續從這個全局變量記錄的位置開始掃描到下一個分隔符,將其修改為\0,最后如果我們開始的分割的位置是\0,也就是字符串末尾,沒有更多的子串來分割時候,那么strtok就返回一個NULL
-
strtok
頭文件:<string.h>
-功能:分割字符串:函數原型
char *strtok(char *str, const char *delim);
在這個函數中我們就定義一個int類型的argc變量來跟蹤命令行的個數,初始化為0,而我們將分割的字符串保存在對應的字符數組的下標就是argc的值,保存之后接著遞增argc,最后返回的該argc就是我們的命令行的個數
代碼實現:
int getString(char temp[],char* argv[])
{int len=strlen(temp);if(len>0&&temp[len-1]=='\n'){temp[len-1]='\0';len--;}int argc=0;char* toke=strtok(temp," ");while(toke!=NULL&&argc<length-1){argv[argc++]=toke;toke=strtok(NULL," ");}argv[argc]=NULL;return argc;
}
3.指令判斷
那么這里在我們上文介紹實現我們的shell外殼程序的框架的時候模塊的時候,其實我們故意漏了一個模塊,那就是指令的判斷,那么想必你一定會有所疑問,那么就是我們獲取解析完用戶輸入的指令之后,我們為什么還要進行指令的判斷呢?直接通通交給子進程去執行不就完了嗎,我們父進程也就是shell外殼程序的本職工作不就是獲取用戶的輸入嗎
那么這里我們就要注意的就是,我們用戶其中輸入的指令,比如cd指令,也就是更改我們進程所處的工作目錄,那么它針對的對象其實是我們的父進程也就是我們的shell外殼程序,那么如果我們把這個指令交給了子進程去完成,將子進程替換為cd指令所對應的上下文,那么子進程的執行是不會影響父進程的,那么子進程執行結束退出之后,我們shell進程所處的工作目錄沒有進行更改,那么所以我們對于有些指令,也就是針對當前父進程shell的運行環境的指令,比如cd,比如PWD指令,那么它就不能交給子進程來執行,而是得交給父進程來自己完成,那么這些指令也就是我們的內置指令
那么內置指令那么就不再是一個編寫好的可執行文件,那么它是通常是一個實現好的庫函數或者直接嵌套在我們的shell進程所對應的代碼中,所以我們自己用c語言實現的時候,那么我們就首先準備定義一個全局屬性的字符指針數組,然后該數組里面記錄了我們所有的內置命令所對應的字符串,那么當解析完用戶的指令之后,解析完保存的字符指針數組的第一個位置就是對應用戶輸入的指令部分的字符串,所以下一步我們依次匹配保存的所有內置命令對應的字符串,如果匹配成功,那么意味著是內置指令,就直接交給我們父進程執行,匹配失敗則說明該指令不是內置命令,就交給子進程來執行,那么我們匹配的過程以及內置命令的執行的過程都可以定義兩個函數來分別實現這兩個模塊,那么其中字符串的匹配就需要用到strcmp函數來實現
而所謂的內置命令,他的底層實現的時候本質其實就是依賴用c編寫的庫函數或者系統調用,比如cd內置命令,那么它就是用chdir庫函數來實現的,那么這個庫函數的作用就是能夠訪問到當前進程的環境變量中的工作目錄字段,然后修改當前所處的工作目錄,而pwd內置命令的本質其實也就是依賴getcwd庫函數,那么該庫函數會訪問到該進程中環境變量記錄當前所處也就是工作目錄的字段PWD,將其值記錄保存到一個數組當中,并且返回指向該數組的指針
-
chdir
頭文件:<unistd.h>:函數原型
int chdir(const char *path);
-
getcwd
頭文件:<unistd.h>:函數原型
char *getcwd(char *buf, size_t size);
那么這里我在實現的時候,就只判斷了cd以及pwd這兩種內置命令,那么我們可以下來直接去添加更多的內置命令,然后查詢對應實現所依賴的庫函數或者系統調用接口
代碼實現:
bool check(char* argv[])//指令的判斷
{for(int i=0;order[i]!=NULL;i++){if(strcmp(argv[0],order[i])==0)//如果該指令是內置命令就返回true{return true;}}return false;
}
void ordercomplete(int argc,char* argv[])//內置命令的執行
{if(strcmp(argv[0],"cd")==0){if(argc==2)//cd指令最多只能兩個參數,其中第二個參數就是跳轉的工作目錄{if (chdir(argv[1]) != 0) {perror("chdir");}}else{printf("error: expected argument for 'cd'\n");}}if(strcmp(argv[0],"pwd")==0){char cwd[length]; // 定義一個字符數組錯誤的來保存我們的當前所處的工作目錄if (getcwd(cwd, sizeof(cwd)) != NULL) {printf("Current working directory: %s\n", cwd);} else {perror("getcwd failed"); // 輸出錯誤信息}}
}
5.子進程執行指令
那么剩下幾個模塊的細節和實現就很簡單了,接下來這個模塊就調用fork函數來創建一個子進程,然后利用fork函數的返回值,讓父子進程有著不同的執行流,然后我們在子進程對應的執行流代碼片段中,調用進程的替換的系統接口,而這里我們調用的exec族函數,一定是不能帶有l的比如execl以及execlp等,因為我們不知道用戶輸入的命令行個數,所以不能用可變參數列表的進程替換接口,這里要注意
而我們用戶輸入的字符串都解析在了一個字符指針數組中,所以我們傳的參數肯定就是一個數組,所以這里我們選擇進程替換的函數就是execvp,那么它可以默認在環境變量的PATH中去匹配我們用戶輸入的指令所對應的可執行文件
那么我們用execvp函數來將子進程替換為指令所對應的進程的上下文,但是我們知道我們進程替換會出現調用失敗的情況,那么調用失敗的結果則是會執行進程替換接口之后的代碼,那么我們就在execvp函數后面打印一個錯誤信息并且返回一個特殊的退出碼
6.父進程的等待
那么我們父進程對應的執行流代碼片段則是等待我們子進程的退出情況,所以我們需要調用waitpid函數來獲取子進程的退出碼,那么waitpid我們的等待方式則是設置為阻塞式等待,那么它的返回值就分別對應兩種情況,要么等待成功并且獲取到子進程的退出碼,對應的返回值就是子進程的pid,而等待失敗則是返回-1,我們對于等待失敗則是要打印錯誤信息以及子進程的退出碼
完整實現
那么將我們上面的6個模塊所對應代碼融合就是我們的shell的外殼程序,那么其實我們在實現shell外殼程序的時候,其實shell的整體實現難度不大,主要考察你對shell的工作原理的理解程度和幾個系統調用接口的熟悉程度,shell實現的真正的難點其實在它各個模塊實現的細節上,很容易出錯,其中就考察我們對于一些c語言的庫函數的掌握情況,那么接下來我就給出完成的shell的c語言代碼的實現
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<stdbool.h>
#define length 1000
#define EXIT_FAIL 40
const char* order[]={"cd","pwd",NULL};int getString(char temp[],char* argv[])
{int len=strlen(temp);if(len>0&&temp[len-1]=='\n'){temp[len-1]='\0';len--;}int argc=0;char* toke=strtok(temp," ");while(toke!=NULL&&argc<length-1){argv[argc++]=toke;toke=strtok(NULL," ");}argv[argc]=NULL;return argc;
}
bool check(char* argv[])
{for(int i=0;order[i]!=NULL;i++){if(strcmp(argv[0],order[i])==0){return true;}}return false;
}
void ordercomplete(int argc,char* argv[])
{if(strcmp(argv[0],"cd")==0){if(argc==2){if (chdir(argv[1]) != 0) {perror("chdir");}}else{printf("error: expected argument for 'cd'\n");}}if(strcmp(argv[0],"pwd")==0){char cwd[length]; // 定義一個足夠大的緩沖區來存儲路徑if (getcwd(cwd, sizeof(cwd)) != NULL) {printf("Current working directory: %s\n", cwd);} else {perror("getcwd failed"); // 輸出錯誤信息}}
}
int main()
{int argc;char* argv[length];char temp[length];while(1){printf("[%s@%s %s]$",getenv("USER"),getenv("HOSTNAME"),getenv("PWD"));if(fgets(temp,sizeof(temp),stdin)==NULL){perror("fgets");continue;}argc=getString(temp,argv);if(argc==0){continue;}if(check(argv)){ordercomplete(argc,argv);continue;}int id=fork();if(id==0){execvp(argv[0],argv);perror("execvp");exit(EXIT_FAIL);}else{int status;int m=waitpid(id,&status,0);if(m<0){perror("waitpid");}else{if(WIFEXITED(status)){if(WEXITSTATUS(status)==40){printf("error\n");}}}}}return 0;
}
在Linux上的運行截圖:
結語
那么這就是用c語言實現shell外殼程序的所有內容啦,那么它也是我第一個學習Linux所完成的一個小項目,那么它這個小項目的教學價值以及學習意義其實非常高,因為它不僅可以幫組你了解shell外殼程序的工作原理,更重要的是幫組你更能熟練掌握運用那幾個關于進程控制十分重要的系統調用接口其中比如fork以及waitpid等,那么我的下一篇Linux文章就正式進入文件系統啦,我會持續更新,希望大家多多關注,那么如果本篇文章對你有所幫組的話,那么還請多多三連加關注哦,你的支持就是我最大的動力!