Smart-World Surf

יחידה 5: מצביעים וניהול זיכרון

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

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

יסודות המצביעים: הצהרה, אתחול וגישה

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

מצביע (Pointer): משתנה המכיל כתובת זיכרון של משתנה אחר (או של מיקום בזיכרון).
אופרטור הכתובת (& - Address-of Operator): מחזיר את כתובת הזיכרון של משתנה. לדוגמה, &x יחזיר את הכתובת של המשתנה x.
אופרטור הגישה לערך (* - Dereference Operator): מאפשר גישה לערך הנמצא בכתובת הזיכרון שמצביע מכיל. לדוגמה, *ptr יחזיר את הערך בכתובת ש-ptr מצביע אליה.

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

  • הצהרה: type *pointer_name;. הכוכבית מציינת שמדובר במצביע. לדוגמה: int *p; מצהיר על מצביע ל-int.
  • אתחול: יש לאתחל מצביע לכתובת חוקית לפני השימוש בו.
    • int x = 10;
    • int *p = &x; // p מצביע כעת ל-x
    • *p = 20; // משנה את הערך של x ל-20
  • מצביע NULL: מצביע שאינו מצביע לשום מקום חוקי. חשוב לאתחל מצביעים ל-NULL אם אין להם כתובת מיידית, ולבדוק אותם לפני dereference.
    • int *p = NULL;
    • if (p != NULL) { *p = 5; }

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

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

אריתמטיקת מצביעים

  • הוספה/החסרה של מספר שלם: ptr + n מזיז את המצביע n יחידות בגודל הטיפוס. אם ptr מצביע ל-int, אז ptr + 1 יצביע לכתובת הבאה בזיכרון במרחק של sizeof(int) בתים.
    • int arr[] = {10, 20, 30};
    • int *p = arr; // p מצביע ל-10
    • p++; // p מצביע כעת ל-20 (התקדם ב-sizeof(int) בתים)
    • printf("%d\n", *p); // ידפיס 20
  • החסרת מצביעים: ptr2 - ptr1 מחזיר את מספר האלמנטים בין שתי הכתובות (בתנאי שהם מצביעים לאותו מערך).

מצביעים למערכים ולפונקציות

  • מצביעים למערכים: שם של מערך הוא למעשה מצביע קבוע (const pointer) לאלמנט הראשון שלו.
    • int arr[5];
    • int *p = arr; // p מצביע ל-arr[0]
    • arr[i] שקול ל-*(arr + i)
  • מצביעים לפונקציות: מאפשרים להעביר פונקציות כארגומנטים, לאחסן אותן במבני נתונים, ולבצע קריאות חוזרות (callbacks).
    • הצהרה: return_type (*pointer_name)(parameter_list);
    • דוגמה: int (*add_func)(int, int);
    • int sum(int a, int b) { return a + b; }
    • add_func = ∑ // או פשוט add_func = sum;
    • int result = add_func(5, 3); // קריאה לפונקציה דרך המצביע
הבנת הקשר בין מערכים למצביעים: מערך ב-C הוא למעשה מצביע קבוע לתחילת הזיכרון שהוקצה לו. הבנה זו קריטית לגישה יעילה לאלמנטים ולמעבר מערכים לפונקציות. טעויות נפוצות כוללות ניסיון לשנות את כתובת המערך עצמו (לדוגמה, arr++ אינו חוקי) או בלבול בין גודל המערך (sizeof(arr)) לגודל המצביע (sizeof(int*)). זכרו, כאשר מערך מועבר לפונקציה, הוא "מתנוון" למצביע לאלמנט הראשון שלו, וגודלו המקורי אובד.

הקצאת זיכרון דינמית

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

Heap: אזור בזיכרון המיועד להקצאה דינמית. הזיכרון נשאר זמין עד לשחרורו במפורש על ידי המתכנת.
דליפת זיכרון (Memory Leak): מצב שבו זיכרון שהוקצה דינמית אינו משוחרר בחזרה למערכת לאחר שאין בו צורך, מה שמוביל לצריכת זיכרון הולכת וגוברת.
מצביע תלוי (Dangling Pointer): מצביע המצביע לכתובת זיכרון שכבר שוחררה או אינה חוקית יותר. גישה דרכו עלולה לגרום לקריסה.

malloc (Memory Allocation)

void* malloc(size_t size);
מקצה בלוק זיכרון בגודל size בתים. מחזיר מצביע ל-void (יש לבצע cast לטיפוס הרצוי) או NULL במקרה של כשל בהקצאה. התוכן של הזיכרון המוקצה אינו מאותחל.

