C: أخطاء شائعة والبرمجة الآمنة

أخطاء C الشائعة كالحفر في الطريق — تبدو غير مؤذية، لكن تخطو في إحداها فينهار برنامجك. تعلّم التعرّف على هذه الحفر لتتجاوزها.

1. أخطاء أسبقية العاملات

لـ C 15 مستوى من أسبقية العاملات، وبعض التركيبات عرضة للأخطاء:

(1) خطأ الأسبقية الأكثر شيوعًا

TEXT 📖 للعرض فقط
int *p = malloc(10 * sizeof(int));
if (p == NULL)

هذا ليس خطأً، لكن هذا كذلك:

C
int a = 1, b = 2, c = 3;
int result = a & b == 0;

== أسبقية أعلى من &، لذا هذا في الواقع a & (b == 0)، أي 1 & 0 = 0، وليس (a & b) == 0.

(2) مرجع سريع للأسبقية (حالات عرضة للخطأ)

تعبير الفخ المعنى الفعلي المعنى المقصود الحل
a & b == 0 a & (b == 0) (a & b) == 0 أضف أقواسًا
a << 2 + 1 a << (2+1) (a<<2)+1 أضف أقواسًا
*p++ *(p++) (*p)++ أضف أقواسًا
a | b + c a | (b+c) (a|b)+c أضف أقواسًا
💡 نصيحة: القاعدة الذهبية: عند الشك، أضف أقواسًا! الأقواس مجانية؛ الأخطاء مكلفة.


2. تجاوز المصفوفة

C لا يفحص حدود المصفوفة — الوصول خارج الحدود سلوك غير معرّف. قد تقرأ قيمًا عشوائية، أو يننهار البرنامج، أو "يعمل بالصدفة" (وهو أخطر نتيجة).

C
int arr[5] = {1, 2, 3, 4, 5};
arr[5] = 100;
arr[-1] = 99;

كلا السطرين يكتبان خارج الحدود. لن يشكو المترجم، لكن هذا قد يُتلف متغيرات أخرى أو يسبب انهيارًا.

(1) التجاوز الكلاسيكي: خطأ الإيقاف في الحلقات

C
int arr[5];
for (int i = 0; i <= 5; i++) {
    arr[i] = 0;
}

i <= 5 يجب أن تكون i < 5. هذا الخطأ بإيقاف واحد يجعل arr[5] تُكتب.

(2) تجاوز السلاسل

TEXT 📖 للعرض فقط
char buf[5];
strcpy(buf, "Hello, World!");

buf لديه 5 بايتات فقط، لكن السلسلة تحتاج 14 بايت (شاملة '\0') — تجاوز مخزن مؤقت كلاسيكي.


3. المؤشرات المعلقة

مؤشر لا يزال يشير إلى ذاكرة أُطلقت بالفعل:

C
int *create_value(void) {
    int x = 42;
    return &x;
}

x متغير محلي. بعد عودة الدالة، يُحرر إطار المكدس، مما يجعل &x مؤشرًا معلقًا. الوصول إليه قد يقرأ قيمًا عشوائية.

TEXT 📖 للعرض فقط
int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p);

بعد free(p)، الذاكرة المُشار إليها بـ p أُعيدت. الوصول إليها عبر p سلوك غير معرّف.

⚠️ ملاحظة: عيّن المؤشر إلى NULL فورًا بعد تحريره: free(p); p = NULL;. بهذه الطريقة، إذا استخدمته بالخطأ لاحقًا، ستحصل على خطأ تقسيم على الأقل بدلاً من خطأ عشوائي يصعب تتبعه.


4. تسرب الذاكرة

ذاكرة مُخصصة لم تُطلق أبدًا:

C
void leak_example(void) {
    int *p = malloc(100 * sizeof(int));
    if (p == NULL) return;
    if (some_error) return;
    free(p);
}

عندما يكون some_error صحيحًا، تعود الدالة مبكرًا، ويُتجاوز free(p)، وتتسرب ذاكرة 100 عدد صحيح.

(1) النسخة الصحيحة

TEXT 📖 للعرض فقط
void no_leak(void) {
    int *p = malloc(100 * sizeof(int));
    if (p == NULL) return;
    if (some_error) {
        free(p);
        return;
    }
    free(p);
}

5. السلوك غير المعرّف

السلوك غير المعرّف (UB) أخطر مفهوم في C — المعيار يقول "السلوك غير معرّف"، مما يعني أن أي شيء يمكن أن يحدث.

(1) سلوكيات غير معرّفة شائعة

