Functions & Calling Conventions

מבחנים - Functions & Calling Conventions

פרק זה עוסק בפונקציות בשפת C, מוסכמות קריאה (cdecl, fastcall, stdcall) ומנגנונים ברמת ה-ABI -- נושאים מרכזיים בהכנה למיון סייבר בגאמא סייבר וליחידה 8200. השאלות מכסות רמות קושי מקלה ועד קשה.

  • Inline Functions
  • cdecl
  • fastcall
  • stdcall
  • Function Pointers
  • Callbacks
  • VTable ידני
  • System Calls
  • int 0x80 / syscall
  • Red Zone
  • Stack Alignment
  • ABI & Register Usage
  • Inline Assembly
  • Tail Call Optimization
טיפ: נסו לקמפל קוד C עם gcc -S כדי לראות את פלט ה-Assembly -- זו הדרך הטובה ביותר להבין calling conventions ו-stack alignment בפועל.

בהצלחה!

מדריך מקיף זה מכסה את כל הנושאים הקשורים לפונקציות ומוסכמות קריאה (Calling Conventions) בשפת C - מהבסיס ועד לנושאים מתקדמים ברמת ABI ו-Assembly. המדריך נכתב כחלק מתוכנית ההכנה למיונים של גאמא סייבר, ומיועד למועמדים המתכוננים למיון סייבר ביחידה 8200 ויחידות טכנולוגיות מובחרות. לאחר קריאת המדריך תוכלו לענות על כל 80 שאלות הבחינה בביטחון מלא.

תוכן עניינים
חלק 1: Inline Functions
1.1 מה עושה inline? 1.2 יתרונות 1.3 חסרונות 1.4 למה רק לפונקציות קצרות? 1.5 inline כהמלצה בלבד 1.6 inline static 1.7 שילוב inline עם fastcall 1.8 inline מול מאקרו 1.9 Profile-Guided Optimization 1.10 אימות שה-inline הופעל 1.11 אי אפשר לעשות inline ל-system calls
חלק 2: Calling Conventions
2.1 cdecl 2.2 cdecl ופונקציות variadic 2.3 fastcall 2.4 fastcall עם הרבה ארגומנטים 2.5 fastcall ב-GCC 2.6 stdcall 2.7 stdcall מול cdecl 2.8 stdcall ופונקציות variadic 2.9 חוסר התאמה בין מוסכמות קריאה 2.10 ארכיטקטורות RISC 2.11 טבלת השוואה
חלק 3: Function Pointers
3.1 סינטקס בסיסי 3.2 הבחנה בין הצהרות 3.3 פולימורפיזם בזמן ריצה 3.4 Callbacks 3.5 העברת מצביע פונקציה כפרמטר 3.6 NULL dereference 3.7 מצביע פונקציה מול מצביע נתונים 3.8 מערך של מצביעי פונקציות 3.9 VTable ידני 3.10 פונקציה שמחזירה מצביע לפונקציה 3.11 volatile function pointer 3.12 שיתוף בין קבצים עם extern 3.13 Branch prediction וקריאות עקיפות
חלק 4: System Calls
4.1 מטרת קריאות מערכת 4.2 עלות context switch 4.3 int 0x80 ו-syscall 4.4 קריאה ישירה מול פונקציות ספרייה 4.5 Buffering בספריות 4.6 vDSO
חלק 5: ABI ופרטים נמוכי רמה
5.1 Red Zone 5.2 החזרת struct גדול 5.3 Stack Alignment 5.4 x64 System V ABI - רגיסטרים 5.5 mfpmath=SSE
חלק 6: Inline Assembly
6.1 Register Clobber Lists 6.2 Intel vs AT&T Syntax 6.3 Naked Functions 6.4 Custom Calling Convention
חלק 7: נושאים מתקדמים
7.1 Tail Call Optimization 7.2 __attribute__((alias)) 7.3 __attribute__((constructor)) 7.4 Cross-Language Interop 7.5 DWARF/PDB Debug Symbols 7.6 תאימות glibc

1.1 מה עושה inline?

המילה inline היא המלצה לקומפיילר להטמיע את גוף הפונקציה ישירות בנקודת הקריאה, במקום לבצע קריאה רגילה עם call. כלומר, במקום שהקומפיילר ייצור הוראת CALL שקופצת לכתובת הפונקציה, הוא מעתיק את הקוד של הפונקציה ישירות לתוך הפונקציה הקוראת.

// בלי inline - הקומפיילר יוצר קריאה רגילה
int add(int a, int b) {
    return a + b;
}

// עם inline - הקומפיילר מטמיע את הגוף
inline int add_inline(int a, int b) {
    return a + b;
}

int main() {
    int result = add_inline(3, 5);
    // הקומפיילר יכול להפוך את זה ל:
    // int result = 3 + 5;
    // ללא CALL/RET כלל
    return 0;
}

1.2 יתרונות של inline

היתרון המרכזי הוא ביטול תקורת הקריאה (call overhead). בקריאה רגילה לפונקציה, המעבד מבצע:

  • PUSH של הפרמטרים על ה-Stack
  • הוראת CALL (שומרת את כתובת החזרה ב-Stack וקופצת)
  • יצירת Stack Frame (פרולוג: push ebp; mov ebp, esp)
  • בסוף: פירוק ה-Frame (אפילוג) והוראת RET

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

שימו לב: בנוסף לחיסכון בתקורה, inline מאפשר לקומפיילר לבצע אופטימיזציות נוספות כמו constant propagation ו-dead code elimination, כי הוא רואה את הקוד בהקשר של הפונקציה הקוראת.

1.3 חסרונות של inline

