מאמר טכני

העברת קוד אסמבלי בתוך השורה של _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) היו שגויות

תרשים Delphi שמשווה את DCC32, שמהדר asm inline בתוך שגרות Pascal, מול DCC64, שנכשל עם E1025 ומתיר רק פונקציות assembler טהורות תחת ה-ABI של Win64
DCC32 סובל בלוק asm בתוך רוטינת Pascal בעוד DCC64 עונה E1025, משום שה-ABI של Win64 מעביר ארגומנטים ברגיסטרים במקום מחסנית ה-x87

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

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

function _ftol: Integer; cdecl;
// ערך Double מצופה ב-XMM0 לפי ABI של 64 סיביות
asm
  .NOFRAME
  cvttsd2si  rax, xmm0   // חיתוך למספר שלם, התוצאה ב-rax
end;

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

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

Delphi: תרשים נתיב נתונים שמציג פרמטר Double שמגיע ב-XMM0 ומומר על ידי הוראת CVTTSD2SI אחת אל שלם קטום שמוחזר ב-RAX, כאשר ערכים מחוץ לטווח מניבים את תבנית ה-integer indefinite
תחת DCC64 ה-double מגיע ב-XMM0 ו-CVTTSD2SI מניחה את התוצאה ה-32-bit הקטומה ב-RAX כהוראת SSE2 אחת

מתי 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

תרשים החלטות Delphi ל-porting של _ftol: להחליף אותו ב-Trunc פשוט כאשר אין קורא חיצוני שזקוק לסימן cdecl, אחרת להשאיר export של assembler טהור, ולהגן על קלטי NaN או אינסוף מפני EInvalidOp
הגרו אל Trunc רגיל אלא אם קוראים חיצוניים תלויים ב-symbol המיוצא, והגנו על קלט NaN או אינסוף מפני EInvalidOp בשתי הדרכים

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

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

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

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

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