Functions in assembly

procedures

דיברנו על control flow in assembly אבל החלק החסר הוא איך מחזירים ערכים מתהליכים. כאן נכנס לתמונה ה procedures שזה בעצם פונקציות.
פונקצייה היא תהליך שבו מעבירים מידע והעברה של שליטה של הקוד ל scope אחר. המידע הוא בעצם הפרמטרים של הפונקצייה.
כאשר מעבירים אחריות לפונקצייה, היא יכולה להקצות גודל חדש אם יש לה משתנים מקומיים.
ב x86-64 Assembly ישנן פקודות פשוטות להעברת השליטה לפונקצייה.

שימוש ב stack

x86 משתמש במחסנית כדי לתמוך בקריאה לתהליכים.
נעשה שימוש בשביל

  1. העברת הפרמטרים לפונקצייה (או לפחות חלק)
  2. לאחסן מידע שחוזר מהפונקצייה וגם לאן לחזור return address.
  3. שמירה רגיסטרים לשחזור מאוחר יותר של המידע שחזר מהפונקצייה
  4. זכרון מקומי - local storage

החלק במחסנית שמוקצה לפונקצייה בודדת נקרא stack frame.

פקודות להעברת שליטה

Pasted image 20221224125412.png|450
CALL
RET

call

פקודה דומה ל jump . שולחת את RIP לכתובת שעליה בוצעה הפקודה ודוחפת את ה return address למחסנית.
הreturn address זה הכתובת של השורה הבאה אחרי ה call.

נסתכל על הדוגמה הבאה

call g
movq %rax, %rdx --> the return address is the address of this instruction.

Pasted image 20221224130203.png|350
נשים לב שתמיד יש איזשהו איזון בדחיפה והוצאה של rsp באופן הזה, שכן תמיד על ידי שימור בפקודה #ret אנחנו נוציא את הreturn address מהמחסנית

ret

העברת ארגומנטים

  1. ששת הארגומנטים הראשונים עוברים על ידי הרגיסטרים הבאים
    Pasted image 20221224131206.png|300
  2. rax שומר את ה return value .

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

ומה קורה כשיש יותר מ 7 ארגומנטים לפונקצייה?
נצטרך לשים אותם ישירות במחסנית, בסדר הפוך , ובכתובות זכרון שהן כפולה של 8.
הסיבה שצריך להיות כפולה של 8 היא בגלל איך ש PUSH עובד, הוא מזיז את rsp ב8 בייטים כל פעם.
Pasted image 20221224141243.png|200
המטרה שדוחפים ארגומנטים בסדר הפוך, היא כדי שהפונקצייה שתקרא את הפרמטרים האלה תוכל לקרוא אותם מהמחסנית בסדר שבוא שלחנו אותם.
חשוב לשים לב, זוהי הכנה לפני קריאה לפונקצייה מסויימת ולכן ה 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

Screenshot 2022-12-24 at 14.27.17.png|300
נשים לב שלקחנו את x7,x8 על ידי שימוש בכתובת שrsp מצביע עליה בקפיצות של 8.
חשוב מאוד אחרי שייקרא הret בפונקצייה f , ה rip יחזור לכתובת של הפקודה הבאה אבל rsp לאחר פקודת pop יצביע על הכתובת של x7, באחריות בעל ה caller frame להחזיר בחזרה את rsp על ידי ביצוע מספר פקודות הpop הדרושות כדי למחוק את הנעלמים ששמרנו במחסנית.

באופן כללי, פונקצייה כלשהי כקונבנצייה תמיד תדבר עם הframe שלה והcaller frame שלה בלבד, כלומר שאם f תפעיל פונקצייה מסויימת g וזאת תפעיל פונקצייה שלישית שצריכה את x7,x8 עלינו להעתיק אותם ל caller frame כלומר ל פריים של g.

Pasted image 20221224145727.png|300

