Bài 7: Hàm/Thủ Tục (Procedures) ở Mức Máy
1. Tổng quan cơ chế gọi hàm
Khi một chương trình C gọi hàm, có 3 nhóm công việc diễn ra ở mức assembly:
┌──────────────────────────────────────┐
│ 1. Chuyển luồng thực thi │
│ - Nhảy vào hàm được gọi │
│ - Trở về đúng vị trí sau khi gọi │
├──────────────────────────────────────┤
│ 2. Truyền dữ liệu │
│ - Đẩy tham số vào stack │
│ - Nhận giá trị trả về (%eax) │
├──────────────────────────────────────┤
│ 3. Quản lý bộ nhớ │
│ - Cấp phát stack frame │
│ - Thu hồi khi hàm kết thúc │
└──────────────────────────────────────┘Lưu ý: IA32 và x86-64 có một số khác biệt trong cơ chế này, sẽ được trình bày riêng.
2. Cấu trúc Stack
2.1 Nguyên tắc hoạt động
Stack là vùng nhớ hoạt động theo nguyên tắc LIFO (Last In, First Out) — vào sau, ra trước.
Địa chỉ cao ┌─────────────────┐ ← Stack "Bottom"
│ ... │
│ dữ liệu cũ │
├─────────────────┤
│ dữ liệu mới │
├─────────────────┤ ← %esp (Stack Pointer)
│ (trống) │
Địa chỉ thấp └─────────────────┘ ← Stack "Top" (đỉnh hiện tại)2.2 Lệnh PUSH
pushl Src ; IA32 — push 4 bytesQuá trình thực hiện (3 bước):
- Lấy giá trị từ
Src - Giảm
%espxuống 4 bytes (%esp -= 4) - Ghi giá trị vào địa chỉ
(%esp)
; Tương đương với:
subl $4, %esp
movl Src, (%esp)2.3 Lệnh POP
popl Dest ; IA32 — pop 4 bytesQuá trình thực hiện (3 bước):
- Lấy giá trị từ địa chỉ
(%esp) - Ghi giá trị vào
Dest - Tăng
%esplên 4 bytes (%esp += 4)
; Tương đương với:
movl (%esp), Dest
addl $4, %esp2.4 Ví dụ Push & Pop
2.5 x86-64 Stack
Trong x86-64, tất cả hoạt động tương tự nhưng:
- Dùng thanh ghi
%rspthay vì%esp - Lệnh
push/popthay đổi%rsp8 bytes (thay vì 4)
3. Gọi hàm trong IA32
3.1 Chuyển luồng thực thi
Mỗi hàm có một địa chỉ bắt đầu (entry point) trong bộ nhớ, thường gắn với một nhãn (label) trong assembly.
Lệnh call label — gọi hàm:
call label
; Thực chất tương đương:
push %eip ; lưu địa chỉ lệnh tiếp theo vào stack
jmp label ; nhảy đến hàmLệnh ret — trở về từ hàm:
ret
; Thực chất tương đương:
pop %eip ; lấy địa chỉ trả về ra khỏi stack
jmp *%eip ; nhảy về đó3.2 Ví dụ minh hoạ call/ret trên stack
Trước khi call 8048b90:
%eip = 0x804854e
%esp = 0x108
Stack[0x108] = 123Sau khi thực hiện call:
%esp = 0x104 (giảm 4)
Stack[0x104] = 0x8048553 (return address được push)
%eip = 0x8048b90 (nhảy vào main)Sau khi thực hiện ret:
%eip = 0x8048553 (pop từ stack)
%esp = 0x108 (tăng 4)
; Tiếp tục thực thi từ 0x80485534. Stack Frame
4.1 Khái niệm Stack Frame
Mỗi hàm khi được gọi sẽ chiếm một vùng riêng trên stack gọi là stack frame (hay activation record). Frame được xác định bởi hai thanh ghi:
| Thanh ghi | Vai trò |
|---|---|
%ebp (Frame Pointer) | Trỏ đến đầu frame — vị trí cố định trong suốt thời gian hàm chạy |
%esp (Stack Pointer) | Trỏ đến đỉnh stack — thay đổi khi push/pop |
┌──────────────────┐
Caller │ Arguments │ ← Tham số truyền cho hàm con
Frame │ Return Address │ ← Tự động push bởi lệnh call
├──────────────────┤ ← %ebp của frame hiện tại
Callee │ Old %ebp │ ← Frame pointer của hàm mẹ (được lưu)
Frame │ Saved Regs │ ← Các thanh ghi cần bảo toàn
│ Local Vars │ ← Biến cục bộ của hàm
│ Arg Build │ ← Tham số để gọi hàm khác (nếu có)
└──────────────────┘ ← %esp4.2 Quy tắc vòng đời Stack Frame
4.3 Set-up và Finish Code
Mỗi hàm có phần đầu (set-up) và phần cuối (finish) để thiết lập/thu hồi stack frame:
Set-up (đầu hàm):
pushl %ebp ; lưu frame pointer của hàm mẹ
movl %esp, %ebp ; thiết lập frame pointer cho hàm hiện tại
subl $N, %esp ; cấp phát N bytes cho biến cục bộ
pushl %ebx ; lưu các thanh ghi callee-saved (nếu dùng)Finish (cuối hàm):
popl %ebx ; khôi phục các thanh ghi đã lưu
; addl $N, %esp ; thu hồi không gian biến cục bộ
; popl %ebp ; hoặc dùng lệnh 'leave' thay cho 2 dòng trên
leave ; tương đương: movl %ebp, %esp + popl %ebp
ret4.4 Ví dụ hoàn chỉnh
int main() {
int result = func(5, 6);
return result;
}
int func(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}main:
pushl %ebp ; set-up: lưu old %ebp
movl %esp, %ebp ; set-up: thiết lập %ebp
subl $16, %esp ; cấp phát 16 bytes cho biến cục bộ
pushl $6 ; đẩy tham số 2 (y=6) vào stack
pushl $5 ; đẩy tham số 1 (x=5) vào stack
call func ; gọi hàm func (push return addr + jmp)
addl $8, %esp ; dọn dẹp 2 tham số (2 x 4 bytes)
movl %eax, -4(%ebp); lưu giá trị trả về vào biến result
movl -4(%ebp), %eax; đặt return value của main vào %eax
leave ; finish: khôi phục %esp và %ebp
ret
func:
pushl %ebp ; set-up
movl %esp, %ebp
subl $16, %esp ; cấp phát cho biến 'sum'
movl $0, -4(%ebp) ; sum = 0
movl 8(%ebp), %edx ; lấy x (tham số 1, offset +8 từ %ebp)
movl 12(%ebp), %eax; lấy y (tham số 2, offset +12 từ %ebp)
addl %edx, %eax ; eax = x + y
movl %eax, -4(%ebp); sum = x + y
movl -4(%ebp), %eax; chuẩn bị return value
leave
ret5. Truyền Tham Số
5.1 Quy tắc truyền tham số trong IA32
- Hàm mẹ push tham số lên stack trước khi
call, theo thứ tự ngược (tham số cuối push trước) - Hàm con truy xuất tham số qua offset tương đối từ
%ebp:
| Offset | Nội dung |
|---|---|
%ebp + 0 | Old %ebp (đã lưu) |
%ebp + 4 | Return address |
%ebp + 8 | Tham số 1 |
%ebp + 12 | Tham số 2 |
%ebp + 16 | Tham số 3 |
%ebp + 4*(n+1) | Tham số thứ n |
5.2 Ví dụ: Hàm swap
void swap(int *xp, int *yp) {
int t0 = *xp;
int t1 = *yp;
*xp = t1;
*yp = t0;
}swap:
pushl %ebp ; set-up
movl %esp, %ebp
pushl %ebx ; lưu %ebx (callee-saved)
movl 8(%ebp), %edx ; edx = xp (địa chỉ của x)
movl 12(%ebp), %ecx ; ecx = yp (địa chỉ của y)
movl (%edx), %ebx ; ebx = *xp = t0
movl (%ecx), %eax ; eax = *yp = t1
movl %eax, (%edx) ; *xp = t1
movl %ebx, (%ecx) ; *yp = t0
popl %ebx ; finish: khôi phục %ebx
popl %ebp
ret6. Giá Trị Trả Về
Giá trị trả về của hàm (kiểu số nguyên/con trỏ) luôn được đặt vào thanh ghi %eax trước khi ret.
int func(int x, int y) { return x + y; }func:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %edx
movl 12(%ebp), %eax
addl %edx, %eax ; eax = x + y ← đây là return value
popl %ebp
ret7. Quản lý Thanh Ghi
7.1 Vấn đề chia sẻ thanh ghi
Vì hàm mẹ và hàm con dùng chung tập thanh ghi, cần có quy ước ai chịu trách nhiệm lưu giá trị.
7.2 Caller-Save vs Callee-Save
Caller-Save (hàm mẹ tự lưu trước khi gọi):
%eax — cũng là register chứa return value
%edx
%ecx
Callee-Save (hàm con lưu nếu muốn dùng):
%ebx
%esi
%edi
Special (bắt buộc khôi phục):
%esp — stack pointer
%ebp — frame pointer8. Biến Cục Bộ
Biến cục bộ được cấp phát ngay trên stack frame của hàm, ở địa chỉ thấp hơn %ebp:
subl $24, %esp ; cấp phát 24 bytes cho biến cục bộTruy xuất:
movl %eax, -4(%ebp) ; lưu vào biến cục bộ đầu tiên
movl -8(%ebp), %edx ; đọc biến cục bộ thứ hai9. Gọi Hàm trong x86-64
Sự khác biệt chính so với IA32:
| Đặc điểm | IA32 | x86-64 |
|---|---|---|
| Stack pointer | %esp | %rsp |
| Frame pointer | %ebp | %rbp |
| Đơn vị push/pop | 4 bytes | 8 bytes |
| Truyền tham số | Qua stack | 6 tham số đầu qua thanh ghi (%rdi, %rsi, %rdx, %rcx, %r8, %r9), còn lại mới dùng stack |
| Return value | %eax | %rax |
10. Minh Hoạ Hàm Đệ Quy (Call Chain)
Giả sử chuỗi gọi: yoo → who → amI → amI → amI
Stack tương ứng sẽ có dạng (địa chỉ giảm dần xuống dưới):
┌─────────────┐ ← địa chỉ cao (bottom)
│ Frame: yoo │
├─────────────┤
│ Frame: who │
├─────────────┤
│ Frame: amI │ (lần 1)
├─────────────┤
│ Frame: amI │ (lần 2)
├─────────────┤
│ Frame: amI │ (lần 3) ← %esp trỏ vào đây (top)
└─────────────┘ ← địa chỉ thấp11. Bài Tập Tổng Hợp
Câu hỏi
Cho đoạn assembly sau:
main:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $1, -4(%ebp) ; biến a = 1
movl $2, -8(%ebp) ; biến b = 2
movl $0, -12(%ebp) ; biến c = 0
pushl -4(%ebp) ; push a (= 1) → tham số 2 của function
pushl -8(%ebp) ; push b (= 2) → tham số 1 của function
call function
addl $8, %esp
movl %eax, -12(%ebp) ; c = return value
movl $0, %eax
leave
ret
function:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $10, -4(%ebp) ; a = 10
movl -4(%ebp), %edx ; edx = 10
movl 8(%ebp), %eax ; eax = x (tham số 1 = 2)
addl %eax, %edx ; edx = 10 + 2 = 12
movl 12(%ebp), %eax ; eax = y (tham số 2 = 1)
imull %edx, %eax ; eax = 12 * 1 = 12
movl %eax, -8(%ebp) ; result = 12
movl -8(%ebp), %eax ; return value = 12
leave
retĐáp án
Tổng kết: Stack đóng vai trò trung tâm trong cơ chế gọi hàm ở mức máy — lưu địa chỉ trả về, tham số, biến cục bộ và frame pointer. Hiểu rõ cơ chế này là nền tảng để học buffer overflow, debugging, và reverse engineering.