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

ลิงก์ออบเจกต์ OMF เป็น COFF บน FPC Win32 ใน PDFlibPas

PDFlibPas บิลด์ภายใต้ Free Pascal สำหรับ Windows 32 บิต และส่วนที่ยากไม่เคยใช่ตัวภาษา Pascal เลย แต่มันคือไฟล์ออบเจกต์ต่างหาก ออบเจกต์ AES และ OpenJPEG ที่บิลด์ Delphi ลิงก์ด้วยเป็น OMF ในขณะที่ internal linker ของ Free Pascal ต้องการ COFF และการแปลงระหว่างสองฟอร์แมตนี้กลับผลิตชื่อเซกชันและ section-definition symbol ที่ทำให้ linker ล้มด้วย internal error แทนที่จะรายงานอะไรที่อ่านรู้เรื่อง

ใครที่เคยลิงก์ออบเจกต์ C เข้าไปในไลบรารี Pascal ย่อมคุ้นเคยกับสมรภูมิแบบนี้ดี Win64 ถือว่าใจดีกว่ามาก มีออบเจกต์ฟอร์แมตเดียว calling convention เดียว และไม่มี name decoration ส่วน Win32 ยังเก็บชั้นประวัติศาสตร์ทุกชั้นที่แพลตฟอร์มนี้สะสมมา และไลบรารีที่ลิงก์โค้ด C ของบุคคลที่สามแบบสแตติกจะเจอมันทั้งหมดพร้อมกันในคราวเดียว

ไดเรกทอรีคอมไพเลอร์ไม่ได้บอกเป้าหมายให้คุณ

เริ่มจากจุดเข้าบิลด์ก่อน เพราะถ้าเข้าใจผิดตรงนี้จะเสียเวลาเป็นชั่วโมงก่อนไฟล์ออบเจกต์แม้แต่ไฟล์เดียวจะถูกแตะด้วยซ้ำ ชื่อไดเรกทอรีติดตั้ง Free Pascal บอกแค่ว่าคอมไพเลอร์หลักอาศัยอยู่ที่ไหน ไม่ได้บอกว่ามันผลิตอะไร คอมไพเลอร์โฮสต์ 32 บิตสามารถเรียก cross-compiler ที่นั่งอยู่ข้าง ๆ แล้วปล่อยโค้ด 64 บิตออกมาได้เมื่อคุณส่ง target switch ที่ถูกต้อง การเดาเป้าหมายจากพาธจึงเป็นการเดาสุ่มที่บังเอิญใช้ได้ จนกระทั่งมีคนจัดระเบียบ toolchain ใหม่

วิธีที่เชื่อถือได้คือถามตัวคอมไพเลอร์โดยตรง สอบถาม target processor และระบบปฏิบัติการจริงผ่าน information switch ของคอมไพเลอร์เอง แล้วรองรับเลย์เอาต์การติดตั้งทั้งสองแบบที่พบบ่อย ทั้งไดเรกทอรีไบนารีแบบแบนและแบบซ้อนตามเวอร์ชัน เพราะตัวติดตั้งและตัวจัดการ toolchain ต่างกันผลิตโครงสร้างต่างกัน สคริปต์บิลด์ที่ hard-code เลย์เอาต์ใดเลย์เอาต์หนึ่งไว้จะทำงานได้แค่บนเครื่องเดียวเท่านั้น

ทำไมไฟล์ออบเจกต์ที่แปลงแล้วจึงทำให้ internal linker พัง

เพราะการแปลงยังคงธรรมเนียมการตั้งชื่อเซกชันของ OMF เอาไว้ และสังเคราะห์ section-definition symbol ที่ไม่ตรงกับสิ่งที่ COFF linker คาดหวัง การแปลงออบเจกต์ OMF เป็น COFF จำเป็นแต่ไม่พอ ไฟล์ที่ได้มายังพกชื่อเซกชันคลาสสิกอย่าง _TEXT, _DATA และ _BSS รวมถึงชื่อ section-definition symbol ที่แผ่ออกมาจากพวกมัน และเมื่อป้อนสิ่งเหล่านี้ให้ internal linker ของ Free Pascal ผลที่ได้คือ internal compiler error ไม่ใช่ข้อความบอกเรื่องการตั้งชื่อเซกชัน

internal error เป็น failure mode ที่แย่ที่สุดสำหรับปัญหาตอนบิลด์ เพราะมันไม่บอกอะไรเลยว่าอินพุตผิดตรงไหน ทางแก้คือ normalization pass หลังการแปลงบนไฟล์ COFF เขียนชื่อเซกชันใหม่ให้อยู่ในรูปที่คาดหวัง และเขียน section-definition symbol ที่เกี่ยวข้องให้ตรงกันตามไปด้วย โดยไม่แตะต้อง symbol index, ไบต์ของโค้ด และ relocations เลย ข้อจำกัดข้อสุดท้ายนี่แหละคือความยากทั้งหมดของเรื่องนี้ การเขียนใหม่ที่ไปเปลี่ยนหมายเลข symbol หรือเลื่อน offset จะได้ออบเจกต์ที่ลิงก์ผ่านแล้ว crash เอาดื้อ ๆ

มีขั้นเตรียมงานเพิ่มสำหรับชุดออบเจกต์หนึ่งในสองชุด ออบเจกต์ OpenJPEG ที่บิลด์ด้วยคอมไพเลอร์ C++ 32 บิตคลาสสิกพึ่งพา helper routine จำนวนเต็ม 64 บิตส่วนตัวของ Delphi ซึ่ง Free Pascal ไม่มีให้ ดังนั้นแปลงฟอร์แมตกี่รอบก็ไม่มีทางใช้งานได้ ชุดนี้ต้องบิลด์ใหม่ด้วยคอมไพเลอร์ที่ใช้ Clang ก่อน ซึ่งไม่ปล่อย dependency พวกนั้นออกมา แล้วจึงแปลงในขั้นถัดไป

ไปป์ไลน์พาออบเจกต์ C สแตติกของ PDFlibPas จาก OMF ของ Delphi ใน Lib\thirdparty\Win32 ไปเป็น COFF ที่ FPC ลิงก์ได้ใน Lib\thirdparty\Win32f บน Win32: การแปลง OMF เป็น COFF, normalization pass ที่เขียนชื่อเซกชันและ section-definition symbol ใหม่โดยไม่แตะ symbol index ไบต์โค้ดและ relocations และการบิลด์ใหม่ด้วย Clang สำหรับออบเจกต์ OpenJPEG ที่เรียก helper 64 บิตของ Delphi
การแปลงจำเป็นแต่ไม่พอ ป้อน COFF ที่เปลี่ยนชื่อแล้วแต่ไม่ได้ normalize ให้ internal linker ของ Free Pascal ก็จะได้ internal error กลับมา จึงต้องมี pass หลังการแปลงแก้ชื่อและ symbol โดยไม่แตะต้อง offset
// ออบเจกต์สำหรับเป้าหมาย FPC อยู่ในไดเรกทอรีของตัวเอง ไม่ได้เข้ามา
// แทนที่ชุดออบเจกต์ของ Delphi เพราะทั้งสอง toolchain บิลด์จากซอร์ส
// ทรีเดียวกัน และแต่ละฝั่งต้องการลิงก์อินพุตของตัวเอง
//
//   Lib\thirdparty\Win32   ออบเจกต์ OMF ของ Delphi ตามเดิม
//   Lib\thirdparty\Win32f  ออบเจกต์ COFF ของ FPC แปลงและจัดระเบียบแล้ว
//
// จุดเข้าบิลด์:
//   build-Win32-Lib-FPC.cmd
//   build-Win64-Lib-FPC.cmd

helper เฉพาะของคอมไพเลอร์ไม่ portable และธรรมเนียมของมันก็เช่นกัน

Delphi runtime เตรียม trampoline แอสเซมบลีสำหรับการดำเนินการจำนวนเต็ม 64 บิตบน x86 32 บิตเอาไว้ และออบเจกต์ C ที่ precompile มาสำหรับ Delphi ก็เรียกเข้าไปใช้ ส่วน Free Pascal มีระบบของตัวเอง อ้างอิงพวกนี้จึงต้องจัดการให้ตรงกันในอีกแบบ ไม่ใช่ redirect ผ่านไปเฉย ๆ รายละเอียดที่ทำให้ redirect เป็นไปไม่ได้คือ calling convention: helper จับเวลาที่โค้ด imaging ใช้ปล่อยให้ callee เก็บกวาดอาร์กิวเมนต์สี่ไบต์ ขณะที่ helper หารเลข 64 บิตเก็บกวาดสิบหกไบต์แล้วคืนผลลัพธ์ในคู่รีจิสเตอร์แบบคลาสสิก สอง helper สองธรรมเนียม trampoline ที่เขียนมาสำหรับอันหนึ่งจะทำลายสแตกของอีกอันอย่างเงียบ ๆ

name decoration เติมครึ่งหลังของปัญหา บน Win32 Free Pascal เติมขีดล่างนำหน้า import C ภายนอกให้อัตโนมัติ ขณะที่ประกาศ public name จะส่งออกตรงตามตัวอักษร ฝั่ง import และฝั่ง export ของบริดจ์เดียวกันจึงใช้กฎต่างกัน bridge C runtime ที่ OpenJPEG ต้องการจึงต้อง export ชื่อ C symbol ที่แม่นยำเป๊ะ ๆ และจุดเข้าแบบ variadic ต้องใช้ indirect jump แบบ 32 บิต ไม่ใช่ direct jump พอพูดออกมาแล้วไม่มีอะไรแปลกแตงอยู่ในนี้เลย แต่ทั้งหมดล้มเหลวในรูป link error ที่ชี้ไปที่ symbol ที่ไม่มีใครเคยเขียน

