לדלג לתוכן

מעבר קריאה ותהליך בתוך הקרנל

מעבר הרשאה אינו מעבר לתהליך אחר

נניח שתוכנית מבצעת קריאה למערכת ההפעלה. המעבד עובר להריץ קוד קרנל, אך אין בכך בהכרח החלפת משימה. אותה משימה יכולה להיכנס לקרנל, לקבל תשובה ולחזור למרחב המשתמש בלי שהמתזמן בחר משימה אחרת. החלפת רמת הרשאה והחלפת הקשר תזמון הן פעולות קשורות במערכת, אבל אינן אותו אירוע.

נעקוב אחרי קריאה פשוטה בגרסת הייחוס v6.12 ובממשק הרגיל של x86_64. ההגבלה לארכיטקטורה ולממשק חשובה, משום שחלוקת האוגרים ומסלול הכניסה שונים ביעדים אחרים. לאחר שנבין את הקריאה הקצרה נוסיף מקרה שבו הקרנל צריך לקרוא זיכרון של המשתמש ולדחות מצביע לא תקף.

האוגרים בגבול הכניסה

מספר הקריאה נמצא באוגר rax, וששת הארגומנטים הראשונים מועברים באוגרים שהממשק מגדיר. הארגומנט הרביעי עובר ב־r10, בניגוד למוסכמת קריאה רגילה לפונקציית שפת סי שבה נעשה שימוש אחר באוגר המתאים. לכן קריאה ישירה לקרנל אינה פשוט קריאה לפונקציית ספרייה עם אותו שם.

ההוראה syscall שומרת מידע חזרה באוגרים ומעבירה שליטה לכתובת שהמערכת הכינה. בממשק הזה ההוראה עצמה אינה שומרת מסגרת שלמה על המחסנית ואינה מחליפה את מצביע המחסנית אוטומטית. קוד הכניסה של הקרנל משלים את המעבר לסביבת מחסנית מתאימה ושומר מצב שנדרש להמשך. זהו מקום שבו פרטי החומרה והמימוש חייבים להתחבר בדיוק.

נקרא בעץ המקור את הסמל entry_SYSCALL_64 בקובץ הכניסה של הארכיטקטורה, ואחריו את do_syscall_64. במקום להעתיק את כל האסמבלי לדוח, נסמן היכן נשמר מצב המשתמש, היכן נבחרת פונקציית הקריאה והיכן נבדקים תנאי החזרה. פרטי הגנות ותצורת זיכרון יכולים להוסיף צעדים, ולכן נפנה תמיד לעץ שנבנה במעבדה.

rg -n 'entry_SYSCALL_64|do_syscall_64' arch/x86/entry
rg -n 'SYSCALL_DEFINE0\(getpid\)|task_tgid_vnr' kernel/sys.c
rg -n 'getpid' arch/x86/entry/syscalls/syscall_64.tbl

משימה, קבוצה ומרחב שמות

מבנה task_struct מייצג משימה שניתנת לתזמון. מה שתוכנית משתמש מכנה תהליך יכול לכלול כמה משימות כאלה החולקות משאבים. הקריאה getpid מחזירה את מזהה קבוצת התהליכונים כפי שהוא נראה במרחב השמות המתאים, בעוד gettid מחזירה את זהות המשימה המסוימת. לכן שני תהליכונים יכולים להחזיר אותו מזהה תהליך ומזהי משימה שונים.

נבדוק זאת בתוכנית עם התלות libc = "0.2". הקריאות נעשות ישירות כדי לקשור את הניסוי למסלול שאנו קוראים, וההדפסה אינה מניחה סדר מסוים בין העובדים.

fn identify(label: &str) {
    let process = unsafe { libc::syscall(libc::SYS_getpid) };
    let thread = unsafe { libc::syscall(libc::SYS_gettid) };
    println!("label={label} process={process} thread={thread}");
}

fn main() {
    identify("main");
    let first = std::thread::spawn(|| identify("first"));
    let second = std::thread::spawn(|| identify("second"));
    first.join().unwrap();
    second.join().unwrap();
}

נשווה את התוצאות לתיקייה /proc/PID/task בזמן שהעובדים עדיין חיים, באמצעות הוספת נקודת המתנה מבוקרת לפני סיומם. אם נבדוק אחרי ההצטרפות, המשימות כבר יכולות להיעלם והתצוגה תהיה אחרת. זהו שוב הבדל בין אירוע שהתרחש לבין מצב שקיים בזמן הסריקה.

אותה משימה יכולה להיות בעלת מזהים שונים כאשר צופים בה ממרחבי שמות שונים. המזהה המודפס אינו כתובת של המבנה ואינו מזהה אוניברסלי לכל המערכת. כלי שמחבר אירועים צריך לדעת באיזה תחום נוצר כל מזהה, במיוחד כאשר הוא אוסף מידע מתוך מכולה ומחוץ לה.

משאבים משותפים במבנים נפרדים

