Menu
flag Ar iconالعربيةdown icon

خطأ التجزئة في لغة C: ما أسبابه وكيف تصلحه

خطأ التجزئة يعني أن برنامجك لمس ذاكرة لا يملكها. إليك الأسباب الخمسة التي تفسّر معظمها، كلٌّ بمثال أدنى وإصلاحه، إضافة إلى كيفية إيجاد السطر الدقيق بـ gdb وAddressSanitizer.

تحتوي هذه الصفحة على محررات قابلة للتشغيل - حرّر، شغّل، وشاهد النتيجة فوراً.

Segmentation fault (core dumped)

ذلك السطر الواحد هو أكثر أخطاء C بحثًا، وهو أقلّ غموضًا مما يبدو. فقد طلب برنامجك من المعالج عنوانًا في الذاكرة، وفحص نظام التشغيل ما إذا كان يُسمح لعمليتك بلمس ذلك العنوان، وكان الجواب لا. ثم قتلت النواة العملية بإشارة SIGSEGV.

والبصيرة الأساسية: الانهيار عَرَض، وموضعه ليس الخطأ غالبًا. فالمؤشّر السيّئ أُنشئ عادةً في موضع آخر سابقًا، وهذا مجرّد أول موضع استُخدم فيه. تغطّي هذه الصفحة الأسباب الخمسة التي تفسّر كل خطأ تجزئة تقريبًا، ثم الأداتين اللتين تجدان السطر الحقيقي في ثوانٍ.

والأمثلة المنهارة أدناه ليست كتلًا قابلة للتشغيل عن قصد - فهي تنهار بحكم التصميم. اقرأها، ثم اقرأ النسخة المُصلَحة التي تليها.

ما معنى "ذاكرة لا تملكها"

حين يبدأ برنامجك، يخطّط نظام التشغيل عدة مناطق في فضاء عناوينه: الشيفرة، والمتغيّرات العامة، والمكدّس، وما نمت إليه الكومة. وكل ما عدا ذلك في فضاء العناوين - بما في ذلك العنوان 0 - غير مخطَّط. فالمس عنوانًا غير مخطَّط، أو اكتب في عنوان للقراءة فقط، فيعترضه العتاد.

فخطأ التجزئة إذًا ليس المترجم يمسك بك. بل حاجز حماية في وقت التشغيل، ولا يعمل إلا حين يصادف العنوان غير الصالح أن يكون خارج صفحاتك المخطَّطة. ولهذا يمكن للخطأ نفسه أن ينهار على جهاز ويبدو عاملًا على آخر: فتوزيع الذاكرة مختلف.

السبب 1: مراجعة مؤشّر فارغ

أشيع الأسباب وأسهلها إصلاحًا. فالعنوان 0 غير مخطَّط أبدًا، فالقراءة أو الكتابة عبر مؤشّر فارغ تخطئ دائمًا.

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

int main(void) {
    int *p = NULL;
    *p = 42;              /* CRASH: writing to address 0 */
    printf("%d\n", *p);
    return 0;
}

والنسخة الواقعية هي تخصيص غير مفحوص:

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

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* fails, returns NULL */
    data[0] = 1;                                        /* CRASH */
    free(data);
    return 0;
}

تُرجِع malloc القيمة NULL حين تعجز عن تلبية الطلب؛ وكذلك fopen حين لا يوجد الملف، وstrchr حين يغيب المحرف. افحص كل دالة قد تُرجِع NULL قبل استخدام نتيجتها.

هيّئ المؤشّرات بـ NULL بدل تركها غير مهيّأة. فالمؤشّر الفارغ ينهار فورًا وبوضوح؛ والمؤشّر المهمل قد يفسد شيئًا وينهار متأخّرًا بكثير. والمزيد عن النمط في المؤشّرات الفارغة.

السبب 2: الكتابة بعد نهاية مصفوفة

لا تفحص C حدود المصفوفات. فالفهرس 10 في مصفوفة من عشرة عناصر ليس إلا الذاكرة التي تلي المصفوفة، وسيحسب المترجم ذلك العنوان نيابةً عنك دون اعتراض.

#include <stdio.h>

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

    for (int i = 0; i <= 10; i++) {   /* <= instead of < : one too many */
        arr[i] = i;
    }

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