החיסרון העיקרי הוא נפיחות קוד (Code Bloat). אם פונקציה inline נקראת מ-100 מקומות שונים, גוף הפונקציה משוכפל 100 פעם בקוד המכונה הסופי. זה גורם ל:

  • הגדלת הבינארי - הקובץ הסופי גדול יותר
  • לחץ על Instruction Cache - קוד גדול יותר = יותר cache misses = ביצועים נמוכים יותר. זה אירוני - inline שנועד לשפר ביצועים יכול דווקא לפגוע בהם
  • זמני קומפילציה ארוכים יותר
זהירות: שימוש ב-inline עבור פונקציות ארוכות (עשרות שורות) הוא כמעט תמיד טעות. לחץ ה-instruction cache יבטל כל חיסכון מביטול תקורת הקריאה.

1.4 למה רק לפונקציות קצרות?

ככלל אצבע, inline מתאים לפונקציות של 1-5 שורות. הסיבה פשוטה: בפונקציה קצרה, תקורת הקריאה (push, call, prologue, epilogue, ret) היא חלק משמעותי מזמן הריצה הכולל. בפונקציה ארוכה, התקורה הזו זניחה ביחס לזמן ביצוע גוף הפונקציה, ולכן inline לא משפר ביצועים אלא רק מנפח את הקוד.

1.5 inline היא המלצה בלבד

חשוב להבין: inline היא המלצה (suggestion) לקומפיילר, לא הוראה. הקומפיילר רשאי להתעלם ממנה לחלוטין. הקומפיילר ישקול גורמים כמו:

  • גודל הפונקציה
  • מספר הקריאות אליה
  • רמת האופטימיזציה (-O0 בדרך כלל מתעלם מ-inline)
  • האם הפונקציה רקורסיבית (אי אפשר inline לרקורסיה)
  • האם יש לולאות מורכבות בגוף הפונקציה

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

1.6 inline static

כאשר מגדירים פונקציה כ-static inline, שני דברים קורים:

  • static - הפונקציה לא מייצאת סימבול גלובלי. היא קיימת רק ביחידת התרגום (Translation Unit) שבה הוגדרה.
  • inline - המלצה להטמעה
// בקובץ utils.h
static inline int max(int a, int b) {
    return (a > b) ? a : b;
}
// כל קובץ .c שעושה #include לקובץ זה
// מקבל עותק פרטי משלו - אין התנגשות סימבולים

השילוב static inline הוא הנפוץ ביותר בקבצי header. בלי static, אם הקומפיילר מחליט שלא לעשות inline, הפונקציה תייצר סימבול גלובלי, ואם היא מופיעה בכמה קבצי .c (דרך header), תהיה שגיאת multiple definition ב-linker.

1.7 שילוב inline עם fastcall

ניתן לשלב inline עם __attribute__((fastcall)). אם הקומפיילר מחליט לעשות inline, מוסכמת הקריאה לא רלוונטית כי אין קריאה בפועל. אם הקומפיילר מחליט לא לעשות inline, הפונקציה תיקרא בהתאם ל-fastcall (פרמטרים ב-ECX ו-EDX).

inline int __attribute__((fastcall)) fast_add(int a, int b) {
    return a + b;
}
// אם inline הופעל - אין call כלל, fastcall לא רלוונטי
// אם inline לא הופעל - a ב-ECX, b ב-EDX

1.8 inline מול מאקרו (Macro)

מאקרו (#define) ו-inline מבצעים דבר דומה - "הטמעת" קוד בנקודת הקריאה - אבל ההבדלים קריטיים:

מאפיין inline #define מאקרו
בדיקת טיפוסים כן - type-safe לא - הרחבת טקסט בלבד
ניפוי שגיאות (Debug) ניתן לעשות debug, breakpoint בפונקציה קשה מאוד - אין שם פונקציה ב-debugger
הערכת ארגומנטים כל ארגומנט מחושב פעם אחת ארגומנט יכול להיות מחושב כמה פעמים (side effects!)
Scope Scope רגיל של פונקציה אין scope - יכול לגרום להתנגשויות
שלב עיבוד קומפילציה Pre-processing (לפני קומפילציה)
// בעיה קלאסית עם מאקרו
#define SQUARE(x) ((x) * (x))

int a = 5;
int result = SQUARE(a++);
// מתרחב ל: ((a++) * (a++)) - undefined behavior!
// a מקודם פעמיים

// עם inline - אין בעיה:
inline int square(int x) { return x * x; }
int result2 = square(a++);
// a++ מחושב פעם אחת, ואז הערך מועבר

1.9 Profile-Guided Optimization (PGO)

PGO הוא תהליך דו-שלבי שבו הקומפיילר מקבל החלטות אופטימיזציה טובות יותר על בסיס נתוני ריצה אמיתיים:

  1. שלב 1: קומפילציה עם -fprofile-generate - יוצרת בינארי שאוסף סטטיסטיקות
  2. שלב 2: הרצת התוכנית עם קלט טיפוסי - נוצרים קבצי .gcda
  3. שלב 3: קומפילציה מחדש עם -fprofile-use - הקומפיילר משתמש בנתונים

PGO משפיע על החלטות inline: פונקציות שנקראות הרבה פעמים (hot functions) יקבלו inline אגרסיבי, בעוד פונקציות נדירות (cold functions) לא יקבלו inline גם אם הן קצרות.

# שלב 1: קומפילציה עם profiling
gcc -fprofile-generate -O2 program.c -o program_profiled

# שלב 2: הרצה עם קלט טיפוסי
./program_profiled < typical_input.txt

# שלב 3: קומפילציה עם הנתונים
gcc -fprofile-use -O2 program.c -o program_optimized

1.10 אימות שה-inline הופעל

כיצד בודקים אם הקומפיילר באמת ביצע inline? הדרך הטובה ביותר היא לבדוק את פלט ה-Assembly:

# יצירת קובץ assembly
gcc -S -O2 program.c -o program.s

# אם הפונקציה עברה inline, לא תראו
# הוראת CALL לשם הפונקציה בפלט

אם הפונקציה הוטמעה inline, לא תמצאו הוראת call function_name בפלט ה-Assembly. גוף הפונקציה יופיע ישירות בתוך הפונקציה הקוראת. אפשר גם להשתמש ב-objdump -d על הבינארי הסופי.

1.11 אי אפשר לעשות inline ל-System Calls

קריאת מערכת (System Call) דורשת מעבר מ-User Mode ל-Kernel Mode. זו פעולה שחייבת לקרות בזמן ריצה באמצעות הוראת מעבד ייעודית (int 0x80 או syscall). הקומפיילר לא יכול להטמיע קוד קרנל בתוך קוד משתמש - זה חסום ברמת החומרה. לכן, inline על קריאות מערכת הוא בלתי אפשרי.

2.1 cdecl - מוסכמת ברירת המחדל של C

cdecl (C Declaration) היא מוסכמת הקריאה הסטנדרטית של שפת C בארכיטקטורת x86 (32-bit). מאפייניה:

  • הפרמטרים נדחפים ל-Stack מימין לשמאל (right-to-left push order)
  • הקורא (caller) מנקה את ה-Stack לאחר הקריאה
  • ערך ההחזרה ב-EAX
// קריאה: result = func(1, 2, 3);
// ה-Assembly (x86, cdecl):
push 3      ; פרמטר שלישי נדחף ראשון (ימין לשמאל)
push 2      ; פרמטר שני
push 1      ; פרמטר ראשון נדחף אחרון
call func   ; קריאה לפונקציה
add esp, 12 ; הקורא מנקה 12 בתים (3 × 4) מה-Stack

הסיבה לסדר ימין-לשמאל: הפרמטר הראשון נמצא בראש ה-Stack (בכתובת הנמוכה ביותר), מה שמאפשר גישה נוחה אליו. זה קריטי לתמיכה ב-variadic functions.

2.2 cdecl ופונקציות Variadic

אחד היתרונות הגדולים של cdecl: היא תומכת בפונקציות עם מספר משתנה של ארגומנטים (variadic) כמו printf. כיצד?

  • הקורא יודע כמה ארגומנטים הוא העביר, ולכן הוא מנקה את ה-Stack
  • הפרמטר הראשון (format string ב-printf) תמיד באותו מיקום ב-Stack, ללא תלות במספר הפרמטרים
#include <stdarg.h>

int my_sum(int count, ...) {
    va_list args;
    va_start(args, count);
    int total = 0;
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);
    }
    va_end(args);
    return total;
}

// שימוש:
int result = my_sum(3, 10, 20, 30); // = 60

2.3 fastcall

fastcall היא מוסכמת קריאה שנועדה להיות מהירה יותר מ-cdecl על ידי העברת הפרמטרים הראשונים ברגיסטרים במקום ב-Stack:

  • הפרמטר הראשון ב-ECX
  • הפרמטר השני ב-EDX
  • שאר הפרמטרים ב-Stack (ימין לשמאל)
  • הנקרא (callee) מנקה את ה-Stack (כמו stdcall)
// הגדרה (MSVC):
int __fastcall fast_add(int a, int b) {
    return a + b;
}
// a מגיע ב-ECX, b ב-EDX
// אין צורך לגשת ל-Stack בכלל!

גישה לרגיסטרים מהירה בהרבה מגישה ל-Stack (שהוא בזיכרון), ולכן לפונקציות עם 1-2 פרמטרים, fastcall יכולה להיות מהירה משמעותית.

2.4 fastcall עם הרבה ארגומנטים

מה קורה כשיש יותר מ-2 פרמטרים? הפרמטרים מעבר ל-ECX ו-EDX עוברים דרך ה-Stack, בדיוק כמו ב-cdecl:

int __fastcall func(int a, int b, int c, int d) {
    return a + b + c + d;
}
// a → ECX
// b → EDX
// c → Stack (נדחף ראשון)
// d → Stack (נדחף שני)

2.5 fastcall ב-GCC

ב-GCC (לינוקס), fastcall לא קיים כמילת מפתח מובנית. במקום זאת משתמשים ב-attribute:

int __attribute__((fastcall)) fast_func(int a, int b) {
    return a + b;
}
הערה: ב-x86_64 (64-bit), fastcall פחות רלוונטי כי ה-ABI הסטנדרטי (System V) כבר מעביר את 6 הפרמטרים הראשונים ברגיסטרים.

2.6 stdcall

stdcall (Standard Call) היא מוסכמת הקריאה הסטנדרטית של Windows API (WinAPI). מאפייניה:

  • פרמטרים ב-Stack, ימין לשמאל (כמו cdecl)
  • הנקרא (callee) מנקה את ה-Stack (באמצעות ret N)
  • שמות מקושטים (decorated names): _FuncName@N כאשר N הוא מספר הבתים של הפרמטרים
// MSVC
int __stdcall StdFunc(int a, int b) {
    return a + b;
}
// ב-symbol table הפונקציה נקראת: _StdFunc@8
// (8 בתים = 2 פרמטרים × 4 בתים)

// Assembly:
// push 2
// push 1
// call _StdFunc@8
// (אין add esp! הפונקציה עצמה עושה ret 8)

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

2.7 stdcall מול cdecl

מאפיין cdecl stdcall
מי מנקה Stack הקורא (Caller) הנקרא (Callee)
סדר דחיפת פרמטרים ימין לשמאל ימין לשמאל
Variadic נתמך לא נתמך
שמות מקושטים _func _func@N
שימוש עיקרי ברירת מחדל ב-C Windows API
גודל קוד גדול יותר (ניקוי בכל אתר קריאה) קטן יותר (ניקוי פעם אחת)

