5.7 מיפוי זכרון הרצאה
הקדמה¶
בפרק 4 למדנו על הקצאת זכרון דינמית עם malloc ו-free. למדנו שאפשר לבקש זכרון בזמן ריצה ולשחרר אותו כשסיימנו.
אבל מה קורה מאחורי הקלעים? איך הקרנל באמת מקצה זכרון לתהליך?
בפרק הזה נלמד על mmap - ה-syscall שנמצא מתחת לכל מנגנון הזכרון הדינמי. הוא חזק יותר, גמיש יותר, ומאפשר לנו לעשות דברים שmalloc לא יכול.
מה זה מיפוי זכרון - mmap?¶
נחזור רגע לפרק 2 שבו למדנו על paging וזכרון וירטואלי. זוכרים את ה-page tables? כל תהליך מקבל מרחב כתובות וירטואלי משלו, והקרנל מנהל טבלת דפים שממפה כתובות וירטואליות לכתובות פיזיות.
הsyscall שנקרא mmap (קיצור של memory map) מאפשר לנו ליצור ערכים חדשים בטבלת הדפים של התהליך. בעצם, אנחנו אומרים לקרנל: "תקצה לי אזור חדש בזכרון הוירטואלי שלי".
זה המנגנון הבסיסי ביותר שבו תהליך מקבל זכרון ממערכת ההפעלה.
החתימה של mmap¶
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
נפרט על כל פרמטר:
-
addr - הכתובת הוירטואלית שבה נרצה את המיפוי. בדרך כלל נעביר
NULLכדי לתת לקרנל לבחור כתובת בשבילנו (וזה מה שנעשה כמעט תמיד). -
length - כמה בתים אנחנו רוצים למפות. הקרנל יעגל את זה למעלה לגודל של page שלם (בדרך כלל 4096 בתים).
-
prot - הרשאות הגישה לזכרון. אלו דגלים שאפשר לשלב עם OR:
PROT_READ- אפשר לקרוא מהזכרוןPROT_WRITE- אפשר לכתוב לזכרוןPROT_EXEC- אפשר להריץ קוד מהזכרון-
PROT_NONE- אין גישה כלל -
flags - דגלים שקובעים את סוג המיפוי:
MAP_PRIVATE- מיפוי פרטי (שינויים לא נכתבים חזרה לקובץ)MAP_SHARED- מיפוי משותף (שינויים נראים לתהליכים אחרים ונכתבים לקובץ)-
MAP_ANONYMOUS- מיפוי ללא קובץ (הזכרון מאותחל לאפסים) -
fd - ה-file descriptor של הקובץ שאנחנו רוצים למפות. אם משתמשים ב-
MAP_ANONYMOUSנעביר-1. -
offset - מאיזה מקום בקובץ להתחיל את המיפוי. חייב להיות כפולה של גודל page.
ערך ההחזרה הוא פוינטר לתחילת האזור שמופה, או MAP_FAILED במקרה של שגיאה.
מיפוי אנונימי - MAP_ANONYMOUS¶
הסוג הפשוט ביותר של mmap הוא מיפוי אנונימי - הקצאת זכרון ללא קובץ. בעצם אנחנו אומרים לקרנל "תן לי דפי זכרון ריקים".
השורה הזו מקצה page אחד (4096 בתים) של זכרון שאפשר לקרוא ולכתוב אליו. הזכרון מאותחל לאפסים.
וזה בדיוק מה שmalloc עושה מאחורי הקלעים! כאשר אנחנו קוראים ל-malloc עם גודל קטן, הוא משתמש ב-sbrk או brk כדי להרחיב את ה-heap. אבל כאשר אנחנו מבקשים הקצאה גדולה (בדרך כלל מעל 128KB), הlibc משתמש ב-mmap עם MAP_ANONYMOUS כדי לקבל את הזכרון ישירות מהקרנל.
דוגמה - הקצאת זכרון עם mmap¶
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
int main() {
// הקצאת 4096 בתים (page אחד) עם mmap
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED) {
perror("mmap failed");
return 1;
}
// כותבים מחרוזת לזכרון שהוקצה
char *msg = (char *)ptr;
strcpy(msg, "hello from mmap!");
// קוראים מהזכרון
printf("content: %s\n", msg);
// משחררים את המיפוי
munmap(ptr, 4096);
return 0;
}
שימו לב שאנחנו משתמשים ב-munmap כדי לשחרר את הזכרון (במקום free). נדבר על זה עוד רגע.
מיפוי קבצים - File-backed Mapping¶
כאן mmap מתחיל להיות באמת מעניין. במקום לקרוא קובץ עם read() לתוך buffer, אפשר פשוט למפות את הקובץ ישירות לזכרון. אחרי המיפוי, התוכן של הקובץ נגיש דרך פוינטר רגיל - כאילו הקובץ כבר נמצא בRAM.
למה זה טוב?
- יותר מהיר מ-read() וmemcpy לקבצים גדולים, כי הקרנל ממפה את הדפים ישירות מהpage cache.
- נוח - אפשר לגשת לכל חלק בקובץ דרך פוינטר, בלי לנהל buffers בעצמנו.
- הקרנל מטפל בכל ניהול הזכרון - טוען דפים רק כשצריך (demand paging).
דוגמה - קריאת קובץ עם mmap¶
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main() {
// פותחים קובץ לקריאה
int fd = open("example.txt", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
// מקבלים את גודל הקובץ
struct stat sb;
if (fstat(fd, &sb) < 0) {
perror("fstat");
close(fd);
return 1;
}
// ממפים את הקובץ לזכרון
char *mapped = mmap(NULL, sb.st_size, PROT_READ,
MAP_PRIVATE, fd, 0);
if (mapped == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
// אפשר לסגור את הfd אחרי המיפוי - המיפוי נשאר תקף
close(fd);
// קוראים את התוכן דרך הפוינטר
for (size_t i = 0; i < (size_t)sb.st_size; i++) {
putchar(mapped[i]);
}
// משחררים את המיפוי
munmap(mapped, sb.st_size);
return 0;
}
שימו לב לפרט חשוב: אחרי שעשינו mmap, אנחנו יכולים לסגור את ה-file descriptor. המיפוי עצמו נשאר תקף עד שנקרא ל-munmap.
MAP_PRIVATE מול MAP_SHARED¶
ההבדל בין שני הדגלים האלו הוא קריטי:
MAP_PRIVATE - כאשר אנחנו כותבים לאזור הממופה, הקרנל יוצר עותק פרטי של הדף (מנגנון שנקרא Copy-on-Write). השינויים נשארים רק אצלנו ולא נכתבים חזרה לקובץ.
MAP_SHARED - שינויים שאנחנו כותבים לזכרון הממופה נכתבים חזרה לקובץ המקורי, ותהליכים אחרים שמיפו את אותו הקובץ יראו את השינויים.
בנוסף, אפשר להשתמש ב-MAP_SHARED | MAP_ANONYMOUS כדי ליצור אזור זכרון משותף בין תהליכים ללא קובץ. זה שימושי במיוחד כשמשלבים עם fork.
דוגמה - זכרון משותף בין תהליכים עם fork¶
#include <stdio.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
// יוצרים אזור זכרון משותף
int *shared = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (shared == MAP_FAILED) {
perror("mmap");
return 1;
}
*shared = 0; // ערך התחלתי
pid_t pid = fork();
if (pid == 0) {
// תהליך הבן - קורא את הערך שהאב כתב
// ממתינים קצת כדי שהאב יספיק לכתוב
sleep(1);
printf("child reads: %d\n", *shared);
_exit(0);
} else if (pid > 0) {
// תהליך האב - כותב ערך
*shared = 42;
printf("parent wrote: 42\n");
wait(NULL);
} else {
perror("fork");
return 1;
}
munmap(shared, sizeof(int));
return 0;
}
זוכרים מפרק 5.2 שfork יוצר עותק של התהליך? בדרך כלל התהליך הבן מקבל עותק של הזכרון של האב (Copy-on-Write). אבל אם השתמשנו ב-MAP_SHARED, האזור הזה באמת משותף בין שני התהליכים - גם האב וגם הבן ניגשים לאותם דפים פיזיים.
שחרור מיפוי - munmap¶
כמו שיש free ל-malloc, יש munmap ל-mmap:
- addr - הפוינטר שחזר מ-mmap.
- length - הגודל שמיפינו.
חשוב לזכור לקרוא ל-munmap כשסיימנו, אחרת יש לנו דליפת זכרון (memory leak), בדיוק כמו שקורה אם שוכחים free.
שינוי הרשאות זכרון - mprotect¶
לפעמים נרצה לשנות את ההרשאות של אזור זכרון שכבר מופה. לדוגמה, נרצה להקצות זכרון ולכתוב אליו קוד מכונה, ואז לעשות אותו ניתן להרצה. בדיוק ככה עובדים מנועי JIT (Just-In-Time compilation).
- addr - חייב להיות מיושר לגודל page.
- len - גודל האזור.
- prot - ההרשאות החדשות (אותם דגלי PROT שראינו).
לדוגמה:
// מקצים זכרון עם הרשאות קריאה וכתיבה
void *code = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// כותבים קוד מכונה לתוך הזכרון...
// משנים את ההרשאות לקריאה והרצה (מורידים כתיבה)
mprotect(code, 4096, PROT_READ | PROT_EXEC);
// עכשיו אפשר להריץ את הקוד שבזכרון
שימו לב - זה קשור ישירות לאבטחת מידע. היכולת לשנות הרשאות זכרון היא חרב פיפיות: היא מאפשרת מנועי JIT, אבל גם מאפשרת ל-exploits להריץ קוד זדוני. לכן מנגנוני אבטחה רבים (כמו NX bit ו-W^X) מגבילים את היכולת הזו.
למה mmap חשוב?¶
הsyscall הזה הוא אחד הבסיסיים ביותר בלינוקס. הוא משמש לכל דבר:
-
טעינת תוכניות - כשהקרנל טוען קובץ ELF להרצה (דרך execve שלמדנו ב-5.2), הוא לא קורא את כל הקובץ לזכרון עם
read. במקום זאת, הוא ממפה את ה-segments של הELF עם mmap. ככה דפים נטענים רק כשהתהליך באמת ניגש אליהם (demand paging - זוכרים מפרק 2?). -
ספריות דינמיות - כשתוכנית משתמשת ב-shared library (כמו libc.so), ה-loader ממפה את הספריה לזכרון עם mmap ו-MAP_PRIVATE. ככה כל התהליכים שמשתמשים באותה ספריה חולקים את אותם דפים פיזיים.
-
הpage cache - כשממפים קובץ עם mmap, הדפים מגיעים מה-page cache של הקרנל. אם תהליך אחר כבר קרא את הקובץ הזה, הדפים כבר בRAM ולא צריך לקרוא מהדיסק שוב.
-
הקצאת זכרון דינמי - כפי שהזכרנו, malloc משתמש ב-mmap להקצאות גדולות.
-
זכרון משותף בין תהליכים - mmap עם MAP_SHARED מאפשר לתהליכים שונים לתקשר דרך זכרון משותף, שזה אחד המנגנונים המהירים ביותר ל-IPC (Inter-Process Communication).
סיכום¶
למדנו על mmap - ה-syscall שמאפשר לנו למפות זכרון לתוך מרחב הכתובות של התהליך. ראינו שיש שני סוגים עיקריים: מיפוי אנונימי (הקצאת זכרון נקי) ומיפוי קבצים (גישה לתוכן קובץ דרך פוינטר). למדנו על ההבדל בין MAP_PRIVATE ל-MAP_SHARED, ועל munmap ו-mprotect. והכי חשוב - הבנו ש-mmap הוא הבסיס לאיך שלינוקס מנהל זכרון, טוען תוכניות, ומשתף מידע בין תהליכים.