السلوك الوصف
تجاوز حدود المصفوفة الفهرس يتجاوز حدود المصفوفة
إلغاء إشارة لمؤشر فارغ *NULL
استخدام مؤشر معلق الوصول إلى ذاكرة مُحررة
فيضان الأعداد الصحيحة الموقّعة INT_MAX + 1
الإزاحة بعدد بتات 1 << 100
تعديل نفس المتغير مرتين في تعبير واحد i = i++ + 1
القسمة على صفر int x = 1 / 0
قراءة متغير غير مهيأ int x; printf("%d", x);

▶ مثال

فخ i = i++ + 1:

C
#include <stdio.h>

int main(void) {
    int i = 3;
    i = i++ + 1;
    printf("%d\n", i);
    return 0;
}
▶ جرّب الكود

مترجمات مختلفة قد تُخرج 4 أو 5 أو قيمًا أخرى. لأن i++ يُعدّل i، والإسناد يُعدّل i أيضًا، تعديل نفس المتغير مرتين في عبارة واحدة سلوك غير معرّف.

💡 نصيحة: المرعب في السلوك غير المعرّف أنه قد يعمل بشكل صحيح في وضع التنقيح لكن يتعطل في بنيات التحسين. قد يفترض المترجم أن UB لا يحدث ويُجري تحويلات غير متوقعة على الكود.


6. منع تجاوز المخزن المؤقت

تجاوز المخزن المؤقت أشهر ثغرة أمنية في C. دودة موريس عام 1988 استغلت هجمات تجاوز المخزن المؤقت.

(1) دوال خطرة مقابل بدائل آمنة

الدالة الخطرة المشكلة البديل الآمن
gets(buf) بلا حد للطول fgets(buf, size, stdin)
strcpy(dst, src) بلا فحص لحجم الوجهة strncpy(dst, src, size-1)
sprintf(buf, fmt, ...) بلا فحص لحجم الوجهة snprintf(buf, size, fmt, ...)
strcat(dst, src) بلا فحص للمساحة المتبقية strncat(dst, src, size-strlen(dst)-1)
scanf("%s", buf) بلا حد لطول الإدخال scanf("%99s", buf) أو scanf_s

(2) الدوال الآمنة بالتفصيل

strncpy

TEXT 📖 للعرض فقط
char *strncpy(char *dest, const char *src, size_t n);

تنسخ على الأكثر n حرفًا. إذا كان src أقل من n حرفًا، تُملأ بقية dest بـ '\0'. لكن إذا كان src بالضبط n حرفًا، لن تُنهى dest تلقائيًا بـ null!

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

int main(void) {
    char buf[6];
    strncpy(buf, "Hello World", sizeof(buf) - 1);
    buf[sizeof(buf) - 1] = '\0';
    printf("%s\n", buf);
    return 0;
}
TEXT 📖 للعرض فقط
Hello
⚠️ ملاحظة: strncpy لا تضيف '\0' تلقائيًا! يجب إضافتها يدويًا بعد النسخ، وإلا قد تتجاوز عمليات السلاسل اللاحقة الحدود.

snprintf

TEXT 📖 للعرض فقط
int snprintf(char *str, size_t size, const char *format, ...);

تكتب على الأكثر size-1 حرفًا وتضيف '\0' تلقائيًا.

C
#include <stdio.h>

int main(void) {
    char buf[10];
    int n = snprintf(buf, sizeof(buf), "Value is %d", 12345);
    printf("Output: \"%s\"\n", buf);
    printf("Required length: %d, Actual space: %d\n", n, (int)sizeof(buf));
    return 0;
}
TEXT 📖 للعرض فقط
Output: "Value is 1"
Required length: 14, Actual space: 10

▶ مثال

نمط آمن لمعالجة الإدخال:

TEXT 📖 للعرض فقط
#include <stdio.h>
#include <string.h>

int main(void) {
    char name[32];

    printf("Enter your name: ");
    if (fgets(name, sizeof(name), stdin) == NULL) {
        fprintf(stderr, "Input failed\n");
        return 1;
    }

    name[strcspn(name, "\n")] = '\0';

    if (strlen(name) == 0) {
        fprintf(stderr, "Name cannot be empty\n");
        return 1;
    }

    printf("Hello, %s!\n", name);
    return 0;
}
💡 نصيحة: fgets + strcspn لإزالة السطر الجديد هو النمط الآمن القياسي لمعالجة إدخال المستخدم. لا تستخدم gets أبدًا.

▶ مثال

دمج سلاسل بأمان:

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

int main(void) {
    char path[256] = "/home/user";
    const char *subdir = "/documents/work/projects/2024";

    size_t current_len = strlen(path);
    size_t remaining = sizeof(path) - current_len - 1;

    if (strlen(subdir) < remaining) {
        strncat(path, subdir, remaining);
    } else {
        fprintf(stderr, "Path too long, cannot concatenate\n");
        return 1;
    }

    printf("Path: %s\n", path);
    return 0;
}
▶ جرّب الكود
TEXT 📖 للعرض فقط
Path too long, cannot concatenate