2.8 stdcall ופונקציות Variadic

stdcall לא תומכת בפונקציות variadic. הסיבה: ב-stdcall, הנקרא מנקה את ה-Stack. אבל בפונקציה variadic, הנקרא לא יודע כמה ארגומנטים הועברו! הוא לא יכול לדעת כמה בתים לנקות. לכן printf ו-scanf חייבות להשתמש ב-cdecl, גם ב-Windows.

2.9 חוסר התאמה בין מוסכמות קריאה

מה קורה כשיש חוסר התאמה (mismatch) בין מוסכמת הקריאה של הקורא לנקרא? קריסה. הנה דוגמה:

// header אומר cdecl (הקורא ינקה Stack)
int func(int a, int b);

// אבל ההגדרה בפועל היא stdcall (הנקרא מנקה Stack)
int __stdcall func(int a, int b) {
    return a + b;
}

// מה קורה:
// 1. הקורא דוחף פרמטרים ל-Stack
// 2. func מנקה Stack בעצמה (ret 8)
// 3. הקורא גם מנקה Stack (add esp, 8)
// 4. Stack נמוך מדי ← Stack corruption ← CRASH!
חשוב: חוסר התאמה במוסכמת קריאה גורם לשחיתות Stack, פרמטרים שגויים וכתובת חזרה שגויה. התוצאה: crash, segfault, או גרוע מכך - התנהגות בלתי צפויה שקשה מאוד לדבג.

2.10 ארכיטקטורות RISC (ARM, PowerPC)

בארכיטקטורות RISC כמו ARM ו-PowerPC, מוסכמות הקריאה שונות מ-x86. ב-ARM (32-bit) למשל, ארבעת הפרמטרים הראשונים עוברים ברגיסטרים (R0-R3) כברירת מחדל. אין צורך ב-fastcall מיוחד - העברה ברגיסטרים היא ההתנהגות הרגילה. ההבחנה בין cdecl/fastcall/stdcall היא בעיקר של עולם ה-x86.

2.11 טבלת השוואה מלאה

מאפיין cdecl fastcall stdcall
העברת פרמטרים הכל ב-Stack ECX, EDX + Stack הכל ב-Stack
סדר דחיפה ימין לשמאל ימין לשמאל (עבור Stack) ימין לשמאל
מי מנקה Stack Caller Callee Callee
Variadic כן לא לא
ערך חזרה EAX EAX EAX
שימוש עיקרי ברירת מחדל C אופטימיזציה Windows API
קישוט שם _func @func@N _func@N

3.1 סינטקס בסיסי של מצביע לפונקציה

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

// הגדרת פונקציה רגילה
int add(int a, int b) {
    return a + b;
}

// הגדרת מצביע לפונקציה
int (*fptr)(int, int);

// השמה - שם הפונקציה = הכתובת שלה
fptr = add;        // או: fptr = &add; (שקול)

// קריאה דרך המצביע
int result = fptr(3, 5);      // = 8
int result2 = (*fptr)(3, 5); // שקול - סגנון ישן

הסינטקס int (*fptr)(int, int) אומר: fptr הוא מצביע (*) לפונקציה שמקבלת שני int ומחזירה int. הסוגריים סביב *fptr הכרחיים!

3.2 הבחנה בין הצהרות

יש להבדיל היטב בין שתי ההצהרות הבאות:

int  *func();      // פונקציה שמחזירה int*
int (*funcPtr)();  // מצביע לפונקציה שמחזירה int

ההבדל הוא בסוגריים:

  • int *func() - ה-* שייך ל-int (ערך ההחזרה). זו פונקציה שמחזירה מצביע ל-int.
  • int (*funcPtr)() - ה-* שייך ל-funcPtr (הסוגריים מכריחים). זהו מצביע לפונקציה.
הצהרה סוג משמעות
int *func() פונקציה מחזירה int*
int (*fptr)() מצביע לפונקציה מצביע לפונקציה שמחזירה int
int (*fptr)(int, int) מצביע לפונקציה מצביע לפונקציה שמקבלת שני int ומחזירה int

3.3 פולימורפיזם בזמן ריצה

מצביעי פונקציות מאפשרים להחליט בזמן ריצה איזו פונקציה לקרוא - מעין פולימורפיזם בשפת C:

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

int main() {
    int (*operation)(int, int);
    char choice;
    scanf("%c", &choice);

    switch (choice) {
        case '+': operation = add; break;
        case '-': operation = sub; break;
        case '*': operation = mul; break;
    }
    printf("Result: %d\n", operation(10, 5));
    return 0;
}

3.4 Callbacks

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

#include <stdlib.h>

// Callback function לצורך השוואה
int compare_ints(const void *a, const void *b) {
    return (*(const int*)a) - (*(const int*)b);
}

int main() {
    int arr[] = {5, 2, 8, 1, 9};
    int n = sizeof(arr) / sizeof(arr[0]);

    // qsort מקבלת callback - מצביע לפונקציית השוואה
    qsort(arr, n, sizeof(int), compare_ints);
    return 0;
}

3.5 העברת מצביע פונקציה כפרמטר

ניתן לכתוב פונקציות שמקבלות מצביע לפונקציה כפרמטר, מה שמאפשר לכתוב קוד גנרי:

// פונקציה שמחילה פעולה על כל אלמנט במערך
void apply(int *arr, int n, int (*transform)(int)) {
    for (int i = 0; i < n; i++) {
        arr[i] = transform(arr[i]);
    }
}

int double_it(int x) { return x * 2; }
int square_it(int x) { return x * x; }

int main() {
    int arr[] = {1, 2, 3, 4};
    apply(arr, 4, double_it);  // arr = {2, 4, 6, 8}
    apply(arr, 4, square_it);  // arr = {4, 16, 36, 64}
    return 0;
}

3.6 NULL Dereference

קריאה למצביע פונקציה שערכו NULL גורמת ל-Segmentation Fault. המעבד מנסה לקפוץ לכתובת 0, שהיא מוגנת על ידי מערכת ההפעלה:

int (*fptr)(int) = NULL;
int result = fptr(42);  // SEGFAULT!

// תמיד לבדוק לפני קריאה:
if (fptr != NULL) {
    int result = fptr(42);
}

3.7 מצביע פונקציה מול מצביע נתונים

מצביע נתונים (למשל int*) מצביע לאזור הנתונים בזיכרון (heap, stack, data segment). מצביע פונקציה מצביע לאזור הקוד (code/text segment). בארכיטקטורות מסוימות, אזורים אלה שונים לחלוטין ואי אפשר להמיר ביניהם.

אזהרה: המרה (cast) בין מצביע לפונקציה למצביע נתונים היא Undefined Behavior לפי תקן C. למרות שזה עובד בפועל ב-x86/x64 (כי שם זיכרון הוא flat), בארכיטקטורות אחרות (Harvard architecture) זה יגרום לבעיות.

3.8 מערך של מצביעי פונקציות

ניתן ליצור מערך של מצביעי פונקציות, מה שמאפשר dispatch table - בחירת פונקציה לפי אינדקס:

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

// מערך של 3 מצביעי פונקציות
int (*ops[3])(int, int) = { add, sub, mul };

// קריאה לפי אינדקס
int result = ops[0](10, 5);  // add(10, 5) = 15
int result2 = ops[2](10, 5); // mul(10, 5) = 50

הסינטקס int (*ops[3])(int, int) נקרא כך: ops הוא מערך בגודל 3, שכל אלמנט בו הוא מצביע (*) לפונקציה שמקבלת שני int ומחזירה int.

3.9 VTable ידני

ב-C++ יש VTable אוטומטי לפולימורפיזם. ב-C אפשר לבנות מנגנון דומה באמצעות struct שמכיל מערך מצביעי פונקציות:

typedef struct {
    void  (*draw)(void *self);
    float (*area)(void *self);
    void  (*destroy)(void *self);
} ShapeVTable;

typedef struct {
    ShapeVTable *vtable;
    float radius;
} Circle;

typedef struct {
    ShapeVTable *vtable;
    float width, height;
} Rectangle;

void  circle_draw(void *self) { printf("Drawing circle\n"); }
float circle_area(void *self) {
    Circle *c = (Circle*)self;
    return 3.14159 * c->radius * c->radius;
}
void circle_destroy(void *self) { free(self); }

// VTable for Circle
ShapeVTable circle_vtable = { circle_draw, circle_area, circle_destroy };

// יצירת עיגול
Circle *c = malloc(sizeof(Circle));
c->vtable = &circle_vtable;
c->radius = 5.0;

// קריאה פולימורפית
c->vtable->draw(c);             // "Drawing circle"
float a = c->vtable->area(c);  // 78.54...
c->vtable->destroy(c);          // free

3.10 פונקציה שמחזירה מצביע לפונקציה

הסינטקס של פונקציה שמחזירה מצביע לפונקציה הוא מורכב מאוד. מומלץ מאוד להשתמש ב-typedef:

// בלי typedef - סינטקס מפלצתי:
int (*get_operation(char op))(int, int) {
    if (op == '+') return add;
    if (op == '-') return sub;
    return NULL;
}

// עם typedef - הרבה יותר קריא:
typedef int (*BinaryOp)(int, int);

BinaryOp get_operation(char op) {
    if (op == '+') return add;
    if (op == '-') return sub;
    return NULL;
}
המלצה: תמיד השתמשו ב-typedef עבור מצביעי פונקציות. זה משפר קריאות ומפחית טעויות.

3.11 volatile Function Pointer

מצביע פונקציה volatile אומר לקומפיילר: הערך של המצביע עשוי להשתנות מחוץ לתוכנית (למשל על ידי interrupt handler או thread אחר). הקומפיילר לא יעשה אופטימיזציות כמו caching של הערך ברגיסטר:

// המצביע עצמו volatile - יכול להשתנות בכל רגע
void (* volatile handler)(int) = default_handler;

// ב-interrupt handler:
handler = emergency_handler;

// בקוד הראשי - הקומפיילר תמיד יקרא מהזיכרון:
handler(42);

3.12 שיתוף בין קבצים עם extern

כדי לשתף מערך מצביעי פונקציות בין קבצי .c שונים, משתמשים ב-extern:

// ops.h
typedef int (*Operation)(int, int);
extern Operation ops[3];

// ops.c
#include "ops.h"
Operation ops[3] = { add, sub, mul };

// main.c
#include "ops.h"
int result = ops[0](10, 5);  // add(10,5)

3.13 Branch Prediction וקריאות עקיפות

קריאה דרך מצביע פונקציה היא קריאה עקיפה (indirect call) - המעבד לא יודע מראש לאן לקפוץ. זה מקשה על branch predictor של המעבד:

  • קריאה ישירה (call 0x401000) - הכתובת ידועה, ה-pipeline ממשיך בלי עיכוב
  • קריאה עקיפה (call [eax]) - צריך לחכות שהרגיסטר יהיה מוכן, אפשר branch misprediction

לכן קריאות דרך מצביעי פונקציות עשויות להיות איטיות יותר מקריאות ישירות, במיוחד אם הכתובת משתנה לעתים קרובות (כי ה-branch predictor לא יכול ללמוד את התבנית).

4.1 מטרת קריאות מערכת

