לדלג לתוכן

10.2 התנהגות לא מוגדרת פתרון

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

פתרון 1 - זיהוי UB בקוד

קטע א - כן, יש UB:
גלישת מערך (buffer overflow). הלולאה רצה עד i <= 5, כלומר ניגשת ל-arr[5] שהוא מחוץ לגבולות (המערך הוא 0-4). יכול לקרוא ערך זבל מהstack.

קטע ב - אין UB:
גלישה של unsigned int מוגדרת בתקן - היא עוטפת מודולו 2^32. התוצאה תהיה 0. זה לא UB.

קטע ג - כן, יש UB:
הזזה ב-33 כשהגודל של int הוא 32 ביט. הזזה בערך גדול או שווה לגודל הטיפוס (בביטים) היא UB.

קטע ד - כן, יש UB:
שינוי מחרוזת קבועה (string literal). המחרוזת "hello" יושבת ב-read-only memory. ברוב המערכות זה יגרום ל-segfault.

קטע ה - כן, יש UB:
שחרור כפול (double free). קריאה ל-free על אותו מצביע פעמיים היא UB. יכולה לגרום לcorruption של ה-heap, לקריסה, או לפגיעות אבטחה.

קטע ו - כן, יש UB:
הפרת נקודת רצף (sequence point violation). הביטוי a++ + a++ משנה את a פעמיים בין שתי נקודות רצף. הקומפיילר רשאי לבצע את שתי ההגדלות בכל סדר.


פתרון 2 - signed overflow ואופטימיזציות

#include <stdio.h>
#include <limits.h>

// הגרסה המקורית - יש בה UB
int check_overflow(int x) {
    if (x + 1 > x)
        return 1;
    return 0;
}

// גרסה בטוחה - בודקת לפני החיבור
int check_overflow_safe(int x) {
    if (x == INT_MAX)
        return 0; // חיבור 1 יגלוש
    return 1;
}

int main(void) {
    printf("check_overflow(INT_MAX) = %d\n", check_overflow(INT_MAX));
    printf("check_overflow_safe(INT_MAX) = %d\n", check_overflow_safe(INT_MAX));
    return 0;
}

הסבר:

  • עם -O0 (ללא אופטימיזציות): הקומפיילר מייצר את הקוד "כמו שהוא". INT_MAX + 1 גולש ל-INT_MIN (ברוב הארכיטקטורות), שהוא קטן מ-INT_MAX, אז הפונקציה מחזירה 0.

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

הגרסה הבטוחה בודקת ישירות אם x הוא INT_MAX, בלי לבצע את החיבור עצמו.


פתרון 3 - שימוש ב-sanitizers

#include <stdio.h>
#include <stdlib.h>
#include <limits.h>

void signed_overflow(void) {
    int x = INT_MAX;
    int y = x + 1; // UB: signed overflow
    printf("overflow: %d\n", y);
}

void buffer_overrun(void) {
    int arr[5] = {1, 2, 3, 4, 5};
    int x = arr[10]; // UB: out of bounds
    printf("out of bounds: %d\n", x);
}

void uninitialized(void) {
    int x;
    if (x > 0) // UB: uninitialized read
        printf("positive\n");
}

void divide_by_zero(void) {
    int a = 5;
    volatile int b = 0;
    int c = a / b; // UB: division by zero
    printf("div: %d\n", c);
}

void use_after_free(void) {
    int *p = malloc(sizeof(int));
    *p = 42;
    free(p);
    printf("use after free: %d\n", *p); // UB
}

int main(void) {
    // בטלו הערה מהפונקציה שרוצים לבדוק
    // signed_overflow();
    // buffer_overrun();
    // uninitialized();
    // divide_by_zero();
    // use_after_free();
    return 0;
}

קומפילציה ובדיקה:

# בדיקת UB כללי
gcc -fsanitize=undefined -g ub_test.c -o ub_test
./ub_test

# בדיקת בעיות זכרון
gcc -fsanitize=address -g ub_test.c -o ub_test
./ub_test

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


