บทความเทคนิค

การพอร์ตแอสเซมบลีแบบอินไลน์ _ftol จาก Delphi แบบ 32 บิตไปยัง DCC64

รูปแบบ _ftol ดั้งเดิมใน Delphi แบบ 32 บิตดูเหมือนโค้ดบรรทัดเดียวที่ฉลาดมาก: มันคือฟังก์ชัน wrapper ของ Pascal ที่เรียกใช้แอสเซมบลีแบบอินไลน์เพื่อจัดการกับ control word ของ x87 FPU, ตัดค่าบน FPU stack และ 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 อนุญาตให้ใช้แอสเซมบลีแบบอินไลน์ภายในรูทีน Pascal ทั่วไป คอมไพเลอร์รู้จัก calling convention แบบ 32 บิตและสามารถประเมินได้ว่าตัวแปรโลคัลและพารามิเตอร์อยู่ที่ไหน ดังนั้นมันจึงอนุญาตให้ใช้โค้ดแอสเซมบลีที่เข้าถึง stack frame ผ่านชื่อได้ แต่ DCC64 มีข้อกำหนดที่เข้มงวดกว่า: โค้ดแอสเซมบลีจะต้องอยู่ในฟังก์ชันแอสเซมเบลอร์เฉพาะ โดยที่โครงสร้างทั้งหมดจะต้องเป็นแอสเซมบลีและต้องจัดการกับ calling convention อย่างชัดเจน การผสมผสานระหว่าง Pascal กับ asm ไม่รองรับเลย

เหตุผลพื้นฐานมาจากสถาปัตยกรรม ใน calling convention ของ Windows แบบ 64 บิต (Microsoft ABI) พารามิเตอร์ 4 ตัวแรกจะถูกส่งเข้ามาใน RCX, RDX, R8 และ R9 สำหรับชนิดจำนวนเต็ม หรือใน XMM0 ถึง XMM3 สำหรับค่าจุดทศนิยมลอยตัว การส่งพารามิเตอร์ปกติจะไม่มีส่วนเกี่ยวข้องกับ x87 FPU เลย; ในทางเทคนิค x87 ยังใช้งานได้ แต่ ABI ไม่ได้ใช้มันในการส่งอาร์กิวเมนต์ โค้ดแอสเซมบลีที่ทึกทักเอาว่ามีค่า "อยู่บน FPU stack" กำลังประเมินสถานะที่ ABI แบบ 64 บิตไม่เคยสร้างขึ้น

ดังนั้นโค้ดดั้งเดิมไม่ได้มีแค่ปัญหาเรื่องซินแทกซ์เท่านั้น แม้ว่า DCC64 จะยอมรับโค้ดนี้ การทึกทักเรื่องรีจิสเตอร์ก็จะผิดอยู่ดี

แผนภาพ Delphi เปรียบเทียบ DCC32 ที่คอมไพล์ inline asm ในรูทีน Pascal ได้ กับ DCC64 ที่ยกเลิกด้วย E1025 และอนุญาตเฉพาะฟังก์ชันแอสเซมเบลอร์ล้วนภายใต้ Win64 ABI
DCC32 อดทนต่อบล็อก asm ภายในรูทีน Pascal ขณะที่ DCC64 ตอบกลับด้วย E1025 เพราะ Win64 ABI ส่งอาร์กิวเมนต์ผ่านรีจิสเตอร์แทนสแตก x87

การเขียนเวอร์ชันแอสเซมเบลอร์ 64 บิตที่ถูกต้อง

เมื่อคุณจำเป็นต้องส่งออกซิมโบล _ftol ด้วยคอนเวนชัน cdecl เพื่อความเข้ากันได้ของไบนารีจริงๆ ฟังก์ชันจะต้องถูกเขียนเป็นรูทีนแอสเซมเบลอร์แบบเพียวๆ ภายใต้ ABI แบบ 64 บิต พารามิเตอร์ Double จะมาใน XMM0 และผลลัพธ์ที่เป็นจำนวนเต็มจะต้องอยู่ใน RAX เมื่อส่งค่ากลับ directive .NOFRAME จะบอก DCC64 ว่ารูทีนนี้จะจัดการ stack ของตัวเอง ซึ่งเหมาะสมกับฟังก์ชันใบไม้ (leaf function) ที่สั้นขนาดนี้:

function _ftol: Integer; cdecl;
// คาดว่าจะมีค่า Double ใน XMM0 ตาม 64-bit ABI
asm
  .NOFRAME
  cvttsd2si  rax, xmm0   // truncate-to-integer โดยผลลัพธ์อยู่ใน rax
end;

CVTTSD2SI เป็นคำสั่ง SSE2 สำหรับแปลง float แบบ double-precision ไปเป็นจำนวนเต็มแบบมีเครื่องหมายโดยปัดเศษทิ้งเข้าหาศูนย์ (truncation toward zero) ซึ่งเป็นสิ่งที่ _ftol ควรจะทำพอดี มันใช้คำสั่งเดียว โดยดึงพารามิเตอร์โดยตรงจากที่ที่ ABI ทิ้งไว้ และวางผลลัพธ์ในที่ที่ ABI คาดหวัง ไม่จำเป็นต้องยุ่งยากกับการจัดการ FPU control-word เลย

ข้อควรระวังคือ หากค่าอินพุตเกินช่วงของจำนวนเต็มแบบมีเครื่องหมาย 32 บิต CVTTSD2SI จะส่งคืนค่าจำนวนเต็ม indefinite ($80000000) นี่เป็นพฤติกรรมเดียวกับ fistp ของ x87 เมื่ออินพุตเกินช่วง การตรวจสอบว่าโค้ดที่เรียกใช้ฟังก์ชันของคุณสามารถสร้างค่าดังกล่าวได้หรือไม่นั้นคุ้มค่าที่จะยืนยันก่อนจะถือว่าการย้ายระบบ (migration) เสร็จสมบูรณ์

แผนภาพเส้นทางข้อมูลใน Delphi แสดงพารามิเตอร์ Double มาถึงใน XMM0 แล้วถูกแปลงด้วยคำสั่ง CVTTSD2SI หนึ่งคำสั่งเป็นจำนวนเต็มตัดเศษที่คืนใน RAX โดยค่าที่เกินช่วงให้ผลลัพธ์เป็นแพตเทิร์น integer indefinite
ภายใต้ DCC64 ค่า double มาถึงใน XMM0 และ CVTTSD2SI วางผลลัพธ์ 32 บิตที่ตัดเศษไว้ใน RAX เป็นคำสั่ง SSE2 เพียงคำสั่งเดียว

เมื่อใดที่ Trunc เป็นคำตอบที่ดีกว่า

เวอร์ชันแอสเซมเบลอร์ข้างต้นคุ้มค่าที่จะเขียนก็ต่อเมื่อคุณมีข้อกำหนดเรื่องความเข้ากันได้ของไบนารีที่แท้จริง: คือมีการเรียกใช้จากภายนอกที่คาดหวังซิมโบล _ftol ด้วย calling convention ที่เฉพาะเจาะจง และคุณไม่สามารถเปลี่ยนผู้เรียกเหล่านั้นได้ สถานการณ์เช่นนี้ไม่พบบ่อยนัก ส่วนใหญ่แล้ว _ftol จะเป็นเพียงฟังก์ชันช่วยเหลือส่วนตัว (private helper) ที่ใช้ภายในยูนิตเดียวกันเท่านั้น และไม่มีการพึ่งพาจากภายนอกในเรื่องชื่อหรือคอนเวนชันของมันเลย

สำหรับกรณีนั้น ให้แทนที่มันด้วยโค้ด Pascal ธรรมดา:

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

Trunc จะปัดเศษทิ้งเข้าหาศูนย์ ซึ่งตรงกับสิ่งที่ _ftol ทำร่วมกับการตั้งค่า x87 control word เป็นโหมด truncation มันสามารถคอมไพล์บน DCC32 และ DCC64 ได้โดยไม่ต้องแก้ไข โค้ดคอมไพเลอร์จะสร้างคำสั่งที่เหมาะสมสำหรับแต่ละเป้าหมาย: บน x64 ปกติแล้วมันจะสร้าง CVTTSD2SI ออกมาเช่นกัน ซึ่งเป็นคำสั่งเดียวกับเวอร์ชันที่เขียนด้วยมือ คุณจะได้พฤติกรรมที่เหมือนกันเป๊ะ ไม่ต้องพึ่งเงื่อนไขแพลตฟอร์ม และไม่ต้องคอยดูแลโค้ดแอสเซมบลี

ความแตกต่างทางความหมายจุดหนึ่งที่ควรตรวจสอบคือ: Trunc จะทำให้เกิด exception EInvalidOp ในค่าคอนฟิกเริ่มต้นของ Delphi เมื่ออินพุตเป็น NaN หรืออินฟินิตี้ (infinity) ในขณะที่ fistp ของ x87 ในโค้ดดั้งเดิมจะแค่เขียนรูปแบบบิตลงไปโดยไม่ทำให้เกิดอะไรขึ้นเลย หากโค้ดของคุณป้อนค่า floating-point ที่ผิดปกติเข้าไปในฟังก์ชันนี้ และพฤติกรรมเดิมคือการเงียบหายไป ให้ป้องกันโดยใช้ IsNaN และ IsInfinite จากยูนิต Math ก่อนที่จะเรียก Trunc

แผนภาพการตัดสินใจพอร์ต _ftol ใน Delphi: แทนด้วย Trunc ธรรมดาเมื่อไม่มีผู้เรียกภายนอกต้องการสัญลักษณ์ cdecl มิฉะนั้นคง export แบบ pure assembler และกันค่า NaN หรืออนันต์ไม่ให้ยก EInvalidOp
ย้ายไปใช้ Trunc ธรรมดา เว้นแต่ผู้เรียกภายนอกพึ่งพาสัญลักษณ์ที่ส่งออก และให้กันชนอินพุต NaN หรืออนันต์จาก EInvalidOp ไม่ว่าทางไหน

การคอมไพล์แบบมีเงื่อนไขเมื่อทั้งสองเป้าหมายยังคงถูกใช้งาน

บางโปรเจ็กต์ยังต้องส่งมอบทั้งไบนารี 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;

นั่นคือการแก้ไขเชิงกลไกขั้นต่ำสุด และควรพิจารณาให้เป็นวิธีชั่วคราว โค้ดเบสที่พกพาแอสเซมบลีเฉพาะสถาปัตยกรรมไว้ในฟังก์ชันตัวช่วย (helper) ซึ่งมีวัตถุประสงค์เพียงเพื่อปัดเศษทศนิยมเป็นจำนวนเต็มนั้น เป็นการแบกรับหนี้ทางเทคนิคโดยไม่จำเป็น เส้นทาง 32 บิตสามารถถูกลบออกไปได้ทั้งหมดทันทีที่คุณยืนยันได้ว่าไม่มีสิ่งใดที่พึ่งพาผลข้างเคียงจาก FPU ในระบบเดิม

หากฟังก์ชันนี้ปรากฏในคอมโพเนนต์ที่ใช้ข้ามหลายยูนิต ให้ค้นหาคำว่า _ftol ในโค้ดเบสทั้งหมดก่อนตัดสินใจว่าจะย้ายระบบอย่างไร ซิมโบลชื่อนี้อาจถูกประกาศไว้ในหลายที่; ลิงเกอร์จะเลือกมาตัวหนึ่งและข้ามตัวอื่น ๆ ไปอย่างเงียบ ๆ ซึ่งหมายความว่าคุณอาจจะแก้ไขชุดหนึ่ง แต่ดันไปลิงก์กับอีกชุดหนึ่งที่ไม่เคยถูกแตะต้องเลย