קריאות מערכת (System Calls) הן הממשק בין תוכנית המשתמש לקרנל. הן מספקות גישה לשירותים שרק הקרנל יכול לבצע:

  • קבצים: open, read, write, close
  • תהליכים: fork, exec, wait, exit
  • זיכרון: mmap, brk, mprotect
  • רשת: socket, bind, listen, accept

תוכנית ב-User Mode לא יכולה לגשת ישירות לחומרה או לזיכרון של תהליכים אחרים. היא חייבת "לבקש" מהקרנל דרך system call.

4.2 עלות Context Switch

כל קריאת מערכת דורשת Context Switch מ-User Mode ל-Kernel Mode ובחזרה. תהליך זה כולל:

  1. שמירת רגיסטרים של המשתמש
  2. מעבר ל-Kernel Stack
  3. החלפת Page Tables (אם נדרש)
  4. ביצוע הפעולה בקרנל
  5. חזרה ל-User Mode ושחזור רגיסטרים

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

4.3 int 0x80 ו-syscall

בלינוקס יש שני מנגנונים לביצוע קריאת מערכת:

int 0x80 (x86 32-bit, ישן)

; write(1, "hello", 5)
mov eax, 4       ; syscall number for write
mov ebx, 1       ; fd = stdout
mov ecx, msg     ; buffer address
mov edx, 5       ; length
int 0x80          ; trigger software interrupt

syscall (x86_64 64-bit, מודרני)

; write(1, "hello", 5)
mov rax, 1       ; syscall number for write (different numbering!)
mov rdi, 1       ; fd = stdout
mov rsi, msg     ; buffer address
mov rdx, 5       ; length
syscall           ; fast system call instruction

הוראת syscall מהירה יותר מ-int 0x80 כי היא לא עוברת דרך ה-IDT (Interrupt Descriptor Table) ומבצעת את המעבר ל-Kernel Mode בצורה ישירה יותר.

4.4 קריאה ישירה מול פונקציות ספרייה

אפשר לבצע קריאת מערכת ישירות (כמו בדוגמאות למעלה), אבל בפרקטיקה משתמשים בפונקציות עטיפה (wrapper functions) של ספריית C:

// פונקציות ספרייה (glibc wrappers)
FILE *fp = fopen("file.txt", "r");  // עוטפת open()
size_t n = fread(buf, 1, 100, fp);   // עוטפת read()
fclose(fp);                           // עוטפת close()

// קריאה ישירה (low-level)
int fd = open("file.txt", O_RDONLY);
ssize_t n = read(fd, buf, 100);
close(fd);

פונקציות הספרייה מוסיפות שכבת הפשטה ופונקציונליות נוספת כמו buffering, טיפול בשגיאות, ופורטביליות.

4.5 Buffering בספריות

אחד היתרונות הגדולים של פונקציות ספרייה כמו fread ו-fwrite: הן מבצעות buffering. במקום לבצע system call לכל בית בנפרד, הן צוברות נתונים ב-buffer פנימי ומבצעות קריאת מערכת אחת גדולה:

// ללא buffering (ישירות עם write) - 1000 system calls!
for (int i = 0; i < 1000; i++) {
    write(fd, &data[i], 1);  // system call לכל בית
}

// עם buffering (fwrite) - מספר קטן של system calls
for (int i = 0; i < 1000; i++) {
    fwrite(&data[i], 1, 1, fp);  // נכתב ל-buffer
}
// fwrite עושה flush ל-buffer רק כשהוא מתמלא
// = הרבה פחות context switches!

צמצום מספר ה-context switches מביא לשיפור ביצועים משמעותי.

4.6 vDSO - Virtual Dynamic Shared Object

vDSO הוא מנגנון חכם בלינוקס שמאפשר לבצע קריאות מערכת מסוימות בלי context switch. הקרנל ממפה דף זיכרון לתוך ה-address space של כל תהליך, שמכיל קוד קרנל שרץ ב-User Mode:

  • gettimeofday() - קריאת השעון (הנתון מעודכן בזיכרון משותף)
  • clock_gettime() - שעון מדויק
  • getcpu() - מספר ה-CPU הנוכחי

במקום לעבור ל-Kernel Mode (שעלותו גבוהה), התהליך פשוט קורא לפונקציה שממופה בזיכרון שלו. זהו אחד הטריקים שמאפשרים לשפר ביצועים ביישומים שקוראים gettimeofday הרבה פעמים.

שימו לב: vDSO עובד רק לקריאות מערכת שלא דורשות הרשאות קרנל - קריאת נתונים בלבד (read-only). קריאות כמו write או open חייבות לעבור דרך context switch מלא.

5.1 Red Zone (x86_64 System V ABI)

ב-ABI של System V x86_64 (לינוקס, macOS), ישנו אזור של 128 בתים מתחת ל-RSP שנקרא Red Zone. פונקציה עלה (leaf function - פונקציה שלא קוראת לפונקציות אחרות) יכולה להשתמש באזור זה בלי להזיז את RSP:

; leaf function - יכולה להשתמש ב-Red Zone
; אין צורך ב: sub rsp, 16
my_leaf_func:
    mov [rsp-8], rdi    ; שומר על ה-Red Zone
    mov [rsp-16], rsi   ; עדיין Red Zone - בטוח
    ; ... חישובים ...
    ret                  ; אין צורך ב: add rsp, 16
חשוב: ה-Red Zone קיים רק ב-System V ABI (לינוקס). ב-Windows x64 ABI אין Red Zone! גם בקוד קרנל לינוקס ה-Red Zone מושבת (כי interrupt יכול לדרוס אותו), ולכן הקרנל מקומפל עם -mno-red-zone.

5.2 החזרת Struct גדול

כיצד פונקציה מחזירה struct שלא נכנס ברגיסטר (יותר מ-16 בתים)? הקומפיילר מוסיף פרמטר מוסתר - מצביע למקום שבו יש לכתוב את התוצאה:

typedef struct {
    double x, y, z;
    int id;
} BigStruct;   // 28 bytes - גדול מדי לרגיסטר

BigStruct create(int id) {
    BigStruct s = { 1.0, 2.0, 3.0, id };
    return s;
}

// מה הקומפיילר באמת עושה (RVO / hidden pointer):
// void create(BigStruct *__result, int id) {
//     __result->x = 1.0;
//     __result->y = 2.0;
//     __result->z = 3.0;
//     __result->id = id;
// }

הקורא מקצה מקום ב-Stack ומעביר את הכתובת כפרמטר נסתר ראשון (ב-RDI ב-System V ABI). הפונקציה כותבת ישירות לכתובת הזו, ומחזירה את הכתובת ב-RAX.

5.3 Stack Alignment (16 בתים)

ב-x86_64, ה-ABI דורש ש-RSP יהיה מיושר ל-16 בתים לפני כל הוראת CALL. הסיבה: הוראות SIMD (כמו SSE) דורשות יישור של 16 בתים, וה-ABI מבטיח שזה תמיד מתקיים.

; כשנכנסים לפונקציה, RSP = 16n + 8
; (כי CALL דחף 8 bytes של return address)
; הפרולוג מיישר:
push rbp           ; RSP -= 8, עכשיו RSP = 16n
mov rbp, rsp
sub rsp, 16        ; הקצאת משתנים מקומיים (תמיד כפולה של 16)
אזהרה: Stack לא מיושר יגרום ל-crash בהוראות SSE כמו movaps (שדורשת יישור). הקומפיילר מוסיף padding אוטומטית, אבל ב-inline assembly חייבים לשמור על היישור ידנית.

5.4 x64 System V ABI - רגיסטרים

ב-x86_64 System V ABI, הפרמטרים עוברים ברגיסטרים (לא ב-Stack כמו ב-x86):

פרמטר Integer/Pointer Float/Double
ראשון RDI XMM0
שני RSI XMM1
שלישי RDX XMM2
רביעי RCX XMM3
חמישי R8 XMM4
שישי R9 XMM5
שביעי+ Stack XMM6-XMM7, ואז Stack

ערך חזרה: RAX ל-integer, XMM0 ל-float/double. חשוב: double תמיד עובר ב-XMM ו-int תמיד ב-GPR (General Purpose Register), גם אם הם מעורבבים:

double mixed(int a, double b, int c, double d) {
    return a + b + c + d;
}
// a → RDI (integer #1)
// b → XMM0 (float #1)
// c → RSI (integer #2)
// d → XMM1 (float #2)
// return → XMM0

5.5 -mfpmath=SSE

הדגל -mfpmath=sse אומר לקומפיילר להשתמש ברגיסטרי SSE (XMM0-XMM7) לחישובי נקודה צפה, במקום ביחידת x87 FPU הישנה. יתרונות:

  • ביצועים טובים יותר - SSE תומך בחישובי SIMD (מספר ערכים במקביל)
  • תוצאות עקביות - x87 משתמש ב-80-bit פנימי ומעגל, מה שיכול לגרום לתוצאות שונות בפלטפורמות שונות
  • ברירת מחדל ב-x86_64
# קומפילציה עם SSE math
gcc -mfpmath=sse -msse2 -O2 program.c

6.1 Register Clobber Lists

ב-GCC inline assembly, clobber list מודיע לקומפיילר אילו רגיסטרים נהרסים (משתנים) על ידי קוד ה-assembly. זה חשוב במיוחד עם fastcall - כי הפרמטרים מגיעים ב-ECX ו-EDX:

int my_func(int a, int b) {
    int result;
    asm volatile (
        "add %1, %2\n\t"     // a += b
        "mov %0, %1\n\t"     // result = a
        : "=r" (result)       // output: result ← רגיסטר כלשהו
        : "r" (a), "r" (b)   // inputs: a ו-b ברגיסטרים
        : "cc"               // clobber: flags register
    );
    return result;
}

אם ה-assembly שלכם הורס את ECX למשל, חייבים לציין "ecx" ב-clobber list. אחרת הקומפיילר עשוי להניח שה-ECX עדיין מכיל ערך קודם, ולייצר קוד שגוי.

שימו לב: "cc" אומר שדגלי הסטטוס (flags) נהרסים. "memory" אומר שה-assembly ניגש לזיכרון שלא צוין. שניהם חשובים לקורקטיות.

6.2 Intel Syntax מול AT&T Syntax

יש שתי צורות כתיבת assembly:

מאפיין AT&T (ברירת מחדל GCC) Intel (NASM, MSVC)
סדר אופרנדים mov src, dst mov dst, src
רגיסטרים %eax eax
קבועים $42 42
גישה לזיכרון (%eax) [eax]
סיומות גודל movl, movw, movb mov dword, mov word, mov byte
// AT&T syntax (ברירת מחדל ב-GCC)
asm("movl $42, %eax");

// Intel syntax ב-GCC
asm(".intel_syntax noprefix\n\t"
    "mov eax, 42\n\t"
    ".att_syntax prefix\n\t");

// או עם דגל קומפילציה: gcc -masm=intel

6.3 Naked Functions

פונקציה naked היא פונקציה שהקומפיילר לא מייצר עבורה פרולוג ואפילוג. אין push ebp, אין mov ebp, esp, אין ret אוטומטי. המתכנת אחראי לכל דבר:

__attribute__((naked)) int my_naked(int a, int b) {
    asm volatile (
        "mov eax, edi\n\t"   // a is in edi (System V)
        "add eax, esi\n\t"   // b is in esi
        "ret\n\t"             // חייבים ret ידנית!
    );
}

