1 คะแนน โดย GN⁺ 2024-07-11 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ระหว่างเขียน CPU emulator สำหรับ Time Travel Debugging ขึ้นใหม่ด้วย C++ พบว่า x86/amd64 ต้องจัดการแม้แต่พฤติกรรมเดียวกันให้ต่างกันตาม encoding, prefix และโหมดการทำงาน
  • มีรูปแบบแทนคำสั่งจำนวนมากที่ส่งผลต่อประสิทธิภาพและการดีบัก เช่น encoding แบบ single-byte CC ของ int 3, รูปแบบสั้นของ ADD EAX, imm, และ REX prefix ที่ไม่มีผลใด ๆ
  • คำสั่ง INC/DEC, CMPXCHG8B/CMPXCHG16B, และคำสั่ง shift/rotate จัดการ flags แบบไม่ตรงกับที่คาด ทำให้เกิด บั๊กใน emulator ได้ง่าย
  • shift count ไม่ได้ถูกใช้ตามขนาด operand ตรง ๆ แต่ถูก mask ไว้ ดังนั้น shr eax,20h จึงไม่ได้ทำให้รีจิสเตอร์ 32 บิตกลายเป็น 0 แต่กลับ คงค่าเดิมไว้
  • segment ยังถูกใช้ในการเข้าถึง TEB บน Windows แบบ 32 บิตและ 64 บิต และความต่างของความหมาย FS/GS รวมถึงวิธีกำหนด base มีผลโดยตรงต่อการทำ disassembler และ emulator

กฎย่อยของ x86 ที่โผล่มาระหว่างการเขียน TTD emulator ใหม่

  • องค์ประกอบหนึ่งของ Time Travel Debugging คือ CPU emulator ที่บันทึกการทำงานทั้งหมดของโปรเซสในระดับคำสั่ง
  • emulator ของ iDNA รุ่นแรกเขียนเกือบทั้งหมดด้วยแอสเซมบลี จึงเร็วมาก แต่ดูแลรักษาและขยายต่อได้ยาก
  • ในรุ่นที่สอง ส่วนของ emulation และส่วนอื่น ๆ เกือบทั้งหมดหลังจากนั้นถูกเขียนใหม่ด้วย C++ โดยตั้งเป้าจะคงประสิทธิภาพส่วนใหญ่ของเวอร์ชันแอสเซมบลีไว้ พร้อมกับทำให้ฐานโค้ดดูแลง่ายขึ้น
  • การสร้าง CPU emulator ต้องทำรายละเอียดพฤติกรรมของ CPU ให้ตรงทุกจุด และแม้แต่กฎที่คนคุ้นเคยกับ x86 รู้สึกว่าคุ้นตาอยู่แล้ว ก็ต้องกลับมาตรวจสอบอีกครั้งเมื่อทำของจริง

x86 encoding ที่แทนคำสั่งเดียวกันได้หลายแบบ

  • x86 สามารถแทนคำสั่งเดียวกันด้วยลำดับไบต์ได้หลายแบบ
  • int 3 เข้ารหัสเป็น CD 03 ได้ แต่ก็เข้ารหัสเป็น CC แบบ ไบต์เดียว ได้เช่นกัน
    • เพราะมันถูกใช้เป็น software breakpoint จึงสามารถวาง breakpoint ที่ตำแหน่งคำสั่งตรงท้ายหน้า memory ซึ่งหน้าถัดไปยังไม่ได้ถูกแมปได้
  • ยังมี encoding ทางเลือกที่ทำให้กรณีที่พบบ่อยสั้นลง
    • add eax, imm เขียนแบบสั้นได้เป็น 05cccccccc
    • ถ้าจะบวกค่าเดียวกันให้ ECX จะต้องใช้ 81c1cccccccc ซึ่งยาวกว่า 1 ไบต์
  • การที่ EAX ถูกเรียกว่า “Accumulator register” ไม่ใช่แค่ขนบ แต่ทำให้เกิดความต่างของ encoding จริง และคำสั่งที่สั้นลงอาจช่วยด้านประสิทธิภาพได้เพราะลดทั้งข้อมูลที่ต้องย้ายจากหน่วยความจำหลักและการใช้ instruction cache
  • คอมไพเลอร์สามารถใช้ encoding แบบสั้น เหล่านี้ได้เมื่อทำได้

