לדלג לתוכן

התנהגות לא מוגדרת - undefined behavior

הקדמה

בפרק 3 למדנו את היסודות של שפת C - פוינטרים, מערכים, הקצאת זיכרון. ראינו שC נותנת לנו שליטה מלאה בזיכרון, אבל לא מגנה עלינו מטעויות. בהרצאה הזו נלמד על אחד הנושאים הכי חשובים (ומסוכנים) בC - undefined behavior, בקיצור UB.


מהי התנהגות לא מוגדרת - undefined behavior?

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

זה שונה מ-התנהגות מוגדרת-מימוש - implementation-defined behavior - שם ההתנהגות תלויה בקומפיילר או בפלטפורמה, אבל היא עקבית ומתועדת. למשל, הגודל של int (16 או 32 ביט) הוא implementation-defined: כל פלטפורמה בוחרת ערך ושומרת עליו.

ב-UB אין שום הבטחה. הקוד יכול "לעבוד" בגרסה אחת של הקומפיילר ולהתפוצץ בגרסה הבאה.


למה קיימת התנהגות לא מוגדרת?

שאלה טובה. למה שתקן שפת C לא פשוט יגדיר מה קורה בכל מצב?

התשובה: אופטימיזציות. כשהקומפיילר יודע שUB לא יקרה (כי אם הוא יקרה, הקוד שבור ממילא), הוא יכול להניח הנחות חזקות ולייצר קוד מהיר יותר.

למשל, אם הקומפיילר יודע ש-signed overflow הוא UB, הוא יכול להניח ש-x + 1 > x תמיד נכון (כי אם x הוא INT_MAX, הoverflow הוא UB ולא יקרה). ההנחה הזו מאפשרת לו לפשט חישובים ולהסיר בדיקות מיותרות.


סוגי UB נפוצים בC

1. גלישת מספר חיובי - signed integer overflow

זה אולי הUB הכי מפתיע:

int x = INT_MAX;
x = x + 1; // UB!

גלישה של int (signed) היא UB. לעומת זאת, גלישה של unsigned int מוגדרת - היא עוטפת מודולו 2^32.

למה זה מסוכן? הקומפיילר יכול להניח שגלישה לעולם לא תקרה, ולהסיר בדיקות שמסתמכות עליה:

// הקומפיילר עלול להסיר את הבדיקה הזו!
if (x + 1 > x) {
    // הקומפיילר חושב: "זה תמיד נכון, כי overflow הוא UB ולא יקרה"
    do_something();
}

עם אופטימיזציות מופעלות, הif יכול להיעלם לגמרי!

דוגמה מהחיים: קוד שבודק buffer overflow:

// רוצים לוודא שלא חורגים מגבול
if (ptr + offset < ptr) {
    // overflow check
    return ERROR;
}

הקומפיילר יכול להסיר את כל הבדיקה הזו. מבחינתו, חיבור pointer לא יכול לגלוש (כי אם כן, זה UB), אז ptr + offset תמיד גדול מ-ptr (כש-offset חיובי). התוצאה: הבדיקה נעלמת, והתוכנית פגיעה ל-buffer overflow.


2. גישה דרך מצביע NULL - null pointer dereference

int *p = NULL;
int x = *p; // UB!

ברוב המערכות זה יגרום ל-segmentation fault, אבל זו לא הבטחה - זו התנהגות של מערכת ההפעלה, לא של שפת C.

דבר מעניין שהקומפיילר עושה:

void foo(int *p) {
    int x = *p;          // dereference של p
    if (p == NULL) {     // בדיקה אם p הוא NULL
        handle_null();
        return;
    }
    use(x);
}

הקומפיילר רואה שביצענו dereference ל-p לפני הבדיקה. מבחינתו, אם p הוא NULL, הdereference כבר היה UB, ולכן p לא יכול להיות NULL. התוצאה: הקומפיילר מסיר את הבדיקה if (p == NULL) לגמרי!

זה באג אמיתי שקרה בקרנל של לינוקס ואפשר ניצול אבטחתי.


3. גלישת מערך - buffer overflow

int arr[10];
arr[15] = 42; // UB! גישה מחוץ לגבולות

למדנו בפרק 3 שC לא בודקת גבולות מערך. גישה מחוץ לגבולות היא UB - יכול לקרוא ערך זבל, לדרוס משתנים אחרים בstack, או לקרוס.


4. שימוש אחרי שחרור - use after free

int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p); // UB! הזיכרון שוחרר

אחרי free, הזיכרון עשוי להיות מוקצה לשימוש אחר. קריאה ממנו עלולה להחזיר ערך שונה, זבל, או לקרוס. כתיבה אליו עלולה לדרוס נתונים של הקצאה אחרת.


5. משתנים לא מאותחלים - uninitialized variables

int x;
printf("%d\n", x); // UB! x לא אותחל

בC, משתנים מקומיים לא מאותחלים אוטומטית. הערך שלהם הוא מה שבמקרה היה בstack באותו מקום - "זבל". אבל מבחינת התקן, קריאה ממשתנה לא מאותחל היא UB, והקומפיילר רשאי להניח שזה לא קורה.


6. הפרת כלל הaliasing - strict aliasing violation

float f = 3.14;
int *ip = (int *)&f;
int i = *ip; // UB! strict aliasing violation

כלל הstrict aliasing אומר: אי אפשר לגשת לאובייקט דרך מצביע מסוג אחר (מלבד char*). הקומפיילר מניח שמצביע int* ומצביע float* לעולם לא מצביעים לאותו מקום, מה שמאפשר לו לבצע אופטימיזציות אגרסיביות.

הדרך התקינה להמיר בין סוגים ברמת הביטים היא עם memcpy:

float f = 3.14;
int i;
memcpy(&i, &f, sizeof(i)); // תקין!

7. הפרת נקודת רצף - sequence point violation

int i = 0;
int j = i++ + i++; // UB!

בין שתי נקודות רצף (sequence points), אסור לשנות את אותו משתנה יותר מפעם אחת. הביטוי i++ + i++ משנה את i פעמיים באותו ביטוי, ולכן זה UB.

גם הביטוי הבא הוא UB:

arr[i] = i++; // UB!

8. הזזה לא חוקית - invalid shift

int x = 1 << 32;   // UB! (אם int הוא 32 ביט)
int y = 1 << -1;   // UB!

הזזה במספר שלילי או במספר גדול/שווה לגודל הטיפוס היא UB.


9. חילוק באפס - division by zero

int x = 5 / 0; // UB!
int y = 5 % 0; // UB!

10. שינוי מחרוזת קבועה - modifying a string literal

char *s = "hello";
s[0] = 'H'; // UB!

מחרוזות קבועות יושבות ב-read-only section (למדנו על זה בפרק 3). ניסיון לשנות אותן הוא UB - ברוב המערכות יגרום ל-segfault.


איך הקומפיילר מנצל UB לאופטימיזציות

בואו נראה דוגמה מפורטת. קחו את הפונקציה הבאה:

int is_positive(int x) {
    if (x > 0)
        return 1;
    if (x < 0)
        return 0;
    return 0;
}

נראה תמים, נכון? עכשיו מה עם הגרסה הזו:

int check(int x) {
    return (x + 1) > x;
}

אנחנו חושבים שזה בודק אם x + 1 > x - מה שנכון תמיד חוץ מכשx הוא INT_MAX (אז x+1 גולש ל-INT_MIN). אבל הקומפיילר מוחק את כל הפונקציה ומחזיר return 1 תמיד. למה? כי signed overflow הוא UB, אז x+1 תמיד גדול מx.

דוגמה נוספת:

void process(int *p) {
    *p = 5;              // שורה 1: dereference p
    if (p == NULL)        // שורה 2: בדיקת NULL
        return;
    printf("value: %d\n", *p);
}

עם אופטימיזציות, הקומפיילר מוחק את הif. ההיגיון: בשורה 1, עשינו dereference ל-p. אם p היה NULL, זה UB שלא קורה. אז p בהכרח לא NULL, ואין צורך בבדיקה.


כלים לזיהוי UB

סאניטייזרים - sanitizers

הקומפיילר GCC ו-Clang תומכים בsanitizers - כלים שמוסיפים בדיקות בזמן ריצה כדי לתפוס UB:

UBSan - undefined behavior sanitizer:

gcc -fsanitize=undefined program.c -o program
./program

תופס: signed overflow, shift errors, division by zero, null dereference, ועוד.

פלט לדוגמה:

program.c:5:15: runtime error: signed integer overflow: 2147483647 + 1
    cannot be represented in type 'int'

ASan - address sanitizer:

gcc -fsanitize=address program.c -o program

תופס: buffer overflow, use-after-free, double free, memory leaks.

MSan - memory sanitizer (ב-clang בלבד):

clang -fsanitize=memory program.c -o program

תופס: קריאה ממשתנים לא מאותחלים.


אזהרות קומפיילר

תמיד קמפלו עם הדגלים האלו:

gcc -Wall -Wextra -Werror program.c -o program
  • -Wall - מפעיל את רוב האזהרות
  • -Wextra - מפעיל אזהרות נוספות
  • -Werror - הופך אזהרות לשגיאות (הקוד לא יתקמפל אם יש אזהרות)

כלי Valgrind

Valgrind הוא כלי שמריץ את התוכנה בסביבה מבוקרת ובודק בעיות זיכרון:

gcc -g program.c -o program
valgrind ./program

תופס: memory leaks, use after free, double free, uninitialized reads, invalid reads/writes.


שיטות עבודה נכונות

  1. תמיד קמפלו עם אזהרות: -Wall -Wextra -Werror
  2. תמיד השתמשו ב-sanitizers בפיתוח:
    gcc -Wall -Wextra -Werror -fsanitize=undefined,address -g program.c -o program
    
  3. אתחלו כל משתנה - גם אם אתם חושבים שתכתבו אליו לפני שתקראו ממנו
  4. בדקו ערכי החזרה - של malloc, fopen, וכל פונקציה שיכולה להיכשל
  5. השתמשו ב-const כשמשתנה לא אמור להשתנות
  6. היזהרו מarithmetic עם signed integers - אם יש סיכוי לגלישה, בדקו לפני הפעולה או השתמשו ב-unsigned
  7. אל תסתמכו על UB "שעובד" - אם קוד מסתמך על UB שבמקרה עובד על המכונה שלכם, הוא עלול להישבר בכל עדכון קומפיילר

סיכום

  • התנהגות לא מוגדרת (UB) היא מצב שהתקן לא מגדיר מה קורה - הקומפיילר רשאי לעשות כל דבר
  • UB קיים כדי לאפשר אופטימיזציות אגרסיביות
  • הקומפיילר מניח שUB לעולם לא קורה, ומשתמש בהנחה הזו כדי להסיר קוד, לפשט חישובים, ולסדר מחדש פעולות
  • סוגי UB נפוצים: signed overflow, null dereference, buffer overflow, use after free, uninitialized variables, strict aliasing
  • כלים כמו sanitizers, אזהרות קומפיילר, ו-Valgrind עוזרים לזהות UB
  • ככלל, קמפלו תמיד עם אזהרות מלאות ו-sanitizers בזמן פיתוח