לדלג לתוכן

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;
}

קמפלו והריצו:

gcc -o minishell minishell.c
./minishell

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 מקובץ

דוגמאות שצריכות לעבוד:

minishell> ls -la > output.txt
minishell> echo hello >> output.txt
minishell> wc -l < output.txt

רמזים:
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

תצטרכו להוסיף:

#include <fcntl.h>


שלב 3 - צינורות - piping

בשלב הזה נוסיף תמיכה באופרטור | - חיבור הפלט של פקודה אחת לקלט של פקודה אחרת. זה ישירות מה שלמדנו בפרק 5.6 על צינורות.

מה צריך לממש:
- זיהוי התו | בשורת הקלט
- חלוקת הפקודה לשתי פקודות - מה שלפני הpipe ומה שאחריו
- יצירת pipe עם pipe()
- יצירת שני תהליכי בן
- בבן הראשון: הפניית stdout לצד הכתיבה של הpipe
- בבן השני: הפניית stdin לצד הקריאה של הpipe
- באב: סגירת שני צדי הpipe והמתנה לשני הבנים

דוגמאות שצריכות לעבוד:

minishell> ls | grep .c
minishell> cat /etc/passwd | wc -l
minishell> ps aux | grep minishell

רמזים:
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 חדש:

void sigint_handler(int sig) {
    printf("\n");
    printf("minishell> ");
    fflush(stdout);
}

2. שימוש ב-signal(SIGINT, sigint_handler) או עדיף sigaction
3. בתהליך הבן, לפני execvp, החזירו את הטיפול בSIGINT לברירת מחדל:
signal(SIGINT, SIG_DFL);

ככה הCtrl+C יהרוג את הבן אבל לא את האב.

למה צריך את זה? בshell רגיל (bash), כשאתם מריצים תוכנית ולוחצים Ctrl+C, התוכנית מתה אבל bash ממשיך לרוץ. בלי טיפול בסיגנלים, Ctrl+C יהרוג גם את הshell שלנו.


שלב 5 - תהליכי רקע - background processes (בונוס)

בשלב הזה נוסיף תמיכה בהרצת פקודות ברקע עם &.

מה צריך לממש:
- אם הפקודה מסתיימת ב-&, להריץ אותה ברקע - כלומר, לא לחכות (waitpid) לסיום התהליך
- הshell ממשיך לקבל פקודות מיד
- טיפול ב-SIGCHLD כדי לנקות תהליכי רקע שסיימו (כדי למנוע zombie-ים)

דוגמאות:

minishell> sleep 10 &
[1] 12345
minishell> ls
...
minishell>
[1] done    sleep 10

רמזים:
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 עם התוכן

בהצלחה!