prefix และข้อจำกัดความยาวคำสั่ง 15 ไบต์

  • คำสั่ง x86 สามารถมี prefix byte ที่เปลี่ยนพฤติกรรมได้
  • REX prefix ซึ่งใช้บ่อยในโค้ด 64 บิต ใช้เพื่อเข้าถึงช่วงรีจิสเตอร์ที่กว้างกว่าโค้ด 32 บิต
  • CPU ยอมรับ REX prefix ที่ไม่มีผลใด ๆ ได้ด้วย
    • 4004cc คือรูปแบบที่มี REX byte นำหน้า add al,0CCh แบบ 8 บิต แต่ในกรณีนี้ REX ไม่มีผลอะไร
    • แม้จะใส่ REX prefix สองตัว CPU ก็ยังรันได้ และ disassembler หลายตัวรวมถึง WinDbg อาจสับสนกับกรณีนี้
  • บน CPU ที่เข้ากันได้กับ x86 ความยาวคำสั่งปัจจุบันมี hard limit อยู่ที่ 15 ไบต์
    • คำสั่งที่ยาวเกิน 15 ไบต์จะถือเป็นคำสั่งไม่ถูกต้องและทำให้เกิด exception
    • CPU รุ่นเก่ายังมีข้อจำกัดอื่นเกี่ยวกับ prefix ด้วย และบาง prefix เช่น LOCK ก็มีเงื่อนไขการใช้ที่เข้มงวดกว่า

การตีความที่เปลี่ยนไปตามขนาดแอดเดรสและโหมด

  • Address override prefix สามารถทำให้ในโหมด 64 บิตอ้างถึง แอดเดรส 32 บิต ได้
    • 488d0424 คือ lea rax,[rsp]
    • 67488d0424 คือ lea rax,[esp] เพราะมี prefix 0x67
  • ในโค้ด 32 บิต Address override จะเปลี่ยนโหมดแอดเดรสเป็น แอดเดรส 16 บิต
  • แม้จะเป็นลำดับไบต์เดียวกัน ก็ต้องรู้ขนาด operand เริ่มต้นและขนาดแอดเดรสของ code segment จึงจะ disassemble หรือตีความได้ถูกต้อง
    • 8b0424 ในโหมด 32 บิตคือ mov eax,dword ptr [esp]
    • 8b0424 ในโหมด 64 บิตคือ mov eax,dword ptr [rsp]
  • ช่วง 40~4F ที่เคยใช้กับ INC reg และ DEC reg บน x86 ถูกนำไปใช้เป็น REX prefix bytes บน x64
    • ในโหมด 32 บิต 48 03 04 24 ถูกตีความเป็นสองคำสั่งคือ dec eax และ add eax,dword ptr [esp]
    • ในโหมด 64 บิต 48030424 ถูกตีความเป็นคำสั่งเดียวคือ add rax,qword ptr [rsp]
  • ผู้ออกแบบ AMD64 นำพื้นที่ encoding ขนาดใหญ่ของ INC/DEC ไปใช้กับ prefix ใหม่เพื่อขยายชุดรีจิสเตอร์ในโหมด 64 บิต โดยคำสั่งเหล่านั้นมี encoding แบบอื่นที่รองรับทั้งรีจิสเตอร์และหน่วยความจำอยู่แล้ว

กับดักของ WinDbg และ INC reg ในโหมด 64 บิต

  • WinDbg ประกอบคำสั่งราวกับอยู่ในโหมด 32 บิตเสมอ ดังนั้นถ้าพยายาม assemble INC reg ในโค้ด 64 บิต อาจได้ผลลัพธ์ไม่ตรงเจตนา
  • ในตัวอย่าง inc eax ไม่ได้กลายเป็นคำสั่งเพิ่มค่าจริง แต่กลายเป็น REX prefix ที่ไร้ประโยชน์ ซึ่งไปขยายคำสั่งถัดไปแทน
  • ผลคือชุดไบต์นั้นไม่ได้ถูกตีความเป็น inc แต่เป็น prefix ที่อยู่หน้าคำสั่ง jmp

ข้อยกเว้นของพฤติกรรม flags

  • INC EAX ดูคล้าย ADD EAX, 1 แต่ไม่เหมือนกันทั้งหมด
    • ADD จะอัปเดต carry flag
    • INC จะ ไม่อัปเดต carry flag
  • ระหว่างทำ TTD emulator ผู้เขียนเคย implement จุดนี้ผิดในตอนแรก และเจอจาก unit test
  • การคำนวณเชิงเลขและตรรกะส่วนใหญ่จะตั้งค่า overflow, sign, zero, auxiliary carry, parity และ carry flag
  • CMPXCHG ก็ตั้งค่า flags เหล่านี้เช่นกัน แต่ CMPXCHG8B และ CMPXCHG16B จะเปลี่ยนเฉพาะ zero flag เท่านั้น
  • คำสั่งบางตัวปล่อยบาง flags ให้อยู่ในสถานะไม่กำหนด
    • คำสั่ง shift และ rotate จะปล่อย overflow flag เป็นสถานะไม่กำหนดเมื่อ shift amount มากกว่า 1
    • พฤติกรรมจริงของ flags ที่ไม่กำหนดเกี่ยวข้องกับการ implement ภายในของการทำ shift และอาจต่างกันตามสถาปัตยกรรม
    • มีเรื่องเล่าว่า CPU ตระกูล Atom ทำ bit shift ใน ALU ด้วยวิธีที่ถูกกว่าและช้ากว่า จึงทำให้ค่า flags ที่ไม่กำหนดออกมาต่างกัน แต่ผู้เขียนไม่ได้ทดสอบเอง

การ mask count ของคำสั่ง shift

  • 66c1e810 คือ shr ax,10h และทำการ shift AX ไปทางขวา 16 บิต
    • เนื่องจาก AX เป็นรีจิสเตอร์ 16 บิต ผลลัพธ์จึงกลายเป็น 0
  • c1e820 คือ shr eax,20h ซึ่งดูเผิน ๆ เหมือนเป็นคำสั่ง shift EAX ไปทางขวา 32 บิต
  • แต่ความจริงแล้วค่า EAX ไม่เปลี่ยนเลย
    • ตาม Intel SDM ค่า count จะถูก mask ด้วย 1Fh ทำให้ใช้เฉพาะ 5 บิตล่างของค่าที่ใช้ rotate/shift
    • ถ้าใช้ prefix REX.W mask จะกลายเป็น 3Fh ทำให้ค่า shift สูงสุดเป็น 63 บิต
  • ในการสัมภาษณ์งานที่ Microsoft เคยมีคำถามว่า “มีวิธีใดบ้างในการ clear รีจิสเตอร์ 32 บิตด้วยคำสั่งเดียว” และพฤติกรรมนี้เคยเป็นประเด็นจริง
    • ผู้สัมภาษณ์คิดว่าน่าจะใช้ shift ได้ แต่คำตอบคือทำไม่ได้สำหรับรีจิสเตอร์ 32 บิต

segment ที่ยังมีชีวิตอยู่ในโค้ด 32 บิตและ 64 บิต

  • หน่วยความจำแบบ segment อาจดูเหมือนของตกค้างจากโค้ด 16 บิต แต่ในโค้ด 32 บิตและ 64 บิตก็ยังมีผลจริง
  • OS ส่วนใหญ่ใช้โมเดลหน่วยความจำที่เกือบเป็น flat และตั้ง segment base address เป็น 0 จึงมักไม่รู้สึกถึงมันในชีวิตประจำวัน
    • ในโหมด 64 บิต CPU จะถือว่า segment base ของ CS, DS, ES, SS เป็น 0 เสมอ
  • ข้อยกเว้นคือ thread local storage ที่ใช้ extra segment register อย่าง FS หรือ GS
  • มีการแก้ไขเพิ่มเติมว่า base ของ segment FS/GS สามารถอ่านได้จากโค้ดที่ไม่มีสิทธิพิเศษด้วยคำสั่ง rdfsbase, wrfsbase, rdgsbase, wrgsbase
    • คำสั่งเหล่านี้ใช้ได้ตั้งแต่ Ivy Bridge หรือปี 2012 เป็นต้นมา

การเข้าถึง Windows TEB และ FS/GS

  • บน Windows นั้น FS และ GS ถูกใช้เพื่ออ้างถึง TEB (Thread Execution Block)
  • โครงสร้าง TEB มี self pointer ที่ชี้ไปยัง flat address ของจุดเริ่มต้นโครงสร้าง และแอดเดรสนี้ก็คือ base ของ segment นั้นด้วย
  • ในโปรเซส 32 บิต TEB อยู่ที่ FS
    • GetLastError จะดึง TEB.NtTib.Self จาก fs:[00000018h] แล้วอ่าน LastErrorValue จาก [eax+34h]
  • ในโปรเซส 64 บิต TEB อยู่ที่ GS
    • GetLastError จะอ่าน pointer จาก gs:[30h] แล้วดึงค่าจาก [rax+68h]
  • โปรเซส 32 บิตที่รันบน OS 64 บิตจะมีทั้ง TEB แบบ 32 บิตและ 64 บิต และมีบริบทที่การเข้าถึงทั้งสองอันมีประโยชน์ เช่น WOW code แบบ 64 บิตที่รันอยู่ภายในโปรเซส 32 บิต

วิธีตัดสิน segment base ก็แตกต่างกันตามโหมด

  • การตั้งค่าของ CPU ที่ใช้กำหนด base address ของ FS และ GS ต่างกันระหว่างโหมด 32 บิตกับโหมด 64 บิต
  • ในโหมด 32 บิต ค่าจริงของ segment register จะอ้างถึง segment descriptor ที่นิยามไว้ใน Global Descriptor Table และ Local Descriptor Table
  • ในโหมด 64 บิต base จะถูกควบคุมด้วย MSR สองตัว
    • FS Base, หรือ IA32_FS_BASE ตาม Intel SDM
    • GS Base, หรือ IA32_GS_BASE ตาม Intel SDM
  • ด้วยโครงสร้างนี้ ค่าของรีจิสเตอร์ FS และ GS เองจึงไม่สำคัญในโหมด 64 บิต
    • สิ่งที่สำคัญคือ segment override prefix
  • ระหว่างดีบักโปรเซส 32 บิตใน WinDbg สามารถใช้ค่ารีจิสเตอร์ FS เพื่อ dump เนื้อหาของ “FS segment” ได้
  • แต่ในโปรเซส 64 บิต วิธีเดียวกันใช้ไม่ได้ และ segment override prefix มีความหมายมากกว่าค่า segment เอง

บทเรียนเชิงปฏิบัติสำหรับคนทำ emulator

  • การสร้าง x86 emulator ทำให้ต้องรับมืออย่างละเอียดกับ พฤติกรรมจริงของ CPU เช่น instruction encoding, prefix, flags, shift count และ segment
  • กฎเหล่านี้จำนวนมากแทบไม่มีประโยชน์กับการเขียนโค้ดทั่วไป แต่เป็นข้อกำหนดโดยตรงสำหรับการ implement emulator
  • ผู้เขียนได้เรียนรู้หลายอย่างจากการลองผิดลองถูกและการมี mentor รวมถึงแนะนำ Darek Mihocka กับ emulators.com ซึ่งมีประสบการณ์กับ emulator รุ่นเก่าอย่างมาก
  • หากสนใจการ optimize บน x86 และพฤติกรรมระดับล่างของระบบ เอกสารใน Agner Fog’s website มีประโยชน์มาก

1 ความคิดเห็น

 
GN⁺ 2024-07-11
ความคิดเห็นจาก Hacker News
  • แถม BSF/BSR ก็มีจุดแปลกด้วย Intel SDM ระบุว่าถ้าอินพุตเป็น 0 ค่าปลายทางจะไม่ถูกกำหนด แต่ AMD ระบุไว้ในเอกสารว่าในกรณีนั้นปลายทางจะไม่ถูกแก้ไข
    แต่ glibc ใช้ข้อเท็จจริงที่ไม่ได้ระบุในเอกสารว่าแม้บน Intel ปลายทางก็ไม่ถูกแก้ไขเช่นกัน [1] เรื่องนี้ทำให้ผมใช้เวลาค่อนข้างนานในการหาสาเหตุปัญหาใน binary translator ของผม
    อีกอย่าง TZCNT/LZCNT เป็น encoding ของ BSF/BSR ที่มี prefix F3 แต่บนโปรเซสเซอร์รุ่นเก่าที่ไม่รองรับส่วนขยายนี้ prefix จะถูกเพิกเฉยไปเงียบ ๆ ดังนั้นโค้ดเดียวกันจึงทำงานต่างกันตาม CPU แต่ อย่างน้อยเรื่องนี้ก็มีระบุในเอกสาร
    ในด้าน encoding คนมักด่า prefix กันบ่อย แต่ส่วนตัวผมคิดว่านั่นยังไม่ใช่ส่วนที่แย่ที่สุด มันเป็นที่รู้กันดีและมีเอกสารอยู่พอสมควร ยังมีจุดแปลกที่แย่กว่านั้นอีก เช่น บิตขยาย REX/VEX/EVEX.RXB ถ้าไม่เกี่ยวกับสิ่งที่จะใช้ก็จะถูกเพิกเฉย แต่กับ mask register k0-k7 จะทำให้เกิด #UD ทว่าเมื่อรีจิสเตอร์ถูก encode อยู่ใน ModRM.rm บิตขยายกลับถูกเพิกเฉยอีก
    APX ยกระดับความแปลกขึ้นไปอีกขั้น prefix REX2 สามารถ encode รีจิสเตอร์ทั่วไป r16-r31 ได้ แต่ encode xmm16-xmm31 ไม่ได้ ส่วน prefix EVEX มี layout หลายแบบขึ้นกับ opcode และบิตขยายที่ใช้กับรีจิสเตอร์ก็แตกต่างกันตามชนิดของรีจิสเตอร์ด้วย รีจิสเตอร์ XMM ใช้ X3:B3:rm และ V4:X3:idx ส่วนรีจิสเตอร์ทั่วไปใช้ B4:B3:rm และ X4:X3:idx ผ่านมา 1 ปีแล้วแต่ผมยังทำ APX decoder ไม่เสร็จ จึงให้รายการทั้งหมดไม่ได้
    [1]: https://sourceware.org/bugzilla/show_bug.cgi?id=31748

    • ตลอดปีที่ผ่านมา ผมค่อย ๆ เขียน x86 decoder ของ QEMU ใหม่ในเวลาว่าง ตอนแรกเป็นงานที่จำเป็นเพื่อใส่การรองรับ AVX แต่ตอนนี้เหลือ opcode ที่ต้องเขียนใหม่อีกแค่ไม่กี่ตัว และหลังจากนั้นการรองรับ APX ก็น่าจะไม่ยากมาก
      สำหรับ EVEX ผมตั้งใจจะเก็บบิตดิบไว้จนกว่าจะอ่าน opcode และระบุคลาสของ EVEX ได้ กล่าวคือวางแผนจะเก็บไว้ถึงขั้นก่อน immediate และน่าจะก่อน ModRM
      decoder ของผมโดยรวมอิงจากตารางในคู่มือ และโค้ดก็ถือว่าโอเคเป็นส่วนใหญ่ การเยื้องไม่ได้มากเกินไป และขั้นตอนต่าง ๆ ส่วนใหญ่ก็แยกกันหรือระบุได้ง่าย เนื่องจากเอาต์พุตเป็นโค้ด JIT จึงไม่จำเป็นต้องมีประสิทธิภาพสูงมาก อ่านง่ายไว้ก็พอ และนี่ก็ไม่ใช่จุดที่ใช้เวลาส่วนใหญ่ด้วย
      ถึงอย่างนั้นก็มีหลายกรณีที่คู่มือผิดหรือไม่ได้บอกเนื้อหาทั้งหมด ตารางก็ไม่ได้อัปเดตมาหลายปีแล้ว เช่น ไม่มีคำสั่งของ K register ด้วยซ้ำ ต่อไปคงต้องทำด้วยมือมากขึ้น
      คอมเมนต์ด้านบนสุดอธิบายสถานการณ์ไว้นิดหน่อย: https://github.com/qemu/qemu/blob/59084feb256c617063e0dbe7e6...
      อย่างที่พูดไปข้างต้น ยังมีคำสั่งอีกไม่กี่ตัวที่โค้ดเก่ายังเป็นผู้จัดการ โดยเฉพาะ BT/BTS/BTR/BTC โค้ดเขียนไว้แล้วแต่ยังไม่ได้ merge
    • มีจุดแปลกอีกอย่างจากยุค 486 และ Pentium BSWAP EAX ใช้แปลงระหว่าง little endian กับ big endian และเป็นคำสั่ง 32 บิตมาตั้งแต่แรก
      แต่มี prefix 0x66 ที่ใช้สลับโหมด 16 บิตกับ 32 บิตอยู่ ถ้านำมันไปใช้กับ BSWAP EAX จะเกิดเรื่องแปลกที่ไม่ได้ถูกกำหนดไว้
      ในสถาปัตยกรรม CPU บางแบบ prefix จะถูกเพิกเฉยไปเหมือนความต่างระหว่าง Intel กับ AMD ส่วนบางแบบจะทำพฤติกรรมที่ผมเรียกว่า “internal swap” เช่น ในสี่ไบต์ที่เก็บอยู่ใน EAX ไบต์ที่ 1 กับไบต์ที่ 2 จะสลับกัน
      0x11223344 จะกลายเป็น 0x11332244
    • แค่คิดว่าต้องทำให้ลอจิกทั้งหมดนี้ทำงานอย่างถูกต้องในซิลิคอน แถมยังต้องเร็วด้วย ก็ชวนเวียนหัวแล้ว
      เมื่อก่อน x86 เคยเป็นคูเมืองของ Intel แต่ตอนนี้ดูเหมือนภาระฝันร้ายที่ต้องแบกต่อไป
    • การผสมกันของ semantics และ encoding ของ LZCNT รู้สึกเหมือนยิงเข้าประตูตัวเอง มันถูก encode เป็นคำสั่ง BSR ที่มี prefix ซึ่ง legacy จะเพิกเฉย และเมื่ออินพุตไม่ใช่ 0 ค่าที่คืนมาคือขนาดของ operand ลบด้วยค่าที่เวอร์ชัน legacy คืน
      มีฟังก์ชันชื่อ clz() อยู่ก็จริง แต่ถ้า LZCNT เป็นแค่ BSR ที่ต่างกันเฉพาะ semantics ของอินพุต 0 การเพิ่มการลบอีกครั้งในการ implementation เพื่อให้ได้ compatibility ก็น่าจะเป็นต้นทุนเล็กน้อย
    • ผมไม่ค่อยรู้ด้านนี้นัก แต่ถ้าเชื่อมต่อ JTAG interface เข้ากับ CPU x86 แล้วรันทีละคำสั่งพร้อมบันทึกค่าของรีจิสเตอร์ทั้งหมด ก็น่าจะน่าสนใจ
      ถ้านำโปรแกรมเดียวกันไปรันบน CPU ที่ emulate ด้วย แล้วตรวจสอบว่าทุกคำสั่งมีสถานะเหมือนกันหรือไม่ ก็น่าจะทดสอบได้ว่า emulator จำลองฮาร์ดแวร์ได้สมบูรณ์แบบหรือเปล่า
  • เป็นคนที่เจ๋งมาก การเขียน assembly ให้ความรู้สึกเรียบง่าย และผมก็ชอบสุนทรียะของมันที่เรียงลงมาเป็นแนวตั้ง
    ประสบการณ์ที่ใกล้เคียงกับงานแบบ OP แม้เพียงเล็กน้อยของผม คือการพยายามอธิบาย stack ให้เพื่อนที่ทำ JS เข้าใจ จนลงเอยด้วยการช่วยกันสร้าง mini VM ที่มี ISA เล็ก ๆ: https://gist.github.com/darighost/2d880fe27510e0c90f75680bfe...
    ตอนนั้นเราน่าจะขุดลึกลงไปได้อีก และผมก็อยากให้เป็นอย่างนั้น แต่คงจะหลุดจากเป้าหมายการสอนเดิมไป คงต้องลองติดต่อเพื่อนคนนั้นดูว่ายังอยากเรียนด้วยกันอยู่ไหม เพื่อนหาเงินได้เยอะจากงานเว็บสุดเจ๋งเลยไม่มีเวลาลงลึก ส่วนผมเป็นคนว่างงาน เวลาและพลังงานจึงแทบเหมือนทะเลที่ไม่มีที่สิ้นสุด เลยไม่ง่ายนัก

    • ใช้โอกาสนั้นเรียน JS จากเพื่อนก็น่าจะดี
    • ในฐานะคนที่ไม่ได้เรียนสายนี้มา โค้ดนี้เป็นทางเข้าที่ดีในการเริ่มเข้าใจการทำงานภายใน และมันพาผมไปสู่เส้นทาง assembly ที่ค่อนข้างเจ๋งและยาก ผมตั้งใจจะขุดลึกต่อไป
  • Justine Tunney กับ emulator ของเธอก็น่าดูเหมือนกัน https://justine.lol/blinkenlights/
    เอกสารพาให้ได้สำรวจอย่างยอดเยี่ยมว่า CPU ทำงานอย่างไร

    • น่าทึ่ง ทุกครั้งก็ทำให้ประทับใจได้เสมอ
    • ชื่อ Tunney ทำให้นึกถึงช่วงราวปี 2014 ตอนที่เร่ร่อนแบบไร้บ้านและโพสต์เรื่องไร้สาระบน Twitter เกี่ยวกับ Occupy
  • การถกเถียงก่อนหน้านี้อยู่ที่นี่: https://news.ycombinator.com/item?id=34636699
    ไม่น่าเชื่อว่าเวลาผ่านไป 16 เดือนแล้ว เวลาผ่านไปเร็วจริง ๆ

  • ไม่เห็นด้วยอย่างแรงกับคำกล่าวที่ว่า “การเขียน CPU emulator คือวิธีที่ดีที่สุดในการเข้าใจการทำงานของ CPU อย่างแท้จริง”
    วิธีที่ดีที่สุดคือ สร้าง CPU ในระดับเกต เหมือนที่ทำกันในวิชาวิทยาการคอมพิวเตอร์ดี ๆ การสร้าง ARM แบบย่อส่วนตั้งแต่ต้นสนุกมากจริง ๆ

    • คิดว่าทั้งสองอย่างมีประโยชน์ เพียงแต่ว่าการออกแบบ CPU สมัยใหม่ใน ระดับเกต เป็นเรื่องที่คนส่วนใหญ่เอื้อมไม่ถึง และมีช่องว่างขนาดใหญ่ระหว่าง CPU ที่ออกแบบในมหาวิทยาลัยกับ CPU ที่รันโค้ดจริง
      การสร้าง emulator ของ CPU สมัยใหม่เป็นความท้าทายที่เข้าถึงได้มากกว่า และแม้ทำงานได้แค่บางส่วนก็ยังให้ผลด้านการเรียนรู้สูงมาก
    • เห็นด้วย แม้แต่โปรเซสเซอร์พื้นฐานที่มี microcode, pipeline, superscalar, branch prediction, แคชข้อมูล/คำสั่ง L1 และตัวควบคุมแคช L2 แบบ write-back ก็ไม่ง่ายเลย
      วิศวกรซอฟต์แวร์ส่วนใหญ่มีความเข้าใจเกี่ยวกับ data hazard, การทำให้แคชเป็นโมฆะ และ pipeline stall ที่ไม่สมบูรณ์
    • คิดว่าทั้งสองฝ่ายถูก การต่อ ชิป 74xx เข้าด้วยกันให้ความพอใจอย่างมาก และช่วยให้เข้าใจด้านไฟฟ้าและ trade-off ภายในได้ด้วยสัญชาตญาณ
      แต่เมื่อเริ่มสร้าง CPU แบบนั้นเพื่อใช้กับงานที่มีความหมาย รายละเอียดพวกนั้นก็เริ่มน่าสนใจน้อยลง ตอนนั้นความซับซ้อนของพฤติกรรมและสเปกจะน่าสนใจกว่า และแนวทาง emulator ก็จัดการได้ง่ายกว่าและครอบคลุมชนิดของพฤติกรรมได้มากกว่า
    • กำลังทำตาม Nand2Tetris โดยไล่สร้างจากระดับเกตขึ้นมา และเพิ่งจบบท VM emulator ซึ่งใช้เวลานานมาก ตอนนี้กำลังจะไปขั้นตอนคอมไพล์
    • ในทางกลับกัน จะ implement ไปถึง memory segmentation จริง ๆ บน CPU ระดับเกตเลยหรือ? ผมคิดว่าทั้งสองขั้นตอน—สร้าง CPU ที่ทำงานได้จริง แล้วจากนั้น emulate CPU จริงรวมถึงข้อบกพร่องของมัน—ล้วนจำเป็นต่อความเข้าใจที่แท้จริง
  • เคยเขียน emulator ความเร็วสูงให้สถาปัตยกรรมที่ไม่ใช่ของเล่นมาราว 12 แบบ และยังทำ JIT translator อยู่บ้าง แต่ x86 ยังทำให้ PTSD อยู่ดี ไม่เคยเห็นสถาปัตยกรรมที่รกขนาดนี้มาก่อน มันมีประวัติศาสตร์และเหตุผลของมัน แต่ก็ยังหนักมากอยู่ดี

    • การศึกษา x86 architecture ให้ความรู้สึกเหมือนเรียนภาษาที่เต็มไปด้วยความไม่สม่ำเสมอและอวัยวะหลงเหลือ รวมถึงระบบไวยากรณ์ที่แข่งกันเอง เช่น ภาษาฝรั่งเศส สถาปัตยกรรมอื่นอย่าง RISC-V หรือ ARMv8 มีความสม่ำเสมอกว่ามาก
    • ถ้า “ไม่เคยเห็นสถาปัตยกรรมที่รกกว่านี้” ก็ยังมี Itanium ทุกครั้งที่เปิดคู่มือ จะเจออะไรใหม่ ๆ แบบไม่ต้องตั้งใจหาเลยที่ทำให้คิดว่า “ตอนนั้นคิดอะไรกันอยู่เนี่ย?”
    • เห็นด้วย บางทีอาจควรเริ่มจากอันนั้นตั้งแต่แรกก็ได้
  • ช่วงหลังผมทำส่วนใหญ่ของ x86-64 decoder เป็นโปรเจกต์ข้างเคียง [1] และค่อนข้างประหลาดใจว่ามันซับซ้อนขึ้นมากในยุคนี้ สำหรับเป้าหมายของผม Sandpile.org [2] มีประโยชน์มากจริง ๆ
    [1] พูดให้ตรงคือเป็นเวอร์ชัน x86-64 ของ disfilter ของ Fabian Giesen ที่ทำไว้เพื่อโปรเจกต์ข้างเคียงอีกตัวซึ่งยังไม่ได้เปิดเผย: https://gist.github.com/lifthrasiir/df47509caac2f065032ef72e...
    [2] https://sandpile.org/

  • 68k disassembler ที่ทำตอนมหาวิทยาลัยให้ความรู้สึกเหมือนฉากที่ Neo พูดว่า “ผมรู้กังฟูแล้ว” มันเป็นชิ้นส่วนเชื่อมโยงที่ขาดหายไป ตั้งแต่ภาษาระดับสูงลงไปถึงทรานซิสเตอร์ แล้วกลับมาย้อนคิดเหตุผลเกี่ยวกับโค้ดได้อีกครั้ง
    ถ้าเขียน emulator เต็มตัว น่าจะให้ผลมากกว่านั้นอีกหนึ่งลำดับขั้น บทความดีมาก

    • ผมคิดว่าการเขียน ISA emulator แทบไม่ได้ช่วยให้เข้าใจว่า CPU superscalar สมัยใหม่ทำงานอย่างไรนัก เพราะแทบทุกส่วนเป็นการปรับแต่งที่ซ่อนอยู่ภายใน
  • ดูเหมือนว่าความจำผมจะผิด ผมจำได้ว่า variant ของ salsa20 กับ machine code เดิมอยู่ที่ cryp.to แต่เว็บไซต์ของ Dan Bernstein คือ https://cr.yp.to/
    สมัยที่ตรวจสอบการเข้ารหัสข้อมูลที่จัดเก็บ, การเข้ารหัสแบบสตรีมมิง ฯลฯ ในสตาร์ตอัป หน้าของ Dan มี implementation หลายตัวแยกตามชิปเซ็ตเป้าหมายและชุดคำสั่ง สิ่งเหล่านั้นถูก cross-compile มาจากรูปแบบ assembler ของเขา
    ตอนลองใช้ VM ก็น่าสนใจที่ได้เห็นว่าชุดคำสั่งใดบ้างที่รองรับในช่วงต้นถึงกลางทศวรรษ 2000 ระหว่างทดสอบ บางครั้งก็เกิดปัญหาเพราะ VM อ้างว่ารองรับ แต่ implementation ยังไม่รองรับครบจริง ๆ

    • หมายถึงเว็บไซต์ของ Dan Berstain สินะ… เดี๋ยวนะ
  • น่าสนใจที่ตรงนี้มีคนพูดกันเยอะว่า x86 assembly เจ็บปวดแค่ไหนเมื่อเทียบกับ RISC แต่ผมมีปัญหาตรงข้ามตอนแยกโค้ดกลับเป็น object file
    สำหรับงานแบบนี้ การวิเคราะห์ x86 ง่ายมาก ส่วน MIPS นั้นเหมือนฝันร้าย เหตุผลหลักคือสิ่งที่ต้องสนใจคือ reference ไปยังโค้ดและข้อมูล x86 มี immediate constant ขนาดพอยน์เตอร์ ส่วน MIPS มีคู่ relocation HI16/LO16 ซึ่งไปพันกับกราฟการใช้รีจิสเตอร์, control flow ของโค้ด และคำสั่ง branch delay จนเกิดปัญหาสารพัด
    แต่นั่นไม่ได้แปลว่าผมกำลังชม x86 นะ

    • ใช่ x86 แปลกก็จริง แต่ คำสั่งความยาวแปรผัน พอคลี่ออกมาเป็นรูปแบบข้อความแล้ว จริง ๆ ก็ดูดีและเข้าใจง่าย ปัญหาคือมันไม่ปลอดภัยเพราะสามารถซ่อนคำสั่งอื่นไว้กลางคำสั่งได้
      สิ่งสำคัญที่สุดที่ได้เรียนจากการเปรียบเทียบ x86 assembly กับ C คือ signed/unsigned ไม่ได้เป็นชนิดข้อมูล แต่เป็นคุณสมบัติของ operation
      ถ้าใช้ประโยชน์จาก flag ได้ก็คงดี และในสถาปัตยกรรมบางตัวอย่าง PPC หรือ armv7 ก็ทำได้ง่ายกว่า แต่ x86 เขียนทับ flag ง่ายเกินไปจนยากมากที่จะเอาค่าเหล่านั้นมาใช้