לדלג לתוכן

9.1 - שלבי הקומפילציה - הרצאה

הקדמה

כשאנחנו כותבים gcc main.c -o main ומקבלים קובץ הרצה, מה בעצם קורה מאחורי הקלעים? זה נראה כמו צעד אחד, אבל בפועל gcc מפעיל ארבעה שלבים שונים - כל אחד עם כלי נפרד ותפקיד שונה.

בפרק 3 למדנו לכתוב C, בפרק 3.7 הכרנו את הpreprocessor, ובפרק 5.11 למדנו על פורמט ELF. עכשיו נחבר את הכל ונבין את התהליך המלא - מקוד מקור ועד קובץ הרצה.

ארבעת השלבים - the four stages

תהליך הקומפילציה מורכב מארבעה שלבים:

source code (.c)
    |
    | [1] Preprocessing (gcc -E)
    v
processed source code (.i)
    |
    | [2] Compilation (gcc -S)
    v
assembly code (.s)
    |
    | [3] Assembly (gcc -c)
    v
object file (.o)
    |
    | [4] Linking (gcc)
    v
executable file (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>     // search in system directories (/usr/include)
#include "myheader.h"  // search current directory first, then system directories

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

הנחיית define

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

שני סוגים:
- קבועים - החלפת שם בערך (PI -> 3.14159)
- מקרואים עם פרמטרים - מעין "פונקציה" שמתרחבת בזמן preprocessing

הסוגריים במקרו חשובים. בלעדיהם עלולות להיות בעיות:

// problematic macro:
#define SQUARE(x) x * x
// SQUARE(2 + 3) -> 2 + 3 * 2 + 3 = 11 (not 25!)

// correct macro:
#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 content
#endif

הpreprocessor בודק תנאים ומחליט אילו חלקי קוד לכלול. זה מאפשר:
- קוד דיבוג שנעלם ב-release
- קוד שתלוי במערכת הפעלה
- הגנה מפני include כפול (include guards)

פרגמה - pragma

#pragma once              // alternative to include guard - supported by most compilers
#pragma pack(1)           // changes struct alignment (disables padding)
#pragma GCC optimize("O2") // GCC-specific optimization directive

הנחיות #pragma הן הוראות ספציפיות לקומפיילר. הן לא חלק מהתקן של C, אבל כל קומפיילר תומך בסט משלו.

מקרואים מוגדרים מראש - predefined macros

הpreprocessor מגדיר כמה מקרואים אוטומטית שאפשר להשתמש בהם:

printf("File: %s\n", __FILE__);    // current file name
printf("Line: %d\n", __LINE__);    // current line number
printf("Function: %s\n", __func__); // current function name
printf("Date: %s\n", __DATE__);    // compilation date
printf("Time: %s\n", __TIME__);    // compilation time

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

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

שומרי הכללה מול pragma once - include guards vs pragma once

שתי דרכים למנוע include כפול:

// way 1: include guards (the standard way)
#ifndef MY_HEADER_H
#define MY_HEADER_H
// header content
#endif

// way 2: pragma once (the short way)
#pragma once
// header content

שתי הדרכים עובדות, אבל #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   # no optimization (default) - suitable for debugging
gcc -O1 main.c   # basic optimizations
gcc -O2 main.c   # advanced optimizations (recommended for production)
gcc -O3 main.c   # aggressive optimizations (may increase code size)
gcc -Os main.c   # optimization for minimal code size
gcc -Og main.c   # optimizations that don't hurt debuggability

מה אופטימיזציות עושות? כמה דוגמאות:
- קיפול קבועים - 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        # enables most warnings
gcc -Wextra main.c      # additional warnings
gcc -Werror main.c      # treats warnings as errors
gcc -Wall -Wextra -Werror main.c  # the recommended combination

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

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. - טבלת הsymbols (שמות הפונקציות והמשתנים)
- rela.text. - רשומות relocation (הוראות ללינקר: "תמלא כאן כתובת")

רשומות relocation - relocation entries

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

  1. שם כתובת זמנית (0) במקום הקריאה
  2. יוצר רשומת relocation שאומרת: "בoffset הזה, תמלא את הכתובת של הsymbol 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.

אנימציה של מילוי רשומת relocation, שמראה כיצד האסמבלר משאיר במקום הקריאה כתובת אפס ורושם חור בסקשן rela.text, והלינקר מוצא את הכתובת הסופית של printf וממלא אותה במקום האפסים

שלב 4 - הלינקר - linking

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

gcc main.o -o main

מה הלינקר עושה:
1. פתרון symbols - symbol resolution - מוצא את ההגדרה של כל symbol שמשתמשים בו
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;
}

עכשיו נראה את התהליך שלב אחרי שלב:

# steps 1+2+3: compile each file separately to an object file
gcc -c main.c -o main.o
gcc -c math_utils.c -o math_utils.o

# step 4: link all object files into an executable
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 עם הsymbols add ו-multiply מוגדרים.

  3. לינקוג' - הלינקר לוקח את main.o ו-math_utils.o. הוא רואה ש-main.o צריך את add ו-multiply, ומוצא אותם ב-math_utils.o. הוא מוצא את printf ב-libc. הוא קובע כתובות סופיות ומתקן את כל הrelocation entries. התוצאה - קובץ הרצה מוכן.

כלים לבדיקה - inspection tools

כמה כלים שימושיים לבדיקת כל שלב:

# checking symbols in the object file
nm main.o
# U printf        <- undefined (defined elsewhere)
# T main          <- defined in text section

# checking sections
readelf -S main.o

# checking relocation entries
readelf -r main.o

# disassembly
objdump -d main.o

# checking all information
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)      (compiler)           (assembler)          (linker)
              |                   |                   |                   |
        processes #include  translates C      translates assembly  links .o files
        replaces #define    to assembly       to machine code      and libraries
        handles #ifdef      performs          creates ELF file     resolves symbols
        removes comments    optimizations     with relocations     determines addresses

ברגע שמבינים את ארבעת השלבים, הרבה דברים שנראו מסתוריים הופכים להגיוניים:
- למה צריך header files? כי הקומפיילר מעבד קובץ אחד בכל פעם, ובזמן קומפילציה של main.c הוא צריך לדעת שהפונקציה add קיימת (אפילו שההגדרה שלה בקובץ אחר)
- למה יש שגיאות "undefined reference"? כי הלינקר לא מצא הגדרה לsymbol
- למה הheader צריך include guards? כי הpreprocessor עלול להעתיק את אותו קובץ פעמיים

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