3 điểm bởi GN⁺ 2025-07-14 | 1 bình luận | Chia sẻ qua WhatsApp
  • Đây là bài đầu tiên trong chuỗi dành cho người mới bắt đầu với x86-64 assembly
  • Cung cấp hướng dẫn cài đặt công cụ và giải thích cấu trúc cơ bản theo chuẩn của hệ thống 64-bit hiện đại
  • Hướng dẫn sử dụng Flat Assembler (FASM)WinDbg làm công cụ phát triển và gỡ lỗi chính
  • Bao gồm phần tóm tắt các kiến thức cốt lõi cần cho thực tế như định dạng PE, import DLL và quy ước gọi hàm của Windows
  • Giải thích theo hướng thực hành, xoay quanh việc viết một chương trình thoát đơn giản và trải nghiệm quy trình gỡ lỗi

Giới thiệu và ý nghĩa

  • Khi lần đầu tiếp xúc với x86 assembly, tác giả từng học trong môi trường cũ ở đại học (16-bit, DOS, bộ nhớ phân đoạn)
  • Ngày nay, bộ xử lý 64-bit đã trở thành chủ đạo, nên chuỗi bài này chỉ tập trung vào môi trường x86-64 đang được sử dụng thực tế, loại bỏ toàn bộ yếu tố cũ
  • Hướng dẫn này tập trung vào việc phát triển chương trình 64-bit chạy trên hệ điều hành Windows
  • Bắt đầu từ những đoạn mã tối thiểu truy cập trực tiếp vào OS mà không dùng thư viện
  • Bài viết hướng đến các lập trình viên muốn học assembly từ đầu, và giả định người đọc có kiến thức cơ bản về C/C++

Chuẩn bị công cụ phát triển

Assembler

  • CPU chỉ có thể diễn giải mã máy vốn rất khó hiểu với con người, còn assembly là dạng mã con người có thể đọc được tương ứng với nó
  • Chương trình chuyển assembly sang mã máy được gọi là assembler
  • x86-64 assembly không có một chuẩn duy nhất, nên cú pháp và cách hoạt động khác nhau tùy assembler
  • Trong chuỗi này, tác giả dùng Flat Assembler (FASM) vì nó nhỏ gọn, dễ dùng, có hệ thống macro mạnh và đi kèm trình soạn thảo

Debugger

  • Để phân tích mã assembly đã viết và quan sát luồng thực thi, debugger là công cụ bắt buộc
  • Tác giả khuyến nghị WinDbg, vì có thể kiểm tra và thao tác độc lập với thanh ghi, bộ nhớ, mã assembly...
  • Có thể cài bằng cách chỉ chọn đúng thành phần trong Windows 10 SDK
  • Nhờ debugger, bạn có thể trực tiếp quan sát trạng thái nội bộ của chương trình, cấu trúc bộ nhớ và sự thay đổi của các thanh ghi

Góc nhìn về lập trình assembly

Cấu trúc CPU và tập lệnh

  • CPU chỉ có thể thực hiện một số hành động giới hạn theo tập lệnh nhất định
  • Lệnh là đơn vị thao tác cơ bản mà CPU có thể thực hiện
  • Mỗi lệnh hoạt động rất đơn giản (lưu giá trị, tính toán số học...) và đi kèm tham số
  • Trong lập trình mức thấp và gỡ lỗi, điều cốt lõi là hiểu rằng cấu trúc này là nền tảng của mọi khái niệm mức cao

Thanh ghi (Registers)

  • Thanh ghi là vùng nhớ chuyên dụng cực nhanh nằm bên trong CPU
  • x86-64 có 16 thanh ghi mục đích chung, tất cả đều có kích thước 64-bit
  • Mỗi thanh ghi có thể được truy cập từng phần theo đơn vị byte, word hoặc doubleword
Thanh ghi Byte thấp Word thấp Doubleword thấp
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
  • rspstack pointer, còn rsi/rdi hoạt động như chỉ số xử lý chuỗi, tức một số thanh ghi được gán mục đích đặc biệt
  • ripinstruction pointer, còn rflags là thanh ghi đặc biệt chứa các cờ trạng thái của kết quả phép toán

Bộ nhớ và địa chỉ

  • Bộ nhớ hoạt động như một mảng byte liên tiếp bắt đầu từ chỉ số 0
  • Trong kiến trúc x86 trước đây, cách segment-offset là bắt buộc, nhưng ở x86-64 toàn bộ bộ nhớ được xem là không gian địa chỉ phẳng (Flat)
  • Trên thực tế, hệ điều hành và phần cứng ánh xạ động không gian địa chỉ ảo của từng tiến trình sang bộ nhớ vật lý
  • Nghĩa là cùng một địa chỉ ảo nhưng ở các tiến trình khác nhau sẽ tương ứng với vùng nhớ vật lý khác nhau
  • Lệnh và dữ liệu cùng tồn tại trong một bộ nhớ (kiến trúc von Neumann), khác với kiến trúc Harvard như AVR dùng trên Arduino, nơi dữ liệu được lưu tách riêng

