דיברנו על control flow in assembly אבל החלק החסר הוא איך מחזירים ערכים מתהליכים. כאן נכנס לתמונה ה procedures שזה בעצם פונקציות.
פונקצייה היא תהליך שבו מעבירים מידע והעברה של שליטה של הקוד ל scope אחר. המידע הוא בעצם הפרמטרים של הפונקצייה.
כאשר מעבירים אחריות לפונקצייה, היא יכולה להקצות גודל חדש אם יש לה משתנים מקומיים.
ב x86-64 Assembly ישנן פקודות פשוטות להעברת השליטה לפונקצייה.
x86 משתמש במחסנית כדי לתמוך בקריאה לתהליכים.
נעשה שימוש בשביל
החלק במחסנית שמוקצה לפונקצייה בודדת נקרא stack frame.
פקודה דומה ל jump . שולחת את RIP לכתובת שעליה בוצעה הפקודה ודוחפת את ה return address למחסנית.
הreturn address זה הכתובת של השורה הבאה אחרי ה call.
נסתכל על הדוגמה הבאה
call g
movq %rax, %rdx --> the return address is the address of this instruction.
נשים לב שתמיד יש איזשהו איזון בדחיפה והוצאה של rsp באופן הזה, שכן תמיד על ידי שימור בפקודה #ret אנחנו נוציא את הreturn address מהמחסנית
זוהי קונבנצייה בלבד שמאפשרת למתכנתים שונים להבין את אותו קוד אסמבלי.
ומה קורה כשיש יותר מ 7 ארגומנטים לפונקצייה?
נצטרך לשים אותם ישירות במחסנית, בסדר הפוך , ובכתובות זכרון שהן כפולה של 8.
הסיבה שצריך להיות כפולה של 8 היא בגלל איך ש PUSH עובד, הוא מזיז את rsp ב8 בייטים כל פעם.
המטרה שדוחפים ארגומנטים בסדר הפוך, היא כדי שהפונקצייה שתקרא את הפרמטרים האלה תוכל לקרוא אותם מהמחסנית בסדר שבוא שלחנו אותם.
חשוב לשים לב, זוהי הכנה לפני קריאה לפונקצייה מסויימת ולכן ה return address ו והframe של הפונקצייה עצמה יהיו בכתובות נמוכות יותר במחסנית
אם כן, קורה מצב שפונקצייה כלשהי ״פולשת״ לפריים של הפונקצייה שקראה לה בשביל לקחת את הארגומנטים.
נסתכל על הדוגמה הבאה:
long f(int* p1, int* p2, int* p3, int* p4, long x5, long x6, long x7, long x8) {
long result = *p3 + *p2;
result += *p3 - *p4;
result += x5 * x6 + x7 - x8;
return result;
}
f:
movl (%rdi) , %eax #get *p1
addl (%rsi) , %eax # result = *p1 + *p2
addl (%rdx) , %eax # result += *p3
subl (%rcx), %eax # result -= *p4
imulq %r8, %r9 # %r9 = x5*x6
addq %r9, %rax # result += x5*x6
addq 8(%rsp), %rax # result += x7
subq 16(%rsp), %rax # result -= x8
ret
נשים לב שלקחנו את x7,x8 על ידי שימוש בכתובת שrsp מצביע עליה בקפיצות של 8.
חשוב מאוד אחרי שייקרא הret בפונקצייה f , ה rip יחזור לכתובת של הפקודה הבאה אבל rsp לאחר פקודת pop יצביע על הכתובת של x7, באחריות בעל ה caller frame להחזיר בחזרה את rsp על ידי ביצוע מספר פקודות הpop הדרושות כדי למחוק את הנעלמים ששמרנו במחסנית.
באופן כללי, פונקצייה כלשהי כקונבנצייה תמיד תדבר עם הframe שלה והcaller frame שלה בלבד, כלומר שאם f תפעיל פונקצייה מסויימת g וזאת תפעיל פונקצייה שלישית שצריכה את x7,x8 עלינו להעתיק אותם ל caller frame כלומר ל פריים של g.
דוגמה קצת יותר מורכבת
int swap_add(int *xp , int*yp) {
int x = *xp;
int y = *yp;
*xp = y;
*yp = x;
return x+y;
}
int caller() {
int arg1 = 534;
int arg2 = 1057;
int sum = swap_add(&arg1, &arg2);
int diff = arg1 - arg2;
return sum * diff;
}
הcaller ייראה ככה באסמבלי
נשים לב שכאן אנחנו מעבירים את המשתנים במחסנית ולא ברגיסטרים, ניתן לשים לב לזה על ידי הפקודה הראשונה שזיזה את rsp למטה ב8 בייטים כלומר 2 אינטים שכל אחד הוא 4 בייט.
אחרי השורה הראשונה מה שקורה הוא ש
כעת נרצה לאכסן את שתי המספרים הקבועים שלנו במחסנית, על ידי movl, בפעם הראשונה שמנו בכתובת
נשים לב שהארגומנטים שאנחנו מעבירים הם הכתובת בזכרון של שתי הפרמטרים האלה, ולכן מלכתחילה שמרנו אותם במחסנית כי בשורות הבאות אנחנו מעבירים לפי קונבנצייה את שתי הפרמטרים הראשונים את הכתובות שלהם במחסנית.
לא כתוב את זה למעלה אבל בשורה 8, היינו משחררים את הזכרון שהוספנו ל rsp.
swap_add ייראה ככה באסמבלי
נשים לב שהתוצאה אכן נמצאת ב rax.
ותמונת המחסנית תיראה ככה:
הregisters שאנחנו מכירים הם מקור אחד לכל הפונקציות של התוכנית.
צריך לשים לב שכאשר פונקצייה אחת (caller) קורא לפונקצייה אחרת (callee), אז הcallee לא דורס ערכים ברגיסטרים שהcaller תכנן להשתמש בהם אחר כך.
בשביל כך יש קונבנציות ב x86-64 עבור שימור בregistes שכל הפונקציות צריכות לכבד, כולל פונקציות ספרייה.
caller save:
הרגיסטרים
מסווגים כ caller save registers. המשמעות היא שאחריות ה caller לשמר אותם בהנחה שמשתמשים בהם. המשמעות היא שאם Q היא פונקצייה שמפעילה את P, אז P יכולה לדרוס את הregisters האלה על ידי כתיבה שלהם מחדש בלי לדאוג למידע ש Q משתמש בו.
דוגמה טובה לכך היא rax שאנחנו יודעים שכקונבנצייה היא שומרת את ערך החזרה של הפונקצייה.
callee save:
הרגיסטרים
נקראים callee save registers. כלומר ישנה אחריות על הפונקצייה שקוראים לה , נניח שזאת P , במצב שבו היא צריכה לדרוס את הערכים שם , עליה לשמור את הערכים בregisters האלו ב stack frame לפני שהיא דורסת אותם. לאחר שהיא דורסת אותם עליה להחזיר את הערכים לרגיסטרים האלה לפני שהיא קוראת ל ret. כי מי שקרא ל P נניח שזה Q בגלל הקונבנצייה מניח ש הוא הולך להשתמש בערכים האלה לאחר ש P יסתיים.
דגשים נוספים:
נתבונן בפונקצייה הבאה
int P(int x) {
int y = x*x;
int z = Q(y);
return y+z;
}
פונקצייה P מחשבת את y לפני שקוראת ל Q אבל היא חייבת לשים לב שהערך של y זמין אחרי ש Q חוזרת. ניתן לעשות זאת בשתי הדרכים הבאות :
כעת, נחזור לקוד שכתבנו מקודם
int swap_add(int *xp , int*yp) {
int x = *xp;
int y = *yp;
*xp = y;
*yp = x;
return x+y;
}
int caller() {
int arg1 = 534;
int arg2 = 1057;
int sum = swap_add(&arg1, &arg2);
int diff = arg1 - arg2;
return sum * diff;
}
ונבנה אותו באסמבלי עם callee save registers (למרות שהגרסה הקודמת יותר טובה כי לא הוספנו זכרון במחסנית שאנחנו יודעים שלגשת לזכרון ולא לרגיסטר זאת פעולה יקרה יותר).
נשים לב שבשורות 10, 11 שחזרנו את הערכים מהרגיסטרים שהחזרנו למחסנית רק בסדר הפוך.
נניח שאין לנו את גודל הפריים בזמן קומפילצייה.
במצב זה כפי שדיברנו ב מבנה התוכנית באסמבלי , ה rbp ישמש אותנו כדי לשמור פוינטר לתחילת הפריים.
כשאנחנו מתחילים פונקציה עם variable size frame נשים בתחילתה
g:
pushq %rbp
movq %rsp, %rbp
עם שתי הפקודות האלה מה שאנחנו עושים זה דוחפים את הכתובת של rbp הישנה למחסנית, ולאחר מכן משווים את rbp ל rsp.
מה שנרצה לעשות עם זה היא שברגע שנגיע ל return address שלפני שנגיע ל return address של ה caller frame, נרצה לשחזר את rbp היכן שהוא היה קודם באמצעות pop.
כלומר, הסוף הפעולה נעשה
movq %rbp, %rsp
popq %rbp
ret
מה שזה מאפשר לנו לעשות זה ״להתעלם״ מכל המשתנים שיצרנו בפריים של הפונקצייה במחסנית, כי בעצם אנחנו לא יודעים כמה יצרנו, ולהגיע ישר למצביע שלפני ה return address. נזכיר גם שיש שהקומפילר אף פעם לא באמת מאפס ערכים שאנחנו לא קוראים במחסנית אז זה לא משנה שלא מחקנו אותם.
יכלנו להחליף את השורה הנ״ל בפקודה leave
שזה בידיוק מה שהוא עושה.
לסיכום, במקרה שבוא נשתמש בכל הכלים שלמדנו עד כה המחסנית תיראה ככה:
הקונבנציות שדיברנו עליהן מקודם מאפשרות לפונקציות להקרא באופן ריקורסיבי.
מאחר וכל קריאה היא בעלת מרחב פרטי במחסנית, המשתנים המקומיים של כמה קריאות ריקורסיביות לא משפיעות אחד על השני
ויתרה מכך, המחסנית באופן טבעי מספקת את ההתנהגות הרצויה בשביל הקצאה של זכרון מקומי למשתנים האלו כאשר הפונקצייה נקראת שוב, ומחיקתם מהזכרון כאשר הפונקצייה הריקורסיבית חוזרת.
למשל נסתכל על פונקציית פיבונצי
וזה ה stack frame שלה
בשפת c אין בדיקת תקינות של גודל המערך, כלומר נוכל לגלוש מהכתובות בזמן קומפילצייה ורק בזמן ריצה נגלה זאת.
משתנים מקומיים מאוחסנים במחסנית ביחד עם מידע על ערכי הregisters ו return addresses. והשילוב הזה של זליגה מכתובות זכרון ביחד עם העובדה שבמחסנית יש מידע חשוב שכזה יכולות להוביל לשגיאות חמורות בתוכנית, שבהן המידע השמור במחסנית ״מתלכלך״ על ידי כתיבה לאיבר במערך שהוא out of bounds.
באופן הזה כאשר התוכנית מנסה לטעון מידע מ register או לבצע פקודת ret דברים יכולים להשתבש.
נתבונן למשל, על המימוש של פונקציית הספרייה gets
הבעיה בפונקצייה הזאת שהיא קוראת תווים מסטרינג כלשהו עד שמגיע תו כלשהו שבכלל לא בטוח שיגיע. כלומר אני יכול להכניס סטרינג מאוד ארוך ולגרום ל buffer overflow. נשים לב שהקלט ש gets מקבל הוא ה buffer , כלומר אם ניתן buffer מאוד קטן ונקרא שורה שככל הנראה מספר התווים בה גדול מ 8 , נחרוג די מהר מהזכרון שהקצנו.