xv6 — نظام يونكس تعليمي › المقاطعات ومُشغّلات الأجهزة 7 / 15 السابق التالي

المقاطعات ومُشغّلات الأجهزة

6. المقاطعات ومُشغّلات الأجهزة

المُشغّل (driver) هو الشيفرة الموجودة في نظام التشغيل والتي تتولّى إدارة جهازٍ معيّن: فهو يضبط عتاد الجهاز، ويأمر الجهاز بأداء عمليات، ويتعامل مع المقاطعات الناتجة، ويتفاعل مع العمليات التي تستخدم الجهاز. وقد تكون شيفرة المُشغّل دقيقةً لأنّ المُشغّل ينفّذ بالتزامنٍ مع الجهاز، وكثيرًا ما ينفّذ أيضًا بالتزامنٍ مع العمليات التي تستخدم الجهاز. فضلًا عن ذلك، يجب على المُشغّل أن يفهم واجهة الجهاز العتاديّة، والتي قد تكون معقّدةً وموثّقةً سيّئًا.

يمكن عادةً ضبط الأجهزة التي تحتاج إلى انتباه نظام التشغيل لتوليد مقاطعات، وهي نوعٌ من المصائد. وتتعرّف شيفرة معالجة المصائد في النواة على اللحظة التي يرفع فيها جهازٌ مقاطعةً، وتنادي معالج مقاطعة المُشغّل؛ وفي xv6، يحدث هذا الإرسال في devintr.

ينفّذ كثيرٌ من مُشغّلات الأجهزة شيفرةً في سياقين: نصفٌ سفليّ (bottom half) يعمل في خيط نواة عمليّة، ونصفٌ علويّ (top half) ينفّذ في وقت المقاطعة. ويُنادى النصف السفليّ عبر نداءات نظامٍ مثل read وwrite تريد من الجهاز أداء إدخال/إخراج. وقد تطلب هذه الشيفرة من العتاد بدء عملية (كت طلبها من القرص بقراءة قطعة)؛ ثمّ تنتظر الشيفرة حتّى تكتمل العمليّة. وفي النهاية يُكمل الجهاز العمليّة ويرفع مقاطعةً. ويقوم معالج مقاطعة المُشغّل، بصفته النصف العلويّ، بتحديد العمليّة التي اكتملت، وإيقاظ عمليّةٍ منتظرةٍ إن كان ذلك مناسبًا، وإبلاغ العتاد ببدء العمل على العمليّة التالية، إن وُجدت.

6.1 الشيفرة: إدخال وحدة التحكّم

يبيّن مُشغّل وحدة التحكّم (console.c) بنية المُشغّلات بيانًا بسيطًا. ويقبل مُشغّل وحدة التحكّم المحارف التي يكتبها إنسان، عبر عتاد المنفذ التسلسلي UART المتّصل بـ RISC-V. ويُجمّع مُشغّل وحدة التحكّم سطرًا من الإدخال في كلّ مرّة، ويعالج محارف الإدخال الخاصّة مثل مسافة الرجوع للخلف وcontrol-u. وتستخدم عمليّات المستخدم، كالصدفة، نداء النظام read لجلب أسطر الإدخال من وحدة التحكّم. وحين تكتب مدخلاتك إلى xv6 في QEMU، تُسلَّم ضربات مفاتيحك إلى xv6 عن طريق عتاد UART المحاكى في QEMU.

عتاد UART الذي يتحدّث إليه المُشغّل هو شريحة 16550 (Michael and Durich 1987) يحاكيها QEMU. وعلى حاسوبٍ حقيقيّ، كانت شريحة 16550 ستُدير وصلةً تسلسليّةً من نوع RS232 تتّصل بطرفيّةٍ أو بحاسوبٍ آخر. وعند تشغيل QEMU، فإنّها متّصلةٌ بلوحة مفاتيحك وشاشتك.

يظهر عتاد UART للبرمجيّات في صورة مجموعةٍ من سجلّات تحكّمٍ مُقيَّدةٍ بالذاكرة. أي إنّ هناك بعض العناوين الفيزيائيّة المتّصلة بالعتاد، بحيث تتفاعل عمليّتا التحميل والتخزين مع عتاد الجهاز لا مع الذاكرة الرئيسة. وتبدأ العناوين المُقيَّدة بالذاكرة الخاصّة بـ UART من 0x10000000، أو UART0. وهناك حفنةٌ من سجلّات تحكّم العتاد، كلٌّ منها بعرض بايتٍ واحد. وإزاحاتها عن UART0 معرَّفةٌ في (uart.c:25). فمثلًا، يحتوي السجلّ LSR على بِتّاتٍ تدلّ على ما إذا كانت محارف الإدخال تنتظر قراءتها من قِبل المُشغّل. وهذه المحارف (إن وُجدت) متاحةٌ للقراءة من السجلّ RHR. وفي كلّ مرّة تُقرأ فيها إحداها، يحذفها العتاد من طابور FIFO داخليّ للمحارف المنتظرة، ويمسح بِتّ «الجاهزيّة» في LSR عندما يخلو الطابور. وللإرسال، يكتب المُشغّل بايتًا في السجلّ THR، ممّا يجعل العتاد يُلحق البايت بطابورٍ من البايتات سيرسله على الوصلة التسلسليّة RS232. وعتاديّ الإرسال والاستقبال مستقلّان كلّ الاستقلال عن بعضهما.

ينادي main في xv6 الدالّة consoleinit لتهيئة عتاد UART. وهذه الشيفرة تضبط العتاد ليولّد مقاطعة استقبالٍ عندما يستقبل كلّ بايتٍ من الإدخال، ومقاطعة اكتمال الإرسال في كلّ مرّة ينتهي فيها من إرسال بايتٍ من المخرجات (uart.c:49).

تقرأ صدفة xv6 من وحدة التحكّم عن طريق واصف ملفٍّ تفتحه init.c. وتمرّ نداءات النظام read عبر النواة لتصل إلى consoleread. فتنتظر consoleread وصول الإدخال (عبر المقاطعات) وتخزينه في cons.buf، ثمّ تنسخ الإدخال إلى فضاء المستخدم، وتعود (بعد وصول سطرٍ كاملٍ) إلى عمليّة المستخدم. فإن لم يكن المستخدم قد كتب سطرًا كاملًا بعد، فإنّ أيّ عمليّاتٍ للقراءة ستنتظر في نداء sleep (console.c:106) (يشرح الفصل 9 تفاصيل sleep).

حين يكتب المستخدم محرفًا، يطلب عتاد UART من RISC-V رفع مقاطعة، فتنشط معالجة مصائد xv6. وتنادي معالج المصائد الدالّة devintr، التي تنظر في سجلّ scause الخاصّ بالمعالج لتكتشف أنّ المقاطعة صادرةٌ عن جهازٍ خارجيّ. ثمّ تسأل وحدة عتاديّة تُسمّى PLIC (Waterman et al. 2024) لتخبرها أيّ جهازٍ قام بالمقاطعة (trap.c:196). فإن كان هو عتاد UART، نادت devintr الدالّة uartintr.

تقرأ uartintr أيّ محارف إدخالٍ منتظرةً من عتاد UART وتسلمها إلى consoleintr؛ ولا تنتظر المحارف، إذ إنّ الإدخال المستقبليّ سيرفع مقاطعةً جديدة. ومهمّة consoleintr هي تجميع محارف الإدخال في cons.buf حتّى يصل سطرٌ كامل. وتتعامل consoleintr مع مسافة الرجوع للخلف وبعض المحارف الأخرى تعاملًا خاصًّا. وعندما يصل محرف السطر الجديد، تُيقظ consoleintr نداء consoleread منتظرًا (إن وُجد).

وبعد إيقاظها، ستُلاحظ consoleread سطرًا كاملًا في cons.buf، وتنسخه إلى فضاء المستخدم، وتعود (عبر آليّة نداء النظام) إلى فضاء المستخدم.

ومن الأنماط الجديرة بالملاحظة فصل نشاط الجهاز عن نشاط العمليّة بواسطة التخزين المؤقّت والمقاطعات. فيستطيع مُشغّل وحدة التحكّم معالجة الإدخال حتى حين لا تكون هناك عمليّةٌ منتظرةٌ لقراءته؛ وسترى قراءةٌ لاحقة الإدخال. ويمكن لهذه المفاصلة أن تزيد الأداء بالسماح للعمليّات بالتنفيذ بالتزامنٍ مع إدخال/إخراج الجهاز، وهو مهمٌّ بصفةٍ خاصّةٍ عندما يكون الجهاز بطيئًا (كما هو الحال مع عتاد UART) أو عندما يحتاج إلى انتباهٍ فوريّ (كما هو الحال مع طباعة صدى المحارف المكتوبة). وتُسمّى هذه الفكرة أحيانًا تزامن الإدخال/الإخراج.

