Writing an OS in Rust

Philipp Oppermann's blog

یک هسته مینیمال با Rust

محتوای ترجمه شده: این یک ترجمه از جامعه کاربران برای پست A Minimal Rust Kernel است. ممکن است ناقص، منسوخ شده یا دارای خطا باشد. لطفا هر گونه مشکل را در این ایشو گزارش دهید!

ترجمه توسط @hamidrezakp و @MHBahrampour.

در این پست ما برای معماری x86 یک هسته مینیمال ۶۴ بیتی به زبان راست می‌سازیم. با استفاده از باینری مستقل Rust از پست قبل، یک دیسک ایمیج قابل بوت می‌سازیم، که متنی را در صفحه چاپ کند.

این بلاگ بصورت آزاد روی گیت‌هاب توسعه داده شده است. اگر شما مشکل یا سوالی دارید، لطفاً آن‌جا یک ایشو باز کنید. شما همچنین می‌توانید در زیر این پست کامنت بگذارید. منبع کد کامل این پست را می‌توانید در بِرَنچ post-02 پیدا کنید.

فهرست مطالب

🔗فرآیند بوت شدن

وقتی یک رایانه را روشن می‌کنید، شروع به اجرای کد فِرْم‌وِر (کلمه: firmware) ذخیره شده در ROM مادربرد می‌کند. این کد یک power-on self-test انجام می‌دهد، رم موجود را تشخیص داده، و پردازنده و سخت افزار را پیش‌ مقداردهی اولیه می‌کند. پس از آن به یک دنبال دیسک قابل بوت می‌گردد و شروع به بوت کردن هسته سیستم عامل می‌کند.

در x86، دو استاندارد فِرْم‌وِر (کلمه: firmware) وجود دارد: «سامانهٔ ورودی/خروجیِ پایه» (BIOS) و استاندارد جدیدتر «رابط فِرْم‌وِر توسعه یافته یکپارچه» (UEFI). استاندارد BIOS قدیمی و منسوخ است، اما ساده است و از دهه ۱۹۸۰ تاکنون در هر دستگاه x86 کاملاً پشتیبانی می‌شود. در مقابل‌، UEFI مدرن‌تر است و ویژگی‌های بسیار بیشتری دارد‌، اما راه اندازی آن پیچیده‌تر است (حداقل به نظر من).

در حال حاضر، ما فقط پشتیبانی BIOS را ارائه می‌دهیم، اما پشتیبانی از UEFI نیز برنامه‌ریزی شده است. اگر می‌خواهید در این زمینه به ما کمک کنید، ایشو گیت‌هاب را بررسی کنید.

🔗بوت شدن BIOS

تقریباً همه سیستم‌های x86 از بوت شدن BIOS پشتیبانی می‌کنند‌، از جمله سیستم‌های جدیدترِ مبتنی بر UEFI که از BIOS شبیه‌سازی شده استفاده می‌کنند. این عالی است‌، زیرا شما می‌توانید از منطق بوت یکسانی در تمام سیستم‌های قرن‌های گذشته استفاده کنید. اما این سازگاری گسترده در عین حال بزرگترین نقطه ضعف راه‌‌اندازی BIOS است، زیرا این بدان معناست که پردازنده قبل از بوت شدن در یک حالت سازگاری 16 بیتی به نام real mode قرار داده می‌شود تا بوت‌لودرهای قدیمی از دهه 1980 همچنان کار کنند.

اما بیایید از ابتدا شروع کنیم:

وقتی یک رایانه را روشن می‌کنید، BIOS را از حافظه فلش مخصوصی که روی مادربرد قرار دارد بارگذاری می‌کند. BIOS روال‌های خودآزمایی و مقداردهی اولیه سخت افزار را اجرا می کند‌، سپس به دنبال دیسک‌های قابل بوت می‌گردد. اگر یکی را پیدا کند، کنترل به بوت‌لودرِ آن منتقل می‌شود‌، که یک قسمت ۵۱۲ بایتی از کد اجرایی است و در ابتدای دیسک ذخیره شده است. بیشتر بوت‌لودرها از ۵۱۲ بایت بزرگتر هستند، بنابراین بوت‌لودرها معمولاً به یک قسمت کوچک ابتدایی تقسیم می‌شوند که در ۵۱۲ بایت جای می‌گیرد و قسمت دوم که متعاقباً توسط قسمت اول بارگذاری می‌شود.

بوت‌لودر باید محل ایمیج هسته را بر روی دیسک تعیین کرده و آن را در حافظه بارگذاری کند. همچنین ابتدا باید CPU را از real mode (ترجمه: حالت واقعی) 16 بیتی به protected mode (ترجمه: حالت محافظت شده) 32 بیتی و سپس به long mode (ترجمه: حالت طولانی) 64 بیتی سوییچ کند، جایی که ثبات‌های 64 بیتی و کل حافظه اصلی در آن در دسترس هستند. کار سوم آن پرس‌وجو درباره اطلاعات خاص (مانند نگاشت حافظه) از BIOS و انتقال آن به هسته سیستم عامل است.

نوشتن بوت‌لودر کمی دشوار است زیرا به زبان اسمبلی و بسیاری از مراحل غیر بصیرانه مانند “نوشتن این مقدار جادویی در این ثبات پردازنده” نیاز دارد. بنابراین ما در این پست ایجاد بوت‌لودر را پوشش نمی‌دهیم و در عوض ابزاری به نام bootimage را ارائه می‌دهیم که بوت‌لودر را به طور خودکار به هسته شما اضافه می‌کند.

اگر علاقه‌مند به ساخت بوت‌لودر هستید: با ما همراه باشید‌، مجموعه‌ای از پست‌ها در این زمینه از قبل برنامه‌ریزی شده است!

🔗استاندارد بوت چندگانه

برای جلوگیری از این که هر سیستم عاملی بوت‌لودر خود را پیاده‌سازی کند، که فقط با یک سیستم عامل سازگار است، بنیاد نرم افزار آزاد در سال 1995 یک استاندارد بوت‌لودر آزاد به نام Multiboot ایجاد کرد. این استاندارد یک رابط بین بوت‌لودر و سیستم عامل را تعریف می‌کند، به طوری که هر بوت‌لودر سازگار با Multiboot می‌تواند هر سیستم عامل سازگار با Multiboot را بارگذاری کند. پیاده‌سازی مرجع GNU GRUB است که محبوب‌ترین بوت‌لودر برای سیستم‌های لینوکس است.

برای سازگار کردن هسته با Multiboot، کافیست یک به اصطلاح Multiboot header را در ابتدای فایل هسته اضافه کنید. با این کار بوت کردن سیستم عامل در GRUB بسیار آسان خواهد شد. با این حال، GRUB و استاندارد Multiboot نیز دارای برخی مشکلات هستند:

  • آنها فقط از حالت محافظت شده 32 بیتی پشتیبانی می‌کنند. این بدان معناست که شما برای تغییر به حالت طولانی 64 بیتی هنوز باید پیکربندی CPU را انجام دهید.
  • آنها برای ساده سازی بوت‌لودر طراحی شده‌اند نه برای ساده سازی هسته. به عنوان مثال، هسته باید با اندازه صفحه پیش فرض تنظیم شده پیوند داده شود، زیرا GRUB در غیر اینصورت نمی‌تواند هدر Multiboot را پیدا کند. مثال دیگر این است که اطلاعات بوت، که به هسته منتقل می‌شوند‌، به جای ارائه انتزاعات تمیز و واضح، شامل ساختارها با وابستگی زیاد به معماری هستند.
  • هر دو استاندارد GRUB و Multiboot بصورت ناقص مستند شده‌اند.
  • برای ایجاد یک ایمیج دیسکِ قابل بوت از فایل هسته، GRUB باید روی سیستم میزبان نصب شود. این امر باعث دشوارتر شدنِ توسعه در ویندوز یا Mac می‌شود.

به دلیل این اشکالات ما تصمیم گرفتیم از GRUB یا استاندارد Multiboot استفاده نکنیم. با این حال، ما قصد داریم پشتیبانی Multiboot را به ابزار bootimage خود اضافه کنیم، به طوری که امکان بارگذاری هسته شما بر روی یک سیستم با بوت‌لودر GRUB نیز وجود داشته باشد. اگر علاقه‌مند به نوشتن هسته سازگار با Multiboot هستید، نسخه اول مجموعه پست‌های این وبلاگ را بررسی کنید.

🔗UEFI

(ما در حال حاضر پشتیبانی UEFI را ارائه نمی‌دهیم، اما خیلی دوست داریم این کار را انجام دهیم! اگر می‌خواهید کمک کنید، لطفاً در ایشو گیت‌هاب به ما بگویید.)

🔗یک هسته مینیمال

اکنون که تقریباً می‌دانیم چگونه یک کامپیوتر بوت می‌شود، وقت آن است که هسته مینیمال خودمان را ایجاد کنیم. هدف ما ایجاد دیسک ایمیجی می‌باشد که “!Hello World” را هنگام بوت شدن چاپ کند. برای این منظور از باینری مستقل Rust که در پست قبل دیدید استفاده می‌کنیم.

همانطور که ممکن است به یاد داشته باشید، باینری مستقل را از طریق cargo ایجاد کردیم، اما با توجه به سیستم عامل، به نام‌های ورودی و پرچم‌های کامپایل مختلف نیاز داشتیم. به این دلیل که cargo به طور پیش فرض برای سیستم میزبان بیلد می‌کند، بطور مثال سیستمی که از آن برای نوشتن هسته استفاده می‌کنید. این چیزی نیست که ما برای هسته خود بخواهیم‌، زیرا منطقی نیست که هسته سیستم عامل‌مان را روی یک سیستم عامل دیگر اجرا کنیم. در عوض، ما می‌خواهیم هسته را برای یک سیستم هدف کاملاً مشخص کامپایل کنیم.

🔗نصب Rust Nightly

