Calling Conventions trong Windows x86 và x64

Khi debug crash trong Assembly, bạn thấy mov eax, [ebp+8] nhưng không hiểu tại sao lại +8? Hay khi reverse malware, gặp ret 8 vs ret và tự hỏi sự khác biệt? Khi đọc stack trace trong WinDbg, làm sao biết thanh ghi nào chứa tham số? Đó chính là calling convention quyết định cách tham số được truyền, ai dọn stack, và thanh ghi nào an toàn sử dụng.

Bài viết này phân tích 5 calling conventions phổ biến (cdecl, stdcall, thiscall, fastcall x86/x64) với Assembly code thực tế, stack diagrams với địa chỉ cụ thể, và giải thích từng instruction, giúp bạn hiểu rõ từng byte trên stack và debug/reverse chính xác hơn.

#Lưu ý

Bài này giả định bạn đã hiểu stack frame cơ bản (prologue/epilogue, tại sao tham số ở [ebp+8], local variables ở [ebp-4]). Nếu chưa rõ, hãy đọc Hiểu Stack Frame Trong Assembly: Từ Code C++ Đến Memory Address trước.

#1. Các kiểu Calling Convention phổ biến

Calling ConventionTruyền tham sốThứ tự đẩy stackDọn dẹp stackCaller‑savedCallee‑saved
_cdecl (x86)StackPhải → TráiCallerEAX, ECX, EDXEBX, ESI, EDI, EBP
_stdcall (x86)StackPhải → TráiCalleeEAX, ECX, EDXEBX, ESI, EDI, EBP
_thiscall (x86)ECX (this) + StackPhải → TráiCalleeEAX, ECX, EDXEBX, ESI, EDI, EBP
x86 fastcallECX, EDX + StackPhải → TráiCalleeEAX, ECX, EDXEBX, ESI, EDI, EBP
x64 fastcallRCX, RDX, R8, R9; còn lại Stack + ShadowPhải → TráiCallerRAX, RCX, RDX, R8–R11RBX, RBP, RDI, RSI, R12–R15

#1.1. Caller-saved (volatile) registers

Là các thanh ghi mà hàm gọi (callee) có thể sử dụng tự do và làm thay đổi giá trị; nếu caller cần giữ nội dung của chúng sau khi gọi hàm, caller phải chủ động lưu (push) trước và khôi phục (pop) sau.

1
2
3
4
5
6
7
8
; Caller lưu EAX, ECX, EDX trước khi gọi hàm vì callee có thể thay đổi
push eax
push ecx
push edx
call SomeFunction    ; có thể clobber EAX, ECX, EDX
pop edx
pop ecx
pop eax

#1.2. Callee-saved (non-volatile) registers:

Là các thanh ghi mà hàm được gọi (callee) phải giữ nguyên giá trị ban đầu; nếu callee muốn sử dụng chúng, nó phải lưu (push) giá trị cũ lên stack và khôi phục (pop) trước khi trả về.

1
2
3
4
5
6
7
8
9
SomeFunction:
    push ebx
    push esi
    push edi
    ; ... function body sử dụng EBX, ESI, EDI ...
    pop edi
    pop esi
    pop ebx
    ret

#2. Ví dụ minh họa

Xem code Assembly thực tế giúp bạn hiểu rõ sự khác biệt giữa các calling conventions. Mỗi ví dụ dưới đây cho thấy:

  • Cách tham số được truyền - qua stack hay thanh ghi?
  • Ai chịu trách nhiệm dọn stack - caller hay callee?
  • Stack layout cụ thể - tham số ở offset nào so với EBP?
  • Thanh ghi nào được sử dụng - ECX/EDX cho fastcall, ECX cho thiscall

Khi debug hoặc reverse engineering, nhận ra calling convention giúp bạn tìm đúng tham số, hiểu crash xảy ra ở đâu, và trace luồng thực thi chính xác.

#2.1. _cdecl (x86)

