בפרק 1.6 למדנו על פסיקות במצב 16 ביט - ראינו את טבלת הIVT, את הפקודה INT, ואיך הBIOS וDOS מספקים שירותים דרך פסיקות.
בפרק 2 ראינו בקצרה את הIDT - טבלת הפסיקות של Protected Mode, ואיך היא מחליפה את הIVT הישנה.
עכשיו הגיע הזמן להבין לעומק איך הקרנל של לינוקס מטפל בפסיקות וב-exceptions על ארכיטקטורת x86-64 המודרנית.
פסיקות ו-exceptions - interrupts and exceptions¶
מה זה בכלל פסיקות?¶
פסיקה (interrupt) היא אירוע שגורם למעבד להפסיק את מה שהוא עושה עכשיו ולקפוץ לפונקציית טיפול (handler) שהוגדרה מראש.
זוכרים את הרעיון מפרק 1.6? אותו רעיון בדיוק - רק שעכשיו אנחנו ב-64 ביט, בProtected Mode (ליתר דיוק - ב-Long Mode), עם קרנל שלם שמנהל את כל התהליך.
יש שני סוגים עיקריים של פסיקות:
פסיקות חומרה - 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 | זמינות לפסיקות חומרה (IRQs) ופסיקות תוכנה |
חריגות המעבד החשובות (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 | גישה לדף שלא ממופה או שאין הרשאה אליו |
שימו לב שהDouble Fault (#DF) מספר 8 הוא מצב מיוחד - הוא קורה כשexception נזרק בזמן שהמעבד מנסה לטפל בexception אחר. אם גם הDouble Fault נכשל, המעבד עושה Triple Fault ומאתחל את המחשב מחדש (ריסטרט).
זרימת פסיקת חומרה - מההתקן עד לקרנל¶
בואו נעקוב אחרי מה קורה כשהתקן חומרה צריך את תשומת הלב של המעבד. ניקח לדוגמה לחיצה על מקש במקלדת:
שלב 1 - ההתקן שולח אות לבקר הפסיקות¶
המקלדת שולחת אות חשמלי לבקר הפסיקות. במערכות מודרניות זה הAPIC (נדבר עליו בהמשך). במערכות ישנות זה היה הPIC (Programmable Interrupt Controller) - שבט 8259.
שלב 2 - בקר הפסיקות מעביר את הפסיקה למעבד¶
הAPIC מעביר את הפסיקה למעבד המתאים עם מספר vector (מספר הכניסה בIDT).
שלב 3 - המעבד שומר מצב וקופץ לhandler¶
המעבד עושה את הדברים הבאים אוטומטית (בחומרה, לא בתוכנה):
1. שומר את הרגיסטרים RFLAGS, CS, RIP על מחסנית הקרנל
2. אם היה מעבר מuser mode לkernel mode - שומר גם SS ו-RSP ומחליף מחסנית
3. מנקה את דגל הIF (Interrupt Flag) - כדי לחסום פסיקות נוספות
4. מחפש את הכניסה המתאימה בIDT
5. קופץ לכתובת הhandler
שלב 4 - הhandler רץ (החצי העליון - top half)¶
הקרנל מריץ את הhandler - שצריך להיות מהיר מאוד. הוא עושה את המינימום ההכרחי:
- מאשר לחומרה שקיבל את הפסיקה (acknowledge)
- שומר את המידע הדרוש
- מתזמן עבודה כבדה יותר שתרוץ מאוחר יותר (bottom half)
שלב 5 - החצי התחתון רץ מאוחר יותר (bottom half)¶
העיבוד הכבד של הפסיקה קורה בהמשך, כשפסיקות כבר מותרות שוב.
שלב 6 - חזרה מהפסיקה¶
הפקודה iretq (interrupt return) משחזרת את כל מה שהמעבד שמר בשלב 3, והריצה ממשיכה מאיפה שהופסקה.
חצי עליון מול חצי תחתון - top half vs bottom half¶
למה בכלל צריך את החלוקה הזאת? הסיבה פשוטה:
כשהhandler של פסיקה רץ, פסיקות אחרות חסומות (או לפחות הIRQ הנוכחי חסום). אם נעשה עיבוד כבד בhandler, אנחנו נפסיד פסיקות אחרות ומערכת תהפוך ללא רספונסיבית.
לכן עושים חלוקה:
החצי העליון - top half¶
- רץ עם פסיקות חסומות (או לפחות ה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 קורא את החבילה מהחומרה, שם אותה בתור, מאשר את הפסיקה - סיים תוך מיקרושניות
2. חצי תחתון (softirq): הstack של הרשת בקרנל מעבד את החבילה - בודק headers, מחליט לאיזה socket להעביר, מטפל בTCP - יכול לקחת הרבה יותר זמן
הAPIC - Advanced Programmable Interrupt Controller¶
בפרק 1.6 הזכרנו בקצרה את הPIC (שבב 8259) שהיה בקר הפסיקות במערכות ישנות. במערכות מודרניות השתדרגנו לAPIC.
למה צריך APIC?¶
הPIC הישן תמך ב-15 קווי פסיקה בלבד, ותמיד שלח פסיקות למעבד אחד בלבד. במערכות מודרניות עם מספר מעבדים (וליבות), זה לא מספיק.
המבנה¶
מערכת הAPIC מורכבת משני חלקים:
Local APIC - לכל ליבה במעבד יש Local APIC משלה. היא:
- מקבלת פסיקות מהI/O APIC ומ-Local APICים אחרים
- מנהלת את הטיימר המקומי של הליבה
- יכולה לשלוח פסיקות לליבות אחרות (IPI - Inter-Processor Interrupt)
I/O APIC - יושב על לוח האם. הוא:
- מקבל פסיקות מהתקני חומרה חיצוניים
- ממפה כל פסיקת חומרה לvector בIDT
- מנתב את הפסיקה לליבה המתאימה
איזון פסיקות - interrupt balancing¶
הI/O APIC יכול להפיץ פסיקות בין הליבות השונות, כדי שליבה אחת לא תהיה עמוסה בכל הפסיקות. בלינוקס אפשר לשלוט בזה דרך /proc/irq/<N>/smp_affinity.
קובץ proc/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 - כמה פסיקות כל ליבה טיפלה בIRQ הזה
- סוג הבקר - IO-APIC, MSI וכו'
- שם ההתקן - הdriver שרשום לIRQ הזה
שימו לב לLOC (Local timer interrupts) - זה הטיימר המקומי של כל ליבה, שאחראי על תזמון תהליכים (scheduler tick).
טיפול בחריגות - exceptions בפירוט¶
בואו נעקוב אחרי חריגה שכולנו מכירים - שגיאת דף - Page Fault.
דוגמה: מה קורה כשתוכנה ניגשת לכתובת לא ממופה?¶
הנה מה שקורה צעד אחרי צעד:
שלב 1 - המעבד מזהה שגיאה¶
המעבד מנסה לתרגם את הכתובת הוירטואלית 0xDEADBEEF לכתובת פיזית דרך טבלאות הדפים. הוא מגלה שאין מיפוי - ומייצר exception מספר 14 (#PF).
שלב 2 - המעבד שומר מצב¶
המעבד דוחף על מחסנית הקרנל:
- קוד שגיאה (error code) - מכיל מידע על סוג הכשלון (קריאה/כתיבה, user/kernel, דף לא קיים/הפרת הרשאה)
- שומר את הכתובת הפוגעת ברגיסטר CR2
שלב 3 - קפיצה לhandler¶
הIDT כניסה מספר 14 מצביעה על הפונקציה page_fault בקרנל, שקוראת לפונקציה do_page_fault().
שלב 4 - הקרנל מנתח את המצב¶
הפונקציה do_page_fault() קוראת את CR2 כדי לדעת איזו כתובת גרמה לבעיה, ואז בודקת:
- האם הכתובת שייכת לVMA חוקי? (זוכרים VMAs מפרק 6.4?) - מחפשת את הכתובת בעץ הVMAs של התהליך
- אם כן - אולי צריך להקצות דף חדש (demand paging), או לטעון דף מהדיסק (swap), או לבצע copy-on-write
- אם לא - הכתובת לא חוקית, התהליך ניגש למקום שלא שייך לו
שלב 5 - תגובה¶
- אם אפשר לטפל - הקרנל מקצה דף, ממפה אותו, וחוזר לתוכנה שממשיכה כרגיל (התוכנה אפילו לא יודעת שהיה page fault!)
- אם לא אפשר לטפל - הקרנל שולח סיגנל SIGSEGV לתהליך (ה-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 לתהליך
- הsyscall ptrace (שGDB משתמש בו) לוכד את הSIGTRAP
- GDB מקבל שליטה - ואתם רואים שהתוכנה "עצרה" בbreakpoint
ועכשיו אתם מבינים את כל השרשרת - מהחומרה (exception), דרך הקרנל (IDT handler), ועד לתוכנה (סיגנל)!
סיכום¶
- פסיקות חומרה מגיעות מהתקנים דרך הAPIC, חריגות נגרמות על ידי המעבד עצמו
- הIDT היא טבלה של 256 כניסות שממפה כל מספר פסיקה לhandler בקרנל
- הטיפול מחולק לtop half (מהיר, פסיקות חסומות) ולbottom half (softirq/tasklet/workqueue)
- חריגות מעבד הופכות לסיגנלים שנשלחים לתהליך
- כל הנושאים שלמדנו מתחברים: IDT מפרק 2, סיגנלים מפרק 5.4, VMAs מפרק 6.4, מתזמן מפרק 6.3