لغات البرمجة عملياً الأدلة التقنية

لغة C لمن يبرمج بلغة أخرى: المترجم GCC والأدوات

نبني مكتبة صغيرة تحسب ضريبة الفاتورة بالهللات بأحدث GCC، ونمر على ما يختلف في C عما تعرفه، ومراحل الترجمة، والتحذيرات، وCMake، وأدوات التنسيق والفحص والاختبار والتصحيح.

لغة C لمن يبرمج بلغة أخرى: المترجم GCC والأدوات

لنفرض أنك مطور تكتب بلغة Python أو Go أو Java في شركة ناشئة تبيع القهوة والتمور في متجر إلكتروني ومعه نقطة بيع صغيرة في المستودع، وكل شيء عندكم يعمل بلغات فيها مدير حزم وجامع قمامة وأداة واحدة تبني وتختبر. ثم تشتري الشركة طابعة فواتير جديدة لنقطة البيع، وتكتشف أن الشركة المصنعة لا تعطيك إلا مكتبة مكتوبة بلغة C وملف ترويسة Header اسمه pos_sdk.h، وأن المطلوب منك مكتبة صغيرة تحسب إجمالي الفاتورة مع ضريبة القيمة المضافة 15% بالهللات، وتربط بمكتبة الطابعة. أو ربما لم تشتر الشركة شيئاً، ولكنك فتحت كود PostgreSQL أو Redis أو curl لتفهم سلوكاً غريباً رأيته في الإنتاج، فوجدت نفسك أمام ملفات .c و.h وملف Makefile لا تعرف من أين تبدأ بها.

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

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

  • لماذا ما زالت C موجودة في كل مكان، وما معنى أن تكون اللغة معياراً Standard وليس منتجاً واحداً.
  • تثبيت المترجم Compiler على Linux وmacOS وWindows، والإصدارات الحالية، والمعيار الافتراضي الذي تغير في GCC 15.
  • جولة سريعة في الصيغة Syntax مقارنة بما تعرفه من اللغات الأخرى.
  • مراحل الترجمة Compilation Stages الأربع من ملف المصدر إلى ملف تنفيذي، ولماذا تفهم بها رسائل الأخطاء.
  • التحذيرات Warnings، وماذا يحدث إذا نسيت ملف الترويسة، وما الجديد في C23.
  • هيكل المشروع Project Layout، والبناء بالأداة make ثم CMake، والحزم Packages بدون مدير حزم رسمي.
  • أدوات التنسيق Formatter والفحص Linter والاختبار Testing والتصحيح Debugging، ثم مشكلة حقيقية في قراءة الأرقام، وقائمة تحقق.

لماذا ما زالت لغة C في كل مكان؟ اللغة والمعيار Standard

ظهرت C في مختبرات Bell بين 1969 و1973، وكتبها Dennis Ritchie لكي يعيد بها كتابة نظام Unix الذي كان مكتوباً بلغة التجميع Assembly، وقد حكى القصة بنفسه في ورقته The Development of the C Language. والفكرة كانت لغة قريبة من الآلة بما يكفي لكتابة نظام تشغيل، فهي تعطيك العناوين في الذاكرة والبايتات مباشرة، وبعيدة عنها بما يكفي لكي ينقل نفس الكود من معالج إلى آخر بإعادة الترجمة فقط. وهذا التوازن هو سبب بقائها خمسين سنة، فنواة Linux مكتوبة بها، وكذلك PostgreSQL وSQLite وRedis وcurl وnginx وCPython، أي مفسر Python نفسه الذي قد تكتب به يومياً. وكل لغة تقريباً تتكلم مع العالم الخارجي عبر واجهة C، فعندما تستدعي مكتبة أصلية Native من Python أو Go أو Java أو Rust فهي في الغالب تمر عبر ما يسمى واجهة الاستدعاء الخارجي FFI بقواعد C.

والفرق الأول الذي يجب أن تعرفه أن C ليست منتجاً تملكه شركة أو مؤسسة واحدة مثل Go أو Rust، وإنما معيار دولي Standard تصدره لجنة اسمها WG14 تحت ISO، وآخر إصدار منه C23، ونشر رسمياً عام 2024 باسم ISO/IEC 9899:2024. والمعيار وثيقة تصف ما يجب أن يفعله البرنامج، ثم تأتي المترجمات Compilers لتطبق هذه الوثيقة، وأشهرها GCC من مشروع GNU وClang من مشروع LLVM وMSVC من Microsoft. ولذلك لا يوجد «إصدار C الحالي» الذي تنزله من موقع واحد، وإنما يوجد إصدار المعيار الذي تكتب به، وإصدار المترجم الذي تستخدمه، وإصدار مكتبة C القياسية libc على النظام، وكل واحد منها يؤثر على برنامجك. وسوف ترى بعد قليل أن مترجمين حديثين على نفس الجهاز لا يتفقان على المعيار الافتراضي.

والإصدارات التي ستقابلها في الكود الحقيقي هي C89 (وتسمى أيضاً ANSI C)، ثم C99 التي جاءت بالتعليق // والأنواع int64_t وتعريف المتغير داخل حلقة for، ثم C11 التي أضافت الخيوط والعمليات الذرية Atomics، ثم C17 التي أصلحت أخطاء فقط، ثم C23. والكود القديم الذي ستقرؤه في مشاريع مفتوحة المصدر قد يكون مكتوباً لأي منها، وكثير من المشاريع الكبيرة ما زال يلتزم بـ C99 أو C11 لكي يبنى على كل الأنظمة.

خط زمني لإصدارات معيار C: C89 عام 1989، وC99 عام 1999، وC11 عام 2011، وC17 عام 2018، وC23 المنشور عام 2024، وتحته المعيار الافتراضي لكل مترجم في المختبر: GCC 16.2 يعطي 202311 أي C23، وClang 19.1.7 يعطي 201710 أي C17، وGCC مع الخيار -std=c17 يعطي 201710
نفس الملف، ومترجمان حديثان، ومعياران افتراضيان مختلفان

تثبيت المترجم Compiler: ما الذي تحتاجه فعلاً؟

في Go أو Rust تنزل أداة واحدة من الموقع الرسمي فتأتيك بالمترجم ومدير الحزم والمنسق والاختبارات. أما في C فأنت تجمع أدواتك بنفسك، والحد الأدنى ثلاثة أشياء: المترجم، ومكتبة C القياسية مع ملفات ترويستها، والرابط Linker. والطريقة تختلف بحسب النظام:

  • على Debian وUbuntu: الحزمة build-essential تثبت GCC وmake وملفات ترويسة glibc بأمر واحد sudo apt install build-essential. والإصدار هو ما تأتي به التوزيعة، فـ Debian 13 يأتي بـ GCC 14.2، وهذا مناسب تماماً لأغلب العمل.
  • على Fedora: sudo dnf install gcc make، وFedora تأتي عادة بإصدار أحدث من GCC لأنها تتبع الإصدارات بسرعة.
  • على macOS: الأمر xcode-select --install يثبت أدوات سطر الأوامر من Apple، وفيها Clang. ولاحظ أن الأمر gcc على macOS هو في الحقيقة Clang باسم آخر، فلا تستغرب إذا رأيت رسائل Clang عندما تكتب gcc.
  • على Windows: لديك ثلاثة طرق، الأولى WSL أي Linux داخل Windows ثم نفس أوامر Ubuntu، وهي الأقرب لما ستشغله في السيرفرات. والثانية MSYS2 التي تعطيك GCC يبني برامج Windows أصلية. والثالثة Visual Studio مع المترجم MSVC، وهو جيد ولكنه يتأخر في دعم المعايير الحديثة ويختلف في الخيارات، ولذلك سوف نلتزم في هذه السلسلة بـ GCC وClang.

وإذا أردت أحدث إصدار من GCC دون أن تغير مترجم النظام، فالطريقة الأنظف هي الصورة الرسمية gcc على Docker Hub. وآخر إصدار عند كتابة المقال هو GCC 16.2، الصادر في 7 أغسطس 2026 بحسب صفحة تغييرات GCC 16. وبنينا عليه صورة المختبر التي سوف نستخدمها في كل مقالات المرحلة، وأضفنا إليها من حزم Debian 13 المترجم Clang وأدوات الفحص والمكتبات التي سنحتاجها لاحقاً في الخدمة:

FROM gcc:16.2.0-trixie
RUN apt-get update && apt-get install -y --no-install-recommends \
      clang clang-format clang-tidy cppcheck valgrind gdb cmake make pkg-config time procps \
      libmicrohttpd-dev libpq-dev libcjson-dev libcurl4-openssl-dev libssl-dev curl postgresql-client \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /lab
docker build -t slr-c-dev:1 .
docker run --rm -v "$PWD":/lab slr-c-dev:1 sh /lab/run.sh

والسطور الأولى من المخرج تبين ما في الصورة:

gcc (GCC) 16.2.0
Debian clang version 19.1.7 (3+b1)
cmake version 3.31.6
GNU Make 4.4.1
valgrind-3.24.0
Debian clang-format version 19.1.7 (3+b1)
Debian LLVM version 19.1.7
Cppcheck 2.17.1
ldd (Debian GLIBC 2.41-12+deb13u4) 2.41

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

  • السطر الأول GCC 16.2.0 من الصورة الرسمية، وهو مبني في /usr/local ولا يحمل كلمة Debian، بينما Clang 19.1.7 جاء من حزم Debian نفسها. فعلى نفس الجهاز مترجمان من مصدرين، وهذا شائع في العمل الحقيقي.
  • السطر الأخير مكتبة C القياسية glibc 2.41. وهذه ليست جزءاً من المترجم، وإنما جزء من النظام، وهي التي فيها printf وmalloc وfopen. والبرنامج الذي تبنيه على glibc حديثة قد لا يعمل على نظام فيه glibc أقدم، وهذا سبب كثير من رسائل GLIBC_2.38 not found التي يراها الناس عند نقل ملف تنفيذي بين الأجهزة.
  • CMake وmake أداتا بناء، وclang-format وclang-tidy وCppcheck أدوات تنسيق وفحص، وValgrind أداة لفحص الذاكرة وقت التشغيل. وكل منها مشروع مستقل بإصداره الخاص.

المعيار الافتراضي Default Standard: هل يتفق المترجمان؟

كل مترجم يختار معياراً يعمل به إذا لم تحدد له، والبرنامج الصغير tour/std.c يطبع الثابت __STDC_VERSION__ الذي يعرفه المترجم بحسب المعيار:

#include <stdio.h>

int main(void) {
    printf("__STDC_VERSION__ = %ldL\n", (long)__STDC_VERSION__);
    return 0;
}
gcc tour/std.c -o /tmp/std && /tmp/std
clang tour/std.c -o /tmp/std && /tmp/std
gcc -std=c17 tour/std.c -o /tmp/std && /tmp/std
__STDC_VERSION__ = 202311L
__STDC_VERSION__ = 201710L
__STDC_VERSION__ = 201710L

GCC 16 يعمل افتراضياً بـ C23، والرقم 202311 هو تاريخ المعيار بالسنة والشهر، بينما Clang 19 يعمل افتراضياً بـ C17. والتغيير حدث في GCC 15، حيث تقول صفحة تغييرات GCC 15 إن المعيار الافتراضي صار -std=gnu23 بدلاً من gnu17. والمعنى العملي أن نفس الكود قد يترجم على جهاز زميلك ويفشل على جهازك، أو العكس، فقط لأن أحدكما حدث المترجم، وسوف ترى مثالاً حقيقياً على ذلك في قسم C23. والحل بسيط: حدد المعيار دائماً في ملف البناء، بالخيار -std=c23 أو -std=c17، ولا تترك القرار للمترجم.

والفرق بين c23 وgnu23 أن الثاني يضيف امتدادات GCC الخاصة ويعرف بعض دوال POSIX تلقائياً، بينما الأول يلتزم بالمعيار حرفياً ويخفي ما ليس فيه. وقد ظهر لنا هذا الفرق فعلاً في مقال الخدمة من هذه المرحلة، عندما رفض المترجم النوع pthread_barrier_t لأننا بنينا بـ -std=c23، وكان الحل سطراً واحداً يطلب امتدادات POSIX صراحة.

جولة سريعة في الصيغة Syntax: ما الذي يختلف عما تعرفه؟

أسرع طريقة لتعرف C إذا كنت تعرف لغة أخرى أن ترى برنامجاً صغيراً فيه أغلب ما ستستخدمه، ثم تقف عند كل شيء لا يشبه ما تعرفه. والبرنامج التالي من tour/tour.c يحسب مجموع سلة فيها منتجان:

#include <stdbool.h>   // bool (a keyword since C23, the header keeps older compilers happy)
#include <stdint.h>    // fixed-width integers: int32_t, uint64_t ...
#include <stdio.h>
#include <string.h>

// No classes: data is a struct, behaviour is a function that takes the struct.
typedef struct {
    int32_t id;
    char name[32];          // a fixed buffer inside the struct, not a String object
    int32_t price_halalas;  // money in integer halalas, never in float
} Product;

enum Status { PENDING, PAID, SHIPPED };   // just named integers

// Functions must be declared before use; a header (.h) is a list of such declarations.
static int64_t line_total(const Product *p, int qty) {   // pass a pointer, not a copy
    return (int64_t)p->price_halalas * qty;             // p->x is (*p).x
}

int main(void) {
    Product items[] = {                     // an array of structs, size counted by the compiler
        {1, "Coffee", 990},
        {2, "Dates", 2500},
    };
    size_t n = sizeof items / sizeof items[0];   // there is no items.length

    int64_t sum = 0;
    for (size_t i = 0; i < n; i++) {
        sum += line_total(&items[i], 3);         // & takes the address
    }
    printf("items=%zu sum=%lld halalas\n", n, (long long)sum);

    char greeting[16];
    snprintf(greeting, sizeof greeting, "order #%d", 42);   // strings are char arrays ending in '\0'
    printf("%s has %zu chars, buffer is %zu bytes\n", greeting, strlen(greeting), sizeof greeting);

    enum Status s = PAID;
    switch (s) {
    case PENDING: puts("pending"); break;
    case PAID:    puts("paid");    break;   // forget break and you fall through to the next case
    default:      puts("other");
    }

    bool ok = sum > 0;
    int *nothing = NULL;            // C23 also has nullptr
    printf("ok=%d pointer=%p\n", ok, (void *)nothing);
    return 0;
}
gcc -std=c23 -Wall -Wextra tour/tour.c -o /tmp/tour && /tmp/tour
items=2 sum=10470 halalas
order #42 has 9 chars, buffer is 16 bytes
paid
ok=1 pointer=(nil)

والمجموع صحيح: ثلاث قطع من كل منتج، أي 3 × 990 زائد 3 × 2500، والناتج 10470 هللة. والآن ننظر إلى ما يختلف، نقطة نقطة:

  • لا توجد كلاسات Classes ولا طرق Methods. البيانات في هيكل struct، والسلوك في دالة تأخذ الهيكل، وغالباً تأخذ مؤشراً إليه. وهذا هو «الكائن» في C، والمكتبات الكبيرة مثل libpq وcurl كلها مبنية بهذا الأسلوب: دالة تنشئ الهيكل، ودوال تعمل عليه، ودالة تحرره.
  • الدالة يجب أن تعرف قبل أن تستدعى، أو أن يعلن عنها على الأقل. ولهذا توجد ملفات الترويسة .h، فهي في الأساس قائمة إعلانات Declarations للدوال والأنواع، تضمنها بالسطر #include في كل ملف يحتاجها، والمعالج المسبق ينسخ محتواها نصاً في مكان السطر كما سنرى.
  • المصفوفة Array لا تعرف طولها. لا يوجد items.length ولا len(items)، والحيلة sizeof items / sizeof items[0] تعمل فقط في نفس المكان الذي عرفت فيه المصفوفة، وتتوقف عن العمل بمجرد أن تمررها إلى دالة، وهذا من أشهر أخطاء القادمين من اللغات الأخرى، وسوف نقيسه في مقال المؤشرات.
  • النص String ليس نوعاً، وإنما مصفوفة حروف تنتهي بالبايت صفر '\0'. ولاحظ المخرج: النص order #42 طوله 9 حروف، والمخزن الذي يحمله 16 بايت. فهناك فرق دائم بين طول النص وحجم المكان الذي يعيش فيه، ونسيان هذا الفرق هو أصل أخطاء تجاوز المخزن Buffer Overflow.
  • المؤشر Pointer عنوان في الذاكرة. الرمز & يأخذ عنوان متغير، وp->price_halalas اختصار لـ (*p).price_halalas، أي اتبع العنوان ثم خذ الحقل. وفي Java أو Python كل كائن يمرر بمرجع ضمني، أما في C فأنت تختار: تمرر الهيكل نفسه فينسخ كله، أو تمرر عنوانه. والكلمة const في const Product *p وعد بأن الدالة لن تغير المنتج.
  • الأعداد لها أحجام تختلف بين الأنظمة. int عادة 4 بايت وlong 8 بايت على Linux و4 بايت على Windows، ولذلك نستخدم في الحسابات المهمة int32_t وint64_t من stdint.h، وهي بحجم مضمون. ولاحظ التحويل (int64_t) قبل الضرب، فبدونه يضرب عددان من 32 بت في 32 بت وقد يتجاوزان الحد قبل أن يحفظ الناتج في 64 بت.
  • enum أسماء لأعداد صحيحة فقط، ولا يمنعك المترجم من وضع 7 في متغير من نوع enum Status. وswitch ينتقل إلى الحالة التالية Fall Through إذا نسيت break، وهو سلوك موروث من بدايات اللغة.
  • الدالة main ترجع عدداً هو رمز الخروج Exit Code الذي يراه نظام التشغيل والسكربتات، فـ 0 نجاح وأي رقم آخر فشل.
  • الكلمة static قبل الدالة تعني أنها خاصة بهذا الملف، ولا يراها الرابط من الملفات الأخرى، وهي أقرب شيء في C إلى private.