#2.1.1. Code

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int __cdecl add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(5, 3);
    std::cout << "add(5, 3) = " << result << std::endl;  // Output: add(5, 3) = 8
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
add:
    ; === PROLOGUE: Thiết lập stack frame ===
    push ebp             ; [1] Lưu EBP của main lên stack
    mov  ebp, esp        ; [2] EBP = ESP (tạo mốc cố định)

    ; === FUNCTION BODY ===
    mov  eax, [ebp+8]    ; eax = tham số a (offset +8 từ EBP)
    add  eax, [ebp+12]   ; eax += tham số b (offset +12 từ EBP)

    ; === EPILOGUE ===
    pop  ebp             ; [3] Khôi phục EBP của main
    ret                  ; [4] Pop return address, nhảy về main

; === CALLER (main) ===
main:
    push 3               ; Đẩy tham số b (right-to-left)
    push 5               ; Đẩy tham số a
    call add             ; CPU tự động push return address
    add  esp, 8          ; CALLER dọn stack (2 params × 4 byte = 8)

Giải thích:

  • [ebp+8] = tham số a - Tham số đầu tiên ở [ebp+8][ebp] = saved EBP, [ebp+4] = return address
  • [ebp+12] = tham số b - Tham số thứ hai ở [ebp+12] (8 + 4 byte)
  • add esp, 8 - Caller dọn stack (2 tham số × 4 byte = 8 byte), đặc trưng của cdecl
  • Right-to-left pushing - push 3 trước push 5 để a (5) ở địa chỉ thấp hơn b (3)
  • Trong thực tế - cdecl cho phép variadic functions (printf, scanf) vì caller biết chính xác số lượng tham số đã push

#2.1.2. Stack/frame

Trước khi gọi hàm (sau push 3; push 5):

1
2
3
4
5
6
7
Địa chỉ cao (High Address)
+------------------------------+
0x0012FF7C:  | 3 (tham số b)    |  ← ESP sau push b
+------------------------------+
0x0012FF78:  | 5 (tham số a)    |  ← ESP = 0x0012FF78
+------------------------------+
Địa chỉ thấp (Low Address)

Sau call add - CPU tự động push Return Address:

1
2
3
4
5
6
7
8
9
Địa chỉ cao (High Address)
+------------------------------+
0x0012FF7C:  | 3 (tham số b)    |
+------------------------------+
0x0012FF78:  | 5 (tham số a)    |
+------------------------------+
0x0012FF74:  | Return Address   |  ← ESP = 0x0012FF74
+------------------------------+
Địa chỉ thấp (Low Address)

Bên trong hàm add (sau prologue push ebp; mov ebp, esp):

1
2
3
4
5
6
7
8
9
10
11
Địa chỉ cao (High Address)
+------------------------------+
0x0012FF7C:  | 3 (tham số b)    |  [ebp+12]
+------------------------------+
0x0012FF78:  | 5 (tham số a)    |  [ebp+8]   ← Tham số đầu tiên
+------------------------------+
0x0012FF74:  | Return Address   |  [ebp+4]
+------------------------------+
0x0012FF70:  | Saved EBP (main) |  [ebp]     ← EBP = 0x0012FF70, ESP = 0x0012FF70
+------------------------------+
Địa chỉ thấp (Low Address)

Bây giờ bạn có thể hiểu:

  • Tại sao [ebp+8]? Vì EBP = 0x0012FF70, nên [ebp+8] = 0x0012FF78 (chứa giá trị 5)
  • Tại sao [ebp+12]?0x0012FF70 + 12 = 0x0012FF7C (chứa giá trị 3)
  • Arguments ở địa chỉ cao hơn EBP (hướng lên: +4, +8, +12…)
  • Stack grows down: Địa chỉ giảm dần khi push (0x0012FF78 → 0x0012FF74 → 0x0012FF70)

#2.2. _stdcall (x86)

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int __stdcall multiply(int a, int b) {
    return a * b;
}

