הרע, הרע והמכוער: הקצאה דינמית במיקרו-בקרים

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

הקצאה סטטית והקצאה דינמית

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

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

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

מי צריך הקצאה דינמית

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

יש לזה שתי תשובות, ושתיהן קשורות למערכות המחשב שעבורן הומצאה הטכניקה של הקצאה דינמית. התשובה הראשונה היא שאנחנו לא יכולים לדעת מראש כמה זיכרון לא מנוצל יש. גם אם במחשב שלי יש, נניח, 4GB של זיכרון, אסור לי לכתוב תוכנה שתופסת מראש את כל הארבעה כי אולי מישהו עם מחשב חלש יותר, בעל 2GB בלבד, ירצה להריץ אותה גם כן – ומצד שני, מישהו עם 8GB לא יוכל ליהנות מיתרון החומרה שלו. בהקצאה דינמית אני יכול לתת לתוכנה את כל מה שיש למחשב להציע, ובכך למצות את הפוטנציאל ולהבטיח שכל המשתמשים יוכלו להשתמש בתוכנה שלי.

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

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

ההקצאה הסמויה מן העין

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

int x[1200];

void setup() {
   for (int j = 0; j < 1000; j++) x[j] = j;
}

void loop() { }

הקומפיילר ידווח על שגיאה: "Not enough memory". כל int תופס 2 בייטים, ומערך של 1200 כאלה יתפוס 2400 בייטים – יותר ממה שיש בזיכרון ה-RAM של ה-Uno. לעומת זאת, נסו את הקוד הבא:

long int j, sum = 0;

void test1() {
  int x[600];
  for (j = 0; j < 600; j++) {
    x[j] = j; sum += x[j];
  }   
}

void test2() {
  int y[600];
  for (j = 0; j < 600; j++) {
    y[j] = j; sum += y[j];
  }  
}

void setup() {
 Serial.begin(9600); 
 test1(); 
 test2(); 
 Serial.println(sum);
}

void loop() { }

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

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

ואם בכל זאת

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

יש שתי דרכים לבצע הקצאה דינמית: הדרך של שפת C באמצעות הפונקציות malloc ו-free, והדרך של שפת C++ באמצעות new ו-delete. רצוי מאד לא לערבב בין שני הסגנונות, ואם יש אפשרות בחירה, להעדיף את new/delete. לא ניכנס כאן לפרטי המימוש – חפשו דוגמאות ומידע במנוע החיפוש החביב עליכם, אם אתם חייבים. הנה הסבר קטן לדרך.

להרשמה
הודע לי על
6 Comments
מהכי חדשה
מהכי ישנה לפי הצבעות
Inline Feedbacks
הראה את כל התגובות

ניסית למנוע את האפטימיזציה עם VOLATILE?
נראה לי שזה חסר משמעות על משתנים לוקליים, אבל יכול להיות שהקומפיילר בכל זאת יתיחס לזה וימנע אופטימיזציה.

אני חושב זה היה עובד. ה"אינסטינקט הבסיסי" של הקומפיילר כשהוא נתקל ב volatile זה לא לעשות שום אופטימיזציות. למרות שנראה ממבט ראשון שהקומפיילר יכול להעיף את המערך בכל מקרה, זה לא תמיד נכון אם עובדים עם כמה threads ובגלל זה לדעתי הקומפיילר ישאיר את זה. הסיבה שציינתי שזה משתנה לוקלי היא בגלל שלמטרות שימושיות להגדיר משתנה volatile לוקאלי זה לא שימושי במיוחד כי הכתובת שלו לא ידועה בזמן קומפילציה. יש שפות תכנות שאוסרות על משתנים כאלה. אבל ב c זה מותר וכנראה שימושי במקרים נדירים. בגלל זה אני חושב שלמרות שבדוגמא שלך המשתנה לוקלי , זה לא ישנה וקומפיילר יתיחס… לקרוא עוד »

מצוין.
תודה !!

הסבר מצויין כרגיל, תודה.