التعامل مع الملفات
درس 2 من 2
2التعامل مع الملفات (C - File I/O)
الانتقال من التعامل مع الذاكرة فقط إلى التعامل المباشر مع نظام التشغيل لقراءة وكتابة الملفات، عبر استدعاءات نظام (system calls) منخفضة المستوى بدل دوال المكتبة القياسية المعتادة مثل fopen.
ماذا تشاهد في الرسم: open يعيد واصفًا مثل 3 (لأن 0 و1 و2 للمدخل والمخرج والأخطاء)، وكل read وwrite تستخدمه، ثم close يحرّره.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
حتى الآن كنت تطلب من مكتبة لغة C أن تقرأ الملف عنك، مثل أن تطلب من موظف إحضار ملف من الأرشيف. الآن تذهب بنفسك إلى شباك الأرشيف وتتعامل مع النظام مباشرة: تفتح، وتقرأ عددًا محددًا من البايتات، وتكتب، وتغلق.
استدعاءات النظام open وread وwrite وclose تعمل بواصف ملف file descriptor رقمي، وأعلام مثل O_RDONLY وO_CREAT وصلاحيات مثل 0644. read قد يعيد أقل مما طلبت، فتقرأ في حلقة وتفحص القيمة المعادة وerrno في كل خطوة.
قبل أن تبدأ
🐚استدعاءات النظام
راجع مفهوم استدعاء النظام (system call) كواجهة مباشرة مع نواة نظام التشغيل، نفس الفكرة المستخدمة في fork و execve بدليل مشاريع التكامل. الفرق الجوهري: استدعاء النظام ينقل التنفيذ من وضع المستخدم (user mode) إلى وضع النواة (kernel mode)، وهذا الانتقال أبطأ من استدعاء دالة مكتبة عادية.
🧵المؤشرات والمخازن المؤقتة
استدعاءات read() و write() تتعامل مع مخزن مؤقت (buffer) هو عمليًا مصفوفة char ومؤشر إليها، تحتاج فهمًا أساسيًا لكيف تُمرّر المصفوفات كمؤشرات في C وكيف يُحدَّد حجمها بـ sizeof.
🔢العمليات البتّية (Bitwise Operations)
أعلام الفتح مثل O_WRONLY | O_CREAT | O_TRUNC تُدمج بعملية OR البتّية، راجع كيف تعمل | و & على مستوى البتات لفهم كيف تُقرأ هذه الأعلام وتُركَّب معًا.
الفكرة الجوهرية: أربعة استدعاءات تبني أي عملية ملف
على عكس fopen/fread (دوال مكتبة قياسية تُضيف طبقة تخزين مؤقت)، استدعاءات النظام الأربعة التالية تتعامل مباشرة مع نواة النظام:
شكل 1، كل عملية ملف تمر بهذه الحلقة الثلاثية: فتح، ثم قراءة/كتابة (قد تتكرر عدة مرات)، ثم إغلاق.
1واصف الملف (File Descriptor)
عدد صحيح صغير يُرجعه open() ويُمثّل "مقبض" (handle) للملف المفتوح، كل استدعاءات القراءة والكتابة والإغلاق اللاحقة تستخدم هذا الرقم للإشارة لنفس الملف. عمليًا، كل عملية (process) تملك جدول واصفات ملفات خاصًا بها، مصفوفة صغيرة من الأعداد الصحيحة (0 و1 و2 محجوزة مسبقًا لـ stdin و stdout و stderr). كل خلية في هذا الجدول تُشير إلى إدخال في جدول الملفات المفتوحة الخاص بنواة النظام، وهذا الإدخال بدوره يحمل موضع القراءة/الكتابة الحالي (offset) ومؤشرًا لبيانات الملف الفعلية على القرص. لهذا لو فتحت نفس الملف مرتين باستدعاءين منفصلين لـ open()، ستحصل على واصفين مختلفين بإزاحتين مستقلتين تمامًا، حتى لو كانا يشيران لنفس الملف فعليًا.
2أعلام الفتح (Flags)
O_RDONLY للقراءة فقط، O_WRONLY للكتابة فقط، O_CREAT لإنشاء الملف إن لم يكن موجودًا، O_TRUNC لتفريغ محتواه، تُدمج هذه الأعلام معًا بعملية OR البتّية. تعمل هذه العملية لأن كل علم مُعرَّف كبتّة مختلفة ضمن نفس العدد الصحيح (قوى الاثنين: 1، 2، 4...)، فدمجها بـ | يُشغّل كل بتّة مطلوبة في الوقت نفسه دون أن تطغى واحدة على الأخرى، والنواة تتفقّد كل بتّة على حدة لتقرر طريقة الفتح. لاحظ أن O_RDONLY غالبًا قيمته صفر، فهو ليس بتّة حقيقية ولا يمكن دمجه مع O_WRONLY (نمط الوصول نفسه لا يمكن أن يكون قراءة وكتابة معًا بدون O_RDWR)، بعكس O_CREAT و O_TRUNC اللذين يعملان كـ"إضافات" يمكن تركيبها فوق أي نمط وصول.
3القيمة المُرجعة
read() و write() يُرجعان عدد البايتات التي فعلاً تمت قراءتها/كتابتها، قد يكون أقل من المطلوب، و-1 تعني فشل العملية. السبب أن الدالتين لا تضمنان نقل العدد الكامل من البايتات في استدعاء واحد: القراءة قد تتوقف عند نهاية الملف فتُرجع عددًا أقل من حجم المخزن المؤقت المطلوب، وقد تُقطع كلتاهما بإشارة (signal) قبل اكتمالها، كما أن الأنابيب (pipes) والمقابس (sockets) قد لا تحتوي إلا على جزء من البيانات المطلوبة في لحظة الاستدعاء. لهذا، أي كود يحتاج نقل عدد محدد من البايتات بشكل موثوق يجب أن يستدعي read()/write() داخل حلقة تتابع النقل حتى يكتمل العدد المطلوب أو تصل لنهاية الملف.
📍الإزاحة (lseek)
lseek(fd, offset, whence) يُحرّك موضع القراءة/الكتابة داخل الملف دون قراءة أو كتابة أي بايت فعليًا. whence يحدد نقطة البداية: SEEK_SET من بداية الملف، SEEK_CUR من الموضع الحالي، SEEK_END من نهاية الملف. استدعاء lseek(fd, 0, SEEK_END) يُرجع حجم الملف بالبايتات، طريقة شائعة لمعرفة الحجم قبل تخصيص ذاكرة لقراءته.
مقارنة: استدعاءات النظام مقابل دوال المكتبة القياسية
| المعيار | استدعاءات النظام (open/read/write/close) | دوال المكتبة (fopen/fread/fwrite/fclose) |
|---|---|---|
| التخزين المؤقت | لا تخزين مؤقت، كل read()/write() ينتقل مباشرة للنواة | تخزين مؤقت تلقائي (buffer) في ذاكرة المستخدم، يقلل عدد استدعاءات النظام الفعلية بشكل كبير |
| الأداء | أبطأ عند القراءات الصغيرة المتكررة (كل بايت = استدعاء نظام كامل) | أسرع للقراءات الصغيرة المتكررة بفضل التخزين المؤقت، لكن أبطأ للنقل المباشر لكتل كبيرة |
| التحكم | تحكم كامل: واصفات ملفات، أعلام دقيقة، إزاحة يدوية بـ lseek | أبسط استخدامًا لكن تحكم أقل، لا تستطيع الوصول لواصف الملف مباشرة بدون fileno() |
كيف يعمل، خطوة بخطوة: نسخ محتوى ملف
تطبيقًا مباشرًا على هذا النمط الرباعي، فيما يلي دالة تنسخ محتوى ملف كامل من مصدر إلى وجهة باستخدام الاستدعاءات الأربعة مباشرة بدل fopen. تتبع الدالة بالضبط هذا التسلسل: فتح المصدر للقراءة فقط، فتح الوجهة (بإنشائها إن لم تكن موجودة وتفريغ محتواها إن كانت موجودة) للكتابة فقط، ثم حلقة قراءة/كتابة متكررة، وأخيرًا إغلاق كلا الواصفين. لاحظ أن استدعائي open() هنا مستقلان تمامًا عن بعضهما: كل واحد منهما يُنشئ إدخالًا جديدًا في جدول الملفات المفتوحة الخاص بالنواة، بإزاحة (offset) تبدأ من صفر خاصة به، فحتى لو كان المصدر والوجهة نفس الملف فعليًا (حالة نادرة لكن ممكنة)، فإن fd_src و fd_dst سيتحركان بإزاحتين منفصلتين تمامًا دون أن يؤثر أحدهما على الآخر، تمامًا كما شرحنا في بطاقة واصف الملف أعلاه.
#include <fcntl.h>
#include <unistd.h>
int copy_file(const char *src, const char *dst)
{
int fd_src, fd_dst;
char buf[1024];
ssize_t n;
fd_src = open(src, O_RDONLY);
if (fd_src == -1)
return (-1);
fd_dst = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd_dst == -1)
{
close(fd_src);
return (-1);
}
while ((n = read(fd_src, buf, sizeof(buf))) > 0)
write(fd_dst, buf, n);
close(fd_src);
close(fd_dst);
return (0);
}
شكل 2، لاحظ حلقة while: القراءة تتم على دفعات بحجم المخزن المؤقت (buffer) بدل قراءة الملف كله دفعة واحدة، لأن حجم الملف قد يتجاوز الذاكرة المتاحة.
شكل 3، حلقة النسخ تتكرر دفعة واحدة في كل مرة بحجم المخزن المؤقت فقط؛ تتوقف تلقائيًا بمجرد أن تُرجع read() القيمة صفر، أي أنها وصلت لنهاية الملف المصدر.
القيمة 0644 الممرّرة كوسيط ثالث لـ open() لا تدخل الحساب أصلًا إلا إذا كانت O_CREAT ضمن الأعلام فعليًا، فهي صلاحيات الملف الجديد في حال اضطر open() لإنشائه، مُعبَّرة بنظام الأعداد الثُمانية (octal): الرقم الأول (6) للمالك (قراءة وكتابة)، والثاني والثالث (4 و4) للمجموعة وبقية المستخدمين (قراءة فقط)، وحتى هذه القيمة لا تُطبَّق كما هي حرفيًا، بل تمر أولًا عبر قناع umask الخاص بالعملية (العملية الفعلية bitwise AND مع معكوس umask) قبل أن تتحول لصلاحية نهائية فعلية على القرص. أما حلقة while فتعتمد كليًا على القيمة التي تُرجعها read(): قيمة أكبر من صفر تعني عدد البايتات التي فعلًا قُرئت في هذه الدفعة بعينها (قد يكون أقل من 1024 إن اقترب المصدر من نهايته)، والصفر تحديدًا، وليس أي قيمة سالبة، هو العلامة الرسمية لنهاية الملف (EOF)، بينما -1 تعني فشل القراءة بخطأ حقيقي. لاحظ أيضًا أن المثال أعلاه، للتبسيط، لا يتحقق من القيمة التي تُرجعها write() بالمثل؛ كود جاهز للإنتاج يحتاج فعليًا للتحقق من كلا الاستدعاءين داخل حلقة كاملة، تمامًا كما نوّهنا في بطاقة "القيمة المُرجعة" أعلاه.
شكل 4، كل استدعاء open() منفصل، حتى من نفس العملية وعلى نفس الملف، يُنشئ إدخالًا مستقلًا بإزاحته الخاصة في جدول الملفات المفتوحة للنواة؛ هذا الإدخال فقط هو ما يحمل مؤشرًا إلى inode الملف الفعلي الذي يحوي بياناته الحقيقية على القرص.
مثال تطبيقي: قراءة ملف نصي سطرًا بسطر
مثال عملي آخر على نفس النمط: دالة تقرأ ملفًا نصيًا وتطبع كل سطر مع رقمه. التحدي هنا أن read() لا تفهم مفهوم "سطر"، هي تنقل بايتات فقط، فعلينا البحث يدويًا عن محرف السطر الجديد '\n' داخل المخزن المؤقت:
#include <fcntl.h>
#include <unistd.h>
void print_lines(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd == -1)
return;
char buf[4096];
ssize_t n;
int line_num = 1;
int col = 0; /* موضعنا في السطر الحالي */
/* نطبع رقم أول سطر */
char header[16];
int hlen = 0;
int tmp = line_num;
/* تحويل رقم السطر لنص */
header[hlen++] = '0' + tmp;
header[hlen++] = ':';
header[hlen++] = ' ';
write(1, header, hlen);
while ((n = read(fd, buf, sizeof(buf))) > 0)
{
ssize_t i;
for (i = 0; i < n; i++)
{
write(1, &buf[i], 1);
if (buf[i] == '\n')
{
line_num++;
hlen = 0;
tmp = line_num;
if (tmp >= 10) header[hlen++] = '0' + tmp / 10;
header[hlen++] = '0' + tmp % 10;
header[hlen++] = ':';
header[hlen++] = ' ';
write(1, header, hlen);
}
}
}
write(1, "\n", 1);
close(fd);
}
لاحظ أن الدالة تقرأ الملف بكتل كبيرة (4096 بايت) لتقليل عدد استدعاءات النظام، لكنها تعالج كل بايت فرديًا للبحث عن '\n'. هذا النمط (قراءة بكتل + معالجة بالبايت) شائع جدًا في أدوات النظام، cat -n و grep و wc كلها تعمل بنفس المبدأ تقريبًا.
write() أيضًا.
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| استدعاءات النظام دائمًا أفضل من دوال المكتبة | ليس بالضرورة، fread()/fwrite() تُضيف تخزينًا مؤقتًا (buffering) يقلل عدد الرحلات الفعلية للنواة بشكل كبير. قراءة ملف بايتًا ببايت بـ read() تُجري استدعاء نظام لكل بايت (بطيء جدًا)، بينما fgetc() تقرأ كتلة كاملة مرة واحدة وتُرجع منها بايتًا بعد آخر من ذاكرة المستخدم. |
| إغلاق الملف اختياري، البرنامج يُنظّف كل شيء عند الخروج | نظام التشغيل يُغلق واصفات الملفات عند انتهاء العملية فعلًا، لكن الاعتماد على ذلك عادة سيئة: البرنامج قد يفتح ملفات في حلقة (فتستنفد الواصفات قبل أن ينتهي)، والبيانات المخزّنة مؤقتًا في FILE* قد لا تُكتب للقرص إن لم تُغلق بشكل صريح. |
| واصف الملف 0 أو 1 أو 2 لا يمكن إغلاقه أو إعادة فتحه | يمكن إغلاقه تمامًا كأي واصف آخر، وهذه بالضبط الآلية المستخدمة لإعادة التوجيه (redirection): تُغلق stdout (الواصف 1)، ثم تفتح ملفًا جديدًا، النظام يمنحه أول رقم متاح، وهو 1 الذي أُغلق للتو، فيصبح كل ما يُكتب على stdout يذهب لذلك الملف. |
تطبيقات عملية
📝ملفات السجل (Log Files)
أي خادم ويب أو تطبيق خلفي يكتب أحداثه في ملفات سجل، فتح الملف بـ O_WRONLY | O_APPEND | O_CREAT يضمن أن كل كتابة جديدة تُضاف في النهاية دون محو ما سبق، حتى لو كتبت عدة عمليات (processes) في نفس الملف.
⚙️ملفات الإعدادات
قراءة ملف .conf أو .ini أو .json وتحليله سطرًا بسطر (parsing)، نمط open → read في حلقة → close بالضبط. برامج مثل nginx و ssh تعتمد على هذا النمط عند بدء التشغيل.
🔄نسخ الملفات وأدوات النظام
أوامر مثل cp و dd و cat كلها مبنية على نفس حلقة read → write التي رأيناها في مثال copy_file، يقرأون كتلة، يكتبونها، ويكررون حتى EOF.
🗄️محركات قواعد البيانات
محركات مثل SQLite تقرأ وتكتب مباشرة في ملف واحد على القرص، تستخدم lseek للقفز لصفحات محددة، و read/write لنقل البيانات، وتدير التخزين المؤقت بنفسها بدل الاعتماد على طبقة stdio.
تعمّق: طبقات الإدخال والإخراج والتحقق الآمن وترتيب البايتات
إضافة إلى ما سبق عن الواصفات والأعلام، نكمل بثلاثة أمور: طبقات الإدخال والإخراج، نمط التحقق الآمن من كل نداء، وكيف يؤثر ترتيب البايتات عند كتابة بيانات ثنائية (مفهوم التحزيم).
طبقات الإدخال والإخراج
الدالة العادية تعمل بالكامل داخل برنامجك. أما نداء النظام فهو طلب إلى النواة، لأن النواة وحدها تملك صلاحية لمس القرص. read و write و open و close نداءات نظام. أما printf فدالة مكتبة: تجمع النص في مخزن ثم تستدعي write في الخلف. لذلك يمنع المشروع دوال المكتبة ليُريك ما تحتها.
شكل 5، طبقات الإدخال والإخراج: دوال المكتبة فوق نداءات النظام فوق النواة.
قراءة الصلاحيات الثمانية
الصلاحيات ثلاث مجموعات: المالك، المجموعة، الآخرون. لكل مجموعة قراءة 4 وكتابة 2 وتنفيذ 1 تُجمع في رقم واحد. فـ0644 تعني: المالك 6 (قراءة وكتابة)، المجموعة 4، الآخرون 4. و 0600 للمالك وحده.
| الرقم الثماني | الرموز | المعنى |
|---|---|---|
| 7 | rwx | كل شيء |
| 6 | rw- | قراءة وكتابة |
| 5 | r-x | قراءة وتنفيذ |
| 4 | r-- | قراءة فقط |
| 0 | --- | لا شيء |
نمط التحقق الآمن
شكل 6، نمط التعامل الآمن مع ملف: افحص كل نداء، وأغلق دائمًا في النهاية.
ترتيب البايتات والتحويل (Marshaling)
العدد الذي يتجاوز بايتًا واحدًا يُخزَّن كعدة بايتات، وهناك طريقتان لترتيبها. في Little Endian (معالجات x86 الشائعة) يُخزَّن البايت الأقل أهمية أولًا في العنوان الأدنى. في Big Endian يُخزَّن الأعلى أهمية أولًا. الرقم 0x12345678 يظهر في الذاكرة كالتالي:
| العنوان | +0 | +1 | +2 | +3 |
|---|---|---|---|---|
| Little Endian | 78 | 56 | 34 | 12 |
| Big Endian | 12 | 34 | 56 | 78 |
إن كتبت int خامًا في ملف أو أرسلته عبر الشبكة إلى جهاز مختلف الترتيب فسيقرؤه خطأ. الحل هو الاتفاق على ترتيب واحد: ترتيب الشبكة وهو Big Endian. تتولى دوال مثل htonl و htons التحويل من ترتيب الجهاز إلى ترتيب الشبكة قبل الإرسال، و ntohl و ntohs العكس عند الاستلام. هذه العملية تسمى التحزيم (Marshaling). أما الملفات النصية فلا تعاني هذه المشكلة لأن كل حرف بايت واحد.
شكل 7، التحزيم: يتفق الطرفان على ترتيب واحد في الوسط مهما كان ترتيب كل منهما.
جرّب بنفسك
ماذا يمثّل واصف الملف الذي تعيده open()؟
كل العمليات اللاحقة (read وwrite وclose) تستعمل هذا الرقم. 0 و1 و2 محجوزة لـ stdin وstdout وstderr.
فتحت الملف نفسه مرتين بنداءين open منفصلين. ماذا تحصل؟
كل open ينشئ إدخالًا جديدًا في جدول الملفات المفتوحة مع إزاحته الخاصة من الصفر.
متى تتوقف حلقة النسخ التي تعتمد على read()؟
read تعيد عدد البايتات المقروءة؛ 0 تعني نهاية الملف، وقيمة سالبة تعني خطأ، وما بينهما بيانات نكتبها.
بطاقات مراجعة سريعة
تحقق من فهمك
لو نسيت O_CREAT عند فتح ملف الوجهة في مثال copy_file، ماذا يحدث لو كان الملف غير موجود؟
سيفشل open() ويُرجع -1، لأن O_WRONLY وحدها تطلب فتح ملف موجود مسبقًا للكتابة فيه، لا تُنشئه إن لم يكن موجودًا.
لماذا نتحقق من القيمة المُرجعة من read() بدل استخدام feof()؟
لأن feof() دالة مكتبة قياسية تعمل مع FILE* فقط (نتيجة fopen())، بينما read() استدعاء نظام يُرجع واصف ملف عددي. الطريقة الوحيدة لمعرفة نهاية الملف مع read() هي أن تُرجع القيمة صفر.
لو فتحت نفس الملف مرتين بـ open()، هل يتأثر موضع القراءة في أحدهما بالقراءة من الآخر؟
لا، كل استدعاء open() يُنشئ إدخالًا مستقلًا تمامًا في جدول الملفات المفتوحة للنواة، بإزاحة (offset) خاصة به. القراءة من واصف لا تُحرّك إزاحة الواصف الآخر أبدًا، حتى لو كانا يشيران لنفس الملف الفعلي على القرص.
تكتب بيانات ثنائية على جهاز Little Endian لتقرأها على جهاز Big Endian؛ ماذا تفعل؟
اتفق على ترتيب الشبكة: حوّل بـ htonl عند الكتابة وبـ ntohl عند القراءة.
طلبت كتابة 100 بايت فأعادت write القيمة 60؛ ما الخطأ؟
كتابة جزئية؛ يجب أن يتحقق برنامجك من العدد وأن يعيد المحاولة أو يعلن الفشل.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي3
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.