Dữ liệu & CPU/Assembly nhập môn — đọc ngôn ngữ CPU thực sự chạy
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):

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

; 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

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.

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

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

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

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

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

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*.

flowchart TD
  A["cmp rax, n"] --> B{"FLAGS"}
  B -->|"rax < n (SF != OF)"| C["jl: nhay ve loop"]
  B -->|"rax >= n"| D["tiep tuc xuong duoi"]

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 code
int sum = 0;
for (int i = 1; i <= n; i++) {
    sum += i;
}
; 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

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:

; x86-64 (Intel syntax)
add rax, rcx          ; 1 lenh, co the tu lay tu bo nho
; 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:

; 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.

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.

// 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
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?
cmp tính hiệu rax - rbx rồi cập nhật thanh ghi FLAGS (một thanh ghi trạng thái đặc biệt trong CPU), không lưu giá trị số ra register thông thường. Lệnh je sau đó đọc bit ZF (Zero Flag) trong FLAGS — nếu kết quả phép trừ là 0 (tức hai giá trị bằng nhau), ZF được bật, je nhảy. Đây là cơ chế "thông điệp ngầm" giữa lệnh so sánh và lệnh nhảy — CPU không cần register trung gian.
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ự.
Compiler thường dịch thành ba bước: đầu tiên cmp để so sánh giá trị của x với 0, tiếp theo một lệnh nhảy có điều kiện đảo (ví dụ jle skip — nhảy qua phần thân if nếu không thoả điều kiện), rồi mới đến các lệnh bên trong thân if. Sau thân if là label skip:. Compiler thường dịch điều kiện đảo để nhảy qua đoạn code cần bỏ qua — cách này gọn hơn dùng nhảy theo chiều thuận rồi nhảy qua phần còn lại.
Q3
Trong Intel syntax, `mov rax, [rbx]` và `mov rax, rbx` khác nhau điều gì?
mov rax, rbx sao chép giá trị số trong register rbx sang rax — đây là copy register. mov rax, [rbx] dùng giá trị trong rbx như một địa chỉ bộ nhớ, đọc nội dung tại địa chỉ đó và đặt vào rax — đây là dereference (giống *ptr trong C). Dấu ngoặc vuông là ký hiệu "đi đến địa chỉ này và lấy giá trị".
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ớ?
Triết lý RISC là mỗi lệnh làm đúng một việc và có độ dài cố định — điều này làm pipeline CPU đơn giản hơn, dễ decode song song hơn, và tiêu thụ điện ít hơn (quan trọng cho thiết bị di động). x86 là CISC, cho phép lệnh phức tạp hơn (vừa load vừa cộng) giúp code nhỏ hơn về số lệnh — nhưng mạch decode phức tạp hơn. Thực tế hiện đại: bộ vi xử lý x86 hiện đại bên trong vẫn chia lệnh CISC thành các micro-operation kiểu RISC trước khi thực thi.
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?
Với tối ưu -O2 trở lên, compiler đủ thông minh để nhận ra vòng lặp tính tổng 1..n tương đương công thức Gauss n*(n+1)/2 — một phép nhân và chia thay vì n lần cộng. Đây là một dạng loop optimization (cụ thể là strength reduction + closed-form substitution). Compiler chỉ làm được khi vòng lặp đủ đơn giản và không có side effect — thêm một lệnh in trong vòng lặp thì compiler không dám bỏ.
Q6
AT&T syntax viết `movq $5, %rax`. Intel syntax viết gì? Hai điểm khác biệt là gì?
Intel syntax viết mov rax, 5. Hai điểm khác: (1) Thứ tự toán hạng đảo — AT&T đặt nguồn trước đích ($5, %rax), Intel đặt đích trước nguồn (rax, 5); (2) Ký hiệu — AT&T dùng % trước register và $ trước immediate, Intel không dùng tiền tố nào. Suffix q trong AT&T chỉ kích thước 64-bit; Intel dùng tên register (rax = 64-bit, eax = 32-bit) thay vì suffix.
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?
Ba tình huống chính đáng: (1) Debug hiệu năng cực hạn sau khi đã profile và xác định hot path cụ thể; (2) Hiểu tại sao compiler không tối ưu như kỳ vọng để viết lại code cho compiler xử lý tốt hơn; (3) Phân tích crash dump khi cần biết lệnh nào gây ra sự cố. Tình huống không chính đáng: viết assembly tay vì "chắc sẽ nhanh hơn" — compiler hiện đại tối ưu tốt hơn con người và không mắc lỗi về register spilling hay calling convention. Micro-optimization thủ công thường tạo code khó bảo trì mà không mang lại lợi ích đo được.

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

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