6.2 الشيفرة: إخراج وحدة التحكّم

يصل نداء نظامٍ من نوع write على واصف ملفٍّ متّصلٍ بوحدة التحكّم إلى consolewrite، الذي ينسخ دفعاتٍ من البايتات من فضاء المستخدم ويسلّم كلّ دفعةٍ إلى uartwrite. وقبل كتابة كلّ بايتٍ في سجلّ THR الخاصّ بـ UART، يجب على uartwrite أن ينتظر جهوزيّة العتاد لقبول مزيدٍ من المخرجات. ولأنّ العتاد بطيءٌ نسبيًّا، فإنّ uartwrite ينتظر باستخدام sleep (الذي يتخلّى عن المعالج) بدلًا من حلقة انشغالٍ. وحين ينتهي العتاد من إرسال أحدث بايت، فإنّه يرفع مقاطعةً. ويتعاون روتين المقاطعة uartintr مع uartwrite: فقبل إرسال كلّ بايتٍ، يقرأ uartwrite سجلّ LSR الخاصّ بالعتاد ليرى ما إذا كان العتاد يستطيع قبول بايتٍ آخر، وينادي sleep إن لم يكن يستطيع؛ وينادي روتين المقاطعة wakeup عندما يُشير العتاد إلى جهوزيّته لمزيدٍ من المخرجات، ممّا يجعل uartwrite يستيقظ، ويُعيد فحص سجلّ LSR، ويرسل البايت التالي.

6.3 التزامن في المُشغّلات

ربّما لاحظت نداءاتٍ لـacquire في consoleread وفي consoleintr. وهذه النداءات تحوز قفلًا يحمي بنى بيانات مُشغّل وحدة التحكّم من الوصول المتزامن. وهناك ثلاثة مخاطر تزامنٍ هنا: قد ينادي عمليّتان على معالجين مختلفين consoleread في الوقت نفسه؛ وقد يطلب العتاد من معالجٍ تسليم مقاطعة وحدة تحكّم (أي UART في الحقيقة) بينما ذلك المعالج ينفّذ داخل consoleread؛ وقد يسلّم العتاد مقاطعة وحدة تحكّم على معالجٍ مختلف بينما consoleread قيد التنفيذ. ويشرح الفصل 7 كيف تُستخدم الأقفال لضمان ألّا تؤدّي هذه المخاطر إلى نتائج خاطئة.

وهناك طريقةٌ أخرى تتطلّب فيها التزامنيّة عنايةً في المُشغّلات، وهي أنّ عمليّةً ما قد تنتظر إدخالاً من جهاز، لكنّ المقاطعة المُشيرة إلى وصول الإدخال قد تأتي بينما عمليّةٌ مختلفة (أو لا عمليّةً على الإطلاق) تعمل. لذا لا يُسمح لمعالجات المقاطعة بأن تفكّر في العمليّة أو الشيفرة التي قاطعتها. فمثلًا، لا يستطيع معالج مقاطعةٍ مناداة copyout بسلامةٍ باستخدام جدول صفحات العمليّة الحاليّة. وغالبًا ما يؤدّي معالجو المقاطعة عملًا قليلًا نسبيًّا (كت نسخ البيانات إلى مخزن مؤقّت فقط)، ويُيقظون شيفرة النصف السفليّ لتؤدّي الباقي.

6.4 مقاطعات المؤقّت

يستخدم xv6 مقاطعات المؤقّت للمحافظة على تصوّره للوقت الحاليّ وللتبديل بين العمليّات المقيّدة بالحساب. وتأتي مقاطعات المؤقّت من عتاد الساعة المتّصل بكلّ معالج RISC-V. ويُبرمج xv6 عتاد الساعة الخاصّ بكلّ معالجٍ ليقطع المعالج دوريًّا.

