בפרק 1.6 למדנו על interrupts במצב 16 ביט - ראינו את טבלת הIVT, את הפקודה INT, ואיך הBIOS וDOS מספקים שירותים דרך interrupts.
בפרק 2 ראינו בקצרה את הIDT - טבלת הinterrupts של Protected Mode, ואיך היא מחליפה את הIVT הישנה.
עכשיו הגיע הזמן להבין לעומק איך הקרנל של לינוקס מטפל בinterrupts וב-exceptions על ארכיטקטורת x86-64 המודרנית.
פסיקות ו-exceptions - interrupts and exceptions¶
מה זה בכלל interrupts?¶
הinterrupt (interrupt) היא אירוע שגורם למעבד להפסיק את מה שהוא עושה עכשיו ולקפוץ לפונקציית טיפול (handler) שהוגדרה מראש.
זוכרים את הרעיון מפרק 1.6? אותו רעיון בדיוק - רק שעכשיו אנחנו ב-64 ביט, בProtected Mode (ליתר דיוק - ב-Long Mode), עם קרנל שלם שמנהל את כל התהליך.
יש שני סוגים עיקריים של interrupts:
פסיקות חומרה - hardware interrupts (IRQs)¶
אותות שמגיעים מהתקנים חיצוניים:
- מקלדת - לחצתם על מקש? המקלדת שולחת IRQ
- דיסק - הדיסק סיים לקרוא נתונים? הוא שולח IRQ
- כרטיס רשת - הגיעה חבילת רשת? כרטיס הרשת שולח IRQ
- טיימר - הטיימר של המערכת מתקתק ושולח IRQ בקצב קבוע (זה מה שמאפשר למתזמן לעבוד - זוכרים מפרק 6.3?)
חריגות - exceptions¶
נגרמות על ידי המעבד עצמו, כתוצאה ממשהו שקרה בזמן ריצת הקוד:
- שגיאת דף - page fault (#PF) - גישה לכתובת שלא ממופה (זוכרים מפרק 6.4?)
- חלוקה באפס - divide error (#DE) - ניסיון לחלק באפס
- הגנה כללית - general protection fault (#GP) - ניסיון לבצע פעולה אסורה
- נקודת עצירה - breakpoint (#BP) - פקודת int3 (ככה debuggers עובדים!)
- קוד לא חוקי - invalid opcode (#UD) - המעבד נתקל בפקודה שהוא לא מכיר
טבלת הIDT - Interrupt Descriptor Table¶
הIDT היא מערך של 256 כניסות (entries), כאשר כל כניסה מצביעה על פונקציית טיפול (handler) בקרנל.
בפרק 1.6 ראינו את הIVT (Interrupt Vector Table) שהיתה פשוט מערך של כתובות בזיכרון. הIDT היא הגרסה המתקדמת שלה - כל כניסה מכילה לא רק כתובת, אלא גם מידע על הרשאות, סוג הgate, וסגמנט היעד.
איך הIDT מוגדרת?¶
- הקרנל בונה את הIDT בזמן אתחול המערכת (boot time)
- הכתובת של הIDT נטענת לרגיסטר מיוחד של המעבד באמצעות הפקודה
LIDT - כל כניסה היא מבנה בגודל 16 בתים (ב-64 ביט) שמכיל את כתובת הhandler, selector, ודגלים
חלוקת הכניסות¶
| טווח | שימוש |
|---|---|
| 0-31 | חריגות מעבד (exceptions) - מוגדרות על ידי אינטל, קבועות |
| 32-255 | זמינות לinterrupts חומרה (IRQs) וinterrupts תוכנה |
חריגות המעבד החשובות (0-31)¶
| מספר | סימון | שם | מתי קורה |
|---|---|---|---|
| 0 | #DE | שגיאת חילוק - Divide Error | חלוקה באפס או overflow בחלוקה |
| 1 | #DB | דיבוג - Debug | צעד בודד (single step) או hardware breakpoint |
| 3 | #BP | נקודת עצירה - Breakpoint | הפקודה int3 הופעלה |
| 4 | #OF | גלישה - Overflow | הפקודה into כשדגל OF דלוק |
| 6 | #UD | פקודה לא חוקית - Invalid Opcode | המעבד נתקל בopcode שהוא לא מכיר |
| 7 | #NM | התקן לא זמין - Device Not Available | ניסיון להשתמש בFPU/SSE כשהם כבויים |
| 8 | #DF | שגיאה כפולה - Double Fault | exception קרה בזמן טיפול בexception אחר |
| 13 | #GP | הגנה כללית - General Protection Fault | הפרת הרשאה - ניסיון לבצע פעולה אסורה |
| 14 | #PF | שגיאת דף - Page Fault | גישה לpage שלא ממופה או שאין הרשאה אליו |
שימו לב שהDouble Fault (#DF) מספר 8 הוא מצב מיוחד - הוא קורה כשexception נזרק בזמן שהמעבד מנסה לטפל בexception אחר. אם גם הDouble Fault נכשל, המעבד עושה Triple Fault ומאתחל את המחשב מחדש (ריסטרט).
זרימת interrupt חומרה - מההתקן עד לקרנל¶
בואו נעקוב אחרי מה קורה כשהתקן חומרה צריך את תשומת הלב של המעבד. ניקח לדוגמה לחיצה על מקש במקלדת:
שלב 1 - ההתקן שולח אות לבקר הinterrupts¶
המקלדת שולחת אות חשמלי לבקר הinterrupts. במערכות מודרניות זה הAPIC (נדבר עליו בהמשך). במערכות ישנות זה היה הPIC (Programmable Interrupt Controller) - שבט 8259.
שלב 2 - בקר הinterrupts מעביר את הinterrupt למעבד¶
הAPIC מעביר את הinterrupt למעבד המתאים עם מספר vector (מספר הכניסה בIDT).
שלב 3 - המעבד שומר מצב וקופץ לhandler¶
המעבד עושה את הדברים הבאים אוטומטית (בחומרה, לא בתוכנה):
1. שומר את הרגיסטרים RFLAGS, CS, RIP על מחסנית הקרנל
2. אם היה מעבר מuser mode לkernel mode - שומר גם SS ו-RSP ומחליף מחסנית
3. מנקה את דגל הIF (Interrupt Flag) - כדי לחסום interrupts נוספות
4. מחפש את הכניסה המתאימה בIDT
5. קופץ לכתובת הhandler
שלב 4 - הhandler רץ (החצי העליון - top half)¶
הקרנל מריץ את הhandler - שצריך להיות מהיר מאוד. הוא עושה את המינימום ההכרחי:
- מאשר לחומרה שקיבל את הinterrupt (acknowledge)
- שומר את המידע הדרוש
- מתזמן עבודה כבדה יותר שתרוץ מאוחר יותר (bottom half)
שלב 5 - החצי התחתון רץ מאוחר יותר (bottom half)¶
העיבוד הכבד של הinterrupt קורה בהמשך, כשinterrupts כבר מותרות שוב.
שלב 6 - חזרה מהinterrupt¶
הפקודה iretq (interrupt return) משחזרת את כל מה שהמעבד שמר בשלב 3, והריצה ממשיכה מאיפה שהופסקה.
חצי עליון מול חצי תחתון - top half vs bottom half¶
למה בכלל צריך את החלוקה הזאת? הסיבה פשוטה:
כשהhandler של interrupt רץ, interrupts אחרות חסומות (או לפחות הIRQ הנוכחי חסום). אם נעשה עיבוד כבד בhandler, אנחנו נפסיד interrupts אחרות ומערכת תהפוך ללא רספונסיבית.
לכן עושים חלוקה:
החצי העליון - top half¶
- רץ עם interrupts חסומות (או לפחות הIRQ הנוכחי חסום)
- חייב להיות מהיר - מיקרושניות בודדות
- עושה רק את המינימום: מאשר לחומרה, קורא מידע קריטי, מתזמן את הbottom half
- לא יכול לישון (אי אפשר לקרוא ל-sleep, ללכת לדיסק, להקצות זיכרון עם GFP_KERNEL)
החצי התחתון - bottom half¶
כאן מתבצע העיבוד האמיתי. יש שלושה מנגנונים שונים:
מנגנון softirqs¶
- מוגדרים סטטית בזמן קומפילציה של הקרנל (לא ניתן להוסיף חדשים בזמן ריצה)
- הכי מהירים ויעילים
- משמשים למשימות קריטיות כמו עיבוד רשת (NET_TX_SOFTIRQ, NET_RX_SOFTIRQ) ובלוק I/O
- יכולים לרוץ במקביל על מספר מעבדים (ולכן צריך להיזהר עם נעילות)
- לא יכולים לישון
מנגנון tasklets¶
- בנויים מעל softirqs
- אפשר להקצות אותם דינמית
- פשוטים יותר לשימוש - tasklet ספציפי לא ירוץ על שני מעבדים במקביל
- לא יכולים לישון
מנגנון workqueues - תורי עבודה¶
- רצים בהקשר של thread קרנל רגיל
- יכולים לישון - ולכן מתאימים לעבודה כבדה
- יכולים להקצות זיכרון, לגשת לדיסק, לבצע פעולות ארוכות
- איטיים יותר מsoftirqs ומtasklets
- משמשים למשימות שדורשות עבודה כבדה ולא דחופה
דוגמה - כרטיס רשת¶
כשכרטיס הרשת מקבל חבילה:
1. חצי עליון: הdriver קורא את החבילה מהחומרה, שם אותה בqueue, מאשר את הinterrupt - סיים תוך מיקרושניות
2. חצי תחתון (softirq): הstack של הרשת בקרנל מעבד את החבילה - בודק headers, מחליט לאיזה socket להעביר, מטפל בTCP - יכול לקחת הרבה יותר זמן
הAPIC - Advanced Programmable Interrupt Controller¶
בפרק 1.6 הזכרנו בקצרה את הPIC (שבב 8259) שהיה בקר הinterrupts במערכות ישנות. במערכות מודרניות השתדרגנו לAPIC.
למה צריך APIC?¶
הPIC הישן תמך ב-15 קווי interrupt בלבד, ותמיד שלח interrupts למעבד אחד בלבד. במערכות מודרניות עם מספר מעבדים (וליבות), זה לא מספיק.
המבנה¶
מערכת הAPIC מורכבת משני חלקים:
Local APIC - לכל ליבה במעבד יש Local APIC משלה. היא:
- מקבלת interrupts מהI/O APIC ומ-Local APICים אחרים
- מנהלת את הטיימר המקומי של הליבה
- יכולה לשלוח interrupts לליבות אחרות (IPI - Inter-Processor Interrupt)
I/O APIC - יושב על לוח האם. הוא:
- מקבל interrupts מהתקני חומרה חיצוניים
- ממפה כל interrupt חומרה לvector בIDT
- מנתב את הinterrupt לליבה המתאימה
איזון פסיקות - interrupt balancing¶
הI/O APIC יכול להפיץ interrupts בין הליבות השונות, כדי שליבה אחת לא תהיה עמוסה בכל הinterrupts. בלינוקס אפשר לשלוט בזה דרך /proc/irq/<N>/smp_affinity.
קובץ proc/interrupts/¶
אחד הכלים השימושיים ביותר לבדיקת interrupts במערכת חיה הוא הקובץ /proc/interrupts:
הפלט נראה משהו כזה:
CPU0 CPU1 CPU2 CPU3
0: 50 0 0 0 IO-APIC 2-edge timer
1: 0 0 0 23 IO-APIC 1-edge i8042
8: 0 0 1 0 IO-APIC 8-edge rtc0
9: 0 0 0 3 IO-APIC 9-fasteoi acpi
14: 0 0 0 0 IO-APIC 14-edge ata_piix
15: 0 0 231 0 IO-APIC 15-edge ata_piix
19: 1205 0 0 0 IO-APIC 19-fasteoi eth0
NMI: 0 0 0 0 Non-maskable interrupts
LOC: 12504 11987 12133 12045 Local timer interrupts
מה רואים כאן:
- עמודה שמאלית - מספר הIRQ
- עמודות CPUn - כמה interrupts כל ליבה טיפלה בIRQ הזה
- סוג הבקר - IO-APIC, MSI וכו'
- שם ההתקן - הdriver שרשום לIRQ הזה
שימו לב לLOC (Local timer interrupts) - זה הטיימר המקומי של כל ליבה, שאחראי על תזמון processes (scheduler tick).
טיפול בחריגות - exceptions בפירוט¶
בואו נעקוב אחרי חריגה שכולנו מכירים - שגיאת דף - Page Fault.
דוגמה: מה קורה כשתוכנה ניגשת לכתובת לא ממופה?¶
הנה מה שקורה צעד אחרי צעד:
שלב 1 - המעבד מזהה שגיאה¶
המעבד מנסה לתרגם את הכתובת הוירטואלית 0xDEADBEEF לכתובת פיזית דרך טבלאות הpages. הוא מגלה שאין מיפוי - ומייצר exception מספר 14 (#PF).
שלב 2 - המעבד שומר מצב¶
המעבד דוחף על מחסנית הקרנל:
- קוד שגיאה (error code) - מכיל מידע על סוג הכשלון (קריאה/כתיבה, user/kernel, page לא קיים/הפרת הרשאה)
- שומר את הכתובת הפוגעת ברגיסטר CR2
שלב 3 - קפיצה לhandler¶
הIDT כניסה מספר 14 מצביעה על הפונקציה page_fault בקרנל, שקוראת לפונקציה do_page_fault().
שלב 4 - הקרנל מנתח את המצב¶
הפונקציה do_page_fault() קוראת את CR2 כדי לדעת איזו כתובת גרמה לבעיה, ואז בודקת:
- האם הכתובת שייכת לVMA חוקי? (זוכרים VMAs מפרק 6.4?) - מחפשת את הכתובת בעץ הVMAs של הprocess
- אם כן - אולי צריך להקצות page חדש (demand paging), או לטעון page מהדיסק (swap), או לבצע copy-on-write
- אם לא - הכתובת לא חוקית, הprocess ניגש למקום שלא שייך לו
שלב 5 - תגובה¶
- אם אפשר לטפל - הקרנל מקצה page, ממפה אותו, וחוזר לתוכנה שממשיכה כרגיל (התוכנה אפילו לא יודעת שהיה page fault!)
- אם לא אפשר לטפל - הקרנל שולח סיגנל SIGSEGV לprocess (ה-Segmentation Fault המפורסם)
הקשר בין חריגות לסיגנלים¶
זוכרים סיגנלים מפרק 5.4? עכשיו אנחנו יכולים לסגור את המעגל ולהבין מאיפה הם באמת מגיעים:
| חריגה (exception) | מספר | סיגנל שנשלח | מה קורה |
|---|---|---|---|
| שגיאת חילוק - #DE | 0 | SIGFPE | התוכנה חילקה באפס |
| נקודת עצירה - #BP | 3 | SIGTRAP | הפקודה int3 הופעלה (debugger!) |
| פקודה לא חוקית - #UD | 6 | SIGILL | המעבד נתקל בopcode שהוא לא מכיר |
| הגנה כללית - #GP | 13 | SIGSEGV | הפרת הרשאה |
| שגיאת דף - #PF | 14 | SIGSEGV (אם לא ניתן לטפל) | גישה לזכרון לא חוקי |
איך GDB משתמש בbreakpoints¶
כשאתם שמים breakpoint בGDB, הנה מה שקורה מאחורי הקלעים:
- GDB כותב את הבית
0xCC(הopcode שלint3) במקום הפקודה הראשונה בכתובת של הbreakpoint - כשהתוכנה מגיעה לנקודה הזו, המעבד מבצע
int3 - המעבד מייצר exception #BP (מספר 3)
- הקרנל מזהה breakpoint exception ושולח SIGTRAP לprocess
- הsyscall ptrace (שGDB משתמש בו) לוכד את הSIGTRAP
- GDB מקבל שליטה - ואתם רואים שהתוכנה "עצרה" בbreakpoint
ועכשיו אתם מבינים את כל השרשרת - מהחומרה (exception), דרך הקרנל (IDT handler), ועד לתוכנה (סיגנל)!
סיכום¶
- הinterrupts חומרה מגיעות מהתקנים דרך הAPIC, חריגות נגרמות על ידי המעבד עצמו
- הIDT היא טבלה של 256 כניסות שממפה כל מספר interrupt לhandler בקרנל
- הטיפול מחולק לtop half (מהיר, interrupts חסומות) ולbottom half (softirq/tasklet/workqueue)
- חריגות מעבד הופכות לסיגנלים שנשלחים לprocess
- כל הנושאים שלמדנו מתחברים: IDT מפרק 2, סיגנלים מפרק 5.4, VMAs מפרק 6.4, מתזמן מפרק 6.3