Calling Conventions trong Windows x86 và x64

Khi lập trình với C++ hay Assembly, việc hiểu rõ các Calling Convention (quy ước gọi hàm) là cực kỳ quan trọng, bởi nó quyết định cách tham số được truyền vào hàm, cách quản lý stack cũng như cách các thanh ghi được lưu trữ trước và sau khi gọi hàm, giúp giảm lỗi, tối ưu hiệu suất và hỗ trợ quá trình debug hiệu quả.

Calling Convention định nghĩa cách hàm nhận tham số và trả về giá trị; mỗi quy ước có cách tổ chức stack và sử dụng thanh ghi khác nhau, ảnh hưởng trực tiếp đến tính tương thích giữa các module và hiệu suất chương trình.

#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

#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
add:
push ebp
mov ebp, esp
mov eax, [ebp+8]
add eax, [ebp+12]
pop ebp
ret

main:
push 3
push 5
call add
add esp, 8

#2.1.2. Stack/frame

Trước khi gọi hàm:

1
2
3
4
5
6
Đỉnh stack
+-----------------+
| Tham số a | <-- ESP (push a)
+-----------------+
| Tham số b | <-- ESP+4 (push b)
+-----------------+

Khi thực hiện call add: CPU tự động đẩy Return Address lên stack

1
2
3
4
5
6
7
8
Đỉnh stack
+-----------------+
| Return Address | <-- ESP+0
+-----------------+
| Tham số a | <-- ESP+4
+-----------------+
| Tham số b | <-- ESP+8
+-----------------+

Bên trong hàm add (với frame prologue push ebp; mov ebp, esp):

1
2
3
4
5
6
7
8
9
10
11
+-----------------+
| Local variables |
+-----------------+
| old EBP | <- [EBP]
+-----------------+
| Return Address | <- [EBP+4]
+-----------------+
| Tham số a | <- [EBP+8]
+-----------------+
| Tham số b | <- [EBP+12]
+-----------------+

#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
multiply:
push ebp
mov ebp, esp
mov eax, [ebp+8]
imul eax, [ebp+12]
pop ebp
ret 8

main:
push 6
push 4
call multiply

#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
Calculator::subtract:
push ebp
mov ebp, esp
mov eax, [ebp+8] ; this pointer trong ECX
sub eax, [ebp+12]
pop ebp
ret 8

main:
lea ecx, [calc] ; this pointer
push 3
push 10
call Calculator::subtract

#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
divide:
push ebp
mov ebp, esp
mov eax, ecx ; a trong ECX
cdq
idiv edx ; b trong EDX
pop ebp
ret

main:
mov ecx, 20
mov edx, 4
call divide

#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
add64:
push rbp
mov rbp, rsp
mov rax, rcx
add rax, rdx
pop rbp
ret

main:
sub rsp, 40
mov rcx, 7
mov rdx, 2
call add64
add rsp, 40

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

#4. Thanh ghi ESP, EBP

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

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

#5. 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.

#6. Bài tập tự kiểm tra

  1. Viết hàm _thiscall trừ hai số, dịch sang Assembly, truyền this qua ECX.
  2. So sánh _cdeclx64 fastcall về truyền tham số và cleanup stack.

#7. Kết luận

Nắm vững Calling Convention giúp bạn:

  • Viết mã rõ ràng, dễ bảo trì.
  • Debug và reverse‑engineering chính xác.
  • Tối ưu hiệu suất thực tế.