Menu

Undefined Behavior ב-C: מה זה אומר ולמה זה נושך

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

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

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

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

מודל החוזה

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

לא לגשת לאינדקס מחוץ למערך. לא לגרום לגלישה של מספר שלם עם סימן. לא לקרוא ערך לא מאותחל. לא להשתמש במצביע אחרי ששחררתם אותו. לא לשנות את אותו אובייקט פעמיים בביטוי אחד בלי נקודת סדר (sequence point) ביניהן.

הפרתם סעיף, והעסקה מבוטלת, לכל התוכנית ולא רק לאותה שורה. אין "ברירת מחדל סבירה" ואין חובה לקרוס.

כדאי להפריד בין שלושה מונחים קרובים:

  • התנהגות לא מוגדרת (undefined): הכול יכול לקרות. גישה מחוץ לגבולות, גלישה עם סימן, שימוש אחרי שחרור.
  • התנהגות לא מפורטת (unspecified): אחת מכמה תוצאות תקינות, והקומפיילר לא חייב לספר לכם איזו. למשל, הסדר שבו מחושבים הארגומנטים של פונקציה.
  • התנהגות מוגדרת מימוש (implementation-defined): המימוש בוחר, וחייב לתעד את הבחירה. האם char הוא עם סימן, מה הגודל של int.

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

המקורות הגדולים

גלישה של מספר שלם עם סימן

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

הבדיקה if (a > INT_MAX - b) רצה כולה בתוך הטווח התקין, וזה מה שהופך אותה לבדיקת גלישה נכונה. כתיבה של if (a + b < 0) מבצעת את הגלישה קודם ורק אז שואלת על התוצאה, והקומפיילר, שרשאי להניח שהגלישה אף פעם לא קרתה, יכול למחוק את הבדיקה.

גישה מחוץ לגבולות

קריאה או כתיבה מחוץ למערך היא לא מוגדרת, בין אם היא גורמת לקריסה ובין אם לא:

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB: אינדקס 5 לא קיים */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* UB אפילו רק לחשב את המצביע הזה */

שימו לב לשורה האחרונה: יצירת מצביע שנמצא יותר ממקום אחד אחרי הסוף היא לא מוגדרת גם אם אף פעם לא מבצעים לו dereference. התקן מתיר arr + 5 (אחד אחרי הסוף, לסיום לולאות) אבל לא arr + 6.

החריגות הקטנות הן המסוכנות. בדרך כלל הן לא גורמות ל-segfault; הן דורסות בשקט משתנה שכן, והתשובה השגויה צצה במקום שאין לו שום קשר.

קריאת ערכים לא מאותחלים

int x;
printf("%d\n", x);     /* UB: קריאת ערך לא מוגדר */

int *p;
*p = 42;               /* UB: dereference למצביע לא מוגדר */

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

מצביעים תלויים (dangling)

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB: שימוש אחרי שחרור */
free(p);               /* UB: שחרור כפול */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB: משך החיים של האובייקט הסתיים */

ההשלכות בזמן ריצה מוסברות ב-segmentation fault; הנקודה כאן היא שהקריסה היא התוצאה בת המזל.

ה-specifier הלא נכון ב-printf

printf("%d\n", 3.14);        /* UB: %d עם double */
printf("%s\n", 42);          /* UB: %s עם int, בדרך כלל קורס */
printf("%d %d\n", 1);        /* UB: פחות ארגומנטים מ-specifiers */
long n = 5;
printf("%d\n", n);           /* UB במערכות שבהן long רחב יותר מ-int */

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

שינוי אובייקט פעמיים בביטוי אחד

int i = 0;
i = i++ + ++i;             /* UB */
arr[i] = i++;              /* UB */
printf("%d %d\n", i++, i); /* UB */

אלה לא מוגדרים, ולא סתם "תלויים בקומפיילר". חידות מספרי לימוד ששואלות לאיזה ערך מתחשב i = i++ + ++i הן חידות בלי תשובה נכונה.

Strict aliasing

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

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB: קריאת float דרך int * */

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

char * הוא החריג: תמיד מותר לבחון את הבתים של כל אובייקט דרך unsigned char *.

למה "אצלי זה עובד" לא מוכיח כלום

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

  • גרסה חדשה של הקומפיילר עם אופטימייזר חכם יותר.
  • מעבר מ--O0 ל--O2 בבילד של השחרור.
  • הוספת פונקציה לא קשורה, שמזיזה את פריסת המחסנית כך שחריגה נוחתת עכשיו על משהו שחשוב.
  • מכונה אחרת, libc אחרת, מערכת הפעלה אחרת.

המראה של "עובד" הוא לא הוכחה לנכונות, כי התקן אף פעם לא הבטיח כלום. זה באג רדום, ומה שמעיר אותו הוא בדרך כלל בילד השחרור.

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

הנה הדוגמה שבדרך כלל סוגרת את הוויכוח. מתכנת כותב בדיקת null:

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

הסדר שגוי, כי הבדיקה באה אחרי ה-dereference, אבל הבדיקה עדיין רצה, נכון?

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

בפונקציה המקומפלת אין בדיקת null בכלל. מקרה אמיתי של הדפוס הזה בקרנל של Linux הפך ל-CVE-2009-1897, שבו GCC הסיר בדיוק בדיקה כזו והפך טעות סדר שנראתה תמימה לחולשת אבטחה שאפשר לנצל.

דוגמה שנייה, קטנה יותר:

/* בדיקת גלישה שלא עובדת */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "האם התגלגל?" */
        return -1;
    }
    return sum;
}

עבור טיפוסים עם סימן הקומפיילר רשאי להניח ש-a + b לא גלש, ואז sum < a אפשרי רק כש-b < 0. תחת ההנחה הזו הבדיקה בודקת משהו אחר ממה שהכותב התכוון, ועם b >= 0 היא עלולה להיעלם לגמרי באופטימיזציה. הגרסה שעובדת בודקת מראש:

שתי הבדיקות נשארות בתוך הטווח שניתן לייצג, כך שאף פעם לא מתרחשת גלישה ואין לאופטימייזר שום דבר להניח שלא קורה. (GCC ו-clang מספקים גם את __builtin_add_overflow, שעושה את זה בפקודה אחת.)

איך מזהים את זה

קודם כל אזהרות סטטיות, הן בחינם:

gcc -Wall -Wextra -Wpedantic program.c -o program

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

אחר כך ה-sanitizers, שמוסיפים מכשור לתוכנית ומדווחים ברגע ההפרה:

gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

שלבו אותם עם AddressSanitizer בשביל חצי הזיכרון:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

יחד הם תופסים גישה מחוץ לגבולות, שימוש אחרי שחרור, שחרור כפול, גלישה עם סימן, הזזות ביטים לא תקינות, מצביעים לא מיושרים ו-dereference ל-null, כל אחד עם קובץ, שורה ו-stack trace. בערך פי 2 יותר איטי, וזה כלום בזמן פיתוח.

valgrind ./program לא דורש קימפול מחדש ותופס קריאות של ערכים לא מאותחלים ושגיאות זיכרון, אבל לא התנהגות לא מוגדרת אריתמטית. clang --analyze ו-gcc -fanalyzer מוצאים חלק מזה בלי להריץ את התוכנית בכלל.

הכלל המעשי: הריצו את הבדיקות שלכם תחת ה-sanitizers ב-CI. התנהגות לא מוגדרת שנראית עובדת מקומית היא בדיוק מה שהם נועדו לחשוף.

לחיות עם זה

אי אפשר להימנע מהתנהגות לא מוגדרת רק בזהירות: כולם כותבים כזו בסוף. מה שעובד הוא לגרום לה להיות רועשת:

  • קמפלו עם -Wall -Wextra מהיום הראשון, ותקנו כל אזהרה.
  • הריצו בדיקות תחת -fsanitize=address,undefined.
  • אתחלו כל משתנה בהצהרה שלו, וכל מצביע ל-NULL.
  • בדקו אינדקסים של מערכים מול האורך, וכתבו לולאות עם i < n.
  • בדקו גלישה לפני החישוב, בעזרת <limits.h>.
  • הציבו NULL במצביע אחרי ששחררתם אותו.
  • השתמשו בטיפוסי unsigned כשגלגול הוא ההתנהגות הרצויה: שם הוא מוגדר.
  • העדיפו memcpy על פני המרות מצביעים כשמפרשים בתים מחדש.

המהירות של C באה מכך שהקומפיילר רשאי להניח שעמדתם בחוזה. זו עסקה אמיתית, לא פגם בתכנון, והכלים שלמעלה מחזירים לכם את רוב הבטיחות בעלות אפסית בזמן הפיתוח.

לצד של הכללים האלה בזמן ריצה, ראו segmentation fault; לטעויות בזמן קימפול שבאות קודם, שגיאות נפוצות.

שאלות נפוצות

מה זו התנהגות לא מוגדרת (undefined behavior) ב-C?

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

למה ב-C יש התנהגות לא מוגדרת במקום להגדיר הכול?

מהירות וניידות. דרישה לבדיקת גבולות בכל גישה למערך הייתה עולה בביצועים ש-C תוכננה לא לשלם; הגדרת גלישה של מספרים עם סימן כ-wraparound הייתה מחייבת פקודות נוספות בחומרה שבמקום זה זורקת חריגה. השארת המקרים האלה לא מוגדרים מאפשרת לקומפיילר להניח שהם אף פעם לא קורים ולבצע אופטימיזציה בהתאם.

האם גלישה של מספר שלם עם סימן היא התנהגות לא מוגדרת ב-C?

כן. INT_MAX + 1 לא מוגדר: לא מובטח שהוא יתגלגל ל-INT_MIN. גלישה של מספר בלי סימן שונה: היא מוגדרת לגמרי ומתגלגלת מודולו 2^N. בגלל זה קומפיילרים רשאים להניח ש-x + 1 > x תמיד נכון עבור x עם סימן, ולמחוק בדיקת גלישה שנכתבה כך.

איך מזהים התנהגות לא מוגדרת בתוכנית C?

קמפלו עם -Wall -Wextra כדי לתפוס את מה שהקומפיילר רואה סטטית, ואז הריצו את הבדיקות עם -fsanitize=address,undefined, שמדווח על גישה מחוץ לגבולות, שימוש אחרי שחרור, גלישה עם סימן ועוד ברגע שהם קורים, עם שם הקובץ והשורה. valgrind תופס קבוצה דומה בלי לקמפל מחדש.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל