- Elevator แปลไฟล์ปฏิบัติการ x86-64 ทั้งหมดเป็น AArch64 แบบสถิต โดยไม่ต้องอาศัยข้อมูลดีบัก ซอร์สโค้ด หรือสมมติฐานเกี่ยวกับเลย์เอาต์ของไบนารี
- แทนที่จะใช้ฮิวริสติกเพื่อแยกโค้ดกับข้อมูล ระบบจะสร้าง superset CFG ที่รวมการตีความที่เป็นไปได้ทั้งหมดของแต่ละไบต์ แล้วตัดทิ้งเฉพาะเส้นทางที่จบลงด้วยการสิ้นสุดโปรแกรมแบบข้อยกเว้น
- ระบบแมปสถานะ x64 กับรีจิสเตอร์ AArch64 แบบหนึ่งต่อหนึ่ง และจัดการ indirect branch ด้วย ตารางค้นหา ที่ชี้จากที่อยู่เดิมไปยังโค้ดที่แปลแล้ว
- tile bank แบบออฟไลน์เขียนความหมายของคำสั่ง x64 เป็นเทมเพลต C แล้วคอมไพล์ด้วย LLVM 20 ให้เป็นลำดับไบต์ AArch64
- ผลลัพธ์คือไบนารี AArch64 แบบ self-contained ที่ไม่ต้องแปลระหว่างรันไทม์ และให้ประสิทธิภาพเทียบเท่าหรือดีกว่า QEMU user-mode JIT บน SPECint 2006
เป้าหมายของ Elevator
- Elevator เป็นตัวแปลไบนารีแบบสถิตสมบูรณ์ที่ย้ายไฟล์ปฏิบัติการ x86-64 ทั้งหมดไปเป็น AArch64
- ไม่ใช้ข้อมูลดีบัก ซอร์สโค้ด แพตเทิร์นโค้ดของไบนารีต้นฉบับ หรือสมมติฐานเกี่ยวกับเลย์เอาต์ของไบนารี
- ตัวแปลแบบสถิตเดิมมักพึ่งพาฮิวริสติกหรือ runtime fallback เพื่อแยกโค้ดกับข้อมูล แต่ Elevator จะแปลทุกไบต์ของไฟล์ปฏิบัติการต้นฉบับล่วงหน้าตามทุกการตีความที่เป็นไปได้
- เพราะไบต์ใด ๆ อาจเป็นข้อมูล เป็นส่วนหนึ่งของ opcode หรือเป็นส่วนหนึ่งของอาร์กิวเมนต์ของ opcode ได้ จึงสร้าง superset CFG ที่ครอบคลุม control flow ที่เป็นไปได้ทั้งหมด แล้วตัดทิ้งเฉพาะเส้นทางที่นำไปสู่การจบโปรแกรมแบบผิดปกติ
- เอาต์พุตประกอบด้วยโค้ดที่แปลแล้ว ไบนารี x64 ต้นฉบับ ตารางค้นหาที่อยู่ และรันไทม์ไดรเวอร์ รวมกันเป็น ไบนารี AArch64 แบบ self-contained
- เมื่อแปลเสร็จแล้วสามารถรันได้โดยไม่ต้องมี JIT หรือการช่วยแปลระหว่างรันไทม์
- หากแปลไบนารีอินพุตเดียวกันสองครั้ง จะได้บิตสตรีมเอาต์พุตเหมือนกัน ทำให้สิ่งที่ใช้ในการทดสอบ ตรวจสอบ รับรอง และลงลายเซ็นเข้ารหัส ตรงกับโค้ดที่นำไปแจกจ่ายจริง
- ต้นทุนหลักคือ ขนาดโค้ดที่เพิ่มขึ้น แต่แลกกับความสามารถในการตรวจสอบก่อนดีพลอยที่สูงกว่าอีมูเลเตอร์หรือ JIT compiler
- การประเมินใช้ทั้งชุดเบนช์มาร์ก SPECint 2006 และไบนารีที่สร้างด้วยมือ โดยได้ประสิทธิภาพเทียบเท่าหรือดีกว่า QEMU user-mode emulation ที่มี JIT acceleration
- ทีมวิจัยระบุว่าจะเปิดซอร์สโค้ดทั้งหมดเมื่อโครงการสิ้นสุด
เหตุผลที่ต้องใช้การแปลแบบสถิตและข้อจำกัดเดิม
- เมื่อฮาร์ดแวร์ย้ายจาก ISA หนึ่งไปอีก ISA หนึ่ง จำเป็นต้องย้ายซอฟต์แวร์เดิมไปยังแพลตฟอร์มใหม่ และการรีคอมไพล์จากซอร์สโค้ดที่ยังเหลืออยู่อาจไม่เพียงพอ
- ในโค้ด legacy ที่ผ่านการตรวจสอบหรือรับรองแล้ว บ่อยครั้งสิ่งที่ได้รับการรับรองไม่ใช่ซอร์สโค้ด แต่เป็น ไบนารี executable ฉบับอ้างอิง ที่ผ่านการทดสอบมาอย่างดีแล้วโดยเฉพาะ
- การจะสร้างไบนารีเดิมแบบ bit-for-bit จากซอร์สในภายหลัง อาจต้องใช้เวอร์ชันของคอมไพเลอร์ ลิงเกอร์ และระบบบิลด์ในเวลานั้น ซึ่งในทางปฏิบัติทำได้ยาก
- หากผู้ผลิตเคยแพตช์ลงบนไบนารีโดยตรงโดยไม่ผ่านซอร์สโค้ด การบิลด์ใหม่จากซอร์สที่เก็บไว้อาจทำให้บั๊กที่เคยแก้ไปแล้วกลับมาอีก
- วิธีจัดการไบนารีโดยตรงที่มีอยู่เดิมมักผสมการทำงานระหว่างอีมูเลชัน การแปลแบบสถิต และการแปลแบบไดนามิก แต่คอมโพเนนต์ระบบเพิ่มเติมที่รันร่วมกับโปรแกรมที่แปลแล้วก็กลายเป็นส่วนหนึ่งของ trusted computing base
- พฤติกรรมแบบไดนามิกอาจเปลี่ยนไปตามลำดับการทดสอบหรืออินพุต ทำให้ยากต่อการยืนยันความน่าเชื่อถือของระบบทั้งหมด
- Horspool และ Marovac แสดงไว้ในปี 1980 ว่าการแปลงไฟล์ปฏิบัติการย้อนกลับจำเป็นต้องแยกโค้ดออกจากข้อมูลให้ได้อย่างแน่นอน และสำหรับสถาปัตยกรรมส่วนใหญ่นั้นเทียบเท่ากับ halting problem จึงแก้ไม่ได้ในกรณีทั่วไป
- ตัวยกไบนารีแบบสถิตที่มีอยู่เดิมมักประมาณการการแยกโค้ดกับข้อมูลด้วยฮิวริสติก โดยปัญหาจะชัดเป็นพิเศษเวลาต้องคาดเดาเป้าหมายของ indirect control-flow transfer
- LLBT ยกคำสั่ง ARM ขึ้นเป็น LLVM IR แล้วรีคอมไพล์ไปยังสถาปัตยกรรมเป้าหมาย แต่ใช้ฮิวริสติกในการตรวจจับเป้าหมายของ indirect branch และวางสมมติฐานหลายอย่างต่อไบนารีอินพุต
- ต่อให้ฮิวริสติกดี ก็ยังล้มเหลวได้กับอินพุตบางชนิด และเพราะการยกทั้งไบนารีให้ถูกต้องต้องแยกโค้ดกับข้อมูลให้ถูกทุกจุด ความน่าจะเป็นที่จะล้มเหลวจึงเพิ่มขึ้นตามขนาดของไบนารี
- วิธีแบบไดนามิกติดตามเฉพาะลำดับคำสั่งที่รันจริง จึงจัดการกับการกู้คืนคำสั่งและ indirect control flow ได้ แต่ไม่สามารถยกคำสั่งที่ไม่ถูกเข้าถึงในการรันแบบรูปธรรมได้
- บน ISA อย่าง x64 ที่มี คำสั่งความยาวแปรผัน ลำดับคำสั่งหนึ่งอาจซ้อนอยู่ในอีกลำดับหนึ่งได้ และถ้ามีการกระโดดเข้าไปกลางคำสั่งหลายไบต์ โอเปอแรนด์เดิมอาจถูกถอดรหัสเป็นคำสั่งอีกชุดหนึ่ง
- การโจมตีแบบ ROP และการทำโค้ดให้อ่านยากสามารถใช้คุณสมบัตินี้ได้
- Rosetta II ของ Apple และ Prism ของ Microsoft ผสานการแปลล่วงหน้ากับคอมโพเนนต์แปลแบบไดนามิก
- WYTIWYG และ Polynima ยกโค้ดแบบสถิตตามเส้นทาง control flow ที่ระบุได้จาก dynamic profiling และเมื่อไปถึงที่อยู่เป้าหมายที่ไม่เคยเห็นมาก่อน ก็ใช้ fallback แบบไดนามิกเพื่อเก็บข้อมูล control flow
- Elevator ไม่ตัดสินว่าไบต์ใดเป็นโค้ดหรือข้อมูล เป็น instruction word หรืออาร์กิวเมนต์ แต่รวมทุกไบต์ของไฟล์ปฏิบัติการไว้ในทุกการตีความที่เป็นไปได้เป็นเส้นทาง control flow แยกกัน
- วิธีนี้คือการนำ superset disassembly มาใช้กับ static recompilation และ cross-ISA compilation โดยแลกความแม่นยำในการถอดรหัสกับการเพิ่มขึ้นของขนาดโค้ด
การคงรักษา control flow และสถานะ
- Elevator ทำงานภายใต้หลักการ คงรักษาสถานะ x64 ทั้งหมด ไว้ภายในโค้ด AArch64 ที่แปลแล้ว
- ระบบแมปรีจิสเตอร์ x64 กับรีจิสเตอร์ AArch64 แบบหนึ่งต่อหนึ่ง เพื่ออีมูเลตสถานะของรีจิสเตอร์ x64 แต่ละตัวบนรีจิสเตอร์ AArch64 ที่สอดคล้องกัน
- สแตก x64 ถูกอีมูเลตโดยตรงบนสแตก AArch64 และการขยายสแตกตามปกติระหว่างรันจะให้ระบบปฏิบัติการจัดการ
- ระบบไม่วิเคราะห์ ABI ของไบนารี x64 อินพุต แต่จะทำ ABI translation เฉพาะที่จุดซึ่งการรันไหลออกไปยังโค้ดภายนอกหรือย้อนกลับเข้ามา โดยอิงกฎของ x64 System V ABI และ AArch64 Procedure Call Standard
- ด้วยการคงรักษาสถานะอย่างสมบูรณ์และการจับคู่รีจิสเตอร์แบบหนึ่งต่อหนึ่ง คำสั่ง x64 แต่ละคำสั่งจึงสามารถแปลได้อย่างอิสระโดยไม่ต้องรู้คำสั่งก่อนหน้าหรือถัดไป
- ไบต์ออฟเซ็ตที่รันได้แต่ละตำแหน่งของไบนารีต้นฉบับถูกตีความได้พร้อมกันทั้งในฐานะข้อมูลและจุดเริ่มต้นที่เป็นไปได้ของลำดับคำสั่ง
- สำหรับทุกเป้าหมายที่เป็นไปได้ซึ่งวิเคราะห์แบบสถิตไม่ได้ เช่น indirect jump, callback หรือ runtime dispatch จะมีจุดลงจอดที่สอดคล้องกันอยู่ในไบนารีที่เขียนใหม่
- ระหว่างรันไทม์ ระบบใช้ ตารางค้นหา ที่รวมอยู่ในไบนารีสุดท้ายเพื่อแปลงจากที่อยู่คำสั่งต้นฉบับไปยังที่อยู่ของโค้ดที่แปลแล้ว
-
ตัวอย่างคำสั่งที่ซ้อนกัน
Listing 1แสดงว่าเมื่อเริ่มถอดรหัสที่.byte 0xB0จะได้MOV AL, 0xC3ตามด้วยRETแต่ถ้าเริ่มที่ReturnC2ซึ่งถัดไปหนึ่งไบต์ จะได้เพียงRET- การถอดรหัสทั้งสองแบบสามารถเข้าถึงได้จาก
jzก่อนหน้า และถ้าตัวแปลเลือกตีความสองไบต์นั้นได้เพียงแบบเดียว ก็จะพลาดหนึ่งเส้นทาง
-
ตัวอย่าง indirect branch แบบคำนวณ
Listing 2แสดงcall Labelที่สร้าง base address ของตาราง จากนั้นpop rsiดึงค่านั้นกลับมา แล้วบวกออฟเซ็ตที่ขึ้นกับอินพุตเพื่อสร้างเป้าหมายของjmp rsi- สาขาสามารถลงที่คำสั่ง
inc eaxใดคำสั่งหนึ่งจากสี่คำสั่งที่วางห่างกันทีละ 2 ไบต์ในสตรีมการเข้ารหัส - ตัวแปลที่เขียนใหม่เฉพาะเป้าหมายการกระโดดที่วิเคราะห์แบบสถิตได้ จะไม่มีจุดให้สาขาแบบนี้ลง
-
การเรียก การคืนค่า และการกระโดด
- คำสั่ง
call,return,branchไม่สามารถแสดงด้วย C tile ได้ เพราะตำแหน่งของ return address, program counter และเลย์เอาต์ของ condition flags ต่างกันระหว่าง x64 กับ AArch64 - การเรียกแบบตรงจะ push return address ของ x64 ต้นฉบับลงบน emulated stack แล้ว branch ไปยัง tile ที่แปลของ callee
- การเรียกแบบอ้อมจะตรวจสอบก่อนว่าเป้าหมายอยู่ภายในไบนารีที่แปลแล้วหรือเป็นไลบรารีภายนอก โดยเป้าหมายภายในจะถูกแปลผ่านตาราง x64 offset-to-tile แล้ว branch ไปยัง tile นั้น
- สำหรับเป้าหมายภายนอก ระบบจะใส่ที่อยู่ของ reverse ABI translation gadget ลงใน
X30ซึ่งเป็นจุดที่ไลบรารี AArch64 จะย้อนกลับมา จากนั้นทำ exit ABI translation แล้ว branch ไปยังเป้าหมายภายนอก - การคืนค่าจะ pop return address ขนาด 8 ไบต์ออกจาก emulated stack แล้วเทียบกับขอบเขตของไบนารี x64 ที่ฝังอยู่ หากเป็นการคืนค่าภายในก็จะแปลที่อยู่ผ่านตารางค้นหาแล้ว branch ไปยัง tile ที่เกี่ยวข้อง
- direct branch รู้เป้าหมายตั้งแต่ตอนแปล ส่วน conditional branch จะถูกแปลเป็น AArch64 conditional branch ที่ตรวจบิต flags ของ x64 ซึ่งเก็บไว้ใน
X14 - indirect branch จะปล่อย bounds check เช่นเดียวกับ indirect call และ return และถ้าเป้าหมายเป็นภายนอกก็จะทำ exit ABI translation
- คำสั่ง
ไปป์ไลน์การแปลแบบอิง tile
- การแปลของ Elevator แบ่งเป็นสามขั้น: การสร้าง tile bank แบบออฟไลน์, การเขียนใหม่ต่อไบนารีอินพุตแต่ละตัว, และการแพ็กเกจขั้นสุดท้าย
- ขั้นออฟไลน์จะแสดงความหมายของคำสั่ง x64 เป็นฟังก์ชัน C แล้วทำการ specialize ตามชุดโอเปอแรนด์ภายใต้การแมปรีจิสเตอร์ x64-to-AArch64 แบบคงที่ ก่อนคอมไพล์ด้วย LLVM 20 ที่ปรับแก้แล้วให้เป็นลำดับไบต์ AArch64 ที่นำกลับมาใช้ซ้ำได้
- ขั้นต่อไบนารีอินพุตจะทำ superset disassembly แล้วเดินผลลัพธ์ CFG จากนั้นค้นหา tile ตามชื่อสำหรับคำสั่งที่เป็นไปได้แต่ละตัว แล้วนำลำดับไบต์ AArch64 มาต่อกัน
- หมวดคำสั่งที่แสดงด้วย C tile ได้ยาก เช่น control-flow transfer และขอบเขต ABI จะถูกจัดการด้วยเทมเพลตขนาดเล็กที่เขียนด้วยมือ
- ขั้นแพ็กเกจจะรวมโค้ดที่แปลแล้ว ไบนารี x64 ต้นฉบับ ตารางค้นหาที่อยู่ และรันไทม์ไดรเวอร์ ให้เป็นไบนารี AArch64 แบบ standalone
-
tile bank แบบออฟไลน์
- การเขียนลำดับคำสั่ง AArch64 ที่เทียบเท่าสำหรับคำสั่ง x64 แต่ละตัวด้วยมือไม่ใช่วิธีที่ใช้งานได้จริง
- เทมเพลตเดียวอย่าง
ADD Reg8, Reg8ก็ขยายเป็นชุดรีจิสเตอร์ที่เป็นรูปธรรมได้ 256 แบบ และชุดคำสั่ง x64 ทั้งหมดก็มีรูปแบบการระบุรีจิสเตอร์ โอเปอแรนด์หน่วยความจำ และ immediate addressing อีกมาก - Elevator เขียนความหมายของคำสั่ง x64 แต่ละตัวเป็นฟังก์ชัน C ขนาดเล็ก แล้ว specialize ตามชุดโอเปอแรนด์จริง จากนั้นให้ LLVM คอมไพล์เป็น AArch64
- ในตัวอย่าง
ADD Reg8, Reg8เทมเพลตจะอัปเดต 8 บิตล่างของรีจิสเตอร์ปลายทางด้วยผลบวก 8 บิต และคง 56 บิตบนไว้ เพื่อรักษาความหมายของ partial register write ใน x64 - คำสั่ง x64
ADD Reg8, Reg8ยังเปลี่ยน flags ในRFLAGSได้แก่ Carry, Parity, Auxiliary Carry, Zero, Sign และ Overflow ด้วย ดังนั้นเพราะข้อจำกัดของฟังก์ชัน C ที่มีค่าคืนเพียงค่าเดียว การอัปเดต flags จึงถูกแยกเก็บเป็น flag tile ต่างหาก - คำสั่ง x64 หนึ่งคำสั่งอาจสอดคล้องกับหนึ่ง tile หรือหลาย tile และตอนปล่อยโค้ดจะนำ tile เหล่านี้มาต่อเรียงกันเพื่อคืนความหมายทั้งหมด
- แอ็ตทริบิวต์
aarch64_custom_regใช้ประกาศให้ LLVM รู้ว่าค่าคืนและอาร์กิวเมนต์แต่ละตัวต้องวางไว้ในรีจิสเตอร์ AArch64 ตัวใด - การแมปแบบคงที่นี้เลือกมาเพื่อให้สอดคล้องกับธรรมชาติของ callee-saved และ caller-saved ระหว่าง x64 System V กับ AAPCS64 ลดการสลับตำแหน่งรีจิสเตอร์อาร์กิวเมนต์แบบจำนวนเต็ม และเหลือรีจิสเตอร์ callee-saved ของ AArch64 ที่ยังว่างไว้สำหรับ shadow state ในอนาคต
- บิต
RFLAGSและไฟล์รีจิสเตอร์XMMของ x64 ก็ถูกเก็บไว้ในรีจิสเตอร์ AArch64 เฉพาะตามหลักการหนึ่งต่อหนึ่งเช่นเดียวกัน - LLVM 20 ที่แก้ไขแล้วรองรับแอ็ตทริบิวต์
aarch64_custom_regรายฟังก์ชัน และจัดประเภทรีจิสเตอร์ AArch64 ที่เก็บสถานะ x64 ที่อีมูเลตไว้ใหม่ให้เป็น callee-saved ภายใน register allocator TileGenจะเดินเทมเพลต C เพื่อสร้างสำเนาที่ specialize แล้วสำหรับทุกชุดโอเปอแรนด์ที่อนุญาต และสังเคราะห์แอ็ตทริบิวต์เชิงกลจากตำแหน่งพารามิเตอร์ของเทมเพลตกับการแมปรีจิสเตอร์
-
การเขียนใหม่ต่อไบนารีอินพุต
- เมื่อได้รับไบนารี x64 อินพุต ขั้น per-binary จะทำ superset disassembly และเดิน CFG ที่ได้
- ที่แต่ละโหนด formatter จะสร้างชื่อ tile จาก opcode และโอเปอแรนด์ของคำสั่งที่ถอดรหัสได้ และสำหรับคำสั่งที่ต้องใช้หลาย tile ก็จะประกอบหลายชื่อเข้าด้วยกัน
- x64 ไม่มีข้อบังคับเรื่องการจัดแนวสแตกพอยน์เตอร์ แต่ AArch64 กำหนดให้สแตกพอยน์เตอร์ต้อง จัดแนว 16 ไบต์ เมื่อนำไปใช้กับโอเปอแรนด์หน่วยความจำ
- หากแมป
RSPไปยังSPโดยตรง แพตเทิร์นโค้ด x64 ทั่วไปอย่างPUSHต่อเนื่องใน function prologue อาจทำให้ AArch64 เกิด alignment exception - Elevator ให้ tile เข้าถึงสแตกผ่านรีจิสเตอร์แยก
X25และจะทำให้มันเป็นSPจริงเฉพาะเมื่อ tile นั้นต้องการจริง ๆ - tile ที่คอมไพล์ด้วย LLVM คาดหวังว่า
SPจะถูกจัดแนว 16 ไบต์เมื่อเข้าใช้งาน ดังนั้นก่อนรัน tile ที่ตรวจพบว่ามีการจอง spill space ระบบจะจัดแนวSPลงด้านล่าง และคืนค่ากลับหลังรันเสร็จ - เนื่องจาก tile คำนวณ flags มีต้นทุนค่อนข้างสูง หาก flags ถูกเขียนทับก่อนที่จะถูกอ่านในคำสั่งที่ post-dominate ภายหลัง ระบบจะลบการคำนวณ flags ของโหนดปัจจุบันทิ้ง
- คำสั่งที่ยังไม่รองรับในปัจจุบันส่วนใหญ่คือส่วนขยายเวกเตอร์กว้างของ x64 อย่าง AVX2 และรุ่นถัดไป และจะใส่คำสั่ง interrupt แทน tile ในตำแหน่งเหล่านั้น
- ในการประเมินด้วย SPECint 2006 ทั้งชุด ชุดคำสั่ง integer x86-64 ทั้งหมดและเพียง subset ของ SSE ที่ SPECint ใช้ ก็เพียงพอสำหรับรันทุกเบนช์มาร์ก
- การรองรับคำสั่งเพิ่มเติมสามารถขยายได้ด้วยการเพิ่ม tile ใหม่ แต่ผู้วิจัยมองว่างานวิศวกรรมเพิ่มเหล่านี้น่าจะไม่เพิ่มสาระเชิงวิทยาศาสตร์มากนัก
การจัดการขอบเขต ABI
- Elevator รองรับเฉพาะไบนารีที่ลิงก์แบบไดนามิก
- ไบนารีที่ลิงก์แบบสถิตอาจมีคำสั่งเฉพาะสถาปัตยกรรมอย่าง
CPUIDอยู่โดยตรง แต่ไบนารีที่ลิงก์แบบไดนามิกมักมอบหมายสิ่งเหล่านี้ให้libcทำแทน จึงลดสิ่งที่ต้องแปลลง - เมื่อต้องโต้ตอบกับไลบรารีที่ลิงก์แบบไดนามิก ระบบรองรับการสลับระหว่าง ABI ของ Linux x64 กับ ABI ของ Linux AArch64 เพื่อไปมาระหว่างสภาพแวดล้อม x64 ที่อีมูเลตกับโค้ดไลบรารี AArch64 แบบเนทีฟ
- องค์ประกอบหลักที่ต้องแปล ABI คือ การวางอาร์กิวเมนต์ และตำแหน่งของ return address
- System V x64 ABI ใช้รีจิสเตอร์หกตัว
RDI,RSI,RDX,RCX,R8,R9เป็น argument register และส่งอาร์กิวเมนต์เพิ่มเติมผ่านสแตกตั้งแต่[RSP+8] - x64
CALLจะเก็บ return address ไว้ที่[RSP] - AArch64 Procedure Call Standard ใช้ argument register แปดตัว
X0-X7วางอาร์กิวเมนต์ที่เหลือลงบนสแตกที่[SP]และเก็บ return address ไว้ในX30 -
การเรียกไลบรารีภายนอก
- หากการเรียก x64 ที่แปลแล้วมีเป้าหมายเป็นไลบรารีภายนอก จะต้องปรับเลย์เอาต์อาร์กิวเมนต์ให้ตรงกับ calling convention ของ AArch64
- ก่อนอื่นระบบจะลบ 8 ออกจาก
SPเพื่อจัดแนวกลับสู่ขอบเขต 16 ไบต์ แล้ววาง return address ของ x64 ที่มีอยู่เดิมไว้ที่[SP+0x8] - ค่าที่ตำแหน่ง
[SP+0x10],[SP+0x18]จะถูกโหลดเข้าX6,X7เพื่อให้ไลบรารี AArch64 มองเห็นอาร์กิวเมนต์ตัวที่ 7 และ 8 ที่โค้ด x64 อาจวางไว้บนสแตก - อาร์กิวเมนต์บนสแตกที่เหลือจะยังเริ่มที่
[SP+0x20]ซึ่งไม่ตรงกับตำแหน่งที่ AArch64 คาดหวัง - การลบ return address ของ x64 และค่าที่ย้ายไปยัง
X6,X7ออกจากสแตกโดยตรงไม่ปลอดภัย เพราะค่าเหล่านั้นอาจไม่ใช่อาร์กิวเมนต์จริง แต่อาจเป็น caller spill space หรือส่วนหนึ่งของโครงสร้างที่วางบนสแตกของ caller - Elevator จะไม่แตะต้องเลย์เอาต์สแตกของ caller แต่จะจองพื้นที่สแตกเพิ่มเติมขนาด
n×8ไบต์ แล้วคัดลอกอาร์กิวเมนต์ 8 ไบต์ที่เป็นไปได้จำนวนnตัวจากตำแหน่งปัจจุบันไปไว้ใหม่ - ค่าเริ่มต้นของ
nคือ 10 และหากไบนารีอินพุตส่งอาร์กิวเมนต์ให้ฟังก์ชันไลบรารีภายนอกเกิน 16 ตัวรวมกัน ก็สามารถเพิ่มค่านี้ผ่านการตั้งค่าได้ - สุดท้ายระบบจะเก็บที่อยู่ gadget ที่ไลบรารีภายนอกจะย้อนกลับมาลงใน
X30
-
การย้อนกลับจากไลบรารีภายนอก
- เมื่อการควบคุมย้อนกลับมายัง gadget ที่เก็บไว้ใน
X30ก่อนเรียกไลบรารีภายนอก ระบบจะบวกn×8ให้สแตกพอยน์เตอร์เพื่อเก็บกวาดอาร์กิวเมนต์สแตกที่คัดลอกไว้ก่อนหน้า - ระบบจะย้ายค่าคืนจากไลบรารีภายนอกใน
X0ไปยังX9ซึ่งเป็นตำแหน่งRAXที่โค้ด x64 แบบอีมูเลตคาดหวัง - จากนั้นจะดึง return address ของ x64 ต้นฉบับพร้อม padding ที่เกี่ยวข้องออกจากสแตก แปลที่อยู่นั้น แล้ว branch ไปยังตำแหน่งนั้นเพื่อทำการรันต่อจากหลัง
CALLเดิม
- เมื่อการควบคุมย้อนกลับมายัง gadget ที่เก็บไว้ใน
-
Callback ที่เข้ามายังโค้ดที่แปลแล้ว
- หากโค้ด AArch64 แบบเนทีฟเรียกไบนารีที่แปลแล้ว จะต้องแปลง calling convention ของ AArch64 ให้เป็น calling convention ของ x64
- เพราะโค้ด x64 แบบอีมูเลตคาดว่าอาร์กิวเมนต์ตัวที่ 7 และ 8 จะอยู่บนสแตก ไม่ใช่ใน
X6,X7ระบบจึง pushX7ก่อน แล้วจึง pushX6เพื่อให้ไปอยู่ในตำแหน่งบนสแตกตามที่ x64 คาดหวัง - หาก callee ไม่ได้ใช้อาร์กิวเมนต์ตัวที่ 7 และ 8 จริง ๆ ค่าที่ push นี้ก็จะไม่ส่งผลใด ๆ
- ระบบจะ push return address ที่คำสั่ง AArch64 branch-and-link ของไลบรารีภายนอกใส่ไว้ใน
X30ลงในตำแหน่งบนสแตกที่คำสั่ง return ของ x64 คาดหวัง
-
การคืนค่าจาก callback กลับสู่ไลบรารีภายนอก
- เมื่อโค้ดที่แปลแล้วจะคืนค่าจาก callback กลับไปยังไลบรารีภายนอก ระบบจะทำขั้นตอนเข้าย้อนกลับ
- ระบบจะ pop return address ออกจากสแตก แล้ว push
X6และX7กลับเข้าไป และเก็บกวาดพื้นที่สแตกที่จองไว้ด้วยการบวก0x10ให้สแตกพอยน์เตอร์
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ไม่แน่ใจนักว่า user-mode JIT ของ QEMU ทำอะไรอยู่กันแน่ แต่ดูเหมือนว่ายังมีพื้นที่ให้ปรับปรุงได้อีกมาก
ในปี 2013 ผมเคยสร้าง JIT engine ที่แปลงจาก x86-64 ไปเป็น aarch64 และตอนนั้นสามารถรันไบนารี Fedora aarch64 รุ่นเบต้า พร้อมทั้งรีบิลด์พอร์ต aarch64 ของ Fedora ได้เกือบทั้งหมดบน x86_64 Linux
ผมยังทำ JIT ในทิศทางกลับกันคือ aarch64 → x86-64 ด้วย และเพื่อความสนุกก็เคยสาธิตให้ดูว่าทั้งสอง JIT รันกันเองแบบลูปแบ็กภายในโปรเซสเดียวได้ในลักษณะ x86-64 → aarch64 → x86_64
JIT ที่ผมทำแมปคำสั่งและสถานะ CPU แบบ 1 ต่อหลาย และช้ากว่าโค้ดที่คอมไพล์ใหม่เป็นเนทีฟประมาณ 2 ถึง 5 เท่า
พอมาเทียบกับ QEMU JIT ภายหลัง ก็รู้สึกว่า QEMU ช้ากว่าราว 10 ถึง 50 เท่า
น่าเสียดายที่เรื่องไลเซนส์โอเพนซอร์สไม่ลงตัว เลยเปิดเผยโค้ดเพื่อพิสูจน์ไม่ได้
โดยเฉพาะถ้าคุณสามารถทำดีไซน์ให้เฉพาะเจาะจงเป็น “x86 ไป aarch64 เท่านั้น” และ “user mode เท่านั้น” ก็จะได้กำไรด้านประสิทธิภาพเยอะมาก
การรองรับ user mode ของ QEMU ค่อนข้างเหมือนภาคผนวกแบบ “บังเอิญใช้งานได้” ที่ติดมากับการรองรับ system emulation และสถาปัตยกรรม JIT โดยรวมก็เป็นแบบ “guest → intermediate representation → host” ซึ่งเหมาะกับการรองรับ guest หลายสถาปัตยกรรมและ host หลายสถาปัตยกรรม แต่ทำให้ใช้ประโยชน์จากคุณสมบัติเฉพาะของคู่ guest/host บางแบบได้ยาก เช่น “x86 มี integer register น้อยจึง hard-allocate ได้” หรือ “ถ้าตั้ง aarch64 CPU ไว้ในโหมดที่เหมาะสม semantic ของ floating point ที่ซับซ้อนจะตรงเสมอ”
อีกอย่าง ในการพัฒนา QEMU เวลาส่วนใหญ่มักหมดไปกับการ “emulate ฟีเจอร์สถาปัตยกรรมใหม่ X” มากกว่าการหาช่องทาง optimize ประสิทธิภาพ เพราะฝั่งที่จ่ายค่าพัฒนามองว่าสิ่งนั้นสำคัญกว่า
การที่เซ็กชัน
.textใหญ่ขึ้น 50 เท่า นั้นมหาศาลมาก แต่ก็ดูเป็นราคาที่พอรับได้เพื่อให้ได้การแปลงแบบกำหนดแน่นอนสมบูรณ์หลายกรณี ความไม่สะดวกจากขนาดที่เพิ่มขึ้นน่าจะเล็กกว่าความต่างด้านประสิทธิภาพเมื่อเทียบกับการ emulation
และก็น่าสนใจด้วยว่าการไม่รองรับ multithreading กับ exception handling ไม่ใช่ว่าทำไม่ได้ แต่แค่อยู่นอกขอบเขตของโปรเจกต์นี้
สงสัยว่าขั้นต่อไปจะเป็นการใช้ heuristic เพื่อตัดทอน search space และลดขนาดไบนารีหรือไม่
ถ้าเป็นอย่างนั้นก็จะเสียการรับประกันเรื่องการแปลง แต่ในทางปฏิบัติอาจทำให้ความสามารถในการพกพาไบนารีดีขึ้น
ตัวแปลนี้ช้ากว่า Box64 หรือ FEX มาก และถ้าไม่ได้อยู่ในสถานการณ์ที่ใช้ JIT ไม่ได้ด้วยเหตุผลบางอย่าง ก็เป็นตัวเลือกที่แย่กว่าเฉยๆ
ผมสงสัยมาตลอดว่าตัวแปลจัดการกับ indirect jump อย่างไร
เวลาวิเคราะห์ไบนารี คุณจะเจอได้แค่ช่วงโค้ดที่เชื่อมกันด้วย direct jump ซึ่งรู้ปลายทางอยู่แล้ว
ถ้าอย่างนั้นก็แปลว่าทุกครั้งที่เกิด indirect jump ต้องไปหา target function แล้วถ้าจำเป็นก็แปลก่อน จากนั้นค่อยกลับมารันโค้ดที่แปลแล้ว แบบนั้นไม่ช้าเหรอ?
สงสัยว่ามีวิธีที่เร็วกว่านี้ไหม เช่น ทำให้ที่อยู่ของฟังก์ชันที่แปลแล้วตรงกับที่อยู่ฟังก์ชันเดิมได้หรือเปล่า หรือไม่ก็ใส่ jump ที่ตำแหน่งเดิมให้ไปยังโค้ดที่แปลแล้ว
jmpไปยัง address X บล็อกที่สอดคล้องกันจะอยู่ที่ตำแหน่ง Y”วิธีนี้ช้ากว่า direct
jmpที่ไม่ต้องใช้ตาราง แต่ indirect jump ในโปรแกรมต้นฉบับก็ช้ากว่าอยู่แล้วตั้งแต่แรก และโดยปกติก็ไม่ค่อยโผล่บ่อยในลูปที่สำคัญต่อประสิทธิภาพผมชอบไอเดียเรื่อง superset control flow graph มาก แต่ถ้าใครกำลังจะอ่านบทความ ก็ควรรู้ข้อมูลด้านล่างนี้ไว้
เวลาในการรันเร็วขึ้นประมาณ 4.75 เท่า (เร็วกว่า QEMU แต่ยังช้ากว่า Box64 พอสมควร), จำนวนคำสั่งที่รันเพิ่มขึ้น 7 เท่า, และขนาดไบนารีเพิ่มขึ้น 50 เท่า
มีการ emulation x86 ABI จนกว่าจะมี external call
ต้อง emulation สถานะ CPU ของ x86 ส่วนใหญ่ เช่น EFLAGS และแม้แต่
movที่ซับซ้อนก็ต้องคำนวณแยกเป็นรายกรณีรองรับเฉพาะไบนารีเธรดเดียว
ไม่มี exception handling และ stack unwinding
และยังรองรับชุดคำสั่งได้ไม่ครบทั้งหมด
เป็นงานที่น่าสนใจ
ผมยังไม่ได้ดูละเอียด แต่ relative offset ก็น่าจะยังเป็นปัญหาได้
ยังไงผลลัพธ์จากการสร้างโค้ดก็มีขนาดต่างออกไปอยู่ดี จึงดูเหมือนว่าคงต้องมีชั้นแปลบางอย่างหรือ MMU และน่าจะกระทบหลักๆ กับ jump table และ branch ภายใน
ผมทำงานกับของยุค 90s เป็นหลัก และ disassembler มักตั้งสมมติฐานไว้มากเกี่ยวกับจุดเริ่มและจุดสิ้นสุดของโค้ด
แต่บางครั้งถ้าไม่มีความรู้ล่วงหน้า เช่น pointer ของ entry point ที่ตำแหน่งคงที่ ก็แทบหาก้อนไบนารีไม่เจอเลย
ดูเหมือนว่าถ้าทำหลายๆ pass ก็น่าจะค่อยๆ กลั่นไบนารีให้เหลือเป็น “บริเวณที่มั่นใจว่าเป็นโค้ด” ได้
ถ้า “Elevator พิจารณาการตีความที่เป็นไปได้ทั้งหมดของทุกไบต์ และสร้างคำแปลแยกไว้ล่วงหน้าสำหรับแต่ละความเป็นไปได้ [...] พร้อมทั้งตัดทิ้งเฉพาะกรณีที่ลงเอยด้วย crash” อย่างนั้น โปรแกรมจริงที่อาจเกิดการชนกัน จะถูกตัดทิ้งหมดเลยหรือเปล่า?
แบบนั้นมันก็ยังชนอยู่ดี แต่จะไม่เหมือนกับการชนจากการรันโค้ดผิดโดยตรง
สำหรับผม ส่วนที่น่าสนใจที่สุดคือในมุมของ certification
ในอุตสาหกรรมที่มีข้อกำกับดูแลอย่างการบินหรืออุปกรณ์การแพทย์ มักมีกรณีที่ใช้ JIT ไม่ได้ด้วยเหตุผลนี้เป๊ะๆ เพราะโค้ดที่รันต้องเป็นโค้ดที่ผ่านการรับรองแล้ว
การแปลงแบบสแตติกที่สร้างไบนารีที่สามารถเซ็นรับรองได้ อาจเป็นจุด突破ที่ใช้งานได้จริงแม้ต้องแลกกับ code bloat
น่าจะเป็นด้านที่ LLM ก็ยังแทบไม่มีทางนำไปใช้ในวงกว้างได้ และในวาทกรรมใหญ่เรื่อง “AI ในการทำงาน” ก็มักไม่ค่อยพูดถึงเรื่องพวกนี้เลย
50 เท่า นี่ไม่สมเหตุสมผล และเป็นหายนะต่อแคช
ประโยชน์ด้านประสิทธิภาพที่ได้จากการเลี่ยง JIT อาจถูกกินหายหมด
ถ้ารวม hot code ไว้ด้วยกัน ก็อาจทำให้โค้ดที่ไม่ถูกใช้งานไม่ถูกโหลดเลยได้
ตัวคำสั่งเองก็ไม่ได้ใหญ่ขนาดนั้นอยู่แล้ว และ CPU ก็มีการ optimize ตอนรันด้วย
จัดการกับ self-modifying code ได้ไหม?
แล้วทำไมถึงมีแค่ x86_64 ด้วย?
ดูเหมือนว่าการแปลงโปรแกรม 32-bit อย่างเกมเก่าๆ จะมีความหมายมากกว่า
“Self-modifying และ JIT-compiled code. Elevator เช่นเดียวกับตัวเขียนไบนารีใหม่แบบสแตติกทั้งหมด ไม่รองรับ self-modifying code หรือ JIT-compiled code”
ปัจจุบันเซ็กชัน
.textส่วนใหญ่เป็นแบบ read-only และข้อกำหนดด้านความปลอดภัยก็คงไม่มีทางผ่อนลงมันขัดแย้งกันในระดับพื้นฐาน
เพราะมันทำลายประสิทธิภาพของ cache line และการทำนายกิ่งก้านของ pipeline
อีกทั้งยังละเมิด W^X ด้วย จึงโดยปกติควรใช้ได้แค่บนหน้าเมมโมรีที่รองรับ JIT
ดังนั้นแทบจะควรหลีกเลี่ยงเสมอ
ในสมัย 486 หรือ P5 เคยมีการใช้บ้าง เช่น เอา immediate value มาใช้เหมือนตัวแปรในลูปชั้นใน แต่ตอนนี้ไม่ค่อยเป็นแบบนั้นแล้ว
ถ้าจะให้ได้การ emulation หรือการแปลที่ใกล้สมบูรณ์แบบ ก็ยังมีกรณียกเว้นสกปรกๆ ของ x86 อีกมากที่ต้องจัดการ
ซอร์สโค้ด อยู่ที่ไหน?