وكون ذلك ينهار مسألة حظّ. فالكتابة بأربعة بايتات بعد مصفوفة محلّية تقع عادةً على بيانات مكدّس أخرى - سجلّ محفوظ، أو متغيّر آخر، أو عنوان العودة - فيفسد البرنامج نفسه وينهار لاحقًا في موضع غير ذي صلة. أما التجاوز الكبير فيغادر الصفحة المخطَّطة ويخطئ في الحال.

والنسخة الأشدّ تنهار دائمًا:

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* far outside anything mapped: CRASH */
    return 0;
}

والإصلاح هو عادة i < n، وحساب n بدل كتابته مرتين:

وللسلاسل النصية نسختها من هذا: مخزن لا مساحة فيه للمنهي '\0'.

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9 chars + terminator into 5 bytes */
    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);   /* use after free: may print garbage, may crash */
    free(p);              /* double free: usually aborts or corrupts the heap */
    return 0;
}

والفخّ المرتبط هو إرجاع عنوان متغيّر محلّي. فإطار مكدّسه يزول لحظة عودة الدالة:

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* the frame dies here; the pointer dangles */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* undefined: garbage, or a crash */
    return 0;
}

وهناك إصلاحان، بحسب ما قصدته. أرجِع القيمة بدل مؤشّر، أو خصّص على الكومة ودع المستدعي يحرّر:

وضبط المؤشّر على NULL فور free هو العادة الدفاعية الرخيصة: فهو يحوّل استخدامًا صامتًا بعد التحرير إلى انهيار فوري واضح بمؤشّر فارغ، ويجعل free(p) ثانية غير ضارّة، إذ free(NULL) معرَّفة بألّا تفعل شيئًا. وجانب التخصيص من هذه القصة في الذاكرة الديناميكية وتسريبات الذاكرة.

السبب 4: فيضان المكدّس من استدعاء ذاتي جامح

يضع كل استدعاء دالة إطارًا على المكدّس، والمكدّس منطقة ثابتة الحجم (8 ميغابايت شائعة). والاستدعاء الذاتي بلا حالة أساسية - أو بحالة لا تُبلَغ أبدًا - يتجاوز نهايته.

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* no base case: never stops */
}

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

ويحدث الشيء نفسه مع حالة أساسية يتخطّاها الاستدعاء الذاتي:

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* from an odd n, never equals 0 */
}

فكل دالة تكرارية ذاتيًا تحتاج حالة أساسية يمكن بلوغها من كل مدخل:

ومصفوفة محلّية ضخمة تفعل ذلك أيضًا - فـ int buffer[10000000]; داخل دالة تطلب 40 ميغابايت من المكدّس وتخطئ عند أول كتابة. خصّص المخازن الكبيرة على الكومة بـ malloc. انظر المكدّس مقابل الكومة للأحجام المعنية، والاستدعاء الذاتي لتصميم الحالة الأساسية.

السبب 5: الكتابة في ثابت نصّي

هذا يفاجئ الناس لأن الشيفرة تبدو غير ضارّة.

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* CRASH: string literals are read-only */
    printf("%s\n", s);
    return 0;
}

يعيش الثابت النصّي في قسم للقراءة فقط من الملف التنفيذي. وchar *s = "hello" تشير إليه؛ والكتابة عبر ذلك المؤشّر خطأ حماية، يبلّغ عنه نظام التشغيل كخطأ تجزئة تمامًا كالوصول غير المخطَّط.

والإصلاح إنشاء مصفوفة، فتحصل على نسختها القابلة للتعديل:

وتعريف مؤشّرات الثوابت النصّية بـ const char * يحوّل هذا الانهيار في وقت التشغيل إلى خطأ في وقت الترجمة، وهو أفضل قطعًا. اجعلها عادة.

إيجاد السطر الحقيقي: gdb

ترجم بـ -g ليحمل الملف التنفيذي رموز التنقيح، ثم شغّله تحت المنقّح:

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) تعرض سلسلة الاستدعاءات التي أوصلت إلى هناك - والإطار #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 على لينكس وmacOS، ويكلّف نحو ضعف زمن التشغيل - وهو غير ذي صلة أثناء التطوير.

واقرنه بـ -fsanitize=undefined لالتقاط فيضان الأعداد الموقّعة وسائر السلوك غير المعرَّف في الوقت نفسه:

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

وvalgrind ./program هو البديل الذي لا يحتاج إعادة ترجمة، ويبلّغ عن أصناف الأخطاء نفسها إضافةً إلى التسريبات.

قائمة تحقّق حين تصادف واحدًا

  1. أعد البناء بـ -g -Wall -Wextra -fsanitize=address,undefined وشغّله مجددًا. فالتقرير يسمّي السطر في معظم الأحيان وينتهي الأمر.
  2. إن لم يتوفّر المعقِّم، شغّله تحت gdb وخُذ backtrace. وانظر إلى الإطار 1، لا إلى الإطار 0 وحده.
  3. افحص كل مؤشّر في السطر المنهار. اطبع كلًّا منها؛ فـ 0x0 يحدّد مؤشّرًا فارغًا، وقيمة شاردة مثل 0x7fff5fc01000 تعني عادةً غير مهيّأ أو محرَّرًا.
  4. اسأل من أين جاء ذلك المؤشّر. malloc أو fopen غير مفحوصة؟ عنوان متغيّر محلّي عادت دالته منذئذ؟ مؤشّر استُخدم بعد free؟
  5. افحص كل حدّ حلقة قرب الانهيار بحثًا عن <= حيث كانت < مقصودة.
  6. إن كان أثر المكدّس بعمق آلاف الإطارات، فهو استدعاء ذاتي جامح لا خطأ مؤشّر إطلاقًا.

الوقاية منها

العادات التي تجعل أخطاء التجزئة نادرة:

  • ترجم بـ -Wall -Wextra دائمًا، وعامِل التحذيرات كأخطاء.
  • هيّئ كل مؤشّر، بـ NULL إن لم يكن لديك أفضل.
  • افحص القيمة المُرجَعة من malloc وcalloc وrealloc وfopen.
  • اضبط المؤشّرات على NULL فور التحرير.
  • استخدم snprintf وfgets بدل sprintf وgets.
  • عرّف مؤشّرات الثوابت النصّية بـ const char *.
  • فضّل sizeof arr / sizeof arr[0] على طول مكتوب يدويًا.
  • شغّل مجموعة الاختبارات تحت AddressSanitizer في التكامل المستمر.

وخطأ التجزئة هو نمط الفشل الودود - فهو يخبرك بأن شيئًا خاطئًا. أما صنف الخطأ نفسه الذي يفسد متغيّرًا مجاورًا بصمت وينتج إجابات خاطئة بعد ثلاث دوال فأسوأ بكثير، والأدوات أعلاه تلتقط كليهما.

الأسئلة الشائعة

ما خطأ التجزئة في لغة C؟

انهيار يطلقه نظام التشغيل حين يصل برنامجك إلى ذاكرة لا يُسمح له بلمسها - قراءة أو كتابة عبر مؤشّر غير صالح، أو تجاوز نهاية مصفوفة إلى صفحة غير مخطَّطة، أو إفاضة المكدّس. فترسل النواة إلى العملية إشارة SIGSEGV تُنهيها وتطبع "Segmentation fault (core dumped)".

كيف أجد أين يقع خطأ التجزئة؟

ترجم برموز تنقيح وشغّل تحت منقّح: gcc -g program.c -o program ثم gdb ./program وrun، وحين ينهار نفّذ backtrace. فيطبع الملف والسطر بالضبط. والأسرع لأخطاء الذاكرة هو gcc -g -fsanitize=address program.c -o program - فمجرّد تشغيل البرنامج يطبع تقريرًا كاملًا بما حدث وأين.

لماذا ينهار برنامجي أحيانًا فقط بخطأ تجزئة؟

لأن الوصول غير الصالح سلوك غير معرَّف لا انهيار مضمون. فالكتابة بعنصر واحد بعد مصفوفة كثيرًا ما تقع في ذاكرة تملكها عمليّتك فعلًا، فلا شيء يوقفك - بل تفسد متغيّرًا مجاورًا بدلًا من ذلك. ولا ينهار إلا حين يصادف العنوان السيّئ أن يقع خارج صفحة مخطَّطة، وهذا يعتمد على توزيع ذلك البناء وذلك التشغيل بعينه.

هل يعني خطأ التجزئة أن لديّ تسريب ذاكرة؟

لا - فهما مشكلتان متضادّتان. فالتسريب ذاكرة خصّصتها ولم تحرّرها قط: فيواصل البرنامج العمل وينمو ببطء. أما خطأ التجزئة فلمس ذاكرة لا تملكها. وتحرير الذاكرة مرتين، أو استخدام مؤشّر بعد تحريره، يسبّب أخطاء تجزئة؛ ونسيان التحرير يسبّب تسريبات.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن