varargs מתחת למכסה המנוע - System V AMD64 ABI¶
הקדמה¶
בפרק 4.9 למדנו להשתמש ב-va_start, va_arg ו-va_end, וכתבנו printf משלנו. השתמשנו במאקרו האלה כקופסה שחורה - אמרנו ל-va_arg איזה טיפוס אנחנו רוצים, והוא הביא אותו.
עכשיו, אחרי שראינו בפרק 10.1 איך נראה קוד אסמבלי בתוך C ואחרי שהבנו בפרק 8 איך המעבד באמת עובד, הגיע הזמן לפתוח את הקופסה. נגלה ש-va_list הוא לא מצביע פשוט, ושיש סיבה טובה מאוד לכך.
כל מה שנראה כאן ספציפי ל-System V AMD64 ABI - התקן שלפיו עובדים לינוקס ו-macOS על מעבדי x86-64. זה ה-ABI שהקוד שלנו רץ מולו לאורך כל הקורס.
תזכורת - איך מועברים ארגומנטים¶
ראינו בפרק 7.2 שארגומנטים לא מועברים על המחסנית כמו באסמבלי 16 ביט. ב-System V AMD64 הכלל הוא:
- ששת הארגומנטים הראשונים מטיפוס שלם או מצביע עוברים ב-
rdi,rsi,rdx,rcx,r8,r9 - שמונת הארגומנטים הראשונים מטיפוס ממשי עוברים ב-
xmm0עדxmm7 - כל מה שמעבר לזה עובר על המחסנית
עכשיו שימו לב לבעיה. הארגומנטים של פונקציה variadic יושבים בשני מקומות שונים: חלקם ברגיסטרים, וחלקם על המחסנית. va_arg צריך לעבור עליהם לפי הסדר, כאילו הם רצף אחד. מצביע פשוט שרץ על המחסנית לא יכול לעשות את זה - הוא לא יכול "להצביע" על רגיסטר.
אזור שמירת הרגיסטרים - register save area¶
הפתרון הוא פשוט ואלגנטי: בתחילת כל פונקציה variadic, הקומפיילר מייצר קוד ששופך את הרגיסטרים למחסנית. מהרגע הזה הכל נמצא בזיכרון, ואפשר להצביע עליו.
ניקח פונקציה פשוטה:
#include <stdarg.h>
int consume(int fixed, ...) {
va_list ap;
va_start(ap, fixed);
int a = va_arg(ap, int);
double d = va_arg(ap, double);
va_end(ap);
return a + (int)d;
}
ונקמפל אותה לאסמבלי:
זה מה שהקומפיילר מייצר בפרולוג (ניקינו את רעש ה-spill/reload של -O0 כדי שיהיה קריא):
consume:
pushq %rbp
movq %rsp, %rbp
subq $160, %rsp
testb %al, %al ; did the caller pass any vector registers?
je .Lskip_sse
movaps %xmm0, -176(%rbp) ; save all 8 SSE registers
movaps %xmm1, -160(%rbp)
movaps %xmm2, -144(%rbp)
movaps %xmm3, -128(%rbp)
movaps %xmm4, -112(%rbp)
movaps %xmm5, -96(%rbp)
movaps %xmm6, -80(%rbp)
movaps %xmm7, -64(%rbp)
.Lskip_sse:
movq %r9, -184(%rbp) ; save the integer registers
movq %r8, -192(%rbp)
movq %rcx, -200(%rbp)
movq %rdx, -208(%rbp)
movq %rsi, -216(%rbp)
; rdi is not saved here - it holds 'fixed'
movl %edi, -4(%rbp)
הפונקציה הזו לא קוראת אף פעם ל-xmm3, ובכל זאת הקומפיילר שומר אותו. הוא חייב - הוא לא יודע מראש מה יבקשו ממנו בזמן ריצה.
האזור שנוצר נקרא register save area, וגודלו קבוע: 48 בתים לששת רגיסטרי המספרים השלמים (6 × 8), ועוד 128 בתים לשמונת רגיסטרי ה-SSE (8 × 16). סך הכל 176 בתים, וזה בדיוק המרווח בין -224(%rbp) ל--48(%rbp) בקוד שלמעלה.
הדגל al¶
השורה הראשונה מעניינת במיוחד:
בקריאה לפונקציה variadic, ה-ABI מחייב את הקורא לשים ב-al את מספר רגיסטרי הווקטור שהוא השתמש בהם. אם הקורא לא העביר אף מספר ממשי, al יהיה 0, והפונקציה תדלג על שמירת 128 הבתים של ה-SSE. זו אופטימיזציה - רוב הקריאות ל-printf לא כוללות %f, ואין טעם לשלם על שמירה מיותרת.
זו הסיבה שקריאה לפונקציה variadic בלי הצהרה (prototype) יכולה לקרוס. בלי ההצהרה הקומפיילר לא יודע שהפונקציה variadic, לא יטרח לאתחל את
al, והפונקציה תשמור או לא תשמור רגיסטרים לפי ערך אקראי.
המבנה האמיתי של va_list¶
עכשיו אפשר להבין מה va_list באמת. ב-System V AMD64 הוא מוגדר כך:
typedef struct {
unsigned int gp_offset; // offset of the next integer register slot
unsigned int fp_offset; // offset of the next SSE register slot
void *overflow_arg_area; // the arguments that were passed on the stack
void *reg_save_area; // where the prologue dumped the registers
} va_list[1];
ארבעה שדות, 24 בתים בסך הכל. אפשר לאמת את זה בלי לזכור כלום:
#include <stdarg.h>
#include <stdio.h>
int main() {
printf("%zu\n", sizeof(va_list)); // 24 on x86-64
return 0;
}
gp_offset- ההיסט בתוך אזור השמירה של רגיסטר המספרים השלמים הבא שנקראfp_offset- אותו דבר, לרגיסטרי ה-SSEoverflow_arg_area- מצביע לארגומנטים שלא נכנסו לרגיסטרים ועברו על המחסניתreg_save_area- מצביע לתחילת האזור שהפרולוג מילא
ועכשיו לפרט שנראה טכני ומסביר המון: va_list מוגדר כמערך של איבר אחד, לא כמבנה רגיל. הסיבה היא שבC שם של מערך מתפוגג למצביע כשמעבירים אותו לפונקציה. כלומר, כשאנחנו כותבים vprintf(fmt, ap), מה שעובר בפועל הוא מצביע למבנה - ולכן vprintf יכולה לקדם את המונים שבתוכו, והשינוי נשמר גם אצלנו.
זה בדיוק מסביר את האזהרה מפרק 4.9: אחרי שהעברתם va_list ל-vprintf, הוא כבר לא במצב ההתחלתי שלו. vprintf שינתה אותו דרך המצביע. אם צריך לעבור שוב, va_copy מייצר עותק טרי.
va_start ו-va_arg באסמבלי¶
va_start הוא פשוט אתחול של ארבעת השדות:
leaq -32(%rbp), %rax ; rax = &ap
leaq -224(%rbp), %rcx
movq %rcx, 16(%rax) ; ap.reg_save_area = -224(%rbp)
leaq 16(%rbp), %rcx
movq %rcx, 8(%rax) ; ap.overflow_arg_area = 16(%rbp)
movl $48, 4(%rax) ; ap.fp_offset = 48
movl $8, (%rax) ; ap.gp_offset = 8
שימו לב ל-gp_offset = 8. הפונקציה שלנו מקבלת פרמטר רגיל אחד (fixed), שתפס את rdi. לכן הארגומנט ה-variadic הראשון יושב ב-rsi, שהוא הסלוט השני - היסט 8. הקומפיילר חישב את זה בזמן קומפילציה.
הfp_offset מתחיל ב-48 כי שם מתחיל אזור ה-SSE, מיד אחרי 48 הבתים של רגיסטרי המספרים השלמים.
ועכשיו va_arg(ap, int) עצמו:
movl (%rcx), %eax ; eax = ap.gp_offset
cmpl $40, %eax
ja .Loverflow ; no integer registers left - take it from the stack
addq 16(%rcx), %rax ; rax = ap.reg_save_area + gp_offset
addl $8, %edx
movl %edx, (%rcx) ; ap.gp_offset += 8
jmp .Ldone
.Loverflow:
movq 8(%rcx), %rax ; rax = ap.overflow_arg_area
addq $8, %rdx
movq %rdx, 8(%rcx) ; ap.overflow_arg_area += 8
.Ldone:
זה כל הסוד. va_arg הוא לא פונקציה - הוא מאקרו שמתפרש לכמה הוראות שעושות בדיוק דבר אחד: לבדוק אם נשארו רגיסטרים, ואם כן לקרוא מאזור השמירה, ואחרת לקרוא מהמחסנית. בשני המקרים הוא מקדם את המונה המתאים.
הסף 40 הוא הסלוט האחרון (r9 יושב בהיסט 40). ברגע ש-gp_offset עובר אותו, כל הרגיסטרים נוצלו וההמשך נמצא על המחסנית. עבור double הבדיקה תהיה cmpl $160 והקידום יהיה ב-16 - כי סלוטי ה-SSE רחבים יותר.
קידום טיפוסים - default argument promotions¶
עכשיו אפשר להסביר לעומק את הכלל שפגשנו בפרק 4.9. כשארגומנט עובר דרך ..., השפה מחייבת קידום אוטומטי:
- ה
char,shortו-_Boolמקודמים ל-int - ה
floatמקודם ל-double
הסיבה נראית מיד מהמבנה שלמעלה: הסלוטים באזור השמירה הם בגודל קבוע. אין סלוט של בית אחד. אם היינו מעבירים char, הוא ממילא היה תופס סלוט שלם, אז השפה פשוט מכריזה על זה מראש.
מכאן נובעות שתי מסקנות מעשיות:
והמסקנה השנייה, החשובה יותר:
הערך 3.0 הוא double, ולכן הוא עבר ב-xmm0 ונשמר באזור ה-SSE. אבל %d גורם ל-printf לקרוא לפי gp_offset - כלומר מאזור המספרים השלמים, שבו יושב משהו אחר לגמרי. התוצאה היא זבל, והיא לא עקבית בין קומפיילרים או רמות אופטימיזציה. זו לא שגיאת עיגול, זו קריאה מהמקום הלא נכון.
זה גם מסביר למה gcc -Wall מתעקש להזהיר על אי-התאמה בין מחרוזת הפורמט לארגומנטים. האזהרה הזו היא לא קפדנות - היא הדבר היחיד שעומד בינכם לבין קריאה מרגיסטר שגוי.
לא כל הארכיטקטורות זהות¶
כל מה שראינו נכון ל-x86-64. על ארכיטקטורות אחרות המבנה שונה לגמרי. למשל, על arm64 של אפל va_list הוא פשוט מצביע יחיד:
הסיבה היא ש-AAPCS מסדר את הארגומנטים אחרת, ולפעמים אפשר להסתפק במצביע שרץ קדימה.
המסקנה המעשית: לעולם אל תיגעו בשדות של va_list ישירות, ואל תניחו הנחות על הגודל שלו. השתמשו במאקרו. הם קיימים בדיוק כדי שהקוד שלכם יעבוד על כל ארכיטקטורה, והם מתקמפלים לאותן הוראות בדיוק שראינו כאן.
סיכום¶
- ב-System V AMD64 הארגומנטים עוברים ב-
rdi,rsi,rdx,rcx,r8,r9ובנוסףxmm0-xmm7לממשיים - פונקציה variadic שופכת בפרולוג את הרגיסטרים ל-register save area בגודל 176 בתים
alמכיל את מספר רגיסטרי הווקטור שהקורא השתמש בהם, ומאפשר לדלג על שמירת ה-SSEva_listעל x86-64 הוא מבנה בן 24 בתים:gp_offset,fp_offset,overflow_arg_area,reg_save_area- הוא מוגדר כמערך בן איבר אחד כדי שיעבור כמצביע - ולכן
vprintf"צורכת" אותו, ולכן קייםva_copy va_argהוא מאקרו שבודק אם נגמרו הרגיסטרים ובוחר בין אזור השמירה לבין המחסנית- קידום טיפוסים אוטומטי:
char/shortל-int,floatל-double printf("%d", 3.0)הוא UB כי הערך נמצא באזור ה-SSE ו-%dקורא מאזור המספרים השלמים- המבנה שונה בין ארכיטקטורות - תמיד השתמשו במאקרו ולא בשדות ישירות