Dữ liệu & CPU
12/23
Bài 12 / 23~17 phútMáy chạy thế nàoMiễn phí lượt xem

Assembly nhập môn — đọc ngôn ngữ CPU thực sự chạy

Lệnh máy là gì, opcode và toán hạng, các lệnh mov/add/cmp/jmp, và cách viết một vòng lặp bằng assembly. Đủ để đọc output compiler khi debug hiệu năng.

TL;DR: Assembly là lớp mỏng nhất giữa code bạn viết và lệnh CPU thực sự chạy. Mỗi lệnh assembly ánh xạ gần như 1-1 với một opcode trong bộ nhớ: mov chuyển dữ liệu, add cộng, cmp so sánh, jmp/je/jl nhảy đến label khác. Biết đọc assembly không có nghĩa là phải viết tay — nhưng khi compiler tạo ra code chậm hơn bạn kỳ vọng, nhìn vào assembly giúp bạn hiểu tại sao, không chỉ đoán mò.

Bạn viết for (int i = 1; i <= n; i++) sum += i; và code chạy. Nhưng CPU không hiểu for, không hiểu int, không hiểu sum. Những gì CPU thực sự nhận được là một chuỗi lệnh cực đơn giản: "chuyển số 1 vào thanh ghi, cộng thanh ghi kia vào, so sánh với n, nếu chưa vượt thì nhảy về đầu vòng". Compiler là người dịch — nó chuyển ngôn ngữ bậc cao xuống ngôn ngữ CPU hiểu.

Assembly là cách đọc kết quả dịch đó. Không phải để viết tay (compiler dịch tốt hơn bạn trong 99% trường hợp), mà để hiểu tại sao một đoạn code nhanh hay chậm, tại sao compiler không tối ưu được như bạn nghĩ, hay đọc crash dump khi debug production.

Bài này giải thích cấu trúc một lệnh assembly, các nhóm lệnh cốt lõi, và cách một vòng lặp đơn giản trông ra sao ở mức CPU.

1. Analogy — công thức nấu ăn cực chi tiết

Hãy tưởng tượng bạn nhờ bếp trưởng làm món "sốt cà chua". Đầu bếp giỏi chỉ cần nghe vậy là làm được. Nhưng nếu bạn dạy một robot nấu ăn không biết suy luận, bạn phải viết từng động tác:

  1. Lấy bát cỡ vừa. 2. Đổ 100 gram cà chua đã thái vào bát. 3. Thêm 5 gram muối. 4. Khuấy 10 vòng theo chiều kim đồng hồ. 5. Nếu chưa đủ 3 phút, quay lại bước 4.

Ngôn ngữ bậc cao như Java hay Python là "làm món sốt cà chua" — ngắn gọn, có ý nghĩa cao. Assembly là tập lệnh cho robot — chi tiết đến từng động tác nhỏ nhất, không ngầm định bất cứ điều gì.

Đời thườngAssembly
"Làm món sốt" (lệnh mức cao)Hàm hoặc vòng lặp trong Java
"Lấy bát" (động tác đơn)mov rax, [rbx] — chuyển giá trị
"Đổ 100g vào"add rax, 100 — cộng 100 vào thanh ghi
"Nếu chưa đủ 3 phút, quay lại"cmp + jl — so sánh rồi nhảy nếu nhỏ hơn
Tên "bát", "nồi", "dao"Register: rax, rbx, rcx...
💡 Cách nhớ

Assembly không thêm ma thuật — nó chỉ nói thẳng với CPU những gì ngôn ngữ bậc cao ngầm định. Mỗi dòng assembly là một hành động đơn lẻ, không hơn.

2. Cấu trúc một lệnh assembly

Mỗi lệnh assembly có dạng:

opcode  toán_hạng_đích, toán_hạng_nguồn

Ví dụ (Intel syntax — được dùng nhất quán trong bài này):

NASM
mov rax, 5        ; Ghi gia tri 5 vao register rax
add rax, rbx      ; Cong gia tri rbx vao rax, ket qua luu vao rax
cmp rax, 10       ; So sanh rax voi 10 (dat flag, khong luu ket qua)
jl  loop_start    ; Jump to label "loop_start" neu ket qua so sanh "less than"

