Writing an OS in Rust

Philipp Oppermann's blog

استثناهای پردازنده

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

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

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

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

فهرست مطالب

🔗بررسی اجمالی

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

در x86 حدود 20 نوع مختلف استثنا پردازنده وجود دارد. مهمترین آنها در زیر آمده اند:

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

برای مشاهده لیست کامل استثنا‌ها ، ویکی OSDev را بررسی کنید.

🔗جدول توصیف کننده وقفه

برای گرفتن و رسیدگی به استثنا‌ها ، باید اصطلاحاً جدول توصیفگر وقفه (IDT) را تنظیم کنیم. در این جدول می توانیم برای هر استثنا پردازنده یک عملکرد تابع کننده مشخص کنیم. سخت افزار به طور مستقیم از این جدول استفاده می کند ، بنابراین باید از یک قالب از پیش تعریف شده پیروی کنیم. هر ورودی جدول باید ساختار 16 بایتی زیر را داشته باشد:

TypeNameDescription
u16Function Pointer [0:15]The lower bits of the pointer to the handler function.
u16GDT selectorSelector of a code segment in the global descriptor table.
u16Options(see below)
u16Function Pointer [16:31]The middle bits of the pointer to the handler function.
u32Function Pointer [32:63]The remaining bits of the pointer to the handler function.
u32Reserved

قسمت گزینه ها (Options) دارای قالب زیر است:

BitsNameDescription
0-2Interrupt Stack Table Index0: Don't switch stacks, 1-7: Switch to the n-th stack in the Interrupt Stack Table when this handler is called.
3-7Reserved
80: Interrupt Gate, 1: Trap GateIf this bit is 0, interrupts are disabled when this handler is called.
9-11must be one
12must be zero
13‑14Descriptor Privilege Level (DPL)The minimal privilege level required for calling this handler.
15Present

هر استثنا دارای یک اندیس از پیش تعریف شده در IDT است. به عنوان مثال استثنا کد نامعتبر دارای اندیس 6 و استثنا خطای صفحه دارای اندیس 14 است. بنابراین ، سخت افزار می تواند به طور خودکار عنصر مربوطه را برای هر استثنا بارگذاری کند. جدول استثناها در ویکی OSDev ، اندیس های IDT کلیه استثناها را در ستون “Vector nr.” نشان داده است.

هنگامی که یک استثنا رخ می دهد ، پردازنده تقریباً موارد زیر را انجام می دهد:

  1. برخی از ثبات‌ها را به پشته وارد می‌کند، از جمله اشاره گر دستورالعمل و ثبات RFLAGS. (بعداً در این پست از این مقادیر استفاده خواهیم کرد.)
  2. عنصر مربوط به آن (استثنا) را از جدول توصیف کننده وقفه (IDT) می‌خواند. به عنوان مثال ، پردازنده هنگام رخ دادن خطای صفحه ، عنصر چهاردهم را می خواند.
  3. وجود عنصر را بررسی می‌کند. اگر اینگونه نباشد یک خطای دوگانه ایجاد می‌کند.
  4. اگر عنصر یک گیت وقفه است (بیت 40 تنظیم نشده است) وقفه های سخت افزاری را غیرفعال می‌کند.
  5. انتخابگر مشخص شده GDT را در سگمنت CS بارگذاری می‌کند.
  6. به تابع کنترل کننده مشخص شده می‌رود.

در حال حاضر نگران مراحل 4 و 5 نباشید ، ما در مورد جدول توصیف کننده گلوبال و وقفه های سخت افزاری در پست های بعدی خواهیم آموخت.

🔗یک نوع IDT

به جای ایجاد نوع IDT خود ، از ساختمان InterruptDescriptorTable کرت x86_64 استفاده خواهیم کرد که به این شکل است:

