5.12 פרויקט סיכום פרויקט
פרויקט סיכום - כתיבת shell פשוט - mini-shell¶
הקדמה¶
בפרויקט הזה נבנה shell פשוט בשפת C. הפרויקט הזה מחבר את כל מה שלמדנו בפרק 5:
- תהליכים (פרק 5.2) - fork, execve, wait
- סיגנלים (פרק 5.4) - טיפול ב-SIGINT, SIGCHLD
- מתארי קבצים (פרק 5.5) - שימוש ב-dup2 להפניית קלט/פלט
- צינורות (פרק 5.6) - pipe להעברת נתונים בין תהליכים
ה-shell שנבנה יוכל לקבל פקודות מהמשתמש, להריץ אותן, לתמוך בהפניית קלט/פלט, צינורות, וטיפול בסיגנלים - בדיוק כמו bash או zsh, רק בגרסה מינימלית.
הפרויקט בנוי בשלבים - כל שלב מוסיף יכולת חדשה. מומלץ לבנות שלב אחרי שלב ולוודא שכל שלב עובד לפני שממשיכים לבא.
שלב 1 - ביצוע פקודות בסיסי¶
בשלב הזה נבנה את הליבה של הshell - לולאה שקוראת פקודה מהמשתמש, מפרקת אותה לחלקים, ומריצה אותה.
מה צריך לממש:
- הצגת prompt למשתמש (למשל "minishell> ")
- קריאת שורה מהקלט עם fgets
- פירוק (parsing) של השורה למילים - שם התוכנית והארגומנטים שלה (פיצול לפי רווחים)
- יצירת תהליך בן עם fork
- בתהליך הבן - הרצת הפקודה עם execvp
- בתהליך האב - המתנה לסיום הבן עם waitpid
- חזרה ללולאה עד שהמשתמש מקליד "exit"
- טיפול במקרה שהפקודה לא נמצאת (execvp מחזירה שגיאה)
שימו לב - אנחנו משתמשים ב-execvp ולא ב-execve. הפונקציה execvp מחפשת את התוכנית ב-PATH באופן אוטומטי, כך שהמשתמש יכול לכתוב ls במקום /bin/ls.
הנה מימוש מלא של שלב 1 כדי לתת לכם בסיס לעבוד איתו:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_INPUT 1024
#define MAX_ARGS 64
int parse_command(char *input, char **args) {
int argc = 0;
char *token = strtok(input, " \t\n");
while (token != NULL && argc < MAX_ARGS - 1) {
args[argc] = token;
argc++;
token = strtok(NULL, " \t\n");
}
args[argc] = NULL;
return argc;
}
void execute_command(char **args) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return;
}
if (pid == 0) {
/* child process */
execvp(args[0], args);
/* if we get here, execvp failed */
perror(args[0]);
exit(1);
}
/* parent process - wait for child */
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
int exit_code = WEXITSTATUS(status);
if (exit_code != 0) {
printf("command exited with status %d\n", exit_code);
}
}
}
int main(void) {
char input[MAX_INPUT];
char *args[MAX_ARGS];
while (1) {
printf("minishell> ");
fflush(stdout);
if (fgets(input, sizeof(input), stdin) == NULL) {
/* EOF (Ctrl+D) */
printf("\n");
break;
}
int argc = parse_command(input, args);
if (argc == 0) {
continue;
}
if (strcmp(args[0], "exit") == 0) {
break;
}
execute_command(args);
}
return 0;
}
קמפלו והריצו:
minishell> ls -la
total 28
drwxr-xr-x 2 user user 4096 ...
...
minishell> echo hello world
hello world
minishell> whoami
user
minishell> blablabla
blablabla: No such file or directory
minishell> exit
ודאו שהshell עובד נכון לפני שממשיכים לשלב הבא.
שלב 2 - הפניית קלט ופלט - I/O redirection¶
בשלב הזה נוסיף תמיכה בהפניית קלט ופלט - בדיוק כמו שlמדנו בפרק 5.5 על מתארי קבצים ו-dup2.
מה צריך לממש:
- תמיכה ב-> - הפניית stdout לקובץ (יצירה/דריסה)
- תמיכה ב->> - הפניית stdout לקובץ (הוספה)
- תמיכה ב-< - הפניית stdin מקובץ
דוגמאות שצריכות לעבוד:
רמזים:
1. בשלב הparsing, חפשו את התווים >, >>, < במערך הארגומנטים
2. אם מצאתם >, הארגומנט הבא הוא שם הקובץ. הסירו את > ואת שם הקובץ מרשימת הארגומנטים
3. בתהליך הבן (אחרי fork ולפני execvp):
- עבור >: פתחו את הקובץ עם open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644) ואז dup2(fd, STDOUT_FILENO)
- עבור >>: אותו דבר אבל עם O_APPEND במקום O_TRUNC
- עבור <: פתחו עם open(filename, O_RDONLY) ואז dup2(fd, STDIN_FILENO)
4. אל תשכחו לסגור את הfd המקורי אחרי dup2
תצטרכו להוסיף:
שלב 3 - צינורות - piping¶
בשלב הזה נוסיף תמיכה באופרטור | - חיבור הפלט של פקודה אחת לקלט של פקודה אחרת. זה ישירות מה שלמדנו בפרק 5.6 על צינורות.
מה צריך לממש:
- זיהוי התו | בשורת הקלט
- חלוקת הפקודה לשתי פקודות - מה שלפני הpipe ומה שאחריו
- יצירת pipe עם pipe()
- יצירת שני תהליכי בן
- בבן הראשון: הפניית stdout לצד הכתיבה של הpipe
- בבן השני: הפניית stdin לצד הקריאה של הpipe
- באב: סגירת שני צדי הpipe והמתנה לשני הבנים
דוגמאות שצריכות לעבוד:
רמזים:
1. חפשו את התו | בשורת הקלט. חלקו את השורה לשני חלקים
2. צרו pipe עם pipe(pipefd) - זוכרים? pipefd[0] לקריאה, pipefd[1] לכתיבה
3. עשו fork לתהליך הראשון:
- dup2(pipefd[1], STDOUT_FILENO) - הפלט שלו הולך לpipe
- סגרו את שני הצדדים של הpipe (הבן לא צריך אותם יותר אחרי dup2)
- execvp של הפקודה הראשונה
4. עשו fork לתהליך השני:
- dup2(pipefd[0], STDIN_FILENO) - הקלט שלו בא מהpipe
- סגרו את שני הצדדים של הpipe
- execvp של הפקודה השניה
5. באב: סגרו את שני הצדדים של הpipe, ואז waitpid לשני הבנים
חשוב: האב חייב לסגור את שני צדי הpipe. אם הוא לא סוגר את צד הכתיבה, הבן השני לעולם לא יקבל EOF ויתקע.
שלב 4 - סיגנלים - signal handling¶
בשלב הזה נטפל בסיגנלים כמו שלמדנו בפרק 5.4, כדי שהshell יתנהג כמו shell אמיתי.
מה צריך לממש:
- טיפול ב-SIGINT (Ctrl+C): כשהמשתמש לוחץ Ctrl+C, רוצים שזה יהרוג את התהליך שרץ כרגע, אבל לא את הshell עצמו
- טיפול ב-SIGTSTP (Ctrl+Z): כשהמשתמש לוחץ Ctrl+Z, רוצים לעצור (stop) את התהליך הנוכחי (בונוס)
רמזים:
1. בתחילת main, רשמו signal handler ל-SIGINT שפשוט מדפיס שורה חדשה ומציג prompt חדש:
2. שימוש ב-
signal(SIGINT, sigint_handler) או עדיף sigaction3. בתהליך הבן, לפני execvp, החזירו את הטיפול בSIGINT לברירת מחדל:
ככה הCtrl+C יהרוג את הבן אבל לא את האב.
למה צריך את זה? בshell רגיל (bash), כשאתם מריצים תוכנית ולוחצים Ctrl+C, התוכנית מתה אבל bash ממשיך לרוץ. בלי טיפול בסיגנלים, Ctrl+C יהרוג גם את הshell שלנו.
שלב 5 - תהליכי רקע - background processes (בונוס)¶
בשלב הזה נוסיף תמיכה בהרצת פקודות ברקע עם &.
מה צריך לממש:
- אם הפקודה מסתיימת ב-&, להריץ אותה ברקע - כלומר, לא לחכות (waitpid) לסיום התהליך
- הshell ממשיך לקבל פקודות מיד
- טיפול ב-SIGCHLD כדי לנקות תהליכי רקע שסיימו (כדי למנוע zombie-ים)
דוגמאות:
רמזים:
1. בshלב הparsing, בדקו אם הארגומנט האחרון הוא &. אם כן, הסירו אותו והדליקו דגל "background"
2. אם הדגל דלוק, אחרי fork אל תקראו ל-waitpid - פשוט הדפיסו את הPID והמשיכו
3. רשמו handler ל-SIGCHLD שקורא ל-waitpid עם הדגל WNOHANG כדי לנקות zombie-ים:
void sigchld_handler(int sig) {
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("\n[done] pid %d\n", pid);
}
}
4. הדגל WNOHANG אומר ל-waitpid: "אם אין תהליך שסיים, אל תחכה - תחזור מיד"
רעיונות להרחבה¶
אם סיימתם את כל השלבים ורוצים אתגר נוסף, הנה כמה רעיונות:
תמיכה ב-cd (פקודה מובנית)
הפקודה cd לא יכולה לרוץ כתהליך בן - היא חייבת לשנות את הספריה של הshell עצמו. זו מה שנקרא "built-in command". ממשו אותה עם chdir():
if (strcmp(args[0], "cd") == 0) {
if (args[1] == NULL) {
chdir(getenv("HOME"));
} else {
if (chdir(args[1]) != 0) {
perror("cd");
}
}
continue;
}
תמיכה במשתני סביבה
- הצגת משתני סביבה עם פקודת env (מובנית)
- הגדרת משתנים עם export KEY=VALUE (שימוש ב-setenv)
- הרחבת $VARIABLE בפקודות (החלפת $HOME בערך האמיתי)
היסטוריית פקודות
- שמירת כל פקודה במערך
- תמיכה בחץ למעלה/למטה לניווט בהיסטוריה (דורש שימוש ב-termios לקריאת תווים בודדים)
- שמירת ההיסטוריה לקובץ (כמו .bash_history)
השלמה אוטומטית - tab completion
- כשהמשתמש לוחץ Tab, להשלים שמות קבצים או פקודות
- דורש שימוש ב-termios וסריקת ספריות
תמיכה בצינורות מרובים
- תמיכה בשרשרת של pipes: cmd1 | cmd2 | cmd3 | cmd4
- דורש יצירת מספר pipes ומספר תהליכי בן
תמיכה ב-heredoc
- תמיכה ב-<< לקלט מרובה שורות
- דורש קריאת שורות עד מילת סיום ויצירת pipe עם התוכן
בהצלחה!