דוגמה: int *arr = (int*)malloc(5 * sizeof(int));

calloc (Contiguous Allocation)

void* calloc(size_t num, size_t size);
מקצה בלוק זיכרון עבור num אלמנטים, כל אחד בגודל size בתים, ומאתחל את כל הביטים ל-0. מחזיר מצביע ל-void או NULL. שימושי למערכים שצריכים להיות מאופסים.

דוגמה: int *arr = (int*)calloc(5, sizeof(int));

realloc (Re-allocation)

void* realloc(void* ptr, size_t new_size);
משנה את הגודל של בלוק זיכרון שהוקצה בעבר (באמצעות malloc או calloc) ל-new_size בתים. אם אין מספיק מקום במיקום הנוכחי, הוא יכול להעתיק את התוכן למיקום חדש. מחזיר מצביע לבלוק החדש או NULL במקרה של כשל (הבלוק המקורי נשאר ללא שינוי). אם ptr הוא NULL, מתנהג כמו malloc. אם new_size הוא 0, מתנהג כמו free.

דוגמה: arr = (int*)realloc(arr, 10 * sizeof(int));

free (Free Memory)

void free(void* ptr);
משחרר בלוק זיכרון שהוקצה דינמית בחזרה למערכת ההפעלה. חיוני למניעת דליפות זיכרון. לאחר קריאה ל-free(ptr), המצביע ptr הופך למצביע תלוי (dangling pointer) ויש לאפס אותו ל-NULL כדי למנוע גישה לא חוקית.

דוגמה: free(arr); arr = NULL;

שאלות לדיון

  • הסבר מדוע הקצאת זיכרון דינמית חיונית בתכנות מערכות, ותאר מקרה שימוש קונקרטי שבו לא ניתן להסתפק בהקצאה סטטית.
  • מה ההבדל המהותי בין malloc ל-calloc? מתי תעדיף להשתמש באחד על פני השני?
  • תאר את הסכנות הכרוכות בשימוש במצביעים, כגון "דליפת זיכרון" ו"מצביעים תלויים" (Dangling Pointers). כיצד ניתן למנוע אותן?
  • הסבר את הקשר בין שם של מערך (לדוגמה int arr[10];) לבין מצביע. אילו פעולות מותרות ואסורות על שם המערך בהקשר זה?

נקודות לתשובת מודל

  • חשיבות הקצאה דינמית: מאפשרת לנהל זיכרון בגודל משתנה בזמן ריצה, כאשר גודל הנתונים אינו ידוע מראש (למשל, קריאת קלט משתמש באורך לא ידוע, בניית רשימה מקושרת). מקרה שימוש: בניית מערך בגודל שנקבע על ידי המשתמש בזמן ריצה.
  • malloc vs. calloc:
    • malloc(size): מקצה size בתים, התוכן אינו מאותחל (מכיל "זבל").
    • calloc(num_elements, element_size): מקצה num_elements * element_size בתים ומאתחל את כולם ל-0.
    • העדפה: calloc עדיף כאשר נדרש איפוס (למשל, מוני אובייקטים, דגלים), malloc עדיף כאשר יעילות קריטית ואין צורך באיפוס מיידי.
  • סכנות ומניעה:
    • דליפת זיכרון: אי-שחרור זיכרון שהוקצה דינמית באמצעות free. מניעה: הקפדה על קריאה ל-free עבור כל הקצאה מוצלחת, במיוחד בטיפול בשגיאות או יציאה מפונקציות.
    • מצביע תלוי: מצביע המצביע לזיכרון שכבר שוחרר (free). גישה לזיכרון זה היא התנהגות בלתי מוגדרת. מניעה: איפוס מצביעים ל-NULL מיד לאחר קריאה ל-free.
    • גישה ל-NULL: ניסיון לבצע dereference למצביע NULL. מניעה: בדיקת NULL לפני כל פעולת dereference.
  • מערכים ומצביעים: שם המערך (לדוגמה arr) הוא מצביע קבוע (const pointer) לכתובת הזיכרון של האלמנט הראשון שלו (&arr[0]).
    • מותר: גישה לאלמנטים באמצעות אינדקס (arr[i]) או אריתמטיקת מצביעים (*(arr + i)), העברת המערך לפונקציה (שם המערך "מתנוון" למצביע).
    • אסור: שינוי כתובת המערך עצמו (לדוגמה, arr++ או arr = some_other_address;), מכיוון שהוא קבוע.
מצאתם טעות או שחסר משהו?
→ הקודמת
מערכים ומחרוזות
הבאה ←
טיפוסי נתונים מורכבים