מבנה המשימה מחזיק הפניות למרחב זיכרון, לטבלת מתארים, למצב מערכת קבצים, לאותות ולרכיבים נוספים. פעולות יצירה בוחרות אילו רכיבים לשתף ואילו ליצור מחדש. דגלי השיתוף של יצירת משימה אינם רק תווית "תהליך" או "תהליכון", ויש ביניהם תנאי התאמה שהקרנל בודק.

כאשר טבלת המתארים משותפת, סגירת מתאר בתהליכון אחד משפיעה על התצוגה של האחרים. כאשר קיימים עותקים נפרדים של הטבלה, הם עדיין יכולים להצביע לאותו תיאור קובץ פתוח ולחלוק היסט ומצב. לכן קריאת שדה אחד מתוך מבנה משימה אינה מספיקה כדי להבין בעלות; צריך לעקוב גם אחרי האובייקט שאליו הוא מצביע ומי עוד מחזיק אותו.

יציאה של משימה מפעילה שחרור של הפניות לפי מחזור החיים של כל רכיב. היא אינה בהכרח משחררת משאב שאחרים עדיין חולקים. גם מידע על משימה שסיימה יכול להישאר לצורך איסוף מצבה. בהמשך נלמד מדוע קוד קרנל שמחזיק מצביע למבנה חייב להוכיח את חייו באמצעות מנגנון מתאים ולא להסתפק בכך שהמזהה נראה כרגע ב־proc.

כתובת משתמש אינה מצביע קרנל מהימן

ניצור צינור ונבקש מהקרנל לכתוב אליו בית מכתובת שאינה ממופה בתהליך המעבדה. הקוד אינו מבצע קריאה בלתי בטוחה לכתובת מתוך ראסט; הוא מוסר ערך כתובת לקריאת המערכת ומצפה שהקרנל יבדוק ויטפל בכשל. הדוגמה הבאה היא תוכנית עצמאית עם אותה תלות.

use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};

fn main() -> io::Result<()> {
    let mut raw = [-1; 2];
    if unsafe { libc::pipe2(raw.as_mut_ptr(), libc::O_CLOEXEC) } == -1 {
        return Err(io::Error::last_os_error());
    }
    let _reader = unsafe { OwnedFd::from_raw_fd(raw[0]) };
    let writer = unsafe { OwnedFd::from_raw_fd(raw[1]) };
    let result = unsafe { libc::syscall(libc::SYS_write,
        writer.as_raw_fd(), 1_usize as *const libc::c_void, 1_usize) };
    let error = io::Error::last_os_error();
    assert_eq!(result, -1);
    assert_eq!(error.raw_os_error(), Some(libc::EFAULT));
    println!("kernel rejected user address: {error}");
    Ok(())
}

הכתובת אחת אינה ממופה בתהליך המעבדה הרגיל שלנו. אין להרחיב את הטענה לכך שכל מספר קטן אסור בכל מערכת אפשרית, אלא לבדוק את המיפויים והתצורה. התוצאה הצפויה היא שגיאת גישה שחוזרת למשתמש, ולא קריסת הקרנל בגלל ניסיון לקרוא מצביע כאילו היה זיכרון פנימי מהימן.

ממשקי העתקה ממשתמש יכולים לפגוש דף שאינו נוכח כרגע ולגרום לטיפול בפגיעת דף. בחלק מההקשרים הדבר מאפשר שינה, ולכן אי אפשר להשתמש בהם באופן שרירותי מתוך קטע שאסור לישון בו. כמו כן התוכן במרחב המשתמש יכול להשתנות בין בדיקה להעתקה. ממשק של דרייבר צריך להעתיק נתונים ולבדוק את העותק לפי חוזה מוגדר, ולא לקרוא שוב ושוב שדות שמשתנים במקביל.

חזרה למשתמש והזדמנות לתזמון

אחרי ביצוע הפעולה הקרנל מחזיר תוצאה במצב האוגרים ומטפל בעבודה שנדרשת לפני חזרה, כגון אותות או צורך בתזמון לפי המצב. חלק מהמסלולים יכולים לחזור מהר, ואחרים דורשים דרך חזרה מלאה יותר בגלל מצב אוגרים או תנאים אחרים. אין להניח שכל קריאה מסתיימת תמיד באותה הוראת חזרה.

קריאה שחוסמת על קלט יכולה להעביר את המשימה למצב המתנה ולאפשר למתזמן לבחור אחרת. קריאה קצרה יכולה להשלים בלי להיחסם. גם פגיעה בדף או תחרות על נעילה יכולה להוסיף המתנה למסלול שלא נראה מבחוץ כפעולת רשת. לכן השאלה אם הייתה החלפת הקשר דורשת תצפית תזמון, ואינה נענית רק מכך שראינו קריאת מערכת.

המסלול המדויק נמצא בקוד הכניסה של התג ובניהול הכניסה והיציאה. חובות ההעתקה מתועדות בממשקי ניהול הזיכרון. בתרגול נחבר בין הקוד, האוגרים והנתונים הנראים למשתמש בלי לערבב בין שלוש רמות התיאור.