Skip to content

Latest commit

 

History

History
983 lines (648 loc) · 46.4 KB

File metadata and controls

983 lines (648 loc) · 46.4 KB

1. קלט/פלט של תהליכים

1.1. מתאר קובץ - FD (file descriptor)

  • פעולות קלט/פלט של תהליך ב-Linux מבוצעות דרך "קבצים":

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

    • למשל, זה המספר המוחזר מקריאת המערכת open.

1.2. טבלת מתארי הקבצים - FDT (file descriptor table)

  • המספר FD הוא אינדקס בטבלת מתארי הקבצים, FDT.
    • הערה: לכל תהליך FDT משלו! תהליך יכול לגשת אליה מהשדה files->fd בטבלה PCB.
    • כל כניסה ב-FDT מצביעה על אובייקט של קובץ פתוח (file object), שמכיל שדות כמו seek pointer, מצביע למיקום הנוכחי בקובץ.

הערה: כל הקבצים הפתוחים של כל התהליכים במערכת נשמרים בטבלה המנוהלת ע"י הגרעין ונקראת GFDT (global file descriptor table).

1.3. ערוצי הקלט/פלט הסטנדרטיים

ערכי ה-FD הבאים מקושרים להתקנים הבאים כברירת מחדל:

  • 0 - ערוץ הקלט הסטנדרטי (STDIN), בדרך-כלל מקושר למקלדת.
  • 1 - ערוץ הפלט הסטנדרטי (STDOUT), בדרך-כלל מקושר למסך.
  • 2 - ערוץ השגיאות הסטנדרטי (STDERR), בדרך-כלל גם הוא מקושר למסך.

1.4. קריאות מערכת

1.4.1. קריאת המערכת open

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *path, int flags);
int open(const char *path, int flags, mode_t mode);
  • פעולה: פותחת קובץ/התקן לפי path עם תכונות הגישה ב-flags והרשאות יצירה לפי mode.
  • הגדרה מלאה: int open(const char *path, int flags, ...).
    • mode נדרש רק כאשר flags כולל O_CREAT, ובשאר המקרים הוא מתעלם ממנו.
  • ערך מוחזר: בהצלחה מוחזר FD חדש. מוקצה האינדקס הפנוי הנמוך ביותר ב-FDT. בכישלון מוחזר -1 ונקבע errno.

פרמטרים:

  • path - מסלול לקובץ/התקן לפתיחה.

    • לדוגמה: "file1" (יחסי) או "/usr/hw/file1" (אבסולוטי).
  • flags - חייב לכלול אחת מהאפשרויות הבאות:

    • O_RDONLY - קריאה בלבד.
    • O_WRONLY - כתיבה בלבד.
    • O_RDWR - קריאה וכתיבה.
    • ניתן להוסיף דגלים אופציונליים בעזרת |:
      • O_CREAT - ליצור קובץ אם אינו קיים.
      • O_APPEND - כתיבה בסוף הקובץ הקיים.
  • mode - הרשאות קובץ חדש (רלוונטי רק כש-O_CREAT קיים).

  • הרשאות: ההרשאות לקובץ חדש הן (mode & ~umask).

1.4.2. קריאת המערכת close

#include <unistd.h>

int close(int fd);
  • פעולה: סוגרת את ה-fd ומנתקת אותו מהקובץ/התקן. לאחר הסגירה לא ניתן להשתמש ב-fd.
  • ערך מוחזר: בהצלחה 0, בכישלון -1 ונקבע errno.
  • פרמטרים:
    • fd - ה-FD המיועד לסגירה.
  • הערה: ה-file object משתחרר רק כאשר מונה ההפניות שלו מגיע ל-0 (ייתכנו תהליכים/חוטים נוספים שמצביעים אליו).

1.4.3. קריאת המערכת read

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
  • פעולה: קוראת עד count בתים מהקובץ המקושר ל-fd לתוך buf.
  • מחוון קובץ: ה-seek pointer מתקדם לפי מספר הבתים שנקראו בפועל.
  • חסימה: ייתכן חסימה עד שיהיו נתונים זמינים (למשל בדיסק או ב-pipe).
  • ערך מוחזר: מספר הבתים שנקראו; 0 עבור EOF או count == 0; בכישלון -1 ונקבע errno.
  • הערות:
    • ייתכן שייקראו פחות מ-count בתים (short read).
    • משתמשים ב-ssize_t כדי לאפשר ערך שלילי בכישלון.

1.4.4. קריאת המערכת write

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);
  • פעולה: כותבת עד count בתים מ-buf לקובץ המקושר ל-fd.
  • מחוון קובץ: ה-seek pointer מתקדם לפי מספר הבתים שנכתבו בפועל.
  • חסימה: ייתכן חסימה (למשל בעת גישה לדיסק).
  • ערך מוחזר: מספר הבתים שנכתבו; 0 אם count == 0; בכישלון -1 ונקבע errno.
  • הערה: ייתכן שייכתבו פחות מ-count בתים (short write).

1.5. file objects

  • open מוסיפה כניסה חדשה במקום הפנוי הראשון ב-FDT של התהליך.
  • הכניסה מצביעה על אובייקט מטיפוס struct file.
struct file {
  atomic_t f_count;
  loff_t f_pos;
  mode_t f_mode;
  struct file_operations *f_op;
  ...
};
  • f_count - מונה הפניות לאובייקט (למשל אב ובן אחרי fork, או dup באותו תהליך). משמש לשחרור האובייקט כאשר המונה מתאפס.
  • f_pos - מחוון הקובץ (seek pointer) למיקום הקריאה/כתיבה הנוכחי.
  • f_mode - הרשאות הקובץ; נבדק לפני read/write.
  • f_op - מצביע ל-file_operations שמכיל את המימושים של open, read, write, lseek ועוד.

1.5.1. שיתוף קלט/פלט

1.5.1.1. שיתוף קלט/פלט בין חוטים

  • חוטים של אותו תהליך משתפים את אותה FDT.
  • פתיחה/סגירה של קובץ ע"י חוט אחד משפיעה על כל החוטים.
  • שימוש ב-FD משותף מחייב תיאום כדי למנוע שיבוש.
  • בלינוקס קיימים מנגנוני נעילה שונים: advisory, mandatory, leases, וכן נעילה בעזרת flock או fcntl.

1.5.1.2. שיתוף קלט/פלט בין תהליכים

  • fork מעתיקה את ה-FDT לתהליך הבן, שינויים ב-FDT לאחר מכן לא משותפים.
  • ה-file objects עצמם משותפים לאב ולבן, ולכן מחוון הקובץ (f_pos) משותף.
  • דוגמה: אם האב קורא 10 בתים ואז הבן קורא 3, הבן יקרא את 3 הבתים שאחרי ה-10 של האב.
  • כל open יוצר file object חדש, גם אם זה אותו קובץ.

1.5.1.3. שיתוף קלט/פלט לאחר execv

  • execv ודומותיה אינן משנות את ה-FDT, ולכן קבצים פתוחים נשארים פתוחים.
  • שימושי ל-input/output redirection, לדוגמה:
date > file.txt

1.5.2. שחרור file object

  • שחרור זיכרון של file object מתבצע רק לאחר שכל המפנים אליו נסגרו.
  • לכל file object יש מונה f_count שמופחת בכל close.
  • כאשר f_count מגיע ל-0, האובייקט משתחרר.

2. pipe ב-Linux

ערוץ תקשורת חד-כיווני בסדר FIFO. ה-pipes מאפשרים העברת מידע וסנכרון בין תהליכים.

  • מערכת ההפעלה מממשת pipes באמצעות "קבצים" בזיכרון, ללא שימוש בדיסק (אינם מופיעים במערכת הקבצים).
  • קיים FD לכתיבה, ו-FD לקריאה, אשר עובדים עם קריאות המערכת המוכרות: read/write.
  • המידע נשמר ב-buffers פנימיים של מערכת ההפעלה.

2.1. יצירת pipe חדש

#include <unistd.h>

int pipe(int filedes[2]);
  • פעולה: יוצרת pipe צינור חדש עם שני קצוות (קריאה וכתיבה).
  • ערך מוחזר: 0 בהצלחה, -1 בכישלון.
  • פרמטרים:
    • filedes - מערך בגודל 2 תאים:
      • filedes[0] יאחסן את ה-FD לקריאה.
      • filedes[1] יאחסן את ה-FD לכתיבה.
  • הערות: הכניסות ב-FDT הן הראשונות הפנויות.

2.2. שיתוף pipes

  • ה-pipe פרטי לתהליך ואינו נגיש לתהליכים אחרים (אלא באמצעות מנגנון fork).
  • שיתוף מתבצע בעזרת קשרי משפחה: אב יוצר pipe (תופס שתי כניסות ב-FDT), ואז קורא ל-fork. ה-FDT משתכפל, וכעת לאב ולבן יש גישה לשני צידי הצינור.
  • מכיוון שה-pipe חד-כיווני, האחד צריך לקרוא והשני לכתוב (או להפך). לכן, חשוב שכל תהליך יסגור את הקצה שאינו בשימוש אצלו.
  • משאבי ה-pipe מפונים אוטומטית כשהוא נסגר ע"י כולם (מספר המפנים יורד ל-0).

2.3. קריאה וכתיבה ל-pipe

  • מתבצעות בעזרת הקריאות read ו-write, כשה-pipe מתנהג כתור FIFO. המצביעים (של הכתיבה והקריאה) מקודמים אוטומטית מתוך הגרעין.
  • תיאום זמנים: בעת גישה מקבילית, הגרעין משתמש במנעולים פנימיים כדי למנוע race conditions.

התנהגות מערכת ההפעלה בקריאה מ-pipe:

  • מצליח לקרוא את הכמות המבוקשת אם יש מספיק נתונים.
  • קורא פחות מהכמות (short read) אם נותרו פחות נתונים זמינים כרגע.
  • קורא 0 (כלומר EOF) אם לא קיימים יותר נתונים בתור וגם כל קצוות הכתיבה (write FDs) נסגרו.
  • חוסם אם אין נתונים זמינים אבל יש לפחות קצה כתיבה אחד שעדיין פתוח.

התנהגות מערכת ההפעלה בכתיבה ל-pipe:

  • כותב מיד אם יש מקום פנוי בחוצץ ה-pipe (מוגבל לרוב ל-64KB).
  • חוסם וממתין לפינוי מקום אם חוצץ ה-pipe מלא.
  • טיפול בכישלון משמעותי: אם כל קצוות הקריאה (read FDs) נסגרו, תהליך הכותב ל-pipe יקבל אוטומטית סיגנל SIGPIPE ממערכת ההפעלה (שכברירת מחדל הורג את התהליך).