דוגמה קצת יותר מורכבת

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 ייראה ככה באסמבלי
Pasted image 20221224150621.png
נשים לב שכאן אנחנו מעבירים את המשתנים במחסנית ולא ברגיסטרים, ניתן לשים לב לזה על ידי הפקודה הראשונה שזיזה את rsp למטה ב8 בייטים כלומר 2 אינטים שכל אחד הוא 4 בייט.
אחרי השורה הראשונה מה שקורה הוא ש rsp=Mem[rsp]8 שכן הכתובות קטנות בגודלן ככל שיורדים במחסנית.
כעת נרצה לאכסן את שתי המספרים הקבועים שלנו במחסנית, על ידי movl, בפעם הראשונה שמנו בכתובת rsp+4 את הערך 534 ומייד מתחתיו שמנו את 1057.

Pasted image 20221224151215.png|300
נשים לב שהארגומנטים שאנחנו מעבירים הם הכתובת בזכרון של שתי הפרמטרים האלה, ולכן מלכתחילה שמרנו אותם במחסנית כי בשורות הבאות אנחנו מעבירים לפי קונבנצייה את שתי הפרמטרים הראשונים את הכתובות שלהם במחסנית.
לא כתוב את זה למעלה אבל בשורה 8, היינו משחררים את הזכרון שהוספנו ל rsp.

swap_add ייראה ככה באסמבלי
Pasted image 20221224153917.png|450

נשים לב שהתוצאה אכן נמצאת ב rax.
ותמונת המחסנית תיראה ככה:
Pasted image 20221224153936.png|450

חשיבות ה registers בעבודה עם procedures

הregisters שאנחנו מכירים הם מקור אחד לכל הפונקציות של התוכנית.
צריך לשים לב שכאשר פונקצייה אחת (caller) קורא לפונקצייה אחרת (callee), אז הcallee לא דורס ערכים ברגיסטרים שהcaller תכנן להשתמש בהם אחר כך.
בשביל כך יש קונבנציות ב x86-64 עבור שימור בregistes שכל הפונקציות צריכות לכבד, כולל פונקציות ספרייה.

registers conventions

caller save:

הרגיסטרים

rax,rdi,rsi,rdx,rcx,r8,r9,r10,r11

מסווגים כ caller save registers. המשמעות היא שאחריות ה caller לשמר אותם בהנחה שמשתמשים בהם. המשמעות היא שאם Q היא פונקצייה שמפעילה את P, אז P יכולה לדרוס את הregisters האלה על ידי כתיבה שלהם מחדש בלי לדאוג למידע ש Q משתמש בו.
דוגמה טובה לכך היא rax שאנחנו יודעים שכקונבנצייה היא שומרת את ערך החזרה של הפונקצייה.

callee save:

הרגיסטרים

rbx,r12,r13,r14,r15,rbp

נקראים callee save registers. כלומר ישנה אחריות על הפונקצייה שקוראים לה , נניח שזאת P , במצב שבו היא צריכה לדרוס את הערכים שם , עליה לשמור את הערכים בregisters האלו ב stack frame לפני שהיא דורסת אותם. לאחר שהיא דורסת אותם עליה להחזיר את הערכים לרגיסטרים האלה לפני שהיא קוראת ל ret. כי מי שקרא ל P נניח שזה Q בגלל הקונבנצייה מניח ש הוא הולך להשתמש בערכים האלה לאחר ש P יסתיים.

דגשים נוספים:

  1. rsp ולפעמים rbp חייב להיות מנוהל לפי הקונבנציות שנלמדו, כלומר, עליהם לשמש אותנו רק כדי לנהל את המחסנית הframe .
  2. rax לפי הקונבנצייה יש לו חשיבות ספציפית יותר שהיא לשמור את הערך המוחזר.

Caller VS Callee

נתבונן בפונקצייה הבאה

int P(int x) {
  int y = x*x;
  int z = Q(y);
  return y+z;
}

