איך תהליך נטען בלינוקס - מה קורה בין הרצת פקודה לרגע שהתוכנית רצה¶
מקישים אנטר בטרמינל, ותוך שבריר שנייה התוכנית כבר רצה. מה שקורה בין הרגע הזה למקודם נראה מיידי, אבל בפועל זו שרשרת מסודרת של פעולות שהקרנל מבצע - בונה מרחב זיכרון חדש מאפס, טוען לתוכו את הקובץ הבינארי, ומעביר שליטה לתוכנית רק כשהכל מוכן. התהליך הזה נקרא טעינה - loading, וההבנה שלו היא בדיוק מה שגורם לרעיון של "הרצת תוכנית" להפסיק להיות מובן מאליו.
שני שלבים - fork ואז exec¶
כשמריצים פקודה בטרמינל בלינוקס, בדרך כלל קורים שני דברים נפרדים. קודם, הטרמינל קורא ל-fork, שיוצר עותק כמעט זהה של התהליך הנוכחי - תהליך בן חדש עם אותו זיכרון, אותם קבצים פתוחים, אבל מזהה תהליך משלו. אחר כך, התהליך הבן קורא ל-execve, קריאת המערכת שבאמת מחליפה את כל מה שהתהליך היה, בתוכנית חדשה לגמרי.
זו נקודה שמבלבלת מתחילים - execve לא יוצר תהליך חדש. הוא לוקח תהליך קיים ומחליף לו את כל מרחב הזיכרון, הקוד, והנתונים, בתוכן של קובץ בינארי אחר. מזהה התהליך נשאר אותו דבר, אבל התוכן מוחלף לגמרי.
מה הקרנל בודק לפני שהוא בכלל מתחיל¶
כשהקרנל מקבל את הקריאה ל-execve, הוא קודם בודק שהקובץ שביקשתם להריץ הוא בכלל קובץ הרצה חוקי. בלינוקס, זה אומר לבדוק שהקובץ מתחיל בחתימה המזהה של פורמט ELF - סדרת בתים ספציפית שמאשרת שזה בכלל קובץ בינארי הרצה, ולא קובץ טקסט או תמונה שמישהו סימן בטעות כהרצה. הקרנל קורא גם את הכותרות של הקובץ, שמתארות אילו חלקים ממנו, קטעים - segments, צריך לטעון לזיכרון, ולאיזו כתובת בדיוק.
בניית מרחב הזיכרון מאפס¶
זה השלב שבו קורית העבודה הכבדה. הקרנל מוחק את מרחב הזיכרון הישן של התהליך, ובונה במקומו מרחב חדש לגמרי, לפי מה שכתובות הקטעים בקובץ הבינארי מבקשות. קטע הקוד - text, נטען לכתובת מסוימת ומסומן כלקריאה בלבד ולהרצה, כדי שהתוכנית לא תוכל בטעות לשנות את הקוד שלה עצמה. קטע הנתונים המאותחלים והקטע של משתנים גלובליים ריקים מוקצים בנפרד, ובנוסף הקרנל מקצה מקום למחסנית - stack, ולערימה - heap, שיגדלו בזמן ריצה.
חשוב להבין - הקרנל לרוב לא טוען את כל תוכן הקובץ לזיכרון פיזי באופן מיידי. הוא ממפה את הקטעים דרך מנגנון זיכרון וירטואלי, ורק כשהתוכנית באמת ניגשת לעמוד זיכרון מסוים, מתרחשת טעינה בפועל - מנגנון שנקרא demand paging, שחוסך זמן טעינה ומשאבים כשלא כל הקובץ נדרש מיד.
הכנת המחסנית לפני שהכל מתחיל¶
לפני שהקרנל מעביר שליטה לתוכנית, הוא מכין למחסנית שלה תוכן ראשוני חשוב - הפרמטרים שהתוכנית קיבלה משורת הפקודה, ומשתני הסביבה של המערכת. זו הסיבה ש-argv ו-envp פשוט "מחכים" מוכנים לתוכנית ברגע שהיא מתחילה לרוץ - הקרנל סידר אותם על המחסנית עוד לפני שהתוכנית קיבלה שליטה בכלל.
מי בעצם מקבל שליטה קודם¶
אם התוכנית מקושרת דינמית, כלומר תלויה בספריות משותפות שנטענות בזמן ריצה, הקרנל לא מעביר שליטה ישירות לפונקציית main של התוכנית. הוא מעביר אותה תחילה למקשר הדינמי - dynamic linker, שהוא עצמו תוכנית קטנה שאחראית לטעון את כל הספריות המשותפות הנדרשות, לפתור כתובות של פונקציות חיצוניות, ורק אז להעביר שליטה סופית לנקודת הכניסה של התוכנית עצמה. כל השרשרת הזאת קורית בשברירי שנייה, בלי שום סימן חיצוני שמשהו כל כך מורכב בכלל התרחש.
למה זה שווה להבין¶
ברגע שרואים את כל השרשרת הזאת, fork, execve, בדיקת ELF, בניית מרחב זיכרון, טעינת ספריות, כל הרצת תוכנית מפסיקה להיראות כמו קסם ומתחילה להיראות כמו תהליך מהונדס בקפידה. זה גם בדיוק המקום שממנו מתחילות טכניקות רבות בהנדסה לאחור ומחקר חולשות - להבין מה קורה ברגע הטעינה זו נקודת המוצא להבנת כל התקפה שקשורה לביצוע קוד.
זה חלק מהיסודות שאנחנו בונים בקורס ליבת המחשב - מה בדיוק קורה בין הרצת פקודה לתוכנית שרצה בפועל, כולל כל שלב בדרך.
ואם משהו לא מסתדר לכם באמצע, יש קהילה שכבר עברה את זה.
לסיכום¶
הרצת תוכנית בלינוקס היא לא פעולה אחת אלא שרשרת שלמה - יצירת תהליך, בדיקת קובץ ELF, בניית מרחב זיכרון מאפס, טעינת קטעים, הכנת מחסנית, וטעינת ספריות משותפות לפני שנקודת הכניסה בכלל מתחילה לרוץ. כל זה קורה תוך שברירי שנייה, אבל הבנה של השלבים האלה היא בדיוק מה שהופך "פשוט מריצים תוכנית" למשהו הרבה יותר מרתק ומובן.