والجدول التالي يضع هذه الأفكار بجانب ما تعرفه:

الفكرةفي Python أو Java أو Goفي C
الكائنكلاس أو Struct مع طرقstruct ودوال تأخذ مؤشراً إليه
النصنوع str أو String يعرف طولهمصفوفة char تنتهي بـ '\0'
طول المصفوفةlen(a) أو a.lengthتحمله أنت في متغير منفصل
الأخطاءاستثناءات Exceptions أو قيمة errorقيمة الرجوع وerrno
الذاكرةجامع قمامة GC أو ملكيةmalloc وfree بيدك
الوحدات Modulesimport أو packageملف .h بـ #include وملف .c يربط
الخصوصيةprivate أو حرف صغير في Gostatic على مستوى الملف
القيمة الفارغةNone أو null أو nilNULL، وnullptr في C23

ولن نتوسع هنا في الأنواع والذاكرة والأخطاء، فلكل منها مقال كامل في هذه المرحلة، وقد شرحنا الفكرة العامة لإدارة الذاكرة في مقال إدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟، وأماكن البيانات في مقال الذاكرة للمبرمجين: ال Stack وال Heap والمؤشرات.

من ملف المصدر إلى ملف تنفيذي: مراحل الترجمة Compilation Stages

عندما تكتب gcc hello.c -o hello يبدو لك أن خطوة واحدة حدثت، ولكن الأمر gcc في الحقيقة يشغل أربعة برامج متتالية، ومعرفة هذه المراحل هي التي تجعلك تفهم من أين جاءت رسالة الخطأ، لأن كل مرحلة لها أخطاؤها. وقد شرحنا ما يحدث داخل المترجم والرابط بالتفصيل في مقالي المترجم والمفسر وJIT والرابط والمحمل Linker and Loader، ومراحل المترجم الداخلية في مقال كيف يعمل المترجم، وهنا نريد أن نرى المراحل بأوامر GCC نفسها، على البرنامج الصغير:

#include <stdio.h>

int main(void) {
    printf("مرحبا من C\n");
    return 0;
}
gcc -E hello.c -o hello.i && wc -l hello.i
gcc -S hello.c -o hello.s && wc -l hello.s && grep -E 'call|puts|printf' hello.s
gcc -c hello.c -o hello.o && file hello.o && nm hello.o
gcc hello.o -o hello && file hello && ls -l hello && ldd hello
gcc -static hello.o -o hello-static && ls -l hello-static
824 hello.i
27 hello.s
	call	puts
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
0000000000000000 T main
                 U puts
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
-rwxr-xr-x 1 root root 15688 Oct 31 07:23 hello
	linux-vdso.so.1 (0x00007ffefcbf2000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2b0b36b000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f2b0b569000)
-rwxr-xr-x 1 root root 937144 Oct 31 07:23 hello-static

ولنقرأ المخرج مرحلة مرحلة:

  • المعالج المسبق Preprocessor بالخيار -E: برنامجنا ستة أسطر، والناتج 824 سطراً. والسبب أن السطر #include <stdio.h> استبدل بمحتوى الملف stdio.h وكل ما يضمنه هو، نصاً كما هو. فالمعالج المسبق لا يفهم C، وإنما ينسخ ويلصق ويستبدل الماكرو Macros، ولهذا فأخطاء الماكرو غريبة الشكل، لأن المترجم يرى النص بعد الاستبدال لا كما كتبته.
  • الترجمة Compilation بالخيار -S: الناتج 27 سطراً بلغة التجميع، وفيها شيء مفاجئ: الاستدعاء call puts وليس printf. فالمترجم لاحظ أن النص ثابت وينتهي بسطر جديد وليس فيه أي %، فاستبدل الدالة بالدالة الأبسط puts، حتى دون أن نطلب أي تحسين. وهذا يذكرك بأن الكود الذي يعمل ليس بالضرورة الكود الذي كتبته حرفياً.
  • التجميع Assembly بالخيار -c: الملف hello.o ملف كائن قابل للنقل Relocatable، والأداة nm تعرض رموزه: T main أي دالة معرفة هنا في قسم الكود، وU puts أي رمز مستخدم وغير معرف Undefined، ينتظر من يعرفه.
  • الربط Linking: الرابط يأخذ hello.o ويبحث عن puts في مكتبة C، ويخرج ملفاً تنفيذياً بحجم 15688 بايت مربوطاً ديناميكياً Dynamically Linked. والأمر ldd يبين أنه يحتاج libc.so.6 وقت التشغيل، والمحمل ld-linux-x86-64.so.2 هو الذي يحمله.
  • الربط الثابت Static Linking بالخيار -static: نفس البرنامج صار 937144 بايت، أي حوالي 60 ضعفاً، لأن الأجزاء التي يحتاجها من مكتبة C صارت داخل الملف نفسه. ومقابل الحجم لا يحتاج الملف أي مكتبة على الجهاز الذي يعمل عليه، وهذا ما يجعل الربط الثابت مفيداً في Container صغير جداً أو أداة تريد نقلها بين توزيعات مختلفة.
مراحل الترجمة الأربع لبرنامج hello.c من ستة أسطر: المعالج المسبق ينتج hello.i من 824 سطراً، والمترجم ينتج hello.s من 27 سطراً فيه call puts بدل printf، والمجمع ينتج hello.o فيه T main وU puts، والرابط ينتج ملفاً تنفيذياً بحجم 15688 بايت مربوطاً بـ libc.so.6، أو 937144 بايت مع الربط الثابت
الأمر gcc يشغل أربع مراحل، ولكل مرحلة أخطاؤها

والفائدة العملية لهذه الصورة أنك تعرف من رسالة الخطأ أي مرحلة فشلت. فإذا قالت الرسالة error: unknown type name أو expected ';' فهي من المترجم، والمشكلة في ملف المصدر أو في ترويسة لم تضمنها. وإذا قالت undefined reference to وجاءت من ld فالمترجم نجح في كل ملف، ولكن الرابط لم يجد تعريف الدالة في أي ملف كائن أو مكتبة، وغالباً نسيت إضافة ملف .c إلى أمر البناء أو نسيت الخيار -lpq مثلاً. وسوف ترى المثالين في القسم التالي.

التحذيرات Warnings: أول أداة فحص لديك

في Python يخبرك المفسر بالخطأ عندما يصل إلى السطر، وفي Go يرفض المترجم متغيراً غير مستخدم. أما C فتفترض أنك تعرف ما تفعل، فتترجم كوداً فيه أخطاء واضحة دون أي كلمة إذا لم تطلب التحذيرات. والبرنامج tour/warn.c فيه ثلاثة أخطاء يقع فيها كل من يكتب C أول مرة:

#include <stdint.h>
#include <stdio.h>

int discount(int qty) {
    if (qty = 10)              // assignment, not comparison
        return 5;
    return 0;
}