פונקצייה P מחשבת את y לפני שקוראת ל Q אבל היא חייבת לשים לב שהערך של y זמין אחרי ש Q חוזרת. ניתן לעשות זאת בשתי הדרכים הבאות :

  1. לשמור את y ברגיסטר callee וככה אנחנו יודעים ש Q ידאג שy יחזור לאותו רגיסטר.
  2. לשמור את y ב stack frame משלו לפני קריאה ל Q, כאשר Q חוזרת, ניתן לקחת את y מהמחסנית.

כעת, נחזור לקוד שכתבנו מקודם

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 (למרות שהגרסה הקודמת יותר טובה כי לא הוספנו זכרון במחסנית שאנחנו יודעים שלגשת לזכרון ולא לרגיסטר זאת פעולה יקרה יותר).

Pasted image 20221224164003.png|350
נשים לב שבשורות 10, 11 שחזרנו את הערכים מהרגיסטרים שהחזרנו למחסנית רק בסדר הפוך.

Variable - Size Frame

נניח שאין לנו את גודל הפריים בזמן קומפילצייה.
במצב זה כפי שדיברנו ב מבנה התוכנית באסמבלי , ה rbp ישמש אותנו כדי לשמור פוינטר לתחילת הפריים.
Pasted image 20221224164206.png|350

כשאנחנו מתחילים פונקציה עם variable size frame נשים בתחילתה

g:
	pushq %rbp
	movq %rsp, %rbp

Pasted image 20221224171514.png|250
עם שתי הפקודות האלה מה שאנחנו עושים זה דוחפים את הכתובת של rbp הישנה למחסנית, ולאחר מכן משווים את rbp ל rsp.
מה שנרצה לעשות עם זה היא שברגע שנגיע ל return address שלפני שנגיע ל return address של ה caller frame, נרצה לשחזר את rbp היכן שהוא היה קודם באמצעות pop.
כלומר, הסוף הפעולה נעשה

movq %rbp, %rsp
popq %rbp
ret

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

Pasted image 20221224171942.png|350

יכלנו להחליף את השורה הנ״ל בפקודה leave שזה בידיוק מה שהוא עושה.

לסיכום, במקרה שבוא נשתמש בכל הכלים שלמדנו עד כה המחסנית תיראה ככה:
Pasted image 20221224172438.png|400

פונקצייה ריקורסיבית

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

למשל נסתכל על פונקציית פיבונצי
Pasted image 20221224173524.png|300
וזה ה stack frame שלה
Pasted image 20221224174020.png|250

IA32 Procedures

Out of bounds Memory References and Buffer Overflow

Buffer Overflow

בשפת c אין בדיקת תקינות של גודל המערך, כלומר נוכל לגלוש מהכתובות בזמן קומפילצייה ורק בזמן ריצה נגלה זאת.
משתנים מקומיים מאוחסנים במחסנית ביחד עם מידע על ערכי הregisters ו return addresses. והשילוב הזה של זליגה מכתובות זכרון ביחד עם העובדה שבמחסנית יש מידע חשוב שכזה יכולות להוביל לשגיאות חמורות בתוכנית, שבהן המידע השמור במחסנית ״מתלכלך״ על ידי כתיבה לאיבר במערך שהוא out of bounds.
באופן הזה כאשר התוכנית מנסה לטעון מידע מ register או לבצע פקודת ret דברים יכולים להשתבש.

נתבונן למשל, על המימוש של פונקציית הספרייה gets

Pasted image 20221224174755.png|350
הבעיה בפונקצייה הזאת שהיא קוראת תווים מסטרינג כלשהו עד שמגיע תו כלשהו שבכלל לא בטוח שיגיע. כלומר אני יכול להכניס סטרינג מאוד ארוך ולגרום ל buffer overflow. נשים לב שהקלט ש gets מקבל הוא ה buffer , כלומר אם ניתן buffer מאוד קטן ונקרא שורה שככל הנראה מספר התווים בה גדול מ 8 , נחרוג די מהר מהזכרון שהקצנו.

Pasted image 20221224180313.png|300