מאמר טכני

העברת קוד אסמבלי בתוך השורה של _ftol מ-Delphi של 32 סיביות ל-DCC64

הניב (idiom) המקורי של _ftol ב-Delphi של 32 סיביות נראה כמו שורת קוד חכמה: עטיפת פונקציית Pascal שצונחת לקוד אסמבלי בתוך השורה (inline assembly) כדי לתפעל את מילת הבקרה של ה-FPU מסוג x87, לקטום (truncate) את הערך במחסנית ה-FPU, ולשלוף (pop) את התוצאה. זה נבנה היטב תחת DCC32 במשך זמן רב, וזו בדיוק הסיבה שזה הגיע לכל כך הרבה יחידות גרפיקה ו-PDF ישנות יותר מבלי שאף אחד הטיל בכך ספק

החלף את יעד הבנייה ל-64 סיביות והמהדר יפסיק עם E1025 Unsupported language feature: 'ASM'. שגיאה זו אינה אזהרת תאימות. המשמעות היא ש-DCC64 לא יהדר את השגרה כלל, ללא קשר לכמה טוב קוד האסמבלי עבד קודם לכן

הגרסה המקורית של 32 סיביות נראתה בדרך כלל כך:

function _ftol(f: Double): Integer; cdecl;
begin
  asm
    lea   eax, f
    fstp  qword ptr [eax]
  end;
  Result := Trunc(f);
end;

בלוק ה-asm הזה בתוך גוף begin...end של Pascal הוא בדיוק מה ש-DCC64 מסרב לו. לשני המהדרים יש כללים שונים לגבי היכן מותר להשתמש באסמבלי, והגבול משנה

מדוע DCC64 מותח את הקו בצורה שונה

DCC32 מתיר קוד אסמבלי בתוך השורה (inline assembly) בתוך שגרות Pascal רגילות. המהדר מכיר את מוסכמת הקריאה של 32 סיביות (calling convention) ויכול להסיק היכן חיים משתנים מקומיים ופרמטרים, ולכן הוא סובל מקטעי אסמבלי שמגיעים לתוך מסגרת המחסנית (stack frame) לפי שם. DCC64 נוקט בעמדה נוקשה יותר: אסמבלי חייב להיות בפונקציית אסמבלר ייעודית, כזו שבה כל הגוף הוא אסמבלי ומוסכמת הקריאה מטופלת במפורש. אין תמיכה כלל בערבוב של Pascal ו-asm

הסיבה הבסיסית היא ארכיטקטונית. במוסכמת הקריאה של Windows של 64 סיביות (Microsoft ABI), ארבעת הפרמטרים הראשונים מגיעים ב-RCX, RDX, R8 ו-R9 עבור סוגי מספרים שלמים, או ב-XMM0 עד XMM3 עבור נקודה צפה (floating-point). אין מעורבות של x87 FPU בהעברת פרמטרים רגילה; x87 זמין מבחינה טכנית אך ה-ABI אינו משתמש בו להעברת ארגומנטים. קוד אסמבלי המניח שערך נמצא "על מחסנית ה-FPU" מסיק לגבי מצב שה-ABI של 64 סיביות לעולם אינו יוצר

כך שלמקטע הישן אין רק בעיית תחביר (syntax). גם אם DCC64 היה מקבל אותו, ההנחות לגבי האוגרים (registers) היו שגויות

כתיבת גרסת אסמבלר תקינה של 64 סיביות

כאשר אתה באמת צריך לייצא סמל _ftol עם מוסכמת cdecl עבור תאימות בינארית, הפונקציה חייבת להיכתב כשגרת אסמבלר טהורה. תחת ה-ABI של 64 סיביות פרמטר Double מגיע ב-XMM0, ותוצאת המספר השלם חייבת להיות ב-RAX בחזרה. ההנחיה .NOFRAME אומרת ל-DCC64 שהשגרה מנהלת את המחסנית שלה בעצמה, מה שמתאים לפונקציית עלה (leaf function) קצרה כזו:

function _ftol: Integer; cdecl;
// Double value expected in XMM0 per 64-bit ABI
asm
  .NOFRAME
  cvttsd2si  rax, xmm0   // truncate-to-integer, result in rax
end;

CVTTSD2SI היא הוראת ה-SSE2 להמרת נקודה צפה בדיוק כפול (double-precision float) למספר שלם עם סימן תוך קטימה לכיוון אפס, שזה בדיוק מה ש-_ftol אמור לעשות. זוהי הוראה אחת, הלוקחת את הפרמטר ישירות מהמקום שבו ה-ABI השאיר אותו, ומניחה את התוצאה היכן שה-ABI מצפה לה. אין צורך בתמרוני מילת בקרה של FPU

שים לב שאם הקלט חורג מהטווח של מספר שלם עם סימן של 32 סיביות, CVTTSD2SI מחזיר את ערך המספר השלם הבלתי מוגדר ($80000000). זוהי אותה התנהגות כמו ה-fistp של x87 על קלט מחוץ לטווח. האם הקוראים שלך (callers) יכולים לייצר ערכים כאלה, כדאי לאמת לפני שמכריזים על סיום ההגירה (migration)

מתי Trunc היא התשובה הטובה יותר

גרסת האסמבלר לעיל כדאית לכתיבה רק כאשר יש לך דרישת תאימות בינארית ממשית: קורא חיצוני כלשהו מצפה לסמל _ftol עם מוסכמת קריאה ספציפית, ואינך יכול לשנות את הקוראים הללו. מצב זה אינו נפוץ. ברוב הפעמים, _ftol היה עוזר פרטי (private helper) ששימש רק בתוך אותה יחידה, ואין עליו כלל תלות חיצונית בשמו או במוסכמה שלו

במקרה זה, החלף אותו ב-Pascal רגיל:

function _ftol(f: Double): Integer; cdecl;
begin
  Result := Trunc(f);
end;

Trunc קוטם לכיוון אפס, מה שתואם למה ש-_ftol עשה כאשר מילת הבקרה של x87 הוגדרה למצב קטימה. הוא עובר הידור ב-DCC32 וב-DCC64 ללא שינוי. המהדר יוצר את ההוראה המתאימה לכל יעד: ב-x64 הוא יפלוט (emit) בדרך כלל את CVTTSD2SI בכל מקרה, אותה הוראה כמו בגרסה הכתובה ביד. אתה מקבל התנהגות זהה, ללא התניות פלטפורמה, וללא קוד אסמבלי לתחזק

ההבדל הסמנטי האחד ששווה לבדוק: Trunc מעלה חריגת EInvalidOp בתצורת ברירת המחדל של Delphi כאשר הקלט הוא NaN או אינסוף (infinity). ה-fistp של x87 בקוד המקורי פשוט כתב תבנית סיביות (bit pattern) מבלי להעלות דבר. אם הקוד שלך מזין ערכי נקודה צפה חריגים לתוך פונקציה זו וההתנהגות הישנה הייתה שקטה, הגן בעזרת IsNaN ו-IsInfinite מ-Math לפני קריאה ל-Trunc

הידור מותנה (Conditional compilation) כאשר שני היעדים נשארים פעילים

חלק מהפרויקטים חייבים להמשיך לספק קבצים בינאריים של 32 סיביות ו-64 סיביות. אם יש לשמור את גרסת האסמבלר המקורית עבור 32 סיביות ולספק יישום חדש עבור 64 סיביות, השתמש בהתניה CPUX64:

function _ftol(f: Double): Integer; cdecl;
begin
{$IFDEF CPUX64}
  Result := Trunc(f);
{$ELSE}
  // 32-bit path: DCC32 accepts inline asm
  asm
    lea   eax, f
    fstp  qword ptr [eax]
  end;
  Result := Trunc(f);
{$ENDIF}
end;

זהו התיקון המכני המינימלי, ושווה להתייחס אליו כזמני. בסיס קוד (codebase) שנושא קוד אסמבלי ספציפי לארכיטקטורה בתוך עוזר שמטרתו היחידה היא קטימה של נקודה צפה למספר שלם נושא חוב (debt) מיותר. ענף ה-32 סיביות יכול להיעלם לחלוטין ברגע שתוודא ששום דבר לא תלוי בתופעות הלוואי של ה-FPU של היישום הישן

אם הפונקציה מופיעה ברכיב המשמש על פני מספר יחידות, חפש בכל בסיס הקוד אחר _ftol לפני שתחליט כיצד להגר (migrate). סמל בשם זה עשוי להיות מוצהר ביותר ממקום אחד; המקשר (linker) בוחר אחד ומתעלם מהאחרים בשקט, מה שאומר שאתה עשוי לתקן עותק אחד ועדיין לקשר נגד עותק שונה שלא נגעו בו