1 คะแนน โดย GN⁺ 1 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • 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 เดิม
  • Callback ที่เข้ามายังโค้ดที่แปลแล้ว

    • หากโค้ด AArch64 แบบเนทีฟเรียกไบนารีที่แปลแล้ว จะต้องแปลง calling convention ของ AArch64 ให้เป็น calling convention ของ x64
    • เพราะโค้ด x64 แบบอีมูเลตคาดว่าอาร์กิวเมนต์ตัวที่ 7 และ 8 จะอยู่บนสแตก ไม่ใช่ใน X6, X7 ระบบจึง push X7 ก่อน แล้วจึง push X6 เพื่อให้ไปอยู่ในตำแหน่งบนสแตกตามที่ 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 ความคิดเห็น

 
GN⁺ 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 เท่า
    น่าเสียดายที่เรื่องไลเซนส์โอเพนซอร์สไม่ลงตัว เลยเปิดเผยโค้ดเพื่อพิสูจน์ไม่ได้

    • ใช่, QEMU JIT แทบจะเป็นเป้าที่เอาชนะได้ไม่ยาก
      โดยเฉพาะถ้าคุณสามารถทำดีไซน์ให้เฉพาะเจาะจงเป็น “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 ประสิทธิภาพ เพราะฝั่งที่จ่ายค่าพัฒนามองว่าสิ่งนั้นสำคัญกว่า
    • QEMU ไม่ได้เป็นแค่ตัวแปล แต่เป็น TCG และเพราะถูกออกแบบมาให้ทำงานได้กับสถาปัตยกรรมจำนวน n แบบ จึงมีข้อจำกัดอยู่
  • การที่เซ็กชัน .text ใหญ่ขึ้น 50 เท่า นั้นมหาศาลมาก แต่ก็ดูเป็นราคาที่พอรับได้เพื่อให้ได้การแปลงแบบกำหนดแน่นอนสมบูรณ์
    หลายกรณี ความไม่สะดวกจากขนาดที่เพิ่มขึ้นน่าจะเล็กกว่าความต่างด้านประสิทธิภาพเมื่อเทียบกับการ emulation
    และก็น่าสนใจด้วยว่าการไม่รองรับ multithreading กับ exception handling ไม่ใช่ว่าทำไม่ได้ แต่แค่อยู่นอกขอบเขตของโปรเจกต์นี้
    สงสัยว่าขั้นต่อไปจะเป็นการใช้ heuristic เพื่อตัดทอน search space และลดขนาดไบนารีหรือไม่
    ถ้าเป็นอย่างนั้นก็จะเสียการรับประกันเรื่องการแปลง แต่ในทางปฏิบัติอาจทำให้ความสามารถในการพกพาไบนารีดีขึ้น

    • ไม่ใช่ว่าความต่างด้านประสิทธิภาพเมื่อเทียบกับการ emulation จะมากกว่าเสมอไป
      ตัวแปลนี้ช้ากว่า Box64 หรือ FEX มาก และถ้าไม่ได้อยู่ในสถานการณ์ที่ใช้ JIT ไม่ได้ด้วยเหตุผลบางอย่าง ก็เป็นตัวเลือกที่แย่กว่าเฉยๆ
  • ผมสงสัยมาตลอดว่าตัวแปลจัดการกับ indirect jump อย่างไร
    เวลาวิเคราะห์ไบนารี คุณจะเจอได้แค่ช่วงโค้ดที่เชื่อมกันด้วย direct jump ซึ่งรู้ปลายทางอยู่แล้ว
    ถ้าอย่างนั้นก็แปลว่าทุกครั้งที่เกิด indirect jump ต้องไปหา target function แล้วถ้าจำเป็นก็แปลก่อน จากนั้นค่อยกลับมารันโค้ดที่แปลแล้ว แบบนั้นไม่ช้าเหรอ?
    สงสัยว่ามีวิธีที่เร็วกว่านี้ไหม เช่น ทำให้ที่อยู่ของฟังก์ชันที่แปลแล้วตรงกับที่อยู่ฟังก์ชันเดิมได้หรือเปล่า หรือไม่ก็ใส่ jump ที่ตำแหน่งเดิมให้ไปยังโค้ดที่แปลแล้ว

    • ตัวแปลที่ผมทำเป็นงานอดิเรกระดับเล่นๆ แต่ผมใช้ตารางขนาดใหญ่แบบ “ถ้ามี indirect 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” อย่างนั้น โปรแกรมจริงที่อาจเกิดการชนกัน จะถูกตัดทิ้งหมดเลยหรือเปล่า?

    • เดาว่าน่าจะตั้งค่าในตาราง lookup address→code ให้ไปยังเส้นทาง crash ที่ถูกทำให้เป็นมาตรฐาน
      แบบนั้นมันก็ยังชนอยู่ดี แต่จะไม่เหมือนกับการชนจากการรันโค้ดผิดโดยตรง
  • สำหรับผม ส่วนที่น่าสนใจที่สุดคือในมุมของ certification
    ในอุตสาหกรรมที่มีข้อกำกับดูแลอย่างการบินหรืออุปกรณ์การแพทย์ มักมีกรณีที่ใช้ JIT ไม่ได้ด้วยเหตุผลนี้เป๊ะๆ เพราะโค้ดที่รันต้องเป็นโค้ดที่ผ่านการรับรองแล้ว
    การแปลงแบบสแตติกที่สร้างไบนารีที่สามารถเซ็นรับรองได้ อาจเป็นจุด突破ที่ใช้งานได้จริงแม้ต้องแลกกับ code bloat

    • ผมสงสัยว่าพื้นที่นี้ใหญ่แค่ไหนในอุตสาหกรรมซอฟต์แวร์
      น่าจะเป็นด้านที่ LLM ก็ยังแทบไม่มีทางนำไปใช้ในวงกว้างได้ และในวาทกรรมใหญ่เรื่อง “AI ในการทำงาน” ก็มักไม่ค่อยพูดถึงเรื่องพวกนี้เลย
  • 50 เท่า นี่ไม่สมเหตุสมผล และเป็นหายนะต่อแคช
    ประโยชน์ด้านประสิทธิภาพที่ได้จากการเลี่ยง JIT อาจถูกกินหายหมด

    • จะเป็นแบบนั้นก็ต่อเมื่อโค้ดทั้งหมดถูกใช้งานจริงระหว่างรัน และจุดเริ่มการถอดรหัสที่เป็นไปได้ส่วนใหญ่น่าจะไม่ได้ถูกใช้
    • นี่เป็นกรณีที่เข้ากับ link-time code reordering ได้ดีมาก
      ถ้ารวม hot code ไว้ด้วยกัน ก็อาจทำให้โค้ดที่ไม่ถูกใช้งานไม่ถูกโหลดเลยได้
    • ผมยังไม่อยากสรุปเร็วเกินไป
      ตัวคำสั่งเองก็ไม่ได้ใหญ่ขนาดนั้นอยู่แล้ว และ CPU ก็มีการ optimize ตอนรันด้วย
  • จัดการกับ self-modifying code ได้ไหม?
    แล้วทำไมถึงมีแค่ x86_64 ด้วย?
    ดูเหมือนว่าการแปลงโปรแกรม 32-bit อย่างเกมเก่าๆ จะมีความหมายมากกว่า

    • ถ้าอ่านบทความที่ลิงก์ไว้จะเห็นว่าเขาพูดถึงเรื่องนี้ไว้ชัดเจน
      “Self-modifying และ JIT-compiled code. Elevator เช่นเดียวกับตัวเขียนไบนารีใหม่แบบสแตติกทั้งหมด ไม่รองรับ self-modifying code หรือ JIT-compiled code”
    • self-modifying code ที่อยู่นอก JIT runtime ทุกวันนี้ค่อนข้างพบได้น้อยมาก เมื่อเทียบกับยุค 80s~90s
      ปัจจุบันเซ็กชัน .text ส่วนใหญ่เป็นแบบ read-only และข้อกำหนดด้านความปลอดภัยก็คงไม่มีทางผ่อนลง
    • ถ้าจะรองรับ self-modifying code มันก็จะไม่ใช่ “fully static” อีกต่อไป
      มันขัดแย้งกันในระดับพื้นฐาน
    • ถ้ามองจากฝั่งที่พัฒนา x86 ใหม่ๆ self-modifying code แม้จะทำได้ แต่โดยทั่วไปถือว่าแย่มาก
      เพราะมันทำลายประสิทธิภาพของ cache line และการทำนายกิ่งก้านของ pipeline
      อีกทั้งยังละเมิด W^X ด้วย จึงโดยปกติควรใช้ได้แค่บนหน้าเมมโมรีที่รองรับ JIT
      ดังนั้นแทบจะควรหลีกเลี่ยงเสมอ
      ในสมัย 486 หรือ P5 เคยมีการใช้บ้าง เช่น เอา immediate value มาใช้เหมือนตัวแปรในลูปชั้นใน แต่ตอนนี้ไม่ค่อยเป็นแบบนั้นแล้ว
      ถ้าจะให้ได้การ emulation หรือการแปลที่ใกล้สมบูรณ์แบบ ก็ยังมีกรณียกเว้นสกปรกๆ ของ x86 อีกมากที่ต้องจัดการ
  • ซอร์สโค้ด อยู่ที่ไหน?