int main(void) {
    int64_t total = 3416;
    int unused;
    printf("total %d, discount %d\n", total, discount(3));   // %d for a 64-bit value
    return 0;
}

نترجمه أولاً دون أي خيار، كما يفعل أغلب المبتدئين:

exit 0
total 3416, discount 5

ترجم بلا أي رسالة، وعمل، وأعطى خصماً 5 لعميل اشترى 3 قطع، بينما الخصم لمن يشتري 10. والسبب أن qty = 10 إسناد وليس مقارنة، وقيمته 10، وهي «صحيحة» في الشرط، فكل عميل يأخذ الخصم. والآن نفس الملف مع الخيارين -Wall -Wextra:

tour/warn.c: In function 'discount':
tour/warn.c:6:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
    6 |     if (qty = 10)              // assignment, not comparison
      |         ^~~
tour/warn.c: In function 'main':
tour/warn.c:14:20: warning: format '%d' expects argument of type 'int', but argument 2 has type 'int64_t' {aka 'long int'} [-Wformat=]
   14 |     printf("total %d, discount %d\n", total, discount(3));   // %d for a 64-bit value
      |                   ~^                  ~~~~~
      |                    |                  |
      |                    int                int64_t {aka long int}
      |                   %ld
tour/warn.c:13:9: warning: unused variable 'unused' [-Wunused-variable]
   13 |     int unused;
      |         ^~~~~~
exit 0

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

  • كل تحذير يبدأ بالملف والسطر والعمود، tour/warn.c:6:9، ثم الوصف، ثم اسم الخيار الذي أطلقه بين قوسين مربعين، مثل [-Wparentheses]. واسم الخيار مفيد لأنك تبحث به في توثيق خيارات التحذير في GCC لتعرف لماذا اعتبره المترجم خطأ محتملاً.
  • التحذير الأول لم يقل «هذا خطأ»، وإنما «ضع أقواساً حول الإسناد إذا كنت تقصده»، لأن هناك كوداً صحيحاً يستخدم الإسناد في الشرط عمداً. فإذا كنت تقصد المقارنة فالإصلاح ==.
  • التحذير الثاني أخطر مما يبدو، فـ %d يقرأ 4 بايت والقيمة 8 بايت، وطبع الرقم صحيحاً هنا بالصدفة لأنه صغير، ولو كان أكبر من 2147483647 لطبع شيئاً آخر تماماً. والمترجم اقترح الإصلاح بنفسه %ld، والأصح في الكود المحمول PRId64 من inttypes.h كما سنرى في مقال الأخطاء الشائعة.
  • رمز الخروج ما زال 0، أي أن البناء نجح رغم التحذيرات. وإذا أردت أن يفشل البناء عند أي تحذير، وهذا ما ننصح به في الاختبارات الآلية CI، فأضف الخيار -Werror.

والقاعدة العملية من هذا القسم: لا تترجم أي كود C بدون -Wall -Wextra على الأقل، ويضيف كثيرون -Wpedantic الذي يحذر من الامتدادات غير القياسية، و-Wconversion الذي يحذر من تحويل الأعداد بين الأحجام. وفي مقال الخدمة سوف نبني بكل هذه الخيارات معاً، وفي ملف الخدمة كاملاً لم يظهر أي تحذير، وهذا هو الهدف.

نفس الملف warn.c بأربع أدوات: بدون خيارات ترجم برمز 0 وطبع خصماً 5 لثلاث قطع، ومع -Wall -Wextra ثلاثة تحذيرات هي الإسناد في الشرط و%d لقيمة 64 بت ومتغير غير مستخدم، وclang-tidy وجد الإسناد في الشرط وقيمة لا تقرأ، وCppcheck وجد الشرط الدائم و%d والمتغيرين
الكود نفسه، والفرق كله في ما تطلبه من الأدوات

ماذا يحدث إذا نسيت ملف الترويسة Header؟

في الملف tour/implicit.c نستدعي الدالة vat_halalas من مكتبتنا دون أن نضمن ملف ترويستها vat.h، وهو خطأ يحدث كل يوم عندما تنقل دالة من ملف إلى آخر. وفي C القديمة كان المترجم يفترض أن أي دالة لا يعرفها ترجع int، ويكمل بصمت، وهذا ما يسمى الإعلان الضمني Implicit Declaration. ونجرب الآن بالمعيار الافتراضي ثم بمعيار C89 القديم:

tour/implicit.c: In function 'main':
tour/implicit.c:5:33: error: implicit declaration of function 'vat_halalas' [-Wimplicit-function-declaration]
    5 |     printf("%lld\n", (long long)vat_halalas(2970));
      |                                 ^~~~~~~~~~~
exit 1
/usr/bin/ld: /tmp/ccycyJlI.o: in function `main':
implicit.c:(.text+0xf): undefined reference to `vat_halalas'
collect2: error: ld returned 1 exit status
exit 1

