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.