Bài 3: Machine-Level Programming: Cơ Bản

1. Lịch sử và kiến trúc Intel x86

1.1 Tổng quan về Intel x86

Intel x86 là dòng vi xử lý thống trị thị trường laptop, desktop và server trong nhiều thập kỷ. Điểm đặc trưng nổi bật nhất là tương thích ngược tuyệt đối — một chương trình viết cho CPU 8086 năm 1978 vẫn có thể chạy trên CPU Intel hiện đại ngày nay.

Intel x86 thuộc kiến trúc CISC (Complex Instruction Set Computer) — nghĩa là có rất nhiều loại lệnh với nhiều định dạng khác nhau, mỗi lệnh có thể thực hiện nhiều thao tác. Điều này tương phản với RISC (Reduced Instruction Set Computer) vốn có ít lệnh hơn nhưng đơn giản và dễ tối ưu hóa phần cứng hơn. Thách thức của Intel là làm sao đạt được hiệu suất của RISC trong khi vẫn phải duy trì độ tương thích của CISC — và họ đã làm được.

1.2 Các mốc phát triển quan trọng

TênNămTransistorsMHzÝ nghĩa
8086197829K5–10CPU Intel 16-bit đầu tiên, dùng cho IBM PC & DOS, không gian địa chỉ 1MB
3861985275K16–33CPU Intel 32-bit đầu tiên (IA32), hỗ trợ “flat addressing”, chạy được Unix
Pentium 4E2004125M2800–3800CPU Intel 64-bit đầu tiên (x86-64)
Core 22006291M1060–3500CPU nhiều nhân (multi-core) đầu tiên
Core i72008731M1700–39004 nhân

2. C, Assembly và Mã Máy

2.1 Tại sao phải học Assembly?

Ngôn ngữ bậc cao (C, Python, Java…) giúp lập trình viên không cần quan tâm đến chi tiết phần cứng. Tuy nhiên, assembly mang lại nhiều lợi ích quan trọng mà ngôn ngữ bậc cao không thể thay thế:

  • Hiểu hoạt động thực sự của hệ thống: Stack, bộ nhớ, register hoạt động ra sao khi chương trình chạy.
  • Phát hiện lỗ hổng bảo mật: Nhiều lỗ hổng mức hệ thống (buffer overflow, return-oriented programming) chỉ có thể hiểu được khi đọc assembly.
  • Tối ưu hóa hiệu năng: Compiler không phải lúc nào cũng tạo ra mã tối ưu nhất; người lập trình có thể can thiệp ở mức assembly.
  • Kỹ năng thiết yếu cho An toàn Thông tin (ATTT): Reverse engineering, phân tích malware đều yêu cầu đọc được assembly.

2.2 Các định nghĩa cốt lõi

ISA (Instruction Set Architecture) — Kiến trúc tập lệnh: là “hợp đồng” giữa phần cứng và phần mềm, định nghĩa các lệnh nào CPU hiểu, register nào tồn tại, và cách bộ nhớ được tổ chức. Ví dụ: Intel x86, x86-64; ARM (dùng trên hầu hết thiết bị di động).

Microarchitecture: Là hiện thực vật lý của ISA — cách CPU cụ thể thực hiện các lệnh đó. Ví dụ: kích thước cache L1/L2/L3, tần số xung nhịp, số pipeline stages.

Mã máy (Machine Code): Chuỗi các byte nhị phân mà CPU trực tiếp thực thi. Đây là ngôn ngữ duy nhất CPU “hiểu”.

Mã hợp ngữ (Assembly Code): Biểu diễn dạng text (con người đọc được) của mã máy. Mỗi dòng assembly tương ứng trực tiếp với một hoặc một nhóm nhỏ instruction máy.

2.3 Quá trình từ mã C đến chương trình thực thi

graph LR A["p1.c, p2.c
(C source)"] -->|"gcc -S
Compiler"| B["p1.s, p2.s
(Assembly)"] B -->|"gcc or as
Assembler"| C["p1.o, p2.o
(Object code)"] D["Static libraries (.a)"] --> E C -->|"gcc or ld
Linker"| E["p (Executable)"]

Câu lệnh biên dịch đầy đủ:

gcc p1.c p2.c -o p

Bước 1 — Compiler (gcc -S): Chuyển mã C thành mã assembly (file .s). Đây là bước dịch ngôn ngữ bậc cao sang ngôn ngữ bậc thấp, với các tối ưu hóa của compiler.

Bước 2 — Assembler (gcc or as): Chuyển từng file .s thành file object .o — là các byte nhị phân đại diện cho các instruction. Tuy nhiên, các file này chưa hoàn chỉnh vì chúng chứa các tham chiếu đến hàm/biến ở file khác chưa được giải quyết.

Bước 3 — Linker (gcc or ld): Kết hợp tất cả các file .o lại, giải quyết tất cả các tham chiếu chéo giữa các file, và liên kết với các thư viện tĩnh (như malloc, printf từ thư viện chuẩn C) để tạo ra file thực thi cuối cùng.

2.4 Disassembling — Đọc ngược mã máy

Disassembler là công cụ phân tích chuỗi byte nhị phân và dựng lại mã assembly tương ứng. Đây là kỹ năng cốt lõi trong reverse engineering và phân tích bảo mật.

Công cụ objdump:

objdump -d <tên_file>

Có thể dùng cho cả file .o (object) và file thực thi hoàn chỉnh.

Ví dụ output:

080483c4 <sum>:
80483c4:  55        push   %ebp
80483c5:  89 e5     mov    %esp,%ebp
80483c7:  8b 45 0c  mov    0xc(%ebp),%eax
80483ca:  03 45 08  add    0x8(%ebp),%eax
80483cd:  5d        pop    %ebp
80483ce:  c3        ret

Công cụ gdb (GNU Debugger):

gdb <tên_file>
disassemble sum        # Disassemble một hàm cụ thể
x/11xb sum             # Xem 11 bytes bắt đầu từ địa chỉ của hàm sum, dạng hex

3. Góc nhìn của mã Assembly/Mã Máy

Khi lập trình ở mức assembly, lập trình viên làm việc với các thành phần sau của CPU:

3.1 Programmer-Visible State

Program Counter (PC)

  • Lưu địa chỉ của instruction tiếp theo sẽ được thực thi.
  • Trong IA32 gọi là EIP (Extended Instruction Pointer).
  • Trong x86-64 gọi là RIP (Register Instruction Pointer).
  • PC tự động tăng lên sau mỗi instruction, hoặc thay đổi khi có lệnh nhảy (jump/call).

Registers (Thanh ghi)

  • Bộ nhớ cực nhanh nằm trực tiếp trong CPU.
  • Được dùng để lưu trữ tạm thời các giá trị trong quá trình tính toán.
  • Số lượng hạn chế — đây là một trong những thách thức chính khi viết assembly tốt.

Condition Codes

  • Các bit trạng thái được tự động cập nhật sau mỗi phép tính toán học/logic.
  • Ví dụ: CF (Carry Flag), ZF (Zero Flag), SF (Sign Flag), OF (Overflow Flag).
  • Được dùng để thực hiện các câu lệnh rẽ nhánh có điều kiện (if/else, vòng lặp).

Bộ nhớ (Memory)

  • Là một mảng tuyến tính các bytes, mỗi byte có một địa chỉ duy nhất.
  • Chứa cả code (các instruction cần thực thi) và data (các biến, hằng số).
  • Stack là vùng bộ nhớ đặc biệt hỗ trợ việc gọi hàm (procedure call), lưu trữ tham số, biến cục bộ, và địa chỉ trả về.

3.2 Kiểu dữ liệu trong Assembly

Assembly không có hệ thống kiểu dữ liệu phong phú như C hay Java. Các “kiểu” chỉ được phân biệt bởi kích thước:

Kích thướcMô tả
1 byteSố nguyên (ký tự)
2 bytesSố nguyên (short)
4 bytesSố nguyên (int trong IA32)
8 bytesSố nguyên (long trong x86-64), con trỏ
4, 8, 10 bytesDấu phẩy động (floating point)

3.3 Nhóm các phép tính trong Assembly

  • Nhóm 1 — Chuyển dữ liệu: Di chuyển dữ liệu giữa bộ nhớ ↔ register (lệnh mov).
  • Nhóm 2 — Tính toán: Thực hiện phép toán số học và logic trên register hoặc bộ nhớ (add, sub, and, or…).
  • Nhóm 3 — Điều khiển luồng: Thay đổi luồng thực thi — nhảy không điều kiện, rẽ nhánh có điều kiện, gọi hàm và trả về.

4. Định dạng AT&T vs Intel

Môn học này sử dụng định dạng AT&T (mặc định của GCC, GDB, objdump). Cần phân biệt với định dạng Intel (dùng trong tài liệu Intel và IDA Pro).

Đặc điểmAT&TIntel
Thứ tự toán hạngmovl source, destmov dest, source
Tên thanh ghi%eax (có tiền tố %)eax (không có tiền tố)
Hằng số$0x400 (có tiền tố $)0x400
Suffix lệnhmovl, movb, movqmov (không suffix)
Địa chỉ ô nhớ8(%ebp)[ebp + 8]
Sinh ra bởiGCC (mặc định), objdumpIDA Pro, MSVC

5. Registers (Thanh Ghi)

5.1 Thanh ghi IA32 — 8 thanh ghi 32-bit

Thanh ghi 32-bit    16-bit    8-bit high    8-bit low    Mục đích
%eax                %ax       %ah           %al          Kết quả hàm (return value)
%ecx                %cx       %ch           %cl          Counter (bộ đếm)
%edx                %dx       %dh           %dl          Data
%ebx                %bx       %bh           %bl          Base
%esi                %si                                  Source index
%edi                %di                                  Destination index
%esp                %sp                                  Stack pointer (con trỏ đỉnh stack)
%ebp                %bp                                  Base pointer (con trỏ khung stack)

Các thanh ghi có thể truy cập từng phần: %eax là 32-bit, %ax là 16-bit thấp của nó, %ah là byte cao của %ax, %al là byte thấp của %ax.

5.2 Thanh ghi x86-64 — 16 thanh ghi 64-bit

x86-64 mở rộng 8 thanh ghi 32-bit cũ thành 64-bit (bằng cách thêm tiền tố r), đồng thời thêm 8 thanh ghi hoàn toàn mới:

64-bit     32-bit (low)    Ghi chú
%rax       %eax            
%rbx       %ebx            
%rcx       %ecx            
%rdx       %edx            
%rsi       %esi            
%rdi       %edi            
%rsp       %esp            Stack pointer
%rbp       %ebp            Trở thành thanh ghi mục đích chung trong x86-64
%r8        %r8d            Thanh ghi mới
%r9        %r9d
%r10       %r10d
%r11       %r11d
%r12       %r12d
%r13       %r13d
%r14       %r14d
%r15       %r15d

6. Lệnh Chuyển Dữ Liệu — mov

6.1 Cú pháp và Suffix

movb  source, dest   # Di chuyển 1 byte
movw  source, dest   # Di chuyển 2 bytes (word)
movl  source, dest   # Di chuyển 4 bytes (long word)
movq  source, dest   # Di chuyển 8 bytes (quad word) — dùng với x86-64
mov   source, dest   # Compiler tự suy ra kích thước từ toán hạng

6.2 Các loại Toán hạng (Operand)

Immediate (Hằng số)

  • Ký hiệu: tiền tố $
  • Ví dụ: $0x400, $-533, $42
  • Chỉ có thể dùng ở vị trí source, không bao giờ là destination.
  • Được mã hoá trực tiếp trong instruction.

Register (Thanh ghi)

  • Ký hiệu: tiền tố %
  • Ví dụ: %eax, %rsi, %bl
  • Nhanh nhất vì không cần truy cập bộ nhớ.

Memory (Ô nhớ)

  • Truy cập vào một địa chỉ bộ nhớ.
  • Có nhiều dạng (xem mục 6.3).
  • Chậm hơn vì cần chu kỳ truy cập bộ nhớ (memory access).

6.3 Các tổ hợp toán hạng hợp lệ cho movl

movl $0x4, %eax          # Imm → Reg:  temp = 0x4
movl $-147, (%eax)       # Imm → Mem:  *p = -147
movl %eax, %edx          # Reg → Reg:  temp2 = temp1
movl %eax, (%edx)        # Reg → Mem:  *p = temp
movl (%eax), %edx        # Mem  Reg:  temp = *p

7. Các Chế Độ Đánh Địa Chỉ Bộ Nhớ

7.1 Dạng tổng quát

D(Rb, Ri, S)  →  Mem[Reg[Rb] + S * Reg[Ri] + D]

Trong đó:

  • D: Hằng số dịch chuyển (displacement), 1, 2, hoặc 4 bytes. Có thể bỏ qua (mặc định = 0).
  • Rb: Base register — bất kỳ thanh ghi nào.
  • Ri: Index register — bất kỳ thanh ghi nào, ngoại trừ %esp/%rsp.
  • S: Scale (tỷ lệ) — phải là 1, 2, 4, hoặc 8. Các giá trị này tương ứng với kích thước của char, short, int, long — rất tiện khi lập chỉ số mảng.

7.2 Các dạng rút gọn

(%eax)              # Dạng thông thường: Mem[Reg[%eax]]
8(%ebp)             # Dịch chuyển: Mem[Reg[%ebp] + 8]
(%edx, %ecx)        # Base+Index: Mem[Reg[%edx] + Reg[%ecx]]
(%edx, %ecx, 4)     # Base+Index*Scale: Mem[Reg[%edx] + 4*Reg[%ecx]]
0x80(, %edx, 2)     # D+Index*Scale: Mem[2*Reg[%edx] + 0x80]
4(%ecx, %eax, 2)    # D+Base+Index*Scale: Mem[Reg[%ecx] + 2*Reg[%eax] + 4]

7.3 Ví dụ tính toán địa chỉ

Giả sử %edx = 0xf000, %ecx = 0x0100:

Biểu thứcTính toánKết quả
0x8(%edx)0xf000 + 0x80xf008
(%edx, %ecx)0xf000 + 0x1000xf100
(%edx, %ecx, 4)0xf000 + 4×0x1000xf400
0x80(, %edx, 2)2×0xf000 + 0x800x1e080

7.4 Câu hỏi luyện tập: Mov

Câu hỏi từ slide: Giả sử %eax = 0x100 và bộ nhớ tại 0x100 chứa giá trị 25. Hai lệnh sau cho kết quả giống hay khác nhau?

movl %eax, %ebx
movl (%eax), %ebx

8. Các Lệnh Mov Không Hợp Lệ — Luyện tập

Câu hỏi từ slide: Xác định lệnh nào hợp lệ, lệnh nào không, và giải thích lý do.

1. movl %eax, %ebx
2. movb $123, %bl
3. movl %eax, %bl
4. movb $3, (%ecx)
5. movl 0x100, (%eax)
6. mov %ecx, $100
7. mov (%eax), %bl
8. movb $3, 0x200

9. Lệnh leal — Load Effective Address

9.1 Cú pháp và mục đích

leal Source, Dest     # IA32
leaq Source, Dest     # x86-64 (64-bit)

Điểm mấu chốt: leal tính toán địa chỉ nhưng không truy xuất bộ nhớ. Nó gán trực tiếp giá trị địa chỉ đó vào register đích.

So sánh:

leal (%edx, %ecx, 4), %eax   # %eax = địa chỉ = Reg[%edx] + 4*Reg[%ecx]
movl (%edx, %ecx, 4), %ebx   # %ebx = Mem[Reg[%edx] + 4*Reg[%ecx]]  đọc bộ nhớ!

9.2 Ứng dụng của leal

Tính địa chỉ con trỏ:

int *p = &x[i];   // Tính địa chỉ của x[i]
leal (%eax, %ecx, 4), %edx   # edx = eax + 4*ecx = địa chỉ của x[i]

Tính toán biểu thức toán học — compiler thường dùng leal để tính nhanh các biểu thức dạng x + k*y + d:

leal (%eax, %eax, 2), %eax   # %eax = %eax + 2*%eax = 3*%eax
sall $2, %eax                 # %eax <<= 2, tức là %eax *= 4
# Kết quả: %eax = 12 * (giá trị ban đầu của %eax)

9.3 Ví dụ lea vs mov

Giả sử %rdx = 0x100, %rcx = 0x4:

leaq (%rdx, %rcx, 4), %rax   # %rax = 0x100 + 4*0x4 = 0x110
movq (%rdx, %rcx, 4), %rbx   # %rbx = Mem[0x110] = ... (đọc từ bộ nhớ!)

leaq (%rdx), %rdi             # %rdi = 0x100 (chép địa chỉ, không truy xuất)
movq (%rdx), %rsi             # %rsi = Mem[0x100] = ... (đọc từ bộ nhớ!)

9.4 Dùng leal để tính biểu thức

Giả sử %eax = x, %ecx = y:

LệnhKết quả
leal 6(%eax), %edxx + 6
leal (%eax, %ecx), %edxx + y
leal 0xA(, %ecx, 4), %edx4y + 10
leal (%ecx, %eax, 2), %edx2x + y

Câu hỏi từ slide: Viết lệnh leal để tính 5x + 9?


10. Ví dụ: Hàm Swap

Ví dụ này minh họa đầy đủ cách sử dụng các chế độ đánh địa chỉ trong thực tế.

void swap(int *xp, int *yp) {
    int t0 = *xp;
    int t1 = *yp;
    *xp = t1;
    *yp = t0;
}

10.1 Phiên bản IA32

swap:
    pushl %ebp
    movl  %esp, %ebp       # Thiết lập stack frame
    pushl %ebx

    movl 8(%ebp), %edx     # edx = xp  (tham số thứ 1, ở ebp+8)
    movl 12(%ebp), %ecx    # ecx = yp  (tham số thứ 2, ở ebp+12)
    movl (%edx), %ebx      # ebx = *xp = t0
    movl (%ecx), %eax      # eax = *yp = t1
    movl %eax, (%edx)      # *xp = t1
    movl %ebx, (%ecx)      # *yp = t0

    popl %ebx
    popl %ebp
    ret

Tại sao tham số ở ebp+8ebp+12?

Trong IA32, tham số hàm được truyền qua stack. Layout stack khi vào hàm:

Địa chỉ    Nội dung
ebp+12  →  yp (tham số 2)
ebp+8   →  xp (tham số 1)
ebp+4   →  Return address
ebp+0   →  Old %ebp (được push bởi lệnh pushl %ebp)
ebp-4   →  Old %ebx (được push bởi pushl %ebx)

10.2 Phiên bản x86-64

swap:
    movl (%rdi), %eax     # t0 = *xp  (xp truyền qua %rdi)
    movl (%rsi), %edx     # t1 = *yp  (yp truyền qua %rsi)
    movl %edx, (%rdi)     # *xp = t1
    movl %eax, (%rsi)     # *yp = t0
    ret

11. Các Phép Tính Toán Học và Logic

11.1 Lệnh hai toán hạng

# Cú pháp: opcode Src, Dest   →   Dest = Dest OP Src

addl  Src, Dest    # Dest = Dest + Src         (cộng)
subl  Src, Dest    # Dest = Dest - Src         (trừ)
imull Src, Dest    # Dest = Dest * Src         (nhân có dấu)
sall  Src, Dest    # Dest = Dest << Src        (shift trái / nhân 2^Src)
sarl  Src, Dest    # Dest = Dest >> Src        (shift phải số học — sign-extend)
shrl  Src, Dest    # Dest = Dest >> Src        (shift phải logic — zero-fill)
xorl  Src, Dest    # Dest = Dest ^ Src         (XOR bitwise)
andl  Src, Dest    # Dest = Dest & Src         (AND bitwise)
orl   Src, Dest    # Dest = Dest | Src         (OR bitwise)

11.2 Lệnh một toán hạng

incl Dest    # Dest = Dest + 1  (increment)
decl Dest    # Dest = Dest - 1  (decrement)
negl Dest    # Dest = -Dest     (phủ định số học — two's complement)
notl Dest    # Dest = ~Dest     (NOT bitwise  đảo tất cả các bit)

11.3 Tổng quát về lệnh Assembly AT&T

Các quy tắc bất biến:

  • Destination không bao giờ là hằng số.
  • Không có lệnh nào hỗ trợ cả hai toán hạng đều là ô nhớ.
  • Sau mỗi lệnh mov hay toán học, giá trị ở dest bị thay đổi.
  • Tất cả các lệnh (trừ leal/leaq) đều thực sự đọc/ghi bộ nhớ khi toán hạng là dạng (%reg).
  • Suffix (l, w, b, q) ảnh hưởng đến tất cả các lệnh: addl, addw, addb, addq.

12. Ví dụ Đầy Đủ: Biểu thức toán học

long arith(long x, long y, long z) {
    long t1 = x + y;        // (1)
    long t2 = z + t1;       // (2)
    long t3 = x + 4;        // (3)
    long t4 = y * 48;       // (4)
    long t5 = t3 + t4;      // (5)
    long rval = t2 * t5;    // (6)
    return rval;
}

Mã assembly x86-64 (tham số: x → %rdi, y → %rsi, z → %rdx):

arith:
    leaq  (%rdi, %rsi), %rax    # (1) %rax = x + y = t1
    addq  %rdx, %rax            # (2) %rax = t1 + z = t2
    leaq  (%rsi, %rsi, 2), %rdx # (4) %rdx = y + 2y = 3y
    salq  $4, %rdx              # (4) %rdx = 3y * 16 = 48y = t4
    leaq  4(%rdi, %rdx), %rcx   # (3,5) %rcx = x + t4 + 4 = t3 + t4 = t5
    imulq %rcx, %rax            # (6) %rax = t2 * t5 = rval
    ret                         # trả về %rax

Phân tích cách compiler tối ưu:

Compiler tính y * 48 bằng cách nhận thấy 48 = 3 * 16:

  • leaq (%rsi, %rsi, 2), %rdx3y (không cần lệnh nhân!)
  • salq $4, %rdx3y * 16 = 48y (shift trái 4 bit = nhân 2^4 = 16)

Đây là ví dụ điển hình của strength reduction — compiler thay thế phép nhân đắt tiền bằng shift và add rẻ tiền hơn.


13. Tổng kết Quy Tắc Vàng