والنتيجتان تريانك المرحلتين اللتين تكلمنا عنهما:

  • بالمعيار الافتراضي رفض المترجم الكود بخطأ error وليس تحذيراً. وهذا تغيير حديث، فـ دليل الانتقال إلى GCC 14 يقول إن الإعلان الضمني للدوال صار خطأ افتراضياً منذ ذلك الإصدار، بعد عقود كان فيها تحذيراً يتجاهله الناس. والسبب أن الافتراض القديم بأن الدالة ترجع int كان يقطع أي قيمة من 64 بت إلى 32 بت دون أي رسالة، وكم من مؤشر قطع نصفه بهذه الطريقة.
  • بالمعيار -std=gnu89 عاد السلوك القديم، فمر الملف من المترجم بلا أي رسالة، ثم فشل في الرابط: undefined reference to `vat_halalas'، لأن الأمر لم يتضمن الملف vat.c الذي فيه التعريف. ولو كنا أضفنا vat.c لترجم البرنامج وعمل، مع افتراض خاطئ عن نوع القيمة الراجعة، وهذا أسوأ من الفشل.

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

ما الجديد في C23؟ ولماذا فشل نفس الملف في Clang؟

المعيار C23 جاء بأشياء تجعل C أقرب قليلاً إلى اللغات الحديثة، وأغلبها كان موجوداً امتدادات في المترجمات منذ سنوات ثم دخل المعيار. والملف tour/c23.c يجمع بعضها:

#include <stdio.h>

constexpr int VAT_PERCENT = 15;          // a real compile-time constant

[[nodiscard]] static int vat(int subtotal) { return (subtotal * VAT_PERCENT + 50) / 100; }

int main(void) {
    auto total = 2970;                   // type inferred as int
    typeof(total) v = vat(total);
    int *p = nullptr;
    bool ok = (p == nullptr);
    vat(1);                              // result ignored: warning from [[nodiscard]]
    printf("vat=%d ok=%d binary=%d\n", v, ok, 0b1111);
    return 0;
}

مع GCC 16 ترجم الملف وعمل، وطبع vat=446 ok=1 binary=15 مع تحذير واحد مقصود: ignoring return value of 'vat', declared with attribute 'nodiscard'. أما Clang 19 بمعياره الافتراضي فأعطى خمسة أخطاء، أولها unknown type name 'constexpr' ثم use of undeclared identifier 'nullptr' و'bool'، لأنه يعمل بـ C17 التي لا تعرف هذه الكلمات. وعندما أضفنا -std=c23 ترجم Clang الملف وأعطى نفس النتيجة. وهذا هو المثال الحقيقي على ما قلناه عن المعيار الافتراضي، فلو كتب أحد الفريق هذا الكود على جهاز فيه GCC 16، ثم بنى زميله على macOS بـ Clang، لفشل البناء عند الزميل دون أن يغير أحد أي سطر.

وفي الكود أعلاه لاحظ التالي:

  • constexpr ثابت حقيقي وقت الترجمة، بدلاً من #define VAT_PERCENT 15 الذي هو نص يستبدله المعالج المسبق ولا نوع له.
  • auto تستنتج النوع من القيمة كما في var في Java و:= في Go، وtypeof تأخذ نوع متغير آخر.
  • nullptr قيمة فارغة لها نوعها الخاص، وهي أوضح من NULL الذي قد يكون العدد 0 في بعض الأنظمة. وbool وtrue وfalse صارت كلمات في اللغة بدون stdbool.h.
  • [[nodiscard]] يطلب من المترجم أن يحذر إذا تجاهل أحد القيمة الراجعة، وهو مفيد جداً في C بالذات، لأن الأخطاء فيها ترجع في القيمة الراجعة كما سنرى في المقال التالي، وتجاهلها أشهر سبب للأعطال.

هيكل المشروع Project Layout: مكتبة الضريبة

الآن نبني المكتبة التي طلبها مدير نقطة البيع. وفي C لا يوجد هيكل مفروض كما في cargo new أو go mod init، ولكن أغلب المشاريع تتبع نفس الشكل تقريباً، وهو الشكل الذي سوف تجده في مكتبات كثيرة على GitHub:

halala/
  CMakeLists.txt      build with CMake
  Makefile            the same build with plain make
  .clang-format       formatting rules
  include/vat.h       the public interface: what other code may call
  src/vat.c           the implementation
  src/main.c          a small command line tool that uses the library
  tests/test_vat.c    tests

والفكرة المهمة هي الفصل بين الواجهة والتنفيذ. فالملف include/vat.h هو كل ما يحتاجه من يستخدم المكتبة، ومنه كود نقطة البيع، والملف src/vat.c يمكن أن يتغير كما تشاء ما دامت الواجهة نفسها:

#ifndef HALALA_VAT_H
#define HALALA_VAT_H

#include <stdint.h>

// VAT in Saudi Arabia is 15%. All money is in integer halalas (1 SAR = 100 halalas).
// Returns the VAT of a subtotal, rounded half up. subtotal must be >= 0.
int64_t vat_halalas(int64_t subtotal);

// Subtotal plus VAT.
int64_t total_with_vat(int64_t subtotal);

#endif
#include "vat.h"

int64_t vat_halalas(int64_t subtotal) {
    // subtotal * 15 / 100, rounded half up, using integers only:
    // adding 50 before dividing by 100 turns "round down" into "round half up" for values >= 0.
    return (subtotal * 15 + 50) / 100;
}

int64_t total_with_vat(int64_t subtotal) {
    return subtotal + vat_halalas(subtotal);
}

في الكود أعلاه لاحظ التالي:

  • السطور #ifndef و#define و#endif تسمى حارس الترويسة Include Guard، ووظيفتها أن الملف إذا ضمن مرتين في نفس الترجمة، وهذا يحدث عندما تضمن ملفين يضمن كل منهما vat.h، لا تتكرر الإعلانات. وتذكر أن #include نسخ ولصق، فبدون الحارس يرى المترجم نفس التعريف مرتين.
  • الترويسة <stdint.h> بين علامتي أصغر وأكبر تعني ابحث في مجلدات النظام، و"vat.h" بين علامتي تنصيص تعني ابحث بجانب الملف أولاً ثم في المجلدات التي يعطيها الخيار -I.
  • المال بالهللات في عدد صحيح، ولا يوجد أي float أو double. والتقريب نصف للأعلى Round Half Up بالحيلة المعروفة: نضيف 50 قبل القسمة على 100، فالضريبة على 29.70 ريالاً هي 4.455 ريالاً، أي 445.5 هللة، فتصبح 446 هللة. وسوف تجد هذه الدالة نفسها في خدمة الطلبات في مقال الخدمة من هذه المرحلة، وفي كل لغات السلسلة.
هيكل مشروع halala: الواجهة include/vat.h يضمنها src/vat.c وsrc/main.c وtests/test_vat.c، وكل ملف c يترجم إلى ملف o، ثم الرابط يجمع main.o وvat.o في الأداة halala، ويجمع test_vat.o وvat.o في test_vat، وCMake يولد ملفات البناء وcompile_commands.json الذي تقرؤه clang-tidy والمحررات
واجهة في ملف ترويسة، وتنفيذ في ملف c، وكل ملف يترجم وحده ثم يربط

البناء بالأداة make: أن تعرف ما الذي يعاد بناؤه Build

في المشروع ملفان .c في المكتبة والأداة، وملف اختبار. ويمكنك أن تبني كل شيء بسطر gcc واحد، ولكن في مشروع فيه مئات الملفات لن تريد إعادة ترجمة كل شيء عند تعديل ملف واحد. وهذه هي وظيفة make، أقدم أداة بناء ما زالت تستخدم، وقد قارناها بأدوات البناء الأخرى في مقال أدوات البناء Build Tools. والملف Makefile قائمة قواعد، كل قاعدة تقول: هذا الملف يعتمد على هذه الملفات، وإذا كان أحدها أحدث منه فأعد بناءه بهذا الأمر:

CC      ?= gcc
CFLAGS  ?= -std=c23 -Wall -Wextra -Wpedantic -O2 -g
CPPFLAGS = -Iinclude

all: halala test_vat

build/%.o: src/%.c include/vat.h
	@mkdir -p build
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

halala: build/main.o build/vat.o
	$(CC) $^ -o $@

test_vat: tests/test_vat.c build/vat.o
	$(CC) $(CPPFLAGS) $(CFLAGS) $^ -o $@

test: test_vat
	./test_vat

clean:
	rm -rf build halala test_vat

.PHONY: all test clean

شغلنا make ثلاث مرات: الأولى على مشروع نظيف، والثانية دون أي تغيير، والثالثة بعد تعديل وقت الملف vat.h بالأمر touch، ثم شغلنا الأداة والاختبار:

cc -Iinclude -std=c23 -Wall -Wextra -Wpedantic -O2 -g -c src/main.c -o build/main.o
cc -Iinclude -std=c23 -Wall -Wextra -Wpedantic -O2 -g -c src/vat.c -o build/vat.o
cc build/main.o build/vat.o -o halala
cc -Iinclude -std=c23 -Wall -Wextra -Wpedantic -O2 -g tests/test_vat.c build/vat.o -o test_vat
make: Nothing to be done for 'all'.
cc -Iinclude -std=c23 -Wall -Wextra -Wpedantic -O2 -g -c src/main.c -o build/main.o
cc -Iinclude -std=c23 -Wall -Wextra -Wpedantic -O2 -g -c src/vat.c -o build/vat.o
cc build/main.o build/vat.o -o halala
cc -Iinclude -std=c23 -Wall -Wextra -Wpedantic -O2 -g tests/test_vat.c build/vat.o -o test_vat
subtotal 29.70 SAR, VAT 4.46 SAR, total 34.16 SAR
subtotal 54.70 SAR, VAT 8.21 SAR, total 62.91 SAR
./test_vat
test_vat: all checks passed

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

  • المرة الأولى ترجمت كل ملف .c وحده إلى ملف .o، ثم ربطت. وهذا الفصل هو الذي يجعل البناء التدريجي ممكناً، فكل ملف وحدة ترجمة Translation Unit مستقلة.
  • المرة الثانية Nothing to be done، لأن كل ملف ناتج أحدث من كل ملف يعتمد عليه. والأداة لا تعرف شيئاً عن C، وإنما تقارن أوقات التعديل فقط.
  • المرة الثالثة أعادت كل شيء، لأن كل ملف .o يعتمد في القاعدة على vat.h. وهذا صحيح هنا، فتغيير الواجهة يجب أن يعيد ترجمة كل من يستخدمها. والخطأ الشائع في ملفات Makefile المكتوبة يدوياً أن تنسى الترويسات في الاعتماديات، فتعدل هيكلاً في ملف .h ولا يعاد بناء الملفات التي تستخدمه، فيعمل البرنامج بنسختين مختلفتين من نفس الهيكل، وهذا من أصعب الأخطاء تتبعاً.
  • الأداة حسبت 9.90 ريالاً × 3 فأعطت 29.70 وضريبة 4.46 وإجمالي 34.16، ثم أضفنا تمراً بـ 25 ريالاً فصار الإجمالي 62.91. والاختبار مر.
  • الأمر cc وليس gcc، لأن CC ?= gcc تعني «إذا لم يكن معرفاً»، وmake يعرف CC مسبقاً بـ cc، وهو في هذه الصورة رابط ينتهي إلى /usr/local/bin/gcc، أي GCC 16.2.0. فإذا أردت مترجماً محدداً فاكتب make CC=clang.

البناء بالأداة CMake: ما الذي تستخدمه أغلب المشاريع؟

الملف Makefile يعمل على Linux وmacOS، ولكنه لا يعمل مع Visual Studio على Windows، ولا يعرف كيف يجد مكتبة مثبتة في مكان غير متوقع، وكل مشروع يكتبه بطريقته. ولهذا تستخدم أغلب مشاريع C وC++ الحديثة CMake، وهي ليست أداة بناء بالمعنى الدقيق، وإنما مولد Generator لملفات البناء: تقرأ وصفاً للمشروع، ثم تولد ملف Makefile أو ملفات Ninja أو مشروع Visual Studio بحسب النظام. وهذا وصف مشروعنا:

cmake_minimum_required(VERSION 3.25)
project(halala C)

set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)   # compile_commands.json for clang-tidy and editors

add_compile_options(-Wall -Wextra -Wpedantic)

add_library(vat src/vat.c)
target_include_directories(vat PUBLIC include)

add_executable(halala src/main.c)
target_link_libraries(halala PRIVATE vat)

enable_testing()
add_executable(test_vat tests/test_vat.c)
target_link_libraries(test_vat PRIVATE vat)
add_test(NAME vat COMMAND test_vat)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build
-- Configuring done (2.1s)
-- Generating done (0.2s)
-- Build files have been written to: /tmp/halala-cmake/build
[ 16%] Building C object CMakeFiles/vat.dir/src/vat.c.o
[ 33%] Linking C static library libvat.a
[ 33%] Built target vat
[ 50%] Building C object CMakeFiles/halala.dir/src/main.c.o
[ 66%] Linking C executable halala
[ 66%] Built target halala
[ 83%] Building C object CMakeFiles/test_vat.dir/tests/test_vat.c.o
[100%] Linking C executable test_vat
[100%] Built target test_vat
Test project /tmp/halala-cmake/build
    Start 1: vat
1/1 Test #1: vat ..............................   Passed    0.02 sec

100% tests passed, 0 tests failed out of 1

والفرق بين الطريقتين يظهر في التفاصيل:

  • في CMake تصف أهدافاً Targets: مكتبة vat، وأداة halala تستخدمها، واختبار. والسطر target_include_directories(vat PUBLIC include) يعني أن أي هدف يربط بالمكتبة يحصل تلقائياً على مجلد الترويسات، فلا تكرر -Iinclude في كل مكان. وهذا الأسلوب المبني على الأهداف هو ما يسمى CMake الحديث، وستجد في الإنترنت أمثلة قديمة تضع الخيارات في متغيرات عامة، وهي أصعب صيانة.
  • CMake بنت المكتبة ملفاً ثابتاً libvat.a، أي أرشيف من ملفات .o يضاف إلى الملف التنفيذي عند الربط. وهذا بالضبط ما سيحتاجه فريق نقطة البيع، فيربطون libvat.a مع مكتبة الطابعة.
  • البناء يكون في مجلد منفصل build خارج المصدر، فتحذفه متى شئت ولا تختلط الملفات الناتجة بالكود.
  • الخيار CMAKE_EXPORT_COMPILE_COMMANDS ينتج الملف compile_commands.json، وفيه أمر الترجمة الكامل لكل ملف. وهذا الملف مهم أكثر مما يبدو، فـ clang-tidy والمحررات مثل VS Code مع clangd تقرؤه لتعرف الخيارات والمجلدات، وبدونه تظهر لك في المحرر أخطاء وهمية مثل «لا أجد الملف vat.h».
  • الأداة ctest تشغل كل اختبار معرف بـ add_test وتجمع النتائج. والاختبار في C برنامج عادي، ونجاحه يعني أنه خرج برمز 0.

الحزم Packages: أين مدير الحزم في C؟

هذا السؤال يطرحه كل من يأتي من npm أو pip أو cargo، والإجابة المختصرة أن C لا تملك مدير حزم رسمياً، وقد شرحنا في مقال مديرو الحزم ما يفعله مدير الحزم في اللغات الأخرى. والسبب تاريخي: اللغة أقدم من فكرة مستودع الحزم المركزي، ونظام التشغيل نفسه كان هو مدير الحزم. ولذلك ستجد في الواقع أربع طرق، ولكل منها مكانها:

  • حزم النظام System Packages: على Debian تثبت مكتبة مثل libpq بحزمتين، الأولى للتشغيل libpq5، والثانية للتطوير libpq-dev وفيها ملفات الترويسة وملف الربط. وهذه أكثر طريقة مستخدمة على Linux، وثمنها أنك تأخذ الإصدار الذي في التوزيعة لا الإصدار الذي تريده.
  • الأداة pkg-config: تجيب عن سؤال «ما الخيارات التي أحتاجها لأستخدم هذه المكتبة؟». ففي مقال الخدمة سوف ترى أن السطر pkg-config --cflags libmicrohttpd libpq libcjson أعطى -I/usr/include/p11-kit-1 -I/usr/include/postgresql -I/usr/include/cjson، والأول منها جاء من مكتبة تعتمد عليها libmicrohttpd ولم نطلبه نحن، وأن --libs أعطى -lmicrohttpd -lpq -lcjson، فلا تحفظ أنت أين وضعت كل توزيعة ترويساتها.
  • مديرو حزم مستقلون: vcpkg من Microsoft وConan، وكلاهما يبني المكتبات من المصدر بالإصدار الذي تحدده ويعمل على Windows وLinux وmacOS، ويندمجان مع CMake. وهذه الطريقة الأقرب إلى ما تعرفه من cargo أو npm، وهي شائعة في مشاريع C++ أكثر من C.
  • نسخ المكتبة في المشروع Vendoring: كثير من مكتبات C الصغيرة مكتوبة في ملف .c وملف .h فقط، مثل مكتبة cJSON التي سنستخدمها لقراءة JSON، فتنسخ الملفين في مجلد third_party وتبنيهما مع مشروعك. وهذا يبدو بدائياً، ولكنه يعطيك تحكماً كاملاً بالإصدار، وعليك أن تتابع تحديثاتها الأمنية بنفسك.

ولاحظ أن غياب مدير الحزم له جانب أمني أيضاً، فلا توجد طريقة موحدة لتعرف أي مكتبات في مشروعك ولها ثغرات معروفة، كما تفعل npm audit أو cargo audit. ففي C عليك أن تحتفظ بقائمة الاعتماديات وإصداراتها بنفسك.

جدول أدوات C مقابل ما يعرفه المطور: المترجم gcc 16.2 أو clang 19.1.7 مقابل go build أو javac، والبناء make 4.4.1 وCMake 3.31.6 مقابل go build أو Gradle، والحزم حزم النظام مع pkg-config أو vcpkg أو Conan مقابل npm وpip وcargo، والتنسيق clang-format مقابل gofmt وblack، والفحص -Wall وclang-tidy وCppcheck مقابل go vet وeslint، والاختبار assert مع ctest مقابل go test وpytest، والمصحح gdb
في C تجمع أدواتك بنفسك، ولكل وظيفة أداة معروفة

التنسيق Formatter والفحص Linter: clang-format ثم clang-tidy ثم Cppcheck

لا يوجد في C منسق رسمي مثل gofmt، وأكثر منسق مستخدم اليوم clang-format، يقرأ قواعده من ملف .clang-format في المشروع. وقواعدنا ثلاثة أسطر: نبدأ من أسلوب LLVM، بمسافة بادئة 4، وسطر حتى 100 حرف. والمثال التالي دالة مكتوبة في سطر واحد بلا مسافات:

printf 'int64_t vat_halalas(int64_t subtotal){return (subtotal*15+50)/100;}\n' > /tmp/ugly.c
clang-format --style=file:/lab/halala/.clang-format /tmp/ugly.c
clang-format --dry-run --Werror src/main.c
int64_t vat_halalas(int64_t subtotal) { return (subtotal * 15 + 50) / 100; }
src/main.c:21:69: error: code should be clang-formatted [-Wclang-format-violations]
           (long long)(subtotal / 100), (long long)(subtotal % 100),
                                                                    ^

السطر الأول هو الدالة بعد التنسيق، والثاني أهم: الخيار --dry-run --Werror لا يغير الملف، وإنما يفشل إذا كان الملف غير منسق، وهذا ما تضعه في خط الاختبارات الآلية لكي لا يدخل كود غير منسق. وقد وجد سطرين في main.c كتبناهما بمحاذاة يدوية تختلف عن القواعد. وقرار الفريق هنا إما أن يقبل تنسيق الأداة، وهذا ما ننصح به، أو أن يعدل القواعد، والمهم ألا يكون التنسيق موضوع نقاش في كل مراجعة كود.

أما الفحص فعلى ثلاث طبقات. الأولى تحذيرات المترجم التي رأيناها. والثانية clang-tidy، وهي تفهم الكود كما يفهمه المترجم Clang، وفيها مئات الفحوص في مجموعات مثل bugprone للأخطاء المحتملة وcert لقواعد البرمجة الآمنة وclang-analyzer للتحليل العميق لمسارات التنفيذ. والثالثة Cppcheck، وهي أداة مستقلة لا تحتاج أوامر الترجمة. وشغلنا الاثنتين على نفس الملف warn.c:

/lab/tour/warn.c:6:9: warning: Although the value stored to 'qty' is used in the enclosing expression, the value is never actually read from 'qty' [clang-analyzer-deadcode.DeadStores]
/lab/tour/warn.c:6:13: warning: an assignment within an 'if' condition is bug-prone [bugprone-assignment-in-if-condition]
tour/warn.c:6:13: style: Condition 'qty=10' is always true [knownConditionTrueFalse]
tour/warn.c:14:5: warning: %d in format string (no. 1) requires 'int' but the argument type is 'signed long'. [invalidPrintfArgType_sint]
tour/warn.c:6:13: style: Variable 'qty' is assigned a value that is never used. [unreadVariable]
tour/warn.c:13:9: style: Unused variable: unused [unusedVariable]

والسطر الأوضح هو سطر Cppcheck الذي قال إن الشرط qty=10 صحيح دائماً، وهي جملة لا تحتاج شرحاً. ولاحظ أن كل أداة رأت الخطأ من زاوية: المترجم اقترح أقواساً، وclang-tidy رأت قيمة تحفظ ولا تقرأ وإسناداً في شرط، وCppcheck رأت شرطاً ثابتاً. ولهذا لا تختار أداة واحدة وتكتفي بها.

وجربنا شيئاً آخر: استبدلنا قراءة السعر في main.c بالدالة atoi بدلاً من strtol، وشغلنا الفحص cert-err34-c:

/tmp/main_atoi.c:15:22: warning: 'atoi' used to convert a string to an integer value, but function will not report conversion errors; consider using 'strtol' instead [cert-err34-c]

والفحص محق، فالدالة atoi لا تستطيع أن تخبرك أنها فشلت. ولكن لاحظ أن الأداة سكتت عن نسختنا الأصلية التي تستخدم strtol، وهي أيضاً لا تتحقق من الخطأ كما سنرى في القسم التالي. فالأداة تعرف الدوال الخطرة بأسمائها، ولا تعرف هل استخدمت الدالة الآمنة بطريقة آمنة.

الاختبارات Testing: بدون إطار رسمي

لا يوجد في C إطار اختبارات قياسي مثل go test أو pytest، وأبسط طريقة هي التي استخدمناها: برنامج عادي يستدعي الدوال ويتحقق بالماكرو assert من assert.h، فإذا فشل شرط أوقف البرنامج وطبع الملف والسطر:

#include <assert.h>
#include <stdio.h>

#include "vat.h"

int main(void) {
    // 9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16
    assert(vat_halalas(2970) == 446);
    assert(total_with_vat(2970) == 3416);
    assert(vat_halalas(0) == 0);
    assert(vat_halalas(1) == 0);     // 0.15 halala rounds down
    assert(vat_halalas(10) == 2);    // 1.5 halalas rounds up
    assert(vat_halalas(100) == 15);
    puts("test_vat: all checks passed");
    return 0;
}

ولهذه الطريقة عيبان يجب أن تعرفهما. الأول أن assert يوقف البرنامج عند أول فشل، فلا ترى بقية النتائج. والثاني أخطر، وقد وقعنا فيه في هذا المختبر نفسه: لاحظ أننا بنينا بـ CMake بالخيار -DCMAKE_BUILD_TYPE=Release، ومر الاختبار. ولكي نتأكد أن الاختبار يكشف شيئاً أفسدنا الدالة عمداً، فجعلنا النسبة 14 بدلاً من 15، وبنينا مرة Release ومرة Debug:

--- Release
-O3 -DNDEBUG
1/1 Test #1: vat ..............................   Passed    0.01 sec
100% tests passed, 0 tests failed out of 1
test_vat: all checks passed
--- Debug
-g
1/1 Test #1: vat ..............................Subprocess aborted***Exception:   0.35 sec
0% tests passed, 1 tests failed out of 1
test_vat: /tmp/h/tests/test_vat.c:9: main: Assertion `vat_halalas(2970) == 446' failed.

