לדלג לתוכן

שלבי הקומפילציה מוסברים - מה קורה בפועל כשמריצים gcc על קובץ אחד

מרבית המתכנתים מריצים gcc file.c -o program, מקבלים קובץ הרצה, וממשיכים הלאה בלי לחשוב פעמיים. אבל מאחורי הפקודה הבודדת הזאת מסתתרים ארבעה שלבים נפרדים לגמרי, שכל אחד מהם מייצר קובץ ביניים משלו, ואפשר לעצור אחרי כל אחד מהם ולבדוק בדיוק מה קרה. ההבנה של השלבים האלה, ברמה המעשית, היא בדיוק מה שהופך שגיאות קומפילציה ולינקינג מבלבול לאבחון מהיר.

שלב ראשון - preprocessing

השלב הראשון מטפל בכל מה שמתחיל בסולמית - #include ו-#define, בדיוק כמו שראינו כשהסברנו על ה-Preprocessor. אפשר לעצור בדיוק בשלב הזה עם הדגל -E:

gcc -E file.c -o file.i

הקובץ file.i שנוצר הוא עדיין C תקני, אבל עם כל ה-includes מודבקים בפועל וכל המאקרואים מוחלפים. אם פותחים קובץ כזה, לרוב מגלים שהוא ארוך משמעותית מהקובץ המקורי, כי כל קובצי ה-header שנכללו נמצאים עכשיו שם במלואם.

שלב שני - compilation לאסמבלי

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

gcc -S file.c -o file.s

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

שלב שלישי - assembly לקוד אובייקט

השלב השלישי לוקח את קובץ האסמבלי ומתרגם אותו לקוד מכונה בינארי אמיתי, ארוז בפורמט ELF שכבר הכרנו, אבל עדיין לא כקובץ הרצה שלם - כקובץ אובייקט, relocatable. הדגל לעצירה כאן הוא -c:

gcc -c file.c -o file.o

קובץ file.o מכיל קוד מכונה, אבל עם "חורים" בכל מקום שבו הקוד קורא לפונקציה שמוגדרת בקובץ אחר, כי בשלב הזה עדיין לא ידוע לאיזו כתובת סופית הפונקציה תיטען. אפשר לבדוק את תוכן קובץ האובייקט עם readelf או objdump, ולראות בדיוק אילו סמלים הוא מגדיר ואילו הוא עדיין זקוק להם מבחוץ.

שלב רביעי - linking

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

זה גם השלב שבו מופיעות שגיאות "undefined reference" - כשהקוד קורא לפונקציה שהוגדרה בקובץ אחר, אבל הלינקר לא הצליח למצוא אותה בשום מקום. שגיאה כזאת שונה במהותה משגיאת קומפילציה רגילה - היא לא נובעת מבעיה בתחביר, אלא מכשל בשלב שמגיע אחרי הקומפילציה בכלל.

למה שווה להריץ כל שלב בנפרד

כשמריצים gcc file.c -o program בלי דגלים, כל ארבעת השלבים רצים ברצף אוטומטית, וכל קבצי הביניים נמחקים בסוף. אבל כשמנסים לאבחן בעיה, אפשר תמיד לעצור באמצע ולבדוק - האם ה-include בכלל נכלל נכון? האם האסמבלי שנוצר הגיוני? האם קובץ האובייקט מכיל את הסמל שציפיתם? זה הופך תהליך שנראה כמו קופסה שחורה אטומה, לרצף ברור של קבצי ביניים שאפשר לבדוק כל אחד מהם בנפרד.

למה זה שווה להבין ברמה הזאת

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

ואם שגיאת לינקינג מסתורית תקעה אתכם, יש קהילה שכבר פענחה כמה כאלה.

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

לסיכום

קומפילציה של קובץ C בודד היא בעצם רצף של ארבעה שלבים נפרדים - preprocessing שמדביק includes ומחליף מאקרואים, compilation שמתרגם לאסמבלי, assembly שמייצר קוד מכונה בפורמט ELF חלקי, ולינקינג שמחבר הכל לקובץ הרצה שלם. כל שלב אפשר לעצור בנפרד עם דגל מתאים ב-gcc, ולבדוק את הקובץ שהוא מייצר - וזו בדיוק הדרך הכי מהירה להבין איפה בדיוק שגיאה קומפילציה מסוימת בכלל מתרחשת.