מדריך מקיף זה מכסה את כל הנושאים הקשורים לפונקציות ומוסכמות קריאה (Calling Conventions) בשפת C - מהבסיס ועד לנושאים מתקדמים ברמת ABI ו-Assembly. המדריך נכתב כחלק מתוכנית ההכנה למיונים של גאמא סייבר, ומיועד למועמדים המתכוננים למיון סייבר ביחידה 8200 ויחידות טכנולוגיות מובחרות. לאחר קריאת המדריך תוכלו לענות על כל 80 שאלות הבחינה בביטחון מלא.
המילה 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; }
היתרון המרכזי הוא ביטול תקורת הקריאה (call overhead). בקריאה רגילה לפונקציה, המעבד מבצע:
PUSH של הפרמטרים על ה-StackCALL (שומרת את כתובת החזרה ב-Stack וקופצת)push ebp; mov ebp, esp)RETכשהפונקציה מוטמעת inline, כל התקורה הזו נחסכת. בפונקציות קטנות שנקראות מיליוני פעמים בלולאה, ההבדל יכול להיות משמעותי.
החיסרון העיקרי הוא נפיחות קוד (Code Bloat). אם פונקציה inline נקראת מ-100 מקומות שונים, גוף הפונקציה משוכפל 100 פעם בקוד המכונה הסופי. זה גורם ל:
ככלל אצבע, inline מתאים לפונקציות של 1-5 שורות. הסיבה פשוטה: בפונקציה קצרה, תקורת הקריאה (push, call, prologue, epilogue, ret) היא חלק משמעותי מזמן הריצה הכולל. בפונקציה ארוכה, התקורה הזו זניחה ביחס לזמן ביצוע גוף הפונקציה, ולכן inline לא משפר ביצועים אלא רק מנפח את הקוד.
חשוב להבין: inline היא המלצה (suggestion) לקומפיילר, לא הוראה. הקומפיילר רשאי להתעלם ממנה לחלוטין. הקומפיילר ישקול גורמים כמו:
-O0 בדרך כלל מתעלם מ-inline)
מצד שני, קומפיילרים מודרניים יכולים גם לעשות inline אוטומטית לפונקציות שלא סומנו כ-inline, אם הם מחליטים שזה כדאי (ברמת אופטימיזציה -O2 ומעלה).
כאשר מגדירים פונקציה כ-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.
ניתן לשלב 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
מאקרו (#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++ מחושב פעם אחת, ואז הערך מועבר
PGO הוא תהליך דו-שלבי שבו הקומפיילר מקבל החלטות אופטימיזציה טובות יותר על בסיס נתוני ריצה אמיתיים:
-fprofile-generate - יוצרת בינארי שאוסף סטטיסטיקות.gcda-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
כיצד בודקים אם הקומפיילר באמת ביצע inline? הדרך הטובה ביותר היא לבדוק את פלט ה-Assembly:
# יצירת קובץ assembly gcc -S -O2 program.c -o program.s # אם הפונקציה עברה inline, לא תראו # הוראת CALL לשם הפונקציה בפלט
אם הפונקציה הוטמעה inline, לא תמצאו הוראת call function_name בפלט ה-Assembly. גוף הפונקציה יופיע ישירות בתוך הפונקציה הקוראת. אפשר גם להשתמש ב-objdump -d על הבינארי הסופי.
קריאת מערכת (System Call) דורשת מעבר מ-User Mode ל-Kernel Mode. זו פעולה שחייבת לקרות בזמן ריצה באמצעות הוראת מעבד ייעודית (int 0x80 או syscall). הקומפיילר לא יכול להטמיע קוד קרנל בתוך קוד משתמש - זה חסום ברמת החומרה. לכן, inline על קריאות מערכת הוא בלתי אפשרי.
cdecl (C Declaration) היא מוסכמת הקריאה הסטנדרטית של שפת C בארכיטקטורת x86 (32-bit). מאפייניה:
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.
אחד היתרונות הגדולים של cdecl: היא תומכת בפונקציות עם מספר משתנה של ארגומנטים (variadic) כמו printf. כיצד?
#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
fastcall היא מוסכמת קריאה שנועדה להיות מהירה יותר מ-cdecl על ידי העברת הפרמטרים הראשונים ברגיסטרים במקום ב-Stack:
ECXEDX// הגדרה (MSVC): int __fastcall fast_add(int a, int b) { return a + b; } // a מגיע ב-ECX, b ב-EDX // אין צורך לגשת ל-Stack בכלל!
גישה לרגיסטרים מהירה בהרבה מגישה ל-Stack (שהוא בזיכרון), ולכן לפונקציות עם 1-2 פרמטרים, 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 (נדחף שני)
ב-GCC (לינוקס), fastcall לא קיים כמילת מפתח מובנית. במקום זאת משתמשים ב-attribute:
int __attribute__((fastcall)) fast_func(int a, int b) { return a + b; }
stdcall (Standard Call) היא מוסכמת הקריאה הסטנדרטית של Windows API (WinAPI). מאפייניה:
ret N)_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 כשזיכרון היה יקר.
| מאפיין | cdecl |
stdcall |
|---|---|---|
| מי מנקה Stack | הקורא (Caller) | הנקרא (Callee) |
| סדר דחיפת פרמטרים | ימין לשמאל | ימין לשמאל |
| Variadic | נתמך | לא נתמך |
| שמות מקושטים | _func |
_func@N |
| שימוש עיקרי | ברירת מחדל ב-C | Windows API |
| גודל קוד | גדול יותר (ניקוי בכל אתר קריאה) | קטן יותר (ניקוי פעם אחת) |
stdcall לא תומכת בפונקציות variadic. הסיבה: ב-stdcall, הנקרא מנקה את ה-Stack. אבל בפונקציה variadic, הנקרא לא יודע כמה ארגומנטים הועברו! הוא לא יכול לדעת כמה בתים לנקות. לכן printf ו-scanf חייבות להשתמש ב-cdecl, גם ב-Windows.
מה קורה כשיש חוסר התאמה (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!
בארכיטקטורות RISC כמו ARM ו-PowerPC, מוסכמות הקריאה שונות מ-x86. ב-ARM (32-bit) למשל, ארבעת הפרמטרים הראשונים עוברים ברגיסטרים (R0-R3) כברירת מחדל. אין צורך ב-fastcall מיוחד - העברה ברגיסטרים היא ההתנהגות הרגילה. ההבחנה בין cdecl/fastcall/stdcall היא בעיקר של עולם ה-x86.
| מאפיין | 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 |
מצביע לפונקציה (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 הכרחיים!
יש להבדיל היטב בין שתי ההצהרות הבאות:
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 |
מצביעי פונקציות מאפשרים להחליט בזמן ריצה איזו פונקציה לקרוא - מעין פולימורפיזם בשפת 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; }
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; }
ניתן לכתוב פונקציות שמקבלות מצביע לפונקציה כפרמטר, מה שמאפשר לכתוב קוד גנרי:
// פונקציה שמחילה פעולה על כל אלמנט במערך 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; }
קריאה למצביע פונקציה שערכו NULL גורמת ל-Segmentation Fault. המעבד מנסה לקפוץ לכתובת 0, שהיא מוגנת על ידי מערכת ההפעלה:
int (*fptr)(int) = NULL; int result = fptr(42); // SEGFAULT! // תמיד לבדוק לפני קריאה: if (fptr != NULL) { int result = fptr(42); }
מצביע נתונים (למשל int*) מצביע לאזור הנתונים בזיכרון (heap, stack, data segment). מצביע פונקציה מצביע לאזור הקוד (code/text segment). בארכיטקטורות מסוימות, אזורים אלה שונים לחלוטין ואי אפשר להמיר ביניהם.
ניתן ליצור מערך של מצביעי פונקציות, מה שמאפשר 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.
ב-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
הסינטקס של פונקציה שמחזירה מצביע לפונקציה הוא מורכב מאוד. מומלץ מאוד להשתמש ב-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 עבור מצביעי פונקציות. זה משפר קריאות ומפחית טעויות.
מצביע פונקציה volatile אומר לקומפיילר: הערך של המצביע עשוי להשתנות מחוץ לתוכנית (למשל על ידי interrupt handler או thread אחר). הקומפיילר לא יעשה אופטימיזציות כמו caching של הערך ברגיסטר:
// המצביע עצמו volatile - יכול להשתנות בכל רגע void (* volatile handler)(int) = default_handler; // ב-interrupt handler: handler = emergency_handler; // בקוד הראשי - הקומפיילר תמיד יקרא מהזיכרון: handler(42);
כדי לשתף מערך מצביעי פונקציות בין קבצי .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)
קריאה דרך מצביע פונקציה היא קריאה עקיפה (indirect call) - המעבד לא יודע מראש לאן לקפוץ. זה מקשה על branch predictor של המעבד:
call 0x401000) - הכתובת ידועה, ה-pipeline ממשיך בלי עיכובcall [eax]) - צריך לחכות שהרגיסטר יהיה מוכן, אפשר branch mispredictionלכן קריאות דרך מצביעי פונקציות עשויות להיות איטיות יותר מקריאות ישירות, במיוחד אם הכתובת משתנה לעתים קרובות (כי ה-branch predictor לא יכול ללמוד את התבנית).
קריאות מערכת (System Calls) הן הממשק בין תוכנית המשתמש לקרנל. הן מספקות גישה לשירותים שרק הקרנל יכול לבצע:
תוכנית ב-User Mode לא יכולה לגשת ישירות לחומרה או לזיכרון של תהליכים אחרים. היא חייבת "לבקש" מהקרנל דרך system call.
כל קריאת מערכת דורשת Context Switch מ-User Mode ל-Kernel Mode ובחזרה. תהליך זה כולל:
תהליך זה יקר ביחס לקריאת פונקציה רגילה - סדר גודל של מאות עד אלפי מחזורי שעון, בגלל שטיפת pipeline, TLB flush אפשרי ועוד.
בלינוקס יש שני מנגנונים לביצוע קריאת מערכת:
; 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
; 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 בצורה ישירה יותר.
אפשר לבצע קריאת מערכת ישירות (כמו בדוגמאות למעלה), אבל בפרקטיקה משתמשים בפונקציות עטיפה (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, טיפול בשגיאות, ופורטביליות.
אחד היתרונות הגדולים של פונקציות ספרייה כמו 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 מביא לשיפור ביצועים משמעותי.
vDSO הוא מנגנון חכם בלינוקס שמאפשר לבצע קריאות מערכת מסוימות בלי context switch. הקרנל ממפה דף זיכרון לתוך ה-address space של כל תהליך, שמכיל קוד קרנל שרץ ב-User Mode:
gettimeofday() - קריאת השעון (הנתון מעודכן בזיכרון משותף)clock_gettime() - שעון מדויקgetcpu() - מספר ה-CPU הנוכחי
במקום לעבור ל-Kernel Mode (שעלותו גבוהה), התהליך פשוט קורא לפונקציה שממופה בזיכרון שלו. זהו אחד הטריקים שמאפשרים לשפר ביצועים ביישומים שקוראים gettimeofday הרבה פעמים.
write או open חייבות לעבור דרך context switch מלא.
ב-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
-mno-red-zone.
כיצד פונקציה מחזירה 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.
ב-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)
movaps (שדורשת יישור). הקומפיילר מוסיף padding אוטומטית, אבל ב-inline assembly חייבים לשמור על היישור ידנית.
ב-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
הדגל -mfpmath=sse אומר לקומפיילר להשתמש ברגיסטרי SSE (XMM0-XMM7) לחישובי נקודה צפה, במקום ביחידת x87 FPU הישנה. יתרונות:
# קומפילציה עם SSE math
gcc -mfpmath=sse -msse2 -O2 program.c
ב-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 ניגש לזיכרון שלא צוין. שניהם חשובים לקורקטיות.
יש שתי צורות כתיבת 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
פונקציה 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 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" );
אם הפעולה האחרונה בפונקציה היא קריאה לפונקציה אחרת (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 הנוכחי משמש לקריאה הבאה.
ה-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 שמפנות לאותו מימוש.
פונקציה עם 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 שמורים למערכת.
כשמקשרים (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
extern "C" כדי למנוע name mangling ולאפשר linkage עם קוד C.
כדי ש-debugger (כמו GDB) יוכל להציג פרמטרים ומשתנים מקומיים, הבינארי צריך להכיל מידע debug:
gcc -gמידע ה-debug מתאר את calling convention של כל פונקציה, כולל:
# קומפילציה עם debug symbols gcc -g -O0 program.c -o program # דיבאג עם GDB gdb ./program (gdb) break main (gdb) run (gdb) info args # מציג פרמטרים - אפשרי בזכות DWARF (gdb) info locals # מציג משתנים מקומיים
glibc (GNU C Library) שומרת על תאימות בינארית לאחור (backward binary compatibility). כלומר, בינארי שקומפל על glibc 2.17 ירוץ גם על מערכת עם glibc 2.35, ללא צורך בקומפילציה מחדש. זה מתאפשר הודות ל:
glibc מתפקדת כשכבת הפשטה בין היישום לקרנל: גם אם הקרנל משתנה (מספרי syscall, מבנים פנימיים), glibc שומרת על ממשק יציב כלפי היישום. זה מאפשר להריץ תוכניות ישנות על גרסאות קרנל חדשות ללא שינוי.
gcc -S -O2 ולבחון את ה-Assembly שנוצר. בהכנה למיון סייבר ביחידה 8200, הבנה עמוקה של מנגנונים נמוכי רמה היא יתרון משמעותי. בהצלחה!