5.5 מתארי קבצים הרצאה
הקדמה¶
בפרק 4 למדנו על עבודה עם קבצים באמצעות libc - השתמשנו בפונקציות כמו fopen, fread, fwrite ו-fclose שעובדות עם FILE* (מצביע לקובץ). ראינו שמאחורי הקלעים כל הפונקציות האלו מבוססות על מנגנון שנקרא file descriptor - מספר שלם שמייצג קובץ פתוח.
בהרצאה הזו נצלול לעומק לתוך המנגנון הזה. נבין מה בדיוק הם מתארי קבצים, איך הם מאורגנים בתוך תהליך, ונלמד לעבוד ישירות עם הsyscall-ים של לינוקס בלי לעבור דרך libc. בנוסף, נלמד על מנגנון חשוב שנקרא dup2 שמאפשר לנו לשלוט לאן הפלט של התוכנה שלנו הולך.
מתארי קבצים - file descriptors¶
מתאר קובץ (file descriptor, בקיצור fd) הוא מספר שלם (int) שמייצג משאב פתוח בתהליך. כשאנחנו פותחים קובץ, מערכת ההפעלה מחזירה לנו מספר - וזה המספר שנשתמש בו כדי לקרוא, לכתוב, ולסגור את המשאב.
אבל fd-ים הם לא רק לקבצים. בלינוקס, הפילוסופיה היא "הכל הוא קובץ" - everything is a file. כלומר, כמעט כל משאב במערכת נגיש דרך file descriptor:
- קבצים רגילים על הדיסק
- צינורות - pipes (נלמד בהמשך)
- סוקטים - sockets (תקשורת רשת)
- התקני חומרה - devices (כמו /dev/null, /dev/random)
- טרמינלים
כל אלו נגישים דרך אותו ממשק: open, read, write, close. בזכות זה, קוד שיודע לקרוא מfd יכול לעבוד עם קובץ, עם pipe, עם socket - בלי לשנות שום דבר.
טבלת הfd-ים של תהליך¶
לכל תהליך בלינוקס יש טבלה פרטית של מתארי קבצים. הטבלה הזו ממפה מספרים (0, 1, 2, 3, ...) לאובייקטי קבצים בקרנל.
כשאנחנו פותחים קובץ עם open, הקרנל מחפש את המספר הנמוך ביותר שפנוי בטבלה, מקצה אותו, ומחזיר לנו את המספר הזה. כשאנחנו סוגרים את הקובץ עם close, המספר משתחרר ויכול לשמש בעתיד.
למשל, אם fd-ים 0, 1, 2 כבר תפוסים (תמיד - נסביר מיד), אז הfd הבא שנקבל מ-open יהיה 3. אם נפתח עוד קובץ, נקבל 4. אם נסגור את 3 ונפתח שוב - נקבל שוב 3.
שלושת הfd-ים הסטנדרטיים¶
כבר ראינו את זה בפרק 4, אבל עכשיו נבין את זה לעומק. כל תהליך בלינוקס מתחיל עם שלושה fd-ים פתוחים מראש:
| מספר fd | שם | תיאור |
|---|---|---|
| 0 | stdin | קלט סטנדרטי (מקלדת) |
| 1 | stdout | פלט סטנדרטי (מסך) |
| 2 | stderr | פלט שגיאות (מסך) |
שלושת הfd-ים האלו נפתחים אוטומטית על ידי מערכת ההפעלה כשתהליך מתחיל לרוץ. הם קיימים עוד לפני ששורה אחת של הקוד שלנו רצה.
כשאנחנו עושים printf("hello"), מאחורי הקלעים libc כותבת לfd מספר 1 (stdout).
כשאנחנו עושים scanf(...), מאחורי הקלעים libc קוראת מfd מספר 0 (stdin).
מopen של libc לopen של הקרנל¶
בפרק 4 למדנו על שתי קבוצות של פונקציות:
- פונקציות FILE* של libc: fopen, fread, fwrite, fclose, fprintf, fscanf
- פונקציות fd: open, read, write, close
מה ההבדל? הפונקציות של libc (אלו שמתחילות ב-f) הן עטיפות נוחות שמוסיפות buffering, תמיכה בפורמט, וניהול שגיאות. אבל מתחת לפני השטח, הן קוראות לsyscall-ים שעובדים עם fd-ים.
הנה השרשרת:
fopen("file.txt", "r") --> open("file.txt", O_RDONLY) --> syscall בקרנל --> מחזיר fd
fwrite(buf, 1, n, f) --> write(fd, buf, n) --> syscall בקרנל
fclose(f) --> close(fd) --> syscall בקרנל
כלומר, FILE* הוא מבנה (struct) של libc שמכיל בתוכו fd, באפר פנימי, ומידע נוסף. כשאנחנו עובדים ישירות עם fd-ים, אנחנו עוקפים את כל השכבה הזו ומדברים ישירות עם הקרנל.
הsyscall של open¶
הsyscall open פותח קובץ ומחזיר file descriptor. ההכרזה שלו:
#include <fcntl.h>
#include <unistd.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
הפרמטר flags קובע איך הקובץ ייפתח. אפשר לשלב כמה דגלים עם האופרטור |:
| דגל | משמעות |
|---|---|
| O_RDONLY | פתיחה לקריאה בלבד |
| O_WRONLY | פתיחה לכתיבה בלבד |
| O_RDWR | פתיחה לקריאה וכתיבה |
| O_CREAT | יצירת הקובץ אם לא קיים |
| O_TRUNC | מחיקת התוכן הקיים (כמו "w" בfopen) |
| O_APPEND | כתיבה לסוף הקובץ (כמו "a" בfopen) |
כשמשתמשים ב-O_CREAT, צריך להוסיף פרמטר שלישי (mode) שמגדיר את ההרשאות של הקובץ החדש. למשל 0644 נותן הרשאות קריאה-כתיבה לבעלים וקריאה לכולם.
דוגמאות:
// פתיחה לקריאה בלבד (כמו fopen עם "r")
int fd = open("data.txt", O_RDONLY);
// פתיחה לכתיבה, יצירה אם לא קיים, מחיקת תוכן (כמו fopen עם "w")
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// פתיחה לכתיבה לסוף הקובץ (כמו fopen עם "a")
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
אם open נכשל, הוא מחזיר -1. תמיד צריך לבדוק את ערך ההחזרה.
הsyscall-ים של read ו-write¶
הsyscall read קורא בתים מfd לתוך באפר:
-
fd - מתאר הקובץ שממנו קוראים-
buf - כתובת הבאפר שאליו הנתונים ייכתבו-
count - מספר הבתים המקסימלי לקריאה- מחזיר את מספר הבתים שנקראו בפועל, 0 כשמגיעים לסוף הקובץ, או -1 בשגיאה
הsyscall write כותב בתים מבאפר לfd:
-
fd - מתאר הקובץ שאליו כותבים-
buf - כתובת הנתונים לכתיבה-
count - מספר הבתים לכתיבה- מחזיר את מספר הבתים שנכתבו בפועל, או -1 בשגיאה
הsyscall של close¶
סוגר את הfd ומשחרר את המשאב. אחרי close, אסור להשתמש באותו fd - הוא כבר לא מצביע על שום דבר.
ההבדל בין FILE* לfd - סיכום¶
| תכונה | FILE* (fopen) | fd (open) |
|---|---|---|
| סוג ערך החזרה | מצביע FILE* | מספר שלם int |
| באפר פנימי - buffering | כן, אוטומטי | לא, כתיבה ישירה |
| תמיכה בפורמט | כן (fprintf, fscanf) | לא |
| רמת השליטה | גבוהה ונוחה | נמוכה וישירה |
| קרבה לsyscall | עטיפה מעל הsyscall | הsyscall עצמו |
מתי נעדיף fd ישירות?
- כשרוצים שליטה מלאה (למשל redirect של פלט)
- כשעובדים עם pipes, sockets, או התקנים
- כשחשוב שהכתיבה תקרה מיד (בלי buffering)
מתי נעדיף FILE*?
- כשרוצים נוחות (fprintf, fscanf)
- כשעובדים עם טקסט ופורמט
- כשה-buffering האוטומטי מועיל לביצועים
דוגמה: כתיבה לקובץ עם fd¶
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
write(2, "open failed\n", 12);
return 1;
}
const char *msg = "hello from fd!\n";
write(fd, msg, strlen(msg));
close(fd);
return 0;
}
שימו לב - גם את הודעת השגיאה כתבנו עם write לfd 2 (stderr), בלי להשתמש ב-printf בכלל. כל הפעולות כאן הן syscall-ים ישירים.
שכפול מתארי קבצים - dup ו-dup2¶
עכשיו נגיע לחלק המעניין באמת. לינוקס נותנת לנו שני syscall-ים ששולטים במתארי קבצים:
dup - שכפול¶
הsyscall dup יוצר עותק של fd קיים. הוא מחזיר fd חדש (המספר הנמוך ביותר שפנוי) שמצביע על אותו משאב כמו הfd המקורי. כלומר, עכשיו יש לנו שני מספרים שונים שמובילים לאותו קובץ.
dup2 - שכפול למספר ספציפי¶
הsyscall dup2 עושה משהו יותר מעניין - הוא מעתיק את oldfd לתוך newfd. אם newfd כבר פתוח, dup2 סוגר אותו קודם. אחרי הקריאה, newfd מצביע על אותו משאב כמו oldfd.
למה זה כל כך חשוב? כי בעזרת dup2 אנחנו יכולים להפנות מחדש את stdin, stdout, ו-stderr!
הפניית פלט - I/O redirection עם dup2¶
דמיינו שאנחנו רוצים שכל הפלט של התוכנה שלנו ילך לקובץ במקום למסך. בshell אנחנו עושים:
איך הshell עושה את זה מאחורי הקלעים? בדיוק עם dup2:
- פותח את הקובץ
output.txtעםopen- מקבל fd (למשל 3) - קורא
dup2(3, 1)- מעתיק את fd 3 לתוך fd 1 (stdout) - סוגר את fd 3 (כבר לא צריכים אותו, fd 1 מצביע על הקובץ)
- מריץ את התוכנה - כל
printfאוwrite(1, ...)עכשיו כותב לקובץ!
הנה דוגמה מלאה:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
// פותחים קובץ לכתיבה
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open");
return 1;
}
// מפנים את stdout לקובץ
dup2(fd, 1); // fd 1 (stdout) עכשיו מצביע על output.txt
// סוגרים את הfd המקורי - כבר לא צריכים אותו
close(fd);
// מעכשיו, כל הפלט הולך לקובץ!
printf("השורה הזו הולכת לקובץ ולא למסך\n");
printf("גם השורה הזו\n");
return 0;
}
אחרי הרצת התוכנה, שום דבר לא יודפס למסך - הכל ילך ישירות לתוך output.txt. נסו! זה אחד מהדברים הכי שימושיים שתלמדו בהרצאה הזו.
מתארי קבצים וfork¶
בהרצאה 5.2 למדנו על fork - שכפול תהליך. דבר חשוב שצריך לדעת: כאשר תהליך קורא ל-fork, תהליך הבן יורש עותק של טבלת הfd-ים של האב.
כלומר, אם לאב יש fd 3 שמצביע על קובץ מסוים, גם לבן יהיה fd 3 שמצביע על אותו קובץ. שניהם חולקים את אותו משאב.
זה גם מסביר למה הshell עובד ככה:
1. הshell פותח קובץ ומבצע dup2 כדי להפנות stdout
2. הshell קורא ל-fork - הבן יורש את הfd-ים, כולל ההפניה
3. הבן קורא ל-execve כדי להריץ את התוכנה החיצונית
4. התוכנה החיצונית כותבת ל-stdout (fd 1) - שמצביע על הקובץ
ככה ls > output.txt עובד מאחורי הקלעים.
דוגמה: אב ובן כותבים לאותו קובץ¶
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
int fd = open("shared.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open");
return 1;
}
pid_t pid = fork();
if (pid == 0) {
// תהליך הבן - הfd עדיין פתוח!
char msg[] = "hello from child\n";
write(fd, msg, sizeof(msg) - 1);
close(fd);
_exit(0);
} else if (pid > 0) {
// תהליך האב
char msg[] = "hello from parent\n";
write(fd, msg, sizeof(msg) - 1);
wait(NULL);
close(fd);
}
return 0;
}
אחרי ההרצה, הקובץ shared.txt יכיל את ההודעות משני התהליכים. שימו לב שלא היינו צריכים לעשות open בתהליך הבן - הוא ירש את הfd מהאב.
סיכום¶
בהרצאה הזו למדנו:
- מתאר קובץ (fd) הוא מספר שלם שמייצג משאב פתוח בתהליך
- בלינוקס הכל הוא קובץ - קבצים, pipes, sockets, התקנים - הכל נגיש דרך fd
- לכל תהליך יש טבלת fd-ים פרטית
- שלושת הfd-ים הסטנדרטיים: stdin=0, stdout=1, stderr=2
- הsyscall-ים open, read, write, close עובדים ישירות עם fd-ים
- ההבדל בין FILE* (buffered, נוח) לבין fd (ישיר, unbuffered)
- הsyscall-ים dup ו-dup2 מאפשרים שכפול והפניה של fd-ים
- בעזרת dup2 אפשר להפנות stdout לקובץ - ככה הshell מממש את האופרטור >
- כש-fork יוצר תהליך בן, הבן יורש את כל הfd-ים של האב