אז מה execve עושה?
ואיך לינוקס יודעת ״להריץ״ תוכנות?
למעשה זו שאלה עם תשובה מאוד מורכבת.
התהליך הוא מאוד מורכב, אבל מאוד מגניב ומעניין.
מיד נצלול פנימה.
הbinfmt - איך לינוקס יודעת להריץ קבצים¶
בהסבר הקצר הזה נבין מה זה binfmt, איך מערכת ההפעלה מזהה סוגי קבצים בינאריים, ואיך אפשר להוסיף תמיכה בסוגי קבצים חדשים.
מה זה binfmt¶
מנגנון binfmt = Binary Format
זהו מנגנון בלינוקס שמאפשר לקרנל להבין איך להריץ קובץ מסוים. (קובץ הרצה)
כאשר מריצים קובץ:
הקרנל לא "מנחש"- הוא בודק את פורמט הקובץ לפי binfmt handler מתאים.
דוגמה- ELF¶
רוב התוכנות בלינוקס הן בפורמט ELF, הפורמט המקובל לקבצי הרצה (בינארים)
כשהקרנל רואה קובץ ELF:
- קורא את ה4 בתים הראשונים של הקובץ,וקורא את הדפוס הבא magic bytes (
0x7F ELF) - משתמש ב־ELF loader (הקוד הקרנלי שיודע להריץ קבצי elf)
- טוען לזיכרון.
- מריץ.
למעשה, כאשר אנחנו מריצים execve על נתיב של קובץ מסוים, הקרנל מפעיל את מנגנון binfmt, שקורא את תחילת הקובץ, ויודע לשייך את הקובץ למנגנון שיודע לטעון אותו לזיכרון
- בהמשך ההרצה נתעכב יותר לעומק על פורמט הelf ואיך הקרנל טוען אותו.
binfmt_misc -הוספת פורמטים חדשים¶
לינוקס מאפשר להוסיף handlers מותאמים אישית דרך:
זה נקרא:
באמצעותו אפשר לגרום ללינוקס להריץ:
- קבצי Python בלי
python file.py - קבצי Java באמצעות jvm
- קבצי Windows דרך Wine
- קבצי ARM דרך QEMU
- כל פורמט מותאם אישית
איך זה עובד¶
כאשר מוסיפים handler, מגדירים:
- הMagic bytes או extension
- איזה interpreter להריץ
- איך להעביר לו את הקובץ
כלומר: אם קובץ מתחיל ב־XYZ → הרץ /usr/bin/my_loader
דוגמה- הרצת קבצי ARM¶
כידוע לכם, אם יש לנו מעבד עם ארכטיקטורת x86, הוא לא יכול להריץ arm- אלה רק באמצעות מכונה וירטואלית.
מערכות רבות משתמשות בbinfmt כדי להריץ קוד ARM על x86 בעזרת QEMU. (מכונה וירטואלית לינוקסית)
הקרנל מזהה ELF של ARM → מעביר ל־QEMU → QEMU מריץ.
הכל שקוף למשתמש.
הshebang¶
הbinfmt זה מנגנון מורכב יחסית, יודע לזהות קבצי הרצה באמצעות magic מסוים, ולפי זה להפעיל loader כלשהו.
הShebang זה מנגנון פשוט יותר, שכאשר לינוקס מזהה שהקובץ מתחיל ב#! (שבנג) הוא לוקח את הנתיב שמצויין בהמשך ומשתמש בו כloader לקובץ.
לדוגמה, אם נרצה להריץ סקריפט פייתון, נוכל בתחילת הסקריפט לציין את השורה הבאה:
וכאשר הקרנל יריץ את הקובץ, הוא ידע להשתמש ב/usr/bin/python3 כדי להריץ את הקובץ.
למעשה שבנג זה מנגנון ברמת המשתמש (user space), הוא לא קרנלי.
ולעומת זאת הbinfmt:
- מנגנון ברמת הקרנל
- מזהה לפי magic bytes שהגדרנו מראש ומריץ loader שציינו מראש.
למה זה חשוב¶
- הרצת פורמטים שונים
- אמולציה (QEMU)
- קונטיינרים multi-arch
- מכונות וירטואליות Wine / JVM
- הרצת loaders מותאמים אישית
צפייה ב-handlers קיימים¶
הELF loader¶
הELF loader הוא רכיב קרנלי שיודע לטעון קבצי הרצה מסוג ELF לזיכרון של תהליך.
פורמט ELF הוא הפורמט הלינוקסי לקבצי הרצה (בווינדוס זה PE), ובחלק הזה נצלול לעומק לפורמט עצמו ונבין כיצד הELF loader יודע לפרסר אותו ולטעון אותו לזיכרון.
פורמט ELF - Executable and Linkable Format¶
פורמט ELF הוא הפורמט הסטנדרטי לקבצי הרצה, ספריות משותפות וקבצי אובייקט בלינוקס.
כל קובץ בינארי שקימפלנו עם gcc לאורך הקורס - הוא קובץ ELF.
נבין עכשיו מה יש בתוך קובץ ELF, ואיך הקרנל יודע לקרוא אותו ולטעון אותו לזיכרון.
הELF Header - כותרת הקובץ¶
כל קובץ ELF מתחיל בכותרת (header) שנמצאת ממש בתחילת הקובץ.
הכותרת מכילה מידע קריטי שהקרנל צריך כדי להבין מה לעשות עם הקובץ:
| שדה | הסבר |
|---|---|
| Magic bytes | ארבעת הבתים הראשונים: 0x7F 0x45 0x4C 0x46 (שזה \x7FELF). ככה הקרנל מזהה שזה קובץ ELF |
| Class | האם זה קובץ 32 ביט או 64 ביט. קובע את גודל הכתובות בקובץ |
| Data (סדר בתים) | האם הקובץ בפורמט Little Endian או Big Endian. כזכור מפרק 0, רוב המעבדים שלנו עובדים ב-Little Endian |
| Type | סוג הקובץ - האם זה executable (קובץ הרצה), shared object (ספריה משותפת), או relocatable (קובץ אובייקט, לפני לינקור) |
| Machine | ארכיטקטורת המעבד שהקובץ מיועד לה - לדוגמה x86, x86_64, ARM וכו' |
| Entry point | הכתובת בזיכרון שבה התוכנית מתחילה לרוץ. זו הכתובת שאליה הקרנל קופץ אחרי שהוא טוען את הקובץ |
נוכל לראות את הכותרת של כל קובץ ELF באמצעות הפקודה readelf -h:
פלט לדוגמה:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x401000
Start of program headers: 64 (bytes into file)
Start of section headers: 4512 (bytes into file)
...
שימו לב לEntry point - זו הכתובת שבה הקוד שלנו מתחיל לרוץ. הקרנל ישתמש בכתובת הזו כדי לדעת לאן לקפוץ אחרי שהוא טוען את הקובץ לזיכרון.
הProgram Headers - סגמנטים¶
אחרי הכותרת, קובץ ELF מכיל טבלת program headers.
כל entry בטבלה הזו מתאר סגמנט - חלק מהקובץ שצריך להיטען לזיכרון (או מכיל מידע חשוב אחר).
נוכל לראות את הסגמנטים באמצעות:
PT_LOAD - סגמנטים שנטענים לזיכרון¶
הסגמנטים הכי חשובים הם מסוג PT_LOAD - אלה הסגמנטים שהקרנל באמת טוען לזיכרון.
בדרך כלל יש לפחות שניים:
- סגמנט הקוד - מכיל את הוראות המכונה של התוכנית. מסומן עם הרשאות קריאה והרצה (R-X)
- סגמנט הנתונים - מכיל משתנים גלובליים ונתונים אחרים. מסומן עם הרשאות קריאה וכתיבה (RW-)
שימו לב - סגמנט הקוד הוא לא writable וסגמנט הנתונים הוא לא executable. זו הפרדה חשובה מבחינת אבטחה.
PT_INTERP - נתיב הDynamic Linker¶
סגמנט מסוג PT_INTERP מכיל את הנתיב לdynamic linker (לרוב /lib64/ld-linux-x86-64.so.2).
הdynamic linker אחראי לטעון ספריות משותפות שהתוכנית תלויה בהן (כמו libc).
נרחיב על זה בהרצאה הבאה.
PT_DYNAMIC - מידע על ספריות דינמיות¶
סגמנט מסוג PT_DYNAMIC מכיל מידע שהdynamic linker צריך - רשימת ספריות משותפות, טבלאות סמלים, ועוד.
גם על זה נרחיב בהמשך.
איך הקרנל טוען קובץ ELF¶
עכשיו שאנחנו מבינים את מבנה הקובץ, נבין את התהליך שהקרנל עובר כשהוא טוען קובץ ELF:
שלב 1 - קריאת הכותרת¶
הקרנל קורא את תחילת הקובץ ובודק את הmagic bytes (\x7FELF). אם הם תואמים, הוא יודע שזה קובץ ELF וממשיך לקרוא את שאר הכותרת - סוג הקובץ, ארכיטקטורה, entry point, ואיפה נמצאים הprogram headers.
שלב 2 - מיפוי סגמנטים לזיכרון¶
הקרנל עובר על טבלת הprogram headers ומחפש סגמנטים מסוג PT_LOAD.
לכל סגמנט כזה, הקרנל משתמש ב-mmap כדי למפות את תוכן הסגמנט מהקובץ אל הזיכרון של התהליך.
כאן נכנס לתמונה מה שלמדנו בפרק 2 על paging - הקרנל יוצר page table entries שממפים כתובות וירטואליות לדפים פיזיים. כל סגמנט ממופה עם ההרשאות המתאימות:
- סגמנט הקוד: read + execute
- סגמנט הנתונים: read + write
שלב 3 - הכנת הStack¶
הקרנל מקצה מקום לstack של התהליך ומאתחל אותו. הוא שם על הstack את:
- הארגומנטים של התוכנית (argc, argv)
- משתני הסביבה (envp)
- מידע נוסף שהתוכנית צריכה (auxiliary vector)
שלב 4 - קפיצה לEntry Point¶
לבסוף, הקרנל מעדכן את רגיסטר ה-instruction pointer (שלמדנו עליו בפרק 1 ו-2) לכתובת הentry point שמצוינת בכותרת הELF, והתוכנית מתחילה לרוץ.
אם הקובץ הוא דינמי (כלומר תלוי בספריות משותפות), הקרנל לא קופץ ישירות לentry point של התוכנית. במקום זה, הוא קודם מעביר שליטה לdynamic linker (שהנתיב אליו מופיע בPT_INTERP), שטוען את הספריות הנדרשות, ורק אז קופץ לentry point של התוכנית עצמה.
סקשנים מול סגמנטים - sections vs segments¶
בפורמט ELF יש שני סוגי "חלוקה" של הקובץ, וזה יכול לבלבל:
- סגמנטים (segments) - מוגדרים בprogram headers. אלה מיועדים לloader - הם אומרים לקרנל מה לטעון לזיכרון ואיפה. זה מה שמעניין אותנו בזמן ריצה.
- סקשנים (sections) - מוגדרים בsection headers. אלה מיועדים לlinker ולכלי דיבוג - הם מכילים מידע מפורט יותר כמו טבלת סמלים, מידע דיבוג, וכו'.
כלומר: הloader מסתכל על סגמנטים, הlinker מסתכל על סקשנים.
בפועל, סגמנט אחד יכול להכיל מספר סקשנים. לדוגמה, סגמנט הקוד (PT_LOAD עם הרשאות R-X) מכיל את הסקשנים .text (קוד), .rodata (נתונים לקריאה בלבד) ועוד.
קובץ סטטי מול דינמי - static vs dynamic¶
יש שני סוגי קבצי הרצה:
- סטטי (static) - כל הקוד שהתוכנית צריכה כבר נמצא בתוך הקובץ עצמו, כולל הפונקציות מlibc. הקובץ גדול יותר, אבל לא תלוי בשום ספריה חיצונית.
- דינמי (dynamic) - הקובץ מכיל רק את הקוד של התוכנית עצמה, ומציין שהוא תלוי בספריות חיצוניות (כמו libc.so). בזמן ריצה, הdynamic linker טוען את הספריות לזיכרון ומחבר אותן.
אפשר לקמפל קובץ סטטי עם:
נרחיב על ספריות דינמיות ועל תהליך הלינקור בהרצאה הבאה.
בדיקת קובץ ELF בפועל¶
נסכם עם הכלים השימושיים לבדיקת קבצי ELF:
# הצגת הכותרת (header)
readelf -h ./my_program
# הצגת הסגמנטים (program headers)
readelf -l ./my_program
# הצגת הסקשנים (section headers)
readelf -S ./my_program
# בדיקה האם קובץ סטטי או דינמי
file ./my_program
# פלט לדוגמה: my_program: ELF 64-bit LSB executable, x86-64, dynamically linked
# הצגת ספריות שהקובץ תלוי בהן
ldd ./my_program
תנסו להריץ את הפקודות האלה על תוכניות שקימפלתם בפרקים הקודמים ותראו את המבנה בעצמכם.