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

Free Pascal บน Win32: การตกแต่ง C symbol ใน HotPDF

Free Pascal บน Win32 เติมขีดล่างนำหน้าให้ทุก import ที่ขึ้นต้นด้วย cdecl; external โดยอัตโนมัติ ขณะที่ public name จะ export สตริงที่คุณเขียนออกไปตรงตัวอักษรต่อตัวอักษร HotPDF ต้องทำให้ธรรมเนียมทั้งสองพอใจในซอร์สทรีเดียวกัน เพราะบิลด์ Delphi ส่งประกาศ import ที่พิมพ์ขีดล่างด้วยมือไว้แล้วเป็นสินค้าออกมาแล้ว พลาดตรงความไม่สมมาตรนี้เมื่อไร ก็จะได้ link error ที่ชี้ไปที่ symbol ที่ไม่มีใครเคยเขียน

การขยายไลบรารี Delphi ไปสู่ Free Pascal มักถูกเล่าให้เป็นปัญหา portability และบน Win64 มันก็เป็นแบบนั้นส่วนใหญ่จริง แต่ Win32 ไม่เหมือนกัน ABI ของ Windows x86 32 บิตพกธรรมเนียมสะสมมาสามสิบปีว่า C symbol สะกดอย่างไร ใครเป็นผู้เก็บกวาดสแตก และ helper เฉพาะของคอมไพเลอร์ตัวไหนที่ translation unit มีสิทธิ์สมมติว่ามีอยู่ และแต่ละข้อคือจุดที่คอมไพเลอร์ Pascal สองตัวที่เห็นพ้องเรื่องภาษาก็ยังเห็นต่างเรื่องไฟล์ออบเจกต์ได้

ทำไม symbol ตัวเดียวกันถึง resolve ผ่านบน Win64 แต่พังบน Win32

เพราะขีดล่างนำหน้าเป็นธรรมเนียมของฝั่ง 32 บิตที่ Free Pascal ใช้กับ import แต่ไม่ใช้กับ export ประกาศ function deflate(...): Integer; cdecl; external; แล้ว FPC จะตามหา _deflate ในไฟล์ออบเจกต์บน Win32 แต่หา deflate บน Win64 นั่นคือพฤติกรรมที่ถูกต้องและตรงกับสิ่งที่คอมไพเลอร์ C ปล่อยออกมา กับดักอยู่อีกฝั่งของบริดจ์: routine ที่ทำเครื่องหมาย public name 'deflate' จะ export เป็น deflate เป๊ะ ๆ บนทั้งสองเป้าหมาย โดยไม่มี prefix ต่อท้าย

ทีนี้เติมรายละเอียดประวัติศาสตร์ที่ทำให้เรื่องจับต้องได้ บิลด์ Delphi ประกาศ entry point บางตัวในกลุ่มนี้โดยพิมพ์ขีดล่างรวมอยู่ในชื่อไปแล้ว เพราะนั่นคือสิ่งที่ไฟล์ออบเจกต์ของมันเองบรรจุอยู่ ป้อนประกาศเดียวกันให้ FPC บน Win32 คอมไพเลอร์ก็เติมขีดล่างซ้ำอีกหนึ่งอย่างถูกหน้าที่ ลิงก์เกอร์จึงไล่ล่าง __deflate ซึ่งเป็น symbol ที่ไม่มีใคร export ทางแก้ตามสัญชาตญาณคือเติมขีดล่างเพิ่มทุกที่ ซึ่งทำ import ที่สะกดถูกอยู่แล้วพังแทน

สิ่งที่ได้ผลคือใช้คู่ค่า prefix แทนค่าเดียว HPDFFPCZLib และ HPDFFPCCodecStubs ใช้ prefix หนึ่งสำหรับ import C ธรรมดา และอีกค่าสำหรับ import ที่พก prefix ฝั่ง Delphi มาแล้ว บน Win64 ทั้งสองค่าเป็นสตริงว่าง ชื่อลิงก์เดิมจึงรอดไปแบบไม่ถูกแตะ สองค่าแทนหนึ่งค่าคือทางแก้ทั้งหมดของเรื่องนี้ และมันจะเห็นชัดก็ต่อเมื่อคุณแยกกฎฝั่ง import ออกจากกฎฝั่ง export ได้เท่านั้น

ประกาศ C symbol ชุดเดียวกันถูก resolve โดย Free Pascal และ Delphi บน Win64 และ Win32: import แบบ cdecl ได้ขีดล่างต่อเมื่ออยู่บนเป้าหมาย 32 บิต ประกาศของ Delphi ที่พิมพ์ขีดล่างไว้แล้วกลายเป็น __deflate และลิงก์ไม่ผ่าน ขณะที่ export แบบ public name คงตรงตัวบนทั้งสองสถาปัตยกรรม
ค่า prefix เดียวรับใช้สองกฎไม่ไหว import แบบ cdecl ธรรมดากับ import ที่พกขีดล่างของ Delphi มาแล้ว decorate ต่างกันใต้ FPC บน Win32 HotPDF จึงเก็บสองค่า แล้วปล่อยให้ทั้งคู่ว่างเปล่าบน Win64
// ใช้สอง prefix ไม่ใช่หนึ่ง import C ธรรมดากับ import ที่พก prefix
// ของ Delphi ที่พิมพ์มือเอาไว้ decorate ต่างกันใต้ FPC/Win32
const
{$IF DEFINED(FPC) and DEFINED(CPU32)}
  CPrefix     = '_';   // FPC เติมขีดล่างนี้ให้เองสำหรับ cdecl external
  DelphiCName = '';    // พิมพ์ขีดล่างไว้ในซอร์สเรียบร้อยแล้ว
{$ELSE}
  CPrefix     = '';
  DelphiCName = '';
{$IFEND}

// ฝั่ง export: 'public name' เป็น literal บนทุกเป้าหมาย
procedure hpdf_codec_free(P: Pointer); cdecl;
  public name 'hpdf_codec_free';

WIN32 บอกสถาปัตยกรรม ไม่ได้บอก ABI

นี่คือความผิดพลาดเรื่อง conditional compilation ที่มีหางการดีบักยาวที่สุด และควรพูดตรง ๆ ว่า WIN32 กับ WIN64 บรรยายสถาปัตยกรรมเป้าหมาย และไม่ได้บอกอะไรเลยว่ามี runtime helper เฉพาะของคอมไพเลอร์ตัวไหนอยู่บ้าง Free Pascal กำหนดทั้งสอง symbol บนเป้าหมาย Windows ที่สอดคล้องกัน เหมือนที่ Delphi ทำเป๊ะ ๆ guard ที่เขียนเป็น {$IFDEF WIN32} ล้อมโค้ดที่เรียก runtime helper ของ Delphi จึงคอมไพล์ผ่านใต้ FPC แต่ไปพังตอนลิงก์

จับตัวเป็นรูปธรรมแล้วมีสามตระกูลโค้ดที่ล้มลงในกับดักนี้ trampoline จำนวนเต็ม 64 บิตของ Delphi ที่ถูกเรียกผ่าน helper System.@_ll, routine สนับสนุนแอสเซมบลี Win32 สไตล์ MSVC และ import slot ที่มาพร้อมกับพวกมัน ล้วนมีอยู่เพื่อรองรับออบเจกต์ C ที่ precompile ไว้ซึ่งบิลด์ Delphi ลิงก์เข้ามา Free Pascal ไม่ได้ลิงก์ออบเจกต์พวกนั้น จึงไม่ต้องใช้เครื่องจักรเหล่านี้เลย และการอ้างอิงทุกจุดต้องหายไปจากโค้ด ความละเอียดอ่อนคือทั้งประกาศและ implementation ต้องถูกตัดออกพร้อมกัน ตัดข้างเดียวแล้วคอมไพเลอร์จะรายงานอะไรไม่เป็นท่าเกี่ยวกับ identifier ที่จับคู่กับอะไรไม่ได้เลย

กฎที่แตกออกมาจากเรื่องนี้สั้นมาก ถ้าคำถามเป็นเรื่อง ABI หรือ runtime support ให้ guard ด้วยคอมไพเลอร์ ถ้าคำถามเป็นเรื่องความกว้าง pointer หรือจำนวนรีจิสเตอร์ให้ guard ด้วยสถาปัตยกรรม และอย่าปล่อยให้ข้างหนึ่งยืนแทนอีกข้างเด็ดขาด

guard ประกาศและ implementation ไปพร้อมกัน

บล็อกเงื่อนไขใน interface section นั้นตกเข้าไปโดยไม่รู้ตัวได้ง่ายมาก แล้วข้อความ error ที่ได้จะชี้ไปทุกที่ยกเว้นสาเหตุ เพิ่มประกาศ method เข้าไปใน class interface ตำแหน่งที่เป็นธรรมชาติคือวางไว้ข้าง method ที่เกี่ยวข้อง ซึ่งไม่มีปัญหาจนกระทั่งเพื่อนบ้านเหล่านั้นบังเอิญนั่งอยู่ในบล็อก {$IFDEF} ที่เปิดไว้แล้ว directive เงื่อนไขไม่ถูกย่อหน้า บล็อกที่เปิดไว้สี่สิบบรรทัดขึ้นไปจึงมองไม่เห็นแทบเป็นเงาตอนกำลังอ่านประกาศรอบ ๆ

สิ่งที่ตามมาคือการคอมไพล์ที่ผ่านบน toolchain หนึ่งแล้วระเบิดเป็นลูกไฟบนอีกตัว ถ้า guard รอบ ๆ เป็นเช็กเวอร์ชัน Delphi ที่ Free Pascal ไม่เข้าเงื่อนไข ประกาศจะหายไปสำหรับ FPC ขณะที่ implementation ที่ไม่มีเงื่อนไขยังอยู่ครบ คอมไพเลอร์จึงรายงานรายการเตือนยาวเหยียดเรื่อง method identifier ที่มันคาดหวังและหาไม่เจอ ไม่มีข้อความไหนแม้แต่จะบอกถึงบล็อกเงื่อนไขที่เป็นต้นเหตุ

มีสองนิสัยที่กันความล้มเหลวทั้งชนิดนี้ได้ หนึ่งก่อนจะแทรกโค้ดลง interface section ให้แหงนขึ้นไปหา conditional ที่ยังเปิดอยู่ใกล้ที่สุด อย่าเชื่อการจัดกลุ่มด้วยสายตา สองให้ถือว่าชุดเทสต์ Delphi ที่เขียวเป็นหลักฐานเฉพาะของ Delphi เท่านั้น การบิลด์ไลบรารีฝั่ง Free Pascal เป็นด่านแยกต่างหาก และวิธีเดียวที่จะรู้ว่ามันผ่านคือรัน build-Win32-Lib-FPC.cmd และ build-Win64-Lib-FPC.cmd เป็นส่วนหนึ่งของการแก้เดียวกัน

อะไรพังในโค้ดเลขคณิต 32 บิต

ข้อจำกัดด้านภาษาหนึ่งข้อโผล่มาในโค้ดที่ไม่เต็มใจจะถูกแก้ที่สุดเป๊ะ ๆ: Free Pascal 32 บิตไม่ยอมรับ UInt64 เป็นตัวแปรควบคุมลูป for ในยูนิตเส้นโค้งวงรีที่พก X25519 กับ X448 อยู่ ลูปที่เดินผ่าน limb array ถูกเขียนด้วยตัวนับ 64 บิตเพียงเพราะทุกอย่างอื่นในไฟล์เป็น 64 บิต

ทางแก้ต้องแม่นแบบผ่าตัด เพราะในเลขคณิตบน field ความกว้างของตัวแปรเป็นส่วนหนึ่งของเหตุผลความถูกต้อง index ของลูปกลายเป็น Integer เพราะ limb array มีองค์ประกอบกี่ตัวไม่แพ้กัน ไม่มี index ที่ไหนเข้าใกล้ช่วง 32 บิตเลย ส่วนทุกอย่างที่ลงมือเล่นคณิตด้วย ทั้งตัว limbs เอง การไหลผ่านของ carry และ mask คงเป็น UInt64 เพราะการหดความกว้างของสิ่งเหล่านี้จะเปลี่ยนผลลัพธ์ modulo กับจำนวนเฉพาะของ field แบบเงียบ ๆ

// FPC 32 บิตปฏิเสธตัวแปรลูปแบบ UInt64 หดขนาดเฉพาะ index เท่านั้น
// limbs, masks และ carries คงความกว้างเดิม ไม่งั้นคณิตของ field เปลี่ยน
var
  I: Integer;                 // เดิมเป็น UInt64
  Carry, Mask: UInt64;
begin
  Carry := 0;
  for I := 0 to High(Limbs) do
  begin
    Limbs[I] := Limbs[I] + Carry;
    Carry := Limbs[I] shr 51;
    Limbs[I] := Limbs[I] and Mask;
  end;
end;

การตรวจสอบสำหรับการแก้แบบนี้ไม่อาจเป็นเทสต์แบบ round-trip ได้ การเข้ารหัสแล้วถอดรหัสด้วย implementation ที่พังเหมือนกันตัวมันเองยอมรับกันและกันสนิทแบบสมบูรณ์ นั่นคือเหตุผลที่ known-answer vectors เป็นข้อตกลงที่ต่อรองไม่ได้ที่นี่ รันชุดเทสต์เวกเตอร์ X25519 และ X448 ที่ประกาศไว้แล้วเทียบไบต์ผลลัพธ์เป๊ะ ๆ เป็นการเช็กเดียวที่แยก implementation ที่ถูกต้องออกจากตัวที่ผิดแต่สอดคล้องกันเอง และมันใช้กับ primitive เชิงสมมาตรที่พูดถึงใน ขอบเขต codec deflate และ AES บน Free Pascal เท่าเทียมกันด้วย

จุดพังทั้งสองจุดของการบิลด์ HotPDF Win32 ด้วย Free Pascal: guard {$IFDEF WIN32} ที่ล้อม runtime helper ของ Delphi ซึ่งคอมไพล์ผ่านแต่ลิงก์พังหากไม่ตัดประกาศและ implementation ออกพร้อมกัน และตัวแปรลูป UInt64 ในการเดิน limb ของ X25519 กับ X448 ที่ถูกหดเหลือ Integer ขณะที่ limbs, carries และ masks คงความกว้างเดิม
guard ด้วยคอมไพเลอร์เมื่อคำถามคือ ABI หรือ runtime support และด้วยสถาปัตยกรรมเมื่อคำถามคือความกว้าง pointer แล้วพิสูจน์การเปลี่ยนเลขคณิตด้วย known-answer vectors ที่ประกาศไว้ ไม่ใช่เทสต์แบบ round-trip

การบิลด์ Free Pascal แบบ Win32 คุ้มค่าแค่ไหน

ผลตอบแทนเชิงปฏิบัติคือแอป Lazarus ที่เป้าหมายคือ Windows 32 บิตได้เครื่องยนต์เอกสารชุดเดียวกับต้นฉบับ Delphi โดยไม่ต้องไปดูแลสัญญาไบนารีแยกอีกชุดหนึ่ง จุดนี้สำคัญที่สุดกับ deployment ที่คนแทบไม่พูดถึง: คอนโทรลเลอร์อุตสาหกรรม เทอร์มินัลขายหน้าร้าน และซอฟต์แวร์ธุรกิจอายุยืน ที่ซึ่ง runtime 32 บิตไม่ใช่ทางเลือกแบบ legacy แต่เป็นข้อจำกัดของฮาร์ดแวร์

เรื่องฝั่ง Win64 มาก่อนแล้วและมีเล่าไว้ใน การรองรับ Free Pascal และ Lazarus บน Win64 Win32 ไม่ใช่การนำมารีรันซ้ำ บน Win64 มี calling convention เดียว ไม่มี name decoration และไม่มี integer helper เฉพาะของ Delphi ให้ต้องหลบเลี่ยง เกือบทุกอย่างในบทความนี้จึงเจาะจงที่เป้าหมาย 32 บิต ยูนิตเลขคณิตที่ต้องแก้เรื่องตัวแปรลูปก็คือชุดเดียวกับที่เล่าไว้ใน เลขคณิต Montgomery บนเส้นโค้ง NIST ที่วินัยเรื่องความกว้างถูกอธิบายลึกกว่านี้

บทเรียนทั่วไปคืองาน portability ข้ามคอมไพเลอร์ไม่ได้เป็นเรื่องฟีเจอร์ภาษาเป็นหลัก คอมไพเลอร์ทั้งสองยอมรับ Object Pascal ชุดเดียวกันที่นี่ สิ่งที่ต่างคือไฟล์ออบเจกต์: symbol ถูกสะกดอย่างไร คอมไพเลอร์สมมติว่า runtime มี routine ช่วยอะไรให้บ้าง และมี precompiled object ชุดไหนอยู่ในการลิงก์ HotPDF ส่งแพ็กเกจ Free Pascal และ Lazarus มาพร้อมแพ็กเกจ Delphi และ C++Builder ใน HotPDF Delphi PDF component ซอร์สทรีเดียวกันจึงเลี้ยงทุก toolchain แทนที่จะแยกก้อนตามคอมไพเลอร์