อะไรทำให้ไฟล์ executable Win32 ตายก่อนถึง main

DLL 64 บิตที่หลุดเข้ามาบน search path เพราะยูนิต zlib ของ Free Pascal bind แบบไดนามิกแทนที่จะลิงก์แบบสแตติก อาการคือโปรแกรมออกทันทีด้วย status code ของ invalid-image ก่อนโค้ด Pascal แม้แต่บรรทัดเดียวจะรัน อาการแบบนี้ดันให้คุณไปจ้องโปรแกรมที่เพิ่งบิลด์เสร็จ ทั้งที่ต้นตออยู่ที่ loader ไป resolve import กับสถาปัตยกรรมผิดตัว

บทเรียนที่ได้เป็นเรื่อง assumption มากกว่า zlib ยูนิตที่ตั้งชื่อตามไลบรารีบีบอัดไม่จำเป็นต้องบรรจุไลบรารีนั้นไว้ข้างในเสมอ มันอาจเป็นแค่ binding ที่เฝ้ารอ shared library ตอนรันไทม์ และ dynamic dependency ที่คุณไม่ได้ตั้งใจคือภาระตอน deploy แม้บังเอิญ resolve ได้ก็ตาม สลับไปใช้ stream implementation ล้วน Pascal แทน ทั้งสองเป้าหมายจะได้เส้นทางบีบอัดที่คอมไพล์รวมมาแบบสแตติกโดยไม่มี external dependency เหลือเลย ซึ่งเป็นสิ่งที่ไลบรารีที่ถูกฝังเข้าไปในแอปของคนอื่นควรเป็นตั้งแต่แรกอยู่แล้ว

สัญชาตญาณแบบเดียวกันใช้กับ backend external JBIG2 encoder ด้วย บนเป้าหมาย 32 บิต external encoder จะไม่ถูกลิงก์ คำขอจึงตกไปใช้ built-in Pascal encoder และเทสต์ที่ตรวจเรื่องนี้ต้องเช็กสถานะการลงทะเบียนของเป้าหมายปัจจุบัน ไม่ใช่ยกการ encode สำเร็จมาเป็นพยานว่า external backend อยู่ครบ fallback ที่ใช้งานได้นี่แหละคือสิ่งที่ซ่อน dependency ที่หายไปได้ดีที่สุด ซึ่งเป็น failure pattern ที่วิเคราะห์ไว้ใน การวินิจฉัยความล้มเหลวเงียบ ๆ ของ stub ส่วนงาน static linking 64 บิตมีอยู่ใน การลิงก์ jbig2enc แบบสแตติกภายใต้ FPC

ผังการวินิจฉัยสำหรับ executable Win32 ที่ออกก่อนถึง main ภายใต้ Free Pascal: loader ค่อย ๆ resolve import ขณะ unit initialization รันอยู่ binding ของ zlib ไปเจอ DLL 64 บิตบน search path แล้วโพรเซสตายด้วยสถานะ invalid-image ก่อนคำสั่ง Pascal แม้แต่คำสั่งเดียว ดัน PDFlibPas ให้หันไปใช้เส้นทางบีบอัดล้วน Pascal ที่คอมไพล์รวมมาแบบสแตติก
ต้นตอไม่เคยใช่โปรแกรมที่เพิ่งบิลด์เลย ยูนิตชื่อ zlib เป็นเพียง runtime binding ที่ resolve เข้ากับสถาปัตยกรรมผิดตัว และ fallback ที่ใช้งานได้อย่าง built-in JBIG2 encoder ก็ช่วยซ่อน dependency ที่หายไป

เลขคณิต 32 บิตบน memory stream

โค้ดที่จัดการขนาด buffer ด้วยเลขคณิต unsigned กว้างเท่า pointer นั้นถูกต้องบน Win64 และแค่ภาพใหญ่เข้ามาหนึ่งใบก็ overflow บน Win32 ได้เลย memory stream ที่ป้อนข้อมูลให้ codec JPEG 2000 เติบโตด้วยการเที่ยบสองเท่าและเดินหน้าด้วยการบวก บนเป้าหมาย 32 บิตสองการดำเนินการนี้ wrap ได้ทั้งคู่กับอินพุตที่ใหญ่แต่ชอบด้วยกฎหมายสมบูรณ์

