cv.cm

Lập trình · Ngôn ngữ lập trình

Hướng dẫn ngôn ngữ Assembly cho người mới bắt đầu (x86-64, NASM)

10 phút đọc

Trả lời nhanh

Hợp ngữ (assembly) là dạng dễ đọc của các lệnh máy của bộ xử lý: mỗi dòng thường trở thành một lệnh như mov rax, 1 hoặc add rax, rbx. Bạn làm việc trực tiếp với thanh ghi, địa chỉ bộ nhớ và ngăn xếp, không có biến, kiểu hay vòng lặp nào ngoài những gì tự xây từ các lệnh nhảy. Bài này dùng x86-64 trên Linux với trình hợp dịch NASM: viết tệp .asm, hợp dịch bằng nasm -f elf64 rồi liên kết bằng ld.

Mở Đổi hệ cơ số

Assembly là gì và ai dùng nó

Mỗi họ bộ xử lý có tập lệnh riêng và do đó có hợp ngữ riêng. x86-64 (còn gọi là AMD64) chạy hầu hết máy tính để bàn và máy chủ; ARM (AArch64) chạy hầu hết điện thoại, máy Mac dùng chip Apple và nhiều máy chủ. Trình hợp dịch (assembler) dịch hợp ngữ sang mã máy, còn trình liên kết (linker) ghép kết quả thành tệp thực thi.

Ngày nay rất ít chương trình được viết hoàn toàn bằng assembly. Nó được dùng trong nhân hệ điều hành và bootloader, các hàm quan trọng về hiệu năng (mật mã, codec, thư viện toán), firmware nhúng, phát triển trình biên dịch, nghiên cứu bảo mật và dịch ngược. Thường gặp nhất là lập trình viên đọc assembly để hiểu trình biên dịch đã tạo ra gì hoặc để gỡ lỗi một vụ treo chương trình.

Cài NASM và chạy Hello, world

Trên Debian hoặc Ubuntu, cài công cụ bằng sudo apt install nasm binutils (binutils cung cấp trình liên kết ld). Chương trình này gọi thẳng nhân Linux qua các lời gọi hệ thống, nên không cần thư viện C.

; hello.asm - x86-64 Linux, NASM syntax
section .data
    msg     db "Hello, world!", 10   ; 10 = newline
    msglen  equ $ - msg              ; length computed by the assembler

section .text
    global _start

_start:
    mov     rax, 1          ; system call number 1 = write
    mov     rdi, 1          ; file descriptor 1 = stdout
    mov     rsi, msg        ; address of the bytes
    mov     rdx, msglen     ; number of bytes
    syscall

    mov     rax, 60         ; system call number 60 = exit
    xor     rdi, rdi        ; exit code 0
    syscall
nasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
./hello
# Hello, world!

section .data chứa dữ liệu đã khởi tạo, section .text chứa mã, và _start là điểm vào mà ld tìm. Chú thích bắt đầu bằng ;. Nếu thiếu lời gọi hệ thống exit ở cuối, bộ xử lý sẽ tiếp tục thực thi bất kỳ byte nào theo sau và chương trình sẽ bị treo.

Thanh ghi và kích thước dữ liệu

Thanh ghi là các vị trí lưu trữ nhỏ, rất nhanh bên trong CPU. x86-64 có 16 thanh ghi đa dụng 64 bit. Mỗi thanh ghi cũng có thể dùng ở các phần nhỏ hơn.

Thanh ghiVai trò thường gặpCác phần nhỏ hơn
raxGiá trị trả về, số hiệu lời gọi hệ thốngeax (32 bit), ax (16), al (8)
rdi, rsi, rdxBa đối số đầu của hàm hoặc lời gọi hệ thốngedi, esi, edx
rcx, r8, r9Đối số thứ tư đến thứ sáu của hàmecx, r8d, r9d
rbx, rbp, r12–r15Các giá trị phải còn nguyên sau lời gọi hàm (callee-saved)ebx, r12d
rspCon trỏ ngăn xếp: địa chỉ của đỉnh ngăn xếpKhông dùng để chứa dữ liệu
ripCon trỏ lệnh (chỉ thay đổi bởi lệnh nhảy, call, return)Không ghi trực tiếp được

Dữ liệu trong bộ nhớ được khai báo bằng db (byte, 8 bit), dw (word, 16), dd (doubleword, 32) và dq (quadword, 64). Vùng đệm chưa khởi tạo đặt trong section .bss với resb, resq và các lệnh tương tự. Ghi vào thanh ghi 32 bit như eax cũng xóa 32 bit cao của rax, vì vậy xor eax, eax là cách thông dụng để đặt rax về không.

Lệnh cơ bản và luồng điều khiển

Hầu hết lệnh nhận đích trước, nguồn sau: add rax, rbx nghĩa là rax = rax + rbx. Không có câu lệnh if hay while; bạn so sánh giá trị bằng cmp (đặt các cờ) rồi dùng lệnh nhảy có điều kiện.

LệnhÝ nghĩa
mov rax, rbxSao chép rbx vào rax
add, sub, imul, inc, decSố học số nguyên
cmp rax, 10So sánh (tính rax - 10 và chỉ đặt cờ)
je, jne, jl, jg, jle, jgeNhảy nếu bằng, khác, nhỏ hơn, lớn hơn... (có dấu)
jmp labelNhảy vô điều kiện
push, popĐẩy một giá trị lên ngăn xếp, lấy nó ra
call, retGọi một hàm, trả về từ hàm
; sum.asm - add 1..10, return the result as the exit code
section .text
    global _start

_start:
    xor     rax, rax        ; sum = 0
    mov     rcx, 1          ; i = 1
.loop:
    cmp     rcx, 10
    jg      .done           ; if i > 10, leave the loop
    add     rax, rcx        ; sum += i
    inc     rcx             ; i++
    jmp     .loop
.done:
    mov     rdi, rax        ; exit code = sum
    mov     rax, 60         ; exit
    syscall
nasm -f elf64 sum.asm -o sum.o && ld sum.o -o sum
./sum; echo $?
# 55

Nhãn bắt đầu bằng dấu chấm, như .loop, là cục bộ đối với nhãn thường đứng trước nó. jl và jg coi giá trị là có dấu; jb và ja là phiên bản không dấu.

Định địa chỉ bộ nhớ và mảng

Dấu ngoặc vuông nghĩa là "vùng nhớ tại địa chỉ này". Một địa chỉ có thể kết hợp thanh ghi cơ sở, thanh ghi chỉ số nhân với 1, 2, 4 hoặc 8, và một hằng số: [base + index*scale + offset]. Nhờ vậy truy cập mảng chỉ cần một lệnh.

section .data
    nums    dq 3, 1, 4, 1, 5         ; five 64-bit integers
    count   equ ($ - nums) / 8

section .text
    global _start

_start:
    xor     rax, rax                 ; total = 0
    xor     rcx, rcx                 ; index = 0
.next:
    add     rax, [nums + rcx*8]      ; total += nums[index]
    inc     rcx
    cmp     rcx, count
    jl      .next
    mov     rdi, rax                 ; exit code 14
    mov     rax, 60
    syscall

mov rax, nums nạp địa chỉ, còn mov rax, [nums] nạp giá trị lưu ở đó. lea rsi, [rbx + 8] tính một địa chỉ mà không đọc bộ nhớ. Khi không thể suy ra kích thước từ thanh ghi, hãy nêu rõ: mov qword [rbx], 0.

Hàm và quy ước gọi hàm

Trên Linux, hàm tuân theo System V AMD64 ABI. Đối số nguyên đi vào rdi, rsi, rdx, rcx, r8, r9; giá trị trả về nằm ở rax. Hàm phải giữ nguyên rbx, rbp, r12–r15 và rsp, và rsp phải là bội số của 16 ngay trước một lệnh call. Tuân theo các quy tắc này cho phép assembly gọi hàm C như printf, và cho phép C gọi assembly.

; square.asm - define a function and call printf from the C library
    extern  printf
    global  main

section .data
    fmt     db "%ld squared is %ld", 10, 0

section .text
square:                         ; long square(long x)
    mov     rax, rdi
    imul    rax, rdi
    ret

main:
    push    rbx                 ; save rbx; also aligns rsp to 16
    mov     rbx, 7
    mov     rdi, rbx
    call    square              ; rax = 49
    lea     rdi, [rel fmt]      ; argument 1: format string
    mov     rsi, rbx            ; argument 2
    mov     rdx, rax            ; argument 3
    xor     eax, eax            ; varargs: no vector registers used
    call    printf wrt ..plt
    xor     eax, eax            ; return 0 from main
    pop     rbx
    ret

section .note.GNU-stack noalloc noexec nowrite progbits
nasm -f elf64 square.asm -o square.o
gcc square.o -o square
./square
# 7 squared is 49

Liên kết bằng gcc sẽ thêm runtime C, nơi gọi main. [rel fmt] và wrt ..plt tạo mã độc lập vị trí, thứ mà các bản phân phối Linux hiện đại dùng mặc định. Dòng cuối đánh dấu ngăn xếp là không thực thi được và tránh một cảnh báo của trình liên kết.

Xử lý lỗi

Assembly không có ngoại lệ. Một lời gọi hệ thống Linux thất bại trả về số lỗi âm trong rax (ví dụ -2, ENOENT, "không có tệp như vậy"), nên bạn kiểm tra dấu sau mỗi lời gọi. Hàm thư viện C báo lỗi qua giá trị trả về và errno. Lỗi phần cứng, như đọc địa chỉ không hợp lệ hoặc chia cho không, làm dừng chương trình bằng một tín hiệu (segmentation fault hoặc floating point exception).

    mov     rax, 2              ; system call 2 = open
    lea     rdi, [rel path]     ; path is a zero-terminated string
    xor     esi, esi            ; flags: O_RDONLY
    syscall
    test    rax, rax
    js      .open_failed        ; negative result = -errno
    mov     r12, rax            ; success: rax is the file descriptor

