לדלג לתוכן

6.5 - מערכת הקבצים הוירטואלית - הרצאה

הקדמה

לינוקס תומך בעשרות סוגים של מערכות קבצים: ext4, xfs, btrfs, ntfs, fat32, nfs, ועוד. בנוסף, יש מערכות קבצים וירטואליות כמו procfs (שלמדנו עליה ב-5.9), sysfs, tmpfs, ו-devtmpfs. כל אחת מהן עובדת אחרת מבפנים, ובכל זאת - מנקודת מבט של המשתמש, כולן נראות אותו דבר. אתם עושים open, read, write, close - ולא אכפת לכם אם הקובץ יושב על ext4 או על nfs.

איך זה עובד? התשובה היא VFS - ה-Virtual File System. זו שכבת הפשטה (abstraction layer) בתוך הקרנל שמספקת interface אחיד לכל מערכות הקבצים. היא מה שהופך את הפילוסופיה של "הכל הוא קובץ" ממשפט נחמד למציאות טכנית.


ארבעת האובייקטים המרכזיים של VFS

ה-VFS מגדיר ארבעה מבנים (structs) מרכזיים. ביחד, הם מייצגים את כל מה שקשור לקבצים ומערכות קבצים:

superblock - גוש-על

ה-struct שנקרא super_block מייצג מערכת קבצים מותקנת (mounted filesystem). כשאתם עושים mount /dev/sda1 /mnt, הקרנל יוצר struct super_block חדש.

struct super_block {
    struct list_head s_list;           // list of all superblocks
    dev_t s_dev;                       // device identifier
    unsigned long s_blocksize;         // block size (e.g. 4096)
    struct file_system_type *s_type;   // filesystem type (ext4, xfs...)
    const struct super_operations *s_op; // operations on the superblock
    struct dentry *s_root;             // the dentry of the root directory
    // ...
};