การเขียน การข้าม การ seek และการจัดสรรครั้งแรกทุกครั้งจึงตรวจก่อนคำนวณเสมอ โดยเพดาน capacity คือค่า signed สูงสุดกว้างเท่า pointer ซึ่งเลือกให้ตรงกับสิ่งที่ block move routine และค่าที่ callback คืนกลับมาแสดงได้ ข้อกำหนดเชิงพฤติกรรมตอนปฏิเสธคำขอเป็นจุดที่พลาดกันง่าย: การปฏิเสธต้องไม่เปลี่ยนตำแหน่งหรือความยาวของ stream การแก้ไขไปครึ่ง ๆ กลาง ๆ แล้วตามด้วย error ทิ้ง stream ไว้ในสถานะที่ผู้เรียกไหนตามไม่ทัน และการดำเนินการถัดไปยิ่งซ้ำเติมเข้าไปอีก

// ตรวจก่อนคำนวณทุกครั้ง บน Win32 สองจุดนี้ wrap ทั้งคู่กับอินพุตขนาด
// ที่ภาพ JPEG 2000 ใบใหญ่สร้างขึ้นได้อย่างถูกต้องตามกฎ
if Needed > NativeUInt(High(NativeInt)) - FPosition then
  Exit(False);                    // ปฏิเสธ โดยไม่แตะ position และขนาด

NewCapacity := FCapacity;
while (NewCapacity < FPosition + Needed) do
begin
  if NewCapacity > NativeUInt(High(NativeInt)) shr 1 then
    Exit(False);                  // เที่ยบสองเท่าต่อไปจะ overflow
  NewCapacity := NewCapacity shl 1;
end;

กับดัก build output สองอันที่อยู่ยงกว่าตัวพอร์ต

การแยก executable ของเทสต์และตัวอย่างตามสถาปัตยกรรมเป้าหมายออกเป็นไดเรกทอรี output รายเป้าหมายนั้นถูกต้องชัดเจน และก็ทำให้ทุกอย่างที่หาเทสต์ดาต้าด้วยการนับจำนวนชั้นไดเรกทอรีขึ้นไปพังทันที ทางแก้คือไล่ค้นขึ้นไปหาไดเรกทอรี asset แทนการสมมติความลึกคงที่ โดยมีข้อจำกัดที่ตั้งใจไว้หนึ่งข้อ: ตัวอย่างการเซ็นเอกสารรับ certificate fallback จากไดเรกทอรีโปรเจกต์ของตัวเองเท่านั้น ห้ามจาก ancestor ลอย ๆ เพราะ certificate ชื่อเดียวกันที่เจอลึกขึ้นไปใน tree เป็นความประหลาดด้านความปลอดภัย ไม่ใช่ความสะดวก

กับดักที่สองรอดผ่านการพอร์ตมาทุกครั้ง และคุ้มที่จะแบบไปใช้กับโปรเจกต์ FPC ไหนก็ได้ หลังอัปเกรดคอมไพเลอร์ การให้คอมไพเลอร์ปฏิเสธไฟล์ PPU ค้างสมัยยังไม่พอ เพราะ linker ยังเลือกไฟล์ออบเจกต์ตกค้างใน unit search path ก่อนเสมอ แม้ PPU ที่โหลดเข้ามาจะมาจากไดเรกทอรีถูกต้องก็ตาม และการเพิ่ม object output path แบบชัดเจนก็ไม่ได้ลบความเคยชินนี้ทิ้ง คำตอบที่เชื่อถือได้มีอย่างเดียวคือ unit directory ชั่วคราวสด ๆ ต่อหนึ่งรอบบิลด์ ต่ำกว่านั้นจะได้ไบนารีที่ลิงก์จากคอมไพเลอร์สองเวอร์ชัน ซึ่งพังในแบบที่ดูเหมือนบั๊กในซอร์สโค้ดเป๊ะ ๆ

platform conditional เป็นชิ้นสุดท้าย และการเลือกแกนให้ถูกสำคัญกว่าที่เห็น คำถามที่ถูกมักเป็นว่าโค้ดนี้พึ่ง Windows เฉพาะตัวหรือเปล่า ไม่ใช่ว่ามี widget library ตัวนี้อยู่ไหม อย่างที่งานแปลง metafile ใน การนำเข้า EMF vector และ platform conditional แสดงให้เห็น: เปลี่ยน guard จากเงื่อนไข control library เป็นเงื่อนไข platform ก็แค่เปลี่ยน directive เดียว งาน rewrite ที่คิดว่าต้องลงแรงหนักก็จบเฉยเลย การรองรับ Free Pascal และ Lazarus สำหรับเป้าหมาย Windows ทั้งสองแบบมาพร้อมกับ PDFlibPas Delphi PDF library ที่บิลด์จากซอร์สชุดเดียวกับแพ็กเกจ Delphi และ C++Builder