Ba thành phần cốt lõi:

  • Opcode (mov, add, cmp, jl): mã lệnh, cho CPU biết làm gì.
  • Toán hạng đích (destination): nơi kết quả được ghi vào — thường là register.
  • Toán hạng nguồn (source): giá trị đầu vào — có thể là immediate, register, hoặc địa chỉ bộ nhớ.

2.1. Ba loại toán hạng

NASM
; Immediate: gia tri hang so, viet truc tiep
mov rax, 42       ; rax = 42

; Register: ten thanh ghi
mov rbx, rax      ; rbx = gia tri hien tai cua rax

; Memory: dia chi bo nho, viet trong dau ngoac vuong
mov rax, [rbx]    ; rax = gia tri tai dia chi bo nho ma rbx tro den
mov [rsi], rax    ; ghi gia tri rax vao dia chi ma rsi tro den

Lệnh mov rax [rbx] chẻ thành opcode, toán hạng đích và toán hạng nguồn; bên dưới là ba loại toán hạng: immediate với mov rax 42 lấy hằng số nằm ngay trong lệnh, register với mov rbx rax lấy giá trị đang có trong thanh ghi, memory với mov rax [rbx] lấy giá trị tại địa chỉ rbx trỏ tới nên phải ra RAM — dấu ngoặc vuông là dấu hiệu chạm bộ nhớ

Register là "biến tạm" siêu nhanh nằm ngay trong CPU. x86-64 có một số register mục đích chung thường gặp: rax, rbx, rcx, rdx, rsi, rdi, r8r15. Compiler tự chọn register nào dùng cho biến nào.

Thử ngẫmbạn đọc disassembly của một hàm và thấy dòng mov rax, [rbx] chạy chậm hẳn so với các dòng mov khác xung quanh. Trong ba loại toán hạng vừa học, loại nào là thủ phạm?

3. Các nhóm lệnh cốt lõi

3.1. Data movement — chuyển dữ liệu

NASM
mov rax, 10       ; rax = 10 (immediate)
mov rbx, rax      ; rbx = rax (register to register)
mov rax, [rbp-8]  ; load tu stack (bien local)
mov [rbp-8], rax  ; store xuong stack

mov là lệnh phổ biến nhất — compiler dùng nó để load biến, truyền tham số, và lưu kết quả.

3.2. Arithmetic — số học

NASM
add rax, rbx      ; rax = rax + rbx
sub rax, 1        ; rax = rax - 1
imul rax, rcx     ; rax = rax * rcx (signed multiply)

3.3. Comparison và flags

NASM
cmp rax, rbx      ; tinh rax - rbx, dat FLAGS, khong luu ket qua

cmp không ghi kết quả ra register nào — nó chỉ tính hiệu và cập nhật một thanh ghi đặc biệt gọi là FLAGS (hay EFLAGS/RFLAGS). Các bit trong FLAGS cho biết kết quả là bằng nhau, âm, tràn số, v.v. Lệnh nhảy có điều kiện đọc FLAGS để quyết định.

3.4. Jump — nhảy

NASM
jmp label         ; nhay vo dieu kien toi label
je  label         ; jump if equal (ZF=1)
jne label         ; jump if not equal
jl  label         ; jump if less than (signed)
jg  label         ; jump if greater than (signed)
jle label         ; jump if less than or equal
jge label         ; jump if greater than or equal

Nhảy là cách duy nhất để CPU thay đổi luồng thực thi — không có if, for, while ở mức này. Mọi cấu trúc điều khiển trong ngôn ngữ bậc cao đều được compiler dịch thành cmp + j*.

4. Vòng lặp bằng assembly — từng dòng

Dưới đây là vòng lặp tính tổng 1 đến n trong C, rồi phiên bản assembly tương ứng (Intel syntax, x86-64):

C
// C code
int sum = 0;
for (int i = 1; i <= n; i++) {
    sum += i;
}
NASM
; x86-64 Intel syntax
; Gia su: n nam trong register rdi (tham so truyen vao)
; Output:  tong nam trong rax

    mov rax, 0       ; sum = 0
    mov rcx, 1       ; i = 1

loop_start:
    cmp rcx, rdi     ; so sanh i voi n
    jg  loop_done    ; neu i > n, thoat vong lap

    add rax, rcx     ; sum += i
    add rcx, 1       ; i++
    jmp loop_start   ; quay lai dau vong