#[repr(C)]
pub struct InterruptDescriptorTable {
    pub divide_by_zero: Entry<HandlerFunc>,
    pub debug: Entry<HandlerFunc>,
    pub non_maskable_interrupt: Entry<HandlerFunc>,
    pub breakpoint: Entry<HandlerFunc>,
    pub overflow: Entry<HandlerFunc>,
    pub bound_range_exceeded: Entry<HandlerFunc>,
    pub invalid_opcode: Entry<HandlerFunc>,
    pub device_not_available: Entry<HandlerFunc>,
    pub double_fault: Entry<HandlerFuncWithErrCode>,
    pub invalid_tss: Entry<HandlerFuncWithErrCode>,
    pub segment_not_present: Entry<HandlerFuncWithErrCode>,
    pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
    pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
    pub page_fault: Entry<PageFaultHandlerFunc>,
    pub x87_floating_point: Entry<HandlerFunc>,
    pub alignment_check: Entry<HandlerFuncWithErrCode>,
    pub machine_check: Entry<HandlerFunc>,
    pub simd_floating_point: Entry<HandlerFunc>,
    pub virtualization: Entry<HandlerFunc>,
    pub security_exception: Entry<HandlerFuncWithErrCode>,
    // some fields omitted
}

فیلدها از نوع <idt::Entry<F هستند ، این ساختمانی است که فیلد های یک عنصر IDT را نشان می دهد (به جدول بالا مراجعه کنید). پارامتر نوع F، نوع تابع کنترل کننده مورد انتظار را تعریف می کند. می بینیم که برخی از عناصر به یک HandlerFunc و برخی دیگر به HandlerFuncWithErrCode نیاز دارند. خطای صفحه حتی نوع خاص خود را دارد: PageFaultHandlerFunc.

بیایید ابتدا به نوع HandlerFunc نگاه کنیم:

type HandlerFunc = extern "x86-interrupt" fn(_: &mut InterruptStackFrame);

این یک نوع مستعار(type alias) برای نوع "extern "x86-interrupt" fn است. کلمه کلیدی extern تابعی را با یک قرارداد فراخوانی خارجی تعریف می کند و اغلب برای برقراری ارتباط با کد C استفاده می شود(extern "C" fn) . اما قرارداد فراخوانی x86-interrupt چیست؟

🔗قرارداد فراخوانی وقفه

استثنا‌ها کاملاً شبیه فراخوانی توابع هستند: پردازنده به اولین دستورالعمل تابع فراخوانی شده می رود و آن را اجرا می کند. پس از آن پردازنده به آدرس بازگشت می پرد و اجرای تابع اصلی را ادامه می دهد.

با این وجود ، تفاوت عمده ای بین فراخوانی استثناها و توابع وجود دارد: یک فراخوانی تابع توسط یک کامپایلر که دستور "فراخوانی" در آن درج شده است ، انجام می شود ، در حالی که یک استثنا ممکن است در هر دستورالعملی رخ دهد. برای درک عواقب این تفاوت ، باید فراخوانی توابع را با جزئیات بیشتری بررسی کنیم.

قرارداد فراخوانی جزئیات فراخوانی تابع را مشخص می کند. به عنوان مثال ، آنها مشخص می‌کنند که پارامترهای تابع کجا قرار می گیرند (به عنوان مثال در ثبات‌ها یا بر روی پشته) و نحوه بازگشت نتایج. در x86_64 لینوکس ، قوانین زیر برای توابع C اعمال می شود (مشخص شده در System V ABI):

  • شش آرگومان اول با نوع عدد صحیح در ثبات‌هایrdi, rsi, rdx, rcx, r8, r9 منتقل می شوند
  • آرگومان های اضافی بر روی پشته منتقل می شوند
  • نتایج درونrax و rdx بر می گردند

توجه داشته باشید که راست از C ABI پیروی نمی کند (در واقع ، هنوز حتی یک Rust ABI وجود ندارد) ، بنابراین این قوانین فقط برای توابع اعلام شده به عنوان extern "C" fn اعمال می شود.

🔗ثبات های حفظ شده و تغییرشونده (Scratch)

قرارداد فراخوانی، ثبات‌ها را به دو دسته ثبات های محفوظ شده و تغییرشونده تقسیم می کند.