דוגמת קוד - למה חייבים לסגור קצוות מיותרים?

int main {
  int fd[2];
  int grade;

  pipe(fd);
  if (fork == 0) { // teacher (child)
    close(fd[0]);
    do {
      grade = get_random_between(0, 100);
      write(fd[1], &grade, sizeof(int));
    } while (grade != 0);
    close(fd[1]);
  } else {           // student (parent)
    close(fd[1]);
    while (read(fd[0], &grade, sizeof(int)) > 0) {
      if (grade == 100) break;
    }
    printf("Grade = %d\n", grade);
    close(fd[0]);
  }
  return 0;
}
  • תרחיש 1: מה יקרה אם המורה לא יסגור את ה-fd[0] שלו? המורה ימשיך להגריל ציונים, גם אחרי שהתלמיד מצא ציון 100, סיים את הביצוע, וסגר את קצה הקריאה שלו במערכת. כיוון שהמורה השאיר את קצה הקריאה אצלו פתוח, הוא לא יקבל SIGPIPE שישבור אותו. במקרה הרע תור הצינור יתמלא והמורה ייתקע לנצח.
  • תרחיש 2: מה יקרה אם התלמיד לא יסגור את ה-fd[1] שלו? אחרי שהמורה יגריל 0 ויסגור את ה-fd[1] העצמי שלו, התלמיד יצפה לעוד נתונים כי ערוץ הכתיבה עדיין נחשב "פתוח" בראיה הכוללת (בגלל שה-fd[1] אצל התלמיד עצמו לא נסגר!). ה-read לעולם לא יחזיר EOF והתלמיד ייתקע לנצח על פעולה חוסמת.

2.4. קריאת המערכת dup

#include <unistd.h>

int dup(int oldfd);
int dup2(int oldfd, int newfd);
  • פעולה (duplicate): משכפלת FD ומייצרת FD חדש אשר מצביע בדיוק אל אותו file object כמו ה-FD המקורי. כעת שניהם יחלקו את מחוון הקובץ (seek pointer), הרשאות ועוד.
  • ערך מוחזר: FD החדש. בכישלון מוחזר -1.
  • פרמטרים:
    • oldfd: תא ה-FD המיועד להעתקה (חייב להיות פתוח קודם).
    • newfd: (בלעדי ל-dup2) - FD הספציפי שאנחנו רוצים לדרוס ולמקם בדיוק עליו את השכפול. dup2 תסגור את newfd (אם היה פתוח) כפעולה ראשונה לפני ההעתקה. בשימוש ב-dup רגיל, הגרעין ימצא בעצמו ויתפוס את ה-FD הפנוי הנמוך ביותר בטבלה.

2.5. צינורות בעלי שם (FIFOs או named pipes)

FIFO הוא pipe בעל מזהה (שם) במערכת הקבצים. קיומו מאפשר תקשורת גם בין תהליכים ללא קשרי משפחה.

  • נגיש לכל תהליך (בהתאם להרשאות), ומופיע כקובץ מיוחד (למשל /home/user/myfifo).
  • אינו שומר נתונים על גבי הדיסק. המידע נמצא בזיכרון, כשם שמתרחש ב-anonymous pipe.
  • שימוש אופייני: תקשורת שרת-לקוח. לקוחות שונים באותה מכונה יכולים לפנות לשרת דרך שם קבוע מראש.

2.5.1. יצירת FIFO עם mkfifo

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);
  • פעולה: יוצרת ערוץ תקשורת מסוג FIFO בנתיב המבוקש.
  • ערך מוחזר: 0 בהצלחה, -1 בכישלון.
  • פרמטרים:
    • pathname - שם ה-FIFO (והנתיב אליו במערכת הקבצים).
    • mode - הרשאות הגישה.

2.5.2. תקשורת באמצעות FIFO

  • שלא כמו pipe שיש לו שני קצוות בעלי FD עצמאי (קריאה/כתיבה), ל-FIFO ניגשים באמצעות קריאת open, כקובץ לכל דבר.
  • ניתן לבצע אליו קריאה וכתיבה (באמצעות read / write). הוא יכול לתמוך בתקשורת דו-כיוונית אך מבוסס על FD אחד אחרי הפתיחה.
  • חסימות בפתיחה:
    • תהליך הפותח לקריאה בלבד (O_RDONLY) ייחסם עד שתהליך אחר יפתח אותו לכתיבה (ולהפך).
    • פתיחת ה-FIFO לקריאה-וכתיבה (O_RDWR) אינה חוסמת.
  • חוקי ה-read וה-write עליו פועלים בדיוק כמו ב-pipe רגיל.

3. מודולים בגרעין

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

  • מייתרים את הצורך בקימפול מחדש של הגרעין או אתחול המחשב בכל הוספת תכונה חדשה.
  • שימוש מוביל: מנהלי התקנים (דרייברים) לחומרה. למשל:
    • התקני תווים (Character Devices): מקלדת, עכבר, מדפסת ועוד.
    • התקני בלוקים (Block Devices): דיסק קשיח, Disk-on-Key ועוד.

3.1. רצף האתחול ב-Linux

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

3.1.1. BIOS (Basic Input/Output System)

הגישה לחומרה מיד עם הדלקת המחשב.

  • ה-BIOS צרוב בלוח האם ורץ אוטומטית. הוא מזהה ומוודא כל חומרה בסיסית.
  • סורק התקנים ומחפש התקן אתחול חוקי (Bootable Device).
  • ברגע שנמצא התקן מתאים, הוא מעתיק את הסקטור הראשון המסוים שלו לזיכרון הראשי.

3.1.2. MBR (Master Boot Record)

  • הסקטור הראשון (512 בתים) על חומרת האחסון.
  • מכיל קוד אסמבלי בסיסי שתפקידו היחיד הוא לאתר ולהפעיל את מנהל האתחול המלא (הגדול לרוב מ-512 בתים ולכן ממוקם בנפרד בדיסק).

3.1.3. מנהל האתחול: GRUB

  • מציג תפריט לבחירת מערכת ההפעלה (או גרסת הגרעין) לטעינה. יכולת קריטית לחזרה לגרעין יציב (vanilla) אם התרחשה קריסה בגרעין מותאם אישית (custom).
  • GRUB טוען לזיכרון הראשי את תמונת הגרעין הדחוסה (לרוב קרויה bzImage או vmlinuz) ולצידה "מיני-מערכת קבצים" בשם initramfs (או initrd).

3.1.4. טעינת הגרעין ומערכת הקבצים האמיתית

  • הגרעין מתחיל לרוץ ופורס את עצמו. הוא אינו קורא ישירות ליחידות הדיסק בשלב זה.
  • מערכת הקבצים הראשונית (initramfs): מספקת לגרעין סביבה המכילה אך ורק את המודולים (הדרייברים) הנחוצים לגישה למערכת הקבצים המלאה (כמו דרייברים לבקר הדיסק ולרשת).
  • הגרעין מריץ סקריפט /init מתוך ה-initramfs. הוא טוען את המודולים הנדרשים, ומעגן (mount) את מערכת הקבצים האמיתית במקום מערכת הקבצים הראשונית.
  • לסיום, תהליך העבודה מועבר למערכת ההפעלה המלאה באמצעות קריאה לתהליך /sbin/init (במערכות מודרניות, לרוב סמליל ל-systemd).

הערה: בזמן האתחול נזרקות הודעות מערכת רבות למסך באופן מהיר מאד. ניתן לקרוא ולחקור אותן לאחר שהמערכת עולה ע"י שימוש בפקודה dmesg.

3.2. מה זה מודול?

מודול הוא ספרייה משותפת הניתנת לטעינה ופריקה דינמית בזמן ריצה, ופועלת במצב גרעין (CPL=0).

  • פקודות ניהול בסיסיות (דורשות הרשאות root):
    • insmod - טעינת מודול.
    • rmmod - פריקת מודול.
    • lsmod - הצגת רשימת המודולים הפעילים.

3.2.1. שימושים מרכזיים

הרחבת פונקציונליות הגרעין ללא הגבלה:

  • הוספת מנהלי התקנים (Drivers) כדוגמת התקני תווים (מקלדת), התקני בלוקים (דיסק) והתקני רשת.
  • הוספת תמיכה במערכות קבצים חדשות.
  • הוספה או שינוי של קריאות מערכת קימות.

3.2.2. יתרונות

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

3.2.3. חסרונות ובעיות אבטחה

  • המודול רץ במצב גרעין ולכן נגיש לכל מבני הנתונים בו. כל באג (כגון חוסר איתחול, buffer overrun) עלול להפיל את המערכת.
  • בשל כך, לינוקס מגבילה טעינת מודולים למשתמשי root בלבד.
  • חשוב להימנע מחורי אבטחה במודול למשל ע"י הקפדה על אתחול משתנים, בדיקת תקינות קלט משתמש כדי למנוע buffer overrun וכו'.

3.2.4. דוגמת קוד למודול

#include <linux/module.h> // חובה בכל מודול
#include <linux/kernel.h> // מאפשר שימוש ב-printk

int init_module(void) {
  printk("Hello World!\n"); 
  return 0;
}

void cleanup_module(void) {
  printk("Goodbye cruel world!\n");
}

הערה חשובה (printk מול printf): קוד גרעין אינו יכול להשתמש בקריאות גישה לספריות מעטפת ה-User Mode (כגון libc), כיוון שתהליך הגרעין מוקם עוד לפני שספריות אלו זמינות. לשם כך, משתמשים בפונקציות שמספק הגרעין בעצמו, למשל printk.

3.3. בניית מודול

יש ליצור קובץ Makefile על מנת לקמפל את המודול:

obj-m += hello.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
  • חובת תאימות: גרסת לינוקס שעליה נבנה המודול חייבת להיות זהה לחלוטין לגרסה שבה הוא ירוץ. חוסר תאימות עלול לגרום לקריסות ושגיאות בזמן ריצה.

3.4. טעינת מודול

make
sudo insmod ./hello.ko       # פקודה זו מפעילה וטוענת את המודול (מחייבת root)
lsmod                        # מוודא שהמודול נטען ומציג רשימת מודולים פעילים
sudo rmmod hello             # פורק את המודול מהזיכרון (מחייב root)
  • הרשאות: פקודות הטעינה והפריקה בסביבת האמת דורשות הרשאות root.
  • modprobe לעומת insmod: בעוד insmod טוען מודול ספציפי בצורה "טיפשה", הפקודה modprobe חכמה יותר - היא מבצעת חיפוש בתיקיית /lib/modules/ וטוענת אוטומטית את כל תלויותיו של מסלול המודול אם הוא דורש מודולים מקדימים.

3.5. העברת פרמטרים למודול

