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:
- 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ường | Assembly |
|---|---|
| "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... |
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, r8–r15. 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ếnsumbằng cách ghi 0 vàorax.mov rcx, 1— khởi tạo biếnibằng cách ghi 1 vàorcx.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ínhrcx - rdi(tứci - n) và cập nhật FLAGS.jg loop_done— nhảy đếnloop_donenếurcxlớn hơnrdi(tứci > n). Đây là điều kiện thoát vòng lặp.add rax, rcx— cộngivàosum.add rcx, 1— tăngilê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,raxchứa kết quả.
Lưu ý: compiler thực tế thường đảo cấu trúc (đặt cmp+jg ở cuối vòng thay vì đầu) để giảm số lần nhảy — nhưng ý nghĩa logic giống nhau.
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 -S và objdump 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
- Bài 03 — Register, ALU và Control Unit: assembly thao tác trực tiếp trên register và ALU — đây là nơi các lệnh
mov/add/cmpthực sự diễn ra bên trong phần cứng. - Bài 02 — Chu kỳ fetch-decode-execute: mỗi lệnh assembly chạy qua chu kỳ FDE; assembly chính là nội dung IR sau khi fetch và decode.
- Bài 05 — Từ code bậc cao tới lệnh máy: compiler dịch code bậc cao xuống chính assembly này — bài đó giải thích toàn bộ pipeline từ source đến opcode.
7. Tóm tắt
- Mỗi lệnh assembly gồm opcode và toá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
Q1Vì 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.Q2Mộ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ự.▸
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.Q3Trong 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ị".Q4Vì 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ớ?▸
Q5Bạ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?▸
-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ỏ.Q6AT&T syntax viết `movq $5, %rax`. Intel syntax viết gì? Hai điểm khác biệt là gì?▸
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.Q7Trong 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 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
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