يبدو "السلوك غير المعرَّف" مصطلحًا تقنيًا لـ "غير قابل للتنبّؤ". وهو أقوى من ذلك. فحين يقول معيار C إن لتركيب ما سلوكًا غير معرَّف، فهو يعني أن المعيار لا يفرض أي شروط إطلاقًا على ما يفعله البرنامج - لا على القيمة، ولا على الجملة، ولا على البرنامج.
وهذا هو الجزء الذي يفاجئ الناس: فالسلوك غير المعرَّف ليس محصورًا في السطر المخالف. إذ يُسمح للمترجم بافتراض أنه لا يحدث أبدًا، وبإعادة كتابة الشيفرة المحيطة بناءً على ذلك الافتراض. وقد تكون النتيجة برنامجًا لا يوجد فيه الفحصُ الذي كتبته بوضوح داخل الملف الثنائي.
نموذج العقد
تصوّر المعيار عقدًا بينك وبين المترجم. أنت تعد بألّا تفعل أشياء معيّنة؛ وفي المقابل يعد المترجم بأن برنامجك يعني ما يقوله.
لا تفهرس خارج مصفوفة. لا تُفِض عددًا صحيحًا موقّعًا. لا تقرأ قيمة غير مهيّأة. لا تستخدم مؤشّرًا بعد تحريره. لا تعدّل الكائن نفسه مرتين في تعبير واحد دون نقطة تسلسل بينهما.
انقض بندًا فتنفسخ الصفقة - للبرنامج كله لا لذلك السطر وحده. فلا يوجد "بديل معقول" ولا اشتراط بالانهيار.
وثلاثة مصطلحات مرتبطة تستحق الفصل بينها:
- السلوك غير المعرَّف - قد يحدث أي شيء. كالوصول خارج الحدود، وفيضان الموقّع، والاستخدام بعد التحرير.
- السلوك غير المحدَّد - واحدة من عدة نتائج صالحة، ولا يلزم المترجمَ أن يخبرك بأيّها. كترتيب تقييم وسائط الدالة مثلًا.
- السلوك المحدَّد بالتطبيق - يختار التطبيق، وعليه توثيق اختياره. كأن يكون
charموقّعًا، وكم يبلغ حجمint.
والأول وحده خطير بمعنى "المحسِّن حذف شيفرتي".
المصادر الكبرى
فيضان العدد الصحيح الموقّع
تلتفّ حسابات غير الموقّع، والمعيار يقول ذلك. أما حسابات الموقّع فلا - وتجاوز المدى غير معرَّف.
يُنفَّذ الفحص if (a > INT_MAX - b) كليًا داخل المدى الصالح، وهذا ما يجعله اختبار فيضان صحيحًا. أما كتابة if (a + b < 0) فتُجري الفيضان أولًا ثم تسأل عن النتيجة - والمترجم، وله حقّ افتراض أن الفيضان لم يحدث قط، قد يزيل الفحص.
الوصول خارج الحدود
القراءة أو الكتابة خارج مصفوفة غير معرَّفة، سواء انهار البرنامج أم لا:
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* UB: index 5 does not exist */
arr[-1] = 0; /* UB */
int *p = arr + 10; /* UB even to compute this pointer */
ولاحظ السطر الأخير: تكوين مؤشّر يتجاوز النهاية بأكثر من موضع واحد غير معرَّف حتى لو لم تراجعه أبدًا. فالمعيار يسمح بـ arr + 5 (موضعًا واحدًا بعد النهاية، لإنهاء الحلقات) ولا يسمح بـ arr + 6.
والتجاوزات الصغيرة هي الخطيرة. فهي لا تسبّب خطأ تجزئة عادةً؛ بل تكتب بهدوء فوق متغيّر مجاور، وتظهر الإجابة الخاطئة في موضع غير ذي صلة.
القراءات غير المهيّأة
int x;
printf("%d\n", x); /* UB: reading an indeterminate value */
int *p;
*p = 42; /* UB: dereferencing an indeterminate pointer */
من المغري التفكير بأنه "يحمل قمامة فحسب"، لكن ليس هذا ما يقوله المعيار، والمترجمات تستغلّ الفرق. فقد عُرف عن GCC أن يستنتج أن متغيّرًا يُقرأ قبل الإسناد يمكن أن يحمل أي قيمة يشاء - بما في ذلك ما يجعل فرعًا ينطوي ويزول.
المؤشّرات المعلّقة
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* UB: use after free */
free(p); /* UB: double free */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* UB: the object's lifetime ended */
ونتائج وقت التشغيل مغطّاة في خطأ التجزئة؛ والمقصود هنا أن الانهيار هو النتيجة المحظوظة.
محدّد printf الخاطئ
printf("%d\n", 3.14); /* UB: %d with a double */
printf("%s\n", 42); /* UB: %s with an int - usually crashes */
printf("%d %d\n", 1); /* UB: fewer arguments than specifiers */
long n = 5;
printf("%d\n", n); /* UB on systems where long is wider than int */
printf متغيّرة الوسائط: فهي تقرأ الوسائط وفق سلسلة التنسيق ولا تستطيع فحصها. وعدم التطابق يجعلها تقرأ عدد بايتات خاطئًا من موضع خاطئ. ترجم بـ -Wall فيفحص المترجم سلسلة التنسيق نيابةً عنك - وهذا من أعلى التحذيرات قيمةً في المجموعة.
تعديل كائن مرتين في تعبير واحد
int i = 0;
i = i++ + ++i; /* UB */
arr[i] = i++; /* UB */
printf("%d %d\n", i++, i); /* UB */
هذه غير معرَّفة، لا "معتمدة على المترجم" فحسب. وألغاز الكتب التي تسأل عمّا تساويه i = i++ + ++i لا إجابة صحيحة لها.
التسمية المستعارة الصارمة
الوصول إلى كائن عبر مؤشّر من نوع غير متوافق غير معرَّف، وهذا يفاجئ المبرمجين المخضرمين:
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* UB: reading a float through an int * */
يفترض المترجم أن int * وfloat * لا يشيران أبدًا إلى الذاكرة نفسها، ويعيد الترتيب وفقًا لذلك. والطريقة المعرَّفة لإعادة تفسير البايتات هي memcpy (التي تُحسَّن إلى التعليمات نفسها) أو اتّحاد:
وchar * استثناء - فيمكنك دائمًا فحص بايتات أي كائن عبر unsigned char *.
لماذا لا تثبت "إنها تعمل على جهازي" شيئًا
كثيرًا ما يبدو السلوك غير المعرَّف عاملًا، وهذا ما يجعله خطيرًا. فيعمل البرنامج بشكل صحيح طوال التطوير والاختبار، ثم ينكسر حين يتغيّر شيء غير ذي صلة إطلاقًا:
- إصدار مترجم جديد بمحسِّن أذكى.
- التحوّل من
-O0إلى-O2لبناء الإصدار. - إضافة دالة غير ذات صلة، فيزيح توزيع المكدّس بحيث يقع التجاوز الآن على شيء يهمّ.
- جهاز مختلف، أو مكتبة C مختلفة، أو نظام تشغيل مختلف.
وظهور العمل ليس دليلًا على الصحّة، لأن المعيار لم يعد بشيء قط. إنه خطأ في حالة سبات، والمحفّز عادةً هو بناء الإصدار.
كيف يستغلّه المحسِّن
إليك المثال الذي يحسم الجدال عادةً. يكتب مبرمج فحص فراغ:
void process(int *p) {
int value = *p; /* dereference */
if (p == NULL) { /* then check for null */
return;
}
printf("%d\n", value * 2);
}
الترتيب خاطئ - فالفحص يأتي بعد المراجعة - لكن الفحص ما يزال يُنفَّذ بالتأكيد؟
ليس بالضرورة. فالمترجم يستدلّ: جرت مراجعة *p، إذًا لا يمكن أن تكون p تساوي NULL (فمراجعة NULL غير معرَّفة، وفي أي برنامج ذي سلوك معرَّف هي ليست NULL)، إذًا p == NULL خاطئة دائمًا، إذًا جسم if كله شيفرة ميتة ويمكن حذفه.
فالدالة المترجَمة لا تحتوي على أي فحص فراغ إطلاقًا. وصار مثال حقيقي لهذا النمط في نواة لينكس الثغرةَ CVE-2009-1897، حيث أزال GCC فحصًا كهذا بالضبط وحوّل خطأ ترتيب يبدو حميدًا إلى ثغرة قابلة للاستغلال.
ومثال ثانٍ أصغر:
/* An overflow check that does not work */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* "did it wrap?" */
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
وهي تلتقط عدم تطابق سلاسل التنسيق، وبعض القراءات غير المهيّأة، والمقارنات المريبة، والشيفرة التي يتعذّر بلوغها.
ثم المعقِّمات، التي تجهّز البرنامج بأدوات قياس وتبلّغ لحظة المخالفة:
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
فهما معًا يلتقطان الوصول خارج الحدود، والاستخدام بعد التحرير، والتحرير المزدوج، وفيضان الموقّع، والإزاحات غير الصالحة، والمؤشّرات غير المحاذاة، ومراجعات الفراغ - كلٌّ مع ملف وسطر وأثر مكدّس. وهما أبطأ نحو الضعف، وهذا لا شيء أثناء التطوير.
وvalgrind ./program لا يحتاج إعادة ترجمة ويلتقط القراءات غير المهيّأة وأخطاء الذاكرة، وإن لم يلتقط السلوك غير المعرَّف الحسابي. وclang --analyze وgcc -fanalyzer يجدان بعضه دون تشغيل البرنامج إطلاقًا.
والقاعدة العملية: شغّل اختباراتك تحت المعقِّمات في التكامل المستمر. فالسلوك غير المعرَّف الذي يبدو عاملًا محليًا هو بالضبط ما وُجدت لكشفه.
التعايش معه
لا تستطيع تجنّب السلوك غير المعرَّف بالحذر - فالجميع يكتبه في النهاية. وما ينفع هو جعله صاخبًا:
- ابنِ بـ
-Wall -Wextraمن اليوم الأول، وأصلح كل تحذير. - شغّل الاختبارات تحت
-fsanitize=address,undefined. - هيّئ كل متغيّر عند التعريف، وكل مؤشّر بـ
NULL. - افحص فهارس المصفوفات مقابل الطول، وكرّر بـ
i < n. - افحص الفيضان قبل العملية الحسابية، مستخدمًا
<limits.h>. - اضبط المؤشّر على
NULLبعد تحريره. - استخدم الأنواع
unsignedحيث يكون الالتفاف هو السلوك المقصود - فهو معرَّف هناك. - فضّل
memcpyعلى تحويلات المؤشّرات عند إعادة تفسير البايتات.
سرعة C تأتي من السماح للمترجم بافتراض أنك حافظت على العقد. وهذه مقايضة حقيقية لا عيب تصميم - والأدوات أعلاه تعيد إليك معظم الأمان بتكلفة معدومة في وقت التطوير.
وللوجه الزمن-تشغيلي لهذه القواعد، انظر خطأ التجزئة؛ وللأخطاء في وقت الترجمة التي تأتي أولًا، الأخطاء الشائعة.
الأسئلة الشائعة
ما السلوك غير المعرَّف في لغة C؟
شيفرة لا يفرض عليها معيار C أي شروط إطلاقًا. والمترجم حرّ في إنتاج أي شيء: انهيار، أو إجابة خاطئة، أو شيفرة تبدو عاملة، أو شيفرة حُذف منها الفرع المخالف كليًا. وهذا ليس "محدَّدًا بالتطبيق" ولا "عشوائيًا" - بل عقد نقضته، ولا يُوعَد بشيء بعد ذلك.
لماذا يوجد في C سلوك غير معرَّف بدل تعريف كل شيء؟
السرعة وقابلية النقل. فاشتراط فحص حدود عند كل وصول إلى مصفوفة كان سيكلّف أداءً صُمّمت C لئلّا تدفعه؛ وتعريف فيضان الأعداد الموقّعة بالالتفاف كان سيفرض تعليمات إضافية على عتاد يعترض بدلًا من ذلك. وترك تلك الحالات غير معرَّفة يتيح للمترجم افتراض أنها لا تحدث أبدًا والتحسين وفقًا لذلك.
هل فيضان العدد الصحيح الموقّع سلوك غير معرَّف في لغة C؟
نعم. فـ INT_MAX + 1 غير معرَّف - وليس مضمونًا أن يلتفّ إلى INT_MIN. أما فيضان غير الموقّع فمختلف: فهو معرَّف تمامًا ويلتفّ بمقياس 2^N. ولهذا يجوز للمترجمات افتراض أن x + 1 > x صحيحة دائمًا لـ x موقّعة، وحذف فحص فيضان مكتوب بتلك الطريقة.
كيف أكتشف السلوك غير المعرَّف في برنامجي بلغة C؟
ترجم بـ -Wall -Wextra لالتقاط ما يستطيع المترجم رؤيته سكونيًا، ثم شغّل اختباراتك بـ -fsanitize=address,undefined، الذي يبلّغ عن الوصول خارج الحدود والاستخدام بعد التحرير وفيضان الأعداد الموقّعة وغيرها لحظة وقوعها، مسمّيًا الملف والسطر. وvalgrind يلتقط مجموعة مشابهة دون إعادة ترجمة.