מודולים יכולים לקבל משתנים מהמשתמש בזמן הטעינה (בדומה לערכי argv). ההגדרות מתבצעות באזור הגלובלי של הקוד (מחוץ לפונקציה) באמצעות המאקרו module_param. יש להגדיר לקריאות ערך ברירת מחדל לאתחול.

#include <linux/moduleparam.h>

int iValue = 0;   // ערך ברירת המחדל אם לא הוזן
char *sValue;

// חתימה: module_param(variable, type, permissions);
module_param(iValue, int, S_IRUGO);
module_param(sValue, charp, S_IRUGO);

// מערך:
int iArray[4];
int len;
module_param_array(iArray, int, &len, S_IRUGO);
  • סוגים נפוצים נתמכים: byte, short, int, charp (מחרוזת) ו-bool.
  • טיפ להעברת פרמטר "אופציונלי": הגדרת מערך בגודל 0-1.
  • עזר - הוספת מידע למשתמש מתאפשרת בעזרת מאקרו נוסף: MODULE_PARM_DESC. כך כלי ניהול משתמש כמו modinfo יחשפו בקלות מה עושה כל פרמטר.

צורת ההעברה בטעינה ממערכת ההפעלה:

sudo insmod ./params.ko iValue=3 sValue="hello"

3.6. גישה לנתוני גרעין

כאשר מודול טוען ספריות ליבה מתאימות באמצעות #include, נחשפת אליו הגישה המלאה למבני הנתונים של גרעין המערכת:

#include <linux/sched.h>
 
int init_module(void) {
  printk("The process is \"%s\" (pid %d)\n", current->comm, current->pid); 
  return 0;
}
  • התהליך שמריץ את הטעינה: בדוגמה מעלה שדה ה-comm מצביע לשם התוכנית של התהליך הנוכחי (current). התהליך הטוען את פקודת הביצוע אל תוך הליבה הוא insmod בעצמו, ולכן הפלט שיתקבל: The process is "insmod" (pid 21676).

3.7. התקני תווים

3.7.1. התקנים ודרייברים

  • התקנים (devices): מייצגים חומרה או רכיב וירטואלי, ומופיעים עקרונית כקבצים מיוחדים (לרוב בתיקיה /dev).
    • המשתמש מתקשר מול ההתקן בקריאות VFS רגילות: open, read, write.
    • לדוגמה: כתיבה לקובץ /dev/lp0 שולחת מידע למדפסת.
  • דרייבר (driver): שכבת תוכנה החוצצת בין האפליקציות להתקן:
    • רץ תחת הרשאות הגרעין.
    • ממפה את קריאות הקלט/פלט לפעולות ספציפיות על החומרה או הרכיב.
    • הפרדת מכניזם ומדיניות: הדרייבר עצמו מממש מכניזם (הגישה הטכנית, "מה אפשר לעשות" - איך לשלוט בבקר דיסקט). מערכת ההפעלה או יישומי המשתמש עוטפים במדיניות ("מי יכול או מתי ניגשים"). רצוי לשמור את המדיניות באזור המשתמש (User Mode).

3.7.2. חלוקת ההתקנים (Block לעומת Character)

  • התקני תווים (character devices):
    • ניגשים אליהם כרצף בתים, בדרך כלל ניתן לגשת להתקן תווים רק באופן סדרתי (ולא אקראי).
    • אין יכולות התמצאות מהירה במיקום בתוך ההתקן.
    • דוגמאות: מקלדת, מסך, עכבר מחשב.
  • התקני בלוקים (block devices):
    • ניגשים למידע שלהם רק בכפולות של בלוק (למשל 512 בתים).
    • הגישה אקראית ומאפשרת שיטוט באמצעות פוינטרים.
    • לינוקס מוסיפה שכבה נוספת (page cache) ומאפשרת לקרוא מהתקני בלוקים גם בתים בודדים.
    • דוגמאות: הארד-דיסק, דיסק און קי.

3.7.2.1. זיהוי התקני תווים

