文章目錄
- 前言
- 一、打印命令行提示符
- 代碼功能概述
- 二、讀取鍵盤輸入的指令
- 2.1 為什么不繼續使用`scanf()`而換成了`fgets()`?
- 2.2 調試輸出的意義
- 2.3 為什么需要去掉換行符?
- 三、指令切割
- 補充知識: `strtok` 的函數原型
- 四、普通命令的執行
- 代碼功能概述
- 五、內建指令執行
- 代碼功能概述
- 結語
前言
在操作系統的世界里,Shell 作為用戶與系統交互的橋梁,扮演著至關重要的角色。無論是資深的開發者,還是對系統運維感興趣的新手,了解 Shell 的工作原理和實現機制都能極大地加深對操作系統底層運行邏輯的理解。
本文將帶領大家深入探索簡易 Shell 的實現過程,從最基礎的打印命令行提示符開始,逐步實現讀取用戶輸入、切割指令、執行普通命令以及處理內建指令等核心功能。每一步都配有詳細的代碼解析和關鍵知識點說明,不僅讓你知其然,更能知其所以然。通過閱讀本文,你將掌握 Shell 實現的關鍵技術,理解其中涉及的編程技巧和系統調用原理,同時也能體會到從無到有構建一個實用工具的樂趣與成就感。
為了方便理解我把源代碼發給大家。(單擊藍色字體)
一、打印命令行提示符
#define MARK ":"
#define LABLE "$"
#define LINE_SIZE 1024char commandline[LINE_SIZE]; // 用于存儲輸入的命令行
char pwd[LINE_SIZE]; // 當前工作目錄// 獲取用戶名
const char* getusername()
{return getenv("USER");
}// 獲取主機名
const char* gethostname()
{return getenv("HOSTNAME");
}// 獲取當前工作目錄并進行格式化
void getpwd()
{getcwd(pwd, sizeof(pwd)); // 獲取當前工作目錄const char* home = getenv("HOME"); // 獲取用戶主目錄if (home != NULL && strcmp(pwd, home) == 0){// 如果當前目錄等于用戶主目錄,則返回 "~"strcpy(pwd, "~");}else if (home != NULL && strncmp(pwd, home, strlen(home)) == 0){// 如果當前目錄是用戶主目錄的子目錄,則替換主目錄部分為 "~"static char relative_path[LINE_SIZE];snprintf(relative_path, sizeof(relative_path), "~%s", pwd + strlen(home));strcpy(pwd, relative_path);}
}int main()
{getpwd();printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd);scanf("%s", commandline); return 0;
}
代碼功能概述
-
定義宏和變量:
- 宏
MARK
和LABLE
定義了提示符中的分隔符:
和$
,用于提示符的格式化輸出。 - 宏
LINE_SIZE
設置了緩沖區大小(1024
),用于存儲命令行輸入和當前工作目錄。 - 全局變量
commandline
和pwd
:commandline
: 存儲從用戶輸入讀取的命令行。pwd
: 存儲當前工作目錄。
- 宏
-
獲取環境變量:
- 函數
getusername()
和gethostname()
分別通過調用getenv()
獲取環境變量USER
和HOSTNAME
,用于表示用戶名和主機名。
- 函數
-
格式化當前工作目錄:
-
函數
getpwd()
獲取當前工作目錄并將其格式化:
- 如果當前目錄是用戶的主目錄(
HOME
),用~
替代完整路徑。 - 如果當前目錄是主目錄的子目錄,則用
~
替代主目錄部分。 - 否則顯示完整路徑。
- 如果當前目錄是用戶的主目錄(
-
-
打印命令行提示符:
-
在
main()
函數中調用getpwd()
獲取當前工作目錄,然后通過printf()
按以下格式打印提示符:
-
-
等待用戶輸入:
- 程序通過
scanf()
等待用戶輸入命令,將輸入存儲到commandline
中。
- 程序通過
二、讀取鍵盤輸入的指令
// 交互式模式,顯示提示符并讀取用戶輸入
void interact(char* cline, int size)
{getpwd(); // 獲取當前工作目錄printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd); // 顯示提示符char* s = fgets(cline, size, stdin); // 讀取用戶輸入assert(s); // 確保輸入不為空printf("echo: %s\n", s); // 測試能否讀取成功cline[strlen(cline)-1] = '\0'; // 去掉末尾的換行符
}int main()
{while(1) // 循環運行直到用戶退出{// 1. 獲取用戶輸入的命令行interact(commandline, sizeof(commandline));}return 0;
}
2.1 為什么不繼續使用scanf()
而換成了fgets()
?
scanf
通常用于格式化輸入,但它在處理用戶輸入時存在一些顯著的缺點:
scanf
默認以空格、制表符或換行符作為輸入的分隔符,因此只能讀取一個單詞(或無空格的字符串)。- 在交互式 Shell 中,用戶輸入的命令往往由多個部分組成(如命令和參數),
scanf
無法正確讀取整行命令。 scanf
不會自動限制輸入長度,如果用戶輸入超出緩沖區大小,就會導致緩沖區溢出,進而引發未定義行為甚至安全漏洞。
2.2 調試輸出的意義
加調試輸出 printf("echo: %s\n", s)
是為了測試程序的輸入功能是否正常運行,具體驗證以下幾點:
- 提示符顯示后,用戶是否能輸入數據:
- 如果
s == NULL
,則說明fgets()
未能成功讀取輸入(可能是因為輸入錯誤或用戶直接按下Ctrl+D
)。
- 如果
- 輸入是否被正確存儲到緩沖區
cline
:- 打印輸入內容以確保其正確性。
- 緩沖區大小是否足夠:
- 如果輸入過長,可能會導致緩沖區溢出或截斷。
調試結果:
2.3 為什么需要去掉換行符?
-
用戶通過鍵盤輸入時,輸入內容會帶有換行符(
\n
),這是因為按下回車鍵會在輸入的末尾自動添加一個換行符。 -
例如,用戶輸入
ls -l
后,緩沖區中的數據實際是:ls -l\n\0
\n
是換行符。\0
是字符串的終止符。
-
在處理命令時,換行符通常是多余的:
- 它會影響字符串的比較。例如,
strcmp(command, "exit")
會返回不匹配,因為字符串實際是"exit\n"
。 - 如果直接打印字符串,換行符會造成多余的空行。
- 某些函數(如文件名或路徑相關函數)可能會因為換行符導致邏輯錯誤。
- 它會影響字符串的比較。例如,
三、指令切割
#define DELIM " " // 分隔符
#define ARGC_SIZE 32char *argv[ARGC_SIZE]; // 用于存儲命令行參數// 將輸入的命令行分割為參數數組
int splitstring(char* cline, char* _argv[])
{int i = 0;argv[i++] = strtok(cline, DELIM); // 分割第一個參數while(_argv[i++] = strtok(NULL, DELIM)); // 分割剩余的參數return i - 1; // 返回參數個數
}int main()
{while(1) // 循環運行直到用戶退出{// 1. 獲取用戶輸入的命令行interact(commandline, sizeof(commandline));// 2. 分割命令行字符串為指令和參數int argc = splitstring(commandline, argv);// 調試代碼for(int i = 0; argv[i]; i++){printf("[%d]->%s\n", i, argv[i]);}// 3. 如果沒有輸入指令(空行),跳過本次循環if(argc == 0) continue;}return 0;
}
補充知識: strtok
的函數原型
char *strtok(char *str, const char *delim);
- 參數:
char *str
:- 第一次調用時傳入需要分割的字符串。
- 后續調用傳入
NULL
表示繼續上一次的分割。
const char *delim
:- 一個以
\0
結尾的字符串,表示分割的分隔符集合(例如,空格、逗號等)。
- 一個以
- 返回值:
- 成功:返回一個指向分割后的子字符串(token)的指針。
- 失敗:如果沒有更多的子字符串可以返回,則返回
NULL
。
注意點:循環結束時,i
的值比實際的參數個數多 1
(因為最后一次分割返回 NULL
)。
因此,用 i - 1
表示參數的實際個數。
調試結果:
四、普通命令的執行
#define EXIT_CODE 44extern char **environ; // 環境變量
int lastcode = 0; // 上次命令的退出狀態
int quit = 0; // 是否退出// 執行外部命令
void normalExcute(char* _argv[])
{pid_t id = fork(); // 創建子進程if(id < 0){perror("fork"); // 創建失敗,打印錯誤信息return;}if(id == 0) // 子進程{execvpe(_argv[0], _argv, environ);exit(EXIT_CODE);}else // 父進程{int status = 0;waitpid(id, &status, 0); // 等待子進程完成if (WIFEXITED(status)) // 檢查子進程是否正常退出{lastcode = WEXITSTATUS(status); // 獲取子進程的退出狀態}}
}int main()
{while(!quit) // 循環運行直到用戶退出{// 1. 獲取用戶輸入的命令行interact(commandline, sizeof(commandline));// 2. 分割命令行字符串為指令和參數int argc = splitstring(commandline, argv);// 3. 如果沒有輸入指令(空行),跳過本次循環if(argc == 0) continue;// 4.執行普通外部指令normalExcute(argv);}return 0;
}
代碼功能概述
功能:執行外部命令并處理子進程的退出狀態。
關鍵點:
fork
創建子進程。execvpe
替換子進程執行映像。waitpid
等待子進程結束并處理退出狀態。
結果:命令執行結果的退出碼存儲在 lastcode
中供后續使用。
調試結果:
五、內建指令執行
char myenv[LINE_SIZE]; // 用于存儲導出的環境變量// 構建內置命令
int buildCommand(char* _argv[], int _argc)
{// 內置命令:cdif(_argc == 2 && strcmp(_argv[0], "cd") == 0){getpwd();chdir(_argv[1]); // 改變工作目錄sprintf(getenv("PWD"), "%s", pwd); // 更新環境變量PWDreturn 1; // 返回已處理標志}// 內置命令:exportelse if(_argc == 2 && strcmp(_argv[0], "export") == 0){strcpy(myenv, _argv[1]); // 保存環境變量putenv(myenv); // 設置環境變量return 1; // 返回已處理標志}// 內置命令:echoelse if(_argc == 2 && strcmp(_argv[0], "echo") == 0){if(strcmp(_argv[1], "$?") == 0) // 顯示上一個命令的退出狀態{printf("%d\n", lastcode);lastcode = 0; // 重置退出狀態}else if(*_argv[1] == '$') // 顯示環境變量的值{char* val = getenv(_argv[1] + 1);if(val) printf("%s\n", val);}else printf("%s\n", _argv[1]); // 直接打印參數return 1; // 返回已處理標志}// 自動為 ls 添加 --color 參數if(strcmp(_argv[0], "ls") == 0){_argv[_argc++] = "--color";_argv[_argc] = NULL; // 確保參數數組以 NULL 結束}return 0; // 返回未處理標志
}int main()
{while(!quit) // 循環運行直到用戶退出{// 1. 獲取用戶輸入的命令行interact(commandline, sizeof(commandline));// 2. 分割命令行字符串為指令和參數int argc = splitstring(commandline, argv);// 3. 如果沒有輸入指令(空行),跳過本次循環if(argc == 0) continue;// 4. 嘗試執行內置命令int n = buildCommand(argv, argc);// 5. 如果不是內置命令,執行普通外部指令if(!n) normalExcute(argv);}return 0;
}
代碼功能概述
該函數 buildCommand
的功能是處理內置命令,包括 cd
、export
和 echo
,并對特定外部命令(如 ls
)添加額外參數。如果輸入的命令屬于內置命令范圍,函數會執行相應邏輯并返回已處理標志;否則返回未處理標志,交由其他部分(如外部命令執行器)處理。
-
處理內置命令:
cd
if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
- 檢查用戶輸入的命令是否是
cd
,且參數個數為 2(命令名 + 目標目錄)。 - 功能:
- 使用
chdir
改變當前工作目錄為用戶指定的路徑(_argv[1]
)。 - 更新環境變量
PWD
,同步當前工作目錄的變化。
- 使用
- 實現思路:要注意
cd
命令是由bash
本身去做,而不是創建一個子進程去做,故而需要改變的是當前可執行程序的工作目錄,并且需要將環境變量中的PWD
改變。 - 測試:
- 檢查用戶輸入的命令是否是
-
處理內置命令:
export
else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
- 檢查用戶輸入的命令是否是
export
,且參數個數為 2。 - 功能:
- 設置環境變量,將用戶輸入的
變量=值
格式字符串存儲到全局變量myenv
。 - 調用
putenv
將該變量添加到環境變量中。
- 設置環境變量,將用戶輸入的
- 實現思路:我們輸入的環境變量實際上是保存在
commandline
當中,只要當下一次輸入指令,上一次定義的環境變量就會被清空。putenv
添加環境變量,并不是把對應的字符串深拷貝到系統的環境變量表當中,而是把該字符串的地址保存在系統的環境變量表中(淺拷貝)。因此我們要確保保存環境變量字符串的那個地址里的環境變量不會被修改,所以我們需要為用戶輸入的環境變量,也就是那一串字符串單獨開辟一塊空間進行存儲,保證在內次重新輸入指令的時候,不會影響到之前用戶添加的環境變量。所以我們需要定義一個二維數組用于存儲導出的環境變量(這里只簡單地分配了一維數組)。 - 測試:
注意看,連續兩次的寫入導致第一次的定義的環境變量被覆蓋了。
- 檢查用戶輸入的命令是否是
-
處理內置命令:
echo
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
- 檢查用戶輸入的命令是否是
echo
,且參數個數為 2。 - 功能:
- 如果參數為
"$?"
,顯示上一個命令的退出狀態(從全局變量lastcode
獲取)。 - 如果參數以
$
開頭,顯示對應環境變量的值。 - 否則,直接打印參數內容。
- 如果參數為
- 測試:
故意寫成ll
(沒有定義的),導致子進程退出,退出碼剛好是44。
- 檢查用戶輸入的命令是否是
-
處理外部命令:自動為
ls
添加--color
參數if (strcmp(_argv[0], "ls") == 0)c
- 檢查用戶輸入的命令是否是
ls
。 - 功能:
- 自動為命令添加
--color
參數,用于增強可讀性(適用于 Linux 的ls
命令)。 - 確保參數數組以
NULL
結束。
- 自動為命令添加
- 測試:
總結:說了這么久的環境變量,那么請問我們登錄的時候,系統中的 shell 的環境變量又是從哪里來的呢?答案是 Bash。那么 Bash 的環境變量又是從何而來?當然是系統自帶的目錄文件中寫入的。
- 檢查用戶輸入的命令是否是
結語
通過以上對簡易 Shell 實現過程的詳細講解,相信大家對 Shell 的工作流程和實現細節已經有了較為全面的認識。從命令行提示符的設計,到輸入指令的處理,再到不同類型命令的執行,每一個環節都凝聚著操作系統與編程的智慧。
雖然本文實現的 Shell 只是一個簡化版本,但其中涉及的技術和思想為進一步探索更復雜、功能更強大的 Shell,乃至深入理解操作系統的運行機制奠定了堅實的基礎。希望大家能將所學應用到實際開發或探索中,不斷挖掘操作系統的奧秘。如果在閱讀過程中有任何疑問或想法,歡迎在評論區交流分享,也別忘了點贊、收藏并持續關注后續更多精彩的技術內容!
今
天的分享到這里就結束啦!如果覺得文章還不錯的話,可以三連支持一下,17的主頁還有很多有趣的文章,歡迎小伙伴們前去點評,您的支持就是17前進的動力!