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

# الرابط Linker والمحمل Loader: ماذا يحدث قبل main؟
- URL: https://arabroot.io/articles/الرابط-linker-والمحمل-loader-ماذا-يحدث-قبل-main/
- Published: 2026-10-31T05:52:53.000Z
- Updated: 2026-10-31T06:54:02.000Z
- Description: نبني مكتبة ضريبة صغيرة بلغة C ونربطها ثابتاً وديناميكياً، ونقرأ ملفاتها بالأدوات readelf وnm وobjdump حتى نفهم من أين تأتي أخطاء undefined reference والمكتبة الناقصة وقت التشغيل.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة

لنفرض أنك في فريق صغير يكتب بلغة C أداتين: أداة فواتير تعمل على السيرفر كل ليلة، وأداة لنقاط البيع تعمل على أجهزة الكاشير، وكلتاهما تحسب ضريبة القيمة المضافة 15%. فقرر الفريق أن يضع حساب الضريبة في مكتبة واحدة اسمها `libvat` بدلاً من نسخ الكود في المشروعين، وهذا قرار صحيح، ولكن في الأسبوع نفسه ظهرت ثلاث رسائل لم يفهمها أحد: خط الـ CI فشل برسالة `` undefined reference to `round_div' `` رغم أن الدالة مكتوبة في ملف موجود، ثم نجح البناء ولكن البرنامج رفض العمل على السيرفر برسالة `error while loading shared libraries: libvat.so.1: cannot open shared object file`، ثم بعد تحديث صغير للمكتبة توقفت أداة التقارير الليلية في منتصف عملها برسالة `symbol lookup error`. والكود في المرات الثلاث لم يتغير فيه شيء خاطئ، وإنما المشكلة في المرحلتين اللتين لا يراهما أغلب المطورين: الربط Linking الذي يحدث بعد المترجم، والتحميل Loading الذي يحدث قبل أن تبدأ الدالة `main`.

وفي مقال [ماذا يحدث عندما تشغل برنامج Hello World؟](https://arabroot.io/articles/%D8%B1%D8%AD%D9%84%D8%A9-hello-world-%D9%85%D9%86-%D9%84%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B4%D8%A7%D8%B4%D8%A9/) مررنا على هاتين المرحلتين مروراً سريعاً مع ملف واحد ودالة واحدة، فرأينا ملف الكائن والفرق بين الربط الثابت والديناميكي ورأس ملف ELF. وفي مقال [أدوات البناء Build Tools](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D9%86%D8%A7%D8%A1-build-tools-%D9%85%D9%86-make-%D8%A7%D9%84%D9%89-maven-%D9%88-gradle-%D9%88-dotnet/) رأينا من يستدعي المترجم والرابط في المشروعات الكبيرة. وهنا سوف ننزل إلى التفاصيل التي تحتاجها عندما يكون في مشروعك أكثر من ملف وأكثر من مكتبة، ونبني مكتبة الضريبة بأيدينا، ونعيد إنتاج الرسائل الثلاث في Container صغير، ونقرأ كل واحدة منها حتى نعرف من كتبها ولماذا.

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

- لماذا وجد الرابط Linker أصلاً، وما الذي يوجد داخل ملف الكائن Object File من رموز Symbols وإعادات تموضع Relocations.
- الخطوات الأربع التي يقوم بها الرابط، وثلاث صور للخطأ `undefined reference`، منها ترتيب المكتبات الثابتة الذي يحير الكثيرين.
- المكتبة الثابتة Static Library والمكتبة المشتركة Shared Library، والاسم `soname` والأسماء الثلاثة لملف المكتبة.
- المكتبة الناقصة وقت التشغيل، وأين يبحث المحمل `ld.so` بالترتيب، والفرق بين `LD_LIBRARY_PATH` و`RUNPATH` و`ldconfig`.
- كيف يصل الاستدعاء إلى دالة في مكتبة مشتركة عبر PLT وGOT، والربط الكسول Lazy Binding مقابل الربط الفوري.
- أحجام وأزمنة بدء مقاسة للربط الثابت والديناميكي، والقياس الأول الذي ضللنا وكيف أصلحناه.
- ماذا يحدث إذا تغيرت المكتبة تحت برنامج مبني، أو كان السيرفر بإصدار glibc أقدم، أو سبقت مكتبة أخرى مكتبتك بالمتغير `LD_PRELOAD`.

## لماذا نحتاج الرابط Linker أصلاً؟ الترجمة المنفصلة Separate Compilation

تخيل أن المترجم يأخذ كل ملفات المشروع مرة واحدة وينتج منها برنامجاً جاهزاً، وهذا ممكن نظرياً ومريح في مشروع من ثلاثة ملفات، ولكنه يفشل لسببين عندما يكبر المشروع. السبب الأول أن أي تعديل في سطر واحد سوف يعيد ترجمة المشروع كله، والمشروعات الحقيقية فيها آلاف الملفات، والسبب الثاني أن جزءاً كبيراً من الكود الذي يستخدمه برنامجك لم تكتبه أنت ولا تملك مصدره دائماً، مثل مكتبة C نفسها التي فيها `printf`، أو مكتبة من مورد يعطيك ملفاً مترجماً فقط. لذلك اتفقت أنظمة التشغيل منذ وقت مبكر على تقسيم العمل إلى مرحلتين: المترجم Compiler يترجم كل ملف وحده إلى ملف كائن Object File لا يعرف شيئاً عن الملفات الأخرى، ثم يأتي الرابط Linker فيجمع هذه الملفات مع المكتبات في ملف تنفيذي واحد، وهذا ما يسمى الترجمة المنفصلة Separate Compilation، وهو السبب أن أداة مثل Make تستطيع أن تعيد ترجمة الملف الذي تغير فقط ثم تعيد الربط.

والمشكلة التي تبقى بعد هذا التقسيم أن ملف الفواتير يستدعي الدالة `vat_total`، وهذه الدالة في ملف آخر، فالمترجم وهو يترجم ملف الفواتير لا يعرف أين ستكون هذه الدالة في الذاكرة ولا حتى إن كانت موجودة. فيترك مكانها فارغاً، ويكتب في ملف الكائن ملاحظة تقول «هنا استدعاء لشيء اسمه `vat_total`، املأ عنوانه لاحقاً». والرابط هو البرنامج الذي يقرأ كل هذه الملاحظات ويملأ كل الفراغات، والمحمل Loader هو البرنامج الذي يكمل ما لم يستطع الرابط أن يملأه، لأن بعض العناوين لا تعرف إلا لحظة التشغيل، وهذه هي الفكرة كلها، وكل ما بعدها تفاصيل.

وفي Linux والأنظمة المشابهة تحفظ ملفات الكائن والملفات التنفيذية والمكتبات المشتركة كلها بتنسيق واحد هو ELF الموصوف في صفحة [elf(5)](https://man7.org/linux/man-pages/man5/elf.5.html?ref=arabroot.io)، والقواعد الدقيقة لكيفية استدعاء الدوال وأنواع إعادة التموضع على معالجات x86-64 موجودة في وثيقة [System V ABI لمعمارية x86-64](https://gitlab.com/x86-psABIs/x86-64-ABI?ref=arabroot.io)، والرابط الذي سوف نستخدمه هو `ld` من حزمة GNU Binutils، وتوثيق خياراته في [صفحة خيارات ld](https://sourceware.org/binutils/docs/ld/Options.html?ref=arabroot.io)، والمحمل هو `ld.so` من مكتبة glibc، وتوثيقه في صفحة [ld.so(8)](https://man7.org/linux/man-pages/man8/ld.so.8.html?ref=arabroot.io)، وسوف نرجع إلى هذه الصفحات كثيراً.

![مسار مكتبة الضريبة من الملفات المصدرية إلى البرنامج الذي يعمل: ثلاثة ملفات C يترجم كل منها وحده إلى ملف كائن فيه فراغات، ثم يجمعها الرابط ld وقت البناء مع المكتبة الثابتة أو يكتب اسم المكتبة المشتركة في الملف، ثم وقت التشغيل تحمل النواة البرنامج والمحمل ld.so الذي يبحث عن libvat.so.1 وlibc.so.6 ويملأ العناوين الباقية ثم يقفز إلى main](https://arabroot.io/content/images/2026/10/linkers-loaders-01-pipeline.webp)

مرحلتان بعد المترجم: الرابط وقت البناء والمحمل وقت التشغيل، وكل رسالة من رسائل القصة جاءت من واحدة منهما

## تجهيز المختبر Lab Setup

كل التجارب تعمل داخل Container من الـ Image الرسمية `debian:trixie-slim`، نضيف إليها المترجم gcc وأدوات Binutils والأداة hyperfine للقياس والأداة strace، ولا نثبت شيئاً على جهازك. والملف `Dockerfile` كما يلي:

```dockerfile
# Lab image for the linking and loading post: gcc, binutils, hyperfine on Debian 13 (trixie)
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev binutils file hyperfine strace \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /lab
```

نبني الـ Image ونشغل كل خطوة من خطوات المختبر بسكربت صغير يحفظ مخرجه بجانبه:

```bash
docker build -t scc2-linkers .
docker run --rm -v "$PWD":/lab scc2-linkers sh /lab/01-objects.sh
```

والإصدارات في التجربة هي gcc 14.2.0 وGNU ld 2.44 وglibc 2.41 على Debian 13.7، ومكتبة الضريبة نفسها أربعة ملفات صغيرة: الملف `vat.h` يعلن عن دالتين ومتغير، والملف `vat.c` يعرفها، والملف `round.c` فيه دالة تقريب، والملف `invoice.c` هو أداة الفواتير. والمبالغ بالهللة حتى لا ندخل في مشكلات الأعداد العشرية التي شرحناها في مقالات سابقة:

```c
/* libvat: VAT helpers shared by the invoice tool and the POS tool */
#ifndef VAT_H
#define VAT_H
long vat_amount(long halalas);           /* VAT on an amount in halalas (1 SAR = 100 halalas) */
long vat_total(long halalas);            /* amount + VAT */
extern int vat_rate_percent;             /* 15 */
#endif
```

```c
#include "vat.h"
#include "round.h"
int vat_rate_percent = 15;
long vat_amount(long halalas) { return round_div(halalas * vat_rate_percent, 100); }
long vat_total(long halalas)  { return halalas + vat_amount(halalas); }
```

```c
#include "round.h"
long round_div(long a, long b) { return (a + b / 2) / b; }
```

```c
#include <stdio.h>
#include <stdlib.h>
#include "vat.h"
int main(int argc, char **argv) {
    long amount = argc > 1 ? atol(argv[1]) : 9999;     /* 99.99 SAR */
    printf("amount %ld.%02ld  vat %ld.%02ld  total %ld.%02ld  (rate %d%%)\n",
           amount / 100, amount % 100, vat_amount(amount) / 100, vat_amount(amount) % 100,
           vat_total(amount) / 100, vat_total(amount) % 100, vat_rate_percent);
    return 0;
}
```

## ملف الكائن Object File من الداخل

نبدأ بالخطوة التي يقوم بها المترجم وحده: نترجم كل ملف دون ربط بالخيار `-c`، ثم نفتح الملفات الناتجة بثلاث أدوات من Binutils، الأداة [nm](https://sourceware.org/binutils/docs/binutils/nm.html?ref=arabroot.io) التي تعرض الرموز، والأداة [readelf](https://sourceware.org/binutils/docs/binutils/readelf.html?ref=arabroot.io) التي تقرأ كل أجزاء ملف ELF، والأداة [objdump](https://sourceware.org/binutils/docs/binutils/objdump.html?ref=arabroot.io) التي تحول تعليمات الآلة إلى لغة التجميع:

```bash
gcc -c -O2 ../src/round.c ../src/vat.c ../src/invoice.c
ls -l *.o
nm vat.o
nm invoice.o
```

والمخرج سوف يكون كما يلي:

```text
-rw-r--r-- 1 root root 2112 Oct 31 06:30 invoice.o
-rw-r--r-- 1 root root 1104 Oct 31 06:30 round.o
-rw-r--r-- 1 root root 1472 Oct 31 06:30 vat.o
                 U round_div