تضبط الشيفرة الموجودة في start.c بعض بِتّات التحكّم التي تسمح بالوصول في وضع المشرف إلى سجلّات تحكّم المؤقّت، ثمّ تطلب أوّل مقاطعة مؤقّت. ويحتوي سجلّ التحكّم time على عدّادٍ تزيده العتاد بمعدّل ثابت؛ وهذا يمثّل تصوّرًا للوقت الحاليّ. ويحتوي سجلّ التحكّم stimecmp على وقتٍ يرفع فيه المعالج مقاطعةَ مؤقّتٍ؛ وضبط stimecmp على القيمة الحاليّة للسجلّ time زائدًا x سيجدول مقاطعةً بعد x وحدة زمنيّة في المستقبل. وفي محاكاة qemu لـ RISC-V، تُساوي 1000000 وحدة زمنيّة تقريبًا عُشر الثانية.

تصل مقاطعات المؤقّت عبر usertrap أو kerneltrap وdevintr، مثل مقاطعات الأجهزة الأخرى. وتصل مقاطعات المؤقّت مع بِتّات scause السفلى مضبوطةً على خمسة؛ ويكتشف devintr في trap.c هذا الموقف وينادي clockintr. وتزيد الدالّة الأخيرة العدّاد ticks، وهو ما يسمح للنواة بتتبّع مرور الوقت. ويحدث هذا الازدياد على معالجٍ واحدٍ فقط، حتّى لا يمرّ الوقت أسرع في حال وجود معالجات متعدّدة. وتُيقظ clockintr أيّ عمليّاتٍ تنتظر في نداء النظام pause، وتجدول مقاطعة المؤقّت التالية بكتابة stimecmp.

تُعيد devintr القيمة 2 بالنسبة لمقاطعة المؤقّت للإشارة إلى kerneltrap أو usertrap بأنّ عليها مناداة yield حتّى تُتاح المعالجات للعمليّات القابلة للتشغيل.

وحقيقة أنّ شيفرة النواة يمكن أن تقاطعها مقاطعةُ مؤقّتٍ تُجبر على تبديل السياق عبر yield هي جزءٌ من السبب الذي يجعل الشيفرة الأولى في usertrap حريصةً على حفظ حالةٍ مثل sepc قبل تفعيل المقاطعات. وهذه تبديلات السياق تعني أيضًا أنّ شيفرة النواة يجب أن تُكتب مع العلم بأنّها قد تنتقل من معالجٍ إلى آخر دون إنذار.

6.5 عالم الواقع

يسمح xv6، ككثيرٍ من أنظمة التشغيل، بالمقاطعات وحتى بتبديلات السياق (عبر yield) أثناء التنفيذ في النواة. والسبب في ذلك هو المحافظة على أزمنة استجابةٍ سريعة أثناء نداءات النظام المعقّدة التي تعمل مدّةً طويلة. غير أنّ السماح بالمقاطعات في النواة، كما أُشير سابقًا، هو مصدر بعض التعقيد؛ ونتيجةً لذلك، لا تسمح بضعة أنظمة تشغيل بالمقاطعات إلّا أثناء تنفيذ شيفرة المستخدم.

إنّ دعم جميع أجهزة الحاسوب النمطيّ دعمًا كاملًا عملٌ كثير، وذلك لأنّ الأجهزة كثيرة، ولأنّ للأجهزة ميزاتٍ كثيرة، ولأنّ البروتوكول بين الجهاز والمُشغّل قد يكون معقّدًا وموثّقًا سيّئًا. وفي كثيرٍ من أنظمة التشغيل، تشكّل المُشغّلات شيفرةً أكثر من نواة النظام الأساسية.

يسترجع مُشغّل UART البيانات بايتًا بايتًا بقراءة سجلّات تحكّم العتاد؛ ويُسمّى هذا النمط الإدخال/الإخراج المبرمَج، إذ إنّ البرمجيّةَ هي التي تقود حركة البيانات. والإدخال/الإخراج المبرمَج بسيط، لكنّه بطيءٌ جدًّا بحيث لا يُستخدم بمعدّلات بياناتٍ عالية. أمّا الأجهزة التي تحتاج إلى نقل كثيرٍ من البيانات بسرعةٍ عالية فتستخدم عادةً الوصول المباشر إلى الذاكرة (DMA). فيكتب عتاد DMA البيانات الواردة إلى الذاكرة الرئيسة مباشرةً، ويقرأ البيانات الصادرة منها. وتستخدم أجهزة القرص والشبكة الحديثة DMA. وكان على مُشغّل جهاز DMA أن يهيّئ البيانات في الذاكرة الرئيسة، ثمّ يستخدم كتابةً واحدةً إلى سجلّ تحكّمٍ لإخبار الجهاز بمعالجة البيانات المهيّأة.

