ساخت یک شبیه‌ساز aarch64 با JIT در زبان Rust

آیا تا به حال فکر کرده‌اید که نرم‌افزارهایی مانند QEMU چگونه می‌توانند یک سیستم‌عامل کامل را برای یک معماری کامپیوتری متفاوت اجرا کنند؟ این مطلب به بررسی عمیق یک پروژه جذاب می‌پردازد: ساخت یک شبیه‌ساز ساده با کامپایل درجا (JIT) برای مجموعه دستورالعمل aarch64 (معماری ۶۴ بیتی Arm) که کاملاً از ابتدا با زبان Rust نوشته شده است.

هدف این پروژه فقط ساخت یک شبیه‌ساز نبود، بلکه درک اصول بنیادی پشت سیستم‌های پیچیده‌ای مانند Tiny Code Generator (TCG) در QEMU بود. نویسنده به جای بررسی کدهای C، رویکردی عملی را در پیش گرفت و همان مفاهیم را در Rust پیاده‌سازی کرد و از کتابخانه‌های قدرتمند برای بخش‌های سنگین کار بهره برد.

از دستورالعمل‌های aarch64 تا کد بومی

جادوی شبیه‌سازی JIT در یک فرآیند دو مرحله‌ای اتفاق می‌افتد:

  1. واکشی (Disassembly): شبیه‌ساز ابتدا کد باینری aarch64 را می‌خواند و با استفاده از کتابخانه‌ای مانند binja، آن را به دستورالعمل‌های مجزا تجزیه می‌کند.
  2. ترجمه و کامپایل JIT: سپس هر دستورالعمل با استفاده از بک‌اند Cranelift JIT به مجموعه‌ای از عملیات معادل ترجمه می‌شود. Cranelift این عملیات را به کد ماشین بومی کامپایل می‌کند که می‌تواند مستقیماً توسط پردازنده میزبان با سرعت بالا اجرا شود.

این منطق ترجمه در اصل یک عبارت match بزرگ است که هر عملیات aarch64 را به معادل JIT آن نگاشت می‌کند. به عنوان مثال، دستور ORR (عملیات بیتی OR) به یک عملیات bor در Cranelift ترجمه می‌شود.

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

فراتر از یک مجموعه دستورالعمل

یک شبیه‌ساز باید کارهای بیشتری از ترجمه دستورالعمل‌های پردازنده انجام دهد. باید دستگاه‌های سخت‌افزاری را نیز شبیه‌سازی کند. برای توسعه اولیه، یک راه ساده برای دریافت خروجی از ماشین مجازی حیاتی است. نویسنده هوشمندانه از یک پیاده‌سازی Rust از یک دستگاه جانبی PL011 (UART مخصوص Arm) که قبلاً برای QEMU نوشته شده بود، دوباره استفاده کرد. با هدایت خروجی آن به خروجی استاندارد، ماشین شبیه‌سازی شده توانست بلافاصله عبارت “Hello world!” را چاپ کند—شاهدی بر قابلیت حمل و ماژولار بودن زبان Rust.

این UART از طریق ورودی/خروجی حافظه‌نگاشت (MMIO) کار می‌کند. هنگامی که سیستم‌عامل مهمان در یک آدرس حافظه خاص می‌نویسد، داده‌ها به RAM نمی‌روند؛ در عوض، منطق UART در شبیه‌ساز فعال می‌شود و باعث چاپ یک کاراکتر در کنسول می‌شود.

ساخت ماشین مجازی

ماشین مجازی حول چند جزء اصلی ساخته شده است:

  • هسته: برای سادگی، تنها یک هسته پردازنده (یا عنصر پردازشی) شبیه‌سازی می‌شود.
  • حافظه: بخشی از حافظه میزبان به عنوان RAM مهمان تخصیص داده می‌شود. شبیه‌ساز همچنین می‌تواند یک درخت دستگاه (device tree) ساده تولید کند، که یک ساختار داده برای توصیف چیدمان سخت‌افزار به سیستم‌عامل مهمان است.
  • حلقه اجرا: شبیه‌ساز یک شمارنده برنامه (PC) را برای ردیابی دستورالعمل فعلی نگه می‌دارد. بلوک ترجمه مربوط به PC فعلی را پیدا کرده، آن را اجرا می‌کند و سپس از PC حاصل برای یافتن بلوک بعدی استفاده می‌کند. برای افزایش سرعت، بلوک‌های کامپایل شده کش می‌شوند تا فقط یک بار نیاز به ترجمه داشته باشند.

اشکال‌زدایی و آزمایش

چگونه کدی را که در داخل یک شبیه‌ساز که خودتان ساخته‌اید اجرا می‌شود، اشکال‌زدایی می‌کنید؟ با ساخت یک سرور GDB! با استفاده از کتابخانه عالی gdbstub برای Rust، شبیه‌ساز یک هدف از راه دور را ارائه می‌دهد که GDB می‌تواند به آن متصل شود. این امکان اجرای گام‌به‌گام کد، تنظیم نقاط شکست و بازرسی رجیسترها را فراهم می‌کند، درست مانند اشکال‌زدایی یک برنامه بومی.

آزمایش نیز به همان اندازه دقیق است. این پروژه از دو رویکرد اصلی استفاده می‌کند:

  1. تست‌های واحد: قطعه‌های کوچکی از کد اسمبلی در نمونه‌های کوچک ماشین مجازی اجرا می‌شوند تا صحت عملکرد دستورالعمل‌های منفرد تأیید شود.
  2. اجرای مقایسه‌ای: یک کرنل تست ساده bare-metal به طور همزمان در شبیه‌ساز جدید و QEMU اجرا می‌شود. یک اسکریپت پایتون هر دو ماشین مجازی را به صورت گام‌به‌گام و همزمان اجرا کرده و وضعیت رجیستر آنها را پس از هر دستور مقایسه می‌کند و هرگونه اختلاف را به طور خودکار گزارش می‌دهد. این تکنیک قدرتمند به کشف تعداد زیادی از باگ‌های ظریف کمک کرده است.

مسیر پیش رو

هدف نهایی بوت کردن لینوکس است. این یک وظیفه بزرگ است که هنوز نیازمند پیاده‌سازی چندین ویژگی حیاتی از جمله مدیریت استثناها، حافظه مجازی (MMU)، تایمرها و یک کنترل‌کننده وقفه است. این سفر هنوز به پایان نرسیده است، اما این پروژه یک پایه محکم و نگاهی جذاب به دنیای شبیه‌سازی نرم‌افزاری فراهم می‌کند.

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

منبع: https://news.ycombinator.com/item?id=45023579

مخزن گیت‌هاب: https://github.com/epilys/simulans

Leave a Comment