Bài 4: Machine-Level Programming¤
1. Thanh ghi (Registers)¤
1.1 Kiến trúc IA32 — 8 thanh ghi 32 bit¤
IA32 có 8 thanh ghi 32 bit. Mỗi thanh ghi có thể truy cập ở nhiều kích thước khác nhau (32-bit, 16-bit, 8-bit cao, 8-bit thấp):
| Thanh ghi 32-bit | 16-bit | 8-bit cao | 8-bit thấp | Mục đích |
|---|---|---|---|---|
%eax |
%ax |
%ah |
%al |
Kết quả (Accumulator) |
%ecx |
%cx |
%ch |
%cl |
Counter |
%edx |
%dx |
%dh |
%dl |
Data |
%ebx |
%bx |
%bh |
%bl |
Base |
%esi |
%si |
— | — | Source Index |
%edi |
%di |
— | — | Destination Index |
%esp |
%sp |
— | — | Stack Pointer |
%ebp |
%bp |
— | — | Base Pointer |
Lưu ý
%esp và %ebp được dành riêng cho quản lý stack. Không nên dùng tùy tiện trong các phép tính toán thông thường.
1.2 Kiến trúc x86-64 — 16 thanh ghi 64 bit¤
x86-64 mở rộng các thanh ghi cũ lên 64 bit và bổ sung 8 thanh ghi mới:
- Các thanh ghi cũ:
%rax,%rbx,%rcx,%rdx,%rsi,%rdi,%rsp,%rbp - Thanh ghi mới:
%r8đến%r15 - Mỗi thanh ghi 64-bit có thể truy cập phần 32-bit thấp (ví dụ:
%r8d), 16-bit (%r8w), và 8-bit (%r8b) %rbptrong x86-64 trở thành thanh ghi mục đích chung (không còn bắt buộc làm base pointer nữa)
2. Chuyển dữ liệu — mov¤
2.1 Các suffix của lệnh mov¤
Suffix quyết định số byte được chuyển:
| Suffix | Kích thước | Ví dụ |
|---|---|---|
movb |
1 byte | movb $123, %al |
movw |
2 bytes | movw %ax, %bx |
movl |
4 bytes | movl %eax, %ebx |
movq |
8 bytes (x86-64) | movq %rax, %rbx |
mov |
Tự động | Dùng được với tất cả |
Quy tắc quan trọng
Kích thước của thanh ghi trong lệnh phải khớp với suffix. Ví dụ:
2.2 Các kiểu toán hạng (Operands)¤
Immediate → $0x400, $-533 ; Hằng số, tiền tố $
Register → %eax, %esi ; Giá trị trong thanh ghi
Memory → (%eax), 0x100 ; Giá trị tại địa chỉ bộ nhớ
2.3 Bảng tổ hợp toán hạng hợp lệ của movl¤
| Source | Dest | Ví dụ | C tương đương |
|---|---|---|---|
| Imm | Reg | movl $0x4, %eax |
temp = 4; |
| Imm | Mem | movl $-147, (%eax) |
*p = -147; |
| Reg | Reg | movl %eax, %edx |
temp2 = temp; |
| Reg | Mem | movl %eax, (%edx) |
*p = temp; |
| Mem | Reg | movl (%eax), %edx |
temp = *p; |
Không thể Mem → Mem
Không thể chuyển trực tiếp từ bộ nhớ sang bộ nhớ với một lệnh duy nhất. Phải dùng thanh ghi trung gian.
3. Các chế độ địa chỉ bộ nhớ¤
3.1 Dạng tổng quát¤
Trong đó:
- D: Hằng số dịch chuyển (displacement) — 1, 2, hoặc 4 bytes
- Rb: Base register — bất kỳ thanh ghi nào
- Ri: Index register — bất kỳ thanh ghi nào trừ %esp/%rsp
- S: Scale — 1, 2, 4, hoặc 8
Tại sao S chỉ có thể là 1, 2, 4, hoặc 8?
Vì đây chính xác là kích thước của các kiểu dữ liệu phổ biến trong C:
- char = 1 byte → S=1
- short = 2 bytes → S=2
- int / float = 4 bytes → S=4
- long / double / con trỏ 64-bit = 8 bytes → S=8
Điều này giúp truy cập phần tử mảng a[i] rất tự nhiên: địa chỉ = base + i * sizeof(type).
3.2 Các dạng đặc biệt¤
| Dạng | Công thức | Ví dụ |
|---|---|---|
(Rb, Ri) |
Mem[Reg[Rb] + Reg[Ri]] |
(%eax, %ecx) |
D(Rb, Ri) |
Mem[Reg[Rb] + Reg[Ri] + D] |
4(%eax, %ecx) |
(Rb, Ri, S) |
Mem[Reg[Rb] + S*Reg[Ri]] |
(%eax, %ecx, 4) |
(,Ri, S) |
Mem[Reg[Ri] * S] |
(, %ecx, 8) |
D(Rb) |
Mem[Reg[Rb] + D] |
8(%ebp) |
4. Lệnh leal — Load Effective Address¤
4.1 Cú pháp và tác dụng¤
leal tính địa chỉ theo biểu thức, rồi gán giá trị địa chỉ đó vào Dst — không đọc bộ nhớ.
Hai công dụng chính:
- Tính địa chỉ:
p = &x[i] - Tính toán biểu thức dạng
x + k*i + d(compiler hay dùng để tối ưu nhân/cộng)
4.2 Ví dụ: Compiler tối ưu phép nhân¤
Thay vì dùng imull $12, %eax, compiler sinh ra:
Mẹo
leal không ảnh hưởng đến các cờ (flags) như addl hay imull. Đây là lý do compiler thích dùng leal để tính toán số học khi có thể.
5. Các phép tính toán học và logic¤
5.1 Lệnh 2 toán hạng¤
| Lệnh | Phép tính | Ghi chú |
|---|---|---|
addl Src, Dest |
Dest = Dest + Src |
|
subl Src, Dest |
Dest = Dest - Src |
|
imull Src, Dest |
Dest = Dest * Src |
|
sall Src, Dest |
Dest = Dest << Src |
Còn gọi là shll |
sarl Src, Dest |
Dest = Dest >> Src |
Arithmetic — giữ dấu (sign bit) |
shrl Src, Dest |
Dest = Dest >> Src |
Logical — điền 0 vào bit cao |
xorl Src, Dest |
Dest = Dest ^ Src |
|
andl Src, Dest |
Dest = Dest & Src |
|
orl Src, Dest |
Dest = Dest \| Src |
Thứ tự toán hạng AT&T
Trong cú pháp AT&T (dùng trong Linux/GCC), Source đứng trước, Dest đứng sau — ngược với Intel syntax. addl %ecx, %eax nghĩa là %eax = %eax + %ecx.
Signed vs Unsigned
Không có sự khác biệt giữa signed và unsigned cho addl, subl, imull ở cấp độ bit. Sự khác biệt chỉ xuất hiện khi xử lý overflow và phép chia.
5.2 Lệnh 1 toán hạng¤
| Lệnh | Phép tính |
|---|---|
incl Dest |
Dest = Dest + 1 |
decl Dest |
Dest = Dest - 1 |
negl Dest |
Dest = -Dest |
notl Dest |
Dest = ~Dest (đảo toàn bộ bit) |
6. Bài tập có lời giải¤
Bài tập 1 — Trace giá trị thanh ghi và bộ nhớ¤
Cho trước:
| Thanh ghi | Giá trị | Địa chỉ | Giá trị |
|---|---|---|---|
%eax |
0x100 |
0x100 |
0xF9 |
%ecx |
0x1 |
0x104 |
0x11 |
%edx |
0x3 |
0x108 |
0x15 |
0x10C |
0xAB |
Xem đáp án
| Câu lệnh | Vị trí thay đổi | Giá trị mới | Giải thích |
|---|---|---|---|
addl %ecx, (%eax) |
Ô nhớ 0x100 |
0xFA |
0xF9 + 0x1 = 0xFA |
imull $2, (%eax,%edx,4) |
Ô nhớ 0x10C |
0x22 |
Địa chỉ = 0x100 + 4*0x3 = 0x10C; 0x11 * 2 = 0x22 |
subl %ecx, %eax |
%eax |
0xFF |
0x100 - 0x1 = 0xFF |
movl (%eax,%ecx,8), %eax |
%eax |
0x15 |
Địa chỉ = 0x100 + 8*0x1 = 0x108; đọc giá trị 0x15 |
leal (%eax,%ecx,8), %edx |
%edx |
0x108 |
leal không đọc bộ nhớ, chỉ tính địa chỉ: 0x100 + 8*0x1 = 0x108 |
Bài tập 2 — Lệnh nào gán %ebx = 2?¤
Cho trước: %eax = 0x1, %ebx = 0x2, %ecx = 0x1, %edx = 0x2
Bộ nhớ: 0x100 = 0x1, 0x104 = 0x2, 0x108 = 0x1
A. movl %eax, %ebx ; %ebx = %eax = 0x1 → ❌
B. movl 2, %ebx ; Sai cú pháp (thiếu $) → ❌ không hợp lệ
C. addl %eax, %ebx ; %ebx = 0x2 + 0x1 = 0x3 → ❌
D. imull %eax, %ebx ; %ebx = 0x2 * 0x1 = 0x2 → ✅
E. movb $2, %bl ; 8 bit thấp của %ebx = 2; %ebx = 0x2 → ✅
F. movl (%edx,%eax,2), %ebx ; địa chỉ = 0x2 + 2*0x1 = 0x4 → không hợp lệ ở đây
G. movl 1(%eax), %ebx ; địa chỉ = 0x1 + 1 = 0x2, đọc Mem[0x2] → không xác định
H. addl (%ecx), %ebx ; %ebx += Mem[0x1] → không xác định
Đáp án
D và E là chắc chắn đúng trong ngữ cảnh cho sẵn.
Bài tập 3 — Dịch assembly sang C¤
; x tại (%ebp+8), y tại (%ebp+12), z tại (%ebp+16)
movl 12(%ebp), %eax ; %eax = y
xorl 8(%ebp), %eax ; %eax = y ^ x
sall $5, %eax ; %eax = (y^x) << 5
incr %eax ; %eax = ((y^x)<<5) + 1 [lưu ý: đây là lỗi đánh máy, phải là incl]
subl 16(%ebp), %eax ; %eax = ((y^x)<<5) + 1 - z
Hàm C tương đương
Kiểm tra với x=2, y=5, z=3
t1 = 5 ^ 2 = 7(vì0b101 ^ 0b010 = 0b111)t2 = 7 << 5 = 224t3 = 224 + 1 = 225t4 = 225 - 3 = 222✅
Bài tập 4 — Điền assembly tương ứng với C¤
movl 16(%ebp), %edx ; %edx = z
movl 12(%ebp), %eax ; %eax = y
subl 8(%ebp), %eax ; %eax = y - x (t1)
leal (%edx,%edx,2), %edx ; %edx = z + 2z = 3z
addl %edx, %edx ; %edx = 3z + 3z = 6z (t2)
xorl %edx, %eax ; %eax = t1 ^ t2 → kết quả
ret
Tại sao không dùng imull $6, %edx?
leal + addl nhanh hơn imull vì phép nhân có latency cao hơn trên nhiều kiến trúc. Đây là kỹ thuật strength reduction — compiler thay nhân bằng tổ hợp shift/add.
Bài tập 5 — Decode assembly¤
; x tại (%ebp+8), y tại (%ebp+12)
movl 8(%ebp), %eax ; %eax = x
subl 12(%ebp), %eax ; %eax = x - y
sarl $31, %eax ; %eax = (x-y) >> 31 → toàn bit 0 hoặc toàn bit 1 (mask)
movl %eax, %edx ; %edx = mask
andl 12(%ebp), %eax ; %eax = mask & y
notl %edx ; %edx = ~mask
andl 8(%ebp), %edx ; %edx = ~mask & x
orl %edx, %eax ; %eax = (mask & y) | (~mask & x)
Hàm C và ý nghĩa
int decode(int x, int y) {
int mask = (x - y) >> 31; // toàn 1 nếu x<y, toàn 0 nếu x>=y
int t1 = mask & y; // nếu x<y: lấy y, ngược lại 0
int t2 = ~mask & x; // nếu x>=y: lấy x, ngược lại 0
return t1 | t2;
}
Đây là hàm max(x, y) không dùng nhánh (branchless):
- Nếu x < y: mask = 0xFFFFFFFF → kết quả = y
- Nếu x >= y: mask = 0x00000000 → kết quả = x
sarl $31 với số 32-bit: dịch phải số học 31 bit — sign bit nhân rộng ra toàn bộ register.
Bài tập 6 — Viết lệnh assembly¤
| Tác vụ | Các cách viết |
|---|---|
Tăng %eax lên 1 |
incl %eax hoặc leal 1(%eax), %eax hoặc addl $1, %eax |
Nhân %ecx với 4 |
imull $4, %ecx hoặc sall $2, %ecx hoặc leal (,%ecx,4), %ecx |
%ebx = %eax + 12 (chỉ địa chỉ) |
leal 12(%eax), %ebx |
Trừ %edx từ ô nhớ 0x104 |
subl %edx, 0x104 |
Giữ 4 bit thấp của %ecx |
andl $0xF, %ecx |
Đọc 2 byte từ -4(%eax) vào %cx |
movw -4(%eax), %cx |
Bài tập 7 — Tính 10a + 4¤
a ở địa chỉ 0x102, %ebx = 0x100. Điền assembly:
movl 0x102, %eax ; %eax = a
leal (%eax,%eax,4), %eax ; %eax = a + 4a = 5a
leal 2(%eax), %eax ; %eax = 5a + 2
leal (%eax,%eax), %eax ; %eax = 2*(5a+2) = 10a + 4
Các cách khác cho từng bước
- Bước 2:
imull $5, %eax - Bước 3:
addl $2, %eax - Bước 4:
imull $2, %eaxhoặcsall $1, %eaxhoặcleal (,%eax,2), %eax
Bài tập 8 — Tính 6a + b + 4 trong 2 lệnh¤
Cho %eax = a, %ebx = b:
; Cách 1:
leal 2(%eax,%eax,2), %eax ; %eax = 2 + 3a
leal (%ebx,%eax,2), %eax ; %eax = b + 2*(3a+2) = 6a + b + 4 ✅
; Cách 2:
leal 4(%ebx,%eax,4), %ebx ; %ebx = b + 4a + 4
leal (%ebx,%eax,2), %eax ; %eax = (b+4a+4) + 2a = 6a + b + 4 ✅
Bài tập 9 — Tính (x+y)² / 2¤
x ở 8(%ebp), y ở 12(%ebp):
movl 8(%ebp), %eax ; %eax = x
addl 12(%ebp), %eax ; %eax = x + y
imull %eax, %eax ; %eax = (x+y)²
sarl $1, %eax ; %eax = (x+y)² >> 1 = (x+y)²/2 (lấy phần nguyên)
Tại sao sarl $1 thay vì chia 2?
Dịch phải 1 bit = chia 2 (lấy phần nguyên, về phía âm vô cực với số âm). sarl (arithmetic shift right) giữ nguyên dấu của số.
Bài tập 10 — Tìm lỗi trong assembly¤
Hàm C cần chuyển đổi:
int func5(char* str) {
int a = str[0] - '0'; // '0' = 0x30 = 48
int b = str[1] - '0';
return a + b;
}
Assembly có lỗi:
movl 8(%ebp), %eax ; (1) %eax = địa chỉ str ✅
movl (%eax), %al ; (2) %al = str[0] ⚠️ suffix không khớp
subl $0x48, %eax ; (3) str[0] - '0' ⚠️ dùng %eax thay vì %al
mov 1(%eax), %bh ; (4) str[1] → vào %bh ❌ địa chỉ sai
subl $'0, %ebx ; (5) - '0' ❌ cú pháp sai
addl %ebx, %eax ; (6) a + b ⚠️ size không nhất quán
Phân tích lỗi và sửa
Lỗi (2): Dùng movl để đọc 4 byte vào %al (1 byte) — không khớp suffix. Phải dùng movb.
Lỗi (3): Sau khi đọc str[0] vào %al, phải trừ từ %al, không phải %eax (vì %eax vẫn chứa địa chỉ).
Lỗi (4): Sau bước (3), %eax đã bị thay đổi → 1(%eax) không còn trỏ đến str[1]. Phải lưu địa chỉ gốc trước.
Lỗi (5): Cú pháp hằng số phải là $0x30 hoặc $48, không phải $'0.
Phiên bản sửa đúng:
movl 8(%ebp), %ecx ; %ecx = địa chỉ str (giữ nguyên)
movb (%ecx), %al ; %al = str[0]
subb $0x30, %al ; %al = str[0] - '0'
movsx %al, %eax ; mở rộng sang 32-bit có dấu
movb 1(%ecx), %bl ; %bl = str[1]
subb $0x30, %bl ; %bl = str[1] - '0'
movsx %bl, %ebx ; mở rộng sang 32-bit
addl %ebx, %eax ; %eax = a + b
Bài tập Bonus — Phân tích đoạn assembly ẩn¤
; x tại (%ebp+8), n tại (%ebp+12)
movl 12(%ebp), %ecx ; (1) %ecx = n
movl 8(%ebp), %edx ; (2) %edx = x
xorl %eax, %eax ; (3) %eax = 0
addl $1, %eax ; (4) %eax = 1
sall %ecx, %eax ; (5) %eax = 1 << n ← LỖI
subl $1, %eax ; (6) %eax = (1<<n) - 1
andl %edx, %eax ; (7) %eax = x & ((1<<n)-1)
Câu 1: Lệnh (3) xorl %eax, %eax có tác dụng gì?
Gán %eax = 0 — đây là kỹ thuật chuẩn để zero một register trong assembly x86. Tại sao dùng xor thay vì movl $0, %eax?
- Lệnh xor reg, reg ngắn hơn (2 bytes vs 5 bytes với movl)
- Trên nhiều CPU, CPU nhận ra pattern này và tối ưu đặc biệt (không cần đọc giá trị cũ)
Câu 2: Lỗi ở lệnh (5) sall %ecx, %eax là gì?
Trong IA32, lệnh shift chỉ chấp nhận %cl (8-bit thấp của %ecx) làm operand count, không nhận %ecx 32-bit. Sửa lại: sall %cl, %eax.
Câu 3: Hàm C tương đương
Chức năng: Lấy n bit thấp nhất của x.
Ví dụ: x = 0b11011010, n = 4
- 1 << 4 = 0b10000
- (1<<4) - 1 = 0b01111 ← mask n bit 1
- x & mask = 0b11011010 & 0b00001111 = 0b00001010
7. Tổng kết — Sơ đồ tư duy¤
Assembly cơ bản
├── Thanh ghi
│ ├── IA32: %eax..%ebp (32-bit)
│ └── x86-64: %rax..%r15 (64-bit)
├── Chuyển dữ liệu
│ ├── mov{b,w,l,q} — suffix khớp với kích thước thanh ghi
│ └── Không có Mem→Mem trực tiếp
├── Địa chỉ bộ nhớ
│ └── D(Rb, Ri, S) = Mem[Rb + S*Ri + D]
├── leal — tính địa chỉ/biểu thức, không đọc bộ nhớ
└── Phép tính
├── 2 toán hạng: add, sub, imul, sal, sar, shr, xor, and, or
└── 1 toán hạng: inc, dec, neg, not