כל התקן תווים מאופיין בצמד מספרים:

  1. מספר ראשי (Major number): מזהה את הדרייבר האחראי על ההתקן - משמש את open לאיתור הדרייבר הנכון בטבלת הגרעין.
  2. מספר משני (Minor number): מזהה את ההתקן הספציפי המקושר לאותו דרייבר (יכולים להיות מספר התקנים, למשל מספר עכברי מחשב, המנוהלים ע"י אותו דרייבר).

שימוש ב-ls -l /dev יציג התקני תווים עם האות 'c':

crw-rw-rw- 1 root root 1,  3 Aug 31 10:33  null
crw------- 1 root root 10, 1 May 12 10:33  psaux
crw------- 1 root tty  4,  1 May 12 10:33  tty1
crw-rw-rw- 1 root root 1,  5 Aug 31 10:33  zero
  • ההתקנים null ו-zero מנוהלים על ידי אותו דרייבר (Major 1), אך מובדלים במספר המשני (3 לעומת 5).

3.7.2.2. התקני תווים פיקטיביים

לינוקס מספקת התקני תווים וירטואליים שאינם מחוברים לחומרה פיזית (בעלי Major מספר 1):

קובץ התקן read write
/dev/null מחזירה מיד EOF (end of file) מצליחה ולא עושה דבר (המידע שנשלח נזרק)
/dev/zero מחזירה רצף אפסים באורך המבוקש מצליחה ולא עושה דבר (המידע שנשלח נזרק)
/dev/full מחזירה רצף אפסים באורך המבוקש מחזירה מיד `ENOSPC (no space left on device)
/dev/random מחזירה רצף בתים אקראי שנוצר בזמן הריצה מתוך "רעש" כותבת לרצף הבתים האקראי
/dev/urandom מחזירה רצף בתים אקראי שנוצר בזמן הריצה מתוך "רעש" כותבת לרצף הבתים האקראי
/dev/arandom מחזירה רצף בתים אקראי שנוצר בזמן הריצה מתוך "רעש" כותבת לרצף הבתים האקראי

3.7.2.3. יצירת ומחיקת התקן חדש

בדרך כלל מערכת ההפעלה מייצרת אוטומטית התקנים עבורנו כשהחומרה מזוהה (באמצעות תהליך מיוחד בשם udev). אולם, ניתן ליצור התקן ידנית עם התוכנית mknod (שממומשת באמצעות קריאת המערכת mknod):

mknod <NAME> <TYPE> <MAJOR> <MINOR>
  • פרמטרים:
    • NAME: מסלול הקובץ ונקודת העיגון.
    • TYPE: סוג - c (תווים) או b (בלוקים).
    • MAJOR: המספר הראשי.
    • MINOR: המספר המשני.

דוגמה:

sudo mknod /dev/myDev c 254 0
  • הפקודה תיצור קובץ בשם /dev/myDev , המייצג התקן תווים, המנוהל ע"י הדרייבר הרשום עם מספר ראשי 254. המספר המשני של ההתקן הוא 0.

  • התקנים חדשים נוצרים כברירת מחדל עם הרשאות כתיבה ליוצר ההתקן (לרוב root) והרשאות קריאה לשאר המשתמשים.

    • במידת הצורך ניתן לשנות את הרשאות אלה לאחר יצירת ההתקן.
  • הסרת ההתקן באופן דומה למחיקת קובץ רגיל:

sudo rm /dev/myDev # מחיקה

3.8. מנהלי התקנים (דרייברים)

3.8.1. תזכורת - מבנה struct file

קריאת open מקצה כניסה חדשה בטבלת מתארי הקבצים (FDT), המצביעה בסופו של דבר לאובייקט מסוג struct file:

struct file {
  loff_t  f_pos;                 // מצביע למיקום הקריאה/הכתיבה הנוכחי.
  void *private_data;            // שדה (אתחול ל-NULL) בו הדרייבר יכול לשמור מידע פנימי שלו.
  struct file_operations *f_op;  // אובייקט הדרייבר עצמו.
  // ... רכיבים אחרים ...
};

3.8.2. מערך הפעולות file_operations

השדה f_op במסגרת אובייקט הקובץ מגדיר מה בפועל מתבצע כאשר המשתמש קורא לקריאות מערכת כמו read ו-write מול ההתקן.

  • מדובר במבנה נתונים המכיל מערך של מצביעים לפונקציות (Function Pointers) המממשות את אותן יכולות.
  • ניתן להתייחס לכך כ-"ממשק מונחה עצמים": הקובץ/ההתקן הוא האובייקט, ומערך file_operations הוא אוסף המתודות שלו.
  • מצביע NULL באחת מהפונקציות הללו מראה לרוב שאין תמיכה בפעולה (או שישנה פעולת ברירת מחדל). את הפונקציות הללו מממש הדרייבר עצמו במסגרת קוד הגרעין.

3.8.3. פונקציות עיקריות במבנה file_operations

הערה: חתימות הפונקציות הפנימיות בדרייבר שונות מהחתימות החיצוניות של קריאות המערכת שהן חשופות למשתמש הכללי.

  • open: אם מוגדרת כ-NULL, קריאת open על ההתקן תמיד תצליח ללא פעולת המשך מותאמת.
int (*open)(struct inode *, struct file *);
  • release: הפונקציה המופעלת בשחרור ההתקן (מופעלת רק כאשר מונה ההפניות, כגון מירושות fork, מתאפס וכל עותקי ההתקן נסגרו).
int (*release)(struct inode *, struct file *);
  • flush: מופעלת בכל סגירת העתק כדי לנקות חוצצים ולדחוף מידע. כשהיא NULL, המערכת תתעלם מהשלב.
int (*flush)(struct file *);
  • read / write: מצביעים לפונקציות לקריאת/כתיבת בתים בהתקן.
    • במידה ומאותחלים ל-NULL, קריאות המערכת המתאימות read / write יחזירו -EINVAL.
ssize_t (*read)(struct file *, char *, size_t, loff_t *);
ssize_t (*write)(struct file *, const char *, size_t, loff_t *);
  • llseek: משנה את מחוון המיקום בקובץ ומשפיעה על הגישות הבאות (רלוונטי להתקני בלוקים שניתנים להזזה).
loff_t (*llseek)(struct file *, loff_t, int);
  • ioctl: משמש להעברת פקודות בקרה ייחודיות בהתקנים. NULL יוביל שוב לשגיאת -EINVAL.
int (*ioctl)(struct inode *, struct file *, unsigned int cmd_id, unsigned long arg);

3.8.4. קריאת המערכת ioctl

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

לשם כך נועדה קריאת המערכת (input/output control) ioctl:

#include <sys/ioctl.h>

int ioctl(int fd, int cmd, ...);
  • פרמטרים:
    • fd: מתאר קובץ להתקן פעיל, שהתקבל כתוצאה מ-open.
    • cmd: קוד פקודה/מספר סילי שהדרייבר בגרעין יזהה ומחכה לו (מועבר "AS IS" למערכת המופעלת).
    • פרמטר אופציונלי (...): ניתן לצרף מצביע למבנה מידע, מחרוזת, או סתם ערך. כיוון שהלינקר (והקומפיילר) אינו יכול לדעת מהי הדרישה בהתקן העצמאי, החובה על הפונקציה המממשת במודול הדרייבר היא לפענח ולוודא את חוקיות הערך במדויק. לרוב מתייחסים אליו כ-char *argp.
    • ערך חזרה: תלוי בכותב הדרייבר, כאשר נהוג שאם יש כישלון הקריאה תחזיר ערך שלילי כמו -1.

3.9. מימוש דרייברים באמצעות מודולים

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

3.9.1. מערך הדרייברים chrdevs

גרעין המערכת מנהל מערך גלובלי בשם chrdevs השומר את כל הדרייברים הרשומים בה.

  • האינדקס של המערך הוא למעשה המספר הראשי (Major) של הדרייבר.
  • כל תא במערך מכיל את שם הדרייבר ומצביע למבנה ה-file_operations שלו.

3.9.2. רישום דרייבר חדש register_chrdev

פונקציית גרעין (ולא קריאת מערכת!) המופעלת על ידי המודול (לרוב בפונקציית הטעינה init_module) כדי לרשום את עצמו במערכת כדרייבר פעיל:

#include <linux/fs.h>
int register_chrdev(unsigned int major, const char *name, struct file_operations *fops);

פעולות:

  1. רושמת את הטיפול במערך ה-chrdevs.
  2. מקצה לו את המספר הראשי major.
  3. מוסיפה לו רישום בקובץ הניהול בסביבת מערכת הקבצים /proc/devices.

ערך חזרה: במקרה של הצלחה יוחזר 0 או חיובי. אחרת -1 השלילי. (שימו לב: שם הפונקציה מטעה, כיוון שהיא מדברת על התקן - chrdev אך רושמת את הדרייבר של ההתקנים הללו).

3.9.3. הקצאת מספר ראשי

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

  • הקצאה סטטית: היזם מזין מספר קבוע בקוד. סכנה של תפיסת המספר על ידי התקן פופולרי קיים (פירוט שמור ב-Documentation/devices.txt).
  • הקצאה דינמית (מומלצת): העברת הפרמטר 0 כערך ה-major. הליבה תחפש מספר פנוי מתחת ל-512 ותחזיר אותו כערך החזרה ב-register_chrdev.

ניתן לצפות ברשימת הדרייברים הרשומים והmajor שלהם על ידי cat /proc/devices.

3.9.4. הסרת דרייבר רשום unregister_chrdev

בשחרור המודול (למשל על ידי rmmod), יש לפנות את הרשומות שהדרייבר תפס על מנת למנוע קריסות (kernel panic) מוגדרות:

int unregister_chrdev(unsigned int major, const char *name);
  • מסירה את הרישום מ-chrdevs ומשחררת את ה-Major. פעולה זו אינה מוחקת פיזית התקני /dev/ שנוצרו על יתר הדרייברים.
  • בעיה אפשרית: אם המודול מוסר בלי קריאה לפונקציה, כיוון שקרתה שגיאה למשל, ייתכן שתהליך אחר ינסה לרשום מודול עם אותו Major בגלל שזה נראה כאילו ההעדפה נשמרה.

3.10. רצף השלבים בקריאת המערכת open

נסכם איך sys_open פועלת מאחורי הקלעים ברגע שתהליך מריץ על קובץ fd = open("/dev/mydev", O_RDONLY):

  1. איתור פיזי: מערכת ההפעלה ניגשת למערכת הקבצים ומאתרת עבור ההתקן המבוקש את רשומת המידע שלו (הנקראת inode). הרשומה נושאת את שדה ה-i_rdev המכיל את המספר הראשי ($M$) והמשני ($m$).
  2. בדיקת רישום: המערכת פונה לתא $M$ במערך chrdevs. במידה ושם מצטייר NULL, משמע הדרייבר חסר, המערכת תיזרק החוצה עם שגיאת ENODEV.
  3. הקצאה ב-file_operations וקשר: מוקצה אובייקט struct file חדש (filp), ומערכת מתאימה אליו כניסה חדשה בטבלת ה-FDT של התהליך. בשלב זה, מוצמד לפוינטר הפנימי filp->f_op המצביע של chrdevs[M]->f_op.
  4. קריאה מותאמת: הליבה בודקת האם קיימת בנייה למתודת ה-open במערך שירשה (filp->f_op->open). אם קיימת, היא מזומנת ומועברים אליה ה-inode וה-filp כפרמטרים.
  5. הדרייבר דורס או בונה נתונים לפי רצונו, ולבסוף הפונקציה משיבה לתהליך המקור את ה-FD - האינדקס החדש שצלח ב-FDT.

3.10.1. זיהוי התקנים ספציפיים בפונקציית ה-open בדרייבר

int (*open)(struct inode *inodep, struct file *filp);

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

  • שליפת המספר ראשי: MAJOR(inodep->i_rdev)
  • שליפת המספר המשני: MINOR(inodep->i_rdev)

4. חוטים לעומת תהליכים

בעוד שתהליך (Process) הוא מופע ריצה מבודד של תוכנית, חוט (Thread) הוא יחידת ביצוע קלה יותר שיכולה לרוץ במקביל בתוך אותו תהליך ולחלוק איתו משאבים.

4.1. רמת השיתוף

ההבדל המרכזי בין ריבוי תהליכים (Multiprocessing) לריבוי חוטים (Multithreading) הוא רמת השיתוף:

  • תהליכים: לא חולקים כלום. לכל תהליך יש מרחב כתובות משלו, והם מבודדים לחלוטין ע"י מערכת ההפעלה.
  • חוטים: חולקים (כמעט) הכל. חוטים באותו תהליך משתפים את מרחב הכתובות בזיכרון.
משאב ייחודי לתהליך? ייחודי לחוט?
אוגרים (Registers) ומונה פקודות (PC) כן כן
מחסנית ביצוע (Execution Stack) כן כן
מרחב כתובות בזיכרון (Code, Data, Heap) כן לא (משותף)
קבצים פתוחים (FDT) ומיקום בקובץ כן לא (משותף)
הרשאות משתמש ותיקיית עבודה כן לא (משותף)

4.2. קריאת המערכת clone

ב-Linux, ברמת הגרעין, אין באמת הבדל בין "תהליך" ל-"חוט". שניהם מיוצגים ע"י אותו מבנה נתונים - task_struct, ונקראים "משימות".

  • קריאות המערכת המוכרות לנו, fork ליצירת תהליך ו-pthread_create ליצירת חוט, הן למעשה רק פונקציות מעטפת (Wrappers) לאותה קריאת מערכת: clone.
  • ההבדל ביניהן הוא בדגלים שמועברים ל-clone. לדוגמה, אם מעבירים את הדגל CLONE_VM, האב והבן ישתפו את אותו מרחב זיכרון (התנהגות של חוט). אם לא, ייווצר מרחב כתובות נפרד (התנהגות של תהליך).

4.3. ספריות וממשקים נפוצים לחוטים

  • OpenMP: אוסף של הנחיות לקומפיילר שמאפשרות לכתוב קוד סדרתי שהקומפיילר יהפוך למקבילי באופן אוטומטי.
  • POSIX Threads (pthreads): ספריית C סטנדרטית למערכות UNIX (דרך <pthread.h>). מאפשרת שליטה ידנית ביצירה וסנכרון של חוטים.
  • תמיכת שפה: שפות כמו Java ו-C++ מציעות תמיכה מובנית בחוטים כחלק מזמן הריצה שלהן.

4.4. IPC: תקשורת בין משימות

אופן התקשורת נגזר ישירות מרמת השיתוף:

  • תקשורת בין חוטים: מתבצעת באופן טבעי דרך זיכרון משותף.
    • יתרון: יעיל מאוד, אין צורך בהעתקת נתונים ("zero copy").
    • חיסרון: דורש סנכרון זהיר ומורכב כדי למנוע race conditions.
  • תקשורת בין תהליכים: מתבצעת לרוב דרך העברת הודעות כדוגמת pipes.
    • יתרון: קל יותר להבנה, לתכנון ולמניעת שגיאות סנכרון.
    • חיסרון: פחות יעיל, לרוב דורש העתקת נתונים פנימית של הליבה ושימוש אינטנסיבי בקריאות מערכת (read/write).

4.5. העלות של החלפת הקשר

הזמן שמערכת ההפעלה מבזבזת על מעבר ממשימה אחת לאחרת מורכב משני גורמים:

  1. עלות ישירה (direct overhead): הזמן שלוקח לגרעין לשמור את המצב (אוגרים) של המשימה הקודמת ולטעון את המצב של המשימה החדשה.
  2. עלות עקיפה (indirect overhead): הזמן שלוקח לחומרה "להתחמם" מחדש עבור המשימה החדשה - בעיקר אכלוס מחדש של זיכרון המטמון (Cache warm-up). העלות העקיפה משמעותית בהרבה ויכולה להיות גדולה בשני סדרי גודל מהעלות הישירה.

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

4.5.1. יחס הזמנים

אם נגדיר זמן החלפת הקשר כ-$C$, והמשימה רצה למשך הזמן $K \cdot C$ לפני שהיא מוחלפת, הרי שחלק הזמן שמבוזבז על החלפת הקשר הוא: $$\frac{C}{K \cdot C + C} = \frac{1}{K + 1}$$ ככל שזמן הריצה בין החלפות ($K$) ארוך יותר (נקבע ע"י קוונטום הזמן של המתזמן או ע"י האפליקציה), כך התקורה היחסית של החלפת ההקשר קטנה.

4.6. היררכיית הזיכרון ועיקרון המקומיות

הסיבה שהעלות העקיפה כה משמעותית נובעת מפער הביצועים העצום בין המעבד לזיכרון. המעבד מהיר בהרבה מזיכרון הראשי (DRAM) שאיטי ממנו פי 100 ויותר. הפתרון לכך הוא זיכרון המטמון (cache).

הקאש עובד בזכות עיקרון המקומיות:

  • מקומיות זמנית (temporal locality): אם התוכנית ניגשה לכתובת מסוימת בזיכרון, סביר שהיא תיגש אליה שוב בקרוב.
  • מקומיות מרחבית (spatial locality): אם התוכנית ניגשה לכתובת מסוימת, סביר שהיא תיגש בקרוב לכתובות סמוכות אליה.

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