0000000000000000 T vat_amount
0000000000000000 D vat_rate_percent
0000000000000020 T vat_total
0000000000000000 r .LC0
0000000000000000 T main
                 U printf
                 U strtol
                 U vat_amount
                 U vat_rate_percent
                 U vat_total
```

### الرموز Symbols: ما يعرفه الملف وما يحتاجه

جدول الرموز Symbol Table هو قائمة الأسماء التي يعرضها الملف للعالم أو يطلبها منه، وكل سطر في مخرج `nm` رمز واحد، والحرف قبل الاسم يقول أين هو. وفي المخرج أعلاه لاحظ التالي:

- الحرف `T` يعني أن الرمز معرف في قسم الكود `.text` في هذا الملف، فالملف `vat.o` يملك `vat_amount` عند الموقع 0 و`vat_total` عند الموقع 0x20، وهذه المواقع نسبية داخل القسم وليست عناوين في الذاكرة، ولذلك تبدأ من الصفر في كل ملف.
- الحرف `D` أمام `vat_rate_percent` يعني متغيراً له قيمة أولية في قسم البيانات `.data`، ولو عرفناه دون قيمة لظهر بالحرف `B` في القسم `.bss` الذي لا يأخذ مساحة في الملف.
- الحرف `U` يعني Undefined، أي أن الملف يحتاج هذا الرمز ولا يملكه، فالملف `vat.o` يحتاج `round_div`، والملف `invoice.o` يحتاج خمسة رموز، ثلاثة منها من مكتبتنا واثنان من مكتبة C.
- الرمز `strtol` لم نكتبه، فنحن كتبنا `atol`، ولكن ملف الترويسة `stdlib.h` في glibc يعرف `atol` كدالة صغيرة تستدعي `strtol`، فاستبدلها المترجم مع `-O2`، وهذا تذكير بأن ما تطلبه أنت في الكود ليس دائماً ما يطلبه الملف من الرابط.
- الحرف الصغير `r` أمام `.LC0` رمز محلي Local في قسم البيانات للقراءة فقط، وهو نص التنسيق الذي مررناه إلى `printf`، والحروف الصغيرة عموماً رموز لا يراها إلا الملف نفسه، مثل الدوال التي تعرفها بالكلمة `static`.

والأداة `readelf -s` تعرض نفس الجدول بتفاصيل أكثر، وأهم ما تضيفه الحجم والنوع والربط Binding:

```text
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000000    24 FUNC    GLOBAL DEFAULT    1 vat_amount
     4: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 vat_rate_percent
     5: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND round_div
     6: 0000000000000020    30 FUNC    GLOBAL DEFAULT    1 vat_total
```

الدالة `vat_amount` حجمها 24 بايتاً والمتغير 4 بايتات، والعمود `GLOBAL` يعني أن الرمز يراه الرابط من الملفات الأخرى، ولو كان `LOCAL` لما استطاع ملف آخر أن يستخدمه، والعمود قبل الأخير رقم القسم، والقيمة `UND` أمام `round_div` تعني أنه غير موجود في أي قسم من هذا الملف. ولاحظ أن الملف لا يعرف عن `round_div` إلا اسمها، فلا نوع ولا حجم ولا عدد معاملات، وهذا مهم لما سوف نراه لاحقاً: الرابط يطابق أسماء فقط، ولا يعرف إن كانت الدالة التي وجدها لها نفس التوقيع الذي افترضه من استدعاها.

### إعادة التموضع Relocations: الفراغات التي يملؤها الرابط

الرموز تقول ماذا يحتاج الملف، وإعادات التموضع Relocations تقول أين يحتاجه بالضبط، أي في أي بايت من الكود يجب أن يكتب عنوان كل رمز. نعرضها بالأمر `readelf -r` ثم نقارنها بالكود نفسه:

```bash
readelf -r vat.o
objdump -d --no-show-raw-insn vat.o
```

```text
Relocation section '.rela.text' at offset 0x1d0 contains 4 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000006  000400000002 R_X86_64_PC32     0000000000000000 vat_rate_percent - 4
000000000027  000400000002 R_X86_64_PC32     0000000000000000 vat_rate_percent - 4
000000000035  000500000004 R_X86_64_PLT32    0000000000000000 round_div - 4
000000000014  000500000004 R_X86_64_PLT32    0000000000000000 round_div - 4

0000000000000000 <vat_amount>:
   0:	mov    %rdi,%rax
   3:	movslq 0x0(%rip),%rdi        # a <vat_amount+0xa>
   a:	mov    $0x64,%esi
   f:	imul   %rax,%rdi
  13:	jmp    18 <vat_amount+0x18>
  18:	nopl   0x0(%rax,%rax,1)

0000000000000020 <vat_total>:
  20:	push   %rbx
  21:	mov    %rdi,%rbx
  24:	movslq 0x0(%rip),%rdi        # 2b <vat_total+0xb>
  2b:	mov    $0x64,%esi
  30:	imul   %rbx,%rdi
  34:	call   39 <vat_total+0x19>
  39:	add    %rbx,%rax
  3c:	pop    %rbx
  3d:	ret
```

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

- التعليمة عند الموقع 3 تقرأ `vat_rate_percent`، ولكنها مكتوبة `0x0(%rip)`، أي «اقرأ من العنوان الذي يبعد صفراً عن التعليمة التالية»، وهذا عنوان مؤقت، والسطر الأول من جدول إعادة التموضع يقول إن البايتات عند الموقع 6، وهي البايتات الأربعة الأخيرة من هذه التعليمة، يجب أن يكتب فيها الفرق بين عنوان المتغير وعنوان التعليمة، ومعنى `PC32` أن العنوان نسبي إلى عداد البرنامج Program Counter، و`- 4` تصحيح لأن المعالج يحسب الفرق من نهاية التعليمة.
- الاستدعاء عند 0x34 مكتوب `call 39`، أي «استدع التعليمة التي بعدي»، وهو بلا معنى قبل الربط، والسطر الثالث من الجدول يقول إن الموقع 0x35 يجب أن يملأ بعنوان `round_div`، والنوع `PLT32` يعني أن الرابط يستطيع أن يوجه الاستدعاء إلى الدالة مباشرة إذا وجدها في ملف آخر، أو إلى جدول PLT إذا كانت في مكتبة مشتركة، وسوف نرجع إلى هذا الجدول بعد قليل.
- الدالة `vat_amount` لا تستدعي `round_div` بالتعليمة `call`، وإنما تقفز إليها بالتعليمة `jmp` عند 0x13، لأن الاستدعاء آخر شيء في الدالة، فاستبدل المترجم الاستدعاء بتعليمة `jmp` تعود منها `round_div` مباشرة إلى من استدعى `vat_amount`، وهذا تحسين اسمه Tail Call، ولكن تعليمة `jmp` نفسها تحتاج إعادة تموضع عند الموقع 0x14 كما في السطر الرابع.

وهذه هي اللحظة التي تفهم فيها ما سوف يقوله لك الرابط عند الخطأ، فالرسالة `` vat.c:(.text+0x35): undefined reference to `round_div' `` التي سوف نراها بعد قليل تشير إلى نفس الموقع 0x35 في نفس الجدول: الرابط وصل إلى هذا الفراغ، ولم يجد في أي ملف رمزاً بهذا الاسم ليملأه به.