مقادیر ثبات‌های محفوظ شده، در فراخوانی تابع باید بدون تغییر باقی بمانند. بنابراین یک تابع فراخوانی شده (“callee”) فقط در صورتی مجاز است این ثبات‌ها را تغییر دهد، که مقادیر اصلی آنها را قبل از بازگشت، برگرداند. بنابراین به این ثبات‌ها "callee-saved" گفته می شود. یک الگوی عمومی این است که ثبات‌ها در آغاز تابع بر روی پشته ذخیره شده و درست قبل از بازگشت از پشته برداشته شده و مقدار دهی شوند.

در مقابل، یک تابع فراخوانی شده مجاز است که بدون محدودیت ، ثبات‌های تغییرشونده را دوباره بنویسد. اگر فراخواننده ("caller") بخواهد مقدار یک ثبات تغییرشونده را در یک فراخوانی تابع حفظ کند ، لازم است قبل از فراخوانی تابع (به عنوان مثال بوسیله اضافه و برداشتن از روی پشته) آن را پشتیبان گیری و بازیابی کند. بنابراین ثبات‌های تغییرشونده caller-saved هستند.

در x86_64 ، قرارداد فراخوانی C ثبات‌های محفوظ شده و تغییرشونده زیر را مشخص می کند:

preserved registersscratch registers
rbp, rbx, rsp, r12, r13, r14, r15rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11
callee-savedcaller-saved

کامپایلر این قوانین را می داند ، بنابراین کد را متناسب با آن تولید می کند. به عنوان مثال ، بیشتر توابع با push rbp شروع می شوند که پشتیبان گیری ازrbp روی پشته است (زیرا این یک ثبات caller-saved).

🔗حفظ کلیه ثبات‌ها

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

از آنجا که نمی دانیم چه زمانی استثنا رخ می‌دهد ، نمی توانیم قبل از آن از هیچ ثباتی پشتیبان گیری کنیم. این بدان معناست که ما نمی توانیم از قرارداد فراخوانی‌ای استفاده کنیم که متکی به ثبات‌های caller-saved برای کنترل کننده های استثنا هست. در عوض ، به یک قرارداد فراخوانی نیاز داریم که همه ثبات‌ها را حفظ کند. قرارداد فراخوانی x86-interrupt چنین قرارداد فراخوانی است ، بنابراین تضمین می کند که تمام مقادیر ثبات‌ها در هنگام بازگشت تابع به مقادیر اصلی خود بازگردند.

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

🔗قاب پشته وقفه (The Interrupt Stack Frame)

در یک فراخوانی عادی تابع (با استفاده از دستورالعمل call) ، پردازنده قبل از پرش به تابع هدف ، آدرس بازگشت را در پشته ذخیره می‌کند. در هنگام بازگشت تابع (با استفاده از دستورالعمل ret) ، پردازنده این آدرس بازگشت را از پشته برمی‌دارد و به آن می پرد. بنابراین قاب پشته یک فراخوانی عادی تابع به این شکل است:

function stack frame

با این وجود، برای کنترل کننده های استثنا و وقفه، ذخیره آدرس برگشت در پشته کافی نیست، زیرا کنترل کننده های وقفه غالباً در context دیگری اجرا می شوند (نشانگر پشته ، پرچم های پردازنده و غیره). در عوض، پردازنده در صورت وقفه مراحل زیر را انجام می دهد:

  1. تراز کردن اشاره‌گر پشته: در هر دستورالعمل امکان رخ دادن وقفه وجود دارد، بنابراین اشاره‌گر پشته نیز می تواند هر مقداری داشته باشد. با این حال ، برخی از دستورالعمل های پردازنده (به عنوان مثال برخی از دستورالعمل های SSE) نیاز دارند که اشاره‌گر پشته در مرز 16 بایت تراز شود ، بنابراین پردازنده درست پس از وقفه چنین ترازی را انجام می دهد.
  2. تعویض پشته‌ها (در بعضی موارد): تعویض پشته زمانی اتفاق می افتد که سطح امتیاز پردازنده (CPU privilege level) تغییر می کند، به عنوان مثال وقتی یک استثنا در یک برنامه حالت کاربر رخ می دهد. همچنین می توان تعویض پشته را برای وقفه های خاص با استفاده از به اصطلاح Interrupt Stack Table پیکربندی کرد (در پست بعدی توضیح داده شده).
  3. پوش کردن اشاره‌گر قدیمی پشته: پردازنده مقادیر اشاره‌گر پشته (rsp) و سگمنت پشته (ss) را در زمان وقوع وقفه (قبل از تراز کردن) پوش می‌کند. این امکان را فراهم می کند تا هنگام بازگشت از کنترل کننده وقفه ، اشاره‌گر اصلی پشته بازیابی شود.
  4. پوش کردن و به‌روزرسانی ثبات RFLAGS: ثبات RFLAGS شامل بیت های مختلف کنترل و وضعیت است. در هنگام وقوع وقفه ، پردازنده برخی از بیت‌ها را تغییر می‌دهد و مقدار قدیمی را پوش می‌کند.
  5. پوش کردن اشاره‌گر دستورالعمل: قبل از پرش به تابع کنترل کننده وقفه ، پردازنده اشاره‌گر دستورالعمل (rip) و سگمنت کد (cs) را پوش می‌کند. این مشابه با پوش کردن آدرس برگشت یک تابع عادی است.
  6. ** پوش کردن کد خطا** (برای برخی استثناها): برای برخی از استثنا های خاص مانند خطاهای صفحه ، پردازنده یک کد خطا را پوش می‌کند که علت استثنا را توصیف می کند.
  7. فراخوانی کنترل کننده وقفه: پردازنده آدرس و توصیف کننده سگمنت تابع کنترل کننده وقفه را از قسمت مربوطه در IDT می خواند. سپس با بارگذاری مقادیر در ثبات های rip و cs این کنترل کننده را فراخوانی می کند.

بنابراین interrupt stack frame به این شکل است:

interrupt stack frame

در کرت x86_64 ، فریم پشته وقفه توسط ساختمان InterruptStackFrame نشان داده می شود. این ساختمان به عنوان &mut به کنترل کننده وقفه منتقل می شود و می تواند برای دریافت اطلاعات بیشتر در مورد علت استثنا استفاده شود. ساختمان بدون فیلد کد خطا است ، زیرا فقط برخی از استثناها کد خطا را پوش می‌کنند. این استثناها از نوع تابع جداگانه HandlerFuncWithErrCode استفاده می‌کنند ، که دارای یک آرگومان اضافی error_code است.

🔗پشت صحنه

قرارداد فراخوانی x86-interrupt یک انتزاع قدرتمند است که تقریباً تمام جزئیات پیچیده فرآیند مدیریت استثناها را پنهان می کند. با این حال ، گاهی اوقات مفید است که بدانیم پشت پرده چه اتفاقی می افتد. در اینجا یک مرور کوتاه از مواردی که قرارداد فراخوانی x86-interrupt انجام می‌دهد را می‌بینید:

  • دریافت آرگومان ها: بیشتر قرارداد های فراخوانی انتظار دارند که آرگومان ها در ثبات‌ها منتقل شوند. این برای کنترل کننده های استثنا امکان پذیر نیست ، زیرا ما نباید قبل از تهیه نسخه پشتیبان از مقادیر ثبات‌ها ، آنها را بازنویسی کنیم. در عوض، قرارداد فراخوانی x86-interrupt آگاه است که آرگومان ها از قبل در مکان خاصی بر روی پشته قرار دارند.
  • بازگشت با استفاده از iretq: از آنجا که قاب پشته وقفه با قاب پشته صدا زدن توابع معمولی کاملاً متفاوت است، نمی توانیم از طریق دستورالعمل ret از توابع کنترل کننده برگردیم. در عوض، باید از دستور iretq استفاده شود.
  • مدیریت کد خطا: کد خطا که برای برخی استثناها به پشته اضافه می شود ، کارها را بسیار پیچیده تر می کند. تراز بندی پشته را تغییر می دهد (به قسمت بعدی مراجعه کنید) و باید قبل از بازگشت، از پشته خارج شود. قرارداد فراخوانی x86-interrupt تمام پیچیدگی‌ها را برطرف می کند. با این حال، نمی داند کدام تابع کنترل کننده برای کدام استثنا استفاده می شود، بنابراین باید این اطلاعات را از تعداد آرگومان های تابع استخراج کند. این بدان معناست که برنامه نویس همچنان مسئول استفاده صحیح هر نوع تابع برای هر استثنا است. خوشبختانه نوع InterruptDescriptorTable که توسط کرت x86_64 تعریف شده است، استفاده از انواع تابع صحیح را تضمین می کند.
  • تراز کردن پشته: برخی دستورالعمل‌ها (به ویژه دستورالعمل های SSE) وجود دارند که به یک تراز پشته 16 بایتی نیاز دارند. پردازنده این تراز را هر زمان که یک استثنا اتفاق می افتد تضمین می کند ، اما برای برخی از استثناها بعداً هنگامی که یک کد خطا را به پشته اضافه می‌کند، دوباره آن را از بین می برد. قرارداد فراخوانی x86-interrupt با تنظیم مجدد پشته در این حالت این مشکل را برطرف می‌کند.

اگر به جزئیات بیشتر علاقه مندید: ما همچنین یک سری پست داریم که مدیریت استثنا با استفاده از توابع برهنه را توضیح می‌دهند. (در انتهای این پست).

🔗پیاده سازی

اکنون که تئوری را فهمیدیم ، وقت آن رسیده است که استثناهای پردازنده را در هسته خود کنترل کنیم. ما با ایجاد یک ماژول جدید وقفه‌ها در src/interrupts.rs شروع خواهیم کرد، که ابتدا یک تابع init_idt ایجاد می کند که یک InterruptDescriptorTable جدید ایجاد می کند:

// in src/lib.rs

pub mod interrupts;

// in src/interrupts.rs

use x86_64::structures::idt::InterruptDescriptorTable;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
}

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

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

برای موارد استفاده ما، نیازی به بازنویسی دستورالعمل نداریم. در عوض، فقط می‌خواهیم هنگام اجرای دستورالعمل بریک‌پوینت پیامی چاپ کنیم و سپس برنامه را ادامه دهیم. بنابراین بیایید یک تابع ساده breakpoint_handler ایجاد کنیم و آن را به IDT خود اضافه کنیم:

// in src/interrupts.rs

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
}

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: &mut InterruptStackFrame)
{
    println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

کنترل کننده ما فقط یک پیام را خارج می کند و قاب پشته وقفه را زیبا چاپ می کند.

هنگامی که می خواهیم آن را کامپایل کنیم ، خطای زیر رخ می دهد:

error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180)
  --> src/main.rs:53:1
   |
53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: &mut InterruptStackFrame) {
54 | |     println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
55 | | }
   | |_^
   |
   = help: add #![feature(abi_x86_interrupt)] to the crate attributes to enable

این خطا به این دلیل رخ می دهد که قرارداد فراخوانی x86-interrupt هنوز ناپایدار است. به هر حال برای استفاده از آن ، باید صریحاً آن را با اضافه کردن #![feature(abi_x86_interrupt)] در بالای lib.rs فعال کنیم.

🔗بارگیری IDT

برای اینکه پردازنده از جدول توصیف کننده وقفه جدید ما استفاده کند ، باید آن را با استفاده از دستورالعمل lidt بارگیری کنیم. ساختمان InterruptDescriptorTable از کرت x86_64 متد load را برای این کار فراهم می کند. بیایید سعی کنیم از آن استفاده کنیم:

// in src/interrupts.rs

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
    idt.load();
}

اکنون هنگامی که می خواهیم آن را کامپایل کنیم ، خطای زیر رخ می دهد:

error: `idt` does not live long enough
  --> src/interrupts/mod.rs:43:5
   |