// שימוש רגיל - הקורא לא יודע שהפונקציה naked
int result = my_naked(10, 20); // = 30
זהירות: בפונקציית naked אי אפשר להשתמש במשתנים מקומיים של C! הקומפיילר לא מקצה Stack Frame. כל הקוד חייב להיות ב-inline assembly.

6.4 Custom Calling Convention

באמצעות naked functions ו-inline assembly, ניתן ליצור מוסכמת קריאה מותאמת אישית שלא קיימת כסטנדרט:

// מוסכמה מותאמת: פרמטרים ב-EAX ו-EBX, תוצאה ב-ECX
__attribute__((naked)) void custom_add() {
    asm volatile (
        "mov ecx, eax\n\t"
        "add ecx, ebx\n\t"
        "ret\n\t"
    );
}

// הקריאה דורשת inline assembly כי המוסכמה לא סטנדרטית
int result;
asm volatile (
    "mov eax, %1\n\t"
    "mov ebx, %2\n\t"
    "call custom_add\n\t"
    "mov %0, ecx\n\t"
    : "=r" (result)
    : "r" (10), "r" (20)
    : "eax", "ebx", "ecx"
);

7.1 Tail Call Optimization (TCO)

אם הפעולה האחרונה בפונקציה היא קריאה לפונקציה אחרת (tail call), הקומפיילר יכול לחסוך Stack Frame - במקום CALL ואחריו RET, הוא עושה JMP ישירות. זה מונע הצפת Stack ברקורסיה:

// רקורסיה רגילה - כל קריאה תופסת Stack Frame
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);  // NOT tail call (כפל אחרי הקריאה)
}

// Tail-recursive - ניתן לאופטימיזציה
int factorial_tail(int n, int acc) {
    if (n <= 1) return acc;
    return factorial_tail(n - 1, n * acc);  // TAIL CALL - הפעולה האחרונה
}
// הקומפיילר יכול להפוך את זה ללולאה!

TCO פועל ברמת אופטימיזציה -O2 ומעלה ב-GCC. בפועל, הקומפיילר מחליף את ה-call ב-jmp, כך שה-stack frame הנוכחי משמש לקריאה הבאה.

7.2 __attribute__((alias))

ה-attribute alias מאפשר ליצור שם נוסף (alias) לפונקציה קיימת, ללא שכפול קוד:

int original_func(int x) {
    return x * 2;
}

// יוצר שם נוסף - שני השמות מצביעים לאותו קוד
int alias_func(int x) __attribute__((alias("original_func")));

// שקול לקריאת original_func(5)
int result = alias_func(5); // = 10

שימוש טיפוסי: תאימות לאחור (backward compatibility) - שמירת שם ישן לפונקציה ששמה שונה, או יצירת גרסאות שונות של API שמפנות לאותו מימוש.

7.3 __attribute__((constructor))

פונקציה עם attribute constructor רצה אוטומטית לפני main. שימושי לאתחול:

__attribute__((constructor)) void init() {
    printf("Initializing before main!\n");
}

__attribute__((destructor)) void cleanup() {
    printf("Cleanup after main!\n");
}

int main() {
    printf("Inside main\n");
    return 0;
}
// פלט:
// Initializing before main!
// Inside main
// Cleanup after main!

אפשר גם לתת עדיפות: __attribute__((constructor(101))) - מספר נמוך יותר = רץ קודם. מספרים 0-100 שמורים למערכת.

7.4 Cross-Language Interop (Pascal + C)

כשמקשרים (link) קוד C עם קוד בשפה אחרת (למשל Pascal), יש לוודא שמוסכמת הקריאה תואמת. Pascal משתמשת בסדר דחיפה שמאל-לימין (הפוך מ-C!) ו-callee cleans stack:

// קריאה מ-C לפונקציה שנכתבה ב-Pascal
// חייבים לציין את מוסכמת הקריאה הנכונה:

// הגדרה ב-C עם calling convention מתאים
extern int __attribute__((stdcall)) pascal_func(int a, int b);

// Pascal default: params left-to-right, callee cleans
// stdcall הכי קרוב ל-Pascal calling convention
שימו לב: ב-C++ משתמשים ב-extern "C" כדי למנוע name mangling ולאפשר linkage עם קוד C.

7.5 DWARF/PDB Debug Symbols

כדי ש-debugger (כמו GDB) יוכל להציג פרמטרים ומשתנים מקומיים, הבינארי צריך להכיל מידע debug:

  • DWARF - פורמט debug סטנדרטי בלינוקס. נוצר עם gcc -g
  • PDB (Program Database) - פורמט debug של Microsoft Visual Studio

מידע ה-debug מתאר את calling convention של כל פונקציה, כולל:

  • באילו רגיסטרים/מיקומי Stack נמצאים הפרמטרים
  • היכן נמצאים משתנים מקומיים ביחס ל-RBP/RSP
  • טבלת unwind - כיצד לשחזר stack frames
# קומפילציה עם debug symbols
gcc -g -O0 program.c -o program

# דיבאג עם GDB
gdb ./program
(gdb) break main
(gdb) run
(gdb) info args        # מציג פרמטרים - אפשרי בזכות DWARF
(gdb) info locals      # מציג משתנים מקומיים

7.6 תאימות בינארית של glibc

glibc (GNU C Library) שומרת על תאימות בינארית לאחור (backward binary compatibility). כלומר, בינארי שקומפל על glibc 2.17 ירוץ גם על מערכת עם glibc 2.35, ללא צורך בקומפילציה מחדש. זה מתאפשר הודות ל:

  • Symbol versioning - כל סימבול יכול לקיים כמה גרסאות במקביל
  • ABI יציב - מבני נתונים וחתימות פונקציות לא משתנות
  • שמירת syscall wrappers - glibc מפשטת את ההתממשקות עם גרסאות קרנל שונות

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

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