بناء Release في CMake يضيف افتراضياً -O3 -DNDEBUG، والماكرو NDEBUG يجعل كل سطر assert يختفي من الكود قبل الترجمة، فمر الاختبار بدالة خاطئة وطبع بثقة all checks passed. أما بناء Debug فأوقف البرنامج عند أول فحص، وذكر الملف والسطر والشرط. والدرس أن الاختبار الذي لم تره يفشل مرة واحدة على الأقل لا تعرف هل يختبر شيئاً، وأن assert أداة للتحقق من افتراضات الكود أثناء التطوير، لا إطار اختبارات. ولذلك نستخدم في مقال الخدمة ماكرو صغيراً خاصاً بنا يعد الفحوص والإخفاقات ويكمل بعد الفشل. وإذا كبر المشروع فهناك أطر معروفة مثل Unity وcmocka وCriterion، وكلها تضيف التجميع والتقارير وتشغيل اختبار واحد بالاسم، وأنواع الاختبارات نفسها شرحناها في مقال أنواع الاختبارات.

المصحح Debugger: جلسة قصيرة مع gdb

في اللغات التي فيها استثناءات ترى مسار الاستدعاء Stack Trace عندما يحدث خطأ، أما في C فإن البرنامج إذا انهار قال لك Segmentation fault فقط. ولهذا فالمصحح gdb أداة يومية لمطور C، وقد شرحنا أساسيات التصحيح وقراءة مسار الاستدعاء في مقال قراءة رسالة الخطأ والتصحيح Debugging. وهذه جلسة غير تفاعلية توقف البرنامج عند دالة الضريبة، وتطبع المتغير ومسار الاستدعاء، ثم تكمل حتى ترجع الدالة:

gcc -g -O0 -Ihalala/include halala/src/main.c halala/src/vat.c -o /tmp/halala-dbg
gdb -q -batch -ex 'break vat_halalas' -ex 'run' -ex 'print subtotal' -ex 'bt' -ex 'finish' --args /tmp/halala-dbg 990 3
Breakpoint 1 at 0x40138a: file halala/src/vat.c, line 6.
warning: Error disabling address space randomization: Operation not permitted
Breakpoint 1, vat_halalas (subtotal=2970) at halala/src/vat.c:6
6	    return (subtotal * 15 + 50) / 100;
$1 = 2970
#0  vat_halalas (subtotal=2970) at halala/src/vat.c:6
#1  0x0000000000401226 in main (argc=3, argv=0x7fffe718af48) at halala/src/main.c:19
0x0000000000401226 in main (argc=3, argv=0x7fffe718af48) at halala/src/main.c:19
19	    int64_t vat = vat_halalas(subtotal);
Value returned is $2 = 446

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

  • الخيار -g عند الترجمة هو الذي جعل gdb يعرف اسم الملف vat.c ورقم السطر 6 واسم المتغير subtotal، و-O0 يمنع التحسين لكي يبقى كل متغير في مكانه. وفي بناء محسن بـ -O2 سوف ترى كثيراً <optimized out> بدلاً من القيمة.
  • الأمر bt أي Backtrace طبع مسار الاستدعاء: نحن في vat_halalas التي استدعتها main من السطر 19، ومعها قيم المعاملات.
  • الأمر finish أكمل حتى رجعت الدالة، وطبع القيمة الراجعة 446.
  • والتحذير Error disabling address space randomization: Operation not permitted مشكلة حقيقية ستراها إذا شغلت gdb داخل Container. فالمصحح يحاول إيقاف عشوائية العناوين ASLR لكي تتكرر العناوين بين التشغيلات، وإعدادات الأمان الافتراضية في Docker تمنعه، فيكمل مع العشوائية. والجلسة تعمل كما ترى، والفرق الوحيد أن العناوين تتغير بين التشغيلات، فإذا احتجت عناوين ثابتة فشغل الـ Container بالخيار --security-opt seccomp=unconfined على جهاز التطوير فقط.

مشكلة واجهتنا: الأداة قبلت أرقاماً خاطئة بصمت Input Parsing

