لنفرض أنك مطور تكتب بلغة 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 لكي يبنى على كل الأنظمة.

تثبيت المترجم 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 /labdocker 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__ = 201710LGCC 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/touritems=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 بايت وlong8 بايت على 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 بيدك |
| الوحدات Modules | import أو package | ملف .h بـ #include وملف .c يربط |
| الخصوصية | private أو حرف صغير في Go | static على مستوى الملف |
| القيمة الفارغة | None أو null أو nil | NULL، و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-static824 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 صغير جداً أو أداة تريد نقلها بين توزيعات مختلفة.

والفائدة العملية لهذه الصورة أنك تعرف من رسالة الخطأ أي مرحلة فشلت. فإذا قالت الرسالة 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 الذي يحذر من تحويل الأعداد بين الأحجام. وفي مقال الخدمة سوف نبني بكل هذه الخيارات معاً، وفي ملف الخدمة كاملاً لم يظهر أي تحذير، وهذا هو الهدف.

ماذا يحدث إذا نسيت ملف الترويسة 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 هللة. وسوف تجد هذه الدالة نفسها في خدمة الطلبات في مقال الخدمة من هذه المرحلة، وفي كل لغات السلسلة.

البناء بالأداة 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 عليك أن تحتفظ بقائمة الاعتماديات وإصداراتها بنفسك.

التنسيق 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.cint64_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 3Breakpoint 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 3subtotal 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.