OS1: نواة بدائية على Rust لـ x86

قررت أن أكتب مقالًا ، وإذا أمكن ، ثم سلسلة من المقالات لمشاركة تجربتي في البحث المستقل لكل من جهاز Bare Bone x86 وتنظيم أنظمة التشغيل. في الوقت الحالي ، لا يمكن حتى الاختراق يسمى نظام التشغيل - إنه نواة صغيرة يمكنها التمهيد من Multiboot (GRUB) ، وإدارة الذاكرة الحقيقية والظاهرية ، وأيضًا تنفيذ العديد من الوظائف عديمة الفائدة في وضع تعدد المهام على معالج واحد.


خلال عملية التطوير ، لم أضع لنفسي هدفًا لكتابة نظام Linux جديد (على الرغم من أنني أعترف أنني حلمت به منذ حوالي 5 سنوات) أو أثارت إعجاب شخص ما ، لذا أطلب منك ألا تبدي إعجابًا بعد الآن. ما أردت فعله حقًا هو معرفة كيفية عمل بنية i386 على المستوى الأساسي ، وكيف تقوم أنظمة التشغيل بالتحديد بسحرها ، وحفر الضجيج Rust.


في ملاحظاتي سأحاول مشاركة ليس فقط نصوص المصدر (يمكن العثور عليها على GitLab) ونظرية العارية (يمكن العثور عليها في العديد من الموارد) ، ولكن أيضًا المسار الذي ذهبت إليه للعثور على إجابات غير واضحة. على وجه التحديد ، سأتحدث في هذا المقال عن إنشاء ملف kernel وتحميله وتهيئته .


أهدافي هي هيكلة المعلومات في رأسي ، وكذلك مساعدة أولئك الذين يتبعون مسارًا مشابهًا. أدرك أن المواد والمدونات المشابهة موجودة بالفعل على الشبكة ، ولكن من أجل الوصول إلى وضعي الحالي ، كان علي جمعها معًا لفترة طويلة. جميع المصادر (على أي حال ، والتي أتذكرها) ، سأشارك الآن.


الأدب والمصادر


بالطبع ، حصلت على معظمها من مورد OSDev الممتاز ، من الويكي ومن المنتدى. ثانياً ، سأسمي فيليب أوبرمان بمدونته - الكثير من المعلومات حول حفنة الصدأ والحديد.


يتم التجسس على بعض النقاط في Linux kernel ، Minix لا يخلو من مساعدة الأدب الخاص ، مثل كتاب Tanenbaum " أنظمة التشغيل " . التصميم والتنفيذ ، "روبرت لوف كتاب" نواة لينكس. وصف عملية التطوير . " تم حل الأسئلة الصعبة حول تنظيم بنية x86 باستخدام كتيب "دليل مطور برامج مطور برامج Intel 64 و IA-32 المجلد 3 (3A ، 3B ، 3C & 3D): دليل برمجة النظام ". في فهم تنسيق الثنائيات ، تعد التصميمات بمثابة أدلة لـ ld ، llvm ، nm ، nasm ، make.
UPD. بفضل CoreTeamTech لتذكيري بنظام Redox OS الرائع. لم أخرج من مصدرها . لسوء الحظ ، لا يتوفر نظام GitLab الرسمي من عنوان IP الروسي ، لذلك يمكنك إلقاء نظرة على GitHub .


مقدمة أخرى


أدرك أنني لست مبرمجًا جيدًا في Rust ، علاوة على ذلك ، هذا هو أول مشروع لي بهذه اللغة (ليس أفضل طريقة لبدء المواعدة ، أليس كذلك؟). لذلك ، قد يبدو التطبيق غير صحيح تمامًا لك - أود مسبقًا أن أطلب تساهلاً في الكود الخاص بي وسأكون سعيدًا بالتعليق والاقتراحات. إذا كان القارئ المحترم يستطيع أن يخبرني أين وكيف أواصل ذلك ، سأكون ممتنًا للغاية. يمكن نسخ بعض أجزاء التعليمات البرمجية من البرامج التعليمية كما هي وتعديلها قليلاً ، لكنني سأحاول تقديم توضيحات واضحة قدر الإمكان لمثل هذه الأقسام حتى لا يكون لديك نفس الأسئلة التي طرحتها عند تحليلها. أنا أيضًا لا أدعي استخدام الأساليب الصحيحة في التصميم ، لذا إذا كان مدير ذاكرتي يجعلك ترغب في كتابة تعليقات غاضبة ، أفهم السبب.


أدوات