يكون للمقاطعات معنىً عندما يحتاج جهازٌ إلى انتباهٍ في أوقاتٍ غير متوقّعة، وليس في غير ذلك كثيرًا. لكنّ للمقاطعات تكلفةً عاليةً على المعالج. لذا تستخدم الأجهزة عالية السرعة، كمُتحكّمات الشبكة والقرص، حِيَلًا تقلّل الحاجة إلى المقاطعات. وإحدى الحِيَل هي رفع مقاطعةٍ واحدةٍ لدفعةٍ كاملةٍ من الطلبات الواردة أو الصادرة. وحيلةٌ أخرى هي أن يعطّل المُشغّل المقاطعات كلّيّةً، ويفحص الجهاز دوريًّا ليرى ما إذا كان بحاجةٍ إلى انتباهٍ. وتُسمّى هذه التقنيّة الاستقصاء. ويُعقل الاستقصاء إذا أدّى الجهاز عمليّاتٍ بمعدّلٍ عالٍ، لكنّه يُهدر وقت المعالج إذا كان الجهاز خاملًا في معظم الأحيان. وبعض المُشغّلات تبدّل ديناميكيًّا بين الاستقصاء والمقاطعات اعتمادًا على حمل الجهاز الحاليّ.

ينسخ مُشغّل UART البيانات الواردة أوّلًا إلى مخزنٍ مؤقّتٍ في النواة، ثمّ إلى فضاء المستخدم. وهذا يُعقل بمعدّلات بياناتٍ منخفضة، لكنّ مثل هذا النسخ المزدوج يمكن أن يُخفّض الأداء تخفيضًا ملحوظًا بالنسبة للأجهزة التي تولّد البيانات أو تستهلكها بسرعةٍ فائقة. وتستطيع بعض أنظمة التشغيل نقل البيانات بين مخازن فضاء المستخدم والعتاد مباشرةً، وغالبًا ما يكون ذلك باستخدام DMA.

كما ذُكر في الفصل 1، تظهر وحدة التحكّم للتطبيقات بمظهر ملفٍّ عادي، وتقرأ التطبيقات الإدخال وتكتب المخرجات باستخدام نداءَي النظام read وwrite. وقد ترغب التطبيقات في التحكّم بجوانبَ من جهازٍ لا يمكن التعبير عنها عبر نداءات نظام الملفّات المعياريّة (كتفعيل التخزين المؤقّت للأسطر في مُشغّل وحدة التحكّم أو تعطيله مثلًا). وتوفّر أنظمة تشغيل Unix نداء نظامٍ من نوع ioctl لمثل هذه الحالات.

تتطلّب بعض استخدامات الحواسيب استجاباتٍ «في الزمن الحقيقيّ» للأحداث الخارجيّة: أي استجاباتٌ مضمونة الحدوث في زمنٍ محدود. فمثلًا، في أنظمة السلامة الحرجة، يمكن أن يؤدّي تفويت موعدٍ نهائيّ إلى كوارث. أمّا xv6 فغير مناسبٍ للأوضاع الزمنيّة الحقيقيّة. ومن بين أسباب أخرى، لا يأخذ مُجدول xv6 المواعيد النهائية الزمنيّة الحقيقيّة في الحسبان عندما يقرّر أيّ عمليّةٍ تُشغَّل تاليًا، ولـxv6 في نواته مسارات شيفرةٍ طويلة المقاطعات معطّلةٌ خلالها، فقد لا يستجيب للمقاطعات بسرعة. ويجب على نظام التشغيل الزمنيّ الحقيقيّ ألّا يُصلح هذه المشكلات فحسب، بل أيضًا أن يكون مبنيًّا بطريقةٍ تسمح بتحليل أزمنة الاستجابة في أسوأ الحالات.

6.6 تمارين

  1. عدّل uart.c بحيث لا يستخدم المقاطعات على الإطلاق. وقد تحتاج إلى تعديل console.c أيضًا.
  2. أضف مُشغّلًا لبطاقة Ethernet.