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 bytes

Quá trình thực hiện (3 bước):

  1. Lấy giá trị từ Src
  2. Giảm %esp xuống 4 bytes (%esp -= 4)
  3. 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 bytes

Quá trình thực hiện (3 bước):

  1. Lấy giá trị từ địa chỉ (%esp)
  2. Ghi giá trị vào Dest
  3. Tăng %esp lên 4 bytes (%esp += 4)
; Tương đương với:
movl (%esp), Dest
addl $4, %esp

2.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 %rsp thay vì %esp
  • Lệnh push/pop thay đổi %rsp 8 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àm

Lệ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] = 123

Sau 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ừ 0x8048553

4. 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 ghiVai 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ó)
         └──────────────────┘  ← %esp

4.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
ret

4.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
    ret

5. 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:
OffsetNội dung
%ebp + 0Old %ebp (đã lưu)
%ebp + 4Return address
%ebp + 8Tham số 1
%ebp + 12Tham số 2
%ebp + 16Tham 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
    ret

6. 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
    ret

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

8. 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ứ hai

9. Gọi Hàm trong x86-64

Sự khác biệt chính so với IA32:

Đặc điểmIA32x86-64
Stack pointer%esp%rsp
Frame pointer%ebp%rbp
Đơn vị push/pop4 bytes8 bytes
Truyền tham sốQua stack6 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

graph TD yoo --> who who --> amI_1["amI (lần 1)"] amI_1 --> amI_2["amI (lần 2)"] amI_2 --> amI_3["amI (lần 3)"]

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ấp

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