Menu

Segmentation Fault ב-C: מה גורם לה ואיך מתקנים

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

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

Segmentation fault (core dumped)

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

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

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

מה זה "זיכרון שלא שייך לכם"

כשהתוכנית שלכם מתחילה, מערכת ההפעלה ממפה כמה אזורים במרחב הכתובות שלה: הקוד, המשתנים הגלובליים, ה-stack, וכמה שה-heap גדל. כל השאר במרחב הכתובות, כולל כתובת 0, אינו ממופה. געו בכתובת לא ממופה, או כתבו לכתובת לקריאה בלבד, והחומרה לוכדת את זה.

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

גורם 1: הפניה (dereference) של מצביע NULL

הגורם הנפוץ ביותר, והקל ביותר לתיקון. כתובת 0 אף פעם לא ממופה, ולכן קריאה או כתיבה דרך מצביע null תמיד נכשלת.

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

int main(void) {
    int *p = NULL;
    *p = 42;              /* קריסה: כתיבה לכתובת 0 */
    printf("%d\n", *p);
    return 0;
}

הגרסה המציאותית היא הקצאה שלא נבדקה:

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

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* נכשלת, ומחזירה NULL */
    data[0] = 1;                                        /* קריסה */
    free(data);
    return 0;
}

malloc מחזירה NULL כשהיא לא יכולה לספק את הבקשה, וכך גם fopen כשהקובץ לא קיים, ו-strchr כשהתו לא נמצא. בדקו כל פונקציה שיכולה להחזיר NULL לפני שאתם משתמשים בתוצאה שלה.

אתחלו מצביעים ל-NULL במקום להשאיר אותם לא מאותחלים. מצביע null קורס מיד ובאופן ברור, ומצביע זבל עלול להשחית משהו ולקרוס הרבה יותר מאוחר. עוד על הדפוס הזה בעמוד מצביעי null.

גורם 2: כתיבה מעבר לסוף מערך

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

#include <stdio.h>

int main(void) {
    int arr[10];

    for (int i = 0; i <= 10; i++) {   /* <= במקום < : אחד יותר מדי */
        arr[i] = i;
    }

    printf("done\n");
    return 0;
}

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

הגרסה הפרועה יותר תמיד קורסת:

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* הרחק מחוץ לכל דבר ממופה: קריסה */
    return 0;
}

התיקון הוא ההרגל של i < n, וחישוב של n במקום להקליד אותו פעמיים:

למחרוזות יש גרסה משלהן לבעיה הזו: buffer שאין בו מקום ל-'\0' המסיים.

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9 תווים + תו מסיים לתוך 5 בתים */
    return 0;
}

strcpy לא יודעת מה גודל name. השתמשו ב-snprintf, שיודעת, כי אתם אומרים לה:

גורם 3: שימוש במצביע אחרי free (מצביעים תלויים)

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

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

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);

    printf("%d\n", *p);   /* שימוש אחרי שחרור: עלול להדפיס זבל, עלול לקרוס */
    free(p);              /* שחרור כפול: בדרך כלל עוצר את התוכנית או משחית את ה-heap */
    return 0;
}

המלכודת הקרובה היא החזרת הכתובת של משתנה מקומי. ה-stack frame שלו נעלם ברגע שהפונקציה חוזרת:

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* ה-frame מת כאן, והמצביע נשאר תלוי */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* לא מוגדר: זבל, או קריסה */
    return 0;
}

שני תיקונים, תלוי במה שהתכוונתם. החזירו את הערך במקום מצביע, או הקצו ב-heap ותנו לקורא לשחרר:

השמת NULL במצביע מיד אחרי free היא ההרגל ההגנתי הזול: היא הופכת שימוש שקט אחרי שחרור לקריסה מיידית וברורה של הפניית null, והיא הופכת free(p) שני לבלתי מזיק, כי free(NULL) מוגדר כפעולה שלא עושה כלום. צד ההקצאה של הסיפור הזה נמצא בזיכרון דינמי ובעמוד דליפות זיכרון.

גורם 4: Stack overflow מרקורסיה שיצאה משליטה

כל קריאה לפונקציה מניחה frame על ה-stack, וה-stack הוא אזור בגודל קבוע (לרוב 8 MB). רקורסיה בלי תנאי עצירה, או עם תנאי שאף פעם לא מגיעים אליו, רצה מעבר לסוף שלו.

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* אין תנאי עצירה: לא עוצרת אף פעם */
}

int main(void) {
    return countdown(5);
}

אותו דבר קורה עם תנאי עצירה שהרקורסיה מדלגת מעליו:

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* מ-n אי־זוגי, אף פעם לא שווה 0 */
}

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

גם מערך מקומי ענק עושה את זה: int buffer[10000000]; בתוך פונקציה מבקש 40 MB של stack ונכשל בכתיבה הראשונה. הקצו buffers גדולים ב-heap עם malloc. ראו stack מול heap לגבי הגדלים הרלוונטיים, ורקורסיה לגבי תכנון תנאי עצירה.

גורם 5: כתיבה לליטרל של מחרוזת

זה מפתיע אנשים, כי הקוד נראה תמים.

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* קריסה: ליטרלים של מחרוזות הם לקריאה בלבד */
    printf("%s\n", s);
    return 0;
}

ליטרל של מחרוזת נמצא באזור לקריאה בלבד של קובץ ההרצה. char *s = "hello" מצביע לתוכו, וכתיבה דרך המצביע הזה היא הפרת הגנה, שמערכת ההפעלה מדווחת עליה כ-segfault בדיוק כמו גישה לא ממופה.

התיקון הוא ליצור מערך, שמקבל עותק משלו שאפשר לשנות:

הצהרה על מצביעים לליטרלים כ-const char * הופכת את קריסת זמן הריצה הזו לשגיאת קומפילציה, וזה עדיף בכל מקרה. הפכו את זה להרגל.

מציאת השורה האמיתית: gdb

קמפלו עם -g כדי שקובץ ההרצה יכלול סמלי דיבוג, ואז הריצו אותו תחת ה-debugger:

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

בתוך gdb:

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555151 in process_item (item=0x0) at program.c:14
14          return item->count * 2;

(gdb) backtrace
#0  process_item (item=0x0) at program.c:14
#1  0x000055555555518a in main () at program.c:23

(gdb) print item
$1 = (struct Item *) 0x0

שלוש פקודות עושות את רוב העבודה. run מפעילה את התוכנית ועוצרת במקום שבו היא נכשלת. backtrace (או bt) מציגה את שרשרת הקריאות שהובילה לשם, ו-frame #1 הוא בדרך כלל המקום שבו המצביע השגוי נוצר בפועל. print בודקת משתנה, ו-item = 0x0 מצביע על הבעיה ישירות.

ב-macOS המקבילה היא lldb ./program, ואחר כך run ו-bt.

למצוא את זה מהר יותר: AddressSanitizer

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

gcc -g -fsanitize=address program.c -o program
./program
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x4011f6 in main program.c:11

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x4011c9 in main program.c:10
previously allocated by thread T0 here:
    #2 0x4011a6 in main program.c:8

הדוח הזה מציין את סוג הבאג, את השורה שגרמה לו, את השורה ששחררה את הזיכרון ואת השורה שהקצתה אותו. זה כלי הדיבוג האפקטיבי ביותר לבאגי זיכרון ב-C, הוא עובד עם GCC ו-clang ב-Linux וב-macOS, והוא עולה בערך פי 2 בזמן ריצה, מה שלא משנה בכלל בזמן פיתוח.

שלבו אותו עם -fsanitize=undefined כדי לתפוס באותו זמן גלישה של מספרים עם סימן ו-undefined behavior אחר:

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

valgrind ./program היא החלופה שלא דורשת קומפילציה מחדש, והיא מדווחת על אותם סוגי שגיאות, ובנוסף על דליפות.

רשימת בדיקה כשזה קורה

  1. בנו מחדש עם -g -Wall -Wextra -fsanitize=address,undefined והריצו שוב. ברוב המקרים הדוח מציין את השורה, וסיימתם.
  2. אם ה-sanitizer לא זמין, הריצו תחת gdb וקחו backtrace. הסתכלו על frame 1, לא רק על frame 0.
  3. בדקו כל מצביע בשורה שקורסת. הדפיסו כל אחד: 0x0 מזהה null, וערך פרוע כמו 0x7fff5fc01000 אומר בדרך כלל שהמצביע לא אותחל או שוחרר.
  4. שאלו מאיפה המצביע הזה הגיע. malloc או fopen שלא נבדקו? כתובת של משתנה מקומי בפונקציה שכבר חזרה? מצביע שהשתמשו בו אחרי free?
  5. בדקו כל גבול של לולאה ליד הקריסה, וחפשו <= במקום שבו התכוונו ל-<.
  6. אם ה-stack trace עמוק באלפי frames, זו רקורסיה שיצאה משליטה, ובכלל לא באג של מצביע.

איך מונעים את זה

ההרגלים שהופכים segfaults לנדירות:

  • קמפלו תמיד עם -Wall -Wextra, והתייחסו לאזהרות כאל באגים.
  • אתחלו כל מצביע, ל-NULL אם אין משהו טוב יותר.
  • בדקו את הערך המוחזר של malloc, calloc, realloc ו-fopen.
  • השימו NULL במצביעים מיד אחרי השחרור.
  • השתמשו ב-snprintf וב-fgets במקום sprintf ו-gets.
  • הצהירו על מצביעים לליטרלים של מחרוזות כ-const char *.
  • העדיפו sizeof arr / sizeof arr[0] על פני אורך שמוקלד ידנית.
  • הריצו את חבילת הבדיקות תחת AddressSanitizer ב-CI.

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

שאלות נפוצות

מה זו segmentation fault ב-C?

קריסה שמערכת ההפעלה מפעילה כשהתוכנית שלכם ניגשת לזיכרון שאסור לה לגעת בו: קריאה או כתיבה דרך מצביע לא תקין, ריצה מעבר לסוף מערך לתוך עמוד שאינו ממופה, או גלישה של ה-stack. הקרנל שולח לתהליך אות SIGSEGV, שמסיים אותו ומדפיס "Segmentation fault (core dumped)".

איך מוצאים איפה קורה segmentation fault?

קמפלו עם סמלי דיבוג והריצו תחת debugger: gcc -g program.c -o program ואז gdb ./program, run, וכשהתוכנית קורסת, backtrace. זה מדפיס את הקובץ והשורה המדויקים. מהיר עוד יותר לבאגי זיכרון הוא gcc -g -fsanitize=address program.c -o program: עצם הרצת התוכנית מדפיסה אז דוח מלא של מה השתבש ואיפה.

למה תוכנית ה-C שלי קורסת עם segfault רק לפעמים?

כי הגישה הלא תקינה היא undefined behavior, ולא קריסה מובטחת. כתיבה של איבר אחד מעבר למערך נוחתת לעיתים קרובות בזיכרון שהתהליך שלכם כן מחזיק, כך ששום דבר לא עוצר אתכם, והיא משחיתה משתנה שכן במקום זאת. segfault מתקבלת רק כשהכתובת השגויה במקרה נופלת מחוץ לעמוד ממופה, וזה תלוי בפריסת הזיכרון של אותו build ואותה הרצה.

האם segmentation fault אומרת שיש לי דליפת זיכרון?

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

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

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

להתחיל