> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# المؤشرات Pointers والذاكرة والأخطاء في لغة C
- URL: https://arabroot.io/articles/المؤشرات-pointers-والذاكرة-والاخطاء-في-لغة-c/
- Published: 2026-10-31T07:19:57.000Z
- Updated: 2026-10-31T08:20:09.000Z
- Description: نقرأ ملف طلبات نقطة البيع بلغة C ونقف عند كل ما تسمح به اللغة دون أن تخبرك: تحويلات الأنواع، والحشو، وتجاوز المخزن، ومن يحرر الذاكرة، وكيف تصل الأخطاء بقيمة الرجوع وerrno.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

في [المقال الأول من مرحلة لغة C](https://arabroot.io/start/#stage-lang-c) بنينا مكتبة صغيرة تحسب ضريبة فاتورة نقطة البيع في شركة ناشئة تبيع القهوة والتمور، وانتهينا بمشكلة مزعجة: الأداة قبلت الكمية `three` على أنها صفر، والسعر `9.90` على أنه 9 هللات، دون أي رسالة. والآن جاء الطلب التالي من مدير المستودع: نقطة البيع تصدر طلبات اليوم في ملف نصي، وفي كل سطر رقم المنتج واسمه بالعربي والسعر والكمية، والمطلوب أن تقرأ المكتبة هذا الملف إلى الذاكرة وتحسب منه تقرير اليوم. ويبدو هذا عملاً بسيطاً لمن يكتب بلغة Python، فهو `open` ثم حلقة ثم `list.append`. ولكنك في C سوف تقف عند كل سطر أمام أسئلة لم تفكر فيها من قبل: كم بايت يأخذ هذا الرقم؟ أين يعيش هذا النص، ومن يحرره؟ وما الذي يحدث إذا كان اسم المنتج أطول من المكان الذي حجزته له؟ وكيف تعرف أن الملف لم يفتح أصلاً، ولا يوجد استثناء يخبرك؟

وهذه الأسئلة الثلاثة هي موضوع هذا المقال: نظام الأنواع Type System في C وما يسمح به دون أن يخبرك، والذاكرة ومن يديرها، وطريقة C الخاصة في معالجة الأخطاء. ولن نكرر شرح ال Stack وال Heap الذي في مقال [الذاكرة للمبرمجين](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86-stack-%D9%88-heap-%D9%88%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA/)، ولا التسرب والاستخدام بعد التحرير الذي قسناه في مقال [إدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)، وإنما نركز على ما يحتاجه مطور يكتب C لأول مرة، وكل مثال هنا شغلناه بالمترجم GCC 16.2 ورأينا مخرجه.

وسوف نناقش في هذا المقال ما يلي:

- نظام الأنواع في C: ثابت ولكنه ضعيف، وأحجام الأنواع على نظامك، وخمسة تحويلات ضمنية Implicit Conversions تمر بلا خطأ، والسلوك غير المعرف Undefined Behavior.
- الهياكل Structs والحشو Padding، ولماذا يغير ترتيب الحقول حجم البيانات بالثلث.
- المؤشرات Pointers والمصفوفات Arrays والنصوص Strings، وتجاوز المخزن Buffer Overflow الذي غير قيمة حقل بصمت، والأدوات التي كشفته والتي لم تكشفه.
- من يدير الذاكرة في C: حدود ال Stack، والمصفوفة التي تكبر بـ `realloc`، والمخصص بالكتلة Arena Allocator مقاساً، وقواعد ملكية المؤشرات Ownership Conventions.
- الأخطاء Errors: قيمة الرجوع والمتغير `errno`، وقراءة الأرقام بشكل صحيح، وما يحدث عندما تتجاهل قيمة الرجوع، ونمط التنظيف `goto cleanup`.

## نظام الأنواع Type System في C: ثابت ولكنه ضعيف

في مقال [أنماط البرمجة وأنظمة الأنواع](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) فرقنا بين محورين: هل يعرف المترجم نوع كل متغير قبل التشغيل، أي ثابت Static أم ديناميكي Dynamic؟ وهل تمنعك اللغة من خلط الأنواع، أي قوي Strong أم ضعيف Weak؟ وC ثابتة بلا شك، فكل متغير له نوع يعرفه المترجم، ولكنها ضعيفة جداً، فهي تحول بين الأنواع تلقائياً في أغلب العمليات، وتسمح لك بتحويل أي مؤشر إلى أي مؤشر آخر، ولا تحمل أي معلومات عن النوع وقت التشغيل. فلا يوجد شيء اسمه `instanceof` أو `type()`، والبايتات في الذاكرة لا تعرف هل هي عدد أو نص أو عنوان، وإنما المترجم وحده يعرف ذلك أثناء الترجمة ثم ينسى.

والسبب في هذا التصميم أن C صممت لكتابة نظام تشغيل على أجهزة السبعينيات، حيث كل بايت وكل تعليمة لها ثمن، فاللغة تعطيك ما يفعله المعالج تقريباً، وتترك لك مسؤولية المعنى. ولهذا أول ما يجب أن تعرفه هو أحجام الأنواع نفسها، لأن المعيار لا يحددها بدقة، وإنما يعطي حداً أدنى فقط، فـ `int` يجب أن يكون 16 بت على الأقل، و`long` 32 بت على الأقل. والبرنامج `lab/sizes.c` يطبع ما هي عليه فعلاً على نظامنا:

```bash
docker run --rm -v "$PWD":/lab slr-c-dev:1 sh /lab/run.sh
```

```text
char 1, short 2, int 4, long 8, long long 8
float 4, double 8, pointer 8, size_t 8
INT_MAX 2147483647, LONG_MAX 9223372036854775807
char is signed
int32_t 4, int64_t 8 (exact widths from stdint.h)
```

في المخرج أعلاه لاحظ التالي:

- `long` هنا 8 بايت، وهذا نموذج LP64 الذي تتبعه Linux وmacOS على 64 بت. أما Windows فيتبع نموذجاً آخر يكون فيه `long` أربعة بايت فقط، كما في [جدول أنواع البيانات في توثيق Microsoft](https://learn.microsoft.com/en-us/cpp/cpp/data-type-ranges?ref=arabroot.io). فالكود الذي يخزن عدد الهللات في `long` ويعمل على Linux قد يتجاوز حده على Windows عند 21 مليون ريال تقريباً.
- `size_t` هو النوع الذي ترجعه `sizeof` و`strlen`، وهو عدد بلا إشارة Unsigned بحجم المؤشر، وسوف ترى بعد قليل لماذا يسبب مشاكل مع الأعداد السالبة.
- `char` بإشارة Signed على معالجات x86، وبلا إشارة على أغلب معالجات ARM. فالبايت 0xD8 الذي يبدأ به كثير من الحروف العربية في UTF-8 قيمته 216 على جهاز و40- على جهاز آخر، وهذا مصدر أخطاء حقيقية في الكود الذي يعالج النصوص العربية، وسوف نقيسه في مقال الأخطاء الشائعة.
- الأنواع `int32_t` و`int64_t` من `stdint.h` بحجم مضمون على كل نظام، ولذلك هي الأنواع الصحيحة للمال والمعرفات وكل ما يكتب في ملف أو يرسل عبر الشبكة.

### التحويلات الضمنية Implicit Conversions: خمس نتائج خاطئة بلا خطأ

ضعف نظام الأنواع يظهر أوضح ما يكون في التحويلات التي يفعلها المترجم دون أن تطلبها. والبرنامج `lab/convert.c` فيه خمس حالات، كل منها سطر ستكتبه يوماً في تقرير المبيعات:

```c
    // 1) signed vs unsigned comparison: -1 becomes a huge unsigned number
    int balance = -1;
    if (balance < sizeof(int)) puts("1) -1 < 4");
    else                       puts("1) -1 is NOT < sizeof(int)");

    // 2) integer division happens before the conversion to double
    double avg = 7 / 2;

    // 3) money in float
    float a = 0.1f, b = 0.2f;

    // 4) int overflow: price * qty in halalas, both int (argc keeps the compiler from folding it)
    int price = 2000000000 + argc - 1;   // 20,000,000 SAR in halalas
    int qty = 2;
    int total = price * qty;             // undefined behaviour: signed overflow

    // 5) truncation without a word
    int64_t big = 5000000000LL;
    int32_t small = (int32_t)big;
```

```text
1) -1 is NOT < sizeof(int)
2) 7 / 2 = 3.0
3) 0.1f + 0.2f = 0.3000000119
4) 2000000000 * 2 = -294967296
   with int64_t: 4000000000
5) 5000000000 stored in int32_t = 705032704
```

ولنقرأ كل نتيجة:

- الحالة الأولى أغرب ما في C لمن يأتي من لغة أخرى: 1- ليس أصغر من 4\. والسبب قاعدة اسمها التحويلات الحسابية المعتادة Usual Arithmetic Conversions، فعندما تقارن عدداً بإشارة `int` بعدد بلا إشارة من نفس الحجم أو أكبر مثل `size_t`، يحول الأول إلى نوع الثاني، و1- بلا إشارة هو أكبر عدد ممكن، 18446744073709551615\. فشرط مثل «إذا كان الرصيد أقل من طول القائمة» يعطي نتيجة معكوسة عندما يكون الرصيد سالباً.
- الحالة الثانية: `7 / 2` قسمة أعداد صحيحة ناتجها 3، ثم يحول 3 إلى `double`. فالنوع الذي تحفظ فيه النتيجة لا يغير نوع العملية، وإذا أردت 3.5 فيجب أن يكون أحد الطرفين عشرياً: `7.0 / 2`.
- الحالة الثالثة ليست خاصة بـ C، فأغلب اللغات تمثل الأعداد العشرية بنفس معيار IEEE 754، ولكنها السبب في أننا نحسب المال بالهللات في عدد صحيح في كل مقالات هذه السلسلة. والرقم 0.3000000119 بدلاً من 0.3 يتراكم في تقرير فيه آلاف الأسطر.
- الحالة الرابعة أخطر الخمس: سعر 20 مليون ريال بالهللات مضروب في 2 أعطى عدداً سالباً. وفي Java أو Go يلتف العدد بنفس الطريقة، وهذا سلوك معرف هناك. أما في C فتجاوز الأعداد بإشارة Signed Overflow سلوك غير معرف Undefined Behavior، أي أن المعيار لا يعد بأي نتيجة، والرقم السالب الذي رأيته هو ما صادف أن يفعله المعالج هذه المرة. والإصلاح في السطر التالي: التحويل إلى `int64_t` قبل الضرب أعطى 4000000000.
- الحالة الخامسة: خمسة مليارات لا تتسع في 32 بت، فبقيت البتات السفلى فقط، 705032704، ولم يقل أحد شيئاً لأننا كتبنا التحويل `(int32_t)` بأنفسنا، والمترجم يفترض أنك تعرف ما تفعل.

ثم سألنا الأدوات: كم من هذه الخمسة تكشف؟ بالخيارات `-Wall -Wextra -Wconversion` ظهر تحذير واحد فقط:

```text
convert.c:9:17: warning: comparison of integer expressions of different signedness: 'int' and 'long unsigned int' [-Wsign-compare]
```

وهو تحذير الحالة الأولى. أما التجاوز فلا يمكن للمترجم أن يعرفه، لأن السعر يأتي وقت التشغيل، والتحويل الصريح طلبناه نحن. ولهذا توجد أداة وقت التشغيل UndefinedBehaviorSanitizer، وهي خيار في المترجم `-fsanitize=undefined` يضيف فحصاً حول كل عملية قد تكون غير معرفة، كما في [توثيق خيارات الفحص في GCC](https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html?ref=arabroot.io):

```text
convert.c:23:9: runtime error: signed integer overflow: 2000000000 * 2 cannot be represented in type 'int'
4) 2000000000 * 2 = -294967296
```

والأداة لم توقف البرنامج، وإنما طبعت السطر والعملية والقيم، ثم أكملت. وهذا هو الأسلوب الصحيح لاستخدامها: تبني بها الاختبارات وتجعل خط الاختبارات الآلية يفشل إذا ظهرت أي رسالة `runtime error`.

### ما معنى السلوك غير المعرف Undefined Behavior؟

هذا المصطلح سوف تقابله كثيراً في C، ويستحق أن تفهمه جيداً. فالمعيار يقسم ما قد يحدث في البرنامج إلى ثلاثة أنواع: سلوك معرف يحدد المعيار نتيجته، وسلوك يحدده كل مترجم ويوثقه Implementation-Defined مثل حجم `long` وإشارة `char`، وسلوك غير معرف لا يعد فيه المعيار بأي شيء على الإطلاق، ومن أمثلته تجاوز الأعداد بإشارة، والقراءة خارج حدود المصفوفة، واستخدام مؤشر بعد تحريره، وقراءة متغير لم يعط قيمة. وتجد القائمة كاملة في ملحق المعيار، ونص مسودته الأخيرة متاح مجاناً من [موقع لجنة WG14](https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf?ref=arabroot.io).

وقد يتساءل البعض: لماذا لا يحدد المعيار نتيجة واحدة وينتهي الأمر؟ والإجابة أن هذا الغموض هو ما يعطي المترجم حرية التحسين، فإذا كان التجاوز «لا يحدث أبداً» في برنامج صحيح، فيمكن للمترجم أن يفترض أن `i + 1 > i` صحيح دائماً، ويحذف الفحص كله. وهذا يعني أن الخطأ لا يعطيك قيمة غريبة فقط، وإنما قد يجعل المترجم يحذف كوداً كتبته لحماية برنامجك، وسوف نرى ذلك حرفياً في مقال الأخطاء الشائعة، حيث اختفى فحص التجاوز من البرنامج بعد التحسين. والقاعدة العملية: لا تحاول أن تفهم «ماذا يحدث فعلاً» عند السلوك غير المعرف، لأن الإجابة تتغير مع المترجم ومستوى التحسين، وإنما تجنبه، واكشفه بأدوات Sanitizers.

![خمس نتائج من convert.c: مقارنة 1- بـ sizeof أعطت أنه ليس أصغر لأن 1- صار عدداً بلا إشارة، و7/2 أعطت 3.0، و0.1f+0.2f أعطت 0.3000000119، وسعر ملياري هللة ضرب في 2 أعطى -294967296 بينما int64_t أعطى 4000000000، وخمسة مليارات في int32_t صارت 705032704، والأدوات: -Wall -Wextra -Wconversion كشفت الأولى فقط، و-fsanitize=undefined كشفت التجاوز في السطر 23](https://arabroot.io/content/images/2026/10/c-types-memory-01-conversions.webp)

خمسة أسطر عادية، ونتيجة خاطئة في كل منها، وتحذير واحد فقط من المترجم

## الهياكل Structs والحشو Padding: لماذا يغير الترتيب الحجم؟

تقرير اليوم يحتاج هيكلاً لكل سطر من سطور الطلبات، فيه السعر والكمية وعلامة ورمز. وفي Python لن تفكر في ترتيب الحقول، أما في C فالترتيب يحدد الحجم. والبرنامج `lab/padding.c` يعرف نفس الحقول الأربعة بترتيبين، ويطبع الحجم وموقع كل حقل بالماكرو `offsetof`:

```c
struct LineA { char flag; int64_t price; char code; int32_t qty; };
struct LineB { int64_t price; int32_t qty; char flag; char code; };
```

```text
LineA: 24 bytes (flag@0 price@8 code@16 qty@20)
LineB: 16 bytes (price@0 qty@8 flag@12 code@13)
10M lines: 240 MB vs 160 MB
```

البيانات في الحالتين 14 بايت فقط: 8 للسعر و4 للكمية وبايت لكل من العلامة والرمز. ولكن الهيكل الأول أخذ 24 والثاني 16\. والسبب المحاذاة Alignment: المعالج يقرأ العدد من 8 بايت أسرع، وعلى بعض المعالجات لا يقرؤه أصلاً، إذا كان عنوانه من مضاعفات 8\. ولذلك يضيف المترجم بايتات فارغة اسمها الحشو Padding. ففي `LineA` جاءت العلامة في البايت 0، والسعر لا يستطيع أن يبدأ في البايت 1، فبدأ في البايت 8 وترك سبعة بايتات فارغة، ثم الرمز في 16، ثم الكمية في 20 لأنها تحتاج مضاعفاً لأربعة، فترك ثلاثة بايتات أخرى. أما `LineB` فرتب الحقول من الأكبر إلى الأصغر، فلم يحتج إلا بايتين في النهاية لكي يكون حجم الهيكل كله من مضاعفات 8، وهذا ضروري لكي يبقى كل عنصر في مصفوفة من هذا الهيكل محاذياً.

والفرق ليس نظرياً: عشرة ملايين سطر طلب تأخذ 240 MB بالترتيب الأول و160 MB بالثاني، أي ثلث الذاكرة ضاع في بايتات فارغة، ومعه ثلث ما يقرؤه المعالج من الذاكرة في كل مرور على البيانات. ولهذا فالقاعدة البسيطة في الهياكل الكبيرة أو الكثيرة أن ترتب الحقول من الأكبر إلى الأصغر. ولاحظ أمرين آخرين: الحشو يجعل مقارنة هيكلين بالدالة `memcmp` خاطئة، لأن البايتات الفارغة قد تحمل أي شيء، وكتابة الهيكل كما هو في ملف أو إرساله عبر الشبكة يجعل الحشو والترتيب جزءاً من صيغة الملف، فينكسر التوافق إذا بني البرنامج على معالج آخر أو بمترجم آخر.

![رسم البايتات للهيكلين: LineA بترتيب flag ثم price ثم code ثم qty يأخذ 24 بايت منها 10 بايتات حشو، وLineB بترتيب price ثم qty ثم flag ثم code يأخذ 16 بايت منها بايتان حشو، وعشرة ملايين سطر 240 MB مقابل 160 MB](https://arabroot.io/content/images/2026/10/c-types-memory-02-padding.webp)

نفس الحقول، وثلث الذاكرة فرق بين ترتيبين

## المؤشرات Pointers والمصفوفات Arrays: الطول الذي يضيع

المؤشر عنوان في الذاكرة ومعه نوع ما يشير إليه، والنوع مهم لأنه يحدد كم بايت تقرأ عندما تتبع المؤشر، وكم بايت تتحرك عندما تضيف إليه 1\. والمصفوفة في C ليست كائناً، وإنما مجموعة عناصر متجاورة في الذاكرة، واسمها يتحول إلى مؤشر لأول عنصر في أغلب الاستخدامات، وهذا التحول اسمه التحلل Array Decay. والبرنامج `lab/decay.c` يمرر مصفوفة أسعار من عشرة عناصر إلى دالة تبدو كأنها تستقبل مصفوفة من عشرة:

```c
static int sum(int prices[10]) {              // looks like an array, is an int *
    int s = 0;
    size_t n = sizeof prices / sizeof prices[0];   // 8 / 4 = 2, not 10
    for (size_t i = 0; i < n; i++) s += prices[i];
    printf("inside sum(): sizeof prices = %zu, n = %zu\n", sizeof prices, n);
    return s;
}

static int sum_n(const int *prices, size_t n) {   // the C way: pass the length
    int s = 0;
    for (size_t i = 0; i < n; i++) s += prices[i];
    return s;
}
```

```text
decay.c:6:23: warning: 'sizeof' on array function parameter 'prices' will return size of 'int *' [-Wsizeof-array-argument]
in main(): sizeof prices = 40
inside sum(): sizeof prices = 8, n = 2
sum() = 200, sum_n() = 1000
p[3] = 100, *(p + 3) = 100, 3[p] = 100
```

في المخرج أعلاه لاحظ التالي:

- في `main` أعطت `sizeof` أربعين بايتاً، أي عشرة أعداد في أربعة. وداخل الدالة أعطت 8، وهو حجم مؤشر، فحسبت الدالة أن في المصفوفة عنصرين وجمعتهما، 200 بدلاً من 1000\. والرقم 10 في `int prices[10]` مجرد تعليق لا يفحصه أحد.
- هذه المرة حذر GCC حتى بدون خيارات، لأن الخطأ مشهور جداً. ولكن التحذير لا يظهر إذا كتبت المعامل `int *prices`، وهو الشكل الأكثر شيوعاً، فالمترجم لا يعرف أنك تظن أنه مصفوفة.
- الطريقة الصحيحة في C هي `sum_n`: المؤشر وطوله يسافران معاً دائماً، وستجد هذا الزوج في كل مكتبة C تقريباً، مثل `memcpy(dst, src, n)` و`fwrite(buf, 1, n, f)`. و`const` في المعامل وعد بأن الدالة لا تغير الأسعار، والمترجم يرفض أي كتابة عبر هذا المؤشر.
- السطر الأخير يبين ما هو الفهرس في C: `p[3]` هو حرفياً `*(p + 3)`، أي العنوان مضافاً إليه ثلاثة عناصر، لا ثلاثة بايتات، لأن المترجم يضرب في حجم النوع. ولأن الجمع تبديلي فإن `3[p]` تعمل أيضاً وتعطي نفس النتيجة، وهي غريبة ولا يكتبها أحد، ولكنها تريك أن الفهرس ليس عملية آمنة على كائن، وإنما حساب عناوين لا يفحص أي حدود.

### النصوص Strings وتجاوز المخزن Buffer Overflow: اسم أطول من مكانه

نصل الآن إلى أشهر خطأ في تاريخ C. سطر الطلب فيه اسم العميل، والمبرمج حجز له 8 بايتات في الهيكل لأن الأسماء في عينة الاختبار كانت قصيرة، وبجانبه حقل يقول هل دفع العميل أم لا:

```c
struct Order { char name[8]; int paid; };

int main(int argc, char **argv) {
    struct Order o = { .paid = 0 };
    const char *name = argc > 1 ? argv[1] : "Ali";
    strcpy(o.name, name);                  // no length check
    printf("name=%s paid=%d\n", o.name, o.paid);
    return 0;
}
```

شغلناه بثلاثة أسماء، ثم ببناءين آخرين فيهما حماية:

```text
name=Ali paid=0
name=Abdulrahman paid=7233901
exit 0
name=Abdulrahman-AlQahtani-Store paid=762208621
exit 0
*** buffer overflow detected ***: terminated
Aborted (core dumped)
exit 134
```

الاسم `Ali` مع نهايته `'\0'` أربعة بايتات، فلا مشكلة. أما `Abdulrahman` فأحد عشر حرفاً ومعها النهاية اثنا عشر بايتاً، والدالة `strcpy` تنسخ حتى تجد النهاية دون أن تعرف حجم المكان، فكتبت `Abdulrah` في الاسم، ثم كتبت `man` والبايت صفر فوق الحقل `paid`. والرقم 7233901 ليس عشوائياً: هو 0x006E616D بالنظام الست عشري، أي البايتات `m` و`a` و`n` وصفر مقروءة كعدد على معالج يخزن البايت الأصغر أولاً Little Endian. فالعميل صار «دافعاً» لأن اسمه طويل، والبرنامج خرج برمز 0\. ومع الاسم الأطول كتبت `man-` فوق الحقل، وما بعده كتب فوق أجزاء من ال Stack، والبرنامج لم ينهر هذه المرة أيضاً. وهذا بالضبط ما يجعل تجاوز المخزن خطيراً أمنياً كما شرحنا في مقال [أمان الذاكرة Memory Safety](https://arabroot.io/articles/%D8%A7%D9%85%D8%A7%D9%86-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-memory-safety-%D9%84%D9%85%D8%A7%D8%B0%D8%A7-%D8%AA%D8%AF%D9%81%D8%B9-%D8%A7%D9%84%D8%AD%D9%83%D9%88%D9%85%D8%A7%D8%AA-%D9%86%D8%AD%D9%88-rust/): المهاجم الذي يتحكم في الاسم يتحكم في البايتات التي بعده.

والسطر الأخير من المخرج جاء من بناء بالخيار `-O2 -D_FORTIFY_SOURCE=3`، وهي حماية في glibc يشرحها [دليل glibc](https://sourceware.org/glibc/manual/latest/html%5Fnode/Source-Fortification.html?ref=arabroot.io): عندما يعرف المترجم حجم المكان الذي تكتب فيه، كما في `o.name` الذي حجمه 8، يستبدل `strcpy` بنسخة تفحص الطول وقت التشغيل، فأوقفت البرنامج برسالة `buffer overflow detected` ورمز 134\. وهذه الحماية رخيصة جداً ومفعلة في حزم أغلب التوزيعات، ولكنها تعمل فقط عندما يعرف المترجم الحجم، وتحتاج تحسيناً `-O1` على الأقل.

ثم جربنا AddressSanitizer، الأداة التي كشفت الاستخدام بعد التحرير في مقال إدارة الذاكرة، وكانت النتيجة مفاجئة: مع الاسم `Abdulrahman` لم تقل شيئاً، وطبع البرنامج `paid=7233901` كما في البناء العادي. والسبب أن ASan تضع مناطق محرمة حول كل متغير وكل قطعة محجوزة، ولكنها لا تضع شيئاً بين حقول نفس الهيكل، والكتابة هنا بقيت داخل الهيكل `o` الذي حجمه 12 بايتاً. أما مع الاسم الأطول فخرجت الكتابة من الهيكل كله، فكشفتها:

```text
==16==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7b28fa5d002c at pc 0x7f28fcae56e2 bp 0x7ffec562c660 sp 0x7ffec562be20
WRITE of size 28 at 0x7b28fa5d002c thread T0
    #1 0x0000004012f4 in main /lab/overflow.c:10
Address 0x7b28fa5d002c is located in stack of thread T0 at offset 44 in frame
SUMMARY: AddressSanitizer: stack-buffer-overflow /lab/overflow.c:10 in main
=>0x7b28fa5d0000: f1 f1 f1 f1 00[04]f3 f3 00 00 00 00 00 00 00 00
```

والسطر الأخير هو خريطة الظل Shadow Memory التي تعتمد عليها الأداة: كل بايت فيها يصف ثمانية بايتات من الذاكرة الحقيقية، و`00` تعني ثمانية بايتات مسموحة، و`04` تعني أن أربعة منها فقط مسموحة، وهي بقية الهيكل ذي 12 بايتاً، و`f1` و`f3` مناطق محرمة قبل المتغير وبعده. والقوس المربع يشير إلى المكان الذي بدأ فيه التجاوز. والدرس المهم من هذه التجربة أن الأدوات ليست سحرية، فكل أداة لها نموذج لما تراقبه، والتجاوز الذي يبقى داخل هيكل يفلت من ASan ويمسكه `_FORTIFY_SOURCE`، والعكس يحدث في حالات أخرى. أما الإصلاح الحقيقي فهو ألا تستخدم `strcpy` مع نص لا تتحكم بطوله، وأن تكتب `snprintf(o.name, sizeof o.name, "%s", name)` التي تقطع النص عند الحد وتضع النهاية دائماً، أو أن تجعل الاسم مؤشراً إلى نص محجوز بطوله الحقيقي.

![رسم الهيكل Order من 12 بايتاً: name من 8 بايتات ثم paid من 4، والاسم Abdulrahman يكتب Abdulrah في name ثم man وصفر في paid فتصبح 7233901 أي 0x006E616D، والنتائج: البناء العادي خرج برمز 0، وFORTIFY_SOURCE=3 أوقفه برمز 134، وASan لم يكتشف الاسم القصير لأنه بقي داخل الهيكل واكتشف الاسم من 27 حرفاً بخطأ stack-buffer-overflow في السطر 10](https://arabroot.io/content/images/2026/10/c-types-memory-03-overflow.webp)

خمسة بايتات زائدة غيرت حالة الدفع، وأداة واحدة من اثنتين كشفتها

### المؤشر المعلق Dangling Pointer: عنوان لشيء انتهى

الخطأ الثاني الذي يقع فيه القادمون من لغات الجامع: دالة تنشئ نصاً في مخزن محلي وترجع عنوانه، كأنها ترجع كائناً:

```c
static char *format_id(int id) {
    char buf[16];
    snprintf(buf, sizeof buf, "ORD-%d", id);
    return buf;                           // buf dies when the function returns
}
```

```text
dangling.c:7:12: warning: function returns address of local variable [-Wreturn-local-addr]
id: (null)
exit 0
```

المخزن `buf` يعيش في ال Stack ضمن إطار الدالة، وينتهي مع نهايتها، فالعنوان الراجع يشير إلى مكان ستستخدمه أول دالة تستدعى بعدها. وحذر GCC بدون أي خيار، ثم فعل شيئاً لافتاً: البرنامج طبع `(null)`، أي أن المترجم لم يرجع عنوان المخزن أصلاً، وإنما رجع مؤشراً فارغاً، و`printf` في glibc تطبع `(null)` بدلاً من أن تنهار. فالمترجم، لأنه يعرف أن استخدام هذا العنوان سلوك غير معرف، اختار أن يجعل الخطأ ظاهراً. ولكن لا تعتمد على ذلك، فمترجم آخر أو حالة أعقد قد تعطيك النص الصحيح مرة ونصاً مشوهاً مرة أخرى. والحلول الصحيحة ثلاثة: أن يمرر المستدعي المخزن وطوله، وهو الأسلوب الأشهر في C، أو أن تحجز الدالة النص في ال Heap وتوثق أن المستدعي يحرره، أو مخزن `static` إذا قبلت أن الدالة ليست آمنة مع الخيوط.

## من يدير الذاكرة في C؟ ال Stack وال Heap Memory

الإجابة القصيرة: أنت. والإجابة الأدق أن في برنامج C ثلاثة أماكن للبيانات، وكل واحد منها له مدير مختلف. المتغيرات العامة و`static` تعيش طوال عمر البرنامج ولا يحررها أحد. والمتغيرات المحلية تعيش في ال Stack، ويحجزها المترجم عند دخول الدالة ويعيدها عند خروجها، دون أن تكتب شيئاً. وما تحجزه بـ `malloc` يعيش في ال Heap حتى تستدعي `free`، وهذا الجزء وحده هو المسؤولية اليدوية. وقد شرحنا ثمن هذه المسؤولية وأخطاءها الثلاثة، التسرب Leak والاستخدام بعد التحرير Use After Free والتحرير مرتين Double Free، مع Valgrind وAddressSanitizer في مقال إدارة الذاكرة، فلن نعيدها هنا، وننتقل إلى ما يقابله المطور الجديد في الكود اليومي.

### ال Stack صغير: مصفوفة محلية من 16 MB

القادم من Java أو Python يفترض أن المتغير المحلي يمكن أن يكون بأي حجم. والبرنامج `lab/stack.c` يحجز مصفوفة محلية من 16 MB لسطور الطلبات، ثم يفعل نفس الشيء بـ `malloc`:

```text
8192
exit 139
Segmentation fault (core dumped)
heap: 4096
==19==ERROR: AddressSanitizer: stack-overflow on address 0x7ffe632670d0 (pc 0x0000004011df bp 0x7ffe64267200 sp 0x7ffe632670c0 T0)
    #0 0x0000004011df in sum_local /lab/stack.c:9
```

الرقم الأول هو ناتج `ulimit -s`، أي أن حد ال Stack للخيط الرئيسي 8192 KB، وهو الافتراضي في أغلب توزيعات Linux. والمصفوفة 16 MB، ضعف الحد، فانهار البرنامج برمز 139، أي الإشارة 11 `SIGSEGV`، دون أي رسالة تقول إن ال Stack امتلأ. ونسخة ال Heap عملت وأعطت 4096، أي عدد الصفحات التي قرأنا منها بايتاً. وASan سمت المشكلة باسمها `stack-overflow` وأشارت إلى السطر 9\. وهذه المشكلة أسوأ في الخيوط، لأن حجم ال Stack للخيط الذي تنشئه بـ `pthread_create` له حد آخر، فالكود الذي يعمل في `main` قد ينهار نفسه في خيط. والقاعدة: كل ما حجمه بالكيلوبايتات أو أكبر، أو يأتي حجمه من المستخدم، يذهب إلى ال Heap.

### لا يوجد ArrayList: المصفوفة التي تكبر Dynamic Array

سطور الطلبات لا تعرف عددها قبل قراءة الملف، وفي كل لغة أخرى تستخدم قائمة تكبر وحدها. وفي C تبنيها بنفسك من ثلاثة أشياء: مؤشر إلى الكتلة، وعدد العناصر المستخدمة، وعدد العناصر المحجوزة، ثم تكبر الكتلة بـ `realloc` عندما تمتلئ:

```c
typedef struct { int32_t *data; size_t len, cap; } Vec;

static int push(Vec *v, int32_t x, int doubling, size_t *moves) {
    if (v->len == v->cap) {
        size_t cap = doubling ? (v->cap ? v->cap * 2 : 8) : v->cap + 1;
        int32_t *p = realloc(v->data, cap * sizeof *p);   // never v->data = realloc(...)
        if (!p) return -1;                                 // v->data is still valid here
        if (p != v->data) (*moves)++;
        v->data = p;
        v->cap = cap;
    }
    v->data[v->len++] = x;
    return 0;
}
```

والسؤال الذي يسأله كل من يكتب هذا أول مرة: كم أكبر الكتلة في كل مرة؟ جربنا طريقتين، عنصراً واحداً في كل مرة، أو ضعف الحجم، ومليون عنصر ثم عشرة ملايين:

```text
plus1  n=1000000 len=1000000 cap=1000000 block moved 7 times, 0.045 s
double n=1000000 len=1000000 cap=1048576 block moved 6 times, 0.015 s
plus1  n=1000000 len=1000000 cap=1000000 block moved 7 times, 0.063 s
double n=1000000 len=1000000 cap=1048576 block moved 6 times, 0.014 s
plus1  n=10000000 len=10000000 cap=10000000 block moved 11 times, 0.540 s
double n=10000000 len=10000000 cap=16777216 block moved 10 times, 0.476 s
```

كنا نتوقع أن طريقة «عنصر في كل مرة» كارثية، لأنها تستدعي `realloc` مليون مرة، وكل استدعاء قد ينسخ الكتلة كلها إلى مكان جديد، أي مليارات البايتات المنسوخة. ولكن المخرج يقول شيئاً آخر: الكتلة انتقلت 7 مرات فقط في المليون، و11 مرة في عشرة ملايين، والفرق في الوقت بين الطريقتين ثلاثة أضعاف في المليون، وأقل من 15% في عشرة ملايين. والسبب في مكتبة C نفسها: `realloc` تكبر الكتلة في مكانها إذا كان ما بعدها فارغاً، وهذا يحدث كثيراً عندما لا يحجز البرنامج شيئاً آخر بين الاستدعاءات. وبعد أن تتجاوز الكتلة 128 KB، وهو الحد الذي تذكره صفحة [malloc(3)](https://man7.org/linux/man-pages/man3/malloc.3.html?ref=arabroot.io)، تحجزها glibc بالطلب `mmap` من النواة مباشرة، ثم تكبرها بالطلب [mremap](https://man7.org/linux/man-pages/man2/mremap.2.html?ref=arabroot.io) الذي ينقل الصفحات في جدول الصفحات دون نسخ أي بايت.

فهل نستنتج أن الطريقة البسيطة جيدة؟ لا، والسبب أن هذا التجريب يمثل أفضل حالة ممكنة: برنامج لا يفعل شيئاً إلا تكبير قائمة واحدة. وفي برنامج حقيقي يحجز أشياء أخرى بين الاستدعاءات، كأسماء المنتجات من نفس الملف، لن تجد الكتلة مكاناً فارغاً بعدها، وسوف تنسخ في كل مرة، وهذا ما تفترضه كل مكتبة قوائم في اللغات الأخرى، فهي تضاعف الحجم أو تضربه في 1.5 لكي يكون متوسط ثمن الإضافة ثابتاً مهما حدث. ولاحظ ثمن المضاعفة أيضاً في السطر الأخير: السعة 16777216 لعشرة ملايين عنصر، أي حوالي 27 MB محجوزة لا تستخدم. ولاحظ في الكود السطر الذي عليه التعليق: النتيجة تحفظ في متغير مؤقت `p` أولاً، لأن `realloc` إذا فشلت ترجع `NULL` وتترك الكتلة القديمة كما هي، فإذا كتبت `v->data = realloc(v->data, ...)` مباشرة ضاع المؤشر الوحيد إليها.

### آلاف الحجوزات الصغيرة أم كتلة واحدة؟ المخصص بالكتلة Arena Allocator

تقرير اليوم يعالج الطلبات واحداً بعد الآخر، وكل طلب ينشئ ألفي سطر صغير ثم ينتهي. والطريقة البديهية `malloc` لكل سطر و`free` لكل سطر في النهاية. والطريقة الأخرى التي تستخدمها برامج C الكبيرة أن تحجز كتلة واحدة لكل طلب، وتعطي منها كل سطر بتحريك مؤشر فقط، ثم «تحرر» الطلب كله بإرجاع المؤشر إلى البداية. وهذا ما يسمى Arena أو Region، ويستخدمه PostgreSQL باسم سياقات الذاكرة Memory Contexts كما في [ملف التوثيق في كوده المصدري](https://github.com/postgres/postgres/blob/master/src/backend/utils/mmgr/README?ref=arabroot.io)، وnginx باسم المجمع Pool كما في [دليل المطورين](https://nginx.org/en/docs/dev/development%5Fguide.html?ref=arabroot.io):

```c
typedef struct { char *base; size_t used, cap; } Arena;

static void *arena_alloc(Arena *a, size_t n) {
    n = (n + 15) & ~(size_t)15;             // keep 16-byte alignment
    if (a->used + n > a->cap) return NULL;
    void *p = a->base + a->used;
    a->used += n;
    return p;
}
```

والبرنامج `lab/arena.c` يعالج ألفي طلب، في كل طلب ألفا سطر، بالطريقتين، وشغلناه خمس مرات متتالية لكل طريقة، ثم مرة تحت Valgrind لنعد الحجوزات:

```text
malloc 2000 requests x 2000 lines: 1.362 s (check 23882000000)
arena  2000 requests x 2000 lines: 0.962 s (check 23882000000)
malloc 2000 requests x 2000 lines: 0.918 s (check 23882000000)
arena  2000 requests x 2000 lines: 0.874 s (check 23882000000)
malloc 2000 requests x 2000 lines: 1.345 s (check 23882000000)
arena  2000 requests x 2000 lines: 0.653 s (check 23882000000)
malloc 2000 requests x 2000 lines: 1.518 s (check 23882000000)
arena  2000 requests x 2000 lines: 0.697 s (check 23882000000)
malloc 2000 requests x 2000 lines: 1.051 s (check 23882000000)
arena  2000 requests x 2000 lines: 0.622 s (check 23882000000)
malloc 2000 requests x 2000 lines: 77.097 s (check 23882000000)
==12==   total heap usage: 4,000,002 allocs, 4,000,002 frees, 160,132,096 bytes allocated
==101==   total heap usage: 2 allocs, 2 frees, 132,096 bytes allocated
```

في المخرج أعلاه لاحظ التالي:

- الرقم `check` متطابق في كل التشغيلات، أي أن الطريقتين حسبتا نفس النتيجة، وهذا أول ما تتحقق منه في أي مقارنة أداء.
- Valgrind عدت أربعة ملايين حجز وأربعة ملايين تحرير في نسخة `malloc`، وحجزين فقط في نسخة الكتلة: الكتلة نفسها ومخزن `printf`. ونسخة `malloc` أخذت 77 ثانية تحت Valgrind، لأن الأداة تراقب كل حجز، وهذا أيضاً يبين لماذا لا تشغل Valgrind على كل شيء.
- الوقت: الكتلة أسرع في كل زوج، ولكن الفرق بين ضعفين و5% بحسب التشغيل، ونفس نسخة `malloc` أخذت مرة 0.92 ومرة 1.52 ثانية. والسبب أن الجهاز مشترك، وكانت تعمل عليه في نفس الوقت تجارب أخرى، ولهذا كررنا ولم نكتف بتشغيل واحد. وأغلب الوقت في الحالتين ليس الحجز أصلاً، وإنما `snprintf` التي تكتب رمز المنتج في كل سطر.

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

![ثلاث تجارب للذاكرة: مصفوفة محلية من 16 MB مع حد Stack من 8192 KB انهارت برمز 139 بينما عملت نسخة malloc، والمصفوفة التي تكبر بعنصر واحد انتقلت 7 مرات فقط في مليون إضافة بفضل mremap وأخذت 0.045 و0.063 ثانية مقابل 0.015 و0.014 بالمضاعفة، والكتلة Arena بحجزين فقط مقابل 4,000,002 حجز وزمن بين 0.62 و0.96 ثانية مقابل 0.92 و1.52](https://arabroot.io/content/images/2026/10/c-types-memory-04-memory.webp)

أين تضع البيانات، وكيف تكبر، وكم مرة تحجز

### من يملك هذا المؤشر؟ قواعد الملكية Ownership Conventions

في Rust يتحقق المترجم من الملكية كما شرحنا في مقال إدارة الذاكرة، وفي Go وJava لا تسأل السؤال أصلاً. أما في C فالملكية اتفاق مكتوب في التوثيق وفي أسماء الدوال، والمترجم لا يعرف عنه شيئاً. وعندما تستخدم مكتبة C يجب أن تبحث في توثيق كل دالة ترجع مؤشراً عن جواب سؤال واحد: من يحرر هذا، ومتى، وبأي دالة؟ وهذه أمثلة من المكتبات التي سنستخدمها في مقال الخدمة من هذه المرحلة:

| الدالة                  | ما ترجعه                  | من يحرره                                          |
| ----------------------- | ------------------------- | ------------------------------------------------- |
| PQexec في libpq         | نتيجة استعلام PGresult \* | أنت، بالدالة PQclear                              |
| PQgetvalue في libpq     | نص داخل النتيجة           | لا أحد، ويموت مع PQclear، فانسخه إذا احتجته بعدها |
| cJSON\_Parse            | شجرة JSON                 | أنت، بالدالة cJSON\_Delete مرة واحدة للجذر        |
| cJSON\_PrintUnformatted | نص جديد                   | أنت، بالدالة free أو cJSON\_free                  |
| strerror                | وصف الخطأ                 | لا أحد، ونص قد يتغير في الاستدعاء التالي          |
| getenv                  | قيمة متغير البيئة         | لا أحد، ولا تعدل عليه                             |

والاتفاق الذي ننصح به في كودك هو نفس اتفاق هذه المكتبات: الدالة التي تنشئ لها دالة تحرر بنفس الاسم، مثل `order_new` و`order_free`، والتوثيق فوق كل دالة ترجع مؤشراً يقول بوضوح هل المستدعي يملكه أم يستعيره فقط. وفي مقال الخدمة سوف ترى أن أغلب الكود المضاف في C مقارنة باللغات الأخرى هو هذا بالضبط: أسطر `PQclear` و`cJSON_Delete` و`free` في كل مسار، ومنها مسارات الخطأ.

## الأخطاء Errors في C: قيمة الرجوع والمتغير errno

لا توجد استثناءات في C، ولا نوع `Result` كما في Rust، ولا قيمة `error` ثانية كما في Go. والطريقة التقليدية، وقد قارناها بطرق اللغات الأخرى في مقال [كيف تتعامل لغات البرمجة مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)، أن ترجع الدالة قيمة خاصة تعني الفشل، مثل `NULL` أو 1-، وتضع سبب الفشل في متغير عام اسمه `errno`، وهو عدد له أسماء معرفة مثل `ENOENT` للملف غير الموجود و`ENOMEM` لنفاد الذاكرة، وتحوله الدالة `strerror` إلى نص، كما في صفحة [errno(3)](https://man7.org/linux/man-pages/man3/errno.3.html?ref=arabroot.io). والبرنامج `lab/errors.c` يجمع الحالات التي ستقابلها في قراءة ملف الطلبات، ومعها دالة قراءة كمية مكتوبة بالطريقة الصحيحة هذه المرة:

```c
// Parses a quantity. Returns 0 on success and writes *out; returns an errno-style code on failure.
static int parse_qty(const char *s, int *out) {
    char *end;
    errno = 0;
    long v = strtol(s, &end, 10);
    if (end == s) return EINVAL;                  // no digits at all
    if (*end != '\0') return EINVAL;              // trailing garbage like "12abc"
    if (errno == ERANGE || v > INT_MAX) return ERANGE;
    if (v <= 0) return EDOM;                      // a quantity must be positive
    *out = (int)v;
    return 0;
}
```

```text
1) fopen: NULL, errno=2 (No such file or directory)
2) parse_qty("3") -> 3
2) parse_qty("12abc") -> Invalid argument
2) parse_qty("") -> Invalid argument
2) parse_qty("99999999999999999999") -> Numerical result out of range
2) parse_qty("-2") -> Numerical argument out of domain
3) atoi("12abc") = 12, atoi("abc") = 0
4) malloc(SIZE_MAX/2) = (nil), errno=12 (Cannot allocate memory)
```

في المخرج والكود أعلاه لاحظ التالي:

- `fopen` رجعت `NULL`، وهذا كل ما تقوله القيمة: فشل. والسبب في `errno`: الرقم 2، أي `ENOENT`، ونصه «No such file or directory». ولاحظ أن قراءة `errno` لها معنى فقط بعد فشل الدالة، فالدالة الناجحة قد تترك فيه قيمة قديمة من محاولة داخلية فاشلة.
- `parse_qty` تستخدم أسلوباً شائعاً في المكتبات الحديثة: القيمة الراجعة رمز الخطأ، و0 نجاح، والنتيجة الحقيقية تكتب في مؤشر `out`. وبهذا لا تحتاج قيمة خاصة من نوع النتيجة تعني الفشل، فكل الأعداد ممكنة.
- السر في `strtol` هو المعامل الثاني `&end`، الذي رميناه في المقال السابق بكتابة `NULL`. فبعد القراءة يشير `end` إلى أول حرف لم يقرأ، فإذا كان يساوي `s` فلم يقرأ أي رقم، وإذا لم يكن نهاية النص فهناك حروف زائدة، كما في `12abc`. وتجاوز المدى يعرف من `errno == ERANGE`، ولذلك صفرنا `errno` قبل الاستدعاء، كما توصي صفحة [strtol(3)](https://man7.org/linux/man-pages/man3/strtol.3.html?ref=arabroot.io) نفسها. وهذه أربعة فحوص لقراءة رقم واحد، وهي الثمن الحقيقي للأخطاء بقيمة الرجوع.
- `atoi` أعطت 12 للنص `12abc` و0 للنص `abc`، ولا تملك أي طريقة لتقول إنها فشلت، فـ 0 قد يكون قيمة صحيحة أو فشلاً. ولهذا تنبه إليها أداة clang-tidy كما رأينا في المقال الأول، ولا تستخدمها مع أي مدخل من مستخدم أو ملف.
- `malloc` فشلت فعلاً مع حجم مستحيل، ورجعت `NULL` مع `ENOMEM`. وفي الواقع نادراً ما تفشل `malloc` على Linux مع أحجام معقولة، لأن النواة تعد بالذاكرة ولا تحجزها إلا عند الاستخدام، ولكن الكود الذي يتجاهل `NULL` ينهار عندما تفشل، ويحدث هذا في Container له حد ذاكرة صارم أو مع حجم جاء من مدخل خاطئ.

### ماذا يحدث إذا تجاهلت قيمة الرجوع Return Value؟

في Java إذا لم تعالج الاستثناء فإنه يصعد ويوقف البرنامج عند السطر الذي حدث فيه، ومعه مسار الاستدعاء. أما في C فإذا تجاهلت قيمة الرجوع، فالخطأ لا يختفي، وإنما ينتقل إلى مكان آخر. والبرنامج `lab/nullderef.c` يفتح ملف الأسعار ولا يتحقق، ثم يقرأ منه:

```c
    FILE *f = fopen("/lab/missing-prices.csv", "r");   // returns NULL, nobody checks
    char line[64];
    fgets(line, sizeof line, f);                        // crash here, far from the cause
```

```text
exit 139
Segmentation fault (core dumped)
==126==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x7fb467b333ae bp 0x7fff31960d40 sp 0x7fff319604b0 T0)
    #0 0x7fb467b333ae in _IO_fgets libio/iofgets.c:47
    #2 0x00000040123a in main /lab/nullderef.c:7
```

البرنامج انهار برمز 139، والرسالة `Segmentation fault` لا تذكر ملفاً ولا سبباً، ولا كلمة عن الملف المفقود. ومع ASan صار عندنا أكثر: القراءة من العنوان صفر داخل `fgets` في مكتبة C، التي استدعاها السطر 7 من برنامجنا. ولكن السبب الحقيقي في السطر 5، والمعلومة المفيدة «لا يوجد ملف بهذا الاسم» كانت في `errno` وضاعت. وفي برنامج حقيقي قد يكون الانهيار بعد مئات الأسطر وفي ملف آخر، أو لا يحدث انهيار أصلاً، كما رأينا مع `strtol`. والخبر الجيد أن GCC يستطيع أن يجد هذا الخطأ دون تشغيل، بالمحلل الساكن `-fanalyzer` الموثق في [خيارات المحلل في GCC](https://gcc.gnu.org/onlinedocs/gcc/Static-Analyzer-Options.html?ref=arabroot.io):

```text
nullderef.c:7:5: warning: use of possibly-NULL 'f' where non-null expected [CWE-690] [-Wanalyzer-possible-null-argument]
```

والمحلل تتبع المسار الذي ترجع فيه `fopen` القيمة `NULL`، ووجد أنها تصل إلى `fgets`، وأعطى الخطأ رقمه في قائمة [CWE-690](https://cwe.mitre.org/data/definitions/690.html?ref=arabroot.io)، أي قيمة رجوع لم تفحص ثم استخدمت كمؤشر. ولكي يساعدك المترجم في دوالك أنت، ضع `[[nodiscard]]` من C23 قبل كل دالة ترجع رمز خطأ، كما رأينا في المقال الأول، فيحذر من يستدعيها ويتجاهل النتيجة.

### نمط التنظيف goto cleanup: مخرج واحد لكل الموارد

عندما تحجز الدالة أكثر من مورد، ملفاً ثم ذاكرة ثم ملفاً آخر، يصبح كل فشل في المنتصف مشكلة: يجب أن تحرر ما حجزته قبله، ولا شيء بعده. وفي Go تكتب `defer`، وفي Python `with`، وفي C++ وRust يفعل الهدم ذلك وحده. أما في C فالأسلوب المستخدم في نواة Linux وفي أغلب مكتبات C هو `goto` إلى مخرج واحد، وهو من الحالات القليلة التي تعتبر فيها `goto` أسلوباً جيداً:

```c
static int export_orders(const char *in_path, const char *out_path) {
    int rc = -1;
    FILE *in = NULL, *out = NULL;
    char *buf = NULL;

    in = fopen(in_path, "r");
    if (!in) { fprintf(stderr, "open %s: %s\n", in_path, strerror(errno)); goto done; }
    buf = malloc(4096);
    if (!buf) { fprintf(stderr, "malloc: %s\n", strerror(errno)); goto done; }
    out = fopen(out_path, "w");
    if (!out) { fprintf(stderr, "open %s: %s\n", out_path, strerror(errno)); goto done; }

    size_t n;
    while ((n = fread(buf, 1, 4096, in)) > 0) fwrite(buf, 1, n, out);
    rc = 0;
done:                                  // reached on success and on every failure
    if (out) fclose(out);
    free(buf);                         // free(NULL) is allowed and does nothing
    if (in) fclose(in);
    return rc;
}
```

```text
open /no/such/dir/out.csv: No such file or directory
export ok: 0
export to a missing dir: -1
```

في الكود أعلاه لاحظ التالي: كل مورد يبدأ بالقيمة `NULL`، فالمخرج يعرف ما حجز وما لم يحجز. والتحرير بترتيب عكسي للحجز. و`free(NULL)` مسموح ولا يفعل شيئاً، فلا تحتاج شرطاً قبله، أما `fclose(NULL)` فغير مسموح، ولذلك يوجد الشرط. والمخرج يبين المسارين: النجاح رجع 0، والفشل في فتح الملف الثاني طبع السبب الحقيقي من `errno` ورجع 1-، بعد أن أغلق الملف الأول وحرر المخزن. وفي مقال الخدمة سوف تجد نفس النمط بالضبط في دالة إنشاء الطلب، حيث المورد هو المعاملة Transaction في قاعدة البيانات، ومسار الخطأ يجب أن ينفذ `ROLLBACK`.

![الأخطاء في C: الدالة ترجع NULL أو رمزاً، والسبب في errno، وأمثلة من المختبر: fopen رجعت NULL مع errno=2، وparse_qty رفضت 12abc والنص الفارغ والرقم الكبير و-2، وatoi أعطت 12 و0 دون إشارة فشل، وmalloc رجعت nil مع ENOMEM، وتجاهل NULL من fopen انهار برمز 139 في fgets بعيداً عن السبب، بينما وجد -fanalyzer الخطأ وقت الترجمة برقم CWE-690](https://arabroot.io/content/images/2026/10/c-types-memory-05-errors.webp)

القيمة تقول «فشل»، وerrno تقول لماذا، وإذا لم تنظر فلن يخبرك أحد

والجدول التالي يضع طريقة C بجانب ما تعرفه، لكي ترى ما تكسبه وما تدفعه:

| اللغة            | كيف يصل الخطأ          | إذا تجاهلته                                            |
| ---------------- | ---------------------- | ------------------------------------------------------ |
| C                | قيمة رجوع خاصة وerrno  | يكمل البرنامج بقيمة خاطئة أو ينهار لاحقاً              |
| Java وPython وC# | استثناء Exception يصعد | يتوقف البرنامج عند السطر مع مسار الاستدعاء             |
| Go               | قيمة error ثانية       | يكمل، والأداة go vet وبعض أدوات الفحص تنبه             |
| Rust             | النوع Result           | المترجم يحذر، ولا يمكن أخذ القيمة دون التعامل مع الخطأ |

## أخطاء شائعة Common Mistakes

- مقارنة عدد بإشارة بنتيجة `sizeof` أو `strlen`: 1- لم يكن أصغر من 4.
- ضرب سعر في كمية بنوع `int`: ملياري هللة في 2 أعطت 294967296-، والحل `int64_t` قبل الضرب.
- افتراض أن `long` 8 بايت في كل مكان، وهو 4 على Windows.
- ترتيب حقول الهيكل كيفما اتفق في بيانات كثيرة: 240 MB بدلاً من 160 MB لعشرة ملايين سطر.
- حساب طول مصفوفة بـ `sizeof` داخل دالة استقبلتها: 2 بدلاً من 10.
- `strcpy` لنص لا تعرف طوله: اسم من 11 حرفاً جعل العميل «دافعاً»، والإصلاح `snprintf` بحجم المخزن.
- الاعتماد على أداة واحدة: ASan لم تكشف التجاوز الذي بقي داخل الهيكل، و`_FORTIFY_SOURCE` كشفته.
- إرجاع عنوان متغير محلي من دالة.
- مصفوفة كبيرة محلية: 16 MB مع حد 8 MB انهارت دون أي رسالة.
- `p = realloc(p, n)` مباشرة: فشل واحد يضيع الكتلة القديمة.
- تمرير `NULL` مكان مؤشر النهاية في `strtol`، أو استخدام `atoi` مع مدخلات من خارج البرنامج.
- تجاهل `NULL` من `fopen` أو `malloc`: الانهيار يظهر بعيداً عن السبب، والسبب الحقيقي في `errno` يضيع.

## قائمة تحقق Checklist للأنواع والذاكرة والأخطاء

- المال والمعرفات والكميات في `int32_t` أو `int64_t`، والحسابات التي قد تكبر تحول إلى 64 بت قبل العملية.
- البناء بـ `-Wall -Wextra -Wconversion`، والاختبارات بـ `-fsanitize=address,undefined`، والإنتاج بـ `-O2 -D_FORTIFY_SOURCE=3`.
- كل مؤشر إلى مصفوفة يسافر معه طوله، وكل نسخ نص يعرف حجم المكان الذي يكتب فيه.
- الحقول في الهياكل الكبيرة مرتبة من الأكبر إلى الأصغر، ولا `memcmp` على الهياكل.
- لا مصفوفات محلية بالكيلوبايتات، ولا مصفوفات محلية بحجم يأتي من مدخل.
- القوائم التي تكبر تضاعف السعة، و`realloc` تحفظ في متغير مؤقت.
- كل دالة ترجع مؤشراً موثقة: من يحرره وبأي دالة، ولكل `_new` دالة `_free`.
- كل قيمة رجوع تفحص، و`errno` تقرأ مباشرة بعد الفشل، و`[[nodiscard]]` على الدوال التي ترجع رمز خطأ، والمحلل `-fanalyzer` في خط الاختبارات.
- الدوال التي تحجز أكثر من مورد لها مخرج واحد `goto done` يحرر بترتيب عكسي.

## تدريب عملي Practice

- اكتب دالة `read_orders` تقرأ ملف طلبات بأسطر مثل `1,قهوة عربية,990,3` إلى مصفوفة تكبر بالمضاعفة، وترجع رمز خطأ مع رقم السطر الخاطئ، وجربها على سطر فيه `three` وسطر فيه اسم من 200 حرف.
- غير `name[8]` في `overflow.c` إلى مؤشر `char *name` تحجزه بـ `strdup`، وشغل البرنامج تحت Valgrind، ولاحظ ماذا يحدث إذا نسيت `free`.
- أضف إلى `arena.c` كتلة ثانية تحجز عندما تمتلئ الأولى، واختبرها بطلب فيه 10000 سطر.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- C ثابتة الأنواع وضعيفتها: في خمسة أسطر عادية ظهرت خمس نتائج خاطئة، وحذر المترجم من واحدة فقط، وكشف UBSan التجاوز وقت التشغيل.
- تجاوز الأعداد بإشارة سلوك غير معرف، والمترجم يفترض أنه لا يحدث، فلا تحاول تفسيره وإنما تجنبه واكشفه بالأدوات.
- الحشو جعل نفس الحقول 24 بايتاً أو 16 بحسب الترتيب، وثلث الذاكرة فرق بينهما.
- المصفوفة تنسى طولها عند تمريرها، والنص مصفوفة تنتهي بصفر، و`strcpy` لاسم من 11 حرفاً غيرت حقل الدفع إلى 7233901 بصمت، وكشفها `_FORTIFY_SOURCE` ولم تكشفها ASan.
- ال Stack حده 8 MB والمصفوفة الكبيرة تنهار فيه، والقائمة التي تكبر تبنيها بنفسك، و`realloc` في glibc تكبر في المكان أو بـ `mremap`، والمضاعفة تبقى الصحيحة في البرامج الحقيقية.
- الكتلة Arena حولت أربعة ملايين حجز إلى حجزين، وجعلت تحرير الطلب سطراً واحداً، والوقت تغير بين التشغيلات أكثر مما تغير بين الطريقتين أحياناً.
- الأخطاء قيمة رجوع ومعها `errno`، وقراءة رقم واحد بشكل صحيح تحتاج أربعة فحوص، وتجاهل `NULL` جعل الانهيار في `fgets` بعيداً عن سببه، و`-fanalyzer` وجده قبل التشغيل.

وفي المقال التالي من مرحلة [لغة C للمطورين](https://arabroot.io/start/#stage-lang-c) ننتقل إلى الخيوط والأقفال، وهناك تصبح كل مشكلة في هذا المقال أصعب، لأن خيطين يكتبان في نفس الذاكرة:

[![](https://arabroot.io/content/images/2026/10/c-for-devs-cover-cover.webp)دليللغة C لمن يبرمج بلغة أخرى: المترجم GCC والأدواتنبني مكتبة صغيرة تحسب ضريبة الفاتورة بالهللات بأحدث GCC، ونمر على ما يختلف في C عما تعرفه، ومراحل الترجمة، والتحذيرات، وCMake، وأدوات التنسيق والفحص والاختبار والتصحيح.](https://arabroot.io/articles/%D9%84%D8%BA%D8%A9-c-%D9%84%D9%85%D9%86-%D9%8A%D8%A8%D8%B1%D9%85%D8%AC-%D8%A8%D9%84%D8%BA%D8%A9-%D8%A7%D8%AE%D8%B1%D9%89-%D8%A7%D9%84%D9%85%D8%AA%D8%B1%D8%AC%D9%85-gcc-%D9%88%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/memory-management-cover-cover.webp)دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)[![](https://arabroot.io/content/images/2026/10/memory-safety-rust-cover-cover.webp)دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.](https://arabroot.io/articles/%D8%A7%D9%85%D8%A7%D9%86-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-memory-safety-%D9%84%D9%85%D8%A7%D8%B0%D8%A7-%D8%AA%D8%AF%D9%81%D8%B9-%D8%A7%D9%84%D8%AD%D9%83%D9%88%D9%85%D8%A7%D8%AA-%D9%86%D8%AD%D9%88-rust/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب داخل Container مبني على gcc:16.2.0-trixie مع GCC 16.2.0 وglibc 2.41 وValgrind 3.24.0، على Docker Engine 29.8.0 بمعالج فيه 8 أنوية منطقية.