![ملف الكائن vat.o من الداخل: جدول الرموز فيه vat_amount وvat_total بالحرف T والمتغير vat_rate_percent بالحرف D والدالة round_div بالحرف U، وجدول إعادة التموضع فيه أربعة أسطر يشير كل منها إلى أربعة بايتات صفرية في الكود عند المواقع 0x6 و0x14 و0x27 و0x35، والرابط يأخذ عنوان كل رمز ويكتبه في هذه البايتات](https://arabroot.io/content/images/2026/10/linkers-loaders-02-symbols-relocs.webp)

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

## ماذا يفعل الرابط Linker بالضبط؟ أربع خطوات

عندما تكتب `gcc invoice.o vat.o round.o -o invoice` فإن gcc نفسه لا يربط شيئاً، وإنما يستدعي البرنامج `collect2` الذي يستدعي `ld`، ويضيف إلى سطر الأوامر ملفات لم تكتبها: ملفات بدء التشغيل مثل `Scrt1.o` و`crti.o` التي فيها الدالة `_start` التي تستدعي `main`، ومكتبة C بالخيار `-lc`، واسم المحمل الديناميكي. ويمكنك أن ترى ما فتحه الرابط فعلاً بالخيار `-Wl,--trace`، وفي تجربتنا فتح قبل ملفنا `Scrt1.o` و`crti.o` و`crtbeginS.o`، وبعده مكتبة gcc المساعدة `libgcc.a` و`libgcc_s.so`، ثم `/lib/x86_64-linux-gnu/libc.so.6` و`libc_nonshared.a` و`/lib64/ld-linux-x86-64.so.2`، وفي النهاية `crtendS.o` و`crtn.o`، أي أن كل برنامج C ديناميكي يربط مع المحمل نفسه كأنه مكتبة، وأن أصغر برنامج تكتبه يمر بعشرة ملفات تقريباً لم تكتب منها شيئاً.

ثم يقوم الرابط بأربع خطوات بالترتيب:

1. **قراءة المدخلات وحل الرموز Symbol Resolution**: يمر على الملفات بالترتيب الذي كتبته، ويبني جدولاً عاماً لكل الرموز، فكل رمز معرف يسجل مع الملف الذي عرفه، وكل رمز غير معرف يضاف إلى قائمة «المطلوب». وإذا وصل إلى النهاية وفي القائمة رمز لم يجد له تعريفاً ظهرت الرسالة `undefined reference`، وإذا وجد تعريفين قويين لنفس الرمز ظهرت الرسالة `multiple definition`.
2. **دمج الأقسام Section Merging**: يضع أقسام `.text` من كل الملفات بجانب بعضها في قسم `.text` واحد، وكذلك `.data` و`.rodata` و`.bss`، بحسب سكربت الربط Linker Script الافتراضي الذي يحدد الترتيب والمحاذاة.
3. **تحديد العناوين Address Assignment**: بعد الدمج صار لكل قسم مكان في الملف النهائي، فيعرف الرابط الآن أن `vat_amount` مثلاً على بعد كذا بايت من بداية البرنامج، وصار لكل رمز عنوان.
4. **تطبيق إعادات التموضع Relocation**: يمر على كل سطر في جداول إعادة التموضع، ويحسب القيمة بحسب النوع، ويكتبها في البايتات المحددة، فتتحول `call 39` إلى استدعاء حقيقي لعنوان `round_div`.

وما لا يستطيع الرابط حله في هذه الخطوات لأنه في مكتبة مشتركة لا يكتب في الكود، وإنما يكتب في جداول خاصة يقرؤها المحمل وقت التشغيل، وهي الأقسام `.dynsym` و`.rela.dyn` و`.rela.plt` التي سوف نراها في البرنامج الديناميكي.

## الخطأ undefined reference: ثلاث صور لنفس المشكلة

نعود إلى الرسالة الأولى في القصة، ونعيد إنتاجها بالطرق التي تحدث بها فعلاً في المشروعات، والسكربت `02-link-errors.sh` يربط ملفات الكائن نفسها بعدة طرق ويطبع رمز الخروج بعد كل محاولة.

### الملف المنسي Missing Object

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

```bash
gcc invoice.o -o invoice
gcc invoice.o vat.o -o invoice
gcc invoice.o vat.o round.o -o invoice && ./invoice 9999
```

```text
### A: invoice.o alone
/usr/bin/ld: invoice.o: warning: relocation against `vat_rate_percent' in read-only section `.text.startup'
/usr/bin/ld: invoice.o: in function `main':
invoice.c:(.text.startup+0x2b): undefined reference to `vat_rate_percent'
/usr/bin/ld: invoice.c:(.text.startup+0x30): undefined reference to `vat_total'
/usr/bin/ld: invoice.c:(.text.startup+0x3b): undefined reference to `vat_total'
/usr/bin/ld: invoice.c:(.text.startup+0x46): undefined reference to `vat_amount'
/usr/bin/ld: invoice.c:(.text.startup+0x51): undefined reference to `vat_amount'
/usr/bin/ld: warning: creating DT_TEXTREL in a PIE
collect2: error: ld returned 1 exit status
exit=1
### B: invoice.o + vat.o (round.o forgotten)
/usr/bin/ld: vat.o: in function `vat_total':
vat.c:(.text+0x35): undefined reference to `round_div'
/usr/bin/ld: vat.o: in function `vat_amount':
vat.c:(.text+0x14): undefined reference to `round_div'
collect2: error: ld returned 1 exit status
exit=1
### C: all three
exit=0
amount 99.99  vat 15.00  total 114.99  (rate 15%)
```

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

- كل رسالة تبدأ بالملف والدالة والموقع داخل القسم، مثل `invoice.c:(.text.startup+0x30)`، وهذه هي إعادة التموضع التي لم يجد الرابط رمزها، والقسم اسمه `.text.startup` لأن gcc يضع الدالة `main` في قسم خاص بها مع `-O2`.
- الرسالة تتكرر لكل استدعاء وليس لكل دالة، فاستدعاءان لـ `vat_total` يعطيان سطرين، ولذلك قد ترى في مشروع حقيقي مئات الأسطر سببها ملف واحد منسي، فاقرأ أسماء الرموز ولا تخف من العدد.
- كلمة `collect2` في السطر الأخير تقول إن الخطأ من الرابط وليس من المترجم، وهذا أول ما تبحث عنه: إذا كانت الرسالة فيها `ld returned 1` فالكود ترجم بنجاح، والمشكلة في الملفات التي أعطيتها للرابط وليس في الكود.
- التحذيران عن `read-only section` و`DT_TEXTREL` نتيجة جانبية للخطأ نفسه، فالرابط لم يجد المتغير، فحاول أن يتركه للمحمل بإعادة تموضع داخل الكود، وهذا غير مقبول في برنامج PIE، ويختفي التحذير عندما يجد التعريف.
- في المحاولة الثانية تغير الملف الذي يشتكي منه الرابط، فأصبح `vat.o` هو الذي يحتاج `round_div`، والموقعان `0x35` و`0x14` هما بالضبط السطران الثالث والرابع في جدول إعادة التموضع الذي قرأناه قبل قليل.

### التعريف المكرر Multiple Definition

الصورة المعاكسة أن يجد الرابط تعريفين، ولنفرض أن مطوراً آخر أضاف ملفاً لنسبة مخفضة وعرف فيه المتغير بنفس الاسم `int vat_rate_percent = 5;`:

```text
### D: a second definition of vat_rate_percent
/usr/bin/ld: dup.o:(.data+0x0): multiple definition of `vat_rate_percent'; vat.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status
exit=1
```

والرسالة واضحة: التعريف الأول في `vat.o` والثاني في `dup.o`، ولا يعرف الرابط أيهما تقصد. ولكن هذا لم يكن الحال دائماً، فقبل الإصدار 10 من gcc كان المتغير العام الذي يعرف دون قيمة أولية في أكثر من ملف يعامل كرمز مشترك Common Symbol، فيدمج الرابط التعريفين بصمت في متغير واحد، وكثير من مشروعات C القديمة كانت تعتمد على ذلك دون أن تعرف. ثم غير gcc 10 الإعداد الافتراضي إلى `-fno-common` كما في [دليل الانتقال إلى GCC 10](https://gcc.gnu.org/gcc-10/porting%5Fto.html?ref=arabroot.io)، فظهر خطأ `multiple definition` في مشروعات كانت تبنى بنجاح لسنوات. والإصلاح الصحيح ليس الرجوع إلى `-fcommon`، وإنما أن يكون للمتغير تعريف واحد في ملف `.c` واحد، وإعلان بالكلمة `extern` في ملف الترويسة كما فعلنا في `vat.h`، وإذا كان المتغير خاصاً بملف واحد فاجعله `static` حتى لا يراه الرابط أصلاً.

### ترتيب المكتبات الثابتة Static Library Order

هذه الصورة هي التي تضيع فيها ساعات، لأن كل الملفات موجودة وكل الرموز معرفة، ومع ذلك يفشل الربط. نجمع أولاً ملفي المكتبة في مكتبة ثابتة Static Library بالأداة [ar](https://sourceware.org/binutils/docs/binutils/ar.html?ref=arabroot.io)، والمكتبة الثابتة ليست أكثر من أرشيف فيه ملفات الكائن كما هي مع فهرس للرموز:

```bash
ar rcs libvat.a vat.o round.o
ar t libvat.a
nm -s libvat.a | head -n 8
```

```text
vat.o
round.o

Archive index:
vat_amount in vat.o
vat_rate_percent in vat.o
vat_total in vat.o
round_div in round.o
```

الحرف `s` في `rcs` هو الذي يبني الفهرس Archive Index، والفهرس يقول للرابط أي ملف داخل الأرشيف يعرف أي رمز، فلا يحتاج أن يفتحها كلها. والآن نربط بطريقتين لا تختلفان إلا في الترتيب:

```bash
gcc -L. -lvat invoice.o -o invoice-static-lib
gcc invoice.o -L. -lvat -o invoice-static-lib && ./invoice-static-lib 9999
```

```text
### F: library before the object
/usr/bin/ld: invoice.o: in function `main':
invoice.c:(.text.startup+0x2b): undefined reference to `vat_rate_percent'
/usr/bin/ld: invoice.c:(.text.startup+0x30): undefined reference to `vat_total'
...
collect2: error: ld returned 1 exit status
exit=1
### G: library after the object
exit=0
amount 99.99  vat 15.00  total 114.99  (rate 15%)
```

والسبب في طريقة تعامل الرابط مع الأرشيف، فهو لا يأخذ المكتبة الثابتة كلها، وإنما يأخذ منها فقط الملفات التي تعرف رمزاً موجوداً في قائمة «المطلوب» لحظة وصوله إليها، ثم يتركها ولا يرجع إليها. ففي المحاولة الأولى وصل الرابط إلى `libvat.a` وقائمة المطلوب فارغة، لأنه لم يقرأ `invoice.o` بعد، فلم يأخذ منها شيئاً، ثم قرأ `invoice.o` فظهرت خمسة رموز مطلوبة، ولكن المكتبة صارت خلفه. وفي المحاولة الثانية قرأ `invoice.o` أولاً، فلما وصل إلى المكتبة أخذ منها `vat.o` لأنه يعرف `vat_total`، وهذا الملف نفسه أضاف `round_div` إلى المطلوب، فرجع الرابط إلى الفهرس داخل الأرشيف نفسه وأخذ `round.o`، لأن البحث داخل الأرشيف الواحد يتكرر حتى لا يجد جديداً.

والقاعدة التي تخرج من هذا: من يحتاج يكتب قبل من يعطي، فالملفات أولاً ثم المكتبات، والمكتبة التي تعتمد على مكتبة أخرى تكتب قبلها، فإذا كانت `libinvoice.a` تستخدم `libvat.a` فالترتيب `-linvoice -lvat`. وإذا كانت مكتبتان تحتاج كل منهما الأخرى، وهذا تصميم يستحق المراجعة، فإما أن تكرر الأولى بعد الثانية، أو تحيطهما بالخيارين `-Wl,--start-group` و`-Wl,--end-group` فيكرر الرابط المرور عليهما حتى لا يجد رمزاً جديداً، وهذا أبطأ ولذلك ليس الافتراضي. ولاحظ أن هذا السلوك خاص بالمكتبات الثابتة مع GNU ld، فالمكتبات المشتركة وملفات الكائن المكتوبة صراحة تؤخذ كاملة مهما كان موقعها، وبعض الروابط الأحدث مثل LLD لا تهتم بالترتيب، ولذلك قد يعمل المشروع على macOS أو مع LLD ويفشل مع GNU ld في الـ CI.

وواجهنا في التجربة نفسها صورة أخرى لنفس الفكرة: عندما أعدنا تشغيل خطوة الربط بعد أن بنينا المكتبة المشتركة في المجلد نفسه، نجح الربط ولكن البرنامج رفض العمل برسالة المكتبة الناقصة، والسبب أن الخيار `-lvat` عندما يجد في المجلد `libvat.a` و`libvat.so` معاً يأخذ المشتركة، فصار البرنامج يحتاج `libvat.so.1` وقت التشغيل دون أن نقصد. والسكربت `02c-so-preferred.sh` يعيد إنتاج ذلك، ويبين الحل وهو أن تطلب الملف الثابت باسمه بالصيغة `-l:libvat.a`:

```text
libvat.a
libvat.so
 0x0000000000000001 (NEEDED)             Shared library: [libvat.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
./inv: error while loading shared libraries: libvat.so.1: cannot open shared object file: No such file or directory
exit=127
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
amount 99.99  vat 15.00  total 114.99  (rate 15%)
exit=0
```

البرنامج الأول فيه السطر `NEEDED` لمكتبتنا رغم أننا ظننا أننا نربط ثابتاً، والثاني ليس فيه إلا `libc.so.6`، وهذا ما تتحقق منه دائماً بالأمر `readelf -d` بعد أي تغيير في سطر الربط. ويمكنك أيضاً أن تفرض المكتبات الثابتة على جزء من السطر بالخيارين `-Wl,-Bstatic` و`-Wl,-Bdynamic` حوله.

![الرابط يمر على المدخلات من اليسار إلى اليمين: في الترتيب الخاطئ يصل إلى libvat.a وقائمة المطلوب فارغة فلا يأخذ منها شيئاً ثم يقرأ invoice.o فتظهر خمسة رموز مطلوبة ولا يجد لها تعريفاً، وفي الترتيب الصحيح يقرأ invoice.o أولاً فيأخذ من الأرشيف vat.o ثم round.o لأن vat.o طلب round_div](https://arabroot.io/content/images/2026/10/linkers-loaders-03-link-order.webp)

الرابط يأخذ من المكتبة الثابتة ما هو مطلوب لحظة وصوله إليها فقط، ولذلك من يحتاج يكتب قبل من يعطي

## المكتبة المشتركة Shared Library: الكود المستقل عن الموضع PIC

المكتبة الثابتة تحل مشكلة تكرار الكود في المصدر، ولكنها تنسخ الكود نفسه داخل كل برنامج، فإذا وجد الفريق خطأ في التقريب فعليه أن يعيد بناء أداة الفواتير وأداة نقاط البيع وكل أداة أخرى تستخدم المكتبة. والمكتبة المشتركة Shared Library تحل ذلك بأن تبقى ملفاً مستقلاً يحمل وقت التشغيل، فتحدثه مرة واحدة فيستفيد كل برنامج، وتشترك البرامج التي تعمل في نفس الوقت في نسخة واحدة من كودها في الذاكرة الحقيقية، كما شرحنا في مقال [الذاكرة الافتراضية Virtual Memory](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D8%A7%D9%84%D8%A7%D9%81%D8%AA%D8%B1%D8%A7%D8%B6%D9%8A%D8%A9-virtual-memory-%D9%88%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B5%D9%81%D8%AD%D8%A7%D8%AA/)، لأن صفحات الكود للقراءة فقط، فيضعها النظام في إطار واحد ويربطه بكل العمليات.

ولكن هذه المشاركة لها شرط: المكتبة قد تحمل في عنوان مختلف في كل عملية، لأن كل عملية لها مكتبات أخرى وعناوين عشوائية بسبب ASLR، فلا يصح أن يكون في كودها عنوان ثابت، وإلا احتاج المحمل أن يعدل الكود نفسه في كل عملية فتضيع المشاركة. لذلك نترجم ملفات المكتبة بالخيار `-fPIC`، أي كود مستقل عن الموضع Position-Independent Code كما في [خيارات توليد الكود في gcc](https://gcc.gnu.org/onlinedocs/gcc/Code-Gen-Options.html?ref=arabroot.io)، حيث يصل الكود إلى بياناته بعناوين نسبية إلى التعليمة الحالية، ويصل إلى الرموز الخارجية عبر جدول عناوين منفصل يعدله المحمل، وهذا الجدول هو GOT الذي سوف نراه في البرنامج نفسه:

```bash
gcc -c -O2 -fPIC ../src/vat.c -o vat.pic.o
gcc -c -O2 -fPIC ../src/round.c -o round.pic.o
gcc -shared -Wl,-soname,libvat.so.1 -o libvat.so.1.0.0 vat.pic.o round.pic.o
ln -sf libvat.so.1.0.0 libvat.so.1
ln -sf libvat.so.1 libvat.so
ls -l libvat.so*
readelf -d libvat.so.1.0.0 | grep -E 'SONAME|NEEDED'
```

```text
lrwxrwxrwx 1 root root    11 Oct 31 06:11 libvat.so -> libvat.so.1
lrwxrwxrwx 1 root root    15 Oct 31 06:11 libvat.so.1 -> libvat.so.1.0.0
-rwxr-xr-x 1 root root 15256 Oct 31 06:11 libvat.so.1.0.0
 0x000000000000000e (SONAME)             Library soname: [libvat.so.1]
```

### اسم المكتبة soname: ثلاثة أسماء لملف واحد

قد يتساءل البعض: لماذا ثلاثة أسماء لملف واحد؟ والإجابة أن كل اسم يقرؤه برنامج مختلف في وقت مختلف، وهذا الاصطلاح موصوف في [سياسة Debian للمكتبات المشتركة](https://www.debian.org/doc/debian-policy/ch-sharedlibs.html?ref=arabroot.io) وتتبعه كل التوزيعات:

- **الاسم الحقيقي** `libvat.so.1.0.0` هو الملف الفعلي، ورقمه يتغير مع كل إصدار، حتى لو كان إصلاحاً صغيراً.
- **اسم soname** `libvat.so.1` هو العقد مع البرامج، والرقم فيه لا يتغير إلا عندما تتغير واجهة المكتبة بطريقة لا تتوافق مع البرامج المبنية سابقاً، أي عندما يتغير الـ ABI. وهذا الاسم مكتوب داخل المكتبة نفسها في السطر `SONAME`، ويقرؤه المحمل وقت التشغيل.
- **اسم الربط** `libvat.so` بلا أرقام يقرؤه الرابط وقت البناء فقط، عندما تكتب `-lvat`، ولذلك تضعه التوزيعات في حزم التطوير مثل `libssl-dev` وليس في حزمة المكتبة نفسها، فالسيرفر الذي يشغل البرامج لا يحتاجه.

والحيلة المهمة هنا أن الرابط عندما يجد `libvat.so` يفتحه، ويقرأ منه الـ soname، ويكتب في البرنامج `libvat.so.1` وليس `libvat.so` ولا `libvat.so.1.0.0`، فيبحث البرنامج وقت التشغيل عن العقد، لا عن إصدار بعينه. لذلك عندما يصدر الفريق النسخة 1.0.1 بإصلاح التقريب، يضعون الملف الجديد ويحركون الرابط الرمزي `libvat.so.1` إليه، فتستخدمه كل البرامج من التشغيل التالي دون إعادة بناء، والأداة `ldconfig` تفعل هذا التحريك تلقائياً كما سوف نرى.

![ثلاثة أسماء لمكتبة واحدة: libvat.so يقرؤه الرابط ld وقت البناء من الخيار -lvat ويوجد في حزمة التطوير، وlibvat.so.1 هو soname المكتوب داخل المكتبة والذي ينسخه الرابط إلى السطر NEEDED في البرنامج ويبحث عنه المحمل وقت التشغيل، وlibvat.so.1.0.0 هو الملف الحقيقي الذي يتغير رقمه مع كل إصدار](https://arabroot.io/content/images/2026/10/linkers-loaders-04-soname.webp)

الرابط يقرأ اسم الربط، والبرنامج يحفظ اسم العقد soname، والملف الحقيقي يتغير تحتهما مع كل إصلاح

## المكتبة الناقصة وقت التشغيل: cannot open shared object file

نربط أداة الفواتير الآن بالمكتبة المشتركة، وننظر إلى ما كتبه الرابط في قسمها الديناميكي، ثم نشغلها:

```bash
gcc invoice.o -L. -lvat -o invoice-dyn
readelf -d invoice-dyn | grep -E 'NEEDED|RUNPATH|RPATH'
./invoice-dyn 9999; echo "exit=$?"
ldd ./invoice-dyn
```

```text
 0x0000000000000001 (NEEDED)             Shared library: [libvat.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
./invoice-dyn: error while loading shared libraries: libvat.so.1: cannot open shared object file: No such file or directory
exit=127
	linux-vdso.so.1 (0x00007ffcdfd3f000)
	libvat.so.1 => not found
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fbd12746000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fbd12943000)
```

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

- الربط نجح لأن الخيار `-L.` قال للرابط أين يجد `libvat.so`، ولكن هذا الخيار لا يكتب في البرنامج شيئاً، فالرابط كتب الاسم فقط في السطر `NEEDED` دون مسار، وهذا هو سوء الفهم الأشهر: `-L` لوقت البناء فقط، والمحمل وقت التشغيل لا يعرف عنه شيئاً.
- الرسالة تبدأ بالكلمات `error while loading shared libraries`، وهذه رسالة المحمل `ld.so` وليس برنامجنا، فالدالة `main` لم تبدأ أصلاً، ورمز الخروج 127 هو الرمز الذي يستخدمه المحمل لهذه الحالة.
- الأمر `ldd` يعرض نتيجة البحث دون تشغيل البرنامج، والسطر `not found` يحدد المكتبة الناقصة بالضبط. وتنبه إلى أن صفحة [ldd(1)](https://man7.org/linux/man-pages/man1/ldd.1.html?ref=arabroot.io) تحذر من استخدامه على ملف لا تثق به، لأنه في بعض الحالات قد يشغل جزءاً من البرنامج، والأسلم مع الملفات المجهولة أن تقرأ السطر `NEEDED` بالأداة `readelf -d`.

### أين يبحث المحمل ld.so؟ ترتيب البحث Search Order

بدل أن نخمن، نطلب من المحمل نفسه أن يقول أين بحث، بالمتغير `LD_DEBUG=libs` الموصوف في صفحة ld.so:

```bash
LD_DEBUG=libs ./invoice-dyn 9999 2>&1 | grep -E 'find library|search path|trying file' | head -n 12
```

```text
        35:	find library=libvat.so.1 [0]; searching
        35:	 search path=/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v3:/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v2:/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v3:/usr/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v2:/usr/lib/x86_64-linux-gnu:/lib/glibc-hwcaps/x86-64-v3:/lib/glibc-hwcaps/x86-64-v2:/lib:/usr/lib/glibc-hwcaps/x86-64-v3:/usr/lib/glibc-hwcaps/x86-64-v2:/usr/lib		(system search path)
        35:	  trying file=/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v3/libvat.so.1
        35:	  trying file=/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v2/libvat.so.1
        35:	  trying file=/lib/x86_64-linux-gnu/libvat.so.1
        35:	  trying file=/usr/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v3/libvat.so.1
        ...
```

الرقم 35 في أول كل سطر رقم العملية، والسطر الثاني يقول إن المحمل بعد أن لم يجد المكتبة في ملف الذاكرة المؤقتة `/etc/ld.so.cache` انتقل إلى مسارات النظام الافتراضية، وجرب في كل مسار مجلدات فرعية باسم `glibc-hwcaps/x86-64-v3` و`x86-64-v2` قبل المجلد نفسه، وهذه ميزة في glibc تسمح للتوزيعة بأن تضع نسخة من المكتبة مترجمة لمعالجات أحدث، فيختارها المحمل تلقائياً إذا كان المعالج يدعم تعليماتها. ولم يجرب المحمل المجلد الحالي ولا مجلد البرنامج، لأن البحث في المجلد الحالي ثغرة أمنية معروفة، فأي شخص يستطيع أن يضع مكتبة باسم `libc.so.6` في مجلد ويقنعك بتشغيل برنامج منه.

والترتيب الكامل كما في صفحة ld.so هو: أولاً المسارات في `DT_RPATH` داخل البرنامج إن وجدت ولم يوجد `DT_RUNPATH`، ثم المتغير `LD_LIBRARY_PATH`، ثم المسارات في `DT_RUNPATH`، ثم الملف `/etc/ld.so.cache`، ثم المسارات الافتراضية `/lib` و`/usr/lib` وما يشبهها. وجربنا هذا الترتيب بوضع نسختين من المكتبة في `/opt/a` و`/opt/b`، ثم بنينا البرنامج مرة بـ `RUNPATH` يشير إلى `/opt/a`، ومرة بـ `RPATH` القديم بالخيار `--disable-new-dtags`، وشغلنا كلاً منهما مع `LD_LIBRARY_PATH=/opt/b`:

```text
 0x000000000000001d (RUNPATH)            Library runpath: [/opt/a]
 0x000000000000000f (RPATH)              Library rpath: [/opt/a]
### inv-runpath with LD_LIBRARY_PATH=/opt/b
find library=libvat.so.1 [0]; searching
search path=/opt/b/glibc-hwcaps/x86-64-v3:/opt/b/glibc-hwcaps/x86-64-v2:/opt/b		(LD_LIBRARY_PATH)
trying file=/opt/b/glibc-hwcaps/x86-64-v3/libvat.so.1
trying file=/opt/b/glibc-hwcaps/x86-64-v2/libvat.so.1
trying file=/opt/b/libvat.so.1
### inv-rpath with LD_LIBRARY_PATH=/opt/b
find library=libvat.so.1 [0]; searching
search path=/opt/a/glibc-hwcaps/x86-64-v3:/opt/a/glibc-hwcaps/x86-64-v2:/opt/a		(RPATH from file ./inv-rpath)
trying file=/opt/a/glibc-hwcaps/x86-64-v3/libvat.so.1
trying file=/opt/a/glibc-hwcaps/x86-64-v2/libvat.so.1
trying file=/opt/a/libvat.so.1
```

البرنامج الذي فيه `RUNPATH` أخذ المكتبة من `/opt/b`، أي أن المتغير غلب المسار المكتوب في البرنامج، أما البرنامج الذي فيه `RPATH` فأخذها من `/opt/a` ولم ينظر إلى المتغير أصلاً، والمحمل نفسه كتب بين القوسين مصدر كل مسار. وهذا هو الفرق العملي بينهما: `RPATH` القديم يفرض نفسه على المستخدم، و`RUNPATH` الأحدث يترك له فرصة أن يجرب نسخة أخرى بالمتغير، والفرق الثاني الذي تذكره الصفحة أن `RUNPATH` يطبق على المكتبات التي يطلبها البرنامج مباشرة فقط، بينما `RPATH` يطبق على شجرة الاعتماديات كلها. والرابط في Debian وأغلب التوزيعات الحديثة يكتب `RUNPATH` افتراضياً عندما تستخدم `-rpath`.

![ترتيب البحث في المحمل ld.so من الأعلى إلى الأسفل: DT_RPATH إذا لم يوجد RUNPATH، ثم LD_LIBRARY_PATH، ثم DT_RUNPATH، ثم الملف /etc/ld.so.cache الذي يبنيه ldconfig، ثم المسارات الافتراضية /lib و/usr/lib مع مجلدات glibc-hwcaps، وبجانب كل خطوة نتيجة تجربتنا: البرنامج ذو RUNPATH أخذ المكتبة من /opt/b بسبب المتغير، والبرنامج ذو RPATH أخذها من /opt/a](https://arabroot.io/content/images/2026/10/linkers-loaders-05-search-order.webp)

أول مكان يجد فيه المحمل الاسم المطلوب يفوز، والمجلد الحالي ومجلد البرنامج ليسا في القائمة إلا إذا أضفتهما

### الحلول الثلاثة: LD\_LIBRARY\_PATH أو RUNPATH أو ldconfig

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

```bash
LD_LIBRARY_PATH=/lab/build ./invoice-dyn 9999

gcc invoice.o -L. -lvat -Wl,-rpath,'$ORIGIN' -o invoice-rpath
readelf -d invoice-rpath | grep -E 'RUNPATH'
mkdir -p /opt/invoice && cp invoice-rpath libvat.so.1.0.0 /opt/invoice/ && ln -sf libvat.so.1.0.0 /opt/invoice/libvat.so.1
/opt/invoice/invoice-rpath 9999

cp libvat.so.1.0.0 /usr/local/lib/; ldconfig; ldconfig -p | grep libvat; ./invoice-dyn 9999
```

```text
amount 99.99  vat 15.00  total 114.99  (rate 15%)
 0x000000000000001d (RUNPATH)            Library runpath: [$ORIGIN]
amount 99.99  vat 15.00  total 114.99  (rate 15%)
	libvat.so.1 (libc6,x86-64) => /usr/local/lib/libvat.so.1
amount 99.99  vat 15.00  total 114.99  (rate 15%)
```

والطرق الثلاث نجحت، ولكن لكل واحدة مكان:

- **المتغير `LD_LIBRARY_PATH`** مناسب للتجربة والتطوير وتشغيل الاختبارات على مكتبة مبنية للتو، وليس مناسباً للإنتاج، لأنه يؤثر على كل برنامج يعمل في نفس الجلسة، ويضيع عند تشغيل البرنامج من systemd أو cron إذا لم تضعه هناك أيضاً، والمحمل يتجاهله تماماً في برامج setuid لأسباب أمنية كما في وضع التنفيذ الآمن Secure-execution Mode في صفحة ld.so.
- **المسار `RUNPATH` مع `$ORIGIN`** يعني «ابحث في المجلد الذي فيه البرنامج نفسه»، والمحمل يستبدل الكلمة `$ORIGIN` بمسار البرنامج وقت التشغيل، فتستطيع أن تنقل المجلد `/opt/invoice` كاملاً إلى أي مكان ويعمل. وهذه طريقة التطبيقات التي تحمل مكتباتها معها، ولاحظ علامات الاقتباس المفردة حول `'$ORIGIN'` حتى لا تستبدلها الصدفة بمتغير فارغ، فهذا خطأ يتكرر كثيراً في ملفات Makefile.
- **التثبيت في `/usr/local/lib` ثم `ldconfig`** هو الطريقة الصحيحة لمكتبة يستخدمها أكثر من برنامج على السيرفر، والأداة [ldconfig](https://man7.org/linux/man-pages/man8/ldconfig.8.html?ref=arabroot.io) تقرأ المجلدات المذكورة في `/etc/ld.so.conf`، وتنشئ رابط الـ soname تلقائياً من الاسم المكتوب داخل كل مكتبة، وتبني الملف `/etc/ld.so.cache` الذي يبحث فيه المحمل بسرعة دون أن يمر على المجلدات واحداً واحداً. ولاحظ أننا نسخنا الملف `libvat.so.1.0.0` فقط، والرابط `libvat.so.1` أنشأه `ldconfig`، أما إذا نسيت تشغيل `ldconfig` بعد التثبيت فسوف تبقى الرسالة نفسها حتى لو كان الملف في مكانه.

## ماذا يحدث بين execve والدالة main؟ عمل المحمل Dynamic Loader

في مقال [فضاء النواة والمستخدم](https://arabroot.io/articles/%D9%81%D8%B6%D8%A7%D8%A1-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D8%A9-%D9%88%D9%81%D8%B6%D8%A7%D8%A1-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%AE%D8%AF%D9%85-%D9%88%D8%A7%D8%B3%D8%AA%D8%AF%D8%B9%D8%A7%D8%A1%D8%A7%D8%AA-%D8%A7%D9%84%D9%86%D8%B8%D8%A7%D9%85/) رأينا أن تشغيل أي برنامج يبدأ بالطلب `execve`، ولكن النواة لا تعرف شيئاً عن المكتبات المشتركة، فهي تقرأ عناوين البرنامج Program Headers، وتحمل أجزاءه في الذاكرة، ثم تجد السطر `INTERP` فتحمل الملف المكتوب فيه وتبدأ التنفيذ منه هو، وليس من برنامجك:

```bash
readelf -l invoice-dyn | grep -E 'INTERP|interpreter|LOAD|DYNAMIC|GNU_RELRO'
```

```text
  INTERP         0x0000000000000394 0x0000000000000394 0x0000000000000394
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
  LOAD           0x0000000000002dc0 0x0000000000003dc0 0x0000000000003dc0
  DYNAMIC        0x0000000000002dd0 0x0000000000003dd0 0x0000000000003dd0
  GNU_RELRO      0x0000000000002dc0 0x0000000000003dc0 0x0000000000003dc0
```

الأجزاء الأربعة `LOAD` هي ما تنسخه النواة إلى الذاكرة، والعناوين تبدأ من الصفر لأن البرنامج PIE يحمل في عنوان عشوائي، والجزء `DYNAMIC` هو القسم الذي فيه أسطر `NEEDED` و`RUNPATH`، والجزء `GNU_RELRO` منطقة يجعلها المحمل للقراءة فقط بعد أن ينتهي من تعديلها، وسوف نرجع إليه. ثم يقوم المحمل بالخطوات التالية، ونراها في استدعاءات النظام التي سجلها strace عند تشغيل البرنامج بعد تثبيت المكتبة:

```text
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 6243, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f9ba7c98000
openat(AT_FDCWD, "/usr/local/lib/libvat.so.1", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 16416, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f9ba7c93000
mmap(0x7f9ba7c94000, 4096, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1000) = 0x7f9ba7c94000
mmap(0x7f9ba7c95000, 4096, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x2000) = 0x7f9ba7c95000
mmap(0x7f9ba7c96000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x2000) = 0x7f9ba7c96000
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 2047568, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f9ba7a9f000
mmap(0x7f9ba7ac7000, 1454080, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x7f9ba7ac7000
mmap(0x7f9ba7c2a000, 352256, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x18b000) = 0x7f9ba7c2a000
```

1. يفتح `/etc/ld.so.cache` ويربطه بالذاكرة بالطلب `mmap`، وحجمه 6,243 بايتاً، ويجد فيه مسار `libvat.so.1` مباشرة.
2. يفتح المكتبة ويحجز لها مساحة 16,416 بايتاً في مكان واحد، ثم يربط كل جزء منها بالصلاحيات المناسبة: جزء للكود بالصلاحيتين `PROT_READ|PROT_EXEC`، وجزء للقراءة فقط، وجزء للبيانات القابلة للكتابة، تماماً كما وصفته عناوين البرنامج داخل المكتبة.
3. يكرر ذلك مع `libc.so.6`، وحجمها في الذاكرة نحو 2 ميجابايت، ولكن لاحظ أن الربط `MAP_PRIVATE` بملف لا يقرأ الملف كله، وإنما تحمل الصفحات عند أول لمسة لها، وهي صفحات مشتركة مع كل البرامج الأخرى التي تستخدم المكتبة نفسها.
4. بعد أن تصبح كل المكتبات في الذاكرة يمر على جداول إعادة التموضع في البرنامج وفي كل مكتبة ويملأ العناوين، ثم يجعل منطقة `RELRO` للقراءة فقط، ثم يشغل دوال التهيئة في كل مكتبة، وأخيراً يقفز إلى `_start` في برنامجك التي تستدعي `main`.

وعدد استدعاءات النظام يعطيك فكرة عن هذا العمل، فقد سجل `strace -c` أربعة وأربعين استدعاء للبرنامج الديناميكي من بدايته إلى نهايته، مقابل سبعة عشر للنسخة الثابتة من نفس البرنامج، والفرق كله تقريباً فتح المكتبات وربطها بالذاكرة.

## الاستدعاء عبر PLT وجدول GOT

بقي سؤال: الكود في برنامج الفواتير للقراءة فقط ومشترك، فكيف يستدعي `vat_total` وعنوانها لا يعرف إلا بعد أن يحمل المحمل المكتبة في مكان عشوائي؟ والإجابة أن الرابط لا يكتب في الكود عنوان الدالة، وإنما يكتب استدعاء لدالة صغيرة يولدها هو في قسم اسمه PLT، اختصار Procedure Linkage Table، وهذه الدالة الصغيرة تقفز إلى العنوان المكتوب في خانة في جدول آخر اسمه GOT، اختصار Global Offset Table، والجدول في منطقة قابلة للكتابة، فيكتب فيها المحمل العنوان الحقيقي، ويبقى الكود نفسه دون أي تعديل:

```bash
readelf -S invoice-dyn | grep -E -A1 ' \.(plt|plt\.sec|got|got\.plt|dynsym|dynstr|rela\.plt)\b'
readelf -r invoice-dyn | grep -E 'JUMP_SLO|GLOB_DAT|Relocation section'
objdump -d --no-show-raw-insn invoice-dyn | grep -E 'call.*@plt|<vat_total@plt>:' | head -n 6
```

```text
  [12] .plt              PROGBITS         0000000000001020  00001020
       0000000000000050  0000000000000010  AX       0     0     16
  [23] .got              PROGBITS         0000000000003fc0  00002fc0
       0000000000000028  0000000000000008  WA       0     0     8
  [24] .got.plt          PROGBITS         0000000000003fe8  00002fe8
       0000000000000038  0000000000000008  WA       0     0     8
Relocation section '.rela.plt' at offset 0x6c8 contains 4 entries:
000000004000  000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000004008  000400000007 R_X86_64_JUMP_SLO 0000000000000000 vat_total + 0
000000004010  000600000007 R_X86_64_JUMP_SLO 0000000000000000 strtol@GLIBC_2.2.5 + 0
000000004018  000700000007 R_X86_64_JUMP_SLO 0000000000000000 vat_amount + 0
0000000000001040 <vat_total@plt>:
    109d:	call   1050 <strtol@plt>
    10af:	call   1040 <vat_total@plt>
    10ba:	call   1040 <vat_total@plt>
    10c5:	call   1060 <vat_amount@plt>
    10d0:	call   1060 <vat_amount@plt>
```

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

- القسم `.plt` صلاحياته `AX`، أي يحمل في الذاكرة وينفذ، وحجمه 0x50 أي 80 بايتاً، 16 بايتاً لكل دالة من الأربع مع مدخل أول مشترك. أما `.got.plt` فصلاحياته `WA`، أي قابل للكتابة، وهذا هو الفرق الذي يجعل الحيلة تعمل.
- الجدول `.rela.plt` فيه أربعة أسطر من النوع `JUMP_SLOT`، كل سطر يقول للمحمل «اكتب عنوان هذه الدالة في هذه الخانة من GOT»، فالخانة 0x4008 لـ `vat_total` والخانة 0x4018 لـ `vat_amount`.
- الدوال من glibc مكتوبة بإصدار، مثل `printf@GLIBC_2.2.5`، أما دوال مكتبتنا فبلا إصدار، لأننا لم نكتب ملف إصدارات Version Script للمكتبة، وهذه التفصيلة الصغيرة سوف تفسر لنا خطأ السيرفر القديم بعد قليل.
- الكود في `main` يستدعي `vat_total@plt` عند العنوان 0x1040، ولا يعرف شيئاً عن مكان المكتبة، فكل الاستدعاءات تمر بتعليمة `jmp` إضافية واحدة.

![الاستدعاء عبر PLT وGOT: الدالة main تستدعي vat_total@plt في قسم الكود للقراءة فقط، والمدخل في PLT يقفز إلى العنوان المكتوب في خانة GOT عند 0x4008، وفي الربط الكسول تشير الخانة أول مرة إلى دالة المحمل التي تبحث عن vat_total في libvat.so.1 وتكتب عنوانها في الخانة، وفي المرات التالية يكون الانتقال مباشراً، ومع BIND_NOW يملأ المحمل كل الخانات قبل main ثم تصبح المنطقة للقراءة فقط](https://arabroot.io/content/images/2026/10/linkers-loaders-06-plt-got.webp)

الكود لا يتغير أبداً، وكل ما يكتبه المحمل خانات في جدول GOT، إما عند أول استدعاء أو كلها قبل main

### الربط الكسول Lazy Binding مقابل الربط الفوري BIND\_NOW

متى يكتب المحمل هذه الخانات؟ هناك طريقتان. في الربط الكسول Lazy Binding تشير كل خانة في البداية إلى كود داخل PLT يستدعي المحمل، فيبحث المحمل عن الدالة عند أول استدعاء فقط ويكتب عنوانها، فلا يدفع البرنامج ثمن البحث عن دوال لا يستدعيها، وفي الربط الفوري Immediate Binding يملأ المحمل كل الخانات قبل `main`. ونرى الفرق بالمتغير `LD_DEBUG=statistics` الذي يطبع إحصاءات المحمل، على نسخة مبنية بالخيار `-z lazy`، مرة كما هي ومرة مع `LD_BIND_NOW=1`:

```text
### statistics: lazy
runtime linker statistics:
total startup time in dynamic loader: 184428 cycles
time needed for relocation: 6467 cycles (3.5%)
number of relocations: 102
number of relocations from cache: 7
number of relative relocations: 6
time needed to load objects: 63534 cycles (34.4%)
runtime linker statistics:
final number of relocations: 108
final number of relocations from cache: 7
### statistics: bind now
runtime linker statistics:
total startup time in dynamic loader: 185452 cycles
time needed for relocation: 5847 cycles (3.1%)
number of relocations: 108
number of relocations from cache: 7
number of relative relocations: 6
time needed to load objects: 60050 cycles (32.3%)
```

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

- مع الربط الكسول عالج المحمل 102 إعادة تموضع قبل `main`، ثم وصل العدد النهائي إلى 108، أي أن ست خانات ملئت أثناء التشغيل عند أول استدعاء: الدوال الأربع في جدول البرنامج، واثنتان داخل مكتبتنا، وهما على الأرجح استدعاء `vat_total` لـ `vat_amount` وانتقال `vat_amount` إلى `round_div`. ومع `LD_BIND_NOW` عالج الـ 108 كلها قبل `main`.
- الوقت الكلي في المحمل نحو 184 ألف دورة معالج في الحالتين، ووقت إعادة التموضع نحو 3% منه فقط، بينما تحميل الملفات أخذ نحو الثلث، فالفرق بين الطريقتين في برنامج صغير لا يقاس، وهو يظهر في التطبيقات الكبيرة التي تربط مع عشرات المكتبات وآلاف الدوال.
- وأهم من الوقت أن الربط الكسول يحتاج أن تبقى خانات GOT قابلة للكتابة طوال عمر البرنامج، والمخترق الذي يجد ثغرة كتابة في الذاكرة يستطيع أن يغير خانة `printf` لتشير إلى كوده. لذلك تبني توزيعات كثيرة برامجها بالخيارين `-z relro -z now`، فيملأ المحمل كل شيء قبل `main` ثم يجعل المنطقة للقراءة فقط، وهذا ما يسمى Full RELRO.

وللطريقتين أثر ثان لا يتعلق بالأداء، وهو متى ترى الخطأ إذا كانت دالة ناقصة، وسوف نراه في تجربة «ماذا يحدث إذا» بعد قليل.

### نسخ المتغيرات Copy Relocation

في مخرج `LD_DEBUG=bindings` ظهر سطران غريبان عن المتغير `vat_rate_percent`: المكتبة ربطت المتغير بالبرنامج، والبرنامج ربطه بالمكتبة، وفي جدول إعادة التموضع ظهر نوع لم نره بعد:

```text
000000004030  000a00000005 R_X86_64_COPY     0000000000004030 vat_rate_percent + 0
binding file /usr/local/lib/libvat.so.1 [0] to ./invoice-lazy [0]: normal symbol `vat_rate_percent'
binding file ./invoice-lazy [0] to /usr/local/lib/libvat.so.1 [0]: normal symbol `vat_rate_percent'
```

والسبب أن ملف `invoice.o` ترجم ككود برنامج وليس ككود مكتبة، فهو يقرأ المتغير بعنوان نسبي مباشر وكأنه داخل البرنامج، فحجز له الرابط أربعة بايتات في بيانات البرنامج نفسه، وأضاف إعادة تموضع من النوع `COPY` تجعل المحمل ينسخ القيمة الأولية من المكتبة إلى هذه البايتات عند البدء، ثم يوجه المكتبة نفسها لتستخدم النسخة التي في البرنامج. والنتيجة تعمل، ولكن حجم المتغير صار جزءاً من العقد بين البرنامج والمكتبة، فإذا غير الفريق النوع من `int` إلى `long` في إصدار جديد بقي البرنامج القديم يحجز أربعة بايتات فقط. لذلك القاعدة في المكتبات المشتركة: لا تصدر متغيرات، وإنما صدر دوال مثل `vat_rate()` تعيد القيمة، وهذه نصيحة قديمة في وثيقة [How To Write Shared Libraries](https://akkadia.org/drepper/dsohowto.pdf?ref=arabroot.io) التي كتبها Ulrich Drepper أحد مطوري glibc.

## الحجم Size وزمن البدء Startup: ثابت أم ديناميكي؟

الآن نقيس السؤال الذي يسأله كل فريق عند اختيار طريقة الربط، ونبني أداة الفواتير بثلاث طرق: ديناميكية بالكامل، ومختلطة نربط فيها مكتبتنا ثابتاً ومكتبة C ديناميكياً بالخيار `-l:libvat.a`، وثابتة بالكامل بالخيار `-static`، ومعها برنامج فارغ لا يفعل شيئاً كمرجع، ثم نقيس الأحجام قبل حذف معلومات التصحيح بالأمر `strip` وبعده:

```bash
gcc -O2 invoice.o -L. -l:libvat.a -o invoice-mixed
gcc -O2 -static invoice.o -L. -lvat -o invoice-static
gcc -O2 ../src/empty.c -o empty-dyn; gcc -O2 -static ../src/empty.c -o empty-static
size invoice-dyn invoice-static
```

```text
15832 empty-dyn
14384 empty-dyn.stripped
754232 empty-static
672200 empty-static.stripped
16136 invoice-dyn
14496 invoice-dyn.stripped
16224 invoice-mixed
14480 invoice-mixed.stripped
758600 invoice-static
676272 invoice-static.stripped
   text	   data	    bss	    dec	    hex	filename
   1998	    624	      8	   2630	    a46	invoice-dyn
 640983	  23640	  22576	 687199	  a7c5f	invoice-static
```

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

- البرنامج الثابت 758,600 بايت مقابل 16,136 للديناميكي، أي أكبر بنحو 47 مرة، ولكن البرنامج الفارغ الثابت نفسه 754,232 بايت، فالفرق بين الفارغ وأداة الفواتير الثابتة نحو 4 كيلوبايت فقط، أي أن الحجم كله تقريباً من مكتبة C وليس من كودنا. وعدد الدوال في البرنامج الثابت بحسب `nm` كان 1,351 مقابل 13 في الديناميكي.
- الأمر `size` يفصل الحجم: قسم الكود `text` في الثابت 640,983 بايت، وهذا ما سحبه الرابط من `libc.a` لأن `printf` وحدها تجر معها التنسيق والأرقام العشرية واللغات Locale وإدارة الذاكرة والملفات.
- النسخة المختلطة بنفس حجم الديناميكية تقريباً، لأن مكتبتنا صغيرة، وهي مفيدة عندما تريد أن تشحن برنامجاً لا يحتاج تثبيت مكتبتك على السيرفر ولكنه يستفيد من تحديثات glibc الأمنية.

### مشكلة واجهناها: القياس الأول Benchmark كان مضللاً

شغلنا الأداة [hyperfine](https://github.com/sharkdp/hyperfine?ref=arabroot.io) على البرامج الخمسة، ثلاثة آلاف مرة لكل برنامج بعد مئتي تشغيل للإحماء، والخيار `-N` يشغل البرنامج مباشرة دون صدفة حتى لا نقيس الصدفة نفسها، فجاءت النتيجة عكس كل ما تتوقعه:

```text
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./empty-static` | 41.0 ± 54.6 | 18.4 | 1203.8 | 10.98 ± 16.28 |
| `./empty-dyn` | 4.5 ± 5.5 | 2.1 | 161.6 | 1.20 ± 1.67 |
| `./invoice-static 9999` | 33.4 ± 18.8 | 19.9 | 275.5 | 8.96 ± 7.72 |
| `./invoice-mixed 9999` | 3.7 ± 2.4 | 2.0 | 63.2 | 1.00 |
| `./invoice-dyn 9999` | 4.1 ± 3.5 | 2.0 | 78.9 | 1.11 ± 1.18 |
```

البرنامج الثابت أبطأ بتسع مرات؟ وهو لا يفتح أي مكتبة ولا يمر بالمحمل؟ هذا لا يستقيم مع ما رأيناه في strace. والسبب لم يكن في الربط أصلاً، وإنما في مكان الملفات، فالمجلد `/lab` مجلد من جهاز Windows مربوط داخل الـ Container بالخيار `-v`، والقراءة منه تمر عبر طبقة مشاركة ملفات بطيئة، والبرنامج الثابت 750 كيلوبايت تقرأ صفحاته من هذا المجلد، بينما البرنامج الديناميكي 16 كيلوبايت فقط، ومكتبة C التي يحتاجها موجودة داخل نظام ملفات الـ Container نفسه في الذاكرة المؤقتة. أي أننا كنا نقيس سرعة مشاركة الملفات وليس سرعة الربط، ويمكنك أن ترى ذلك بنفسك بالأمر `df` الذي أظهر أن `/lab` من القرص `D:\` وأن `/tmp` من `overlay`.

فنسخنا البرامج الخمسة إلى `/tmp/run` داخل الـ Container وأعدنا القياس مرتين، أربعة آلاف تشغيل لكل برنامج، وأخذنا أصغر زمن والوسيط Median بدلاً من المتوسط، لأن المتوسط على جهاز مكتبي يرتفع بسبب عدد قليل من التشغيلات البطيئة التي سببها برنامج آخر:

```text
round 1
"command": "./empty-static"	"median": 0.0005428075	"min": 0.000369739
"command": "./empty-dyn"	"median": 0.0007996865	"min": 0.00048775200000000004
"command": "./invoice-static 9999"	"median": 0.0006859255	"min": 0.000410745
"command": "./invoice-mixed 9999"	"median": 0.000971506	"min": 0.0005467600000000001
"command": "./invoice-dyn 9999"	"median": 0.0009565040000000001	"min": 0.0005640570000000001
round 2
"command": "./empty-static"	"median": 0.000633063	"min": 0.00040264
"command": "./empty-dyn"	"median": 0.0007892320000000001	"min": 0.000507155
"command": "./invoice-static 9999"	"median": 0.000625717	"min": 0.00038084100000000004
"command": "./invoice-mixed 9999"	"median": 0.0009233005000000001	"min": 0.000489554
"command": "./invoice-dyn 9999"	"median": 0.0007274795	"min": 0.000513656
```

والقيم بالثواني، فالبرنامج الثابت بدأ وانتهى في وسيط بين 0.63 و0.69 ملي ثانية وأصغر زمن نحو 0.38 إلى 0.41، والديناميكي في وسيط بين 0.73 و0.96 وأصغر زمن نحو 0.51 إلى 0.56\. أي أن الربط الديناميكي أضاف في هذا البرنامج بين 0.1 و0.27 ملي ثانية تقريباً عند كل تشغيل بحسب الجولة، وهذا ما يكلفه فتح مكتبتين وربطهما بالذاكرة وتطبيق 108 إعادات تموضع. والوسيط نفسه تغير بين الجولتين بما يصل إلى 30%، ولذلك لا تبن قراراً على فرق أصغر من هذا في قياس واحد.

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

![نتائج القياس: الحجم 16,136 بايت للديناميكي و16,224 للمختلط و758,600 للثابت، وزمن البدء الوسيط بعد نسخ البرامج إلى /tmp بين 0.63 و0.69 ملي ثانية للثابت وبين 0.73 و0.96 للديناميكي، والقياس الأول من المجلد المشترك أعطى 33.4 ملي ثانية للثابت بسبب قراءة الملف من القرص المشترك](https://arabroot.io/content/images/2026/10/linkers-loaders-07-results.webp)

الثابت أكبر بنحو 47 مرة وأسرع في البدء بنحو 0.1 إلى 0.27 ملي ثانية، والقياس من المجلد المشترك قلب النتيجة

## ماذا يحدث إذا تغيرت المكتبة تحت البرنامج؟ الواجهة الثنائية ABI

الواجهة البرمجية API هي ما يراه المطور في ملف الترويسة، والواجهة الثنائية ABI هي ما يعتمد عليه البرنامج المبني فعلاً: أسماء الرموز، وأنواع المعاملات وأحجامها، وترتيب الحقول في الـ structs، وحجم المتغيرات المصدرة. والمكتبة المشتركة تعد البرامج بأن الـ ABI لا يتغير طالما بقي الـ soname نفسه، وإذا نقضت المكتبة هذا الوعد فلن يخبرك أحد وقت البناء، لأن البرنامج بني قبل التغيير.

### دالة اختفت: symbol lookup error

نعيد إنتاج الرسالة الثالثة في القصة: زميل في الفريق أصدر النسخة 1.1 من المكتبة وغير اسم `vat_total` إلى `vat_gross` لأنه أوضح، وترك الـ soname `libvat.so.1` كما هو، فثبتها على السيرفر بدلاً من 1.0.0\. ولكي نرى أثر طريقة الربط أضفنا أداة تقارير صغيرة تطبع سطراً ثم تستدعي `vat_amount` ثم `vat_total`:

```text
### A: library 1.1 drops vat_total but keeps the soname
lrwxrwxrwx 1 root root    15 Oct 31 06:19 libvat.so.1 -> libvat.so.1.1.0
-rwxr-xr-x 1 root root 15256 Oct 31 06:19 libvat.so.1.1.0
./invoice-dyn: symbol lookup error: ./invoice-dyn: undefined symbol: vat_total
exit=127
daily report: started
./report-dyn: symbol lookup error: ./report-dyn: undefined symbol: vat_total
exit=127
./report-dyn: symbol lookup error: ./report-dyn: undefined symbol: vat_total
exit=127
```

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

- الأداة `ldconfig` حركت الرابط `libvat.so.1` إلى الملف الجديد تلقائياً، لأن الـ soname نفسه، وهذا بالضبط ما صممت له، ولكنه هنا نقل المشكلة إلى كل البرامج دفعة واحدة.
- الرسالة `symbol lookup error` ليست `undefined reference`، فالأولى من المحمل وقت التشغيل والثانية من الرابط وقت البناء، ولكنها نفس المشكلة: اسم مطلوب لا يعرفه أحد.
- أداة التقارير بالربط الكسول طبعت `daily report: started` ثم توقفت عند أول استدعاء لـ `vat_total`، وفي برنامج حقيقي قد يكون هذا بعد ساعة من العمل وكتابة نصف الملفات، أما مع `LD_BIND_NOW=1` فتوقفت قبل أن تطبع أي شيء، لأن المحمل بحث عن كل الدوال قبل `main`. وهذا سبب آخر لتفضيل الربط الفوري: الخطأ يظهر عند البدء بدلاً من منتصف العمل.
- أما أداة الفواتير فتوقفت قبل أي مخرج في الحالتين، لأن أول ما تستدعيه بعد `strtol` هو `vat_total`.

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

```text
### B: the build would have caught it
/usr/bin/ld: cannot find -lvat: No such file or directory
collect2: error: ld returned 1 exit status
exit=1
/usr/bin/ld: invoice.o: in function `main':
invoice.c:(.text.startup+0x30): undefined reference to `vat_total'
/usr/bin/ld: invoice.c:(.text.startup+0x3b): undefined reference to `vat_total'
collect2: error: ld returned 1 exit status
exit=1
```

المحاولة الأولى فشلت لأن `/usr/local/lib` فيه `libvat.so.1` وليس فيه `libvat.so`، والرابط لا يبحث إلا عن اسم الربط، فالسيرفر فيه المكتبة للتشغيل وليس فيه ملفات التطوير. وبعد أن أضفنا الرابط الرمزي ظهر الخطأ الحقيقي وقت البناء. والإصلاح الصحيح ليس أن نعيد بناء كل البرامج، وإنما أحد أمرين: أن يبقي الزميل `vat_total` في الإصدار 1.1 ويضيف `vat_gross` بجانبها، أو أن يرفع الـ soname إلى `libvat.so.2` فيبقى `libvat.so.1` القديم على السيرفر للبرامج القديمة، وتربط البرامج الجديدة بالإصدار 2، ويعيش الاثنان معاً.

### إصدار glibc أقدم: GLIBC\_2.38 not found

ولنفس الفكرة صورة تراها كثيراً عند نقل برنامج من جهاز المطور أو من الـ CI إلى سيرفر أقدم. كتبنا برنامجاً صغيراً يستخدم الدالة `strlcpy` التي أضيفت إلى glibc في الإصدار 2.38 كما في صفحة [strlcpy(3)](https://man7.org/linux/man-pages/man3/strlcpy.3.html?ref=arabroot.io)، وبنيناه على Debian 13 الذي فيه glibc 2.41، ثم شغلناه هو والنسخة الثابتة من أداة الفواتير في Container من `node:22-slim` المبنية على Debian 12 التي فيها glibc 2.36:

```text
### D: built on glibc 2.41
invoice
0000000000000000      DF *UND*	0000000000000000 (GLIBC_2.34) __libc_start_main
0000000000000000      DF *UND*	0000000000000000 (GLIBC_2.2.5) puts
0000000000000000      DF *UND*	0000000000000000 (GLIBC_2.38) strlcpy
0000000000000000  w   DF *UND*	0000000000000000 (GLIBC_2.2.5) __cxa_finalize

ldd (Debian GLIBC 2.36-9+deb12u14) 2.36
./label: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found (required by ./label)
exit=1
amount 99.99  vat 15.00  total 114.99  (rate 15%)
exit=0
```

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

- الأمر `objdump -T` يعرض الرموز الديناميكية مع إصدار كل منها، وglibc تستخدم إصدار الرموز Symbol Versioning، فكل دالة مربوطة بأقدم إصدار من المكتبة ظهرت فيه بشكلها الحالي، فالدالة `puts` من 2.2.5، والدالة `__libc_start_main` من 2.34 لأن طريقة بدء البرامج تغيرت في ذلك الإصدار، والدالة `strlcpy` من 2.38.
- أعلى إصدار في القائمة هو الحد الأدنى من glibc الذي يحتاجه البرنامج، وهنا 2.38، والسيرفر فيه 2.36، فرفض المحمل التشغيل برسالة تذكر الإصدار الناقص بالضبط. ولاحظ أن البرنامج نفسه بدون `strlcpy` كان سيحتاج 2.34 فقط، أي أن دالة واحدة غيرت الحد الأدنى.
- النسخة الثابتة من أداة الفواتير عملت على السيرفر القديم دون مشكلة، لأنها تحمل glibc الخاصة بها داخلها، وهذا أحد الأسباب الحقيقية لاختيار الربط الثابت في الأدوات التي توزع على أنظمة كثيرة.

والقاعدة العملية من هذه التجربة: ابن برامجك على أقدم نظام سوف تعمل عليه، وليس على أحدث نظام عندك، لأن glibc تحافظ على التوافق مع البرامج القديمة ولا تعدك بالعكس، ولذلك يبني كثير من المشروعات ملفاتها التنفيذية في Container من توزيعة قديمة، أو بالربط الثابت مع مكتبة C أخرى مثل musl.

## المكتبة التي تسبق الجميع: LD\_PRELOAD والاعتراض Interposition

ذكرنا أن المحمل يبحث عن كل رمز في المكتبات بالترتيب، وأول مكتبة تعرف الرمز تفوز، والمتغير `LD_PRELOAD` يضع مكتبة في أول هذا الترتيب قبل البرنامج نفسه. كتبنا مكتبة صغيرة فيها دالة `vat_amount` تطبع سطراً وتعيد صفراً، وشغلنا بها النسختين الديناميكية والثابتة:

```text
### C: LD_PRELOAD
[preload] vat_amount(9999)
[preload] vat_amount(9999)
[preload] vat_amount(9999)
[preload] vat_amount(9999)
amount 99.99  vat 0.00  total 99.99  (rate 15%)
amount 99.99  vat 15.00  total 114.99  (rate 15%)
```

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

- النسخة الديناميكية استخدمت الدالة المزيفة، فصارت الضريبة صفراً، ولم يتغير في البرنامج ولا في المكتبة بايت واحد.
- الدالة المزيفة استدعيت أربع مرات وليس مرتين، فالاستدعاءان الآخران من داخل `vat_total` نفسها، أي أن الاعتراض Interposition وصل حتى إلى الاستدعاءات داخل المكتبة، والسبب أن `vat_amount` رمز عام في مكتبة مشتركة، فالمترجم مع `-fPIC` يجعل حتى الاستدعاء الداخلي يمر عبر PLT حتى يسمح بهذا الاعتراض. وإذا لم تكن تريد ذلك في مكتبتك فاجعل الدوال الداخلية مخفية بالخيار `-fvisibility=hidden` وصدر فقط ما يحتاجه المستخدم، وهذا يسرع الاستدعاءات الداخلية أيضاً.
- النسخة الثابتة لم تتأثر، لأنها لا تمر بالمحمل أصلاً، فلا يوجد من يقرأ المتغير.

وهذه القدرة مفيدة جداً في التصحيح، فأدوات قياس الذاكرة ومحاكاة الأعطال تعتمد عليها، ولكنها في الوقت نفسه سبب أن المحمل جزء حساس من النظام. ففي أكتوبر 2023 نشرت Qualys ثغرة [Looney Tunables](https://www.qualys.com/2023/10/03/cve-2023-4911/looney-tunables-local-privilege-escalation-glibc-ld-so.txt?ref=arabroot.io) برقم CVE-2023-4911، وهي تجاوز في الذاكرة داخل `ld.so` نفسه أثناء قراءة المتغير `GLIBC_TUNABLES`، وكانت تسمح لأي مستخدم محلي بأن يصبح root عبر برنامج setuid على أغلب التوزيعات الكبيرة، لأن المحمل يعمل قبل أي كود في البرنامج وبصلاحياته. وفي مارس 2024 كشف Andres Freund [الباب الخلفي في xz](https://www.openwall.com/lists/oss-security/2024/03/29/4?ref=arabroot.io) برقم [CVE-2024-3094](https://nvd.nist.gov/vuln/detail/CVE-2024-3094?ref=arabroot.io)، وكانت المكتبة المصابة `liblzma` تحمل داخل sshd عبر سلسلة اعتماديات مشتركة، ثم تستغل آليات المحمل مثل IFUNC لتستبدل دالة يستدعيها sshd قبل أن يبدأ عمله، وقد شرحنا القصة كاملة في مقال [كيف اكتشف Andres Freund الباب الخلفي في xz](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%A7%D9%83%D8%AA%D8%B4%D9%81-andres-freund-%D8%A7%D9%84%D8%A8%D8%A7%D8%A8-%D8%A7%D9%84%D8%AE%D9%84%D9%81%D9%8A-%D9%81%D9%8A-xz/). والدرس من الحالتين أن كل مكتبة مشتركة يحملها برنامجك تعمل بصلاحياته، وأن عدد المكتبات في سلسلة الاعتماديات جزء من سطح الهجوم Attack Surface، فراجع ما يحمله برنامجك بالأمر `ldd` أو `readelf -d` كما تراجع اعتماديات npm في مقال [مديرو الحزم Package Managers](https://arabroot.io/articles/%D9%85%D8%AF%D9%8A%D8%B1%D9%88-%D8%A7%D9%84%D8%AD%D8%B2%D9%85-npm-%D9%88-pip-%D9%88-maven-%D9%88-cargo/).

## أخطاء شائعة Common Mistakes في الربط والتحميل

- وضع المكتبة الثابتة قبل الملفات التي تحتاجها في سطر الربط، ثم البحث عن الدالة في الكود ساعات وهي موجودة. القاعدة: من يحتاج قبل من يعطي.
- الظن بأن `-L` يخبر البرنامج أين يجد المكتبة وقت التشغيل، وهو للرابط فقط، والبرنامج يحتاج `RUNPATH` أو `ldconfig` أو مساراً افتراضياً.
- وضع `LD_LIBRARY_PATH` في ملف `.bashrc` على سيرفر الإنتاج، فيعمل البرنامج من الطرفية ويفشل من systemd، أو يأخذ برنامج آخر نسخة خاطئة من مكتبة مشتركة.
- نسخ المكتبة إلى `/usr/local/lib` ونسيان `ldconfig`، فتبقى رسالة `cannot open shared object file` والملف في مكانه.
- تغيير توقيع دالة أو حذفها أو تغيير حقول struct مع بقاء الـ soname نفسه، فتفشل البرامج القديمة وقت التشغيل أو تحسب قيماً خاطئة بصمت، لأن الرابط يطابق الأسماء فقط ولا يرى الأنواع.
- تصدير متغيرات عامة من مكتبة مشتركة، فيصبح حجمها جزءاً من الـ ABI بسبب Copy Relocation، والأفضل دالة تعيد القيمة.
- بناء البرنامج على أحدث توزيعة ثم تشغيله على سيرفر أقدم، فتظهر رسالة `GLIBC_2.xx not found`.
- استخدام `-fcommon` لإسكات خطأ `multiple definition` بدلاً من إصلاح التعريف المكرر.
- تشغيل `ldd` على ملف تنفيذي مجهول المصدر، والأسلم `readelf -d` و`objdump -p`.
- قياس زمن البدء من مجلد مشترك أو قرص شبكي، فتقيس سرعة نظام الملفات بدلاً من الربط.

## جدول قرار Decision Table: ما تراه وماذا تفعل

| ما تراه                                   | من كتبه ومتى                        | السبب الغالب                                                                | ماذا تفعل                                                                |
| ----------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| undefined reference to \`x'               | الرابط وقت البناء                   | ملف أو مكتبة ناقصة، أو ترتيب خاطئ للمكتبات الثابتة، أو اسم مختلف بين C وC++ | ابحث عن الرمز بالأمر nm -A \*.o \*.a \| grep x، ثم صحح سطر الربط وترتيبه |
| multiple definition of \`x'               | الرابط وقت البناء                   | تعريف في ملف ترويسة، أو متغير عام معرف في ملفين                             | تعريف واحد في ملف .c، وextern في الترويسة، أو static                     |
| cannot find -lx                           | الرابط وقت البناء                   | لا يوجد libx.so أو libx.a في مسارات البحث                                   | ثبت حزمة التطوير \-dev، أو أضف \-L للمسار الصحيح                         |
| cannot open shared object file            | المحمل قبل main                     | المكتبة غير موجودة في أي مسار يبحث فيه المحمل                               | ldconfig لمكتبة النظام، أو RUNPATH مع $ORIGIN لمكتبة التطبيق             |
| symbol lookup error: undefined symbol     | المحمل عند البدء أو عند أول استدعاء | المكتبة المثبتة أقدم أو أحدث من التي بني عليها البرنامج، وتغير الـ ABI      | ثبت الإصدار الصحيح، وارفع الـ soname عند كل تغيير غير متوافق             |
| version \`GLIBC\_2.xx' not found          | المحمل قبل main                     | البرنامج بني على glibc أحدث من السيرفر                                      | ابن على أقدم نظام مستهدف، أو اربط ثابتاً                                 |
| البرنامج يعمل من الطرفية ويفشل من systemd | المحمل                              | الطرفية فيها LD\_LIBRARY\_PATH وsystemd لا                                  | لا تعتمد على المتغير في الإنتاج، واستخدم ldconfig أو RUNPATH             |

## قائمة تحقق Checklist قبل أن تشحن مكتبة مشتركة

- المكتبة مبنية بالخيار `-fPIC` ولها soname واضح بالخيار `-Wl,-soname`.
- الأسماء الثلاثة موجودة في مكانها: الملف الحقيقي ورابط الـ soname للتشغيل، واسم الربط في حزمة التطوير فقط.
- الدوال الداخلية مخفية بالخيار `-fvisibility=hidden`، ولا تصدر المكتبة متغيرات.
- أي حذف أو تغيير في توقيع دالة أو في struct عام يرفع رقم الـ soname، والإضافة وحدها لا ترفعه.
- البرامج مبنية بالخيارين `-z relro -z now` حتى تظهر الدالة الناقصة عند البدء وتكون جداول GOT للقراءة فقط.
- تحققت من الاعتماديات بالأمر `readelf -d`، ومن أعلى إصدار glibc مطلوب بالأمر `objdump -T`.
- التثبيت على السيرفر يشغل `ldconfig`، ولا يعتمد على `LD_LIBRARY_PATH`.
- جربت البرنامج على أقدم نظام سوف يعمل عليه، داخل Container من تلك التوزيعة.

## حذف المختبر Cleanup

كل الخطوات عملت بالخيار `--rm`، فلا يبقى أي Container بعد التشغيل، وبقيت الـ Image والملفات المبنية:

```bash
docker rmi scc2-linkers
rm -rf build
```

## الخلاصة

- المترجم يترجم كل ملف وحده ويترك فراغات، والرابط يملؤها وقت البناء، والمحمل يملأ ما بقي وقت التشغيل، وكل رسالة خطأ تبدأ بمعرفة أي منهما كتبها.
- ملف الكائن فيه جدول رموز يقول ماذا يملك وماذا يحتاج، وجدول إعادة تموضع يقول في أي بايت يكتب كل عنوان، والموقع في رسالة `undefined reference` هو سطر في هذا الجدول.
- الرابط يأخذ من المكتبة الثابتة ما هو مطلوب لحظة وصوله إليها فقط، ولذلك الملفات قبل المكتبات، ومن يحتاج قبل من يعطي.
- المكتبة المشتركة لها ثلاثة أسماء: اسم الربط للرابط، والـ soname للمحمل وهو العقد، والملف الحقيقي الذي يتغير مع كل إصلاح.
- المحمل يبحث بالترتيب في `RPATH` ثم `LD_LIBRARY_PATH` ثم `RUNPATH` ثم `ld.so.cache` ثم المسارات الافتراضية، ولا يبحث في المجلد الحالي، و`LD_DEBUG=libs` يريك ما فعله.
- الاستدعاء إلى مكتبة مشتركة يمر عبر PLT وخانة في GOT، والربط الكسول يؤجل البحث إلى أول استدعاء، والربط الفوري يظهر الخطأ عند البدء ويسمح بجعل الجداول للقراءة فقط.
- في تجربتنا كان الثابت أكبر بنحو 47 مرة وأسرع في البدء بنحو 0.1 إلى 0.27 ملي ثانية، وأغلب حجمه من مكتبة C، والقياس من مجلد مشترك قلب النتيجة حتى أصلحناه.
- تغيير الـ ABI دون رفع الـ soname يوقف البرامج القديمة بالرسالة `symbol lookup error`، والبناء على glibc أحدث من السيرفر يعطي `GLIBC_2.38 not found`، والنسخة الثابتة تتجاوز المشكلتين بثمن الحجم والتحديثات.
- المتغير `LD_PRELOAD` يسمح لمكتبة بأن تعترض أي دالة، وهذا مفيد في التصحيح، وهو نفسه سبب أن المحمل والمكتبات المشتركة جزء من سطح الهجوم.

[![](https://arabroot.io/content/images/2026/10/hello-world-cover-cover.webp)دليلماذا يحدث عندما تشغل برنامج Hello World؟نتتبع برنامج C صغيراً من المحرر إلى الشاشة، فترى كل طبقة يمر بها في الجهاز حتى تظهر الجملة أمامك.](https://arabroot.io/articles/%D8%B1%D8%AD%D9%84%D8%A9-hello-world-%D9%85%D9%86-%D9%84%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B4%D8%A7%D8%B4%D8%A9/)[![](https://arabroot.io/content/images/2026/10/build-tools-cover-cover.webp)دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D9%86%D8%A7%D8%A1-build-tools-%D9%85%D9%86-make-%D8%A7%D9%84%D9%89-maven-%D9%88-gradle-%D9%88-dotnet/)[![](https://arabroot.io/content/images/2026/10/kernel-user-syscalls-cover-cover.webp)دليلفضاء النواة والمستخدم: كم يكلف استدعاء النظام System Call؟نعبر الحد بين برنامجك والنواة بأيدينا دون أي مكتبة، ونقيس كلفة كل عبور، ونرى لماذا يأخذ نسخ ملف ببايت واحد في كل طلب عشرات الثواني بدلاً من أقل من ملي ثانية.](https://arabroot.io/articles/%D9%81%D8%B6%D8%A7%D8%A1-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D8%A9-%D9%88%D9%81%D8%B6%D8%A7%D8%A1-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%AE%D8%AF%D9%85-%D9%88%D8%A7%D8%B3%D8%AA%D8%AF%D8%B9%D8%A7%D8%A1%D8%A7%D8%AA-%D8%A7%D9%84%D9%86%D8%B8%D8%A7%D9%85/)[![](https://arabroot.io/content/images/2026/10/os-virtual-memory-cover-cover.webp)دليلالذاكرة الافتراضية Virtual Memory: جداول الصفحات وخطأ الصفحةنتابع العنوان من برنامجك إلى شريحة الذاكرة، ونفهم بأرقام حقيقية لماذا تخدعك أعمدة VIRT وRES، ومتى تسرع الصفحات الكبيرة برنامجك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D8%A7%D9%84%D8%A7%D9%81%D8%AA%D8%B1%D8%A7%D8%B6%D9%8A%D8%A9-virtual-memory-%D9%88%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B5%D9%81%D8%AD%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/compilation-jit-cover-cover.webp)دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%AA%D8%B1%D8%AC%D9%85-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%B3%D8%B1-%D9%88-jit-%D9%88-webassembly-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/)[![](https://arabroot.io/content/images/2026/10/package-managers-cover-cover.webp)دليلمديرو الحزم Package Managers: npm و pip و Maven و Cargoنجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.](https://arabroot.io/articles/%D9%85%D8%AF%D9%8A%D8%B1%D9%88-%D8%A7%D9%84%D8%AD%D8%B2%D9%85-npm-%D9%88-pip-%D9%88-maven-%D9%88-cargo/)[![](https://arabroot.io/content/images/2026/10/xz-investigation-cover-cover.webp)دليلكيف اكتشف Andres Freund الباب الخلفي في XZخطوات التحقيق والأوامر التي قادت مطوراً لاحظ نصف ثانية زائدة في دخول SSH إلى باب خلفي في مكتبة XZ، وكيف تكررها على سيرفرك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%A7%D9%83%D8%AA%D8%B4%D9%81-andres-freund-%D8%A7%D9%84%D8%A8%D8%A7%D8%A8-%D8%A7%D9%84%D8%AE%D9%84%D9%81%D9%8A-%D9%81%D9%8A-xz/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ كل الخطوات داخل Container من الـ Image الرسمية `debian:trixie-slim` (Debian 13.7) مع gcc 14.2.0 وGNU ld 2.44 وglibc 2.41 وhyperfine 1.19.0، وتجربة السيرفر القديم في `node:22-slim` (Debian 12.15 مع glibc 2.36)، على Docker Engine 29.8.0.