لذلك ، سأبدأ بالغطس في أدوات التطوير التي استخدمتها. كبيئة ، اخترت محرر VS Code جيدًا ومريحًا مع مكونات إضافية لـ Rust و مصحح أخطاء GDB. في بعض الأحيان ، لا يكون VS Code جيدًا مع RLS ، خاصةً عند إعادة تعريفه في دليل محدد ، لذلك بعد كل تحديث ليلي Rust اضطررت إلى إعادة تثبيت RLS.


تم اختيار الصدأ لعدة أسباب. أولاً ، تزايد شعبيتها وفلسفتها الممتعة. ثانياً ، قدرته على العمل بمستوى منخفض ولكن مع احتمال أقل بـ "إطلاق النار على قدمه". ثالثًا ، باعتباري أحد محبي Java و Maven ، فإنني مدمن جدًا على بناء الأنظمة وإدارة التبعية ، وقد تم بالفعل تضمين الشحنات في لغة سلسلة الأدوات. رابعا ، أردت فقط شيئا جديدا ، وليس مثل C.


لرمز المستوى المنخفض ، أخذت NASM ، كما أشعر بالثقة في بناء جملة Intel ، كما أنني مرتاح للعمل مع توجيهاته. لقد تخليت عن عمد عن إدراج المجمّع في Rust من أجل فصل العمل بشكل واضح بالحديد والمنطق عالي المستوى.
تم استخدام Make and the linker من LLVM LLD supply (as a linker better and better) as a public assembly and layout - وهذه مسألة ذوق. كان من الممكن القيام به مع بناء نصوص للبضائع.


تم استخدام Qemu لإطلاق - أحب سرعته ووضعه التفاعلي والقدرة على ربط GDB. للتمهيد والحصول على جميع معلومات الأجهزة فورًا - بالطبع GRUB (Legacy أسهل لتنظيم الرأس ، لذلك خذها).


ربط وتخطيط


من الغريب ، بالنسبة لي ، اتضح أنه أحد أصعب المواضيع. كان من الصعب للغاية إدراك بعد تجارب طويلة مع سجلات قطعة x86 أن المقاطع والأقسام ليست هي نفسها. في البرمجة الخاصة بالبيئة الحالية ، ليست هناك حاجة للتفكير في كيفية وضع البرنامج في الذاكرة - لكل منصة وتنسيق لديه رابط بالفعل وصفة جاهزة ، لذلك ليست هناك حاجة لكتابة برنامج نصي رابط.


بالنسبة للحديد العاري ، على العكس من ذلك ، من الضروري الإشارة إلى كيفية وضع رمز البرنامج وعنوانه في الذاكرة. أود هنا التأكيد على أننا نتحدث عن عنوان خطي (افتراضي) باستخدام آلية الصفحة. يستخدم نظام التشغيل 1 آلية صفحة ، لكنني سأتناولها بشكل منفصل في القسم المقابل من المقال.


منطقية ، خطية ، افتراضية ، مادية ...

العناوين المنطقية والخطية والظاهرية والمادية. لقد كسرت رأسي بشأن هذا السؤال ، لذا للحصول على التفاصيل التي أريد أن أتناولها في هذا المقال الممتاز


بالنسبة لأنظمة التشغيل التي تستخدم الترحيل ، في بيئة 32 بت ، تحتوي كل مهمة على 4 غيغابايت من مساحة الذاكرة القابلة للعنونة ، حتى إذا كان لديك 128 ميغابايت من ذاكرة الوصول العشوائي المثبتة. يحدث هذا فقط بسبب تنظيم الترحيل للذاكرة ؛ يتم التعامل مع عدم وجود صفحات في الذاكرة الرئيسية وفقًا لذلك.


ومع ذلك ، في الواقع ، تتوفر عادةً تطبيقات أقل قليلاً من 4 غيغابايت. وذلك لأن نظام التشغيل يجب أن يتعامل مع المقاطعات ، ومكالمات النظام ، مما يعني أن معالجاتهم على الأقل يجب أن تكون في مساحة العنوان هذه. نحن نواجه السؤال التالي: أين يجب وضع عناوين kernel في هذه الـ 4 جيجابايت بالضبط بحيث تعمل البرامج بشكل صحيح؟


