לדלג לתוכן

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;
}

ונקמפל אותה לאסמבלי:

gcc -O0 -S consume.c -o consume.s

זה מה שהקומפיילר מייצר בפרולוג (ניקינו את רעש ה-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

השורה הראשונה מעניינת במיוחד:

    testb   %al, %al
    je      .Lskip_sse

בקריאה לפונקציה 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 - אותו דבר, לרגיסטרי ה-SSE
  • overflow_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, הוא ממילא היה תופס סלוט שלם, אז השפה פשוט מכריזה על זה מראש.

מכאן נובעות שתי מסקנות מעשיות:

va_arg(ap, char);    // wrong - the value was promoted to int
va_arg(ap, int);     // correct

והמסקנה השנייה, החשובה יותר:

printf("%d\n", 3.0);   // undefined behavior

הערך 3.0 הוא double, ולכן הוא עבר ב-xmm0 ונשמר באזור ה-SSE. אבל %d גורם ל-printf לקרוא לפי gp_offset - כלומר מאזור המספרים השלמים, שבו יושב משהו אחר לגמרי. התוצאה היא זבל, והיא לא עקבית בין קומפיילרים או רמות אופטימיזציה. זו לא שגיאת עיגול, זו קריאה מהמקום הלא נכון.

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

לא כל הארכיטקטורות זהות

כל מה שראינו נכון ל-x86-64. על ארכיטקטורות אחרות המבנה שונה לגמרי. למשל, על arm64 של אפל va_list הוא פשוט מצביע יחיד:

printf("%zu\n", sizeof(va_list));
// 24 on x86-64 Linux
// 8  on arm64 macOS

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

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

סיכום

  • ב-System V AMD64 הארגומנטים עוברים ב-rdi, rsi, rdx, rcx, r8, r9 ובנוסף xmm0-xmm7 לממשיים
  • פונקציה variadic שופכת בפרולוג את הרגיסטרים ל-register save area בגודל 176 בתים
  • al מכיל את מספר רגיסטרי הווקטור שהקורא השתמש בהם, ומאפשר לדלג על שמירת ה-SSE
  • va_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 קורא מאזור המספרים השלמים
  • המבנה שונה בין ארכיטקטורות - תמיד השתמשו במאקרו ולא בשדות ישירות