פתרון 4 - strict aliasing

#include <stdio.h>
#include <string.h>
#include <stdint.h>

// דרך לא בטוחה - strict aliasing violation
float int_bits_to_float_bad(int i) {
    return *(float *)&i;
}

// דרך בטוחה - memcpy
float int_bits_to_float_good(int i) {
    float f;
    memcpy(&f, &i, sizeof(f));
    return f;
}

int main(void) {
    int pi_bits = 0x40490FDB; // IEEE 754 representation of pi

    float f_bad  = int_bits_to_float_bad(pi_bits);
    float f_good = int_bits_to_float_good(pi_bits);

    printf("bad method:  %f\n", f_bad);
    printf("good method: %f\n", f_good);
    // שתיהן צריכות להדפיס 3.141593, אבל הגרסה הראשונה היא UB

    return 0;
}

הסבר:

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

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


פתרון 5 - כתיבת קוד בטוח

#include <stdio.h>
#include <limits.h>

// חיבור בטוח
int safe_add(int a, int b, int *result) {
    // בדיקת גלישה לפני החיבור
    if (b > 0 && a > INT_MAX - b)
        return -1; // גלישה חיובית
    if (b < 0 && a < INT_MIN - b)
        return -1; // גלישה שלילית

    *result = a + b;
    return 0;
}

// חילוק בטוח
int safe_div(int a, int b, int *result) {
    if (b == 0)
        return -1; // חילוק באפס

    // INT_MIN / (-1) גולש כי |INT_MIN| > INT_MAX
    if (a == INT_MIN && b == -1)
        return -1;

    *result = a / b;
    return 0;
}

// הזזה בטוחה
int safe_shift(int val, int shift, int *result) {
    // shift שלילי הוא UB
    if (shift < 0)
        return -1;

    // shift >= מספר הביטים הוא UB
    if (shift >= (int)(sizeof(int) * 8))
        return -1;

    // הזזה שמאלה של ערך שלילי היא UB
    if (val < 0)
        return -1;

    *result = val << shift;
    return 0;
}

int main(void) {
    int result;

    // בדיקות safe_add
    if (safe_add(INT_MAX, 1, &result) == 0)
        printf("INT_MAX + 1 = %d\n", result);
    else
        printf("INT_MAX + 1 = overflow!\n"); // יודפס

    if (safe_add(100, 200, &result) == 0)
        printf("100 + 200 = %d\n", result); // 300

    // בדיקות safe_div
    if (safe_div(10, 0, &result) == 0)
        printf("10 / 0 = %d\n", result);
    else
        printf("10 / 0 = error!\n"); // יודפס

    if (safe_div(INT_MIN, -1, &result) == 0)
        printf("INT_MIN / -1 = %d\n", result);
    else
        printf("INT_MIN / -1 = overflow!\n"); // יודפס

    if (safe_div(42, 7, &result) == 0)
        printf("42 / 7 = %d\n", result); // 6

    // בדיקות safe_shift
    if (safe_shift(1, 33, &result) == 0)
        printf("1 << 33 = %d\n", result);
    else
        printf("1 << 33 = error!\n"); // יודפס

    if (safe_shift(1, 10, &result) == 0)
        printf("1 << 10 = %d\n", result); // 1024

    return 0;
}

שימו לב לבדיקת הגלישה ב-safe_add: אנחנו לא מבצעים את החיבור ואז בודקים אם גלש (כי זה כבר UB). במקום זה, אנחנו בודקים לפני החיבור: אם b > 0, בודקים אם a > INT_MAX - b (כלומר a + b יהיה גדול מ-INT_MAX). הבדיקה עצמה משתמשת בחיסור שלא גולש.

באותו אופן, INT_MIN / (-1) הוא UB כי הערך המוחלט של INT_MIN (שהוא 2147483648) גדול מ-INT_MAX (שהוא 2147483647), ולכן התוצאה לא מייצגת ב-signed int.