לדלג לתוכן

אסמבלי בתוך C - inline assembly

הקדמה

בפרק 1 ו-2 למדנו לכתוב אסמבלי בקבצי .asm נפרדים, ובפרק 3 למדנו את שפת C. ראינו שהקומפיילר ממיר את קוד הC שלנו לאסמבלי, אבל מה אם אנחנו רוצים לשלוט בדיוק באיזה אסמבלי ירוץ - בתוך קוד C?

לזה קיים מנגנון שנקרא inline assembly - היכולת לכתוב פקודות אסמבלי ישירות בתוך קובץ C. זה כלי חזק שמאפשר לנו:

  • לגשת להוראות מעבד מיוחדות שאין להן מקבילה בC (כמו rdtsc, cpuid, cmpxchg)
  • לבצע אופטימיזציות קריטיות בקטעי קוד רגישים לביצועים
  • לתקשר ישירות עם חומרה (קריאת רגיסטרים מיוחדים של המעבד)
  • לבצע קריאות מערכת (syscalls) ישירות, ללא libc

אסמבלי בסיסי - basic asm

הצורה הפשוטה ביותר של inline assembly בGCC:

__asm__("nop");

או בקיצור:

asm("nop");

אפשר לכתוב כמה פקודות:

asm("nop\n\t"
    "nop\n\t"
    "nop");

שימו לב שכל פקודה מסתיימת ב-\n\t כדי שהפלט יהיה מפורמט נכון. הקומפיילר לוקח את המחרוזת הזו ומדביק אותה ישירות לתוך קוד האסמבלי שהוא מייצר.

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


אסמבלי מורחב - extended asm

הצורה המורחבת מאפשרת לנו לתקשר עם משתני C - לקרוא מהם ולכתוב אליהם:

int result;
__asm__("movl $42, %0" : "=r"(result));
printf("result = %d\n", result); // ידפיס 42

המבנה הכללי של extended asm:

asm [volatile] (
    "template"       // פקודות אסמבלי עם placeholders
    : outputs        // אופרנדים של פלט
    : inputs         // אופרנדים של קלט
    : clobbers       // רגיסטרים שנפגעים
);

ארבעה חלקים, מופרדים בנקודתיים:

  1. תבנית - template - מחרוזת עם פקודות האסמבלי. משתמשים ב-%0, %1, %2 וכו' כplaceholders לאופרנדים
  2. פלטים - outputs - משתני C שהאסמבלי כותב אליהם
  3. קלטים - inputs - משתני C שהאסמבלי קורא מהם
  4. משובשים - clobbers - רגיסטרים או זכרון שהאסמבלי משנה, אבל לא מופיעים בפלטים/קלטים

אופרנדים של פלט - output operands

כל אופרנד פלט מוגדר כך:

"=constraint"(c_variable)

הסימן = אומר שזה אופרנד כתיבה (output). הconstraint אומר לקומפיילר איפה לשים את הערך:

קיצור משמעות
r כל רגיסטר כללי
a רגיסטר eax/rax
b רגיסטר ebx/rbx
c רגיסטר ecx/rcx
d רגיסטר edx/rdx
m כתובת בזיכרון
i ערך מיידי - immediate (לקלט בלבד)

דוגמה - חיבור שני מספרים:

int a = 10, b = 20, result;
asm("addl %2, %1\n\t"
    "movl %1, %0"
    : "=r"(result)       // %0 - פלט: result
    : "r"(a), "r"(b)     // %1 - קלט: a, %2 - קלט: b
);
printf("result = %d\n", result); // 30

הקומפיילר יבחר רגיסטרים מתאימים ויחליף את %0, %1, %2 ברגיסטרים שבחר.


אופרנדים של קלט - input operands

אופרנדי קלט מוגדרים בצורה דומה, אבל בלי =:

"constraint"(c_expression)

אפשר להעביר כל ביטוי של C כקלט - משתנה, קבוע, תוצאה של חישוב.

דוגמה - הכפלה:

int x = 7, result;
asm("imull %1, %0"
    : "=r"(result)
    : "r"(x), "0"(x)  // "0" אומר: תשתמש באותו רגיסטר כמו אופרנד 0
);
// result = x * x = 49

הconstraint "0" הוא מיוחד - הוא אומר "תשתמש באותו רגיסטר כמו אופרנד מספר 0". זה שימושי כשפקודת האסמבלי קוראת וגם כותבת לאותו רגיסטר.


רשימת משובשים - clobber list

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

asm("cpuid"
    : "=a"(eax_val), "=b"(ebx_val), "=c"(ecx_val), "=d"(edx_val)
    : "a"(function_id)
    : // אין clobbers כאן כי כל הרגיסטרים שcpuid משנה מופיעים בפלטים
);

אבל אם האסמבלי משנה רגיסטר שלא מופיע בoutputs, חובה לציין אותו:

asm("xorl %%ecx, %%ecx\n\t"
    "movl %1, %%eax\n\t"
    "addl $5, %%eax\n\t"
    "movl %%eax, %0"
    : "=r"(result)
    : "r"(input)
    : "eax", "ecx"  // האסמבלי משנה את eax ו-ecx
);

שני clobbers מיוחדים:

  • "memory" - אומר לקומפיילר שהאסמבלי קורא או כותב לזכרון שלא צוין במפורש. זה מונע מהקומפיילר לשמור ערכים ברגיסטרים מעבר לנקודה הזו.
  • "cc" - אומר לקומפיילר שהאסמבלי משנה את דגלי המצב (flags register). כמעט כל פקודת חישוב משנה את הדגלים, אז זה נפוץ מאוד.

חשוב: ב-extended asm, שמות רגיסטרים בtemplate דורשים %% (שני סימני אחוז) במקום % אחד. זה כי % בודד שמור לplaceholders כמו %0, %1.


מילת המפתח volatile

כברירת מחדל, הקומפיילר רשאי להזיז, לשכפל, או אפילו למחוק בלוק asm אם הוא חושב שהתוצאה לא משמשת, או שאפשר לבצע את החישוב פעם אחת ולשמור את התוצאה.

asm volatile אומר לקומפיילר: אל תיגע בבלוק הזה - תריץ אותו בדיוק איפה שהוא כתוב, בדיוק כמה פעמים שהוא כתוב:

asm volatile("rdtsc" : "=a"(lo), "=d"(hi));

מתי חובה להשתמש ב-volatile?

  • כשהאסמבלי מבצע side effects (כמו כתיבה לחומרה)
  • כשהתוצאה תלויה בזמן הריצה (כמו rdtsc)
  • כשחשוב שהאסמבלי ירוץ בדיוק בנקודה הזו בקוד

דוגמאות מעשיות

קריאת מונה הזמן של המעבד - TSC - Time Stamp Counter

הפקודה rdtsc קוראת מונה שסופר מחזורי שעון מאז שהמעבד הופעל. זה מאפשר מדידת זמן ברזולוציה של מחזור שעון בודד:

#include <stdio.h>
#include <stdint.h>

static inline uint64_t read_tsc(void)
{
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi << 32) | lo;
}

int main(void)
{
    uint64_t start = read_tsc();

    // קוד שרוצים למדוד
    volatile int sum = 0;
    for (int i = 0; i < 1000000; i++)
        sum += i;

    uint64_t end = read_tsc();
    printf("cycles: %lu\n", end - start);
    return 0;
}

הפקודה rdtsc שמה את 32 הביטים הנמוכים של המונה ב-eax ואת 32 הביטים הגבוהים ב-edx. לכן אנחנו משתמשים ב-"=a" ו-"=d" ומרכיבים ערך 64-ביט.


פקודת CPUID - קבלת מידע על המעבד

הפקודה cpuid מחזירה מידע על המעבד - דגם, יצרן, יכולות נתמכות (כמו SSE, AVX וכו'):

#include <stdio.h>
#include <stdint.h>

void get_cpu_vendor(char *vendor)
{
    uint32_t ebx, ecx, edx;

    asm("cpuid"
        : "=b"(ebx), "=c"(ecx), "=d"(edx)
        : "a"(0)  // function 0 = get vendor string
    );

    // הvendor string מחולק בין שלושה רגיסטרים
    *((uint32_t *)vendor)     = ebx;
    *((uint32_t *)(vendor+4)) = edx;
    *((uint32_t *)(vendor+8)) = ecx;
    vendor[12] = '\0';
}

int main(void)
{
    char vendor[13];
    get_cpu_vendor(vendor);
    printf("CPU vendor: %s\n", vendor);
    // למשל: "GenuineIntel" או "AuthenticAMD"
    return 0;
}

כשקוראים ל-cpuid עם eax=0, המעבד מחזיר את מחרוזת היצרן בשלושה רגיסטרים: ebx, edx, ecx (כן, בסדר הזה).


השוואה-והחלפה אטומית - atomic compare-and-swap

הפקודה cmpxchg (compare and exchange) היא הבסיס לתכנות נטול נעילות (lock-free). היא משווה את הערך ב-eax עם יעד - אם שווים, שמה את הערך החדש ביעד; אם לא, שמה את הערך הנוכחי של היעד ב-eax:

#include <stdio.h>
#include <stdint.h>

// מחזירה 1 אם ההחלפה הצליחה, 0 אם לא
static inline int cas(volatile int *ptr, int expected, int desired)
{
    int result;
    asm volatile(
        "lock cmpxchgl %3, %1\n\t"
        "sete %%cl\n\t"
        "movzbl %%cl, %0"
        : "=r"(result), "+m"(*ptr), "+a"(expected)
        : "r"(desired)
        : "cl", "cc", "memory"
    );
    return result;
}

int main(void)
{
    int val = 5;

    if (cas(&val, 5, 10))
        printf("swap succeeded, val = %d\n", val); // val = 10
    else
        printf("swap failed, val = %d\n", val);

    if (cas(&val, 5, 20))
        printf("swap succeeded, val = %d\n", val);
    else
        printf("swap failed, val = %d\n", val); // val עדיין 10

    return 0;
}

הprefix lock הופך את הפקודה לאטומית - המעבד נועל את ה-cache line כדי שאף ליבה אחרת לא תוכל לגשת לאותו זכרון באמצע הפעולה. ראינו את הרעיון הזה בפרק 5.8 כשדיברנו על threads ומנעולים.


קריאת מערכת ישירה - syscall ללא libc

בפרק 6 למדנו על syscalls. בדרך כלל אנחנו קוראים להם דרך libc, אבל אפשר לקרוא להם ישירות:

#include <stdio.h>

static inline long my_write(int fd, const void *buf, long count)
{
    long ret;
    asm volatile(
        "syscall"
        : "=a"(ret)
        : "a"(1),           // syscall number: write = 1 (x86_64)
          "D"(fd),           // rdi = first argument
          "S"(buf),          // rsi = second argument
          "d"(count)         // rdx = third argument
        : "rcx", "r11", "memory"  // syscall משנה את rcx ו-r11
    );
    return ret;
}

int main(void)
{
    const char msg[] = "hello from inline asm!\n";
    my_write(1, msg, sizeof(msg) - 1);
    return 0;
}

בx86_64, הconvention של syscalls הוא: מספר הsyscall ב-rax, ארגומנטים ב-rdi, rsi, rdx, r10, r8, r9. הפקודה syscall מבצעת את הקריאה, וערך ההחזרה חוזר ב-rax.

הclobbers "rcx" ו-"r11" הם חובה כי הפקודה syscall דורסת את הרגיסטרים האלו (שומרת בהם את הrip ו-rflags למטרות חזרה).


טבלת constraints נפוצים

להלן טבלה מסכמת של הconstraints הנפוצים ביותר:

קיצור שם תיאור
r רגיסטר כללי - general register הקומפיילר בוחר רגיסטר מתאים
a eax/rax רגיסטר A
b ebx/rbx רגיסטר B
c ecx/rcx רגיסטר C
d edx/rdx רגיסטר D
S esi/rsi רגיסטר Source Index
D edi/rdi רגיסטר Destination Index
m זכרון - memory כתובת בזיכרון (לא רגיסטר)
i מיידי - immediate קבוע בזמן קומפילציה
g כללי - general רגיסטר, זכרון, או מיידי
= כתיבה - write-only אופרנד פלט
+ קריאה וכתיבה - read-write אופרנד שמשמש גם כקלט וגם כפלט
& משובש מוקדם - early clobber רגיסטר הפלט נכתב לפני שכל הקלטים נקראו

מתי לא להשתמש באסמבלי תוך-שורתי

למרות שinline assembly הוא כלי חזק, ברוב המקרים עדיף לא להשתמש בו:

  1. אינטרינזיקס - compiler intrinsics - הקומפיילר מספק פונקציות מובנות שמתורגמות ישירות לפקודות אסמבלי, אבל הקומפיילר יכול לבצע עליהן אופטימיזציות:
  2. __builtin_popcount(x) במקום לכתוב popcnt באסמבלי
  3. __builtin_expect(x, 0) לרמוז לקומפיילר על branch prediction
  4. __sync_val_compare_and_swap() במקום cmpxchg ידני
  5. הheader <immintrin.h> מספק intrinsics ל-SSE, AVX ועוד

  6. ניידות - portability - inline assembly ספציפי לארכיטקטורה ולקומפיילר. קוד עם inline asm לx86 לא יעבוד על ARM

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

  8. תחזוקה - קוד אסמבלי קשה יותר לקריאה, לדיבוג, ולתחזוקה

הכלל: תשתמשו ב-inline assembly רק כשאין חלופה בC או ב-intrinsics - בעיקר לגישה להוראות מעבד מיוחדות, אינטראקציה עם חומרה, או syscalls ישירים.


סיכום

  • אסמבלי תוך-שורתי מאפשר לשלב פקודות אסמבלי ישירות בקוד C
  • הצורה המורחבת (extended asm) מאפשרת תקשורת עם משתני C דרך מערכת של constraints
  • חשוב להצהיר על כל רגיסטר שהאסמבלי משנה ברשימת הclobbers, אחרת הקומפיילר עלול להניח שהרגיסטר לא השתנה ולייצר קוד שגוי
  • volatile מונע מהקומפיילר לבצע אופטימיזציות על בלוק האסמבלי
  • ברוב המקרים עדיף להשתמש ב-compiler intrinsics - inline assembly שמור למקרים מיוחדים