3 คะแนน โดย GN⁺ 2025-07-14 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • บทความแรกของซีรีส์สำหรับผู้เริ่มต้น แอสเซมบลี x86-64
  • อธิบายการติดตั้งเครื่องมือและโครงสร้างพื้นฐานโดยอิงจาก ระบบ 64 บิตสมัยใหม่
  • แนะนำให้ใช้ Flat Assembler (FASM) และ WinDbg เป็นเครื่องมือหลักสำหรับการพัฒนาและดีบัก
  • มีสรุปความรู้สำคัญที่จำเป็นในการใช้งานจริง เช่น ฟอร์แมต PE, การ import DLL, และ calling convention ของ Windows
  • อธิบายโดยเน้นการลงมือทำ ตั้งแต่การเขียนโปรแกรมที่จบการทำงานแบบง่าย ๆ ไปจนถึงการฝึก ขั้นตอนการดีบัก

บทนำและความสำคัญ

  • ตอนเริ่มเรียน x86 แอสเซมบลีครั้งแรก ในมหาวิทยาลัยมักได้เรียนในสภาพแวดล้อมแบบเก่า เช่น 16 บิต, DOS, และหน่วยความจำแบบเซกเมนต์
  • ปัจจุบัน โปรเซสเซอร์ 64 บิต เป็นกระแสหลัก ดังนั้นซีรีส์นี้จะพูดถึงเฉพาะ สภาพแวดล้อม x86-64 ที่ใช้งานจริง และตัดองค์ประกอบแบบเก่าออกทั้งหมด
  • บทเรียนนี้มุ่งเน้นการพัฒนาโปรแกรม 64 บิตที่ทำงานบน ระบบปฏิบัติการ Windows
  • เริ่มจากโค้ดขนาดเล็กที่สุดที่เข้าถึง OS โดยตรงโดยไม่ใช้ไลบรารี
  • บทความนี้เหมาะสำหรับนักพัฒนาที่ต้องการเริ่มเรียนแอสเซมบลี และสมมติว่ามี ความรู้พื้นฐาน C/C++ อยู่แล้ว

การเตรียมเครื่องมือพัฒนา

แอสเซมเบลอร์(Assembler)

  • CPU สามารถตีความได้เฉพาะ machine code ซึ่งมนุษย์เข้าใจได้ยาก และแอสเซมบลีภาษาคือสิ่งที่แปลงมันให้อยู่ในรูปของ โค้ดที่มนุษย์อ่านได้
  • โปรแกรมที่แปลงภาษาแอสเซมบลีให้เป็น machine code เรียกว่า assembler
  • แอสเซมบลี x86-64 ไม่มีมาตรฐานตายตัว แต่ละ assembler จึงมีไวยากรณ์และพฤติกรรมที่ต่างกัน
  • ซีรีส์นี้ใช้ Flat Assembler(FASM) ซึ่งมีขนาดเล็ก ใช้งานง่าย และมีระบบมาโครกับเอดิเตอร์ที่ทรงพลัง

ดีบักเกอร์(Debugger)

  • ในการวิเคราะห์โค้ดแอสเซมบลีที่เขียนและสังเกตลำดับการทำงาน จำเป็นต้องใช้ debugger เป็นเครื่องมือหลัก
  • แนะนำ WinDbg ซึ่งสามารถตรวจดูและแก้ไขรีจิสเตอร์ หน่วยความจำ และโค้ดแอสเซมบลีได้อย่างแยกอิสระ
  • สามารถติดตั้งได้โดยเลือกเฉพาะคอมโพเนนต์จาก Windows 10 SDK
  • ผ่าน debugger เราสามารถสังเกตสถานะภายในของโปรแกรม โครงสร้างหน่วยความจำ และการเปลี่ยนแปลงของรีจิสเตอร์ได้โดยตรง

มุมมองของการเขียนโปรแกรมแอสเซมบลี

โครงสร้าง CPU และชุดคำสั่ง

  • CPU สามารถทำงานได้จำกัดตาม ชุดคำสั่ง ที่กำหนดไว้
  • คำสั่ง คือหน่วยงานพื้นฐานที่ CPU สามารถทำได้
  • แต่ละคำสั่งทำงานอย่างเรียบง่ายมากพร้อมพารามิเตอร์ เช่น การเก็บค่า หรือการคำนวณเลขคณิต
  • สำหรับการเขียนโปรแกรมระดับล่างและการดีบัก หัวใจสำคัญคือการเข้าใจว่าโครงสร้างเหล่านี้เป็นพื้นฐานของแนวคิดระดับสูงทั้งหมด

รีจิสเตอร์(Registers)

  • รีจิสเตอร์ คือพื้นที่หน่วยความจำเฉพาะความเร็วสูงมากที่ฝังอยู่ภายใน CPU
  • ใน x86-64 มี general-purpose register อยู่ 16 ตัว และทั้งหมดมีขนาด 64 บิต
  • แต่ละรีจิสเตอร์สามารถเข้าถึงบางส่วนได้ในระดับไบต์ เวิร์ด และดับเบิลเวิร์ด
รีจิสเตอร์ ไบต์ล่าง เวิร์ดล่าง ดับเบิลเวิร์ดล่าง
rax al ax eax
rbx bl bx ebx
rcx cl cx ecx
rdx dl dx edx
rsp spl sp esp
rsi sil si esi
rdi dil di edi
rbp bpl bp ebp
r8~r15 r8b~r15b r8w~r15w r8d~r15d
  • rsp ทำหน้าที่เป็น stack pointer และ rsi/rdi ทำหน้าที่เป็นดัชนีสำหรับการจัดการสตริง เป็นต้น โดยบางรีจิสเตอร์มี วัตถุประสงค์เฉพาะ กำหนดไว้
  • rip คือ instruction pointer และ rflags คือรีจิสเตอร์พิเศษที่เก็บแฟล็กสถานะของผลลัพธ์จากการคำนวณ

หน่วยความจำและแอดเดรส

  • หน่วยความจำทำงานเสมือนอาร์เรย์ของไบต์ที่ต่อเนื่องกันตั้งแต่อินเด็กซ์ 0
  • ในสถาปัตยกรรม x86 แบบเก่า วิธี segment-offset เป็นสิ่งจำเป็น แต่ใน x86-64 หน่วยความจำทั้งหมดถูกมองเป็น flat address space
  • ในทางปฏิบัติ ระบบปฏิบัติการและฮาร์ดแวร์จะทำการแมป virtual address space ของแต่ละโปรเซสกับหน่วยความจำจริงแบบไดนามิก
  • กล่าวคือ แม้จะเป็น virtual address เดียวกัน แต่ในคนละโปรเซสก็อาจอ้างถึงหน่วยความจำจริงคนละตำแหน่ง
  • คำสั่งและข้อมูลอยู่ในหน่วยความจำเดียวกัน (สถาปัตยกรรมฟอน นอยมันน์) ซึ่งแตกต่างจาก Harvard architecture ที่เก็บข้อมูลแยกต่างหาก เช่น AVR ที่ใช้กับ Arduino

การเขียนโปรแกรมแอสเซมบลีตัวแรก

  • หลังติดตั้ง FASM แล้ว ให้ลองเขียนและ build โปรแกรมง่าย ๆ ด้านล่าง
format PE64 NX GUI 6.0
entry start

section '.text' code readable executable
start:
        int3
        ret

คำอธิบายโค้ด

  • format PE64 NX GUI 6.0 : ระบุฟอร์แมตไฟล์ปฏิบัติการที่ FASM จะสร้าง ซึ่งในที่นี้คือ PE(Portable Executable) 64 บิต GUI
  • entry start : กำหนด entry point ที่โปรแกรมจะเริ่มทำงาน โดยจะเริ่มรันจากตำแหน่งเลเบล(start)
  • section '.text' code readable executable : ระบุว่าเป็น ส่วนของโค้ด ใน PE และเป็นพื้นที่ที่สามารถรันได้
  • start: : ตั้งชื่อให้จุดเริ่มต้นที่กำหนดไว้ก่อนหน้า
  • int3 : breakpoint สำหรับ debugger ใช้หยุดโปรแกรมชั่วคราวเพื่อตรวจสอบสถานะ
  • ret : คำสั่งที่ดึงแอดเดรสจากสแตกและโอนการควบคุมกลับไปยังตำแหน่งนั้น ซึ่งในโปรแกรมนี้จะตอบสนองด้วยการจบการทำงานทันที

การฝึกดีบัก

  • เปิดไฟล์ปฏิบัติการ(.exe) ของโปรแกรมข้างต้นใน WinDbg แล้วเตรียมหน้าต่างต่าง ๆ เช่น disassembly และ registers

  • กด F5 เพื่อให้โปรแกรมไปถึง breakpoint จากนั้นกด F8 เพื่อรันทีละคำสั่งแบบ step-by-step

  • สามารถสังเกตการเปลี่ยนแปลงของรีจิสเตอร์(rip เป็นต้น) ได้แบบเรียลไทม์

  • หลัง ret ทำงานแล้ว การควบคุมจะถูกส่งกลับไปยังระบบปฏิบัติการ และต่อจากนั้นจะมีการเรียก RtlExitUserThread เพื่อนำไปสู่การปิด thread และ process

  • ข้อควรระวัง : หากจบการทำงานด้วย ret เพียงอย่างเดียว โปรเซสอาจยังคงอยู่ได้ขึ้นกับว่ามีงานเบื้องหลังอื่นกำลังทำงานหรือไม่ ดังนั้น หากต้องการจบอย่างถูกต้อง ควรเรียก ExitProcess เสมอ

ฟอร์แมต PE และการ import DLL

ภาพรวมโครงสร้างการ import ฟังก์ชันจาก DLL

  • ฟังก์ชัน WinAPI อย่าง ExitProcess อยู่ใน KERNEL32.DLL
  • หากต้องการใช้ฟังก์ชันภายนอกลักษณะนี้ ต้องจัดเตรียม import table ของไฟล์ปฏิบัติการ (ส่วน .idata)
  • ใน Import Directory Table(IDT) ของส่วน idata จะมีข้อมูลอย่างชื่อ DLL, ชื่อฟังก์ชัน และแอดเดรส(RVA) ของ IAT/ILT
  • IAT(Import Address Table) จะถูกเขียนทับด้วยแอดเดรสจริงของฟังก์ชันโดย OS loader ในช่วงรันไทม์
  • Hint/Name Table ประกอบด้วยชื่อและข้อมูล hint ของแต่ละฟังก์ชัน

ตัวอย่างการกำหนดส่วน .idata ใน FASM

section '.idata' import readable writeable
idt: 
    dd rva kernel32_iat
    dd 0
    dd 0
    dd rva kernel32_name
    dd rva kernel32_iat
    dd 5 dup(0)
name_table: 
    _ExitProcess_Name dw 0
                      db "ExitProcess", 0, 0
kernel32_name: db "KERNEL32.DLL", 0
kernel32_iat: 
    ExitProcess dq rva _ExitProcess_Name
    dq 0 
  • db/dw/dd/dq : แทรกค่าทีละไบต์/เวิร์ด/ดับเบิลเวิร์ด/ควอดเวิร์ด(8 ไบต์)
  • rva : คำนวณ Relative Virtual Address ของสัญลักษณ์
  • สามารถอ้างอิงฟังก์ชัน DLL ได้ด้วยการประกอบ IAT และ Name Table แบบทำมือ

Calling Convention ของ Windows 64 บิต(MS x64 Calling Convention)

  • เป็นข้อกำหนดมาตรฐานที่กำหนดวิธีส่งอาร์กิวเมนต์และการใช้สแตกระหว่างการเรียกฟังก์ชัน
  • ใน Windows 64 บิต จะใช้ Microsoft x64 Calling Convention
  • คุณลักษณะสำคัญ :
    • stack pointer ต้อง จัดแนว 16 ไบต์ อยู่เสมอ
    • อาร์กิวเมนต์จำนวนเต็ม/พอยน์เตอร์ 4 ตัวแรกใช้รีจิสเตอร์ rcx, rdx, r8, r9
    • อาร์กิวเมนต์เลขทศนิยม 4 ตัวแรกใส่ไว้ใน xmm0~xmm3
    • อาร์กิวเมนต์เพิ่มเติมใช้สแตก
    • ต้องสำรอง shadow space 32 ไบต์ บนสแตกโดยไม่ขึ้นกับจำนวนอาร์กิวเมนต์
    • ผู้เรียกเป็นผู้จัดการการคืนสภาพสแตก

ตัวอย่างการเรียก ExitProcess

format PE64 NX GUI 6.0
entry start

section '.text' code readable executable
start:
    int3
    sub rsp, 8 * 5
    xor rcx, rcx
    call [ExitProcess]

section '.idata' import readable writeable
idt: 
    dd rva kernel32_iat
    dd 0
    dd 0
    dd rva kernel32_name
    dd rva kernel32_iat
    dd 5 dup(0)
name_table: 
    _ExitProcess_Name dw 0
                      db "ExitProcess", 0, 0
kernel32_name db "KERNEL32.DLL", 0
kernel32_iat: 
    ExitProcess dq rva _ExitProcess_Name
    dq 0 

การวิเคราะห์โค้ดใหม่

  • sub rsp, 8 * 5 : ปรับ stack pointer (สำรอง 40 ไบต์) เพื่อจัดแนว 16 ไบต์และเตรียม shadow space ในครั้งเดียว

  • xor rcx, rcx : กำหนดค่า 0 ให้รีจิสเตอร์ rcx ซึ่งเป็นอาร์กิวเมนต์ตัวแรก (ใช้เป็น EXIT code)

  • call [ExitProcess] : กระโดดไปยังแอดเดรสฟังก์ชันของ ExitProcess ที่ถูกบันทึกจริงไว้ใน import table

  • เมื่อลองรันทีละขั้นใน WinDbg จะสามารถตรวจสอบการเปลี่ยนแปลงของ stack pointer(rsp) และ รีจิสเตอร์ rcx รวมถึงลำดับการจบของโปรเซสได้โดยตรง

สรุป

  • บทความนี้แนะนำภาพรวมของแอสเซมบลี x86-64 โดยเน้นการลงมือทำ ตั้งแต่ การตั้งค่าเครื่องมือพื้นฐาน ฟอร์แมต PE, การ import DLL, x64 calling convention, ไปจนถึงการเขียนและดีบักโปรแกรมแรก
  • ในตอนถัดไปจะพูดถึงการสร้างฟังก์ชันที่หลากหลายขึ้นและโค้ดที่ใช้งานจริงมากขึ้น

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

 
GN⁺ 2025-07-14
ความคิดเห็นจาก Hacker News
  • อยากแชร์โปรเจ็กต์ที่พัฒนามาหลายปี
    https://asm-editor.specy.app
    เป็น IDE แบบอินเทอร์แอ็กทีฟออนไลน์ที่รองรับภาษาแอสเซมบลีหลากหลายแบบ เช่น M68K, MIPS, RISC-V, X86
    มีฟีเจอร์มากมายสำหรับใช้สอนการเขียนโปรแกรมแอสเซมบลี
    และยังสามารถฝังลงในเว็บไซต์อื่นได้

  • ไม่เคยรู้มาก่อนว่ารีจิสเตอร์ pointer indexing มีความสามารถในการเข้าถึงไบต์ low address ได้โดยตรง (เช่น ใน 16/32 บิต สามารถเข้าถึง si/esi เป็น sil ได้)
    แนวคิดคล้ายกับการเข้าถึง ax/eax เป็น al
    เลยสงสัยว่ามี opcode ที่ถูกเพิ่มเข้ามาใหม่ใน x86_64 อยู่จริงหรือไม่
    คิดว่าคงต้องกลับไปตรวจดูสเปกของแพลตฟอร์มอีกครั้ง
    ถามด้วยความอยากรู้อย่างเดียว

  • แชร์เอกสารแนะนำแอสเซมบลีสำหรับผู้เริ่มต้นที่เขียนขึ้นเอง
    https://www.nayuki.io/page/a-fundamental-introduction-to-x86-assembly-programming

  • ลองปรับแต่งด้วยแอสเซมบลีเพราะสงสัยว่าจะทำ dispatch ของ CPU emulator ให้เร็วกว่าที่ทำด้วย C++ ได้หรือไม่
    ลองรันโปรแกรมฟีโบนักชีแล้ว แต่ผลลัพธ์ก็ยังไม่ใกล้เคียงเลย
    สุดท้ายเลยรวมเข้าไปแค่เป็นตัวเลือกปิดใช้งานค่าเริ่มต้น
    ถึงอย่างนั้นก็ยังเชื่อว่าต้องมีวิธีทำให้เร็วขึ้นได้อีกแน่
    https://github.com/libriscv/libriscv/blob/master/lib/libriscv/amd64/inaccurate_dispatch.nasm
    ระหว่างเรียนรู้วิธีเข้าถึงหน่วยความจำก็ปรับปรุงประสิทธิภาพได้เล็กน้อย
    ลดขนาด jump table จาก 64 บิตเหลือ 32 บิต และย้ายให้ไปอยู่ในส่วน .text เพื่อให้เข้าถึงแบบ RIP-relative
    โปรแกรมฟีโบนักชีไม่ได้ต้องใช้ไบต์โค้ดจำนวนมาก
    อยากได้คำแนะนำเรื่องจุดที่ยังปรับปรุงได้จริง ๆ

    • อยากรู้ว่าได้ลองเปรียบเทียบโค้ดที่ตัวเองเขียนกับโค้ดที่ C++ compiler สร้างขึ้นโดยตรงหรือยัง
      แม้จะไม่ค่อยรู้บริบท แต่คิดว่าความต่างอาจไม่ได้มาจากกลไก dispatch (วิธี fetch คำสั่ง) แต่อาจมาจากความต่างของการ implement คำสั่งจริง ๆ ก็ได้
      วิธีเพิ่มประสิทธิภาพอย่างหนึ่งคือ map รีจิสเตอร์ของเครื่องที่กำลัง emulation ไปยังรีจิสเตอร์จริงของ x86-64 และไม่ปล่อยให้ไหลลงหน่วยความจำเลย
      แบบนี้การคำนวณอย่าง add ก็จะทำได้ทันทีโดยไม่ต้องโหลดจากหน่วยความจำ
      แต่ข้อเสียคือจะทำให้เขียน emulator ยุ่งยากขึ้นมาก
  • เป็นเอกสารแนะนำ x86 แอสเซมบลีที่ให้ลองฝึกได้ในเบราว์เซอร์
    สามารถรันตัวอย่างได้ทันทีโดยแทบไม่ต้องตั้งค่าอะไรบนเครื่อง
    https://shikaan.github.io/assembly/x86/guide/2024/09/08/x86-64-introduction-hello.html
    เพื่อความโปร่งใส ผู้เขียนคือเจ้าตัวเอง

    • อยากรู้ว่ามีการตรวจสอบความถูกต้องของ input แยกไว้หรือไม่
      ดูเหมือนเป็นวิธีที่ assemble ด้วย NASM ตรง ๆ แล้วรันไบนารีเลย จึงเริ่มกังวลเรื่องความปลอดภัย
  • แค่ดูรูปโปรไฟล์ก็นึกว่าเป็น junferno

  • แค่ได้ลองจับแอสเซมบลีสักครั้งก็ช่วยให้ความเข้าใจภาพรวมลึกขึ้นแล้ว จึงเป็นประสบการณ์ที่ดีเสมอ
    ไม่จำเป็นต้องทำเป็นโปรเจ็กต์ใหญ่ แนะนำให้รวบรวมความกล้าลองทำเองสักนิดก็ยังดี

  • แชร์ลิงก์ไปยังกระทู้ HN ในตอนนั้น (2020)
    https://news.ycombinator.com/item?id=24195627

  • รู้สึกโล่งใจที่ใช้ไวยากรณ์แอสเซมบลีแบบ Intel

    • เลยสงสัยว่ามีไวยากรณ์แอสเซมบลีแบบอื่นอะไรอีกบ้าง
  • อยากลองทำอะไรบางอย่างด้วยแอสเซมบลี แต่ยังคิดไอเดียเฉพาะไม่ออก

    • ขอแนะนำเกม TIS-100
      เป็นเกมแก้ปริศนาด้วย pseudo-assembly แบบหนึ่ง
      คิดว่าเกมแบบนี้น่าจะช่วยตอบสนองความอยากลองแอสเซมบลีได้