לדלג לתוכן

בונים shell משלכם ב-C - הפרויקט שסוף סוף מסביר איך תהליכים עובדים

יש רגע קלאסי בלמידת מערכות הפעלה שבו כל המושגים המופשטים - תהליך, קריאת מערכת, fork - פתאום הופכים למוחשיים. הרגע הזה קורה כשבונים shell משלכם. זה לא פרויקט תיאורטי - זה כלי שאתם משתמשים בו כל יום (bash, zsh) ופתאום אתם מבינים איך הוא בכלל עובד מבפנים.

מה shell עושה בעצם

shell הוא תוכנית רגילה - לא חלק מהקרנל, לא קסם. כשאתם מקלידים ls -la ולוחצים Enter, ה-shell קורא את השורה, מפרק אותה למילים, ומבקש מהמערכת ההפעלה להריץ תוכנית בשם ls עם הפרמטר -la. כל הסיפור המרתק קורה בשלושה שלבים שחוזרים על עצמם שוב ושוב - זו בדיוק הלולאה שבונים בפרויקט: read - parse - execute.

שלב 1 - קריאת הקלט

הלולאה הראשית פשוטה - מדפיסים prompt, קוראים שורה מהמשתמש, ומעבירים אותה לעיבוד:

while (1) {
    printf("mini-shell> ");
    fgets(line, sizeof(line), stdin);
    // פירוק והרצה כאן
}

שלב 2 - פירוק השורה לטוקנים

השורה שהוקלדה היא מחרוזת בודדת, אבל כדי להריץ אותה צריך לפרק אותה למערך של מילים - שם התוכנית והפרמטרים שלה. ב-C זה נעשה בדרך כלל עם strtok, שמפצל מחרוזת לפי תו הפרדה (כאן, רווח):

char *args[MAX_ARGS];
int i = 0;
char *token = strtok(line, " \n");
while (token != NULL) {
    args[i++] = token;
    token = strtok(NULL, " \n");
}
args[i] = NULL;

השדה האחרון חייב להיות NULL - זו דרישה של פונקציות ה-exec שנשתמש בהן בשלב הבא, כדי שהן ידעו איפה רשימת הארגומנטים מסתיימת.

שלב 3 - fork ו-exec, לב הפרויקט

כאן קורה הקסם האמיתי. כדי להריץ תוכנית חדשה בלי "להרוג" את ה-shell עצמו, קוראים לפונקציית fork(), שיוצרת עותק כמעט זהה של התהליך הנוכחי. אחרי הקריאה, יש שני תהליכים שרצים - ה"הורה" (ה-shell) וה"ילד" (העותק). ב"ילד", קוראים לאחת מפונקציות ה-exec, שמחליפה את התוכנית שרצה בתהליך הנוכחי בתוכנית חדשה לגמרי:

pid_t pid = fork();
if (pid == 0) {
    // כאן אנחנו בתהליך הילד
    execvp(args[0], args);
    perror("execvp נכשל");
    exit(1);
} else {
    // כאן אנחנו בתהליך ההורה - מחכים שהילד יסיים
    int status;
    waitpid(pid, &status, 0);
}

זה בדיוק המנגנון שבו כל shell אמיתי מריץ פקודות - fork יוצר תהליך חדש, exec טוען אליו תוכנית, וה-waitpid בהורה מבטיח שה-shell מחכה שהפקודה תסתיים לפני שהוא מציג prompt חדש.

שלב 4 - פקודות מובנות (built-ins)

לא כל פקודה יכולה לעבור דרך fork ו-exec. הדוגמה הכי ברורה היא cd. אם cd היה מריץ תוכנית חיצונית בתהליך ילד נפרד, שינוי התיקייה היה קורה רק בתוך הילד - וברגע שהוא מסתיים, ה-shell עצמו (ההורה) היה נשאר באותה תיקייה כמו קודם. לכן cd (וגם exit) חייבים להיות מטופלים ישירות בקוד ה-shell עצמו, עם chdir(), ולא דרך תהליך נפרד:

if (strcmp(args[0], "cd") == 0) {
    chdir(args[1]);
    continue;
}
if (strcmp(args[0], "exit") == 0) {
    exit(0);
}

מה זה מלמד שקורס תיאורטי לא מלמד

אחרי הפרויקט הזה, מושגים כמו PID, תהליך הורה וילד, וקריאות מערכת מפסיקים להיות שקופיות בהרצאה והופכים לדברים שראיתם לרוץ מול העיניים שלכם. זה גם חושף בעיות אמיתיות שצריך להתמודד איתן - מה קורה אם fork נכשל? מה קורה אם המשתמש מקליד פקודה שלא קיימת? כל אחת מהשאלות האלה מכריחה אתכם להבין לעומק את הממשק שבין תוכנית לבין מערכת ההפעלה.

מה מרוויחים מהפרויקט

מיני shell הוא אחד הפרויקטים הכי מומלצים בכל קורס מערכות הפעלה, בדיוק כי הוא קטן מספיק להשלים תוך כמה ימים, אבל נוגע כמעט בכל מושג ליבה - תהליכים, קריאות מערכת, ניהול זיכרון בסיסי, וטיפול בשגיאות. אם תבינו אותו לעומק, תתחילו לראות תהליכים ומערכות הפעלה בעיניים אחרות לגמרי.

אם הפרויקט הזה עורר לכם עניין בעולם שמתחת לשפות התכנות ברמה גבוהה, קורס ליבת המחשב שלנו לוקח את זה עוד כמה צעדים קדימה - עם C, אסמבלי, והבנה אמיתית של איך מחשב עובד מבפנים.

תקועים ב-fork, ב-exec, או פשוט רוצים להראות את ה-shell שבניתם? מוזמנים לבוא.

הצטרפו לקהילה בדיסקורד