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 Convention | Truyền tham số | Thứ tự đẩy stack | Dọn dẹp stack | Caller‑saved | Callee‑saved |
|---|---|---|---|---|---|
| _cdecl (x86) | Stack | Phải → Trái | Caller | EAX, ECX, EDX | EBX, ESI, EDI, EBP |
| _stdcall (x86) | Stack | Phải → Trái | Callee | EAX, ECX, EDX | EBX, ESI, EDI, EBP |
| _thiscall (x86) | ECX (this) + Stack | Phải → Trái | Callee | EAX, ECX, EDX | EBX, ESI, EDI, EBP |
| x86 fastcall | ECX, EDX + Stack | Phải → Trái | Callee | EAX, ECX, EDX | EBX, ESI, EDI, EBP |
| x64 fastcall | RCX, RDX, R8, R9; còn lại Stack + Shadow | Phải → Trái | Caller | RAX, RCX, RDX, R8–R11 | RBX, 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]vì[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 3trướcpush 5đểa(5) ở địa chỉ thấp hơnb(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]? Vì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ọnGiả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, 8saucall, callee đã tự dọn - Tại sao
ret 8? - Instructionret 8tương đươngpop 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 stackGiả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 -
avàbvẫ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 objectcalcvào ECX trước khicall- 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ố đầuGiả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
cdqinstruction - Sign-extend EAX (32-bit) vào EDX:EAX (64-bit) trước khi chia, vìidivyê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
callpush return address (8 byte). Total: 32 + 8 = 40 - Caller dọn stack - Sau khi
callreturn, caller phảiadd 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 stackGiả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,
rspcầ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ủarsp(32 mod 16 = 0), nhưng lệnhcallsẽ đẩy Return Address (8 byte) lên stack, làm lệch căn chỉnh. Thêm 8 byte padding trước lệnhcallđả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, Xvớ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ế!