ה-superblock מכיל מידע גלובלי על מערכת הקבצים: גודל בלוק, סוג מערכת הקבצים, מגבלות, ומצביעים לפעולות (כמו סנכרון לדיסק, יצירת inode חדש, וכו').

inode - צומת מידע

ה-struct שנקרא inode מייצג קובץ או תיקייה על הדיסק. לכל קובץ במערכת יש inode אחד. שימו לב לנקודה קריטית: ה-inode לא מכיל את שם הקובץ! הוא מכיל את כל שאר המטא-דאטה.

struct inode {
    umode_t i_mode;                    // file type + permissions (rwxrwxrwx)
    kuid_t i_uid;                      // owner (user id)
    kgid_t i_gid;                      // group (group id)
    unsigned long i_ino;               // inode number (unique within the filesystem)
    loff_t i_size;                     // file size in bytes
    struct timespec64 i_atime;         // last access time
    struct timespec64 i_mtime;         // last modification time
    struct timespec64 i_ctime;         // last metadata change time
    const struct inode_operations *i_op;  // operations on inode
    const struct file_operations *i_fop;  // operations on files (default)
    struct super_block *i_sb;          // pointer to the superblock
    struct address_space *i_mapping;   // mapping to the page cache
    atomic_t i_count;                  // reference count
    // ...
};

כמה נקודות חשובות:

  • i_mode - מכיל גם את סוג הקובץ (רגיל, תיקייה, symlink, pipe, socket, device) וגם את ההרשאות.
  • i_ino - מספר ה-inode. ייחודי בתוך מערכת קבצים אחת.
  • i_mapping - קישור ל-page cache. כשקוראים את תוכן הקובץ, הpages נשמרים כאן.
  • i_op ו-i_fop - מצביעים לטבלאות פונקציות שמממשות את הפעולות על הinode. כל מערכת קבצים מספקת מימוש משלה.

dentry - רשומת תיקייה

ה-struct שנקרא dentry (קיצור של directory entry) הוא הקשר בין שם לinode. הוא מייצג רכיב בודד בנתיב - למשל, בנתיב /home/user/file.txt יש ארבעה dentries: אחד ל-/, אחד ל-home, אחד ל-user, ואחד ל-file.txt.

struct dentry {
    struct dentry *d_parent;           // pointer to the parent directory's dentry
    struct qstr d_name;                // file/directory name
    struct inode *d_inode;             // pointer to the inode
    const struct dentry_operations *d_op; // operations on dentry
    struct super_block *d_sb;          // pointer to the superblock
    struct list_head d_child;          // list of siblings (dentries in the same directory)
    struct list_head d_subdirs;        // list of children (dentries inside this directory)
    // ...
};

למה צריך הפרדה בין dentry לinode? בגלל hard links! שני dentries שונים (שמות שונים) יכולים להצביע על אותו inode. כשיוצרים hard link, נוצר dentry חדש שמצביע על inode קיים. מבחינת ה-inode, הוא לא יודע ולא אכפת לו כמה שמות יש לו.

מטמון ה-dentry - dcache

הקרנל שומר dentries בcache (cache) שנקרא dcache. זה אחד הcaches הכי חשובים בקרנל. כשהקרנל צריך לפתור נתיב, הוא בודק קודם ב-dcache. אם ה-dentry כבר שם - אין צורך לקרוא מהדיסק.

file - קובץ פתוח

ה-struct שנקרא file מייצג קובץ פתוח. הוא נוצר כשprocess קורא ל-open() ונמחק כש-close() נקרא (או כשהprocess מת). שימו לב - inode מייצג קובץ על הדיסק. file מייצג קובץ פתוח ע"י process מסוים.

struct file {
    struct path f_path;                // the path (including dentry and mount)
    const struct file_operations *f_op;   // operations on the file
    atomic_long_t f_count;             // reference count
    unsigned int f_flags;              // flags (O_RDONLY, O_WRONLY, O_NONBLOCK...)
    fmode_t f_mode;                    // mode (read/write)
    loff_t f_pos;                      // current position in the file
    struct fown_struct f_owner;        // owner (for signals)
    void *private_data;                // the driver's private data
    // ...
};

השדה הכי חשוב כאן הוא f_op - מצביע ל-file_operations. בואו נעמיק בזה.


file_operations - הלב של VFS

הstruct שנקרא file_operations הוא המפתח להבנת VFS. כל מערכת קבצים, כל driver, כל pseudo-filesystem ממלא struct כזה עם מצביעים לפונקציות שלו:

struct file_operations {
    struct module *owner;
    loff_t (*llseek)(struct file *, loff_t, int);
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    int (*mmap)(struct file *, struct vm_area_struct *);
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
    unsigned int (*poll)(struct file *, struct poll_table_struct *);
    ssize_t (*read_iter)(struct kiocb *, struct iov_iter *);
    ssize_t (*write_iter)(struct kiocb *, struct iov_iter *);
    // ... and many more
};

כשאתם קוראים ל-syscall read(), הנה מה שקורה:

  1. הקרנל מקבל את ה-file descriptor.
  2. הוא מוצא את ה-struct file המתאים (דרך טבלת ה-file descriptors של הprocess, שלמדנו עליה ב-5.5).
  3. הוא קורא ל-file->f_op->read().
  4. הפונקציה שרצה היא הread של מערכת הקבצים הספציפית - ext4, procfs, או מה שלא יהיה.

זה בעצם פולימורפיזם בC - אותו interface, מימושים שונים. כשקוראים read() על קובץ ב-ext4, רץ הread של ext4. כשקוראים read() על /proc/cpuinfo, רץ הread של procfs שמייצר את התוכן דינמית. מנקודת מבט של הprocess, אין הבדל.

דוגמה קונקרטית - ככה procfs מגדיר file_operations עבור /proc/meminfo:

static const struct file_operations meminfo_proc_fops = {
    .open    = meminfo_proc_open,
    .read    = seq_read,
    .llseek  = seq_lseek,
    .release = single_release,
};

כשאתם עושים cat /proc/meminfo, הread שרץ הוא seq_read, שקורא ל-meminfo_proc_show שמייצר את התוכן דינמית ממצב הזכרון של הקרנל.


פתרון נתיבים - pathname resolution

כשprocess קורא ל-open("/home/user/file.txt", O_RDONLY), הקרנל צריך לפתור את הנתיב ולמצוא את ה-inode של הקובץ. התהליך הזה נקרא pathname resolution, וזה אחד הדברים שVFS עושה הכי הרבה.

הנה הזרימה:

  1. מתחילים ב-dentry של השורש (/). כל מערכת קבצים מותקנת (mounted) יש לה dentry שורש.

  2. מחפשים "home" בdcache - אם נמצא, מעולה. אם לא, הקרנל קורא את תיקיית השורש מהדיסק ומחפש entry בשם "home". אם נמצא, יוצרים dentry ו-inode חדשים ומכניסים אותם לdcache.

  3. מחפשים "user" ב-"home" - אותו תהליך. בודקים dcache, אם לא נמצא - קוראים מהדיסק.

  4. מחפשים "file.txt" ב-"user" - אותו תהליך.

  5. מצאנו את ה-inode - עכשיו אפשר ליצור struct file ולהחזיר file descriptor.

בכל שלב, הקרנל בודק גם הרשאות - האם לprocess יש הרשאת execute (שפירושה "חיפוש" עבור תיקיות) על כל תיקייה בנתיב.

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


מטמון הדפים - page cache

כשקוראים תוכן מקובץ, הוא לא נקרא ישירות מהדיסק אל הbuffer של הprocess. הנתונים עוברים דרך ה-page cache - הcache ברמת pages ש-VFS מנהל.

איך זה עובד

  1. הprocess קורא ל-read() על קובץ.
  2. הקרנל בודק אם הpage המבוקש כבר ב-page cache.
  3. אם כן - הנתונים מועתקים מה-cache לbuffer של הprocess. לא צריך לגשת לדיסק.
  4. אם לא - הקרנל קורא את הpage מהדיסק, שומר אותו ב-page cache, ואז מעתיק לprocess.

כתיבה עובדת באופן דומה: הנתונים נכתבים ל-page cache, והpage מסומן כ-dirty. בהמשך, הpage נכתב חזרה לדיסק (על ידי pdflush/writeback threads).

למה לינוקס "משתמש בהרבה RAM"

אם הרצתם free או top ושמתם לב שכמעט כל הRAM תפוס - זה בגלל ה-page cache. לינוקס משתמש בכל הRAM הפנוי לcache קבצים. זה לא בזבוז - זה אופטימיזציה. ברגע שprocess צריך זכרון, הקרנל זורק pages מה-page cache כדי לפנות מקום.

אפשר לראות את מצב ה-page cache:

cat /proc/meminfo | grep -E "Cached|Buffers"

התקנת מערכות קבצים - mount

מערכת קבצים לא פשוט "קיימת" - צריך להתקין (mount) אותה לנקודה בעץ התיקיות. הפקודה mount יוצרת קשר בין מערכת קבצים למיקום בעץ התיקיות.

כשהקרנל פותר נתיב ומגיע לנקודת mount, הוא "קופץ" ממערכת קבצים אחת לאחרת. למשל, אם /proc מותקן כ-procfs, אז כשפותרים את הנתיב /proc/cpuinfo, הקרנל מגיע ל-/proc, מזהה שזו נקודת mount, ועובר ל-superblock של procfs.

מרחבי שמות של mount - mount namespaces

בלינוקס מודרני, כל process (או קבוצת processes) יכול לראות עץ mount שונה. זה נקרא mount namespace. זה הבסיס לcontainers - כל container רואה מערכת קבצים שונה, עם מערכות קבצים שונות מותקנות בנקודות שונות.


סוגי מערכות קבצים חשובים

בלינוקס יש הרבה מערכות קבצים. הנה החשובות ביותר:

ext4

מערכת הקבצים הסטנדרטית בלינוקס. זו מערכת קבצים "אמיתית" שכותבת נתונים לדיסק. היא תומכת ב-journaling (כתיבת log של פעולות לפני ביצוען, כדי לאפשר recovery אחרי קריסה), extents (ייצוג יעיל של בלוקים רציפים), וגדלים של עד 1 exabyte.

procfs - מערכת הקבצים proc

למדנו על procfs בפרק 5.9. היא מותקנת ב-/proc ומספקת מידע על תהליכים ועל הקרנל. הקבצים בה לא קיימים באמת על הדיסק - הם נוצרים דינמית ע"י הקרנל. כשקוראים ל-read() על /proc/meminfo, הפונקציה read שרשומה ב-file_operations מייצרת את התוכן מתוך מבני נתונים פנימיים של הקרנל.

sysfs - מערכת הקבצים sys

מותקנת ב-/sys. דומה ל-procfs, אבל מאורגנת סביב חומרה ודרייברים. כאן תמצאו מידע על התקנים, באסים, דרייברים, ומחלקות (classes) של התקנים.

tmpfs

מערכת קבצים שחיה בRAM (ובswap). משמשת עבור /tmp, /dev/shm, ולפעמים /run. מאוד מהירה כי אין גישה לדיסק, אבל הנתונים נמחקים באתחול מחדש.

devtmpfs

מותקנת ב-/dev. מכילה את קבצי ההתקנים (device files) - character devices ו-block devices. כשאתם ניגשים ל-/dev/sda או /dev/null, אתם ניגשים לקבצי התקן שה-VFS מנתב אותם לדרייבר המתאים דרך file_operations ייעודיים.


לראות את כל מערכות הקבצים

אפשר לראות אילו סוגי מערכות קבצים הקרנל תומך בהם:

cat /proc/filesystems

ואפשר לראות מה מותקן כרגע:

mount
# or in a more readable form:
findmnt

סיכום

ה-VFS הוא שכבת ההפשטה שמאחדת את כל מערכות הקבצים בלינוקס תחת interface אחד. ראינו את ארבעת המבנים המרכזיים:

  • superblock - מייצג מערכת קבצים מותקנת.
  • inode - מייצג קובץ על הדיסק (מטא-דאטה, בלי שם!).
  • dentry - מקשר שם לinode, נשמר בdcache לחיפוש מהיר.
  • file - מייצג קובץ פתוח, מכיל מיקום נוכחי ומצביע ל-file_operations.

ה-file_operations הוא הלב של VFS - טבלת פונקציות שכל מערכת קבצים ממלאת בפונקציות שלה. זה מה שמאפשר ל-read() לעבוד אותו דבר על ext4, על procfs, ועל pipe.

למדנו גם על פתרון נתיבים, page cache, mount, ועל סוגי מערכות הקבצים השונים בלינוקס. כל הידע הזה מתחבר למה שלמדנו ב-5.5 (מתארי קבצים), ב-5.9 (procfs), וב-6.4 (ניהול זכרון - כי ה-page cache הוא חלק מניהול הזכרון).