راست دارای سه کانال انتشار است: stable, beta, and nightly (ترجمه از چپ به راست: پایدار، بتا و شبانه). کتاب Rust تفاوت بین این کانال‌ها را به خوبی توضیح می‌دهد، بنابراین یک دقیقه وقت بگذارید و آن را بررسی کنید. برای ساخت یک سیستم عامل به برخی از ویژگی‌های آزمایشی نیاز داریم که فقط در کانال شبانه موجود است‌، بنابراین باید نسخه شبانه Rust را نصب کنیم.

برای مدیریت نصب‌های Rust من به شدت rustup را توصیه می‌کنم. به شما این امکان را می‌دهد که کامپایلرهای شبانه، بتا و پایدار را در کنار هم نصب کنید و بروزرسانی آنها را آسان می‌کند. با rustup شما می‌توانید از یک کامپایلر شبانه برای دایرکتوری جاری استفاده کنید، کافیست دستور rustup override set nightly را اجرا کنید. همچنین می‌توانید فایلی به نام rust-toolchain را با محتوای nightly در دایرکتوری ریشه پروژه اضافه کنید. با اجرای rustc --version می‌توانید چک کنید که نسخه شبانه را دارید یا نه. شماره نسخه باید در پایان شامل nightly- باشد.

کامپایلر شبانه به ما امکان می‌دهد با استفاده از به اصطلاح feature flags در بالای فایل، از ویژگی‌های مختلف آزمایشی استفاده کنیم. به عنوان مثال، می‌توانیم asm! macro آزمایشی را برای اجرای دستورات اسمبلیِ این‌لاین (تلفظ: inline) با اضافه کردن [feature(asm)]!# به بالای فایل main.rs فعال کنیم. توجه داشته باشید که این ویژگی‌های آزمایشی، کاملاً ناپایدار هستند‌، به این معنی که نسخه‌های آتی Rust ممکن است بدون هشدار قبلی آن‌ها را تغییر داده یا حذف کند. به همین دلیل ما فقط در صورت لزوم از آنها استفاده خواهیم کرد.

🔗مشخصات هدف

کارگو (کلمه: cargo) سیستم‌های هدف‌ مختلف را از طریق target-- پشتیبانی می‌کند. سیستم هدف توسط یک به اصطلاح target triple (ترجمه: هدف سه گانه) توصیف شده‌ است، که معماری CPU، فروشنده، سیستم عامل، و ABI را شامل می‌شود. برای مثال، هدف سه گانه x86_64-unknown-linux-gnu یک سیستم را توصیف می‌کند که دارای سی‌پی‌یو x86_64، بدون فروشنده مشخص و یک سیستم عامل لینوکس با GNU ABI است. Rust از هدف‌های سه گانه مختلفی پشتیبانی می‌کند، شامل arm-linux-androideabi برای اندروید یا wasm32-unknown-unknown برای وب‌اسمبلی.

برای سیستم هدف خود، به برخی از پارامترهای خاص پیکربندی نیاز داریم (به عنوان مثال، فاقد سیستم عامل زیرین)، بنابراین هیچ یک از اهداف سه گانه موجود مناسب نیست. خوشبختانه Rust به ما اجازه می‌دهد تا هدف خود را از طریق یک فایل JSON تعریف کنیم. به عنوان مثال، یک فایل JSON که هدف x86_64-unknown-linux-gnu را توصیف می‌کند به این شکل است:

{
    "llvm-target": "x86_64-unknown-linux-gnu",
    "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "linux",
    "executables": true,
    "linker-flavor": "gcc",
    "pre-link-args": ["-m64"],
    "morestack": false
}

اکثر فیلدها برای LLVM مورد نیاز هستند تا بتواند کد را برای آن پلتفرم ایجاد کند. برای مثال، فیلد data-layout اندازه انواع مختلف عدد صحیح، مُمَیزِ شناور و انواع اشاره‌گر را تعریف می‌کند. سپس فیلد‌هایی وجود دارد که Rust برای کامپایل شرطی از آن‌ها استفاده می‌کند، مانند target-pointer-width. نوع سوم فیلدها نحوه ساخت crate (تلفظ: کرِیت) را تعریف می‌کنند. مثلا، فیلد pre-link-args آرگومان‌های منتقل شده به لینکر را مشخص می‌کند.

ما همچنین سیستم‌های x86_64 را با هسته خود مورد هدف قرار می‌دهیم‌، بنابراین مشخصات هدف ما بسیار شبیه به مورد بالا خواهد بود. بیایید با ایجاد یک فایل x86_64-blog_os.json شروع کنیم (هر اسمی را که دوست دارید انتخاب کنید) با محتوای مشترک:

{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "none",
    "executables": true
}

توجه داشته باشید که ما OS را در llvm-target و همچنین فیلد os را به none تغییر دادیم، زیرا ما هسته را روی یک bare metal اجرا می‌کنیم.

همچنین موارد زیر که مربوط به ساخت (ترجمه: build-related) هستند را اضافه می‌کنیم:

"linker-flavor": "ld.lld",
"linker": "rust-lld",

به جای استفاده از لینکر پیش فرض پلتفرم (که ممکن است از اهداف لینوکس پشتیبانی نکند)، ما از لینکر کراس پلتفرم LLD استفاده می‌کنیم که برای پیوند دادن هسته ما با Rust ارائه می‌شود.

