6.2 - הsyscall מבפנים - הרצאה
הקדמה¶
בפרק 5.1 למדנו על syscall-ים מצד היוזר מוד - איך קוראים להם, מה הם עושים, ואיך משתמשים בstrace כדי לעקוב אחריהם.
עכשיו נראה את הצד השני - מה בדיוק קורה בתוך הקרנל מהרגע שהתוכנה קוראת ל-syscall ועד שהתוצאה חוזרת. נעקוב אחרי כל שלב במסע, מהפקודה syscall באסמבלי ועד לפונקציה שמטפלת בבקשה בתוך הקרנל.
המסע המלא של syscall¶
בואו נעקוב אחרי מה שקורה כשתוכנית יוזר מודית קוראת ל-write() כדי לכתוב טקסט למסך. כל שלב כאן הוא אמיתי - כך זה באמת עובד בלינוקס על x86-64.
שלב 1: קריאה לlibc¶
התוכנית שלנו קוראת ל-write(1, "hello", 5). הפונקציה write היא פונקציית wrapper של libc - היא לא עושה את הכתיבה בעצמה, היא רק מכינה את הקריאה לsyscall.
שלב 2: הכנת האוגרים¶
הwrapper של libc שם את הפרמטרים באוגרים לפי הconvention של syscall-ים בx86-64:
| אוגר | תפקיד | ערך בדוגמה שלנו |
|---|---|---|
rax |
מספר הsyscall | 1 (SYS_write) |
rdi |
פרמטר ראשון | 1 (fd - stdout) |
rsi |
פרמטר שני | כתובת המחרוזת "hello" |
rdx |
פרמטר שלישי | 5 (מספר הבתים) |
r10 |
פרמטר רביעי | (לא בשימוש כאן) |
r8 |
פרמטר חמישי | (לא בשימוש כאן) |
r9 |
פרמטר שישי | (לא בשימוש כאן) |
שימו לב: הconvention של syscall-ים שונה קצת מconvention הקריאה הרגיל של C (שבו הפרמטר הרביעי עובר ב-rcx). בsyscall-ים הפרמטר הרביעי עובר ב-r10, כי rcx תפוס - ניכנס לזה עוד רגע.
שלב 3: הפקודה syscall¶
הlibc מבצעת את הפקודה:
זו הוראת מכונה מיוחדת (לא int 0x80 שהוא הדרך הישנה). הפקודה syscall מהירה יותר כי היא תוכננה ספציפית בשביל מעבר לקרנל.
מה המעבד עושה כשהוא מבצע את syscall:
1. שומר את הכתובת הנוכחית (RIP) לתוך rcx - לכן rcx תפוס ולא משמש לפרמטרים
2. שומר את הFLAGS לתוך r11
3. עובר ל-Ring 0 (קרנל מוד)
4. קופץ לכתובת שרשומה ברגיסטר MSR_LSTAR - זוהי כתובת הentry point של syscall-ים שהקרנל הגדיר בזמן האתחול
שלב 4: entry_SYSCALL_64 - נקודת הכניסה¶
המעבד קופץ לפונקציה entry_SYSCALL_64 שנמצאת בקובץ arch/x86/entry/entry_64.S. זהו קוד אסמבלי שאחראי לשמור את מצב המעבד ולהכין את הסביבה לקריאת הפונקציה הקרנלית.
מה הפונקציה עושה:
1. מחליפה למחסנית הקרנל - כל process יש לו מחסנית קרנלית נפרדת (זוכרים את הTSS מפרק 2.4?)
2. שומרת את כל האוגרים על המחסנית - יוצרת מבנה שנקרא pt_regs (נדבר עליו בהמשך)
3. קוראת לקוד C שמטפל בsyscall
שלב 5: חיפוש בטבלת הsyscall-ים - sys_call_table¶
הקרנל מסתכל על הערך ב-rax (מספר הsyscall) ומשתמש בו כאינדקס לתוך טבלה - sys_call_table. זוהי פשוט מערך של פוינטרים לפונקציות:
// simplified representation of the table
const sys_call_ptr_t sys_call_table[] = {
[0] = sys_read, // syscall 0
[1] = sys_write, // syscall 1
[2] = sys_open, // syscall 2
[3] = sys_close, // syscall 3
// ... hundreds more syscalls
[57] = sys_fork, // syscall 57
[59] = sys_execve, // syscall 59
[60] = sys_exit, // syscall 60
// ...
};
הטבלה בנויה מתוך קובץ שנקרא arch/x86/entry/syscalls/syscall_64.tbl שמכיל את המיפוי בין מספרים לפונקציות:
# number abi name entry point
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
...
57 common fork sys_fork
59 64 execve sys_execve
60 common exit sys_exit_group
...
כשהקרנל רוצה להפעיל את הsyscall, הוא פשוט עושה:
שלב 6: הפונקציה הקרנלית¶
בדוגמה שלנו, הקרנל קופץ ל-sys_write, שמובילה ל-ksys_write, שמובילה ל-vfs_write (שכבת הVFS שלמדנו עליה ב-6.1), שבסוף מגיעה לדרייבר הספציפי שכותב את הנתונים.
שלב 7: חזרה ליוזר מוד¶
אחרי שהפונקציה הקרנלית סיימה:
1. ערך ההחזרה נשמר ב-rax (או ערך שלילי אם הייתה שגיאה)
2. הקרנל משחזר את האוגרים ממבנה הpt_regs
3. מבצע את הפקודה sysret שמחזירה את המעבד ל-Ring 3
4. sysret משחזרת את RIP מ-rcx ואת FLAGS מ-r11
5. התוכנית ממשיכה לרוץ ביוזר מוד מהשורה שאחרי הsyscall
המאקרו SYSCALL_DEFINE - הגדרת syscall¶
כשמסתכלים על קוד המקור של הקרנל, הsyscall-ים מוגדרים באמצעות מאקרו מיוחד שנקרא SYSCALL_DEFINE. המספר אחרי DEFINE מציין כמה פרמטרים הsyscall מקבל:
// definition of sys_write in the kernel
// SYSCALL_DEFINE3 = syscall with 3 parameters
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
{
return ksys_write(fd, buf, count);
}
שימו לב לסדר: שם הsyscall, ואז סוג, שם לכל פרמטר (מופרדים בפסיקים). המאקרו הזה יוצר את הפונקציה sys_write עם החתימה הנכונה.
דוגמאות נוספות:
// syscall with no parameters
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current);
}
// syscall with 2 parameters
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
// ... send signal to the process
}
// syscall with 6 parameters (the maximum)
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, off)
{
// ... memory mapping
}
הסימון user__ - מצביעים מיוזר מוד¶
שמתם לב לסימון __user ליד הפרמטר buf בהגדרה של write?
הסימון __user אומר לקרנל: הפוינטר הזה מגיע מיוזר מוד. זה לא רק תיעוד - יש לזה משמעות אמיתית:
- אי אפשר פשוט לעשות dereference לפוינטר מיוזר מוד! למה? כי:
- הכתובת יכולה להיות לא חוקית (הuser שלח כתובת שטויות)
- הpage יכול להיות לא ממופה (יגרום ל-page fault)
-
זה יכול להיות ניסיון לגרום לקרנל לכתוב/לקרוא מכתובת קרנלית (אם הuser שולח כתובת בחצי העליון)
-
במקום זה, הקרנל משתמש בפונקציות מיוחדות:
copy_from_user - העתקה מיוזר מוד לקרנל¶
// copies count bytes from src (in user mode) to dst (in the kernel)
unsigned long copy_from_user(void *dst, const void __user *src, unsigned long count);
לדוגמה, כשwrite צריכה לקרוא את הנתונים שהmuser רוצה לכתוב:
// simplified - what happens inside vfs_write
char kernel_buf[PAGE_SIZE];
if (copy_from_user(kernel_buf, user_buf, count)) {
return -EFAULT; // invalid address!
}
// now kernel_buf holds the data safely
copy_to_user - העתקה מקרנל ליוזר מוד¶
// copies count bytes from src (in the kernel) to dst (in user mode)
unsigned long copy_to_user(void __user *dst, const void *src, unsigned long count);
לדוגמה, כשread צריכה להחזיר נתונים ליוזר:
// simplified - what happens inside vfs_read
char kernel_buf[PAGE_SIZE];
// ... reads data from disk into kernel_buf ...
if (copy_to_user(user_buf, kernel_buf, count)) {
return -EFAULT;
}
הפונקציות האלה עושות כמה דברים חשובים:
- מוודאות שהכתובת היא ביוזר מוד (לא בחצי הקרנלי של המרחב)
- מטפלות ב-page fault אם הpage לא ממופה כרגע (הקרנל עצמו לא יכול פשוט לקרוס מpage fault)
- מחזירות שגיאה אם הכתובת לא חוקית (במקום לגרום ל-kernel panic)
המבנה pt_regs - מצב המעבד השמור¶
כשsyscall מתחיל, הקרנל שומר את כל אוגרי המעבד במבנה שנקרא pt_regs (process trace registers). המבנה הזה נמצא על המחסנית הקרנלית של הprocess:
// arch/x86/include/asm/ptrace.h (simplified)
struct pt_regs {
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
unsigned long orig_rax; // the original syscall number
unsigned long rip; // return address
unsigned long cs;
unsigned long eflags;
unsigned long rsp; // the user's stack pointer
unsigned long ss;
};
שימו לב לשדה orig_rax - הוא שומר את מספר הsyscall המקורי. למה צריך את זה בנפרד? כי rax ישתנה להכיל את ערך ההחזרה של הsyscall, אבל לפעמים צריך לדעת מה היה הsyscall המקורי (למשל, אם הsyscall הופסק על ידי סיגנל וצריך להפעיל אותו מחדש).
עלות הsyscall - syscall overhead¶
מעבר מיוזר מוד לקרנל מוד הוא לא חינמי. כל syscall כולל:
- שמירת אוגרים - כל הpt_regs צריכים להישמר על המחסנית
- החלפת מחסנית - מהמחסנית היוזרית למחסנית הקרנלית
- בדיקות אבטחה - וידוא פרמטרים, בדיקת הרשאות
- שחזור אוגרים - בחזרה ליוזר מוד
- TLB ו-cache - מעבר ההרשאות יכול לגרום לpipeline flush במעבד
כל syscall עולה בערך כמה מאות ננו-שניות עד כמה מיקרו-שניות. זה נשמע מעט, אבל כשתוכנית עושה מיליוני syscall-ים בשניה, זה מסתכם.
זו אחת הסיבות שmmap (שלמדנו בפרק 5.7) יכול להיות מהיר יותר מread/write לגישה לקבצים. עם mmap, פעם אחת מבצעים syscall כדי לממפות את הקובץ לזכרון, ואחרי זה כל הגישות הן קריאות זכרון רגילות - בלי syscall בכלל.
הוספת syscall מותאם אישית (רעיוני)¶
מה צריך לעשות כדי להוסיף syscall חדש לקרנל? התהליך הוא בערך כזה:
-
בוחרים מספר - מוסיפים שורה בקובץ
syscall_64.tbl:
-
מוסיפים הצהרה בקבצי הheader:
-
כותבים את הפונקציה:
-
מקמפלים מחדש את הקרנל ומאתחלים
בפועל, כמעט אף פעם לא מוסיפים syscall-ים חדשים. לינוקס מאוד שמרני בנוגע להוספת syscall-ים כי כל syscall שנוסף חייב להישאר לעולם (תאימות לאחור). במקום זה, משתמשים במנגנונים כמו ioctl, netlink, או procfs/sysfs כדי להוסיף interfaces חדשים.
strace מבפנים - איך strace באמת עובד¶
בפרק 5.1 השתמשנו בstrace כדי לראות את הsyscall-ים של תוכנה. עכשיו שאנחנו מבינים מה קורה בתוך הקרנל, בואו נבין איך strace עושה את מה שהוא עושה.
strace משתמש בsyscall שנקרא ptrace (process trace). ptrace מאפשר לprocess אחד (הtracer) לשלוט על process אחר (הtracee):
- strace יוצר את הprocess שהוא רוצה לעקוב אחריו (או מתחבר לprocess קיים)
- strace קורא ל-
ptrace(PTRACE_SYSCALL, ...)שאומר לקרנל: "עצור את הprocess הזה בכל כניסה ויציאה מsyscall" - כשהprocess המנוטר מבצע syscall, הקרנל עוצר אותו לפני שהsyscall מתבצע, ומעיר את strace
- strace קורא את האוגרים של הprocess (עם
ptrace(PTRACE_GETREGS, ...)) ורואה מה מספר הsyscall והפרמטרים - strace ממשיך את הprocess (עם
ptrace(PTRACE_SYSCALL, ...)), הsyscall מתבצע, והprocess נעצר שוב בחזרה - strace קורא שוב את האוגרים ורואה את ערך ההחזרה
זה מסביר למה תוכנית שרצה תחת strace היא הרבה יותר איטית - כל syscall גורם לשני context switch-ים נוספים (לstrace ובחזרה). ptrace הוא גם הsyscall שמאפשר לdebuggers כמו GDB לעבוד - הם משתמשים בו כדי לעצור processes, לקרוא זכרון, להציב breakpoints, ועוד.
int 0x80 מול syscall¶
בפרק 5.1 הזכרנו ש-int 0x80 היא הדרך הישנה לקרוא ל-syscall, ו-syscall היא הדרך החדשה. בואו נבין את ההבדלים:
int 0x80 |
syscall |
|
|---|---|---|
| מנגנון | הinterrupt תוכנתית | הוראת מכונה ייעודית |
| מהירות | איטי - עובר דרך הIDT | מהיר - קופץ ישירות לכתובת בMSR |
| convention | פרמטרים ב-ebx, ecx, edx, esi, edi, ebp | פרמטרים ב-rdi, rsi, rdx, r10, r8, r9 |
| ארכיטקטורה | עובד ב-32 ו-64 ביט | רק 64 ביט (ב-32 ביט יש sysenter) |
| מספרי syscall | מספרים של 32 ביט | מספרים של 64 ביט (שונים!) |
נקודה חשובה: מספרי הsyscall-ים שונים בין int 0x80 לsyscall! למשל, write הוא מספר 4 בint 0x80 (טבלת 32 ביט) אבל מספר 1 בsyscall (טבלת 64 ביט). אם מערבבים בטעות, מקבלים syscall שונה לגמרי.
סיכום - המסע המלא¶
בואו נסכם את כל המסע של syscall, מתחילתו ועד סופו:
user-mode program
|
| calls write(1, "hello", 5)
v
libc wrapper
|
| sets rax=1, rdi=1, rsi=ptr, rdx=5
| executes the syscall instruction
v
entry_SYSCALL_64 (assembly in the kernel)
|
| switches to kernel stack
| saves registers into pt_regs
v
sys_call_table[rax]
|
| finds sys_write
v
sys_write -> ksys_write -> vfs_write
|
| copy_from_user() to copy the data
| writes through the driver
v
return: value in rax, sysret
|
| returns to Ring 3
v
continues in user mode
זהו! עכשיו אנחנו מבינים את התמונה המלאה - גם מה שקורה מעל (יוזר מוד, כפי שלמדנו בפרק 5) וגם מה שקורה מתחת (קרנל מוד, כפי שלמדנו כאן).