Viết chương trình assembly đầu tiên

  • Sau khi cài FASM, hãy thử viết và build chương trình đơn giản dưới đây
format PE64 NX GUI 6.0
entry start

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

Giải thích mã

  • format PE64 NX GUI 6.0 : Chỉ định định dạng tệp thực thi mà FASM sẽ tạo ra, ở đây là PE (Portable Executable) 64-bit GUI
  • entry start : Định nghĩa entry point nơi chương trình bắt đầu chạy, tức vị trí của nhãn start
  • section '.text' code readable executable : Chỉ định đây là vùng mã của PE, tức vùng có thể thực thi
  • start: : Đặt tên cho điểm vào đã chỉ định ở trên
  • int3 : breakpoint dành cho debugger, dùng để tạm dừng chương trình nhằm kiểm tra trạng thái
  • ret : Lệnh lấy địa chỉ từ stack và chuyển quyền điều khiển đến đó; trong chương trình này nó phản hồi việc kết thúc ngay lập tức

Thực hành gỡ lỗi

  • Trong WinDbg, mở tệp thực thi (.exe) của chương trình trên và chuẩn bị các cửa sổ như disassembly, thanh ghi...

  • Nhấn F5 để chương trình đi tới breakpoint, rồi nhấn F8 để thực thi từng lệnh một (step-by-step)

  • Có thể quan sát trực tiếp sự thay đổi của các thanh ghi như rip

  • Sau khi thực hiện ret, quyền điều khiển được trả về cho hệ điều hành, rồi quá trình kết thúc thread và tiến trình tiếp tục khi RtlExitUserThread được gọi

  • Lưu ý: nếu chỉ kết thúc bằng lệnh ret, tiến trình có thể vẫn còn tồn tại tùy việc có tác vụ nền nào khác đang chạy hay không, vì vậy để kết thúc đúng cách thì nên luôn gọi ExitProcess

Định dạng PE và import DLL

Tổng quan cấu trúc import hàm từ DLL

  • Các hàm WinAPI như ExitProcess nằm trong KERNEL32.DLL
  • Để dùng các hàm bên ngoài như vậy, cần cấu hình import table của tệp thực thi (section .idata)
  • Import Directory Table (IDT) trong section idata chứa tên DLL, tên hàm và thông tin địa chỉ RVA của IAT/ILT...
  • IAT (Import Address Table) sẽ bị OS loader ghi đè bằng địa chỉ hàm thực tế tại thời điểm chạy
  • Hint/Name Table gồm tên của từng hàm và thông tin hint tương ứng

Ví dụ định nghĩa section .idata trong 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 : Chèn giá trị theo đơn vị byte/word/doubleword/quadword (8 byte)
  • rva : Tính địa chỉ ảo tương đối (Relative Virtual Address) của symbol
  • Có thể tham chiếu hàm DLL bằng cách tự cấu hình IAT và Name Table

Quy ước gọi hàm 64-bit của Windows (MS x64 Calling Convention)

  • Đây là quy ước chuẩn xác định cách truyền đối số và sử dụng stack khi gọi hàm
  • Trên Windows 64-bit, quy ước được dùng là Microsoft x64 Calling Convention
  • Các đặc điểm chính:
    • Stack pointer phải luôn được căn chỉnh 16 byte
    • 4 đối số integer/pointer đầu tiên dùng các thanh ghi rcx, rdx, r8, r9
    • 4 đối số dấu chấm động đầu tiên được đặt trong xmm0~xmm3
    • Các đối số bổ sung dùng stack
    • Bất kể số lượng đối số là bao nhiêu, vẫn phải cấp phát 32 byte shadow space trên stack
    • Việc dọn stack do bên gọi đảm nhiệm

Ví dụ gọi 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 

Phân tích phần mã mới

  • sub rsp, 8 * 5 : Điều chỉnh stack pointer (cấp phát 40 byte), đồng thời xử lý luôn việc căn chỉnh 16 byte và cấp phát shadow space

  • xor rcx, rcx : Gán giá trị 0 vào thanh ghi rcx, là đối số đầu tiên (dùng làm mã thoát)

  • call [ExitProcess] : Nhảy tới địa chỉ hàm ExitProcess thực tế đã được ghi vào import table

  • Khi thực thi từng bước trong WinDbg, có thể trực tiếp xác nhận sự thay đổi của stack pointer (rsp), thanh ghi rcx, cũng như luồng kết thúc tiến trình

Kết luận

  • Bài viết này hướng dẫn toàn bộ luồng làm quen với x86-64 assembly theo hướng thực hành, từ thiết lập công cụ cơ bản, định dạng PE, import DLL, quy ước gọi hàm x64 cho tới viết và gỡ lỗi chương trình đầu tiên
  • Ở phần tiếp theo, tác giả sẽ triển khai thêm nhiều chức năng đa dạng hơn và đi vào mã thực tế

1 bình luận

 
GN⁺ 2025-07-14
Ý kiến trên Hacker News
  • Muốn chia sẻ một dự án đã phát triển trong vài năm
    https://asm-editor.specy.app
    Đây là một IDE tương tác trực tuyến hỗ trợ nhiều ngôn ngữ assembly như M68K, MIPS, RISC-V, X86
    Có nhiều tính năng đa dạng để dạy lập trình assembly
    Cũng có thể nhúng vào các website khác

  • Trước đây không biết rằng các thanh ghi chỉ mục con trỏ có thể truy cập trực tiếp byte địa chỉ thấp (ví dụ: trên 16/32-bit, có thể truy cập si/esi bằng sil)
    Đây là khái niệm tương tự như truy cập al từ ax/eax
    Tò mò không biết các opcode mới được thêm vào trong x86_64 có thực sự tồn tại hay không
    Nghĩ rằng nên kiểm tra lại đặc tả nền tảng
    Chỉ hỏi vì tò mò thuần túy

  • Chia sẻ một tài liệu nhập môn assembly do chính mình viết
    https://www.nayuki.io/page/a-fundamental-introduction-to-x86-assembly-programming

  • Đã thử tối ưu bằng assembly vì tò mò liệu có thể làm phần dispatch của CPU emulator nhanh hơn C++ hay không
    Đã chạy chương trình Fibonacci nhưng kết quả hoàn toàn không tiệm cận được
    Cuối cùng chỉ gộp vào với tùy chọn mặc định bị vô hiệu hóa
    Dù vậy vẫn tin chắc là phải có cách làm nhanh hơn
    https://github.com/libriscv/libriscv/blob/master/lib/libriscv/amd64/inaccurate_dispatch.nasm
    Trong lúc học cách truy cập bộ nhớ, đã cải thiện hiệu năng được một chút
    Thu nhỏ jump table từ 64-bit xuống 32-bit và đưa nó vào phần .text để dùng truy cập RIP-relative
    Chương trình Fibonacci không cần nhiều bytecode
    Rất muốn nghe các mẹo về những điểm có thể cải thiện thêm

    • Tò mò không biết bạn đã trực tiếp so sánh mã mình viết với mã mà trình biên dịch C++ sinh ra chưa
      Không rõ toàn bộ ngữ cảnh, nhưng tôi nghĩ khác biệt có thể không nằm ở cơ chế dispatch (cách fetch lệnh) mà ở sự khác nhau trong phần cài đặt lệnh thực tế
      Một hướng tối ưu là ánh xạ các thanh ghi đang mô phỏng vào các thanh ghi x86-64 thật và không để chúng tràn ra bộ nhớ
      Làm vậy thì các phép toán như add có thể thực hiện trực tiếp mà không cần lấy từ bộ nhớ ra
      Tuy nhiên, cách này khiến việc viết emulator phiền phức hơn nhiều
  • Đây là tài liệu nhập môn x86 assembly có thể thực hành ngay trên trình duyệt
    Có thể chạy thử ví dụ ngay mà không cần thiết lập cục bộ gì đặc biệt
    https://shikaan.github.io/assembly/x86/guide/2024/09/08/x86-64-introduction-hello.html
    Nhân tiện, đây cũng là tài liệu do chính mình viết

    • Tò mò không biết có kiểm tra đầu vào riêng hay không
      Có vẻ là cách assemble trực tiếp bằng NASM rồi chạy nhị phân, nên thấy băn khoăn về bảo mật
  • Chỉ nhìn ảnh đại diện thì tôi đã tưởng là junferno

  • Chỉ cần thử chạm vào assembly một lần thôi cũng giúp hiểu sâu hơn về tổng thể, nên đó luôn là một trải nghiệm đáng giá
    Không nhất thiết phải làm một dự án lớn, nên khuyên mọi người cứ mạnh dạn tự thử một chút

  • Chia sẻ liên kết tới cuộc thảo luận HN hồi đó (2020)
    https://news.ycombinator.com/item?id=24195627

  • Thật may vì đây là cú pháp assembly kiểu Intel

    • Tự nhiên thấy tò mò không biết còn có những cú pháp assembly nào khác
  • Muốn thử làm gì đó bằng assembly, nhưng chưa nghĩ ra ý tưởng cụ thể nào

    • Đề xuất trò chơi TIS-100
      Đây là một trò giải đố bằng kiểu pseudo-assembly
      Tôi nghĩ những trò như vậy có thể phần nào thỏa cơn "thèm" assembly