int main() {
    int result = multiply(4, 6);
    std::cout << "multiply(4, 6) = " << result << std::endl;  // Output: multiply(4, 6) = 24
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
multiply:
    ; === PROLOGUE ===
    push ebp             ; [1] Lưu EBP của caller
    mov  ebp, esp        ; [2] EBP = ESP (tạo mốc cố định)

    ; === FUNCTION BODY ===
    mov  eax, [ebp+8]    ; eax = tham số a (4)
    imul eax, [ebp+12]   ; eax *= tham số b (6)

    ; === EPILOGUE ===
    pop  ebp             ; [3] Khôi phục EBP của caller
    ret 8                ; [4] CALLEE dọn stack: pop 8 byte + return

; === CALLER (main) ===
main:
    push 6               ; Đẩy tham số b
    push 4               ; Đẩy tham số a
    call multiply        ; CPU push return address
                         ; Không cần "add esp, 8" vì callee đã dọn

Giải thích:

  • ret 8 - Đặc trưng của stdcall: Callee dọn stack bằng cách pop 8 byte (2 tham số × 4 byte) trước khi return
  • So với cdecl - Caller không cần add esp, 8 sau call, callee đã tự dọn
  • Tại sao ret 8? - Instruction ret 8 tương đương pop eip; add esp, 8 (pop return address rồi skip 8 byte tham số)
  • Win32 API dùng stdcall - Hầu hết Windows API functions (MessageBoxA, CreateFileA, etc.) đều dùng stdcall
  • Trong thực tế - stdcall không hỗ trợ variadic functions vì callee không biết có bao nhiêu tham số để dọn stack

#2.3. _thiscall (x86)

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

class Calculator {
public:
    int subtract(int a, int b) { return a - b; }
};

int main() {
    Calculator calc;
    int result = calc.subtract(10, 3);
    std::cout << "subtract(10, 3) = " << result << std::endl;  // Output: subtract(10, 3) = 7
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Calculator::subtract:
    ; Lúc này: ECX = this pointer (địa chỉ object calc)

    ; === PROLOGUE ===
    push ebp             ; [1] Lưu EBP của caller
    mov  ebp, esp        ; [2] EBP = ESP

    ; === FUNCTION BODY ===
    ; ECX vẫn còn chứa this pointer nếu cần dùng member variables
    mov  eax, [ebp+8]    ; eax = tham số a (10)
    sub  eax, [ebp+12]   ; eax -= tham số b (3)

    ; === EPILOGUE ===
    pop  ebp             ; [3] Khôi phục EBP
    ret 8                ; [4] CALLEE dọn stack (2 params × 4 = 8 byte)

; === CALLER (main) ===
main:
    lea  ecx, [calc]     ; ECX = this pointer (địa chỉ object)
    push 3               ; Đẩy tham số b
    push 10              ; Đẩy tham số a
    call Calculator::subtract
                         ; Không cần "add esp, 8" vì callee dọn stack

Giải thích:

  • ECX = this pointer - Đặc trưng thiscall: con trỏ this được truyền qua thanh ghi ECX (không qua stack)
  • Tham số thông thường vẫn qua stack - ab vẫn push lên stack như stdcall
  • ret 8 - Callee dọn stack giống stdcall (dọn 2 tham số, không dọn ECX vì nó là thanh ghi)
  • lea ecx, [calc] - Load địa chỉ của object calc vào ECX trước khi call
  • Trong thực tế - Compiler tự động dùng thiscall cho non-static member functions trong C++. Nếu hàm cần truy cập member variables, nó sẽ dùng [ecx+offset]

#2.4. x86 fastcall

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int __fastcall divide(int a, int b) {
    return a / b;
}

int main() {
    int result = divide(20, 4);
    std::cout << "divide(20, 4) = " << result << std::endl;  // Output: divide(20, 4) = 5
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
divide:
    ; Lúc này: ECX = 20 (tham số a), EDX = 4 (tham số b)

    ; === PROLOGUE ===
    push ebp             ; [1] Lưu EBP của caller
    mov  ebp, esp        ; [2] EBP = ESP

    ; === FUNCTION BODY ===
    mov  eax, ecx        ; eax = a (20) từ ECX
    cdq                  ; Sign-extend EAX vào EDX (chuẩn bị cho idiv)
    idiv edx             ; eax = eax / edx (20 / 4 = 5)

    ; === EPILOGUE ===
    pop  ebp             ; [3] Khôi phục EBP
    ret                  ; [4] CALLEE dọn stack (không có tham số trên stack)

; === CALLER (main) ===
main:
    mov  ecx, 20         ; ECX = tham số đầu tiên (a)
    mov  edx, 4          ; EDX = tham số thứ hai (b)
    call divide          ; Không cần push/pop stack cho 2 tham số đầu

Giải thích:

  • ECX và EDX cho 2 tham số đầu - x86 fastcall truyền 2 tham số đầu tiên qua ECX (param 1) và EDX (param 2)
  • Tham số thứ 3+ vẫn qua stack - Nếu có thêm tham số, chúng sẽ push lên stack (right-to-left)
  • Nhanh hơn cdecl/stdcall - Không cần push/pop stack cho 2 tham số đầu, giảm memory access
  • cdq instruction - Sign-extend EAX (32-bit) vào EDX:EAX (64-bit) trước khi chia, vì idiv yêu cầu dividend 64-bit
  • Trong thực tế - Hữu ích cho hot path functions (gọi nhiều lần) hoặc functions với ít tham số. Trade-off: clobber ECX/EDX, caller phải save nếu cần giữ giá trị

#2.5. x64 fastcall

#2.5.1. Code

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

// x64 fastcall: RCX = a, RDX = b
int __fastcall add64(int a, int b) {
    return a + b;
}

int main() {
    int r = add64(7, 2);
    std::cout << "add64(7,2) = " << r << std::endl;  // Output: add64(7,2) = 9
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
add64:
    ; Lúc này: RCX = 7 (param a), RDX = 2 (param b)

    ; === PROLOGUE ===
    push    rbp          ; [1] Lưu RBP của caller
    mov     rbp, rsp     ; [2] RBP = RSP

    ; === FUNCTION BODY ===
    mov     rax, rcx     ; rax = a (7) từ RCX
    add     rax, rdx     ; rax += b (2) từ RDX

    ; === EPILOGUE ===
    pop     rbp          ; [3] Khôi phục RBP
    ret                  ; [4] Return về caller

; === CALLER (main) ===
main:
    sub     rsp, 40      ; Cấp phát Shadow Space (32) + padding (8) = 40 byte
    mov     rcx, 7       ; RCX = tham số 1 (a)
    mov     rdx, 2       ; RDX = tham số 2 (b)
    call    add64        ; Gọi hàm
    add     rsp, 40      ; CALLER dọn stack (khôi phục RSP)

Giải thích:

  • RCX, RDX, R8, R9 cho 4 tham số đầu - x64 fastcall (Microsoft x64 ABI) truyền 4 tham số đầu qua thanh ghi
  • Tham số thứ 5+ qua stack - Nếu có thêm tham số, push lên stack (offset [rsp+32], [rsp+40], etc.)
  • Shadow Space (32 byte) - Caller PHẢI cấp phát 32 byte trên stack trước call, cho callee dùng làm home space cho 4 thanh ghi tham số
  • Padding 8 byte - Đảm bảo stack alignment 16-byte sau khi call push return address (8 byte). Total: 32 + 8 = 40
  • Caller dọn stack - Sau khi call return, caller phải add rsp, 40 để khôi phục stack
  • Trong thực tế - x64 fastcall nhanh hơn nhiều so với x86 (4 tham số qua thanh ghi vs 0 hoặc 2 trong x86). Tất cả x64 code trên Windows đều dùng calling convention này

#2.5.2. Stack/frame

1
2
3
4
5
6
7
8
9
10
11
12
Stack (x64) after sub rsp,40:
 +-----------------+  <-- RSP+0  Shadow Space
 | Shadow[0]       |
 | Shadow[1]       |
 | Shadow[2]       |
 | Shadow[3]       |
 +-----------------+  <-- RSP+32 Alignment
 | Alignment pad   |
 +-----------------+  <-- RSP+40
 | Return Address  |  <-- [RSP]
 +-----------------+
 | ...             |

#2.5.3. Ví dụ minh họa Shadow Space

1
2
3
4
5
6
; Windows x64: cấp phát 32 byte Shadow Space + 8 byte để căn chỉnh 16-byte boundary
sub     rsp, 40        ; 32 (Shadow Space) + 8 (padding)
mov     rcx, 1         ; Tham số 1 (RCX)
mov     rdx, 2         ; Tham số 2 (RDX)
call    SomeFunction   ; Gọi hàm, callee có thể dùng Shadow Space
add     rsp, 40        ; Khôi phục stack

Giải thích:

  • Shadow Space (32 byte): Theo chuẩn Microsoft x64 ABI, caller phải cấp 32 byte trên stack trước khi call để callee sử dụng làm home space cho bốn tham số đầu truyền qua thanh ghi.
  • Padding 8 byte: Sau khi cấp Shadow Space, rsp cần chia hết cho 16 để đáp ứng yêu cầu căn chỉnh của ABI (đồng bộ với SSE và tối ưu CPU). Vì 32 byte Shadow Space không làm thay đổi modulo 16 của rsp (32 mod 16 = 0), nhưng lệnh call sẽ đẩy Return Address (8 byte) lên stack, làm lệch căn chỉnh. Thêm 8 byte padding trước lệnh call đảm bảo Return Address sẽ nằm ở địa chỉ chia hết cho 16.

#3. Thanh ghi ESP, EBP

#3.1. ESP & EBP (x86) – ESP trỏ đỉnh stack, EBP làm frame pointer

  • ESP (Stack Pointer): luôn trỏ đến đỉnh stack dùng cho push/pop, thay đổi sau mỗi thao tác.
  • EBP (Base Pointer): làm mốc cố định để truy cập biến cục bộ và tham số, không đổi trong suốt hàm.
1
2
3
4
5
6
7
8
push ebp
mov  ebp, esp      ; EBP = ESP
sub  esp, 0x10     ; cấp phát local
mov  dword ptr [ebp-4], 0 ; local var x
mov  dword ptr [ebp-8], 1 ; local var y
mov  esp, ebp      ; khôi phục stack
pop  ebp
ret

#3.2. RSP & RBP (x64) – RSP trỏ đỉnh stack, RBP làm frame pointer

  • RSP (Stack Pointer): tương tự ESP nhưng 64‑bit.
  • RBP (Base Pointer): tương tự EBP nhưng 64‑bit.
1
2
3
4
5
6
7
push rbp
mov  rbp, rsp      ; RBP = RSP
sub  rsp, 0x20     ; cấp phát local 32 byte
; ... code sử dụng local ...
mov  rsp, rbp      ; khôi phục stack
pop  rbp
ret

#4. Lỗi phổ biến & cách khắc phục

  • Quên add esp, X với _cdecl: stack lệch, crash ở lần gọi hàm tiếp theo.
  • Nhầm thanh ghi truyền tham số: ví dụ dùng EAX thay vì ECX cho _thiscall → sai logic.

Lưu ý: Đảm bảo luôn cân nhắc Caller‑saved và Callee‑saved khi viết code Assembly để tránh mất dữ liệu quan trọng.

Chọn calling convention nào?

View Mermaid diagram code
flowchart TD
    Start([Nhận diện convention từ Assembly]) --> Check1{Thấy gì?}

    Check1 -->|"RCX, RDX, R8, R9<br/>sub rsp, 40"| X64[x64 fastcall]
    Check1 -->|"ret 8/ret 12<br/>(callee dọn)"| Stdcall[stdcall - Win32 API]
    Check1 -->|"add esp, X sau call<br/>(caller dọn)"| Cdecl[cdecl - variadic]
    Check1 -->|"ECX = this pointer"| Thiscall[thiscall - C++ method]
    Check1 -->|"ECX/EDX cho params"| Fastcall[x86 fastcall]

Vậy là bạn đã hiểu 5 calling conventions chính trong Windows x86/x64: cdecl (caller dọn stack, cho variadic functions), stdcall (callee dọn stack, dùng cho Win32 API), thiscall (ECX = this pointer cho C++ methods), x86 fastcall (2 tham số qua ECX/EDX), và x64 fastcall (4 tham số qua RCX/RDX/R8/R9 + Shadow Space 32 byte). Mỗi convention quyết định ai dọn stack, thanh ghi nào dùng cho tham số, và offset nào trên stack - ảnh hưởng trực tiếp đến cách bạn đọc disassembly và debug crash.

Đặc biệt khi reverse malware hoặc debug crash dumps trong WinDbg, việc nhận biết calling convention giúp bạn tìm đúng tham số (nhìn vào ECX/EDX/stack), trace luồng thực thi (biết return address ở đâu), và hiểu stack corruption (khi ESP bị lệch). Hãy mở IDA/Ghidra và phân tích một DLL bất kỳ - xem function prologue/epilogue và stack layout - bạn sẽ thấy rõ các pattern này trong code thực tế!