9.1 שלבי הקומפילציה הרצאה
הקדמה¶
כשאנחנו כותבים gcc main.c -o main ומקבלים קובץ הרצה, מה בעצם קורה מאחורי הקלעים? זה נראה כמו צעד אחד, אבל בפועל gcc מפעיל ארבעה שלבים שונים - כל אחד עם כלי נפרד ותפקיד שונה.
בפרק 3 למדנו לכתוב C, בפרק 3.7 הכרנו את הpreprocessor, ובפרק 5.11 למדנו על פורמט ELF. עכשיו נחבר את הכל ונבין את התהליך המלא - מקוד מקור ועד קובץ הרצה.
ארבעת השלבים - the four stages¶
תהליך הקומפילציה מורכב מארבעה שלבים:
קוד מקור (.c)
|
| [1] Preprocessing (gcc -E)
v
קוד מקור מעובד (.i)
|
| [2] Compilation (gcc -S)
v
קוד אסמבלי (.s)
|
| [3] Assembly (gcc -c)
v
קובץ אובייקט (.o)
|
| [4] Linking (gcc)
v
קובץ הרצה (executable)
נעבור על כל שלב בפירוט עם דוגמה מעשית. ניקח את הקובץ הבא:
// main.c
#include <stdio.h>
#define GREETING "Hello"
#define WORLD "World"
int main() {
printf("%s, %s!\n", GREETING, WORLD);
return 0;
}
שלב 1 - הpreprocessor - preprocessing¶
הpreprocessor הוא השלב הראשון. הוא לא מבין C - הוא עובד ברמת הטקסט בלבד. מה שהוא עושה:
- מעבד כל שורה שמתחילה ב-
# - מעתיק תוכן של קבצי header (בעקבות
#include) - מחליף מקרואים (בעקבות
#define) - מטפל בתנאי קומפילציה (
#ifdef,#ifndefוכו') - מסיר הערות
כדי לראות את הפלט של שלב זה בלבד:
הקובץ main.i יהיה ענק - אלפי שורות. למה? כי כל התוכן של stdio.h (ושל כל הקבצים שהוא עצמו עושה להם include) הועתק לתוך הקובץ שלנו. בסוף הקובץ נראה את הקוד שלנו, אבל עם המקרואים מוחלפים:
שימו לב - GREETING ו-WORLD הוחלפו בערכים שלהם. זו פעולה טקסטואלית טהורה.
הpreprocessor לעומק¶
בפרק 3.7 כבר הכרנו את הpreprocessor, אבל עכשיו נרחיב ונבין אותו לעומק כחלק מתהליך הקומפילציה.
הנחיית include¶
#include <stdio.h> // חיפוש בתיקיות המערכת (/usr/include)
#include "myheader.h" // חיפוש קודם בתיקייה הנוכחית, אח"כ במערכת
מה שקורה בפועל הוא פשוט: הpreprocessor לוקח את כל התוכן של הקובץ ומדביק אותו במקום שורת ה-include. זה למה אחרי gcc -E הקובץ כל כך גדול - הוא מכיל את כל ההגדרות מכל קבצי הheader.
הנחיית define¶
שני סוגים:
- קבועים - החלפת שם בערך (PI -> 3.14159)
- מקרואים עם פרמטרים - מעין "פונקציה" שמתרחבת בזמן preprocessing
הסוגריים במקרו חשובים. בלעדיהם עלולות להיות בעיות:
// מקרו בעייתי:
#define SQUARE(x) x * x
// SQUARE(2 + 3) -> 2 + 3 * 2 + 3 = 11 (לא 25!)
// מקרו נכון:
#define SQUARE(x) ((x) * (x))
// SQUARE(2 + 3) -> ((2 + 3) * (2 + 3)) = 25
תנאי קומפילציה - conditional compilation¶
#ifdef DEBUG
printf("x = %d\n", x);
#endif
#ifndef MY_HEADER_H
#define MY_HEADER_H
// תוכן הheader
#endif
הpreprocessor בודק תנאים ומחליט אילו חלקי קוד לכלול. זה מאפשר:
- קוד דיבוג שנעלם ב-release
- קוד שתלוי במערכת הפעלה
- הגנה מפני include כפול (include guards)
פרגמה - pragma¶
#pragma once // חלופה להגנת include - נתמך ברוב הקומפיילרים
#pragma pack(1) // שינוי יישור של סטראקט (ביטול padding)
#pragma GCC optimize("O2") // הנחיית אופטימיזציה ספציפית לGCC
הנחיות #pragma הן הוראות ספציפיות לקומפיילר. הן לא חלק מהתקן של C, אבל כל קומפיילר תומך בסט משלו.
מקרואים מוגדרים מראש - predefined macros¶
הpreprocessor מגדיר כמה מקרואים אוטומטית שאפשר להשתמש בהם:
printf("File: %s\n", __FILE__); // שם הקובץ הנוכחי
printf("Line: %d\n", __LINE__); // מספר השורה הנוכחית
printf("Function: %s\n", __func__); // שם הפונקציה הנוכחית
printf("Date: %s\n", __DATE__); // תאריך הקומפילציה
printf("Time: %s\n", __TIME__); // שעת הקומפילציה
אלה שימושיים במיוחד לדיבוג ולוגים:
שומרי הכללה מול pragma once - include guards vs pragma once¶
שתי דרכים למנוע include כפול:
// דרך 1: include guards (הדרך הסטנדרטית)
#ifndef MY_HEADER_H
#define MY_HEADER_H
// תוכן הheader
#endif
// דרך 2: pragma once (הדרך הקצרה)
#pragma once
// תוכן הheader
שתי הדרכים עובדות, אבל #pragma once קצרה יותר ופחות מועדת לשגיאות (אין צורך לבחור שם ייחודי למקרו). מצד שני, היא לא חלק מהתקן הרשמי של C - למרות שכמעט כל קומפיילר מודרני תומך בה.
שלב 2 - הקומפיילר - compilation¶
הקומפיילר (הכלי הפנימי של gcc נקרא cc1) לוקח את הקוד שעבר preprocessing ומתרגם אותו לקוד אסמבלי.
הקובץ main.s יראה משהו כזה (בצורה מפושטת):
.file "main.c"
.section .rodata
.LC0:
.string "%s, %s!\n"
.LC1:
.string "Hello"
.LC2:
.string "World"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
leaq .LC2(%rip), %rdx
leaq .LC1(%rip), %rsi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
ret
מה שהקומפיילר עושה בתוכו:
- ניתוח לקסיקלי - lexical analysis - פירוק הקוד לtoken-ים (מילות מפתח, שמות, אופרטורים)
- ניתוח תחבירי - parsing - בניית עץ תחביר (AST - Abstract Syntax Tree)
- ניתוח סמנטי - semantic analysis - בדיקות טיפוסים, בדיקה שמשתנים מוגדרים, וכו'
- אופטימיזציה - optimization - שיפור הקוד (על זה נרחיב בפרק 9.4)
- יצירת קוד - code generation - תרגום לאסמבלי של ארכיטקטורת היעד
רמות אופטימיזציה - optimization levels¶
gcc -O0 main.c # ללא אופטימיזציה (ברירת מחדל) - מתאים לדיבוג
gcc -O1 main.c # אופטימיזציות בסיסיות
gcc -O2 main.c # אופטימיזציות מתקדמות (מומלץ לproduction)
gcc -O3 main.c # אופטימיזציות אגרסיביות (עלול להגדיל את גודל הקוד)
gcc -Os main.c # אופטימיזציה לגודל קוד מינימלי
gcc -Og main.c # אופטימיזציות שלא פוגעות ביכולת דיבוג
מה אופטימיזציות עושות? כמה דוגמאות:
- קיפול קבועים - constant folding - 3 + 4 מחושב בזמן קומפילציה ומוחלף ב-7
- הסרת קוד מת - dead code elimination - קוד שלעולם לא ירוץ מוסר
- פריסת לולאות - loop unrolling - גוף הלולאה מוכפל כדי להפחית overhead של הסתעפויות
- הטמעת פונקציות - function inlining - גוף הפונקציה מועתק למקום הקריאה במקום לבצע call
- וקטוריזציה אוטומטית - auto-vectorization - שימוש בהוראות SIMD (כמו SSE/AVX) לעיבוד מספר ערכים במקביל
נרחיב על כל אחד מאלה בפרק 9.4.
אזהרות - warnings¶
gcc -Wall main.c # הפעלת רוב האזהרות
gcc -Wextra main.c # אזהרות נוספות
gcc -Werror main.c # התייחסות לאזהרות כשגיאות
gcc -Wall -Wextra -Werror main.c # השילוב המומלץ
אזהרות הן הדרך של הקומפיילר להגיד "הקוד הזה חוקי אבל כנראה יש כאן באג". למשל:
זה חוקי מבחינת התחביר, אבל כמעט בוודאות באג. עם -Wall הקומפיילר יתריע. עם -Werror הקומפילציה תיכשל עד שנתקן את הבעיה.
סמלי דיבוג - debug symbols¶
הדגל -g מוסיף מידע דיבוג בפורמט DWARF לתוך קובץ ההרצה. המידע הזה כולל:
- מיפוי בין כתובות בזיכרון לשורות בקוד המקור
- שמות משתנים וטיפוסים
- מבנה הפונקציות
בלי -g, הקוד עובד אותו דבר, אבל GDB לא יוכל להראות שורות מקור או שמות משתנים. ראינו את זה בפרק 3.9 כשלמדנו דיבוג עם GDB.
שלב 3 - האסמבלר - assembly¶
האסמבלר (הכלי נקרא as) לוקח את קובץ האסמבלי ומתרגם אותו לקוד מכונה - בינארי שהמעבד יודע להריץ. התוצאה היא קובץ אובייקט (object file) עם סיומת .o.
או ישירות מקובץ אסמבלי:
קובץ האובייקט הוא קובץ ELF (כמו שלמדנו בפרק 5.11), אבל הוא עדיין לא קובץ הרצה. למה? כי חסרים בו דברים:
- הכתובות הסופיות של הפונקציות והמשתנים עדיין לא ידועות
- קריאות לפונקציות חיצוניות (כמו printf) עדיין לא מחוברות
- יש בו "חורים" שצריך למלא - אלה נקראים relocation entries
נבדוק את הסקשנים בקובץ האובייקט:
הסקשנים החשובים:
- text. - הקוד (הוראות מכונה)
- data. - משתנים גלובליים מאותחלים
- bss. - משתנים גלובליים לא מאותחלים
- symtab. - טבלת הסמלים (שמות הפונקציות והמשתנים)
- rela.text. - רשומות relocation (הוראות ללינקר: "תמלא כאן כתובת")
רשומות relocation - relocation entries¶
זה הרעיון המרכזי: כשהאסמבלר רואה קריאה ל-printf, הוא לא יודע מה הכתובת הסופית של printf (היא נמצאת בספריה חיצונית). אז הוא:
- שם כתובת זמנית (0) במקום הקריאה
- יוצר רשומת relocation שאומרת: "בoffset הזה, תמלא את הכתובת של הסמל
printf"
נוכל לראות את הrelocation entries:
Relocation section '.rela.text' at offset 0x...:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000500000004 R_X86_64_PLT32 0000000000000000 printf - 4
זה אומר: ב-offset 0x20 בתוך סקשן .text, הלינקר צריך למלא את הכתובת של printf.
שלב 4 - הלינקר - linking¶
השלב האחרון. הלינקר (הכלי נקרא ld, ו-gcc מפעיל אותו אוטומטית) לוקח קבצי אובייקט (ואולי גם ספריות) ומחבר אותם לקובץ הרצה אחד.
מה הלינקר עושה:
1. פתרון סמלים - symbol resolution - מוצא את ההגדרה של כל סמל שמשתמשים בו
2. relocation - קובע כתובות סופיות ומתקן את כל הrelocation entries
3. בניית קובץ ELF סופי - עם program headers שהקרנל צריך לטעינה
נרחיב הרבה על הלינקר בפרק 9.2.
ההתמונה המלאה - דוגמה עם שני קבצים¶
בפועל, פרויקטים מורכבים ממספר קבצי מקור. נראה דוגמה:
// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
// math_utils.c
#include "math_utils.h"
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
// main.c
#include <stdio.h>
#include "math_utils.h"
int main() {
int result = add(3, 4);
printf("3 + 4 = %d\n", result);
result = multiply(3, 4);
printf("3 * 4 = %d\n", result);
return 0;
}
עכשיו נראה את התהליך שלב אחרי שלב:
# שלב 1+2+3: קימפול כל קובץ בנפרד לקובץ אובייקט
gcc -c main.c -o main.o
gcc -c math_utils.c -o math_utils.o
# שלב 4: חיבור כל קבצי האובייקט לקובץ הרצה
gcc main.o math_utils.o -o my_program
מה קורה בכל שלב:
-
קימפול main.c - הpreprocessor מעתיק את התוכן של
stdio.hו-math_utils.h. הקומפיילר רואה הצהרות שלaddו-printf(מתוך הheader-ים) אבל לא את ההגדרות שלהן. הוא מסמן אותן כ-undefined symbols. האסמבלר יוצרmain.oעם relocation entries עבורadd,multiply, ו-printf. -
קימפול math_utils.c - הpreprocessor מעתיק את
math_utils.h. הקומפיילר רואה את ההגדרות שלaddו-multiply. האסמבלר יוצרmath_utils.oעם הסמליםaddו-multiplyמוגדרים. -
לינקוג' - הלינקר לוקח את
main.oו-math_utils.o. הוא רואה ש-main.oצריך אתaddו-multiply, ומוצא אותם ב-math_utils.o. הוא מוצא אתprintfב-libc. הוא קובע כתובות סופיות ומתקן את כל הrelocation entries. התוצאה - קובץ הרצה מוכן.
כלים לבדיקה - inspection tools¶
כמה כלים שימושיים לבדיקת כל שלב:
# בדיקת סמלים בקובץ אובייקט
nm main.o
# U printf <- undefined (מוגדר במקום אחר)
# T main <- defined in text section
# בדיקת סקשנים
readelf -S main.o
# בדיקת relocation entries
readelf -r main.o
# פירוק לאסמבלי (disassembly)
objdump -d main.o
# בדיקת כל המידע
readelf -a main.o
הכלים האלה הם אותם כלים שהשתמשנו בהם בפרק 5.11 (ELF) ובפרק 7 (הנדסה הפוכה). ההבדל הוא שעכשיו אנחנו מבינים למה כל חלק נמצא שם - כי ראינו את התהליך שיצר אותו.
סיכום¶
gcc -E gcc -S gcc -c gcc (ld)
.c file ---------> .i file ---------> .s file ---------> .o file ---------> executable
| | | |
Preprocessor Compiler Assembler Linker
(הpreprocessor) (הקומפיילר) (האסמבלר) (הלינקר)
| | | |
מעבד #include מתרגם C מתרגם אסמבלי מחבר קבצי .o
מחליף #define לאסמבלי לקוד מכונה וספריות
מטפל ב-#ifdef מבצע יוצר קובץ ELF פותר סמלים
מסיר הערות אופטימיזציות עם relocations קובע כתובות
ברגע שמבינים את ארבעת השלבים, הרבה דברים שנראו מסתוריים הופכים להגיוניים:
- למה צריך header files? כי הקומפיילר מעבד קובץ אחד בכל פעם, ובזמן קומפילציה של main.c הוא צריך לדעת שהפונקציה add קיימת (אפילו שההגדרה שלה בקובץ אחר)
- למה יש שגיאות "undefined reference"? כי הלינקר לא מצא הגדרה לסמל
- למה הheader צריך include guards? כי הpreprocessor עלול להעתיק את אותו קובץ פעמיים
בפרק הבא נצלול לעומק ללינקר - הכלי שמחבר הכל ביחד.