7. استخدام scanf الآمن

scanf دالة الإدخال الأكثر استخدامًا للمبتدئين، والأخطر أيضًا:

C
char buf[10];
scanf("%s", buf);

إذا أدخل المستخدم أكثر من 9 أحرف، يحدث تجاوز. النسخة الآمنة:

C
char buf[10];
scanf("%9s", buf);

أو استخدم scanf_s على المترجمات التي تدعم C11:

TEXT 📖 للعرض فقط
char buf[10];
scanf_s("%9s", buf, (unsigned)sizeof(buf));
⚠️ ملاحظة: scanf_s جزء من الملحق K الاختياري في C11. MSVC يدعمه، لكن gcc/clang قد لا يدعمانه. للكود عبر المنصات، يُنصح بتركيبة fgets + sscanf.


8. فيضان الأعداد الصحيحة

فيضان الأعداد الصحيحة الموقّعة سلوك غير معرّف:

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

int main(void) {
    int a = INT_MAX;
    int b = a + 1;
    printf("%d + 1 = %d\n", a, b);
    return 0;
}

المخرجات ليست بالضرورة INT_MIN — قد يحذف المترجم هذه الإضافة عبر التحسين.

طريقة الفحص الآمن:

C
#include <limits.h>

int safe_add(int a, int b) {
    if (a > 0 && b > INT_MAX - a) return 0;
    if (a < 0 && b < INT_MIN - a) return 0;
    return a + b;
}

9. أخطاء شائعة أخرى

(1) sizeof مع معاملات المصفوفة

C
void func(int arr[]) {
    printf("%zu\n", sizeof(arr));
}

عند تمرير مصفوفة كمعامل دالة، تتحلل إلى مؤشر. sizeof(arr) يعطي حجم المؤشر (4 أو 8)، لا حجم المصفوفة. يجب تمرير الطول كمعامل إضافي.

(2) أخطاء تعريف الماكرو

TEXT 📖 للعرض فقط
#define SQUARE(x) x * x
SQUARE(2 + 3)

هذا يتمدد إلى 2 + 3 * 2 + 3 = 11، وليس 25. النسخة الصحيحة:

C
#define SQUARE(x) ((x) * (x))

لكن SQUARE(i++) ما زال بها مشكلة (i يُزاد مرتين). الدالة المضمنة أكثر أمانًا.

(3) == و =

C
if (x = 5) {
}

هذا إسناد، لا مقارنة! قيمة تعبير الإسناد هي 5 (غير صفرية)، لذا الشرط صحيح دائمًا. بعض المترجمات تحذّر من هذا، لكن ليس جميعها يفعّل هذا التحذير.


❓ أسئلة شائعة

س لماذا لا تفحص C حدود المصفوفة تلقائيًا؟
ج الأداء. فحص الفهرس عند كل وصول يُبطئ البرنامج. فلسفة تصميم C "الثقة بالمبرمج"، مقابل أن يضمن المبرمجون أمانهم بأنفسهم.
س هل free(NULL) تسبب انهيارًا؟
ج لا. معيار C يضمن أن free(NULL) آمنة ولا تفعل شيئًا. فلا حاجة لفحص ما إذا كان المؤشر NULL قبل استدعاء free.
س كيف أكشف عن تسرب الذاكرة؟
ج في لينكس، استخدم أداة Valgrind: valgrind --leak-check=full ./program. في ويندوز، يمكنك استخدام كومة التنقيح في Visual Studio أو AddressSanitizer.
س ما AddressSanitizer؟
ج أداة كشف أخطاء الذاكرة مدمجة في المترجم. جمّع بـ -fsanitize=address لتفعيلها. يمكنها كشف الوصول خارج الحدود، والاستخدام بعد التحرير، وتسرب الذاكرة، وغيرها.

📖 ملخص

📝 تمارين

  1. اكتب دالة نسخ سلاسل آمنة safe_strcpy(char *dst, size_t dst_size, const char *src) تضمن عدم التجاوز وإنهاء الوجهة بـ null دائمًا
  2. اكتب برنامجًا يسبب عمدًا تجاوز حدود مصفوفة وفيضان أعداد صحيحة، وجمعه وتشغيله مع AddressSanitizer، ولاحظ المخرجات
  3. راجع الكود التالي، اعثر على جميع مشكلات الأمان وأصلحها: char buf[8]; gets(buf); sprintf(buf, "Result: %d", value);
Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%