في عالم البرامج الحديثة ، يتم استخدام مثل هذا المفهوم: تعتقد كل مهمة أنها تسود على المعالج وهي البرنامج الوحيد قيد التشغيل على الكمبيوتر (في هذه المرحلة لا نتحدث عن التواصل بين العمليات). إذا نظرت بالضبط إلى كيفية قيام المجمعين بجمع البرامج في مرحلة الربط ، اتضح أنها تبدأ بعنوان خطي يساوي الصفر أو بالقرب من الصفر. هذا يعني أنه إذا كانت صورة kernel تشغل مساحة ذاكرة بالقرب من الصفر ، فلن يمكن تنفيذ البرامج التي تم تجميعها بهذه الطريقة ، وأي تعليمات jmp في البرنامج ستؤدي إلى الدخول في ذاكرة kernel المحمية وخطأ في الحماية. لذلك ، إذا أردنا استخدام ليس فقط البرامج المكتوبة ذاتيا في المستقبل ، فمن المعقول إعطاء التطبيق أكبر قدر ممكن من الذاكرة بالقرب من الصفر ، ووضع صورة النواة أعلى.


يُطلق على هذا المفهوم "High-half kernel" (أحيلك هنا إلى osdev.org ، إذا كنت تريد معلومات ذات صلة). أي جزء من الذاكرة لاختيار يعتمد فقط على شهيتك. 512 ميغا بايت كافية لشخص ما ، لكنني قررت أن أمسك بنفسي 1 غيغابايت ، لذلك يوجد نواة بلدي على 3 غيغابايت + 1 ميغا بايت (+ 1 ميغابايت مطلوبة للامتثال لحدود الذاكرة المنخفضة الأعلى ، يقوم GRUB بتحميلنا في الذاكرة الفعلية بعد 1 ميغابايت) .
من المهم أيضًا بالنسبة لنا تحديد نقطة الدخول لملفنا القابل للتنفيذ. بالنسبة للتنفيذ الخاص بي ، ستكون هذه هي وظيفة _loader المكتوبة في المجمع ، والتي سأتطرق إليها بمزيد من التفصيل في القسم التالي.


حول نقطة الدخول

هل تعلم أنك قد كذبت طوال حياتك حول حقيقة أن main () هي نقطة الدخول إلى البرنامج؟ في الواقع ، main () هي اصطلاح للغة C واللغات التي تم إنشاؤها بها. إذا قمت بالتجول ، فسيظهر شيء مثل التالي.


أولاً ، لكل نظام أساسي مواصفاته ونقطة الدخول الخاصة به: بالنسبة لنظام التشغيل linux ، فإنه عادةً ما يكون _start ، أما نظام التشغيل Windows فهو mainCRTStartup. ثانياً ، يمكن إعادة تعريف هذه النقاط ، لكن بعد ذلك لن تعمل على استخدام مسرات libc. ثالثًا ، يوفر المترجم نقاط الإدخال هذه افتراضيًا وهم في الملفات crt0..crtN (CRT - C RunTime ، N - عدد الوسائط الرئيسية).


في الواقع ، ما الذي يقوم به المترجمون مثل gcc أو vc do - حيث يقومون بتحديد نص ارتباط خاص بالنظام الأساسي يحدد نقطة إدخال قياسية ، وحدد ملف الكائن المطلوب مع وظيفة تهيئة تهيئة C الجاهزة واستدعاء الوظيفة الرئيسية وارتباط الإخراج في شكل ملف بالتنسيق المطلوب مع نقطة إدخال قياسية.


لذلك ، لأغراضنا ، يجب إيقاف تشغيل نقطة الدخول القياسية وتهيئة CRT ، حيث لا يوجد لدينا أي شيء سوى الحديد العاري.


ماذا تحتاج إلى معرفته للربط؟ كيف سيتم تحديد موقع أقسام البيانات (.rodata ، .data) ، والمتغيرات غير المهيأة (.bss ، شائعة) ، وتذكر أيضًا أن GRUB يتطلب موقع رؤوس التشغيل المتعدد في أول 8 كيلوبايت من الملف الثنائي.


حتى الآن يمكننا كتابة برنامج نصي رابط!


ENTRY(_loader) OUTPUT_FORMAT(elf32-i386) SECTIONS { . = 0xC0100000; .text ALIGN(4K) : AT(ADDR(.text) - 0xC0000000) { *(.multiboot1) *(.multiboot2) *(.text) } .rodata ALIGN(4K) : AT(ADDR(.rodata) - 0xC0000000) { *(.rodata*) } .data ALIGN (4K) : AT(ADDR(.data) - 0xC0000000) { *(.data) } .bss : AT(ADDR(.bss) - 0xC0000000) { _sbss = .; *(COMMON) *(.bss) _ebss = .; } } 

التحميل بعد اليرقة