"panic-strategy": "abort",

این تنظیم مشخص می‌کند که هدف از stack unwinding درهنگام panic پشتیبانی نمی‌کند، بنابراین به جای آن خود برنامه باید مستقیماً متوقف شود. این همان اثر است که آپشن panic = "abort" در فایل Cargo.toml دارد، پس میتوانیم آن را از فایل Cargo.toml حذف کنیم.(توجه داشته باشید که این آپشنِ هدف همچنین زمانی اعمال می‌شود که ما کتابخانه هسته را مجددا در ادامه همین پست کامپایل می‌‌کنیم. بنابراین حتماً این گزینه را اضافه کنید، حتی اگر ترجیح می دهید گزینه Cargo.toml را حفظ کنید.)

"disable-redzone": true,

ما در حال نوشتن یک هسته هستیم‌، بنابراین بالاخره باید وقفه‌ها را مدیریت کنیم. برای انجام ایمن آن، باید بهینه‌سازی اشاره‌گر پشته‌ای خاصی به نام “red zone” (ترجمه: منطقه قرمز) را غیرفعال کنیم، زیرا در غیر این صورت باعث خراب شدن پشته می‌شود. برای اطلاعات بیشتر، به پست جداگانه ما در مورد غیرفعال کردن منطقه قرمز مراجعه کنید.

"features": "-mmx,-sse,+soft-float",

فیلد features ویژگی‌های هدف را فعال/غیرفعال می‌کند. ما ویژگی‌های mmx و sse را با گذاشتن یک منفی در ابتدای آن‌ها غیرفعال کردیم و ویژگی soft-float را با اضافه کردن یک مثبت به ابتدای آن فعال کردیم. توجه داشته باشید که بین پرچم‌های مختلف نباید فاصله‌ای وجود داشته باشد، در غیر این صورت LLVM قادر به تفسیر رشته ویژگی‌ها نیست.

ویژگی‌های mmx و sse پشتیبانی از دستورالعمل‌های Single Instruction Multiple Data (SIMD) را تعیین می‌کنند، که اغلب می‌تواند سرعت برنامه‌ها را به میزان قابل توجهی افزایش دهد. با این حال، استفاده از ثبات‌های بزرگ SIMD در هسته سیستم عامل منجر به مشکلات عملکردی می‌شود. دلیل آن این است که هسته قبل از ادامه یک برنامه‌ی متوقف شده، باید تمام رجیسترها را به حالت اولیه خود برگرداند. این بدان معناست که هسته در هر فراخوانی سیستم یا وقفه سخت افزاری باید حالت کامل SIMD را در حافظه اصلی ذخیره کند. از آنجا که حالت SIMD بسیار بزرگ است (512-1600 بایت) و وقفه‌ها ممکن است اغلب اتفاق بیفتند، این عملیات ذخیره و بازیابی اضافی به طور قابل ملاحظه‌ای به عملکرد آسیب می‌رساند. برای جلوگیری از این، SIMD را برای هسته خود غیرفعال می‌کنیم (نه برای برنامه‌هایی که از روی آن اجرا می شوند!).

یک مشکل در غیرفعال کردن SIMD این است که عملیات‌های مُمَیزِ شناور (ترجمه: floating point) در x86_64 به طور پیش فرض به ثبات‌های SIMD نیاز دارد. برای حل این مشکل، ویژگی soft-float را اضافه می‌کنیم، که از طریق عملکردهای نرم‌افزاری مبتنی بر اعداد صحیح عادی، تمام عملیات مُمَیزِ شناور را شبیه‌سازی می‌کند.

For more information, see our post on disabling SIMD.

🔗کنار هم قرار دادن

فایل مشخصات هدف ما اکنون به این شکل است:

{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "none",
    "executables": true,
    "linker-flavor": "ld.lld",
    "linker": "rust-lld",
    "panic-strategy": "abort",
    "disable-redzone": true,
    "features": "-mmx,-sse,+soft-float"
}

🔗ساخت هسته

عملیات کامپایل کردن برای هدف جدید ما از قراردادهای لینوکس استفاده خواهد کرد (کاملاً مطمئن نیستم که چرا، تصور می‌کنم این فقط پیش فرض LLVM باشد). این بدان معنی است که ما به یک نقطه ورود به نام start_ نیاز داریم همانطور که در پست قبلی توضیح داده شد:

// src/main.rs

#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points

use core::panic::PanicInfo;

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
    // this function is the entry point, since the linker looks for a function
    // named `_start` by default
    loop {}
}

توجه داشته باشید که بدون توجه به سیستم عامل میزبان، باید نقطه ورود را start_ بنامید.

اکنون می‌توانیم با نوشتن نام فایل JSON بعنوان target--، هسته خود را برای هدف جدید بسازیم:

> cargo build --target x86_64-blog_os.json

error[E0463]: can't find crate for `core`