بعد أن مر كل شيء، جربنا الأداة كما سيستخدمها شخص على عجل في المستودع، فكتب الكمية بالحروف مرة، وكتب السعر بالريال بدلاً من الهللات مرة أخرى:

./build/halala 990 three
./build/halala 9.90 3
subtotal 0.00 SAR, VAT 0.00 SAR, total 0.00 SAR
subtotal 0.27 SAR, VAT 0.04 SAR, total 0.31 SAR

لا خطأ ولا تحذير ولا رمز خروج غير صفر. ففي الحالة الأولى لم تجد strtol أي رقم في three فأرجعت 0، والفاتورة صارت صفراً. وفي الثانية قرأت 9 وتوقفت عند النقطة، فصار السعر 9 هللات بدلاً من 990، والإجمالي 31 هللة. ولو كانت هذه الأداة تطبع فواتير حقيقية لخرج العميل بتمر وقهوة بأقل من ريال. والسبب أننا كتبنا strtol(argv[i], NULL, 10)، أي أننا أعطينا الدالة NULL في المكان الذي تخبرنا فيه أين توقفت عن القراءة، فرمينا المعلومة الوحيدة التي تكشف الخطأ. وهذا هو الدرس الأهم عن C في هذا المقال: اللغة لا ترمي استثناءً عندما يفشل شيء، وإنما تعطيك قيمة ومعلومات بجانبها، وإذا لم تنظر إليها فلن يخبرك أحد. وقد شرحنا الطرق المختلفة لمعالجة الأخطاء بين اللغات في مقال كيف تتعامل لغات البرمجة مع الأخطاء، وفي المقال التالي من هذه المرحلة سوف نكتب دالة قراءة صحيحة ونرى رسائل errno الحقيقية لكل حالة.

وماذا يحدث إذا غيرت مستوى التحسين؟ بنينا نفس الأداة بالخيارات -O0 و-O2 و-Os، فكان حجم الملف 15928 بايت في الثلاثة، وبعد حذف الرموز بالأمر strip صار 14320 بايت. والسبب أن البرنامج صغير جداً، وأغلب حجم الملف رؤوس ELF وأقسام تحاذى على حدود الصفحات، فالكود الفعلي بضع مئات من البايتات لا تغير الصفحة. ففي برنامج بهذا الحجم لا معنى لمقارنة مستويات التحسين بالحجم، والفرق يظهر في المشاريع الكبيرة وفي السرعة، وهو ما سنقيسه في مقال التزامن.

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

  • الترجمة بدون -Wall -Wextra: الملف warn.c بثلاثة أخطاء ترجم برمز 0 وأعطى الخصم لكل العملاء.
  • ترك المعيار للمترجم: GCC 16 يعمل بـ C23 وClang 19 بـ C17، ونفس الملف ترجم في الأول وأعطى خمسة أخطاء في الثاني.
  • العودة إلى معيار قديم لإسكات خطأ الإعلان الضمني بدلاً من إضافة #include.
  • الخلط بين أخطاء المترجم وأخطاء الرابط: undefined reference تعني ملفاً أو مكتبة ناقصة في أمر الربط، وليس خطأ في الكود.
  • نسيان ملفات الترويسة في اعتماديات Makefile اليدوي، فلا يعاد بناء ما يجب بناؤه.
  • تجاهل compile_commands.json، فتظهر في المحرر وفي clang-tidy أخطاء وهمية لأن الأداة لا تعرف الخيارات.
  • الاعتماد على assert في الاختبارات مع بناء Release: الاختبار مر بدالة ضريبة خاطئة لأن -DNDEBUG حذف كل الفحوص.
  • تجاهل المعلومات التي تعطيها الدوال عن الفشل، مثل مؤشر النهاية في strtol: الأداة قبلت three صفراً و9.90 تسع هللات.
  • تخزين المال في float أو double بدلاً من عدد صحيح بالهللات.
  • نقل ملف تنفيذي مبني على glibc حديثة إلى سيرفر أقدم، ثم الاستغراب من GLIBC_... not found، والحل البناء على أقدم نظام ستشغل عليه، أو داخل Container بنفس النظام.

قائمة تحقق Checklist لمشروع C جديد

  • المعيار محدد في ملف البناء: set(CMAKE_C_STANDARD 23) أو -std=c17.
  • التحذيرات: -Wall -Wextra -Wpedantic، ومعها -Werror في خط الاختبارات الآلية.
  • هيكل واضح: include/ للواجهة وsrc/ للتنفيذ وtests/ للاختبارات، وحارس في كل ملف ترويسة، وstatic لكل دالة داخلية.
  • CMake مع CMAKE_EXPORT_COMPILE_COMMANDS، والبناء في مجلد منفصل.
  • ملف .clang-format في المشروع، وفحص clang-format --dry-run --Werror قبل الدمج.
  • clang-tidy أو Cppcheck في خط الاختبارات، بمجموعة فحوص يتفق عليها الفريق.
  • اختبارات تعمل بـ ctest، ولا تعتمد على assert وحده.
  • قائمة مكتوبة بالمكتبات الخارجية وإصداراتها ومصدرها، لأن C لا تملك ملف قفل Lock File يحفظها لك.
  • البناء داخل Container بنفس نظام الإنتاج، لكي لا تفاجئك نسخة glibc.

تدريب عملي Practice

  • عدل main.c لكي تقرأ الأرقام بـ strtol مع مؤشر النهاية، وترفض three و9.90 برسالة واضحة ورمز خروج 2، ثم أضف لذلك اختباراً.
  • ابن المشروع بـ cmake -DCMAKE_C_COMPILER=clang مع حذف السطر set(CMAKE_C_STANDARD 23)، ثم أضف إلى vat.c كلمة constexpr، ولاحظ متى يفشل البناء ولماذا.
  • احذف include/vat.h من القاعدة build/%.o في Makefile، ثم غير توقيع الدالة في الترويسة، وشغل make ولاحظ ما لا يعاد بناؤه.

الخلاصة

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

  • C معيار دولي آخر إصدار منه C23، والمترجمات تطبقه، وعلى نفس الجهاز يعمل GCC 16.2 بـ C23 افتراضياً وClang 19.1.7 بـ C17، فحدد المعيار دائماً.
  • الأمر gcc يشغل أربع مراحل: المعالج المسبق حول ستة أسطر إلى 824، والمترجم استبدل printf بـ puts، والمجمع ترك puts رمزاً غير معرف، والرابط أخرج 15688 بايت ديناميكياً أو 937144 ثابتاً.
  • بدون -Wall -Wextra تترجم أخطاء واضحة بصمت، والإعلان الضمني للدوال صار خطأ منذ GCC 14.
  • لا يوجد هيكل مفروض ولا مدير حزم رسمي، والمعتاد واجهة في include/ وتنفيذ في src/، وCMake للبناء، وحزم النظام مع pkg-config أو vcpkg وConan.
  • clang-format للتنسيق، وتحذيرات المترجم مع clang-tidy وCppcheck للفحص، وكل أداة رأت نفس الخطأ من زاوية مختلفة.
  • الاختبار برنامج يخرج برمز 0، وctest يجمعها، وassert يختفي في بناء Release فمر اختبار دالة خاطئة، وgdb يحتاج -g لكي يعرف الأسماء والسطور.
  • C لا ترمي استثناءات، والأداة قبلت three صفراً لأننا تجاهلنا مؤشر النهاية في strtol، وهذا موضوع المقال التالي.

وفي مرحلة لغة C للمطورين تكمل المقالات التالية من حيث توقفنا: الأنواع والمؤشرات والذاكرة والأخطاء، ثم التزامن بالخيوط، ثم خدمة طلبات كاملة، ثم الأخطاء الشائعة وأدوات كشفها:

دليلالرابط Linker والمحمل Loader: ماذا يحدث قبل main؟نبني مكتبة ضريبة صغيرة بلغة C ونربطها ثابتاً وديناميكياً، ونقرأ ملفاتها بالأدوات readelf وnm وobjdump حتى نفهم من أين تأتي أخطاء undefined reference والمكتبة الناقصة وقت التشغيل.دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ كل الأوامر داخل Container مبني على الصورة الرسمية gcc:16.2.0-trixie مع GCC 16.2.0 وClang 19.1.7 وCMake 3.31.6 وGNU Make 4.4.1 وglibc 2.41 من Debian 13، على Docker Engine 29.8.0.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.