أول برنامج يكتبه أغلبنا هو برنامج يطبع جملة Hello, World على الشاشة، وهو برنامج من سبعة أسطر نكتبه ونشغله في أقل من دقيقة ثم ننتقل إلى ما بعده، ولكن خلف هذه الدقيقة تعمل طبقات كثيرة لا نراها: محرر النصوص Text Editor الذي حول ضغطات المفاتيح إلى بايتات، والمترجم Compiler الذي حول النص إلى تعليمات يفهمها المعالج، والصدفة Shell التي طلبت من النظام تشغيل البرنامج، ونواة نظام التشغيل Kernel التي حملته في الذاكرة، والمعالج CPU الذي نفذه، ثم الطرفية Terminal التي حولت البايتات إلى حروف ورسمتها بكسلات على الشاشة. وكل طبقة من هذه الطبقات جاءت لتحل مشكلة محددة، وعندما تفهم هذه المشكلات يصبح كثير مما تراه في عملك اليومي مفهوماً: لماذا يعمل البرنامج على جهازك ولا يعمل داخل Container، ولماذا تظهر رسالة exec format error على سيرفر ARM، ولماذا يبطئ برنامجك عندما يطبع كثيراً، وكيف تقرأ مخرج strace عندما يتعطل شيء ولا تعرف السبب.
وفكرة تتبع برنامج Hello World عبر النظام كله فكرة معروفة في تعليم علوم الحاسب، وأشهر من قدمها الفصل الأول من كتاب Computer Systems: A Programmer's Perspective لمؤلفيه Randal Bryant وDavid O'Hallaron، وهو كتاب ننصح به لمن أراد التعمق. أما هذا المقال فهو شرحنا نحن بأمثلتنا ومخرجاتنا، حيث نفذنا كل أمر فيه داخل Container على Ubuntu 24.04 وألصقنا المخرج الحقيقي كما ظهر، وسوف نناقش في هذا المقال ما يلي:
- الصورة الكاملة: المحطات السبع التي يمر بها البرنامج من لوحة المفاتيح إلى الشاشة.
- الملف المصدري Source File كبايتات، والفرق بين ASCII وUTF-8 عندما نكتب كلمة عربية.
- مراحل البناء Build الأربع داخل الأمر
gcc: المعالجة المسبقة Preprocessing والترجمة Compilation والتجميع Assembly والربط Linking، ثم شكل الملف التنفيذي ELF. - الفرق بين اللغات المترجمة Compiled والمفسرة Interpreted ولغات ال JIT، بالبرنامج نفسه في Python.
- ماذا يحدث عندما تكتب
./hello: النسخforkوالاستبدالexecveوالتحميل في الذاكرة الافتراضية Virtual Memory. - المعالج من الداخل: المسجلات Registers وعداد البرنامج Program Counter وهرم الذاكرة Memory Hierarchy بأرقام من مصدر معروف.
- كيف تصل الجملة إلى الشاشة: من
printfإلى استدعاء النظامwriteثم الطرفية والخطوط Fonts والبكسلات، ولماذا تظهر العربية متصلة ومن اليمين. - دور نظام التشغيل في كل ما سبق، ولماذا يهمك هذا كمطور أو كمدير سيرفر، ثم جدول بكل المصطلحات.
الصورة الكاملة قبل التفاصيل
قبل أن ندخل في كل خطوة، الصورة التالية تبين المحطات السبع بالترتيب: تكتب البرنامج في محرر، فيحفظه المحرر ملفاً من البايتات على القرص، ثم يحوله gcc عبر أربع مراحل إلى ملف تنفيذي بصيغة ELF، ثم تطلب الصدفة من النواة تشغيله فتحمله في الذاكرة، ثم ينفذ المعالج تعليماته، وأخيراً يطلب البرنامج من النواة كتابة 13 بايتاً إلى الطرفية التي ترسمها على الشاشة:

وسوف نمشي مع هذه المحطات بالترتيب، وفي كل محطة نسأل السؤال نفسه: ما المشكلة التي جاءت هذه الطبقة لتحلها؟ والسبب أن حفظ أسماء المراحل لا ينفعك عند أول عطل، أما فهم المشكلة التي تحلها كل مرحلة فهو الذي يجعلك تعرف أين تبحث.
تجهيز بيئة التجربة
لن نثبت شيئاً على جهازك، وإنما نشغل Container من Ubuntu 24.04 ونثبت فيه المترجم gcc وأدوات binutils التي تقرأ الملفات التنفيذية، والأداة strace التي تريك استدعاءات النظام، والأداة xxd لعرض البايتات، وpython3 للمقارنة. وإذا لم يكن Docker مثبتاً لديك فقد شرحنا تثبيته في دليل تثبيت Docker على Ubuntu:
docker run -it --rm --name lab-hello ubuntu:24.04 bash
apt-get update
apt-get install -y gcc libc6-dev binutils strace python3 file xxd gcc-aarch64-linux-gnu
mkdir /lab && cd /labالحزمة الأخيرة gcc-aarch64-linux-gnu مترجم لمعالجات ARM سوف نحتاجه في آخر المقال فقط، ويمكنك تأجيلها، والخيار --rm يحذف ال Container عند خروجك منه فلا يبقى شيء على جهازك. والإصدارات في تجربتنا هي GCC 13.3 وglibc 2.39 وPython 3.12.3 على معالج x86-64.
المحطة الأولى: الملف المصدري مجرد بايتات
عندما تضغط على حرف H في لوحة المفاتيح فإن لوحة المفاتيح لا ترسل حرفاً، وإنما ترسل رمزاً للمفتاح الذي ضغطته، ثم يحول نظام التشغيل هذا الرمز إلى حرف بحسب لغة لوحة المفاتيح المختارة، ثم يضع المحرر الحرف في الذاكرة ويرسمه لك. وعندما تحفظ الملف فإن المحرر يكتب على القرص سلسلة من البايتات Bytes لا أكثر، فالقرص لا يعرف الحروف ولا الأسطر، والمشكلة التي يحلها الترميز Character Encoding هي الاتفاق على أي رقم يمثل أي حرف، حتى يقرأ المترجم والمحرر والطرفية الملف نفسه بالطريقة نفسها. لنكتب البرنامج أولاً:
#include <stdio.h>
int main(void)
{
printf("Hello, World\n");
return 0;
}واحفظه باسم hello.c، ثم اكتب نسخة ثانية باسم salam.c مطابقة لها إلا أنها تطبع الكلمة مرحبا بدلاً من Hello, World، ثم نعرض حجم الملفين وبايتاتهما بالأمر xxd:
ls -l hello.c salam.c
xxd hello.c
xxd salam.c
file hello.c salam.cوالمخرج سوف يكون كما يلي:
-rw-r--r-- 1 root root 83 Oct 5 22:35 hello.c
-rw-r--r-- 1 root root 81 Oct 5 22:35 salam.c
00000000: 2369 6e63 6c75 6465 203c 7374 6469 6f2e #include <stdio.
00000010: 683e 0a0a 696e 7420 6d61 696e 2876 6f69 h>..int main(voi
00000020: 6429 0a7b 0a20 2020 2070 7269 6e74 6628 d).{. printf(
00000030: 2248 656c 6c6f 2c20 576f 726c 645c 6e22 "Hello, World\n"
00000040: 293b 0a20 2020 2072 6574 7572 6e20 303b );. return 0;
00000050: 0a7d 0a .}.
00000000: 2369 6e63 6c75 6465 203c 7374 6469 6f2e #include <stdio.
00000010: 683e 0a0a 696e 7420 6d61 696e 2876 6f69 h>..int main(voi
00000020: 6429 0a7b 0a20 2020 2070 7269 6e74 6628 d).{. printf(
00000030: 22d9 85d8 b1d8 add8 a8d8 a75c 6e22 293b "..........\n");
00000040: 0a20 2020 2072 6574 7572 6e20 303b 0a7d . return 0;.}
00000050: 0a .
hello.c: C source, ASCII text
salam.c: C source, Unicode text, UTF-8 textفي المخرج أعلاه لاحظ التالي:
- العمود الأول هو موقع البايت Offset داخل الملف بنظام Hex، ثم البايتات نفسها بنظام Hex، ثم الحروف المقابلة لها في اليمين، والنقطة تعني بايتاً لا يقابله حرف يطبع.
- كل حرف إنجليزي في
hello.cبايت واحد، فالحرف H هو48والحرف e هو65والمسافة20، ونهاية السطر بايت واحد0a، وهذا هو جدول ASCII الذي يعطي كل حرف إنجليزي رقماً من 0 إلى 127. - الكلمة
مرحباخمسة حروف ولكنها عشرة بايتات:d9 85للميم وd8 b1للراء وهكذا، لأن ASCII لا يعرف الحروف العربية، والترميز UTF-8 يكتب كل حرف عربي في بايتين، ولذلك صارsalam.cأصغر منhello.cببايتين فقط رغم أن الكلمة أقصر بسبعة حروف. - الأمر
fileلا يعرف الترميز من معلومة مكتوبة في الملف، وإنما يخمنه من البايتات نفسها، فالملف الأول كله أقل من 128 فاعتبره ASCII، والثاني فيه بايتات تطابق قواعد UTF-8 فاعتبره UTF-8.
والصورة التالية تبين البايتات نفسها حرفاً حرفاً، ولاحظ فيها أن الملف يحفظ الكلمة العربية بترتيب القراءة، أي الميم أولاً ثم الراء، أما رسمها من اليمين إلى اليسار فهو عمل الطرفية عند العرض وليس شيئاً مكتوباً في الملف:

وقد يتساءل البعض: إذا كان 0a هو نهاية السطر، فلماذا نرى في الملف 5c 6e داخل علامتي التنصيص؟ والإجابة أن \n في الملف المصدري حرفان عاديان هما الشرطة المائلة العكسية والحرف n، وهذا اتفاق في لغة C معناه «ضع هنا نهاية سطر»، والمترجم هو الذي يحولهما إلى البايت 0a داخل البرنامج، وسوف نراه بعد قليل في مخرج strace. أما الفرق بين Unicode وUTF-8 وطريقة عرض البايتات بنظام Hex وBase64 فقد شرحناها بالتفصيل في الترميز Encoding للمطورين، فلن نكررها هنا.
xxd وانظر إلى البايتات نفسها بدلاً من الحكم على ما يظهر على الشاشة.المحطة الثانية: من النص إلى ملف يفهمه المعالج
المعالج لا يقرأ النصوص، فهو يفهم فقط تعليمات الآلة Machine Code، وهي أرقام لكل منها معنى محدد في معمارية المعالج Architecture، مثل «انقل هذه القيمة إلى هذا المسجل» أو «اقفز إلى هذا العنوان». والحل البسيط أن يكتب المبرمج هذه الأرقام بنفسه، وهذا ما كان يحدث في بدايات الحاسب، ولكنه بطيء وكثير الأخطاء ويجعل البرنامج مربوطاً بنوع معالج واحد، ولذلك جاءت لغات مثل C ومعها برنامج يترجمها، وهو هنا gcc. وعندما تكتب gcc hello.c -o hello فأنت تظن أنها خطوة واحدة، ولكنها في الحقيقة أربع مراحل متتالية، والصورة التالية تبينها مع الملف الذي تخرجه كل مرحلة وحجمه في تجربتنا:

ويمكنك أن توقف gcc بعد كل مرحلة بخيار خاص بها كما في توثيق GCC، وهذا ما سوف نفعله الآن مرحلة بمرحلة.
المعالجة المسبقة: لصق الملفات المطلوبة
السطر الأول في برنامجنا #include <stdio.h>، والمشكلة التي يحلها أن الدالة printf ليست من كتابتنا، والمترجم يحتاج أن يعرف شكلها، أي اسمها ونوع ما تأخذه وما ترجعه، قبل أن نستخدمها. والمعالج المسبق Preprocessor يقوم بعمل بسيط جداً: يبحث عن الأسطر التي تبدأ بالعلامة #، فيلصق محتوى الملفات المطلوبة مكان #include، ويستبدل الثوابت المعرفة بـ #define، ويحذف التعليقات:
gcc -E hello.c -o hello.i
wc -l hello.c hello.i
ls -l hello.i
grep -n "printf" hello.i | head -3
tail -8 hello.iوالمخرج سوف يكون كما يلي:
7 hello.c
820 hello.i
827 total
-rw-r--r-- 1 root root 21315 Oct 5 22:35 hello.i
470:extern int fprintf (FILE *__restrict __stream,
476:extern int printf (const char *__restrict __format, ...);
478:extern int sprintf (char *__restrict __s,
# 3 "hello.c"
int main(void)
{
printf("Hello, World\n");
return 0;
}لاحظ أن ملفنا من سبعة أسطر صار 820 سطراً وأكثر من 21 ألف بايت، وكلها تقريباً من الملف stdio.h والملفات التي يضمها هو، ومنها السطر 476 الذي يعرف شكل printf، ثم يأتي برنامجنا في آخر الملف كما هو. ولاحظ أن الملف لا يحتوي على كود الدالة printf نفسها، وإنما على إعلانها Declaration فقط، أي وعد بأن هذه الدالة موجودة في مكان ما، وهذا الوعد هو ما يحله الرابط في المرحلة الأخيرة.
الترجمة: من C إلى لغة التجميع
المرحلة الثانية هي الترجمة الفعلية، حيث يقرأ المترجم كود C ويتحقق من صحته ثم يحوله إلى لغة التجميع Assembly، وهي نص أيضاً، ولكن كل سطر فيه يقابل تقريباً تعليمة واحدة من تعليمات المعالج:
gcc -S hello.c -o hello.s
cat hello.sوالجزء المهم من المخرج سوف يكون كما يلي:
.section .rodata
.LC0:
.string "Hello, World"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rax
movq %rax, %rdi
call puts@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
retفي المخرج أعلاه لاحظ التالي:
- الجملة
"Hello, World"وضعت في قسم اسمه.rodata، أي بيانات للقراءة فقط Read-Only Data، لأن البرنامج لا يغيرها. - الأسماء التي تبدأ بالعلامة
%مثل%raxو%rdiهي مسجلات Registers، أي خانات تخزين صغيرة داخل المعالج نفسه، وسوف نعود إليها في محطة المعالج. - السطر
leaq .LC0(%rip), %raxيحسب عنوان الجملة، ثم ينقل إلى المسجل%rdi، لأن الاتفاق على معالجات x86-64 في Linux أن أول معامل يمرر لأي دالة يوضع في هذا المسجل، ثمcallتستدعي الدالة، وmovl $0, %eaxهيreturn 0، لأن القيمة المرجعة توضع في المسجل%eax. - والمفاجأة أن الكود يستدعي
putsوليسprintf، والسبب أن GCC يعاملprintfكدالة يعرفها مسبقاً Built-in كما في توثيق الدوال المدمجة، فلما رأى أن الجملة ثابتة وتنتهي بنهاية سطر ولا تحتوي على أي%استبدلها بالدالة الأبسطputsالتي تطبع النص وتضيف نهاية السطر، والنتيجة على الشاشة واحدة. وإذا أضفت الخيار-fno-builtinفسوف ترىcall printf@PLTكما كتبتها، وهذا أول درس عملي: المترجم لا ينفذ ما كتبته حرفياً، وإنما ينتج كوداً يعطي النتيجة نفسها.
التجميع: ملف كائن لا يعمل وحده
المرحلة الثالثة يقوم بها المجمع Assembler، وهو البرنامج as، ويحول نص لغة التجميع إلى أرقام تعليمات الآلة الحقيقية، ويضعها في ملف كائن Object File:
gcc -c hello.c -o hello.o
ls -l hello.o
file hello.o
objdump -d hello.o
objdump -r hello.o
nm hello.oوالمخرج سوف يكون كما يلي:
-rw-r--r-- 1 root root 1504 Oct 5 22:36 hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # f <main+0xf>
f: 48 89 c7 mov %rax,%rdi
12: e8 00 00 00 00 call 17 <main+0x17>
17: b8 00 00 00 00 mov $0x0,%eax
1c: 5d pop %rbp
1d: c3 ret
hello.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
000000000000000b R_X86_64_PC32 .rodata-0x0000000000000004
0000000000000013 R_X86_64_PLT32 puts-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
0000000000000000 T main
U putsفي المخرج أعلاه لاحظ التالي:
- الدالة
mainكلها 30 بايتاً من تعليمات الآلة، والعمود الأوسط هو هذه البايتات فعلاً، فالتعليمةretهي البايتc3فقط، وهذه هي الأرقام التي سوف يقرؤها المعالج. - الأمر
fileيصف الملف بأنهrelocatable، أي قابل لإعادة التموضع، ومعناه أن عناوينه لم تحدد بعد، ولذلك ترى في التعليمةcallالبايتاتe8 00 00 00 00، أي «استدع العنوان صفر»، لأن المجمع لا يعرف أين ستكونputs. - جدول إعادة التموضع Relocation Records هو قائمة الفراغات التي يجب ملؤها لاحقاً: عند الموقع
0x13ضع عنوانputs، وعند الموقع0xbضع عنوان الجملة في.rodata. - الأمر
nmيعرض الرموز Symbols، والحرفTأمامmainيعني أنها معرفة في هذا الملف، والحرفUأمامputsيعني Undefined، أي أن هذا الملف يحتاجها ولا يملكها.
وقد يتساءل البعض: لماذا لا ينتج المترجم ملفاً جاهزاً مباشرة بدلاً من هذه الفراغات؟ والإجابة أن المشروعات الحقيقية تتكون من مئات الملفات، ولو ترجم كل شيء دفعة واحدة لاحتجت إلى إعادة ترجمة المشروع كله عند كل تعديل صغير، أما مع ملفات الكائن فكل ملف يترجم وحده، وعند تعديل ملف واحد تعاد ترجمته هو فقط ثم يعاد الربط، وهذا ما تفعله أدوات مثل make.
الربط: ملء الفراغات وإنتاج Production الملف التنفيذي
المرحلة الأخيرة يقوم بها الرابط Linker، وهو البرنامج ld، ومهمته جمع ملفات الكائن مع المكتبات Libraries التي تحتاجها، وتحديد عنوان نهائي لكل دالة ولكل بيانات، وملء الفراغات التي تركها المجمع. والدالة puts موجودة في مكتبة C القياسية، وهي على Ubuntu مكتبة glibc في الملف libc.so.6:
gcc hello.c -o hello
./hello
ls -l hello
file hello
ldd helloوالمخرج سوف يكون كما يلي:
Hello, World
-rwxr-xr-x 1 root root 15960 Oct 5 22:37 hello
hello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=a72563003289565aae3c0fe39a7f26489b8c9dd7, for GNU/Linux 3.2.0, not stripped
linux-vdso.so.1 (0x00007ffffc762000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007feaf2176000)
/lib64/ld-linux-x86-64.so.2 (0x00007feaf2393000)لاحظ أن الملف التنفيذي حجمه 15,960 بايت، بينما المكتبة libc.so.6 على النظام نفسه حجمها 2,129,424 بايت، أي أن كود puts لم ينسخ داخل برنامجنا، وإنما كتب الرابط في الملف ملاحظة تقول «هذا البرنامج يحتاج libc.so.6»، وهذا ما يسمى الربط الديناميكي Dynamic Linking. ولاحظ أيضاً كلمة interpreter في مخرج file، وهي هنا لا تعني مفسر لغة، وإنما المحمل الديناميكي Dynamic Loader ld-linux-x86-64.so.2 الذي سوف يعمل قبل برنامجنا عند كل تشغيل، فيبحث عن المكتبات المطلوبة ويحملها ويملأ عناوينها كما في توثيق ld.so، والأمر ldd يعرض لك نتيجة هذا البحث دون أن يشغل البرنامج.
والبديل هو الربط الثابت Static Linking، حيث تنسخ الأجزاء المطلوبة من المكتبة داخل الملف التنفيذي نفسه:
gcc -static hello.c -o hello-static
ls -l hello-static
file hello-static
ldd hello-staticوالمخرج سوف يكون كما يلي:
-rwxr-xr-x 1 root root 785384 Oct 5 22:37 hello-static
hello-static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=3dc3c9f48ea6c6897ebc4532f751bc91eef8710d, for GNU/Linux 3.2.0, not stripped
not a dynamic executableالبرنامج نفسه صار 785,384 بايت، أي أكبر بنحو خمسين مرة، ولكنه لا يحتاج أي ملف آخر ليعمل. وحتى بعد حذف معلومات التصحيح بالأمر strip بقي الملف الثابت في تجربتنا 706,576 بايت مقابل 14,472 بايت للملف الديناميكي. والجدول التالي يلخص الفرق:
| الربط الديناميكي Dynamic | الربط الثابت Static | |
|---|---|---|
| حجم hello في تجربتنا | 15,960 بايت | 785,384 بايت |
| يحتاج مكتبات على الجهاز | نعم، libc.so.6 والمحمل ld-linux | لا |
| تحديث أمني في المكتبة | يصل لكل البرامج بتحديث المكتبة مرة واحدة | يحتاج إعادة بناء كل برنامج |
| الذاكرة | نسخة واحدة من المكتبة تشترك فيها كل البرامج | كل برنامج يحمل نسخته |
| داخل Container فارغ | لا يعمل دون المكتبات | يعمل وحده |
الملف التنفيذي ELF من الداخل
الملف الناتج ليس مجرد تعليمات متتالية، والمشكلة التي يحلها تنسيقه أن النواة تحتاج أن تعرف أين تبدأ التعليمات، وأي أجزاء الملف كود للتنفيذ وأيها بيانات للقراءة فقط وأيها بيانات قابلة للتعديل، وأين يجب أن يوضع كل جزء في الذاكرة. وتنسيق الملفات التنفيذية في Linux اسمه ELF، واختصاراً Executable and Linkable Format، وهو موصوف في صفحة elf، ويبدأ كل ملف ELF برأس Header نقرؤه بالأمر readelf:
readelf -h helloوالمخرج سوف يكون كما يلي:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 13976 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30في المخرج أعلاه لاحظ التالي:
- أول أربعة بايتات
7f 45 4c 46هي البايت7fثم الحروف E وL وF بترميز ASCII، وهذا هو الرقم السحري Magic Number الذي تعرف منه النواة والأداةfileأن هذا ملف ELF، دون أي اعتماد على اسم الملف أو امتداده. - السطر
Machineيحدد معمارية المعالج التي كتبت لها التعليمات، وهو السطر الذي سوف يفسر لنا لاحقاً خطأexec format error. - السطر
Entry point addressهو عنوان أول تعليمة ينفذها البرنامج، ولاحظ أنها ليستmain، وإنما كود بدء صغير يضيفه الرابط من مكتبة C يجهز البيئة ثم يستدعيmain، ثم يأخذ القيمة التي ترجعها ويمررها إلى النظام عند الخروج. - عناوين البرامج Program Headers هي التي تقرؤها النواة عند التشغيل، فكل سطر
LOADفيها يقول «انسخ هذا الجزء من الملف إلى الذاكرة بهذه الصلاحيات»، ويمكنك رؤيتها بالأمرreadelf -l hello، حيث ستجد جزءاً بالصلاحياتR Eللكود، أي قراءة وتنفيذ، وجزءاً بالصلاحياتRWللبيانات القابلة للتعديل.
المترجمة والمفسرة وال JIT: البرنامج نفسه في Python
ما فعلناه حتى الآن هو الترجمة المسبقة Ahead-of-Time Compilation، حيث تحول الشيفرة إلى تعليمات آلة مرة واحدة قبل التشغيل، ثم ينفذها المعالج مباشرة، وهذه أسرع طريقة في التنفيذ، ولكن لها ثمن: الملف الناتج مربوط بمعمارية معالج ونظام تشغيل محددين، وكل تعديل يحتاج إعادة بناء. ولغات مثل Python اختارت طريقاً آخر، فلنكتب البرنامج نفسه بها:
printf 'print("Hello, World")\n' > hello.py
python3 hello.py
python3 -m dis hello.pyوالمخرج سوف يكون كما يلي:
Hello, World
0 0 RESUME 0
1 2 PUSH_NULL
4 LOAD_NAME 0 (print)
6 LOAD_CONST 0 ('Hello, World')
8 CALL 1
16 POP_TOP
18 RETURN_CONST 1 (None)الوحدة dis تعرض ما يحدث فعلاً داخل CPython، وهو التطبيق الرسمي للغة: البرنامج يترجم أولاً إلى تعليمات وسيطة اسمها Bytecode، وهي ليست تعليمات معالج حقيقي، وإنما تعليمات لآلة افتراضية Virtual Machine، فالتعليمة LOAD_NAME تبحث عن اسم print، وLOAD_CONST تحضر الجملة، وCALL تستدعي الدالة. ثم يأتي المفسر Interpreter، وهو حلقة داخل البرنامج python3 نفسه، فيقرأ هذه التعليمات واحدة واحدة وينفذ لكل منها كوداً مكتوباً بلغة C. أي أن المعالج في النهاية لا ينفذ برنامجك، وإنما ينفذ البرنامج python3 الذي يقرأ برنامجك، وهذا هو سبب البطء النسبي، وهو أيضاً سبب المرونة، فالملف hello.py نفسه يعمل على x86-64 وعلى ARM دون إعادة بناء، لأن ما يتغير بين الأجهزة هو python3 وليس برنامجك.
وبين الطريقتين طريقة ثالثة اسمها الترجمة أثناء التشغيل Just-In-Time، واختصاراً JIT. ففي آلة Java الافتراضية HotSpot يبدأ البرنامج بمفسر عادي، ثم تراقب الآلة أثناء التشغيل الأجزاء التي تتكرر كثيراً، فتترجمها وحدها إلى تعليمات آلة حقيقية وتترك الباقي للمفسر، كما يصف توثيق Oracle. ومحرك JavaScript المسمى V8 في Chrome وNode.js يعمل بالفكرة نفسها على عدة طبقات: المفسر Ignition أولاً، ثم Sparkplug، ثم Maglev، ثم TurboFan للكود الذي يتكرر أكثر، كما في مدونة V8. أما CPython فقد أضاف ترجمة JIT تجريبية منذ الإصدار 3.13 كما في PEP 744، ولكنها ليست مفعلة في البناء الافتراضي. والصورة التالية تضع الطرق الثلاث جنباً إلى جنب:

| مترجمة مسبقاً (C وGo وRust) | مفسرة (CPython) | JIT (Java وJavaScript) | |
|---|---|---|---|
| متى تتحول إلى تعليمات آلة | قبل التشغيل، مرة واحدة | لا تتحول، المفسر ينفذها | أثناء التشغيل، للأجزاء المتكررة |
| سرعة البدء | فورية | سريعة | أبطأ في البداية حتى تسخن الأجزاء المتكررة |
| سرعة التنفيذ | الأعلى عادة | الأقل عادة | تقترب من المترجمة في الأجزاء المتكررة |
| ما تحتاجه على السيرفر | الملف التنفيذي، ومكتباته إن كان ديناميكياً | المفسر بالإصدار المناسب ومكتبات المشروع | الآلة الافتراضية JVM أو Node.js |
| تغيير المعمارية | إعادة بناء لكل معمارية | الشيفرة نفسها تعمل | الشيفرة نفسها تعمل |
المحطة الثالثة: ماذا يحدث عندما تكتب ./hello
الملف الآن على القرص، وعندما تكتب ./hello وتضغط Enter فإن أول من يقرأ ما كتبته هو الصدفة Shell، وهي هنا bash، وهي نفسها برنامج عادي يعمل كعملية Process. والمشكلة هنا أن bash لا تستطيع أن تحمل برنامجاً آخر في الذاكرة وتنفذه بنفسها، لأن الذاكرة والمعالج يديرهما نظام التشغيل، ولذلك تطلب ذلك من النواة بخطوتين: الأولى fork، وفيها تنسخ bash نفسها فتصبح هناك عمليتان متطابقتان، والثانية execve، وفيها تطلب النسخة الجديدة من النواة أن تستبدل البرنامج الذي بداخلها بالبرنامج ./hello، بينما تنتظر bash الأصلية حتى ينتهي. والأداة strace تريك ذلك بوضوح:
strace -f -e trace=clone,clone3,execve,wait4 bash -c "./hello; true"والمخرج سوف يكون كما يلي:
execve("/usr/bin/bash", ["bash", "-c", "./hello; true"], 0x7ffc7bcab348 /* 7 vars */) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f71958a7a10) = 4017
strace: Process 4017 attached
[pid 4016] wait4(-1, <unfinished ...>
[pid 4017] execve("./hello", ["./hello"], 0x56006e560480 /* 7 vars */) = 0
Hello, World
[pid 4017] +++ exited with 0 +++
<... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 4017
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=4017, si_uid=0, si_status=0, si_utime=1 /* 0.01 s */, si_stime=0} ---
wait4(-1, 0x7ffd93c81550, WNOHANG, NULL) = -1 ECHILD (No child processes)
+++ exited with 0 +++في الإعداد أعلاه لاحظ التالي:
- أضفنا
; trueبعد./helloلأنbash -cإذا كان لديها أمر واحد فقط فإنها توفر خطوة النسخ وتستبدل نفسها به مباشرة، أما مع أمرين فهي تحتاج أن تبقى لتنفذ الثاني، فترى النسخ واضحاً. - السطر
clone(...) = 4017هو النسخ، والدالةforkفي glibc الحديثة تستخدم استدعاء النظامcloneمن الداخل كما في صفحة fork، والرقم 4017 هو معرف العملية الجديدة PID. - العملية الأصلية 4016 تنتظر في
wait4، والعملية 4017 تنفذexecve("./hello")، وبعدها يبقى رقمها 4017 كما هو ولكن البرنامج بداخلها صار برنامجنا، وهذا معنى الاستبدال في صفحة execve. - عندما ينتهي البرنامج بالقيمة 0 تصل هذه القيمة إلى
bashعبرwait4، وهي نفسها القيمة التي تقرؤها بالمتغير$?بعد أي أمر، وهي التي تعتمد عليها السكربتات وأنظمة CI لتعرف هل نجح الأمر أم فشل.
وقد يتساءل البعض: لماذا هذا التصميم الغريب، أي أن تنسخ نفسك ثم تستبدل النسخة، بدلاً من أمر واحد يقول «شغل هذا البرنامج»؟ والإجابة أن الفترة بين النسخ والاستبدال هي التي تجعل الصدفة قوية، ففيها تستطيع النسخة الجديدة أن تغير ما ترثه قبل أن يبدأ البرنامج، مثل توجيه المخرج إلى ملف عندما تكتب ./hello > out.txt، أو ربطه ببرنامج آخر عند استخدام |، أو تغيير المجلد الحالي ومتغيرات البيئة، والبرنامج نفسه لا يعرف شيئاً عن ذلك كله.
التحميل في الذاكرة الافتراضية
عندما تستقبل النواة طلب execve فإنها تقرأ رأس ELF وعناوين البرامج التي رأيناها، ثم تجهز للعملية مساحة عناوين جديدة، وتربط أجزاء الملف بها بالصلاحيات المطلوبة، ثم ترى سطر INTERP فتحمل المحمل الديناميكي ld-linux وتبدأ التنفيذ منه، فيحمل هو libc.so.6 ثم يقفز إلى نقطة البداية في برنامجنا. ويمكنك أن ترى ذلك كله في أول أسطر مخرج strace ./hello:
execve("./hello", ["./hello"], 0x7ffc0564de00 /* 8 vars */) = 0
brk(NULL) = 0x55b591527000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f6dbed4c000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=9199, ...}) = 0
mmap(NULL, 9199, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f6dbed49000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\243\2\0\0\0\0\0"..., 832) = 832لاحظ أن المحمل يبحث عن المكتبة في الملف /etc/ld.so.cache أولاً، ثم يفتح libc.so.6 ويقرأ أول بايتاتها فيجد \177ELF، وهي الأرقام السحرية نفسها 7f 45 4c 46 بصيغة أخرى، ثم يربطها بالذاكرة باستدعاءات mmap. وحتى نرى النتيجة النهائية نحتاج برنامجاً لا ينتهي فوراً، لذلك نضيف إلى نسخة من hello.c السطر getchar(); بعد printf حتى ينتظر ضغطة Enter، ونبنيها باسم wait، ثم نشغلها في طرفية ونقرأ خريطة ذاكرتها من طرفية أخرى عبر الملف الذي يصفه توثيق proc/pid/maps:
cat /proc/$(pgrep -x wait)/maps
getconf PAGESIZEوالمخرج سوف يكون كما يلي:
55fb6fe91000-55fb6fe92000 r--p 00000000 08:20 1918820 /lab/wait
55fb6fe92000-55fb6fe93000 r-xp 00001000 08:20 1918820 /lab/wait
55fb6fe93000-55fb6fe94000 r--p 00002000 08:20 1918820 /lab/wait
55fb6fe94000-55fb6fe95000 r--p 00002000 08:20 1918820 /lab/wait
55fb6fe95000-55fb6fe96000 rw-p 00003000 08:20 1918820 /lab/wait
55fb9433b000-55fb9435c000 rw-p 00000000 00:00 0 [heap]
7f155c485000-7f155c488000 rw-p 00000000 00:00 0
7f155c488000-7f155c4b0000 r--p 00000000 08:20 180088 /usr/lib/x86_64-linux-gnu/libc.so.6
7f155c4b0000-7f155c639000 r-xp 00028000 08:20 180088 /usr/lib/x86_64-linux-gnu/libc.so.6
7f155c639000-7f155c688000 r--p 001b1000 08:20 180088 /usr/lib/x86_64-linux-gnu/libc.so.6
7f155c688000-7f155c68c000 r--p 001ff000 08:20 180088 /usr/lib/x86_64-linux-gnu/libc.so.6
7f155c68c000-7f155c68e000 rw-p 00203000 08:20 180088 /usr/lib/x86_64-linux-gnu/libc.so.6
7f155c68e000-7f155c69b000 rw-p 00000000 00:00 0
7f155c69e000-7f155c6a0000 rw-p 00000000 00:00 0
7f155c6a0000-7f155c6a1000 r--p 00000000 08:20 180068 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f155c6a1000-7f155c6cc000 r-xp 00001000 08:20 180068 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f155c6cc000-7f155c6d6000 r--p 0002c000 08:20 180068 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f155c6d6000-7f155c6d8000 r--p 00036000 08:20 180068 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f155c6d8000-7f155c6da000 rw-p 00038000 08:20 180068 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fff40722000-7fff40743000 rw-p 00000000 00:00 0 [stack]
7fff40789000-7fff4078d000 r--p 00000000 00:00 0 [vvar]
7fff4078d000-7fff4078f000 r-xp 00000000 00:00 0 [vdso]
4096في المخرج أعلاه لاحظ التالي:
- كل سطر منطقة Region من الذاكرة، والعمود الأول مداها، والثاني صلاحياتها:
rللقراءة وwللكتابة وxللتنفيذ، فالكودr-xpلا يكتب فيه، والبياناتrw-pلا تنفذ، وهذا يمنع أن يتحول خطأ في كتابة البيانات إلى تنفيذ كود يحقنه مخترق. - ترى البرنامج نفسه، ثم
[heap]وهي الذاكرة التي تطلبها البرامج أثناء العمل، ثمlibc.so.6والمحمل، ثم[stack]وهي ذاكرة الدوال ومتغيراتها المحلية. - المنطقة
[vdso]مكتبة صغيرة تضعها النواة في كل عملية حتى تنفذ بعض الطلبات البسيطة، مثل قراءة الوقت، دون الدخول إلى النواة، كما في صفحة vdso، وهي نفسهاlinux-vdso.so.1التي ظهرت في مخرجldd. - كل المدى أرقام مضاعفة للرقم
0x1000، أي 4096 بايت، وهو حجم الصفحة Page في هذا النظام كما أظهر الأمرgetconf.
وهذه العناوين ليست عناوين حقيقية في شرائح الذاكرة RAM، وإنما عناوين افتراضية Virtual Addresses، والمشكلة التي تحلها الذاكرة الافتراضية أن عشرات البرامج تعمل في الوقت نفسه، ولو رأى كل منها الذاكرة الحقيقية مباشرة لاستطاع أي برنامج أن يقرأ بيانات غيره أو يفسدها، ولاحتاج كل برنامج أن يعرف مسبقاً أي جزء من الذاكرة فارغ. أما مع الذاكرة الافتراضية فكل عملية ترى مساحة عناوين كاملة خاصة بها، والنواة تحتفظ لكل عملية بجدول صفحات Page Table يربط كل صفحة افتراضية بإطار Frame حقيقي في RAM، وجزء من المعالج اسمه وحدة إدارة الذاكرة MMU يترجم كل عنوان بهذا الجدول عند كل وصول للذاكرة، كما يشرح توثيق النواة. والصورة التالية تبين الخطوتين معاً:

ولاحظ في الصورة أن صفحات libc.so.6 للقراءة والتنفيذ يمكن أن تشترك فيها كل البرامج على الجهاز في الإطارات الحقيقية نفسها، وهذا أحد أسباب انتشار الربط الديناميكي، ولاحظ أيضاً أن الصفحة غير المربوطة إذا حاول البرنامج الوصول إليها فإن المعالج ينبه النواة بخطأ الصفحة Page Fault، فإما أن تحضرها النواة من القرص إن كانت مؤجلة، وإما أن تنهي البرنامج بالرسالة المشهورة Segmentation fault إن كان العنوان غير مسموح.
المحطة الرابعة: المعالج ينفذ main
بعد التحميل يبدأ المعالج التنفيذ، وعمل المعالج في أبسط صورة حلقة لا تتوقف من ثلاث خطوات: يجلب Fetch التعليمة من العنوان المكتوب في مسجل خاص اسمه عداد البرنامج Program Counter، ويسمى على x86-64 بالاسم rip، ثم يفك Decode التعليمة ليعرف ما المطلوب، ثم ينفذها Execute، ثم يزيد العداد ليشير إلى التعليمة التالية، إلا إذا كانت التعليمة قفزة أو استدعاء مثل call، فتضع في العداد عنواناً جديداً. والمعالجات الحديثة تنفذ هذه الحلقة بطرق أعقد بكثير، فتعمل على عدة تعليمات في الوقت نفسه وتتوقع القفزات قبل حدوثها، ولكنها تحافظ على النتيجة كما لو أنها نفذت التعليمات واحدة بعد الأخرى.
والتعليمات التي رأيناها في objdump تعمل في معظمها على المسجلات Registers، وهي خانات تخزين قليلة العدد داخل المعالج نفسه، فعلى x86-64 هناك 16 مسجلاً للأغراض العامة حجم كل منها 64 بت، مثل rax وrdi وrsp الذي يشير إلى رأس ال Stack، وهي أسرع ذاكرة على الإطلاق لأنها جزء من دوائر المعالج. ولكنها لا تكفي لشيء يذكر، فالبرنامج وبياناته في RAM، وبين المعالج وRAM ناقل Bus ومسافة، والمعالج يستطيع أن ينفذ عشرات التعليمات في الوقت الذي ينتظر فيه قيمة واحدة من RAM.
وهذه هي المشكلة التي يحلها التخزين المؤقت Cache: ذاكرة صغيرة وسريعة جداً داخل المعالج تحفظ نسخة من البيانات التي استخدمت مؤخراً والبيانات المجاورة لها، والسبب أن البرامج في الغالب تعود إلى البيانات نفسها بعد قليل، وتقرأ البيانات المتجاورة بالترتيب، فإذا كانت القيمة في ال Cache حصل عليها المعالج فوراً، وإلا انتظر حتى تأتي من RAM. ولذلك تبنى الذاكرة على شكل هرم: كلما ارتفعت كانت أسرع وأصغر وأغلى، وكلما نزلت كانت أكبر وأرخص وأبطأ. والصورة التالية تبين هذا الهرم بأرقام تقريبية من القائمة المشهورة التي نشرها Peter Norvig ثم وسعها Jeff Dean في Latency Numbers Every Programmer Should Know:

لا تحفظ هذه الأرقام كما هي، فهي تختلف من جهاز إلى آخر، وأقراص NVMe الحديثة أسرع من رقم ال SSD في القائمة، ولكن احفظ النسبة بينها: الوصول إلى RAM أبطأ من L1 بنحو مئتي مرة، وقراءة عشوائية من SSD أبطأ من RAM بنحو ألف وخمسمئة مرة، والقرص الصلب أبطأ من ذلك بكثير. ولو كان الوصول إلى L1 يستغرق ثانية واحدة لكان الوصول إلى RAM نحو ثلاث دقائق، والقراءة من SSD نحو ثلاثة أيام ونصف، والبحث في قرص صلب نحو سبعة أشهر ونصف. ولهذا السبب يحمل نظام التشغيل الملفات التي تقرؤها كثيراً في RAM، ولهذا السبب تضع قواعد البيانات أهم بياناتها في الذاكرة، ولهذا السبب يكون المرور على مصفوفة بالترتيب أسرع بكثير من القفز بين عناصر متباعدة في الذاكرة، رغم أن عدد العمليات نفسه. أما الأنوية Cores والخيوط Threads وكيف تشترك الأنوية في ال Cache فقد خصصنا لها مقال المعالج والخيوط والتزامن للمبتدئين.
المحطة الأخيرة: كيف تصل الجملة إلى الشاشة
وصلنا إلى السطر الذي كتبنا البرنامج من أجله، والمشكلة هنا أن برنامجنا لا يستطيع أن يكتب على الشاشة مباشرة، فالشاشة والطرفية وكل الأجهزة تديرها النواة، والبرنامج يعمل في وضع المستخدم User Mode الذي لا يسمح له المعالج فيه بالوصول إلى الأجهزة، والطريق الوحيد إلى النواة هو استدعاء النظام System Call، أي طلب رسمي له رقم ومعاملات تنفذه النواة بالنيابة عن البرنامج في وضع النواة Kernel Mode ثم ترجع النتيجة، وقائمة هذه الطلبات في صفحة syscalls. لنر كل الطلبات التي يرسلها برنامجنا، هذه المرة من طرفية حقيقية:
strace -o trace.txt ./hello
wc -l < trace.txt
grep -E "fstat\(1|^write" trace.txt
ls -l /proc/self/fd/1
strace -c ./helloوالمخرج سوف يكون كما يلي:
Hello, World
36
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}) = 0
write(1, "Hello, World\n", 13) = 13
lrwx------ 1 root root 64 Oct 5 22:38 /proc/self/fd/1 -> /dev/pts/0
Hello, World
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
0.00 0.000000 0 1 read
0.00 0.000000 0 1 write
0.00 0.000000 0 2 close
0.00 0.000000 0 3 fstat
0.00 0.000000 0 8 mmap
0.00 0.000000 0 3 mprotect
0.00 0.000000 0 1 munmap
0.00 0.000000 0 3 brk
0.00 0.000000 0 2 pread64
0.00 0.000000 0 1 1 access
0.00 0.000000 0 1 execve
0.00 0.000000 0 1 arch_prctl
0.00 0.000000 0 1 set_tid_address
0.00 0.000000 0 2 openat
0.00 0.000000 0 1 set_robust_list
0.00 0.000000 0 1 prlimit64
0.00 0.000000 0 1 getrandom
0.00 0.000000 0 1 rseq
------ ----------- ----------- --------- --------- ----------------
100.00 0.000000 0 34 1 totalفي المخرج أعلاه لاحظ التالي:
- برنامج من سبعة أسطر أرسل 34 طلباً إلى النواة، وكلها تقريباً للتحميل والتجهيز، أما العمل الحقيقي فهو طلب واحد:
write(1, "Hello, World\n", 13)، أي اكتب 13 بايتاً إلى الملف رقم 1، وهذه هي البايتات الثلاثة عشر نفسها التي رأيناها في الصورة الثانية، و\nهنا هي طريقةstraceفي عرض البايت0a. - الرقم 1 هو واصف الملف File Descriptor للمخرج القياسي stdout، وهو ليس الشاشة، وإنما رقم يرثه البرنامج من الصدفة، والأمر
ls -l /proc/self/fd/1يبين أنه يشير هنا إلى/dev/pts/0، أي طرفية وهمية Pseudo-Terminal. - قبل الكتابة فحصت مكتبة C الملف رقم 1 بالطلب
fstatفوجدت أنه جهاز حروفS_IFCHR، أي طرفية، وعلى ذلك قررت طريقة التخزين المؤقت كما سوف نرى بعد قليل. - ولو شغلت النسخة الثابتة
hello-staticبالأمر نفسه لوجدت 16 طلباً فقط، لأنه لا يوجد محمل ولا مكتبات تفتح وتربط بالذاكرة.
ولو شغلت salam بالطريقة نفسها مع الخيار -e trace=write فسوف ترى ما يلي:
write(1, "\331\205\330\261\330\255\330\250\330\247\n", 11مرحبا
) = 11وstrace هنا يعرض البايتات بالنظام الثماني Octal، فالرقم \331 هو d9 والرقم \205 هو 85، أي أنها البايتات العشرة نفسها التي رأيناها في الملف المصدري، ثم البايت 0a. وهذه نقطة مهمة: البرنامج والمكتبة والنواة لا يعرفون شيئاً عن الحروف العربية، فهم ينقلون 11 بايتاً كما هي، ومعرفة أن هذه البايتات حروف عربية تبدأ عند الطرفية.
لماذا لا تطلب printf من النواة عند كل حرف
الحل البسيط أن ترسل printf كل ما يصلها إلى النواة فوراً، والمشكلة أن كل استدعاء نظام له كلفة، فالمعالج ينتقل من وضع المستخدم إلى وضع النواة ويرجع، ولذلك تجمع مكتبة C ما تطبعه في ذاكرة مؤقتة Buffer داخل البرنامج، ولا ترسله إلا عند الحاجة. ومتى تكون الحاجة؟ هذا ما تشرحه صفحة setbuf: إذا كان المخرج طرفية فهو مخزن بالسطر Line Buffered، أي يرسل عند كل نهاية سطر حتى يرى المستخدم كل سطر فوراً، وإذا كان ملفاً أو أنبوباً Pipe فهو مخزن بالكتلة Block Buffered، أي يرسل عندما تمتلئ الذاكرة المؤقتة. ولنر الفرق ببرنامج يطبع ألف سطر:
#include <stdio.h>
int main(void)
{
for (int i = 0; i < 1000; i++)
printf("Hello, World %d\n", i);
return 0;
}نحفظه باسم many.c ونبنيه، ثم نعد طلبات write مرة والمخرج إلى الطرفية، ومرة والمخرج إلى ملف:
gcc many.c -o many
strace -c -e trace=write ./many 2>&1 >/dev/tty | tail -4
strace -c -e trace=write ./many 2>&1 > out.txt | tail -4
ls -l out.txtوالمخرج بعد ألف سطر من Hello, World على الشاشة سوف يكون كما يلي:
------ ----------- ----------- --------- --------- ----------------
100.00 0.044128 44 1000 write
------ ----------- ----------- --------- --------- ----------------
100.00 0.044128 44 1000 total
------ ----------- ----------- --------- --------- ----------------
100.00 0.000265 53 5 write
------ ----------- ----------- --------- --------- ----------------
100.00 0.000265 53 5 total
-rw-r--r-- 1 root root 16890 Oct 5 22:39 out.txtالبرنامج نفسه أرسل إلى الطرفية 1000 طلب write، وأرسل إلى الملف 5 طلبات فقط لنحو 16 ألف بايت، والسبب أن الذاكرة المؤقتة في تجربتنا تتسع لنحو 4 آلاف بايت. ولا تعتمد على الأزمنة في هذا المخرج، لأن strace نفسه يبطئ كل استدعاء، وإنما انظر إلى عدد الطلبات. وهذا هو سبب ظاهرة يقابلها كل من شغل برنامجاً داخل Container: السجلات Logs تظهر فوراً عندما تشغل البرنامج في طرفيتك، ثم تتأخر أو تظهر دفعة واحدة عندما يشغله Docker أو systemd، لأن المخرج هناك أنبوب وليس طرفية، فيتحول إلى التخزين بالكتلة، ولذلك ترى في كثير من ملفات Dockerfile لبرامج Python المتغير PYTHONUNBUFFERED=1.
من الطرفية إلى البكسلات
عندما تستقبل النواة طلب write على /dev/pts/0 فهي لا ترسم شيئاً، فالطرفية الوهمية PTY زوج من الأطراف كما تشرح صفحة pty: طرف تابع Slave يراه البرنامج كأنه طرفية حقيقية، وهو /dev/pts/0، وطرف رئيسي Master يمسكه برنامج محاكي الطرفية Terminal Emulator، مثل GNOME Terminal أو Windows Terminal أو برنامج SSH. فالبايتات التي يكتبها برنامجنا في الطرف التابع يقرؤها محاكي الطرفية من الطرف الرئيسي، وهو برنامج عادي في وضع المستخدم، ثم يقوم هو بالعمل الذي نظنه بسيطاً:
- يفك ترميز UTF-8، فيعرف أن
48هو الحرف H، وأنd9 85معاً هما الميم. - يبحث في الخط Font المختار عن الشكل المرسوم للحرف، ويسمى Glyph.
- للعربية عمل إضافي: الحرف الواحد له أشكال بحسب موقعه في الكلمة وما يجاوره، فالميم في أول الكلمة غيرها في آخرها، واختيار الشكل المناسب يسمى التشكيل Shaping، ثم ترتيب الحروف على الشاشة من اليمين إلى اليسار بحسب خوارزمية Unicode للاتجاه Bidirectional Algorithm.
- يرسم هذه الأشكال في نافذته، ثم يسلم النافذة لنظام العرض وتعريف كرت الشاشة GPU، الذي يكتب البكسلات النهائية في ذاكرة الإطار Frame Buffer، والشاشة تقرأ هذه الذاكرة عشرات المرات في الثانية.
والصورة التالية تبين هذا المسار كله، ولاحظ فيها الحد بين وضع المستخدم ووضع النواة الذي لا يعبر إلا باستدعاء نظام:

وقد يتساءل البعض: لماذا تظهر العربية مقطعة أو معكوسة في بعض الطرفيات؟ والإجابة أن التشكيل والاتجاه مسؤولية محاكي الطرفية والخط وليست مسؤولية البرنامج ولا النواة، والطرفيات تاريخياً صممت لنصوص تكتب من اليسار إلى اليمين بخلايا ثابتة العرض، ومشكلة دعم الاتجاه فيها أعقد مما تبدو، لأن الطرفية لا ترى إلا جزء السطر الظاهر أمامها، وهذا ما تناقشه توصية مجموعة عمل الطرفيات حول BiDi، والتي طبقت مكتبة VTE التي يقوم عليها GNOME Terminal مستواها الأول. لذلك إذا رأيت العربية مقلوبة في مخرج برنامجك فتأكد أولاً من الطرفية والخط قبل أن تبحث في الكود، فالبايتات كما رأيت سليمة.
نظام التشغيل في كل ما سبق
إذا رجعت إلى المحطات السابقة فسوف تجد أن نظام التشغيل حاضر في كل واحدة منها، ودوره يمكن جمعه في ثلاثة تجريدات Abstractions، أي ثلاث صور مبسطة يقدمها للبرامج بدلاً من الأجهزة الحقيقية:
- العملية Process: كل برنامج يعمل يظن أنه يملك المعالج وحده، والنواة في الحقيقة توزع المعالج على مئات العمليات بالتناوب بسرعة لا تلاحظها، وتحفظ حالة كل عملية من مسجلات وعداد برنامج عندما توقفها، وتعيدها عندما يحين دورها.
- الذاكرة الافتراضية Virtual Memory: كل عملية تظن أنها تملك مساحة عناوين كاملة، والنواة تربطها بأجزاء متفرقة من RAM وتمنع أي عملية من رؤية ذاكرة غيرها.
- الملفات Files: القرص والطرفية والأنبوب واتصال الشبكة كلها تظهر للبرنامج كأرقام ملفات يقرأ منها بالطلب
readويكتب إليها بالطلبwrite، ولذلك لم يحتج برنامجنا أي تعديل عندما وجهنا مخرجه إلى ملف بدلاً من الطرفية.
ويحمي هذه التجريدات المعالج نفسه، فهو يعمل بوضعين: وضع المستخدم User Mode الذي تعمل فيه البرامج دون صلاحية على الأجهزة أو على جداول الصفحات، ووضع النواة Kernel Mode الذي لا يدخله المعالج إلا عبر استدعاء نظام أو مقاطعة Interrupt أو خطأ، ويقفز عندها إلى كود النواة فقط. وهذه الفكرة هي نفسها التي تقوم عليها ال Containers، فال Container ليس جهازاً افتراضياً بنواة مستقلة، وإنما عمليات عادية على النواة نفسها، تعزلها النواة بحيث ترى ملفات خاصة بها وشبكة خاصة بها وأرقام عمليات خاصة بها، ولذلك ظهر في كل تجاربنا رقم نواة المضيف عندما شغلنا strace داخل ال Container، وقد شرحنا الفرق بين ال Containers والأجهزة الافتراضية في مصطلحات الاستضافة الذاتية.
لماذا يهمك هذا كمطور أو كمدير سيرفر
قد تبدو المحطات السابقة كلاماً أكاديمياً، ولكن كل واحدة منها تظهر في عملك اليومي في صورة رسالة خطأ أو مشكلة أداء، وفيما يلي أشهرها:
عندما يفشل برنامج ولا يقول لماذا
كثير من البرامج تفشل برسالة عامة مثل «لا يمكن قراءة الإعدادات»، وهنا يأتي دور strace، فكل ما يفعله البرنامج مع العالم الخارجي يمر عبر استدعاءات النظام كما رأيت، وبالتالي يكفي أن تشغله بالأمر strace -f -e trace=openat,execve -o trace.txt ثم تبحث في الملف عن السطور التي تنتهي بخطأ مثل ENOENT أو EACCES، فتعرف أي ملف بحث عنه البرنامج ولم يجده، أو وجده ولم يملك صلاحية قراءته، وهذا يوفر عليك ساعات من التخمين.
المكتبة غير موجودة و«no such file or directory» الغريبة
إذا نقلت برنامجاً مربوطاً ديناميكياً إلى جهاز لا توجد عليه إحدى مكتباته فإن الأمر ldd يعرض أمامها الكلمتين not found، والمحمل يرفض التشغيل برسالة فيها اسم المكتبة، والحل أن تثبت الحزمة التي توفرها، أو أن تبني البرنامج على النظام الذي سوف يعمل عليه. وأغرب صورة لهذه المشكلة تظهر في ال Containers، فلنضع الملف hello الديناميكي وحده في Docker Image فارغة تماماً تبدأ من scratch، وهي نقطة البداية الفارغة التي يصفها توثيق Docker، ثم نفعل الشيء نفسه مع hello-static:
FROM scratch
COPY hello /hello
CMD ["/hello"]بنينا ملفاً بهذا الشكل باسم Dockerfile.dynamic، وآخر مطابقاً له باسم Dockerfile.static ينسخ hello-static بدلاً منه، ثم شغلنا كلاً منهما:
docker build -q -f Dockerfile.dynamic -t lab-hello-dynamic .
docker build -q -f Dockerfile.static -t lab-hello-static .
docker run --rm lab-hello-dynamic; echo "exit=$?"
docker run --rm lab-hello-static; echo "exit=$?"والمخرج سوف يكون كما يلي:
sha256:c28e5573e08196eab8207275f03245c09d0359fdac4367aee15a8e43cbcae907
sha256:b04ec0e17bbbf270ee88c91d79d409b4e4ff3057e145a5e70a6106891553d7f9
exec /hello: no such file or directory
exit=255
Hello, World
exit=0لاحظ أن الرسالة تقول إن الملف /hello غير موجود، رغم أننا نسخناه للتو، والسبب أن الملف الناقص ليس برنامجنا، وإنما المحمل /lib64/ld-linux-x86-64.so.2 المكتوب داخل برنامجنا في سطر INTERP، فالنواة لم تجده فرجعت بالخطأ ENOENT الذي تذكره صفحة execve لهذه الحالة أيضاً. أما النسخة الثابتة فعملت لأنها لا تحتاج شيئاً. وهذا هو سبب انتشار البرامج الثابتة في Docker Images الصغيرة جداً المبنية على scratch، ولغة Go مثلاً تنتج ملفات ثابتة بسهولة، والحل الوسط هو Docker Images مثل distroless التي تحتوي على مكتبة C والملفات الأساسية فقط دون صدفة ولا مدير حزم. ومن الخطأ الشائع أيضاً أن تبني البرنامج على Ubuntu ثم تشغله على Alpine، لأن Alpine تستخدم مكتبة C أخرى اسمها musl بدلاً من glibc، فيظهر الخطأ نفسه.
معمارية المعالج و«exec format error»
ذكرنا أن الترجمة المسبقة تنتج تعليمات لمعمارية معالج محددة، وأن رأس ELF يحفظ هذه المعمارية في السطر Machine، والآن مع انتشار سيرفرات ARM وأجهزة Mac بمعالجات Apple Silicon صار هذا الموضوع يومياً. لنبن البرنامج نفسه لمعالجات ARM بالمترجم الذي ثبتناه في البداية، ثم نحاول تشغيله على جهازنا x86-64:
aarch64-linux-gnu-gcc hello.c -o hello-arm64
file hello-arm64
./hello-arm64; echo "exit=$?"
aarch64-linux-gnu-gcc -static hello.c -o hello-arm64-static
./hello-arm64-static; echo "exit=$?"والمخرج سوف يكون كما يلي:
hello-arm64: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=916e30a28e7454f14d02fcc45499151ceded635c, for GNU/Linux 3.7.0, not stripped
qemu-aarch64: Could not open '/lib/ld-linux-aarch64.so.1': No such file or directory
exit=255
Hello, World
exit=0والنتيجة هنا تحتاج شرحاً، فقد يتوقع البعض أن يرفض الجهاز تشغيل برنامج ARM، ولكن الرسالة جاءت من qemu-aarch64، والنسخة الثابتة عملت فعلاً. والسبب أن تجربتنا تعمل على Docker Desktop، وهو يسجل في النواة محاكي المعالجات QEMU عبر ميزة binfmt_misc، وهذه الميزة تقول للنواة: «إذا وجدت ملف ELF لمعالج ARM فلا ترفضه، وإنما شغله داخل المحاكي»، فالمحاكي يترجم كل تعليمة ARM إلى تعليمات x86-64 أثناء التشغيل، وهذا أبطأ بكثير من التشغيل المباشر. وفشلت النسخة الديناميكية لأن المحاكي لم يجد محمل ARM، فهو المشكلة نفسها التي رأيناها في scratch ولكن لمعمارية أخرى. أما على سيرفر Linux عادي لم يسجل فيه محاكي، فإن النواة ترفض الملف بالخطأ ENOEXEC، والذي تصفه صفحة execve بأنه ملف بتنسيق غير معروف أو لمعمارية أخرى، وتظهر الرسالة عندها منتهية بالكلمات exec format error. وأشهر حالة لهذا الخطأ هي Docker Image بنيت على جهاز Mac بمعالج Apple Silicon لمعمارية arm64 ثم شغلت على VPS بمعالج x86-64، والحل أن تبني ال Docker Image للمعمارية الصحيحة أو لعدة معماريات معاً، وأول خطوة في التشخيص دائماً هي الأمر file على الملف التنفيذي.
حدس الأداء
من المحطات السابقة تخرج بقاعدتين بسيطتين تفسران كثيراً من مشكلات الأداء. الأولى أن استدعاءات النظام مكلفة نسبياً، لذلك لا تقم بالكتابة إلى ملف أو اتصال شبكة بايتاً بايتاً أو سطراً سطراً في حلقة كبيرة، وإنما اجمع البيانات واكتبها دفعة واحدة، كما فعلت مكتبة C عندما كتبت ألف سطر بخمسة طلبات فقط. والثانية أن الذاكرة ليست سرعة واحدة، فالبيانات المتجاورة التي تقرأ بالترتيب تستفيد من ال Cache، والبيانات المتناثرة التي تتبعها عبر مؤشرات Pointers كثيرة تجعل المعالج ينتظر RAM مرة بعد مرة، وعندما يحتاج السيرفر إلى القرص لأن RAM امتلأت وبدأ النظام يستخدم مساحة التبديل Swap فإن الفرق يصبح بآلاف المرات كما رأيت في الهرم.
جدول المصطلحات
| المصطلح | بالعربية | معناه باختصار |
|---|---|---|
| Source File | الملف المصدري | النص الذي يكتبه المبرمج، وهو على القرص بايتات فقط |
| Character Encoding | ترميز الحروف | الاتفاق على البايتات التي تمثل كل حرف، مثل ASCII وUTF-8 |
| ASCII | آسكي | جدول قديم يعطي الحروف الإنجليزية والرموز أرقاماً من 0 إلى 127، بايت لكل حرف |
| UTF-8 | يو تي إف 8 | ترميز Unicode الأكثر انتشاراً، يكتب الحرف في بايت إلى أربعة بايتات، والحرف العربي في بايتين |
| Hex | النظام الست عشري | طريقة لكتابة البايت برمزين من 00 إلى ff |
| Compiler | المترجم | برنامج يحول لغة برمجة إلى لغة أدنى منها، وهنا من C إلى لغة التجميع |
| Preprocessor | المعالج المسبق | ينفذ أسطر #include و#define قبل الترجمة |
| Assembly | لغة التجميع | نص يقابل كل سطر فيه تعليمة معالج تقريباً |
| Assembler | المجمع | يحول لغة التجميع إلى تعليمات آلة في ملف كائن |
| Object File | ملف الكائن | تعليمات آلة بعناوين غير نهائية وفراغات تنتظر الربط |
| Linker | الرابط | يجمع ملفات الكائن والمكتبات ويملأ العناوين وينتج الملف التنفيذي |
| Symbol | الرمز | اسم دالة أو متغير في ملف كائن، معرف فيه أو مطلوب من غيره |
| Relocation | إعادة التموضع | قائمة الأماكن التي يجب أن يكتب فيها الرابط العناوين النهائية |
| Library | المكتبة | كود جاهز تستخدمه البرامج، مثل مكتبة C القياسية glibc |
| Dynamic Linking | الربط الديناميكي | المكتبة تحمل من النظام عند التشغيل وتشترك فيها البرامج |
| Static Linking | الربط الثابت | أجزاء المكتبة تنسخ داخل الملف التنفيذي فلا يحتاج غيره |
| Dynamic Loader | المحمل الديناميكي | البرنامج ld-linux الذي يحمل المكتبات قبل أن يبدأ البرنامج |
| ELF | تنسيق ELF | تنسيق الملفات التنفيذية والمكتبات وملفات الكائن في Linux |
| Entry Point | نقطة البداية | عنوان أول تعليمة ينفذها البرنامج، وهي قبل main |
| Machine Code | تعليمات الآلة | الأرقام التي ينفذها المعالج مباشرة |
| Architecture | معمارية المعالج | مجموعة التعليمات التي يفهمها المعالج، مثل x86-64 وarm64 |
| Interpreter | المفسر | برنامج يقرأ شيفرة أو Bytecode وينفذها خطوة خطوة |
| Bytecode | الشيفرة الوسيطة | تعليمات لآلة افتراضية وليست لمعالج حقيقي |
| JIT | الترجمة أثناء التشغيل | ترجمة الأجزاء المتكررة إلى تعليمات آلة أثناء عمل البرنامج |
| Shell | الصدفة | البرنامج الذي يقرأ أوامرك ويطلب من النظام تشغيلها، مثل bash |
| Process | العملية | برنامج قيد التشغيل بذاكرته وحالته ورقمه PID |
| fork | النسخ | إنشاء عملية جديدة نسخة من الحالية |
| execve | الاستبدال | استبدال البرنامج داخل العملية ببرنامج من ملف |
| Exit Status | قيمة الخروج | الرقم الذي يرجعه البرنامج عند انتهائه، و0 تعني النجاح |
| Kernel | النواة | قلب نظام التشغيل الذي يدير المعالج والذاكرة والأجهزة |
| Virtual Memory | الذاكرة الافتراضية | مساحة عناوين خاصة بكل عملية تربطها النواة بالذاكرة الحقيقية |
| Page | الصفحة | وحدة الذاكرة التي تدار بها، وهي هنا 4096 بايت |
| Page Table | جدول الصفحات | جدول يربط كل صفحة افتراضية بإطار في RAM |
| MMU | وحدة إدارة الذاكرة | جزء في المعالج يترجم العناوين الافتراضية إلى حقيقية |
| Page Fault | خطأ الصفحة | تنبيه للنواة عند الوصول إلى صفحة غير مربوطة |
| Heap / Stack | الكومة / المكدس | ذاكرة تطلب أثناء العمل، وذاكرة الدوال ومتغيراتها المحلية |
| CPU | المعالج | ينفذ التعليمات في حلقة جلب وفك وتنفيذ |
| Register | المسجل | خانة تخزين صغيرة داخل المعالج، وهي أسرع ذاكرة |
| Program Counter | عداد البرنامج | مسجل يحمل عنوان التعليمة التالية، واسمه rip على x86-64 |
| Cache | الذاكرة المخبئية | ذاكرة سريعة داخل المعالج تحفظ نسخة مما استخدم مؤخراً، بمستويات L1 وL2 وL3 |
| RAM | الذاكرة الرئيسية | ذاكرة العمل التي يحمل فيها البرنامج، أبطأ من ال Cache بكثير |
| Bus | الناقل | المسار الذي تنتقل عليه البيانات بين المعالج والذاكرة والأجهزة |
| System Call | استدعاء النظام | طلب رسمي من البرنامج إلى النواة، مثل write |
| User Mode / Kernel Mode | وضع المستخدم / وضع النواة | وضعا عمل المعالج، والثاني وحده يصل إلى الأجهزة |
| File Descriptor | واصف الملف | رقم يمثل ملفاً مفتوحاً، و1 هو المخرج القياسي stdout |
| Buffer | الذاكرة المؤقتة | مكان تجمع فيه البيانات قبل إرسالها دفعة واحدة |
| PTY | الطرفية الوهمية | زوج أطراف تربط البرنامج بمحاكي الطرفية |
| Terminal Emulator | محاكي الطرفية | البرنامج الذي يحول البايتات إلى حروف مرسومة في نافذة |
| Glyph | شكل الحرف | الرسم الذي يخزنه الخط لكل حرف أو شكل منه |
| Shaping / BiDi | التشكيل / ثنائية الاتجاه | اختيار شكل الحرف العربي بحسب موقعه، وترتيب النص من اليمين إلى اليسار |
| Frame Buffer | ذاكرة الإطار | الذاكرة التي تحمل بكسلات الشاشة |
| strace | أداة تتبع الاستدعاءات | تعرض كل استدعاءات النظام التي يرسلها برنامج |
| binfmt_misc | تسجيل تنسيقات التنفيذ | ميزة في النواة تحدد برنامجاً يشغل تنسيقات ملفات إضافية، مثل QEMU لملفات ARM |
تدريب عملي
- غير القيمة في
return 0;إلىreturn 3;ثم ابن البرنامج وشغله واكتبecho $?، ثم ابحث عن الرقم 3 في مخرجobjdump -dلتعرف في أي تعليمة صار. - شغل
strace -cعلى أمر تستخدمه كل يوم مثلlsأوcurl، وقارن عدد استدعاءات النظام بعدد استدعاءاتhello، ثم ابحث في مخرجstrace -e trace=openatعن الملفات التي يفتحها.
وعند الانتهاء احذف ما أنشأناه، فال Container يحذف نفسه عند الخروج بسبب الخيار --rm، وتبقى ال Images التي بنيناها:
docker rmi lab-hello-dynamic lab-hello-staticالخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الملف المصدري بايتات فقط، والحرف الإنجليزي بايت واحد في ASCII وUTF-8، والحرف العربي بايتان في UTF-8، والملف يحفظ النص بترتيب القراءة لا بترتيب العرض.
- الأمر
gccأربع مراحل: المعالج المسبق يلصق الملفات، والمترجم ينتج لغة التجميع، والمجمع ينتج ملف كائن بفراغات، والرابط يملأ الفراغات وينتج ملف ELF. - الربط الديناميكي يصغر البرامج ويشارك المكتبات ولكنه يحتاجها على الجهاز، والربط الثابت يحمل كل شيء معه، وهذا ما يجعله مناسباً لل Docker Images الفارغة.
- اللغات المترجمة مسبقاً تنفذ مباشرة وترتبط بمعمارية، والمفسرة تنفذ Bytecode عبر برنامج آخر، وال JIT يجمع بين الطريقتين أثناء التشغيل.
- التشغيل نسخ بالطلب
forkثم استبدال بالطلبexecve، ثم تحميل في ذاكرة افتراضية من صفحات حجمها 4096 بايت يربطها جدول الصفحات بالذاكرة الحقيقية. - المعالج يجلب ويفك وينفذ، والانتظار هو العدو: RAM أبطأ من L1 بنحو مئتي مرة، و SSD أبطأ من RAM بنحو ألف وخمسمئة مرة.
- الطباعة طلب واحد
writeإلى النواة، ثم طرفية وهمية ومحاكي طرفية يفك UTF-8 ويشكل العربية ويرسمها، والتخزين المؤقت يجعل الكتابة إلى ملف أقل طلبات بكثير من الكتابة إلى الطرفية. - عند أي عطل غامض استخدم
straceوlddوfile، فهي تكشف الملف الناقص والمكتبة الناقصة والمعمارية الخاطئة في دقائق.
ولمن أراد التعمق في كل محطة من هذه المحطات فالكتاب الذي ذكرناه في البداية، Computer Systems: A Programmer's Perspective، يشرحها فصلاً فصلاً من منظور المبرمج، وهو من أفضل ما كتب في هذا الباب.
دليلالترميز Encoding للمطورين: Base64 وأخواتهلماذا نحول البايتات إلى حروف حتى تمر في البريد والروابط، وكيف تفك أي سلسلة منها، ولماذا لا يحمي Base64 أي سر.
دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.
دليلإدارة النسخ وGit للمبتدئين: لماذا نحتاجها وكيف تعمللماذا تضيع الملفات بأسماء final_v2 ويمسح الزملاء عمل بعضهم، وكيف يحل Git ذلك على مشروع حقيقي من أول commit.
دليلتثبيت Docker Engine وCompose على Ubuntuكيف تثبت Docker على Ubuntu من المستودع الرسمي، وتضبطه من البداية حتى لا يمتلئ القرص أو تنكشف منافذك للإنترنت.
دليلمصطلحات الاستضافة الذاتية Self-hosting للمبتدئينمرجع مبسط لأهم المصطلحات التي سوف تقابلها في أدلة الاستضافة الذاتية، بأسمائها الإنجليزية كما تظهر في لوحات التحكم، مع شرح قصير لكل منها.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ الأمثلة داخل Container على Ubuntu 24.04 مع GCC 13.3 وglibc 2.39 وPython 3.12.3 وstrace، على Docker Engine 29.8.0.