شکست میخورد! این خطا به ما می‌گوید که کامپایلر Rust دیگر کتابخانه core را پیدا نمی‌کند. این کتابخانه شامل انواع اساسی Rust مانند Result ، Option و iterators است، و به طور ضمنی به همه کریت‌های no_std لینک است.

مشکل این است که کتابخانه core همراه با کامپایلر Rust به عنوان یک کتابخانه precompiled (ترجمه: از پیش کامپایل شده) توزیع می‌شود. بنابراین فقط برای میزبان‌های سه‌گانه پشتیبانی شده مجاز است (مثلا، x86_64-unknown-linux-gnu) اما برای هدف سفارشی ما صدق نمی‌کند. اگر می‌خواهیم برای سیستم‌های هدف دیگر کدی را کامپایل کنیم، ابتدا باید core را برای این اهداف دوباره کامپایل کنیم.

🔗آپشن build-std

این‌جاست که ویژگی build-std کارگو وارد می‌شود. این امکان را می‌دهد تا بجای استفاده از نسخه‌های از پیش کامپایل شده با نصب Rust، بتوانیم core و ‌کریت سایر کتابخانه‌های استاندارد را در صورت نیاز دوباره کامپایل کنیم. این ویژگی بسیار جدید بوده و هنوز تکمیل نشده است، بنابراین بعنوان «ناپایدار» علامت گذاری شده و فقط در نسخه شبانه کامپایلر Rust در دسترس می‌باشد.

برای استفاده از این ویژگی، ما نیاز داریم تا یک فایل پیکربندی کارگو در cargo/config.toml. با محتوای زیر بسازیم:

# in .cargo/config.toml

[unstable]
build-std = ["core", "compiler_builtins"]

این به کارگو می‌گوید که باید core و کتابخانه‌ compiler_builtins را دوباره کامپایل کند. مورد دوم لازم است زیرا یک وابستگی از core است. به منظور کامپایل مجدد این کتابخانه‌ها، کارگو نیاز به دسترسی به کد منبع Rust دارد که می‌توانیم آن را با rustup component add rust-src نصب کنیم.

یادداشت: کلید پیکربندی unstable.build-std به نسخه‌‌ای جدیدتر از نسخه 2020-07-15 شبانه Rust نیاز دارد.

پس از تنظیم کلید پیکربندی unstable.build-std و نصب مولفه rust-src، می‌توانیم مجددا دستور بیلد (کلمه: build) را اجرا کنیم.

> cargo build --target x86_64-blog_os.json
   Compiling core v0.0.0 (/…/rust/src/libcore)
   Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
   Compiling compiler_builtins v0.1.32
   Compiling blog_os v0.1.0 (/…/blog_os)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs

می‌بینیم که cargo build دوباره core و rustc-std-workspace-core (یک وابستگی از compiler_builtins)، و کتابخانه compiler_builtins را برای سیستم هدف سفارشی‌مان کامپایل می‌کند.

🔗موارد ذاتیِ مربوط به مموری

کامپایلر Rust فرض می‌کند که مجموعه خاصی از توابع داخلی برای همه سیستم‌ها در دسترس است. اکثر این توابع توسط کریت compiler_builtins ارائه می‌شود که ما آن را به تازگی مجددا کامپایل کردیم. با این حال‌، برخی از توابع مربوط به حافظه در آن کریت وجود دارد که به طور پیش‌فرض فعال نیستند زیرا به طور معمول توسط کتابخانه C موجود در سیستم ارائه می‌شوند. این توابع شامل memset می‌باشد که مجموعه تمام بایت‌ها را در یک بلوک حافظه بر روی یک مقدار مشخص قرار می‌دهد، memcpy که یک بلوک حافظه را در دیگری کپی می‌کند و memcmp که دو بلوک حافظه را با یکدیگر مقایسه می‌کند. اگرچه ما در حال حاضر به هیچ یک از این توابع برای کامپایل هسته خود نیازی نداریم، اما به محض افزودن کدهای بیشتر به آن، این توابع مورد نیاز خواهند بود (برای مثال، هنگام کپی کردن یک ساختمان).

از آنجا که نمی‌توانیم به کتابخانه C سیستم عامل لینک دهیم، به روشی جایگزین برای ارائه این توابع به کامپایلر نیاز داریم. یک رویکرد ممکن برای این کار می‌تواند پیاده‌سازی توابع memset و غیره و اعمال صفت [no_mangle]# (برای جلوگیری از تغییر نام خودکار در هنگام کامپایل کردن) بر روی آنها اعمال باشد. با این حال، این خطرناک است زیرا کوچک‌ترین اشتباهی در اجرای این توابع می‌تواند منجر به یک رفتار تعریف نشده شود. به عنوان مثال، ممکن است هنگام پیاده‌سازی memcpy با استفاده از حلقه for یک بازگشت بی‌پایان داشته باشید زیرا حلقه‌های for به طور ضمنی مِتُد تریتِ (کلمه: trait) IntoIterator::into_iter را فراخوانی می‌کنند، که ممکن است دوباره memcpy را فراخوانی کند. بنابراین بهتر است به جای آن از پیاده سازی‌های تست شده موجود، مجدداً استفاده کنید.

خوشبختانه کریت compiler_builtins از قبل شامل پیاده سازی تمام توابع مورد نیازمان است، آن‌ها فقط به طور پیش فرض غیرفعال هستند تا با پیاده سازی های کتابخانه C تداخلی نداشته باشند. ما می‌توانیم آنها را با تنظیم پرچم build-std-features کارگو بر روی ["compiler-builtins-mem"] فعال کنیم. مانند پرچم build-std، این پرچم می‌تواند به عنوان پرچم Z- در خط فرمان استفاده شود یا در جدول unstable در فایل cargo/config.toml. پیکربندی شود. از آن‌جا که همیشه می‌خواهیم با این پرچم بیلد کنیم، گزینه پیکربندی فایل منطقی‌تر است:

# in .cargo/config.toml

[unstable]
build-std-features = ["compiler-builtins-mem"]

پشتیبانی برای ویژگی compiler-builtins-mem به تازگی اضافه شده، پس حداقل به نسخه‌ شبانه‌ 2020-09-30 نیاز دارید.

در پشت صحنه، این پرچم ویژگی mem از کریت compiler_builtins را فعال می‌کند. اثرش این است که صفت [no_mangle]# بر روی پیاده‌سازی memcpy و بقیه موارد از کریت اعمال می‌شود، که آن‌ها در دسترس لینکر قرار می‌دهد. شایان ذکر است که این توابع در حال حاضر بهینه نشده‌اند، بنابراین ممکن است عملکرد آ‌ن‌ها در بهترین حالت نباشد، اما حداقل صحیح هستند. برای x86_64 ، یک pull request باز برای بهینه سازی این توابع با استفاده از دستورالعمل‌های خاص اسمبلی وجود دارد.

با این تغییر، هسته ما برای همه توابع مورد نیاز کامپایلر، پیاده سازی معتبری دارد، بنابراین حتی اگر کد ما پیچیده‌تر باشد نیز باز کامپایل می‌شود.

🔗تنظیم یک هدف پیش‌ فرض

برای این‌که نیاز نباشد در هر فراخوانی cargo build پارامتر target-- را وارد کنیم، می‌توانیم هدف پیش‌فرض را بازنویسی کنیم. برای این کار، ما کد زیر را به پیکربندی کارگو در فایل cargo/config.toml. اضافه می‌کنیم:

# in .cargo/config.toml

[build]
target = "x86_64-blog_os.json"

این به cargo می‌گوید در صورتی که صریحاً از target-- استفاده نکردیم، از هدف ما یعنی x86_64-blog_os.json استفاده کند. در واقع اکنون می‌توانیم هسته خود را با یک cargo build ساده بسازیم. برای اطلاعات بیشتر در مورد گزینه‌های پیکربندی کارگو، اسناد رسمی را بررسی کنید.

اکنون می‌توانیم هسته را برای یک هدف bare metal با یک cargo build ساده بسازیم. با این حال، نقطه ورود start_ ما، که توسط بوت لودر فراخوانی می‌شود، هنوز خالی است. وقت آن است که از طریق آن، چیزی را در خروجی نمایش دهیم.

🔗چاپ روی صفحه

ساده‌ترین راه برای چاپ متن در صفحه در این مرحله بافر متن VGA است. این یک منطقه خاص حافظه است که به سخت افزار VGA نگاشت (مَپ) شده و حاوی مطالب نمایش داده شده روی صفحه است. به طور معمول از 25 خط تشکیل شده است که هر کدام شامل 80 سلول کاراکتر هستند. هر سلول کاراکتر یک کاراکتر ASCII را با برخی از رنگ‌های پیش زمینه و پس زمینه نشان می‌دهد. خروجی صفحه به این شکل است:

screen output for common ASCII characters

ما در پست بعدی، جایی که اولین درایور کوچک را برای آن می‌نویسیم، در مورد قالب دقیق بافر متن VGA بحث خواهیم کرد. برای چاپ “!Hello World”‌، فقط باید بدانیم که بافر در آدرس 0xb8000 قرار دارد و هر سلول کاراکتر از یک بایت ASCII و یک بایت رنگ تشکیل شده است.

پیاده‌سازی مشابه این است:

static HELLO: &[u8] = b"Hello World!";

#[no_mangle]
pub extern "C" fn _start() -> ! {
    let vga_buffer = 0xb8000 as *mut u8;

    for (i, &byte) in HELLO.iter().enumerate() {
        unsafe {
            *vga_buffer.offset(i as isize * 2) = byte;
            *vga_buffer.offset(i as isize * 2 + 1) = 0xb;
        }
    }

    loop {}
}

ابتدا عدد صحیح 0xb8000 را در یک اشاره‌گر خام (ترجمه: raw pointer) می‌ریزیم. سپس روی بایت‌های رشته بایت استاتیک HELLO پیمایش می‌کنیم. ما از متد enumerate برای اضافه کردن متغیر درحال اجرای i استفاده می‌کنیم. در بدنه حلقه for، از متد offset برای نوشتن بایت رشته و بایت رنگ مربوطه استفاده می‌کنیم (0xb فیروزه‌ای روشن است).

توجه داشته باشید که یک بلوک unsafe همیشه هنگام نوشتن در حافظه مورد استفاده قرار می‌گیرد. دلیل این امر این است که کامپایلر Rust نمی‌تواند معتبر بودن اشاره‌گرهای خام که ایجاد میکنیم را ثابت کند. آن‌ها می‌توانند به هر کجا اشاره کنند و منجر به خراب شدن داده‌ها شوند. با قرار دادن آن‌ها در یک بلوک unsafe، ما در اصل به کامپایلر می‌گوییم که کاملاً از معتبر بودن عملیات اطمینان داریم. توجه داشته باشید که یک بلوک unsafe، بررسی‌های ایمنی Rust را خاموش نمی‌کند. فقط به شما این امکان را می‌دهد که پنج کار اضافی انجام دهید.

می خواهم تأکید کنم که این روشی نیست که ما بخواهیم در Rust کارها را از طریق آن پبش ببریم! به هم ریختگی هنگام کار با اشاره‌گرهای خام در داخل بلوک‌های ناامن بسیار محتمل و ساده است، به عنوان مثال، اگر مواظب نباشیم به راحتی می‌توانیم فراتر از انتهای بافر بنویسیم.

بنابراین ما می‌خواهیم تا آن‌جا که ممکن است استفاده از unsafe را به حداقل برسانیم. Rust با ایجاد انتزاع‌های ایمن به ما توانایی انجام این کار را می‌دهد. به عنوان مثال، ما می‌توانیم یک نوع بافر VGA ایجاد کنیم که تمام کدهای ناامن را در خود قرار داده و اطمینان حاصل کند که انجام هرگونه اشتباه از خارج از این انتزاع غیرممکن است. به این ترتیب، ما فقط به حداقل مقادیر ناامن نیاز خواهیم داشت و می‌توان اطمینان داشت که ایمنی حافظه را نقض نمی‌کنیم. در پست بعدی چنین انتزاع ایمن بافر VGA را ایجاد خواهیم کرد.

🔗اجرای هسته

حال یک هسته اجرایی داریم که کار محسوسی را انجام می‌دهد، پس زمان اجرای آن فرا رسیده است. ابتدا، باید هسته کامپایل شده خود را با پیوند دادن آن به یک بوت‌لودر، به یک دیسک ایمیج قابل بوت تبدیل کنیم. سپس می‌توانیم دیسک ایمیج را در ماشین مجازی QEMU اجرا یا با استفاده از یک درایو USB آن را بر روی سخت افزار واقعی بوت کنیم.

🔗ساخت دیسک ایمیج

برای تبدیل هسته کامپایل شده به یک دیسک ایمیج قابل بوت، باید آن را با یک بوت لودر پیوند دهیم. همانطور که در [بخش مربوط به بوت شدن (لینک باید اپدیت شود)] آموختیم، بوت لودر مسئول مقداردهی اولیه پردازنده و بارگیری هسته می‌باشد.

به جای نوشتن یک بوت لودر مخصوص خودمان، که به تنهایی یک پروژه است، از کریت bootloader استفاده می‌کنیم. این کریت بوت‌لودر اصلی BIOS را بدون هیچگونه وابستگی به C، فقط با استفاده از Rust و این‌لاین اسمبلی پیاده سازی می‌کند. برای استفاده از آن برای راه اندازی هسته، باید وابستگی به آن را ضافه کنیم:

# in Cargo.toml

[dependencies]
bootloader = "0.9"

افزودن بوت‌لودر به عنوان وابستگی برای ایجاد یک دیسک ایمیج قابل بوت کافی نیست. مشکل این است که ما باید هسته خود را با بوت لودر پیوند دهیم، اما کارگو از اسکریپت های بعد از بیلد پشتیبانی نمی‌کند.

برای حل این مشکل، ما ابزاری به نام bootimage ایجاد کردیم که ابتدا هسته و بوت لودر را کامپایل می‌کند و سپس آن‌ها را به یکدیگر پیوند می‌دهد تا یک ایمیج دیسک قابل بوت ایجاد کند. برای نصب ابزار‌، دستور زیر را در ترمینال خود اجرا کنید:

cargo install bootimage

برای اجرای bootimage و ساختن بوت‌لودر، شما باید llvm-tools-preview که یک مولفه rustup می‌باشد را نصب داشته باشید. شما می‌توانید این کار را با اجرای دستور rustup component add llvm-tools-preview انجام دهید.

پس از نصب bootimage و اضافه کردن مولفه llvm-tools-preview، ما می‌توانیم یک دیسک ایمیج قابل بوت را با اجرای این دستور ایجاد کنیم:

> cargo bootimage

می‌بینیم که این ابزار، هسته ما را با استفاده از cargo build دوباره کامپایل می‌کند، بنابراین به طور خودکار هر تغییری که ایجاد می‌کنید را در‌بر‌ میگیرد. پس از آن بوت‌لودر را کامپایل می‌کند که ممکن است مدتی طول بکشد. مانند تمام کریت‌های وابسته ، فقط یک بار بیلد می‌شود و سپس کش (کلمه: cache) می‌شود، بنابراین بیلدهای بعدی بسیار سریع‌تر خواهد بود. سرانجام، bootimage، بوت‌لودر و هسته شما را با یک دیسک ایمیج قابل بوت ترکیب می‌کند.

پس از اجرای این دستور، شما باید یک دیسک ایمیج قابل بوت به نام bootimage-blog_os.bin در مسیر target/x86_64-blog_os/debug ببینید. شما می‌توانید آن را در یک ماشین مجازی بوت کنید یا آن را در یک درایو USB کپی کرده و روی یک سخت افزار واقعی بوت کنید. (توجه داشته باشید که این یک ایمیج CD نیست، بنابراین رایت کردن آن روی CD بی‌فایده‌ است چرا که ایمیج CD دارای قالب متفاوتی است).

🔗چگونه کار می کند؟

ابزار bootimage مراحل زیر را در پشت صحنه انجام می دهد:

  • کرنل ما را به یک فایل ELF کامپایل می‌کند.
  • وابستگی بوت‌لودر را به عنوان یک اجرایی مستقل (ترجمه: standalone executable) کامپایل می‌کند.
  • بایت‌های فایل ELF هسته را به بوت‌لودر پیوند می‌دهد.

وقتی بوت شد، بوت‌لودر فایل ضمیمه شده ELF را خوانده و تجزیه می‌کند. سپس بخش‌های (ترجمه: segments) برنامه را به آدرس‌های مجازی در جداول صفحه نگاشت (مپ) می‌کند، بخش bss. را صفر کرده و یک پشته را تنظیم می‌کند. در آخر، آدرس نقطه ورود (تابع start_) را خوانده و به آن پرش میکند.

🔗بوت کردن در QEMU

اکنون می‌توانیم دیسک ایمیج را در یک ماشین مجازی بوت کنیم. برای راه اندازی آن در QEMU، دستور زیر را اجرا کنید:

> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]

این یک پنجره جداگانه با این شکل باز می‌کند:

QEMU showing “Hello World!”

می‌بینیم که “!Hello World” بر روی صفحه قابل مشاهده است.

🔗ماشین واقعی

همچنین می‌توانید آن را بر روی یک درایو USB رایت و بر روی یک دستگاه واقعی بوت کنید:

> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync

در اینجا sdX نام دستگاه USB شماست. مراقب باشید که نام دستگاه را به درستی انتخاب کنید، زیرا همه داده‌های موجود در آن دستگاه بازنویسی می‌شوند.

پس از رایت کردن ایمیج در USB، می‌توانید با بوت کردن، آن را بر روی سخت افزار واقعی اجرا کنید. برای راه اندازی از طریق USB احتمالاً باید از یک منوی بوت ویژه استفاده کنید یا ترتیب بوت را در پیکربندی BIOS تغییر دهید. توجه داشته باشید که این در حال حاضر برای دستگاه‌های UEFI کار نمی‌کند، زیرا کریت bootloader هنوز پشتیبانی UEFI را ندارد.

🔗استفاده از cargo run

برای سهولت اجرای هسته در QEMU، می‌توانیم کلید پیکربندی runner را برای کارگو تنظیم کنیم:

# in .cargo/config.toml

[target.'cfg(target_os = "none")']
runner = "bootimage runner"

جدول 'target.'cfg(target_os = "none") برای همه اهدافی که فیلد "os" فایل پیکربندی هدف خود را روی "none" تنظیم کرده‌اند، اعمال می‌شود. این شامل هدف x86_64-blog_os.json نیز می‌شود. runner دستوری را که باید برای cargo run فراخوانی شود مشخص می‌کند. دستور پس از بیلد موفقیت آمیز با مسیر فایل اجرایی که به عنوان اولین آرگومان داده شده، اجرا می‌شود. برای جزئیات بیشتر به اسناد کارگو مراجعه کنید.

دستور bootimage runner بصورت مشخص طراحی شده تا بعنوان یک runner قابل اجرا مورد استفاده قرار بگیرد. فایل اجرایی داده شده را به بوت‌لودر پروژه پیوند داده و سپس QEMU را اجرا می‌کند. برای جزئیات بیشتر و گزینه‌های پیکربندی احتمالی‌، به توضیحات bootimage مراجعه کنید.

اکنون می‌توانیم از cargo run برای کامپایل هسته خود و راه اندازی آن در QEMU استفاده کنیم.

🔗مرحله بعد چیست؟

در پست بعدی، ما بافر متن VGA را با جزئیات بیشتری بررسی خواهیم کرد و یک رابط امن برای آن می‌نویسیم. همچنین پشتیبانی از ماکرو println را نیز اضافه خواهیم کرد.



نظرات

Do you have a problem, want to share feedback, or discuss further ideas? Feel free to leave a comment here! Please stick to English and follow Rust's code of conduct. This comment thread directly maps to a discussion on GitHub, so you can also comment there if you prefer.

Instead of authenticating the giscus application, you can also comment directly on GitHub.

لطفا نظرات خود را در صورت امکان به انگلیسی بنویسید.