C: أخطاء شائعة والبرمجة الآمنة
أخطاء C الشائعة كالحفر في الطريق — تبدو غير مؤذية، لكن تخطو في إحداها فينهار برنامجك. تعلّم التعرّف على هذه الحفر لتتجاوزها.
1. أخطاء أسبقية العاملات
لـ C 15 مستوى من أسبقية العاملات، وبعض التركيبات عرضة للأخطاء:
(1) خطأ الأسبقية الأكثر شيوعًا
int *p = malloc(10 * sizeof(int));
if (p == NULL)
هذا ليس خطأً، لكن هذا كذلك:
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 لا يفحص حدود المصفوفة — الوصول خارج الحدود سلوك غير معرّف. قد تقرأ قيمًا عشوائية، أو يننهار البرنامج، أو "يعمل بالصدفة" (وهو أخطر نتيجة).
int arr[5] = {1, 2, 3, 4, 5};
arr[5] = 100;
arr[-1] = 99;
كلا السطرين يكتبان خارج الحدود. لن يشكو المترجم، لكن هذا قد يُتلف متغيرات أخرى أو يسبب انهيارًا.
(1) التجاوز الكلاسيكي: خطأ الإيقاف في الحلقات
int arr[5];
for (int i = 0; i <= 5; i++) {
arr[i] = 0;
}
i <= 5 يجب أن تكون i < 5. هذا الخطأ بإيقاف واحد يجعل arr[5] تُكتب.
(2) تجاوز السلاسل
char buf[5];
strcpy(buf, "Hello, World!");
buf لديه 5 بايتات فقط، لكن السلسلة تحتاج 14 بايت (شاملة '\0') — تجاوز مخزن مؤقت كلاسيكي.
3. المؤشرات المعلقة
مؤشر لا يزال يشير إلى ذاكرة أُطلقت بالفعل:
int *create_value(void) {
int x = 42;
return &x;
}
x متغير محلي. بعد عودة الدالة، يُحرر إطار المكدس، مما يجعل &x مؤشرًا معلقًا. الوصول إليه قد يقرأ قيمًا عشوائية.
int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p);
بعد free(p)، الذاكرة المُشار إليها بـ p أُعيدت. الوصول إليها عبر p سلوك غير معرّف.
free(p); p = NULL;. بهذه الطريقة، إذا استخدمته بالخطأ لاحقًا، ستحصل على خطأ تقسيم على الأقل بدلاً من خطأ عشوائي يصعب تتبعه.
4. تسرب الذاكرة
ذاكرة مُخصصة لم تُطلق أبدًا:
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) النسخة الصحيحة
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:
#include <stdio.h>
int main(void) {
int i = 3;
i = i++ + 1;
printf("%d\n", i);
return 0;
}
مترجمات مختلفة قد تُخرج 4 أو 5 أو قيمًا أخرى. لأن i++ يُعدّل i، والإسناد يُعدّل i أيضًا، تعديل نفس المتغير مرتين في عبارة واحدة سلوك غير معرّف.
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
char *strncpy(char *dest, const char *src, size_t n);
تنسخ على الأكثر n حرفًا. إذا كان src أقل من n حرفًا، تُملأ بقية dest بـ '\0'. لكن إذا كان src بالضبط n حرفًا، لن تُنهى dest تلقائيًا بـ null!
#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;
}
Hello
strncpy لا تضيف '\0' تلقائيًا! يجب إضافتها يدويًا بعد النسخ، وإلا قد تتجاوز عمليات السلاسل اللاحقة الحدود.
snprintf
int snprintf(char *str, size_t size, const char *format, ...);
تكتب على الأكثر size-1 حرفًا وتضيف '\0' تلقائيًا.
#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;
}
Output: "Value is 1"
Required length: 14, Actual space: 10
▶ مثال
نمط آمن لمعالجة الإدخال:
#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 أبدًا.
▶ مثال
دمج سلاسل بأمان:
#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;
}
Path too long, cannot concatenate
7. استخدام scanf الآمن
scanf دالة الإدخال الأكثر استخدامًا للمبتدئين، والأخطر أيضًا:
char buf[10];
scanf("%s", buf);
إذا أدخل المستخدم أكثر من 9 أحرف، يحدث تجاوز. النسخة الآمنة:
char buf[10];
scanf("%9s", buf);
أو استخدم scanf_s على المترجمات التي تدعم C11:
char buf[10];
scanf_s("%9s", buf, (unsigned)sizeof(buf));
scanf_s جزء من الملحق K الاختياري في C11. MSVC يدعمه، لكن gcc/clang قد لا يدعمانه. للكود عبر المنصات، يُنصح بتركيبة fgets + sscanf.
8. فيضان الأعداد الصحيحة
فيضان الأعداد الصحيحة الموقّعة سلوك غير معرّف:
#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 — قد يحذف المترجم هذه الإضافة عبر التحسين.
طريقة الفحص الآمن:
#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 مع معاملات المصفوفة
void func(int arr[]) {
printf("%zu\n", sizeof(arr));
}
عند تمرير مصفوفة كمعامل دالة، تتحلل إلى مؤشر. sizeof(arr) يعطي حجم المؤشر (4 أو 8)، لا حجم المصفوفة. يجب تمرير الطول كمعامل إضافي.
(2) أخطاء تعريف الماكرو
#define SQUARE(x) x * x
SQUARE(2 + 3)
هذا يتمدد إلى 2 + 3 * 2 + 3 = 11، وليس 25. النسخة الصحيحة:
#define SQUARE(x) ((x) * (x))
لكن SQUARE(i++) ما زال بها مشكلة (i يُزاد مرتين). الدالة المضمنة أكثر أمانًا.
(3) == و =
if (x = 5) {
}
هذا إسناد، لا مقارنة! قيمة تعبير الإسناد هي 5 (غير صفرية)، لذا الشرط صحيح دائمًا. بعض المترجمات تحذّر من هذا، لكن ليس جميعها يفعّل هذا التحذير.
❓ أسئلة شائعة
valgrind --leak-check=full ./program. في ويندوز، يمكنك استخدام كومة التنقيح في Visual Studio أو AddressSanitizer.-fsanitize=address لتفعيلها. يمكنها كشف الوصول خارج الحدود، والاستخدام بعد التحرير، وتسرب الذاكرة، وغيرها.📖 ملخص
- عند الشك في أسبقية العاملات، أضف أقواسًا — أبسط دفاع
- تجاوز المصفوفة وتجاوز المخزن المؤقت أخطر مشكلات أمان في C
- استخدم
fgetsبدلاً منgets، وsnprintfبدلاً منsprintf، وstrncpyبدلاً منstrcpy - السلوك غير المعرّف قد "يعمل بالصدفة" لكنه يتصرف بشكل مختلف بين المترجمات أو مستويات التحسين
- عيّن المؤشرات إلى NULL بعد free، وتحقق من قيم malloc المُرجعة، واحذر من فيضان الأعداد الموقّعة
📝 تمارين
- اكتب دالة نسخ سلاسل آمنة
safe_strcpy(char *dst, size_t dst_size, const char *src)تضمن عدم التجاوز وإنهاء الوجهة بـ null دائمًا - اكتب برنامجًا يسبب عمدًا تجاوز حدود مصفوفة وفيضان أعداد صحيحة، وجمعه وتشغيله مع AddressSanitizer، ولاحظ المخرجات
- راجع الكود التالي، اعثر على جميع مشكلات الأمان وأصلحها:
char buf[8]; gets(buf); sprintf(buf, "Result: %d", value);