في الدقائق القادمة، سأشرح لكم معظم المصطلحات في البرمجة منخفضة المستوى (Low-Level). في الحقيقة، ليست كل المصطلحات، ولكنها تغطي الأغلبية. ولكن قبل ذلك، نحتاج أن نعرف ما هي البرمجة منخفضة المستوى أساساً.
تنقسم البرمجة إلى قسمين: الأول هو بناء البرنامج دون الخوض في تفاصيل المعالج والذاكرة (RAM)، مثل لغة Python
وأغلب اللغات الحديثة. والثاني هو التعامل المباشر مع المعالج والذاكرة وكتابة كل التفاصيل التي تحدث أثناء تشغيل البرنامج، وهذا يُعرف بالبرمجة منخفضة المستوى. في نهاية هذا المقال، ستتعرف على المصطلحات الأساسية في هذا المجال.
تمرير بالمرجع (By Reference) والتمرير بالقيمة (By Value)
تنقسم طريقة تعامل الدوال مع المتغيرات إلى جزئين:
- التمرير بالقيمة (By Value): يتم عن طريق إنشاء متغير جديد يحمل قيمة الوسيط (Argument) للتعامل معه دون تغيير قيمة المتغير الأصلي.
- التمرير بالمرجع (By Reference): يتم عن طريق أخذ عنوان المتغير الأصلي والتعديل عليه مباشرة.
المؤشر (Pointer)
هو متغير يخزن عنوان متغير آخر عن طريق أخذ مرجعه (Reference). لكن، خذ في الاعتبار أن عنوان المؤشر نفسه مختلف عن عنوان المتغير الذي يخزنه.
إلغاء المرجع (Dereference)
بعد استخدام المؤشر ليصبح مشيراً إلى متغير، يمكننا الوصول إلى قيمة ذلك المتغير عن طريق استخدام المؤشر. يمكننا تعريف متغير ثانٍ يحمل نفس قيمة المتغير المُشار إليه، كما يمكننا تغيير قيمة المتغير الذي يشير إليه المؤشر بتحديد قيمة جديدة له.
المؤشر الفارغ (Null Pointer)
أحياناً، بعد إنشاء مؤشر، تحتاج إلى إعطائه قيمة فارغة بهدف التنظيم والأمان. ولهذا استخدامات كثيرة، مثل التحقق مما إذا كان المؤشر فارغاً أم لا قبل إسناد قيمة له. هذه الممارسة مفيدة جداً في هياكل البيانات مثل القوائم المترابطة (Linked Lists). لغة C
لا تدعم هذا المفهوم مباشرة، وتستخدم NULL
بدلاً منه، لكنه قد يسبب مشاكل وأخطاء.
تخصيص الذاكرة (Allocation)
بعد تعريف متغير، يمكنك تخصيص مساحة محددة له داخل ذاكرة الكومة (Heap) باستخدام new
في C++
أو malloc
في C
. هذا يجعلك تتعامل مع الذاكرة بطريقة أفضل ويعطيك إمكانية تحكم أكبر في برنامجك.
إلغاء تخصيص الذاكرة (Deallocation)
بعد تخصيص مساحة داخل الكومة، يجب عليك حذف كل المساحة التي خصصتها بعد الانتهاء من استخدامها، وذلك باستخدام delete
في C++
أو free
في C
. إهمال هذه الخطوة يمكن أن يسبب مشاكل في استهلاك الذاكرة على المدى البعيد.
المؤشر المعلق (Dangling Pointer)
أحياناً، لا يكون حذف الجزء المخصص من الذاكرة كافياً لحمايتها. فبعد حذف المساحة، ما زال المؤشر يحمل عنوان تلك المساحة المحذوفة. في هذه الحالة، قد يسبب استخدامه مشاكل في المستقبل. لذلك، يجب تعيين المؤشر إلى قيمة فارغة (Null Pointer
) لحل مشكلة المؤشر المعلق.
تسريب الذاكرة (Memory Leaks)
إذا خصصت مساحة داخل الكومة ونسيت حذفها، فهذا سيسبب استهلاكاً كبيراً للذاكرة، مما يجعل البرنامج أبطأ ويقلل من أدائه.
جمع القمامة (Garbage Collection)
هي أداة لحل مشكلة تسريب الذاكرة، وقد أصبحت مدمجة مع أغلب لغات البرمجة الحديثة. تمكّن هذه الأداة اللغة من التعرف على جميع العناصر غير المستخدمة والمخصصة في الذاكرة وحذفها بشكل تلقائي دون تدخل المبرمج.
المكدس (The Stack)
هو جزء من الذاكرة يُستخدم لاستدعاء الدوال وتخزين المتغيرات المحلية بشكل منظم. يعتبر سريعاً لأنه يستخدم بنية “آخر من يدخل، أول من يخرج” (LIFO - Last In, First Out). ويعتبر سريعاً جداً لأنه يخصص العناوين داخل أحد المسجلات (Registers) الذي يُعرف بمؤشر المكدس (Stack Pointer).
الكومة (The Heap)
هو الجزء الثاني من الذاكرة، ويُستخدم لتخزين الكائنات أو المتغيرات التي يتم تخصيصها ديناميكياً أثناء وقت التشغيل (Runtime) باستخدام new
أو malloc
. تتميز الكومة بحجمها الكبير وإمكانية تخصيص العناصر فيها بشكل مرن، لكنها أبطأ وتحتاج إلى إدارة يدوية للتخصيص وإلغاء التخصيص.
صفحة الذاكرة (Memory Page)
يتم تقسيم الذاكرة الافتراضية (Virtual Memory) والذاكرة المادية (Physical Memory) إلى أجزاء صغيرة تسمى صفحات، ويكون حجمها غالباً 4 كيلوبايت (ويمكن تغييره). تُستخدم غالباً لتنظيم الذاكرة وزيادة الأمان عن طريق إضافة صلاحيات وصول لبعض صفحات الذاكرة.
تعيين الذاكرة (Memory Mapping)
مع تطور الأنظمة، تمت إضافة طريقة لربط عناوين الذاكرة الافتراضية بالذاكرة المادية عن طريق نظام التشغيل. وبذلك، أصبح البرنامج عند تشغيله لا يتعامل مباشرة مع الذاكرة الحقيقية، بل مع ذاكرة افتراضية خاصة به. مهمة نظام التشغيل هي ربط هذه الذاكرة الافتراضية بالذاكرة الحقيقية باستخدام جدول الصفحات (Page Table).
خطأ الصفحة (Page Fault)
إذا حاول برنامج الوصول إلى جزء في الذاكرة الافتراضية لا يملك صلاحية الوصول إليه أو كان قد تم حذفه، سيتدخل المعالج ويرسل مقاطعة (Interrupt) إلى نظام التشغيل.
قطاعات الذاكرة (Memory Segments)
تنقسم الذاكرة إلى عدة أجزاء:
- قطاع النص (Text Segment): يحتوي على كود البرنامج.
- قطاع البيانات (Data Segment): يحتوي على كل البيانات المهيأة، مثل المتغيرات العامة.
- قطاع BSS (BSS Segment): يحتوي على البيانات غير المهيأة.
- قطاع الكومة (Heap Segment): هو الجزء الذي تُخصص فيه المتغيرات الديناميكية.
- قطاع المكدس (Stack Segment): هو الجزء المخصص لاستدعاء الدوال والمتغيرات المحلية.
كان استخدام هذه القطاعات أكثر شيوعاً في المعماريات القديمة (32-بت وما قبلها)، لكن قل استخدامها حالياً وأصبح لها بدائل مثل الذاكرة الافتراضية وصفحات الذاكرة.
الذاكرة الافتراضية (Virtual Memory)
في الأنظمة الحديثة، أصبح من الممكن استخدام مساحة أكبر من الذاكرة المتوفرة فعلياً (RAM) باستخدام ذاكرة افتراضية. فبدلاً من التعامل المباشر مع الذاكرة، يمكن تخصيص عنوان وهمي لكل برنامج، وتكون مهمة نظام التشغيل ربطه مع عنوان حقيقي في الذاكرة. هذا أتاح تنظيماً أكبر وزاد من الحماية.
المسجلات (Registers)
يتكون المعالج من عدة أجزاء، أحدها هي المسجلات، وهي وحدات تخزين بيانات فائقة السرعة. لها استخدامات كثيرة، وكل معمارية معالج لها أنواعها الخاصة من المسجلات.
مسجل الأعلام (Flags Register)
هو أحد أنواع المسجلات ويتكون من بتات على شكل إشارات (أعلام). يُستخدم غالباً لتخزين معلومات ناتجة عن العمليات الحسابية والمنطقية، ويساعد في تنفيذ بعض الأوامر الشرطية مثل القفز (Jumps) والتحقق. في معمارية Intel
، يتكون من مجموعة من الأعلام:
- علم الصفر (Zero Flag): يعبر عما إذا كانت نتيجة العملية صفراً.
- علم الحمل (Carry Flag): يعبر عن وجود حمل (carry) ناتج عن عملية حسابية.
- علم الفائض (Overflow Flag): يعبر عن تجاوز الحد الأقصى للقيمة الممكنة.
- علم الإشارة (Sign Flag): يعبر عما إذا كانت نتيجة العملية سالبة.
في معمارية ARM
، يُستخدم مسجل باسم CPSR
بأسماء N
, Z
, C
, V
، ولها وظائف مشابهة تقريباً لتلك الموجودة في Intel
.
شفرة العملية (OpCode)
الحاسب الآلي يتعامل مع البتات ولا يفهم لغة البشر. وعندما تجمع 8 بتات، فإنها تكوّن بايتاً واحداً. هذه البايتات تشكل أوامر صغيرة مثل الجمع والطرح. في الأنظمة الحديثة، تُستخدم صيغة السادس عشر (Hexadecimal) لتسهيل قراءتها.
مجموعة التعليمات (Instruction Set)
لكل معمارية معالج طريقة خاصة للتعامل مع الأوامر والمسجلات. فمثلاً، في Intel
، ترتيب الأوامر يبدأ بالأمر، ثم المسجل الأول، ثم المسجل الثاني. لكن في ARM
، قد يأتي الأمر أولاً ثم تتبعه ثلاثة مسجلات في نفس السطر.
المقاطعة (Interrupt)
هي إشارة من المعالج للخروج من الأمر الحالي وتنفيذ أمر آخر مهم، سواء كان من العتاد (Hardware) أو البرامج. وبعد الانتهاء من الأمر المهم، يعود المعالج ليكمل تنفيذ الأوامر السابقة. يتم تحديدها عن طريق إشارة IF
(Interrupt Flag).
استدعاء النظام (System Call / Syscall)
بعض الأوامر، مثل إنشاء ملف أو الاتصال بشبكة أو حتى تشغيل برنامج، لا يمكن تنفيذها مباشرة من البرنامج وتحتاج إلى صلاحيات أعلى مثل وضع النواة (Kernel Mode). يتم طلب هذه الصلاحيات باستخدام استدعاء النظام. في Linux
، طريقة التواصل واضحة. أما في Windows
، فتحتاج للتعامل معها عن طريق Windows API
، حيث يتم أخذ الصلاحيات بشكل تلقائي سواء من مكتبات الربط الديناميكي (DLL
) مثل kernel32.dll
أو من دوال مثل ReadProcessMemory
.
المفكك (Disassembler)
بعد تحويل الكود الأصلي إلى ملف تنفيذي عن طريق المترجم (Compiler)، تتحول الأكواد إلى صيغة ثنائية (Binary) وتفقد تفاصيلها. لكن يمكن تحويلها إلى لغة التجميع (Assembly)، وهي لغة قريبة من لغة الآلة ومفهومة للإنسان.
المحول العكسي (Decompiler)
بعض التقنيات، مثل .NET
، لا تحول الكود إلى صيغة ثنائية بشكل مباشر، بل إلى لغة وسيطة اسمها CIL
(Common Intermediate Language). وأثناء تشغيل البرنامج، يتم تحويل الكود الوسيط إلى لغة الآلة وتنفيذه. هذا يترك بعض التفاصيل في اللغة الوسيطة، ومنها يمكن استرجاع الكود الأصلي بنفس لغة البرمجة. وكذلك يمكن محاكاة اللغات التي تحول الكود إلى صيغة ثنائية مباشرة، مثل لغة C
، عن طريق لغة التجميع. لكن في النهاية، هذه محاكاة ولا تعتبر مماثلة للغة الأصلية لأن المترجم يمسح التفاصيل ويقوم بعمليات تحسين (Optimization).
المصحح (Debugger)
بعض الأخطاء البرمجية يكون حلها صعباً، وهنا نتحدث عن الأخطاء المنطقية (Logical Errors)، حيث يعمل البرنامج لكن تظهر نتيجة غير متوقعة. حل هذه الأخطاء صعب، ولهذا السبب، تم تطوير برامج تتبع الكود خطوة بخطوة وتُظهر النتائج. المصححات لها أنواع، منها مصحح الكود المصدري (Source Debugger) ومصحح لغة التجميع (Assembly Debugger). هنا نتحدث عن مصحح لغة التجميع، حيث يستخدم المفكك (Disassembler) لتحويل الكود الثنائي ويُظهر التفاصيل داخل المعالج.
وضع النواة (Kernel Mode) ووضع المستخدم (User Mode)
تحدثنا كثيراً عن الصلاحيات في أنظمة التشغيل، والآن حان الوقت لنعرف ما هما. في الأنظمة الحديثة، توجد حالتان لتشغيل البرامج:
- وضع المستخدم (User Mode): في هذه الحالة، لا يمكنك الوصول بشكل مباشر إلى موارد الجهاز. كل شيء يتم تخصيصه بشكل تلقائي، ولطلب أمر يتطلب صلاحيات، تحتاج إلى استدعاء النظام (System Call). في تلك الحالة، أي خطأ في البرنامج سيؤثر على البرنامج فقط، ولن يحدث أي شيء للنظام. أغلب البرامج تعمل في وضع المستخدم، مثل المتصفح والألعاب وبرامج المونتاج.
- وضع النواة (Kernel Mode): هنا، يتم التعامل مباشرة مع العتاد. وفي هذه الحالة، أي خطأ يحدث قد يؤثر على نظام التشغيل نفسه، مثل ظهور الشاشة الزرقاء في
Windows
. هذه المشكلة لا تحدث بنفس الشكل فيLinux
بسبب طريقة تقسيمه للملفات. تعمل في هذا الوضع برامج تشغيل الأجهزة (Drivers) وبعض البرامج التي تتعامل مباشرة مع بطاقة الشاشة والقرص الصلب، مثلOpenGL
.
الوصول المباشر للذاكرة (Direct Memory Access - DMA)
مع زيادة حجم البيانات وكثرة البرامج، احتجنا إلى طريقة لنقل البيانات بين الأجهزة والذاكرة دون تدخل مباشر من المعالج. على سبيل المثال، عند استخدام القرص الصلب، يأتي طلب للمعالج لنسخ البيانات. هنا، سيضطر المعالج إلى نقل كل البيانات، وهذا سيجعله أبطأ، خصوصاً مع كثرة البرامج. لكن مع وجود DMA
، أصبحت مهمة المعالج هي قراءة وتحديد حجم البيانات، ثم يتم النقل بشكل تلقائي. وبعد أن ينتهي DMA
من النقل، يرسل مقاطعة (Interrupt) إلى المعالج لإعلامه.
وبهذا نكون قد تحدثنا عن أغلب مصطلحات البرمجة منخفضة المستوى.