Menu

Stack מול Heap ב-C: אורך חיים, מצביעים תלויים ומתי להשתמש בכל אחד

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

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

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

שלושת האזורים

  כתובות גבוהות
  +---------------------------+
  |  stack                    |  משתנים מקומיים, פרמטרים, כתובות חזרה
  |    grows downward  |      |  משתחררים אוטומטית ביציאה
  |                    v      |
  +---------------------------+
  |         (unused gap)      |
  +---------------------------+
  |                    ^      |
  |    grows upward    |      |
  |  heap                     |  בלוקים של malloc / calloc / realloc
  +---------------------------+  משתחררים רק עם free()
  |  static / global data     |  גלובליים ו-static, כל הריצה
  +---------------------------+
  |  code (text)              |  קוד המכונה, לקריאה בלבד
  +---------------------------+
  כתובות נמוכות
  • אחסון אוטומטי (ה-stack) מחזיק פרמטרים של פונקציות ומשתנים מקומיים שאינם static. קטע של stack נתפס כשנכנסים לפונקציה ומשתחרר כשהיא חוזרת. הגודל נקבע בזמן קומפילציה.
  • אחסון דינמי (ה-heap) מחזיק את כל מה שמגיע מ-malloc, calloc ו-realloc. הגודל נקבע בזמן ריצה, ואורך החיים מסתיים רק ב-free.
  • אחסון סטטי מחזיק משתנים גלובליים וכל דבר שהוצהר static. הוא קיים לאורך כל ריצת התוכנית ומאותחל לאפס לפני ש-main מתחילה.

הכתובות בתרשים הן הסידור הנפוץ, לא הבטחה: התקן מתאר אורכי חיים, לא פריסה.

אחסון אוטומטי בפעולה

כל קריאה ל-demo מקבלת local חדש ו-table חדש. שום דבר לא משוחרר ידנית, שום דבר לא יכול לדלוף, וההקצאה עולה פקודה אחת שמזיזה את מצביע ה-stack. בגלל זה משתנים מקומיים רגילים צריכים להיות ברירת המחדל שלכם: זה האחסון המהיר והבטוח ביותר שיש ב-C.

המלכודת היא הסוגר המסולסל הסוגר. ברגע שהוא רץ, הזיכרון הזה נעלם.

המצביע התלוי

הנה הבאג שכל מתכנת C כותב פעם אחת:

/* שבור: מחזיר כתובת של זיכרון שכבר לא קיים */
int *make_counter(void) {
    int count = 0;
    return &count;          /* count מת בסוגר הזה */
}

int main(void) {
    int *p = make_counter();
    *p = 5;                 /* כתיבה לתוך stack frame מת */
    return 0;
}

&count הייתה כתובת תקינה לגמרי בזמן ש-make_counter רצה. ביציאה, שטח ה-stack הזה נמסר לכל פונקציה שנקראת אחריה, כך ש-p מצביע עכשיו לתוך המשתנים המקומיים של מישהו אחר. קריאה נותנת זבל, וכתיבה משחיתה אותם. GCC ו-Clang מזהירים בדיוק על הצורה הזו (-Wreturn-local-addr), לכן קמפלו עם אזהרות מופעלות.

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

הגרסה השבורה של הפונקציה הזו הייתה בונה את הטקסט ב-char buf[64] מקומי ועושה return buf;, כלומר מחזירה את הכתובת של buffer שמפסיק להתקיים באותו רגע בדיוק.

שלוש דרכים לתקן

1. הקורא מספק את ה-buffer (כמו שלמעלה). בלי הקצאה, בלי שאלת בעלות, וזה הסגנון הנפוץ ביותר בספריות C. הפונקציה מקבלת את הגודל כדי שתוכל להישאר בתוכו.

2. החזירו זיכרון מה-heap, ואמרו מי משחרר אותו.

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

3. השתמשו באחסון סטטי, כש-buffer משותף יחיד הוא מקובל:

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

