استثناهای پردازنده
محتوای ترجمه شده: این یک ترجمه از جامعه کاربران برای پست CPU Exceptions است. ممکن است ناقص، منسوخ شده یا دارای خطا باشد. لطفا هر گونه مشکل را در این ایشو گزارش دهید!
ترجمه توسط @hamidrezakp و @MHBahrampour.
استثناهای پردازنده در موقعیت های مختلف دارای خطا رخ می دهد ، به عنوان مثال هنگام دسترسی به آدرس حافظه نامعتبر یا تقسیم بر صفر. برای واکنش به آنها ، باید یک جدول توصیف کننده وقفه تنظیم کنیم که توابع کنترل کننده را فراهم کند. در انتهای این پست ، هسته ما قادر به گرفتن استثناهای breakpoint و ادامه اجرای طبیعی پس از آن خواهد بود.
این بلاگ بصورت آزاد روی گیتهاب توسعه داده شده است. اگر مشکل یا سوالی دارید، لطفاً آنجا یک ایشو باز کنید. همچنین میتوانید در زیر این پست کامنت بگذارید. سورس کد کامل این پست را میتوانید در بِرَنچ post-05
پیدا کنید.
فهرست مطالب
🔗بررسی اجمالی
یک استثنا نشان می دهد که مشکلی در دستورالعمل فعلی وجود دارد. به عنوان مثال ، اگر دستورالعمل فعلی بخواهد تقسیم بر 0 کند ، پردازنده یک استثنا صادر می کند. وقتی یک استثنا اتفاق می افتد ، پردازنده کار فعلی خود را رها کرده و بسته به نوع استثنا ، بلافاصله یک تابع خاص کنترل کننده استثنا را فراخوانی می کند.
در x86 حدود 20 نوع مختلف استثنا پردازنده وجود دارد. مهمترین آنها در زیر آمده اند:
- خطای صفحه: خطای صفحه در دسترسی غیرقانونی به حافظه رخ می دهد. به عنوان مثال ، اگر دستورالعمل فعلی بخواهد از یک صفحه نگاشت نشده بخواند یا بخواهد در یک صفحه فقط خواندنی بنویسد.
- کد نامعتبر: این استثنا وقتی رخ می دهد که دستورالعمل فعلی نامعتبر است ، به عنوان مثال وقتی می خواهیم از دستورالعمل های SSE جدیدتر بر روی یک پردازنده قدیمی استفاده کنیم که آنها را پشتیبانی نمی کند.
- خطای محافظت عمومی: این استثنا دارای بیشترین دامنه علل است. این مورد در انواع مختلف نقض دسترسی مانند تلاش برای اجرای یک دستورالعمل ممتاز در کد سطح کاربر یا نوشتن فیلدهای رزرو شده در ثبات های پیکربندی رخ می دهد.
- خطای دوگانه: هنگامی که یک استثنا رخ می دهد ، پردازنده سعی می کند تابع کنترل کننده مربوطه را اجرا کند. اگر یک استثنا دیگر رخ دهد هنگام فراخوانی تابع کنترل کننده استثنا ، پردازنده یک استثنای خطای دوگانه ایجاد می کند. این استثنا همچنین زمانی اتفاق می افتد که هیچ تابع کنترل کننده ای برای یک استثنا ثبت نشده باشد.
- خطای سهگانه: اگر در حالی که پردازنده سعی می کند تابع کنترل کننده خطای دوگانه را فراخوانی کند استثنایی رخ دهد ، این یک خطای سهگانه است. ما نمی توانیم یک خطای سه گانه را بگیریم یا آن را کنترل کنیم. بیشتر پردازنده ها ریست کردن خود و راه اندازی مجدد سیستم عامل واکنش نشان می دهند.
برای مشاهده لیست کامل استثناها ، ویکی OSDev را بررسی کنید.
🔗جدول توصیف کننده وقفه
برای گرفتن و رسیدگی به استثناها ، باید اصطلاحاً جدول توصیفگر وقفه (IDT) را تنظیم کنیم. در این جدول می توانیم برای هر استثنا پردازنده یک عملکرد تابع کننده مشخص کنیم. سخت افزار به طور مستقیم از این جدول استفاده می کند ، بنابراین باید از یک قالب از پیش تعریف شده پیروی کنیم. هر ورودی جدول باید ساختار 16 بایتی زیر را داشته باشد:
Type | Name | Description |
---|---|---|
u16 | Function Pointer [0:15] | The lower bits of the pointer to the handler function. |
u16 | GDT selector | Selector of a code segment in the global descriptor table. |
u16 | Options | (see below) |
u16 | Function Pointer [16:31] | The middle bits of the pointer to the handler function. |
u32 | Function Pointer [32:63] | The remaining bits of the pointer to the handler function. |
u32 | Reserved |
قسمت گزینه ها (Options) دارای قالب زیر است:
Bits | Name | Description |
---|---|---|
0-2 | Interrupt Stack Table Index | 0: Don’t switch stacks, 1-7: Switch to the n-th stack in the Interrupt Stack Table when this handler is called. |
3-7 | Reserved | |
8 | 0: Interrupt Gate, 1: Trap Gate | If this bit is 0, interrupts are disabled when this handler is called. |
9-11 | must be one | |
12 | must be zero | |
13‑14 | Descriptor Privilege Level (DPL) | The minimal privilege level required for calling this handler. |
15 | Present |
هر استثنا دارای یک اندیس از پیش تعریف شده در IDT است. به عنوان مثال استثنا کد نامعتبر دارای اندیس 6 و استثنا خطای صفحه دارای اندیس 14 است. بنابراین ، سخت افزار می تواند به طور خودکار عنصر مربوطه را برای هر استثنا بارگذاری کند. جدول استثناها در ویکی OSDev ، اندیس های IDT کلیه استثناها را در ستون “Vector nr.” نشان داده است.
هنگامی که یک استثنا رخ می دهد ، پردازنده تقریباً موارد زیر را انجام می دهد:
- برخی از ثباتها را به پشته وارد میکند، از جمله اشاره گر دستورالعمل و ثبات RFLAGS. (بعداً در این پست از این مقادیر استفاده خواهیم کرد.)
- عنصر مربوط به آن (استثنا) را از جدول توصیف کننده وقفه (IDT) میخواند. به عنوان مثال ، پردازنده هنگام رخ دادن خطای صفحه ، عنصر چهاردهم را می خواند.
- وجود عنصر را بررسی میکند. اگر اینگونه نباشد یک خطای دوگانه ایجاد میکند.
- اگر عنصر یک گیت وقفه است (بیت 40 تنظیم نشده است) وقفه های سخت افزاری را غیرفعال میکند.
- انتخابگر مشخص شده GDT را در سگمنت CS بارگذاری میکند.
- به تابع کنترل کننده مشخص شده میرود.
در حال حاضر نگران مراحل 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(_: 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 registers | scratch registers |
---|---|
rbp , rbx , rsp , r12 , r13 , r14 , r15 | rax , rcx , rdx , rsi , rdi , r8 , r9 , r10 , r11 |
callee-saved | caller-saved |
کامپایلر این قوانین را می داند ، بنابراین کد را متناسب با آن تولید می کند. به عنوان مثال ، بیشتر توابع با push rbp
شروع می شوند که پشتیبان گیری ازrbp
روی پشته است (زیرا این یک ثبات caller-saved).
🔗حفظ کلیه ثباتها
برخلاف فراخوانی تابع ، استثناها می توانند در هر دستورالعملی رخ دهند. در بیشتر موارد ، ما حتی در زمان کامپایل نمی دانیم که کد تولید شده استثنا ایجاد می کند یا نه. به عنوان مثال ، کامپایلر نمی تواند بداند که آیا یک دستورالعمل باعث سرریز شدن پشته یا خطای صفحه می شود.
از آنجا که نمی دانیم چه زمانی استثنا رخ میدهد ، نمی توانیم قبل از آن از هیچ ثباتی پشتیبان گیری کنیم. این بدان معناست که ما نمی توانیم از قرارداد فراخوانیای استفاده کنیم که متکی به ثباتهای caller-saved برای کنترل کننده های استثنا هست. در عوض ، به یک قرارداد فراخوانی نیاز داریم که همه ثباتها را حفظ کند. قرارداد فراخوانی x86-interrupt
چنین قرارداد فراخوانی است ، بنابراین تضمین می کند که تمام مقادیر ثباتها در هنگام بازگشت تابع به مقادیر اصلی خود بازگردند.
توجه داشته باشید که این بدان معنا نیست که همه ثباتها در ورود به تابع در پشته ذخیره می شوند. در عوض ، کامپایلر فقط از ثباتهایی که توسط تابع تغییر میکنند ، پشتیبان تهیه می کند. به این ترتیب ، کد بسیار کارآمدی برای توابع کوتاه که فقط از چند ثبات استفاده می کنند ، تولید می شود.
🔗قاب پشته وقفه (The Interrupt Stack Frame)
در یک فراخوانی عادی تابع (با استفاده از دستورالعمل call
) ، پردازنده قبل از پرش به تابع هدف ، آدرس بازگشت را در پشته ذخیره میکند. در هنگام بازگشت تابع (با استفاده از دستورالعمل ret
) ، پردازنده این آدرس بازگشت را از پشته برمیدارد و به آن می پرد. بنابراین قاب پشته یک فراخوانی عادی تابع به این شکل است:
با این وجود، برای کنترل کننده های استثنا و وقفه، ذخیره آدرس برگشت در پشته کافی نیست، زیرا کنترل کننده های وقفه غالباً در context دیگری اجرا می شوند (نشانگر پشته ، پرچم های پردازنده و غیره). در عوض، پردازنده در صورت وقفه مراحل زیر را انجام می دهد:
- تراز کردن اشارهگر پشته: در هر دستورالعمل امکان رخ دادن وقفه وجود دارد، بنابراین اشارهگر پشته نیز می تواند هر مقداری داشته باشد. با این حال ، برخی از دستورالعمل های پردازنده (به عنوان مثال برخی از دستورالعمل های SSE) نیاز دارند که اشارهگر پشته در مرز 16 بایت تراز شود ، بنابراین پردازنده درست پس از وقفه چنین ترازی را انجام می دهد.
- تعویض پشتهها (در بعضی موارد): تعویض پشته زمانی اتفاق می افتد که سطح امتیاز پردازنده (CPU privilege level) تغییر می کند، به عنوان مثال وقتی یک استثنا در یک برنامه حالت کاربر رخ می دهد. همچنین می توان تعویض پشته را برای وقفه های خاص با استفاده از به اصطلاح Interrupt Stack Table پیکربندی کرد (در پست بعدی توضیح داده شده).
- پوش کردن اشارهگر قدیمی پشته: پردازنده مقادیر اشارهگر پشته (
rsp
) و سگمنت پشته (ss
) را در زمان وقوع وقفه (قبل از تراز کردن) پوش میکند. این امکان را فراهم می کند تا هنگام بازگشت از کنترل کننده وقفه ، اشارهگر اصلی پشته بازیابی شود. - پوش کردن و بهروزرسانی ثبات
RFLAGS
: ثباتRFLAGS
شامل بیت های مختلف کنترل و وضعیت است. در هنگام وقوع وقفه ، پردازنده برخی از بیتها را تغییر میدهد و مقدار قدیمی را پوش میکند. - پوش کردن اشارهگر دستورالعمل: قبل از پرش به تابع کنترل کننده وقفه ، پردازنده اشارهگر دستورالعمل (
rip
) و سگمنت کد (cs
) را پوش میکند. این مشابه با پوش کردن آدرس برگشت یک تابع عادی است. - ** پوش کردن کد خطا** (برای برخی استثناها): برای برخی از استثنا های خاص مانند خطاهای صفحه ، پردازنده یک کد خطا را پوش میکند که علت استثنا را توصیف می کند.
- فراخوانی کنترل کننده وقفه: پردازنده آدرس و توصیف کننده سگمنت تابع کنترل کننده وقفه را از قسمت مربوطه در IDT می خواند. سپس با بارگذاری مقادیر در ثبات های
rip
وcs
این کنترل کننده را فراخوانی می کند.
بنابراین 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: 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: 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
) ، موارد زیر را مشاهده می کنیم:
کار می کند! پردازنده با موفقیت تابع کنترل کننده بریکپوینت ما را فراخوانی می کند ، که پیام را چاپ می کند و سپس به تابع 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
بودند. توجه داشته باشید که این پستها بر اساس نسخه اول این وبلاگ هستند و ممکن است قدیمی باشند.
🔗مرحله بعدی چیست؟
ما اولین استثنای خود را با موفقیت گرفتیم و از آن بازگشتیم! گام بعدی اطمینان از این است که همه استثناها را می گیریم ، زیرا یک استثنا گرفته نشده باعث خطای سهگانه می شود که منجر به شروع مجدد سیستم می شود. پست بعدی توضیح می دهد که چگونه می توان با گرفتن صحیح خطای دوگانه از این امر جلوگیری کرد.
نظرات
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.
لطفا نظرات خود را در صورت امکان به انگلیسی بنویسید.