loop_done:
    ; rax chua tong 1..n

Vòng for trong C xếp cạnh bốn nhóm lệnh x86-64 tương ứng: khởi tạo sum và i thành mov rax 0 với mov rcx 1; điều kiện i nhỏ hơn hoặc bằng n thành cmp rcx rdi với jg loop_done; thân sum cộng i thành add rax rcx; tăng i thành add rcx 1; quay lại kiểm tra thành jmp loop_start — không có khái niệm vòng lặp ở tầng CPU, chỉ có nhãn và một lệnh nhảy ngược

Giải thích từng dòng:

  • mov rax, 0 — khởi tạo biến sum bằng cách ghi 0 vào rax.
  • mov rcx, 1 — khởi tạo biến i bằng cách ghi 1 vào rcx.
  • loop_start: — nhãn (label), đánh dấu địa chỉ để lệnh nhảy tham chiếu. Không tạo ra opcode, chỉ là ký hiệu.
  • cmp rcx, rdi — tính rcx - rdi (tức i - n) và cập nhật FLAGS.
  • jg loop_done — nhảy đến loop_done nếu rcx lớn hơn rdi (tức i > n). Đây là điều kiện thoát vòng lặp.
  • add rax, rcx — cộng i vào sum.
  • add rcx, 1 — tăng i lên 1.
  • jmp loop_start — nhảy vô điều kiện về đầu vòng lặp.
  • loop_done: — nhãn kết thúc; sau nhảy ra, rax chứa kết quả.

Lưu ý: compiler thực tế thường đảo cấu trúc (đặt cmp+jgcuối vòng thay vì đầu) để giảm số lần nhảy — nhưng ý nghĩa logic giống nhau.

📚 Đào sâu (tuỳ chọn) — CISC, RISC và hai syntax

x86 (CISC) vs ARM (RISC):

x86 là kiến trúc CISC (Complex Instruction Set Computing) — có nhiều lệnh phức tạp, mỗi lệnh làm được nhiều việc, độ dài lệnh thay đổi. ARM là kiến trúc RISC (Reduced Instruction Set Computing) — ít lệnh hơn, mỗi lệnh đơn giản và đồng đều, độ dài cố định 4 byte (32 bit) cả ARM32 lẫn AArch64.

Cùng phép sum += i trên hai ISA:

NASM
; x86-64 (Intel syntax)
add rax, rcx          ; 1 lenh, co the tu lay tu bo nho
NASM
; AArch64 (ARM64 syntax)
add x0, x0, x1       ; cung 1 lenh, nhung chi lam viec voi register
                      ; ARM bat buoc load tu bo nho truoc (load-store arch)

ARM bắt buộc load giá trị từ bộ nhớ vào register trước, rồi mới tính toán, rồi store lại. x86 cho phép lệnh add đọc thẳng từ bộ nhớ trong một bước. Mỗi cách có tradeoff — RISC dễ pipeline hơn, CISC code nhỏ gọn hơn.

AT&T vs Intel syntax:

Cùng một lệnh, hai cách viết:

NASM
; Intel syntax (dung trong bai nay):
mov rax, 5       ; dich: "rax = 5"

; AT&T syntax (dung trong GDB, objdump Linux):
movq $5, %rax    ; dich: "5 vao %rax"

AT&T đảo thứ tự toán hạng (nguồn trước, đích sau), thêm % trước register và $ trước immediate. gcc -Sobjdump mặc định dùng AT&T; Godbolt Compiler Explorer mặc định Intel. Khi gặp AT&T, chỉ cần đảo thứ tự trong đầu.

Godbolt Compiler Explorer (godbolt.org) cho phép bạn dán code C/C++/Rust/Go và xem ngay assembly được compiler tạo ra, so sánh giữa các compiler và mức tối ưu -O0/-O2/-O3. Đây là công cụ không thể thiếu khi muốn hiểu compiler làm gì với code của bạn.

Thử ngẫmđội chọn CPU cho thiết bị IoT chạy pin, ưu tiên tiêu thụ điện thấp hơn code gọn. Theo triết lý CISC/RISC vừa đọc, họ nên nghiêng về hướng nào?