43 |     idt.load();
   |     ^^^ does not live long enough
44 | }
   | - borrowed value only lives until here
   |
   = note: borrowed value must be valid for the static lifetime...

پس متد load انتظار دریافت یک static self'& را دارد، این مرجعی است که برای تمام مدت زمان اجرای برنامه معتبر است. دلیل این امر این است که پردازنده در هر وقفه به این جدول دسترسی پیدا می کند تا زمانی که IDT دیگری بارگیری کنیم. بنابراین استفاده از طول عمر کوتاه تر از static' می تواند منجر به باگ های استفاده-بعد-از-آزادسازی شود.

در واقع ، این دقیقاً همان چیزی است که در اینجا اتفاق می افتد. idt ما روی پشته ایجاد می شود ، بنابراین فقط در داخل تابع init معتبر است. پس از آن حافظه پشته برای توابع دیگر مورد استفاده مجدد قرار می گیرد ، بنابراین پردازنده حافظه پشته تصادفی را به عنوان IDT تفسیر می کند. خوشبختانه ، متد InterruptDescriptorTable::load این نیاز به طول عمر را در تعریف تابع خود اجباری می کند، بنابراین کامپایلر راست قادر است از این مشکل احتمالی در زمان کامپایل جلوگیری کند.

برای رفع این مشکل، باید idt را در مکانی ذخیره کنیم که طول عمر static' داشته باشد. برای رسیدن به این هدف می توانیم IDT را با استفاده از Box بر روی حافظه Heap ایجاد کنیم و سپس آن را به یک مرجع static' تبدیل کنیم، اما ما در حال نوشتن هسته سیستم عامل هستیم و بنابراین هنوز Heap نداریم.

به عنوان یک گزینه دیگر، می توانیم IDT را به صورت static ذخیره کنیم:

static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

pub fn init_idt() {
    IDT.breakpoint.set_handler_fn(breakpoint_handler);
    IDT.load();
}

با این وجود، یک مشکل وجود دارد: استاتیک‌ها تغییرناپذیر هستند، پس نمی توانیم ورودی بریک‌پوینت را از تابع init تغییر دهیم. می توانیم این مشکل را با استفاده از static mut حل کنیم:

static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

pub fn init_idt() {
    unsafe {
        IDT.breakpoint.set_handler_fn(breakpoint_handler);
        IDT.load();
    }
}

در این روش بدون خطا کامپایل می شود اما مشکلات دیگری به همراه دارد. static mut بسیار مستعد Data Race هستند، بنابراین در هر دسترسی به یک بلوک unsafe نیاز داریم.

🔗Lazy Statics به نجات ما می‌آیند

خوشبختانه ماکرو lazy_static وجود دارد. ماکرو به جای ارزیابی یک static در زمان کامپایل ، مقداردهی اولیه آن را هنگام اولین ارجاع به آن انجام می دهد. بنابراین، می توانیم تقریباً همه کاری را در بلوک مقداردهی اولیه انجام دهیم و حتی قادر به خواندن مقادیر زمان اجرا هستیم.

ما قبلاً کرت lazy_static را وارد کردیم وقتی یک انتزاع برای بافر متن VGA ایجاد کردیم. بنابراین می توانیم مستقیماً از ماکرو !lazy_static برای ایجاد IDT استاتیک استفاده کنیم:

// in src/interrupts.rs

use lazy_static::lazy_static;

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt
    };
}

pub fn init_idt() {
    IDT.load();
}

توجه داشته باشید که چگونه این راه حل به هیچ بلوک unsafe نیاز ندارد. ماکرو !lazy_static از unsafe در پشت صحنه استفاده می کند ، اما در یک رابط امن به ما داده می شود.

🔗اجرای آن

آخرین مرحله برای کارکرد استثناها در هسته ما فراخوانی تابع init_idt از main.rs است. به جای فراخوانی مستقیم آن، یک تابع عمومی init را در lib.rs معرفی می کنیم:

// in src/lib.rs

pub fn init() {
    interrupts::init_idt();
}

با استفاده از این تابع اکنون یک مکان اصلی برای روالهای اولیه داریم که می تواند بین توابع مختلف start_ در main.rs ، lib.rs و تست‌های یک‌پارچه به اشتراک گذاشته شود.

اکنون می توانیم تابع start_ در main.rs را به روز کنیم تا init را فراخوانی کرده و سپس یک استثنا بریک‌پوینت ایجاد کند:

// in src/main.rs

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");

    blog_os::init(); // new

    // invoke a breakpoint exception
    x86_64::instructions::interrupts::int3(); // new

    // as before
    #[cfg(test)]
    test_main();

    println!("It did not crash!");
    loop {}
}

اکنون هنگامی که آن را در QEMU اجرا می کنیم (با استفاده از cargo run) ، موارد زیر را مشاهده می کنیم:

QEMU printing EXCEPTION: BREAKPOINT and the interrupt stack frame

کار می کند! پردازنده با موفقیت تابع کنترل کننده بریک‌پوینت ما را فراخوانی می کند ، که پیام را چاپ می کند و سپس به تابع start_ برمی گردد ، جایی که پیام !It did not crash چاپ شده است.

می بینیم که قاب پشته وقفه، دستورالعمل و نشانگرهای پشته را در زمان وقوع استثنا به ما می گوید. این اطلاعات هنگام رفع اشکال استثناهای غیر منتظره بسیار مفید است.

🔗افزودن یک تست

بیایید یک تست ایجاد کنیم که از ادامه کار کد بالا اطمینان حاصل کند. ابتدا تابع start_ را به روز می کنیم تا init را نیز فراخوانی کند:

// in src/lib.rs

/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
    init();      // new
    test_main();
    loop {}
}

بخاطر داشته باشید، این تابع start_ هنگام اجرایcargo test --lib استفاده می شود، زیرا راست lib.rs را کاملاً مستقل ازmain.rs تست می‌کند. قبل از اجرای تست‌ها باید برای راه اندازی IDT در اینجا init فراخوانی شود.

اکنون می توانیم یک تست test_breakpoint_exception ایجاد کنیم:

// in src/interrupts.rs

#[test_case]
fn test_breakpoint_exception() {
    // invoke a breakpoint exception
    x86_64::instructions::interrupts::int3();
}

این تست تابع int3 را فراخوانی می کند تا یک استثنا بریک‌پوینت ایجاد کند. با بررسی اینکه اجرا پس از آن ادامه دارد ، تأیید می کنیم که کنترل کننده بریک‌پوینت ما به درستی کار می کند.

شما می توانید این تست جدید را با اجرای cargo test (همه تست‌ها) یا cargo test --lib (فقط تست های lib.rs و ماژول های آن) امتحان کنید. باید موارد زیر را در خروجی مشاهده کنید:

blog_os::interrupts::test_breakpoint_exception...	[ok]

🔗خیلی جادویی بود؟

قرارداد فراخوانی x86-interrupt و نوع InterruptDescriptorTable روند مدیریت استثناها را نسبتاً سر راست و بدون درد ساخته‌اند. اگر این برای شما بسیار جادویی بود و دوست دارید تمام جزئیات مهم مدیریت استثنا را بیاموزید، برای شما هم مطالبی داریم: مجموعه "مدیریت استثناها با توابع برهنه" ما، نحوه مدیریت استثنا‌ها بدون قرارداد فراخوانیx86-interrupt را نشان می دهد و همچنین نوع IDT خاص خود را ایجاد می کند. از نظر تاریخی، این پست‌ها مهمترین پست‌های مدیریت استثناها قبل از وجود قرارداد فراخوانی x86-interrupt و کرت x86_64 بودند. توجه داشته باشید که این پست‌ها بر اساس نسخه اول این وبلاگ هستند و ممکن است قدیمی باشند.

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

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



نظرات

Instead of authenticating the giscus application, you can also comment directly on the on GitHub. Just click the "X comments" link at the top — or the date of any comment — to go to the GitHub discussion.

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