كما ذكر أعلاه ، تتطلب مواصفات Multiboot أن يكون الرأس في أول 8 كيلوبايت من صورة التمهيد. يمكن رؤية المواصفات الكاملة هنا ، لكنني سوف أتناول فقط تفاصيل الاهتمام.


  • يجب احترام محاذاة 32 بت (4 بايت)
  • يجب أن يكون هناك رقم سحري 0x1BADB002
  • من الضروري إخبار multibooter بالمعلومات التي نريد تلقيها وكيفية وضع الوحدات (في حالتي ، أريد أن تكون وحدة kernel محاذاة على صفحة بحجم 4 كيلوبايت ، وكذلك الحصول على بطاقة ذاكرة لتوفير الوقت والجهد)
  • تقديم المجموع الاختباري (المجموع الاختباري + الرقم السحري + الأعلام يجب أن يعطي صفرًا)

 MB1_MODULEALIGN equ 1<<0 MB1_MEMINFO equ 1<<1 MB1_FLAGS equ MB1_MODULEALIGN | MB1_MEMINFO MB1_MAGIC equ 0x1BADB002 MB1_CHECKSUM equ -(MB1_MAGIC + MB1_FLAGS) section .multiboot1 align 4 dd MB1_MAGIC dd MB1_FLAGS dd MB1_CHECKSUM 

بعد التشغيل ، يضمن Multiboot بعض الشروط التي يجب مراعاتها.


  • يحتوي سجل EAX على الرقم السحري 0x2BADB002 ، والذي يقول إن التنزيل كان ناجحًا
  • يحتوي سجل EBX على العنوان الفعلي للهيكل مع معلومات حول نتائج التحميل (سنتحدث عنه لاحقًا)
  • المعالج في وضع محمي ، يتم إيقاف تشغيل ذاكرة الصفحة ، وتسجيلات القطع والمكدس في حالة غير محددة (بالنسبة لنا) ، استخدمها GRUB لتلبية احتياجاتها ويجب إعادة تعريفها في أقرب وقت ممكن.

أول شيء نحتاج إلى القيام به هو تمكين الترحيل ، وضبط المكدس ، ونقل التحكم في النهاية إلى رمز Rust عالي المستوى.
لن أتناول بالتفصيل تنظيم صفحة الذاكرة ودليل الصفحة وجدول الصفحة ، لأن مقالات ممتازة كتبت عن هذا ( أحدها ). الشيء الرئيسي الذي أود مشاركته هو أن الصفحات ليست شرائح! يرجى عدم تكرار خطأي وعدم تحميل عنوان جدول الصفحة في GDTR! لجدول الصفحة هو CR3! يمكن أن يكون للصفحة حجم مختلف في أبنية مختلفة ، وبساطة العمل (للحصول على جدول صفحة واحد فقط) ، اخترت حجمًا قدره 4 ميغابايت نظرًا لتضمين PSE.


لذلك ، نريد تمكين ذاكرة الصفحة الافتراضية. للقيام بذلك ، نحتاج إلى جدول صفحة ، وعنوانه الفعلي ، يتم تحميلهما في CR3. في الوقت نفسه ، تم ربط ملفنا الثنائي بالعمل في مساحة عنوان افتراضية مع إزاحة 3 غيغابايت. هذا يعني أن كل العناوين والعلامات المتغيرة لها إزاحة قدرها 3 غيغابايت. جدول الصفحة هو مجرد صفيف يحتوي فيه عنوان الصفحة على عنوانه الحقيقي ، محاذيًا لحجم الصفحة ، وكذلك إشارات الوصول والحالة. منذ أن استخدمت صفحات بحجم 4 ميجابايت ، أحتاج فقط إلى جدول صفحات PD واحد به 1024 إدخال:


 section .data align 0x1000 BootPageDirectory: dd 0x00000083 times (KERNEL_PAGE_NUMBER - 1) dd 0 dd 0x00000083 times (1024 - KERNEL_PAGE_NUMBER - 1) dd 0 

ما في الجدول؟


  1. يجب أن تؤدي الصفحة الأولى إلى القسم الحالي من التعليمات البرمجية (0-4 ميغابايت من الذاكرة الفعلية) ، نظرًا لأن جميع العناوين الموجودة في المعالج حقيقية وأن الترجمة إلى الظاهرية لم يتم تنفيذها بعد. سيؤدي غياب واصف هذه الصفحة إلى تعطل فوري ، حيث لن يتمكن المعالج من أخذ التعليمات التالية بعد تشغيل الصفحات. علامات: بت 0 - الجدول موجود ، بت 1 - الصفحة مكتوبة ، بت 7 - حجم الصفحة 4 ميغابايت. بعد تشغيل الصفحات ، تتم إعادة تعيين السجل.
  2. تخطي ما يصل إلى 3 جيجابايت - تضمن الأصفار أن الصفحة ليست في الذاكرة
  3. علامة 3 غيغابايت هي جوهرنا في الذاكرة الافتراضية ، مع الإشارة إلى 0 في الذاكرة الفعلية. بعد تشغيل الصفحات ، سنعمل هنا. تشبه الأعلام السجل الأول.
  4. تخطي ما يصل إلى 4 جيجابايت.

لذلك ، أعلنا الجدول ونريد الآن تحميل عنوانه الفعلي في CR3. لا تنسَ إزاحة العنوان البالغ 3 غيغابايت في مرحلة الربط. محاولة تحميل العنوان كما هو ، سوف يرسلنا إلى العنوان الحقيقي المتمثل في الإزاحة المتغيرة 3 جيجابايت + ويؤدي إلى تعطل فوري. لذلك ، نأخذ عنوان BootPageDirectory ونطرح 3 غيغابايت منه ، ضعه في CR3. نقوم بتشغيل PSE في سجل CR4 ، وتشغيل العمل مع الصفحات الموجودة في سجل CR0:


  mov ecx, (BootPageDirectory - KERNEL_VIRTUAL_BASE) mov cr3, ecx mov ecx, cr4 or ecx, 0x00000010 mov cr4, ecx mov ecx, cr0 or ecx, 0x80000000 mov cr0, ecx 

حتى الآن ، كل شيء يسير على ما يرام ، ولكن بمجرد إعادة تعيين الصفحة الأولى للانتقال أخيرًا إلى النصف العلوي من 3 غيغابايت ، سينهار كل شيء ، لأن سجل EIP لا يزال لديه عنوان فعلي في منطقة أول ميجابايت. لإصلاح ذلك ، نقوم بإجراء تلاعب بسيط: ضع علامة في أقرب مكان ، وقم بتحميل عنوانه (وهو بالفعل مع إزاحة 3 غيغابايت ، تذكر ذلك) وقم بإجراء قفزة غير مشروطة من خلاله. بعد ذلك ، يمكن إعادة تعيين صفحة غير ضرورية للتطبيقات المستقبلية.


  lea ecx, [StartInHigherHalf] jmp ecx StartInHigherHalf: mov dword [BootPageDirectory], 0 invlpg [0] 

الآن يتعلق الأمر بالشيء الصغير للغاية: تهيئة الحزمة ، واجتياز بنية GRUB والمجمّع يكفي!


  mov esp, stack+STACKSIZE push eax push ebx lea ecx, [BootPageDirectory] push ecx call kmain hlt section .bss align 32 stack: resb STACKSIZE 

ما تحتاج لمعرفته حول هذا الكود:


  1. وفقًا للاتفاقية C للمكالمات (تنطبق أيضًا على الصدأ) ، يتم نقل المتغيرات إلى الوظيفة من خلال المكدس بالترتيب العكسي. تتم محاذاة جميع المتغيرات بواسطة 4 بايت في x86.
  2. تكدس المكدس من النهاية ، لذلك يجب أن يؤدي المؤشر إلى المكدس إلى نهاية المكدس (إضافة STACKSIZE إلى العنوان). كان حجم المكدس الذي أخذته 16 كيلوبايت ، ويجب أن يكون كافيًا.
  3. يتم نقل التالي إلى kernel: الرقم السحري Multiboot ، العنوان الفعلي لهيكل أداة تحميل التشغيل (توجد بطاقة ذاكرة قيمة لنا) ، العنوان الظاهري لجدول الصفحة (في مكان ما في مساحة 3 غيغابايت)

أيضًا ، لا تنسَ أن تعلن أن kmain خارجي وأن أداة التحميل عالمية.


خطوات إضافية


في الملاحظات التالية ، سأتحدث عن إعداد سجلات للمقاطع ، واستعرض بإيجاز إخراج المعلومات من خلال مخزن مؤقت VGA ، وأخبرك كيف نظمت العمل مع المقاطعات ، وإدارة الصفحة ، وأحب شيء - تعدد المهام - سأترك للحلوى.


رمز المشروع الكامل متاح على GitLab .


شكرا لاهتمامكم!


UPD2: الجزء 2
UPD2: الجزء 3

Source: https://habr.com/ru/post/ar445506/


All Articles