5. Áp dụng vào code của bạn

Hầu hết lập trình viên không bao giờ cần viết assembly tay — compiler làm tốt hơn và không mắc lỗi. Nhưng có ba tình huống biết đọc assembly thật sự hữu ích:

Debug hiệu năng cực hạn. Khi bạn đã profile, đã xác định được hot path, nhưng muốn hiểu tại sao thay đổi code nhỏ lại tạo ra chênh lệch lớn. Nhìn assembly giúp bạn thấy có bao nhiêu lệnh, có branch prediction nào bị miss, hay dữ liệu có nằm gọn trong register không.

Hiểu tại sao compiler không tối ưu như kỳ vọng. Đôi khi bạn nghĩ compiler sẽ vector hóa một vòng lặp hoặc loại bỏ code thừa, nhưng nó không làm. Assembly cho thấy compiler đang làm gì thật sự — từ đó bạn viết lại code để compiler tối ưu được.

Đọc crash dump và stack trace ở mức thấp. Khi một process crash với địa chỉ bộ nhớ lạ, disassembly tại vị trí đó cho biết lệnh nào gây ra vấn đề.

Cách thực hành không tốn công: dán đoạn code vào Godbolt Compiler Explorer, chọn compiler và mức -O2, đọc assembly được tạo ra. Thử thay đổi code nhỏ và quan sát assembly thay đổi thế nào — đây là cách học nhanh nhất mà không cần hiểu mọi lệnh từ đầu.

C
// Thu dan bai nay vao Godbolt voi -O2 va xem assembly:
int sum_to_n(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    return sum;
}
// Voi -O2, compiler co the dung cong thuc Gauss n*(n+1)/2 thay vi vong lap!

6. Liên hệ các bài khác

7. Tóm tắt

  • Mỗi lệnh assembly gồm opcodetoán hạng — ánh xạ gần 1-1 với lệnh máy CPU thực thi.
  • Ba loại toán hạng: immediate (hằng số), register (thanh ghi), memory (địa chỉ bộ nhớ trong [...]).
  • Nhóm lệnh cốt lõi: mov (chuyển dữ liệu), add/sub/imul (số học), cmp (so sánh, cập nhật FLAGS), jmp/je/jl/... (nhảy có/không điều kiện).
  • Mọi vòng lặp và rẽ nhánh đều được dịch thành cmp + lệnh nhảy + label.
  • Intel syntax và AT&T syntax diễn đạt cùng lệnh — Intel đặt đích trước nguồn, AT&T đặt nguồn trước đích.
  • x86 (CISC) và ARM (RISC) có triết lý khác nhau về độ phức tạp lệnh — cùng kết quả, khác cách.
  • Godbolt Compiler Explorer là công cụ thực hành tốt nhất để xem compiler tạo ra assembly gì từ code của bạn.

8. Tự kiểm tra

Tự kiểm tra
0/7 câu đã trả lời
  1. Q1
    Vì sao lệnh `cmp rax, rbx` không ghi kết quả vào register nào, nhưng lệnh nhảy `je` ngay sau vẫn hoạt động đúng?
  2. Q2
    Một đoạn code C dùng `if (x > 0)` sẽ được compiler dịch thành những lệnh assembly nào? Mô tả thứ tự.
  3. Q3
    Trong Intel syntax, `mov rax, [rbx]` và `mov rax, rbx` khác nhau điều gì?
  4. Q4
    Vì sao ARM (RISC) bắt buộc load dữ liệu từ bộ nhớ vào register trước khi tính toán, trong khi x86 (CISC) cho phép tính thẳng từ bộ nhớ?
  5. Q5
    Bạn chạy đoạn C sau qua Godbolt với `-O2` và thấy compiler không tạo vòng lặp mà chỉ tạo vài lệnh số học. Tại sao?
  6. Q6
    AT&T syntax viết `movq $5, %rax`. Intel syntax viết gì? Hai điểm khác biệt là gì?
  7. Q7
    Trong ba tình huống nào dev thường cần đọc assembly? Tình huống nào KHÔNG phải lý do chính đáng?

Bài tiếp theo: Từ code bậc cao tới lệnh máy

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Từ code bậc cao tới lệnh máy — compiler dịch thế nào