לדלג לתוכן

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 מבצעת את הפקודה:

syscall

זו הוראת מכונה מיוחדת (לא 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. מחליפה למחסנית הקרנל - כל תהליך יש לו מחסנית קרנלית נפרדת (זוכרים את הTSS מפרק 2.4?)
2. שומרת את כל האוגרים על המחסנית - יוצרת מבנה שנקרא pt_regs (נדבר עליו בהמשך)
3. קוראת לקוד C שמטפל בsyscall

שלב 5: חיפוש בטבלת הsyscall-ים - sys_call_table

הקרנל מסתכל על הערך ב-rax (מספר הsyscall) ומשתמש בו כאינדקס לתוך טבלה - sys_call_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
    // ... מאות syscall-ים נוספים
    [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, הוא פשוט עושה:

// מפושט
sys_call_table[rax](rdi, rsi, rdx, r10, r8, r9);

שלב 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 מקבל:

// הגדרת sys_write בקרנל
// SYSCALL_DEFINE3 = syscall עם 3 פרמטרים
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
{
    return ksys_write(fd, buf, count);
}

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

דוגמאות נוספות:

// syscall בלי פרמטרים
SYSCALL_DEFINE0(getpid)
{
    return task_tgid_vnr(current);
}

// syscall עם 2 פרמטרים
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
    // ... שליחת סיגנל לתהליך
}

// syscall עם 6 פרמטרים (המקסימום)
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
                unsigned long, prot, unsigned long, flags,
                unsigned long, fd, unsigned long, off)
{
    // ... מיפוי זכרון
}


הסימון user__ - מצביעים מיוזר מוד

שמתם לב לסימון __user ליד הפרמטר buf בהגדרה של write?

const char __user *buf

הסימון __user אומר לקרנל: הפוינטר הזה מגיע מיוזר מוד. זה לא רק תיעוד - יש לזה משמעות אמיתית:

  1. אי אפשר פשוט לעשות dereference לפוינטר מיוזר מוד! למה? כי:
  2. הכתובת יכולה להיות לא חוקית (הuser שלח כתובת שטויות)
  3. הדף יכול להיות לא ממופה (יגרום ל-page fault)
  4. זה יכול להיות ניסיון לגרום לקרנל לכתוב/לקרוא מכתובת קרנלית (אם הuser שולח כתובת בחצי העליון)

  5. במקום זה, הקרנל משתמש בפונקציות מיוחדות:

copy_from_user - העתקה מיוזר מוד לקרנל

// מעתיק count בתים מ-src (ביוזר מוד) ל-dst (בקרנל)
unsigned long copy_from_user(void *dst, const void __user *src, unsigned long count);

לדוגמה, כשwrite צריכה לקרוא את הנתונים שהmuser רוצה לכתוב:

// מפושט - מה שקורה בתוך vfs_write
char kernel_buf[PAGE_SIZE];
if (copy_from_user(kernel_buf, user_buf, count)) {
    return -EFAULT;  // כתובת לא חוקית!
}
// עכשיו kernel_buf מכיל את הנתונים בצורה בטוחה

copy_to_user - העתקה מקרנל ליוזר מוד

// מעתיק count בתים מ-src (בקרנל) ל-dst (ביוזר מוד)
unsigned long copy_to_user(void __user *dst, const void *src, unsigned long count);

לדוגמה, כשread צריכה להחזיר נתונים ליוזר:

// מפושט - מה שקורה בתוך vfs_read
char kernel_buf[PAGE_SIZE];
// ... קוראים נתונים מהדיסק ל-kernel_buf ...
if (copy_to_user(user_buf, kernel_buf, count)) {
    return -EFAULT;
}

הפונקציות האלה עושות כמה דברים חשובים:
- מוודאות שהכתובת היא ביוזר מוד (לא בחצי הקרנלי של המרחב)
- מטפלות ב-page fault אם הדף לא ממופה כרגע (הקרנל עצמו לא יכול פשוט לקרוס מpage fault)
- מחזירות שגיאה אם הכתובת לא חוקית (במקום לגרום ל-kernel panic)


המבנה pt_regs - מצב המעבד השמור

כשsyscall מתחיל, הקרנל שומר את כל אוגרי המעבד במבנה שנקרא pt_regs (process trace registers). המבנה הזה נמצא על המחסנית הקרנלית של התהליך:

// arch/x86/include/asm/ptrace.h (מפושט)
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;  // מספר הsyscall המקורי
    unsigned long rip;       // כתובת החזרה
    unsigned long cs;
    unsigned long eflags;
    unsigned long rsp;       // הstack pointer של היוזר
    unsigned long ss;
};

שימו לב לשדה orig_rax - הוא שומר את מספר הsyscall המקורי. למה צריך את זה בנפרד? כי rax ישתנה להכיל את ערך ההחזרה של הsyscall, אבל לפעמים צריך לדעת מה היה הsyscall המקורי (למשל, אם הsyscall הופסק על ידי סיגנל וצריך להפעיל אותו מחדש).


עלות הsyscall - syscall overhead

מעבר מיוזר מוד לקרנל מוד הוא לא חינמי. כל syscall כולל:

  1. שמירת אוגרים - כל הpt_regs צריכים להישמר על המחסנית
  2. החלפת מחסנית - מהמחסנית היוזרית למחסנית הקרנלית
  3. בדיקות אבטחה - וידוא פרמטרים, בדיקת הרשאות
  4. שחזור אוגרים - בחזרה ליוזר מוד
  5. TLB ו-cache - מעבר ההרשאות יכול לגרום לpipeline flush במעבד

כל syscall עולה בערך כמה מאות ננו-שניות עד כמה מיקרו-שניות. זה נשמע מעט, אבל כשתוכנית עושה מיליוני syscall-ים בשניה, זה מסתכם.

זו אחת הסיבות שmmap (שלמדנו בפרק 5.7) יכול להיות מהיר יותר מread/write לגישה לקבצים. עם mmap, פעם אחת מבצעים syscall כדי לממפות את הקובץ לזכרון, ואחרי זה כל הגישות הן קריאות זכרון רגילות - בלי syscall בכלל.


הוספת syscall מותאם אישית (רעיוני)

מה צריך לעשות כדי להוסיף syscall חדש לקרנל? התהליך הוא בערך כזה:

  1. בוחרים מספר - מוסיפים שורה בקובץ syscall_64.tbl:

    548    common    my_syscall    sys_my_syscall
    

  2. מוסיפים הצהרה בקבצי הheader:

    // include/linux/syscalls.h
    asmlinkage long sys_my_syscall(int param1, const char __user *param2);
    

  3. כותבים את הפונקציה:

    // kernel/my_syscall.c
    SYSCALL_DEFINE2(my_syscall, int, param1, const char __user *, param2)
    {
        char kbuf[256];
        if (copy_from_user(kbuf, param2, sizeof(kbuf)))
            return -EFAULT;
    
        printk(KERN_INFO "my_syscall called: param1=%d, param2=%s\n",
               param1, kbuf);
        return 0;
    }
    

  4. מקמפלים מחדש את הקרנל ומאתחלים

בפועל, כמעט אף פעם לא מוסיפים syscall-ים חדשים. לינוקס מאוד שמרני בנוגע להוספת syscall-ים כי כל syscall שנוסף חייב להישאר לעולם (תאימות לאחור). במקום זה, משתמשים במנגנונים כמו ioctl, netlink, או procfs/sysfs כדי להוסיף ממשקים חדשים.


strace מבפנים - איך strace באמת עובד

בפרק 5.1 השתמשנו בstrace כדי לראות את הsyscall-ים של תוכנה. עכשיו שאנחנו מבינים מה קורה בתוך הקרנל, בואו נבין איך strace עושה את מה שהוא עושה.

strace משתמש בsyscall שנקרא ptrace (process trace). ptrace מאפשר לתהליך אחד (הtracer) לשלוט על תהליך אחר (הtracee):

  1. strace יוצר את התהליך שהוא רוצה לעקוב אחריו (או מתחבר לתהליך קיים)
  2. strace קורא ל-ptrace(PTRACE_SYSCALL, ...) שאומר לקרנל: "עצור את התהליך הזה בכל כניסה ויציאה מsyscall"
  3. כשהתהליך המנוטר מבצע syscall, הקרנל עוצר אותו לפני שהsyscall מתבצע, ומעיר את strace
  4. strace קורא את האוגרים של התהליך (עם ptrace(PTRACE_GETREGS, ...)) ורואה מה מספר הsyscall והפרמטרים
  5. strace ממשיך את התהליך (עם ptrace(PTRACE_SYSCALL, ...)), הsyscall מתבצע, והתהליך נעצר שוב בחזרה
  6. strace קורא שוב את האוגרים ורואה את ערך ההחזרה

זה מסביר למה תוכנית שרצה תחת strace היא הרבה יותר איטית - כל syscall גורם לשני context switch-ים נוספים (לstrace ובחזרה). ptrace הוא גם הsyscall שמאפשר לdebuggers כמו GDB לעבוד - הם משתמשים בו כדי לעצור תהליכים, לקרוא זכרון, להציב breakpoints, ועוד.


int 0x80 מול syscall

בפרק 5.1 הזכרנו ש-int 0x80 היא הדרך הישנה לקרוא ל-syscall, ו-syscall היא הדרך החדשה. בואו נבין את ההבדלים:

int 0x80 syscall
מנגנון פסיקה תוכנתית הוראת מכונה ייעודית
מהירות איטי - עובר דרך ה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, מתחילתו ועד סופו:

תוכנית יוזר מודית
    |
    | קוראת ל-write(1, "hello", 5)
    v
libc wrapper
    |
    | שם rax=1, rdi=1, rsi=ptr, rdx=5
    | מבצע פקודת syscall
    v
entry_SYSCALL_64 (אסמבלי בקרנל)
    |
    | מחליף למחסנית קרנל
    | שומר אוגרים ב-pt_regs
    v
sys_call_table[rax]
    |
    | מוצא את sys_write
    v
sys_write -> ksys_write -> vfs_write
    |
    | copy_from_user() להעתקת הנתונים
    | כותב דרך הדרייבר
    v
חזרה: ערך ב-rax, sysret
    |
    | חוזר ל-Ring 3
    v
ממשיך ביוזר מוד

זהו! עכשיו אנחנו מבינים את התמונה המלאה - גם מה שקורה מעל (יוזר מוד, כפי שלמדנו בפרק 5) וגם מה שקורה מתחת (קרנל מוד, כפי שלמדנו כאן).