Một chương trình hoàn chỉnh nhỏ: in một số ở dạng thập phân

In một số nghĩa là tự chuyển nó thành các chữ số. Chương trình này tính 1 + 2 + ... + 100, chia liên tiếp cho 10 để lấy các chữ số từ phải sang trái, rồi ghi chúng bằng một lời gọi hệ thống.

section .bss
    buf     resb 32

section .text
    global _start

_start:
    xor     rax, rax
    mov     rcx, 100
.sum:
    add     rax, rcx
    loop    .sum                ; rcx -= 1, repeat while rcx != 0
    lea     rsi, [buf + 31]
    mov     byte [rsi], 10      ; newline at the end of the buffer
    mov     rbx, 10
.digit:
    xor     rdx, rdx            ; div uses rdx:rax as the dividend
    div     rbx                 ; rax = quotient, rdx = remainder
    add     dl, '0'             ; remainder to ASCII digit
    dec     rsi
    mov     [rsi], dl
    test    rax, rax
    jnz     .digit
    mov     rdx, buf + 32
    sub     rdx, rsi            ; length = end - start
    mov     rax, 1              ; write(1, rsi, rdx)
    mov     rdi, 1
    syscall
    mov     rax, 60             ; exit(0)
    xor     rdi, rdi
    syscall
; prints 5050

Cú pháp AT&T so với Intel, và ARM

Cùng một lệnh x86-64 có thể viết bằng hai cú pháp. NASM và hầu hết tài liệu của Intel và Microsoft dùng cú pháp Intel. Trình hợp dịch GNU (as), đầu ra của GCC và objdump mặc định dùng cú pháp AT&T; objdump -d -M intel và gcc -S -masm=intel chuyển sang cú pháp Intel.

Khía cạnhIntel (NASM)AT&T (GNU as)
Thứ tự toán hạngmov rax, 1 (đích đứng trước)movq $1, %rax (nguồn đứng trước)
Thanh ghi và hằng sốrax, 5%rax, $5
Toán hạng bộ nhớ[rbx + rcx*8 + 16]16(%rbx,%rcx,8)
Kích thước toán hạngmov qword [rbx], 0movq $0, (%rbx) (hậu tố b, w, l, q)

Bộ xử lý ARM dùng tập lệnh khác. AArch64 có 31 thanh ghi đa dụng 64 bit (x0–x30), mã hóa lệnh cố định 32 bit và thiết kế load/store: số học chỉ chạy trên thanh ghi, còn bộ nhớ được truy cập bằng ldr và str. Trên Linux, số hiệu lời gọi hệ thống đặt trong x8 và lời gọi được thực hiện bằng svc #0.

// AArch64 Linux, GNU as syntax: exit(0)
    mov     x0, #0          // exit status
    mov     x8, #93         // system call 93 = exit
    svc     #0

Học tiếp ở đâu

  • Sách hướng dẫn NASM về chỉ thị, macro và các định dạng đầu ra.
  • Intel 64 and IA-32 Architectures Software Developer's Manuals để biết chính xác hành vi của từng lệnh.
  • Tài liệu System V AMD64 ABI về quy ước gọi hàm và bố cục ngăn xếp.
  • Biên dịch các hàm C nhỏ bằng gcc -O2 -S -masm=intel (hoặc một trình khám phá trình biên dịch trực tuyến) rồi đọc assembly được tạo ra.

Câu hỏi thường gặp

Học hợp ngữ ngày nay có còn đáng không?

Có, để hiểu máy tính hoạt động ra sao, gỡ lỗi các vụ treo, đọc đầu ra của trình biên dịch, làm bảo mật và phát triển hệ nhúng. Viết cả ứng dụng bằng assembly hiếm khi thực tế ngày nay.

Assembly x86 có giống assembly ARM không?

Không. Chúng là các tập lệnh khác nhau với thanh ghi, lệnh và cách mã hóa khác nhau. Các khái niệm (thanh ghi, bộ nhớ, lệnh nhảy, quy ước gọi hàm) dùng chung được, nhưng mã thì không.

Khác nhau giữa NASM và GAS là gì?

NASM là trình hợp dịch độc lập dùng cú pháp Intel. GAS, trình hợp dịch GNU, được GCC dùng và mặc định là cú pháp AT&T, dù cũng chấp nhận cú pháp Intel với .intel_syntax noprefix.

Mã này có chạy trên Windows hoặc macOS không?

Không thể chạy nguyên trạng. Các lệnh giống nhau trên mọi hệ thống x86-64, nhưng số hiệu lời gọi hệ thống, định dạng tệp thực thi và quy ước gọi hàm thì khác. Windows dùng quy ước gọi hàm khác, còn máy Mac chip Apple dùng ARM thay vì x86-64.

Tất cả hướng dẫn lập trình