גודל: איפה ה-stack נגמר

שטח ה-stack קטן וקבוע. ה-thread הראשי מקבל בדרך כלל 1 MB ב-Windows ו-8 MB ב-Linux, ו-thread שנוצר מקבל לעיתים קרובות הרבה פחות. ה-heap מוגבל בזיכרון הפנוי של המערכת.

void bad(void) {
    int huge[1000000];      /* כ-4 MB של stack: כנראה יקרוס בכניסה */
    huge[0] = 1;
}

אין הודעת אבחון ואין NULL לבדוק: התוכנית פשוט מתה, בדרך כלל עם segmentation fault, עוד לפני שהשורה הראשונה בגוף רצה. גרסת ה-heap מדווחת על כישלון כמו שצריך:

רקורסיה עמוקה ממצה את ה-stack באותה דרך, frame אחר frame: פונקציה רקורסיבית שיצאה משליטה היא הגורם הנפוץ ביותר ל-stack overflow בפועל.

עלות ולוקליות

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

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

לראות את האזורים

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

המשתנה הגלובלי והסטטי יושבים זה ליד זה, בלוק ה-heap נמצא במקום אחר, והמשתנה המקומי בדרך כלל רחוק משניהם. בצעו cast ל-void * עבור %p, כי זה מה שה-format specifier דורש.

איך לבחור

השתמשו ב-stack כש:

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

השתמשו ב-heap כש:

  • הגודל תלוי בקלט, בקובץ או בחישוב,
  • הנתונים צריכים לחיות יותר מהפונקציה שיצרה אותם,
  • או שהם גדולים מספיק כדי לאיים על מגבלת ה-stack.

השתמשו ב-static כש:

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

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

שני באגים שהם תמונת ראי

כדאי לתת להם שם יחד, כי זו אותה שאלה של אורך חיים שנענתה בשתי דרכים:

  • מצביע תלוי: הזיכרון מת לפני שהמצביע מת. החזרת &local, או שימוש במצביע אחרי free. התוכנית קוראת או כותבת לאחסון ששייך עכשיו למשהו אחר.
  • דליפת זיכרון: המצביע מת לפני שהזיכרון מת. איבוד ההפניה האחרונה לבלוק של malloc. שום דבר לא נשבר מיד, התהליך פשוט גדל.

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

שאלות נפוצות

מה ההבדל בין ה-stack ל-heap ב-C?

ה-stack מחזיק משתנים מקומיים: הקומפיילר קובע את הגודל שלהם, הם נוצרים כשנכנסים לפונקציה ונהרסים כשהיא חוזרת, וההקצאה לא עולה כלום. ה-heap מחזיק בלוקים של malloc: אתם בוחרים את הגודל בזמן ריצה, הבלוק שורד עד שעושים לו free, ולהקצאה יש עלות אמיתית.

למה אי אפשר להחזיר מצביע למשתנה מקומי ב-C?

כי האחסון של המשתנה המקומי משתחרר ברגע שהפונקציה חוזרת. המצביע עדיין מחזיק את הכתובת הזו, אבל הזיכרון שייך עכשיו לקריאה הבאה לפונקציה: קריאה ממנו נותנת זבל, וכתיבה אליו משחיתה נתונים לא קשורים. זה מצביע תלוי (dangling pointer). החזירו בלוק של malloc, או תנו לקורא לספק את ה-buffer.

כמה גדול ה-stack ב-C?

בדרך כלל 1 עד 8 MB ל-thread הראשי, והרבה פחות ל-threads נוספים. זה קטן מספיק כדי ש-int big[1000000]; כמשתנה מקומי בדרך כלל יקריס את התוכנית כבר בכניסה. ה-heap מוגבל בזיכרון הפנוי של המערכת, ולכן נתונים גדולים או בגודל לא ידוע שייכים לשם.

מתי להשתמש ב-heap במקום ב-stack ב-C?

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

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל