לדלג לתוכן

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 וכו')
  • מסיר הערות

כדי לראות את הפלט של שלב זה בלבד:

gcc -E main.c -o main.i

הקובץ main.i יהיה ענק - אלפי שורות. למה? כי כל התוכן של stdio.h (ושל כל הקבצים שהוא עצמו עושה להם include) הועתק לתוך הקובץ שלנו. בסוף הקובץ נראה את הקוד שלנו, אבל עם המקרואים מוחלפים:

int main() {
    printf("%s, %s!\n", "Hello", "World");
    return 0;
}

שימו לב - GREETING ו-WORLD הוחלפו בערכים שלהם. זו פעולה טקסטואלית טהורה.


הpreprocessor לעומק

בפרק 3.7 כבר הכרנו את הpreprocessor, אבל עכשיו נרחיב ונבין אותו לעומק כחלק מתהליך הקומפילציה.

הנחיית include

#include <stdio.h>     // חיפוש בתיקיות המערכת (/usr/include)
#include "myheader.h"  // חיפוש קודם בתיקייה הנוכחית, אח"כ במערכת

מה שקורה בפועל הוא פשוט: הpreprocessor לוקח את כל התוכן של הקובץ ומדביק אותו במקום שורת ה-include. זה למה אחרי gcc -E הקובץ כל כך גדול - הוא מכיל את כל ההגדרות מכל קבצי הheader.

הנחיית define

#define PI 3.14159
#define MAX(a, b) ((a) > (b) ? (a) : (b))

שני סוגים:
- קבועים - החלפת שם בערך (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__);    // שעת הקומפילציה

אלה שימושיים במיוחד לדיבוג ולוגים:

#define LOG(msg) fprintf(stderr, "[%s:%d] %s\n", __FILE__, __LINE__, msg)

שומרי הכללה מול 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 ומתרגם אותו לקוד אסמבלי.

gcc -S main.c -o main.s

הקובץ 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

מה שהקומפיילר עושה בתוכו:

  1. ניתוח לקסיקלי - lexical analysis - פירוק הקוד לtoken-ים (מילות מפתח, שמות, אופרטורים)
  2. ניתוח תחבירי - parsing - בניית עץ תחביר (AST - Abstract Syntax Tree)
  3. ניתוח סמנטי - semantic analysis - בדיקות טיפוסים, בדיקה שמשתנים מוגדרים, וכו'
  4. אופטימיזציה - optimization - שיפור הקוד (על זה נרחיב בפרק 9.4)
  5. יצירת קוד - 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  # השילוב המומלץ

אזהרות הן הדרך של הקומפיילר להגיד "הקוד הזה חוקי אבל כנראה יש כאן באג". למשל:

int x;
if (x == 5) { ... }  // Warning: x is uninitialized

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

סמלי דיבוג - debug symbols

gcc -g main.c -o main

הדגל -g מוסיף מידע דיבוג בפורמט DWARF לתוך קובץ ההרצה. המידע הזה כולל:
- מיפוי בין כתובות בזיכרון לשורות בקוד המקור
- שמות משתנים וטיפוסים
- מבנה הפונקציות

בלי -g, הקוד עובד אותו דבר, אבל GDB לא יוכל להראות שורות מקור או שמות משתנים. ראינו את זה בפרק 3.9 כשלמדנו דיבוג עם GDB.


שלב 3 - האסמבלר - assembly

האסמבלר (הכלי נקרא as) לוקח את קובץ האסמבלי ומתרגם אותו לקוד מכונה - בינארי שהמעבד יודע להריץ. התוצאה היא קובץ אובייקט (object file) עם סיומת .o.

gcc -c main.c -o main.o

או ישירות מקובץ אסמבלי:

as main.s -o main.o

קובץ האובייקט הוא קובץ ELF (כמו שלמדנו בפרק 5.11), אבל הוא עדיין לא קובץ הרצה. למה? כי חסרים בו דברים:
- הכתובות הסופיות של הפונקציות והמשתנים עדיין לא ידועות
- קריאות לפונקציות חיצוניות (כמו printf) עדיין לא מחוברות
- יש בו "חורים" שצריך למלא - אלה נקראים relocation entries

נבדוק את הסקשנים בקובץ האובייקט:

readelf -S main.o

הסקשנים החשובים:
- text. - הקוד (הוראות מכונה)
- data. - משתנים גלובליים מאותחלים
- bss. - משתנים גלובליים לא מאותחלים
- symtab. - טבלת הסמלים (שמות הפונקציות והמשתנים)
- rela.text. - רשומות relocation (הוראות ללינקר: "תמלא כאן כתובת")

רשומות relocation - relocation entries

זה הרעיון המרכזי: כשהאסמבלר רואה קריאה ל-printf, הוא לא יודע מה הכתובת הסופית של printf (היא נמצאת בספריה חיצונית). אז הוא:

  1. שם כתובת זמנית (0) במקום הקריאה
  2. יוצר רשומת relocation שאומרת: "בoffset הזה, תמלא את הכתובת של הסמל printf"

נוכל לראות את הrelocation entries:

readelf -r main.o
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 מפעיל אותו אוטומטית) לוקח קבצי אובייקט (ואולי גם ספריות) ומחבר אותם לקובץ הרצה אחד.

gcc main.o -o main

מה הלינקר עושה:
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

מה קורה בכל שלב:

  1. קימפול main.c - הpreprocessor מעתיק את התוכן של stdio.h ו-math_utils.h. הקומפיילר רואה הצהרות של add ו-printf (מתוך הheader-ים) אבל לא את ההגדרות שלהן. הוא מסמן אותן כ-undefined symbols. האסמבלר יוצר main.o עם relocation entries עבור add, multiply, ו-printf.

  2. קימפול math_utils.c - הpreprocessor מעתיק את math_utils.h. הקומפיילר רואה את ההגדרות של add ו-multiply. האסמבלר יוצר math_utils.o עם הסמלים add ו-multiply מוגדרים.

  3. לינקוג' - הלינקר לוקח את 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 עלול להעתיק את אותו קובץ פעמיים

בפרק הבא נצלול לעומק ללינקר - הכלי שמחבר הכל ביחד.