어셈블리어에서 함수의 매개변수를 말할 때, 레지스터와 스택 이야기가 나오는 부분이 헷갈려서 정리해보고자 한다.
출처: https://www.geeksforgeeks.org/cpp/calling-conventions-in-c-cpp/
'함수 호출 규약' 이름에 힌트가 있듯, 함수를 어떻게 호출할지에 관한 규칙들을 의미한다. 함수 호출 규약을 이해하기 위해 선행되어야 하는 지식이 있다. 바로 caller와 callee!
caller 와 callee
요 놈들도 이름에 힌트가 있듯이, subroutine을 부르는 함수측을 caller라고 하고, caller에게 불리는 함수를 callee라고 한다.
int myFunc(void) {
return 100;
}
int main(void) {
myFunc();
return 0;
}
요런 코드가 있다고 하면, main()이 caller, myFunc가 callee가 되는 것!
calling convention(함수 호출 규약)
C와 C++에서 함수 호출 규약은 다음 세가지를 결정하는 가이드라인이다.
- 인수가 stack으로 어떻게 넘겨지는지
- caller와 callee중 누가 stack을 정리할 것인지
- 어떤 레지스터를 어떻게 쓸 것인지
함수 호출 규약은 반환형과 함수이름 사이에 들어가며, __cdecl, __stdcall, __fastcall, __thiscall(C++용) 등 여러 규약이 있는데, 어떤 규약을 사용하느냐에 따라서 다른 어셈블리 코드가 생성된다.
반환형 calling_convention 함수이름{
구현...
}
calling convention 예시 (코드 출처)
우선 각각의 함수 호출 규약을 적용한 코드를 보고 3가지 규약(__thiscall 빼고)의 특징에 대해 알아보자.
// C++ Program to demonstrate the calling convention
#include <iostream>
// __cdecl calling convention
int __cdecl cdeclAdd(int a, int b)
{
int c = a + b;
return c;
}
// __stdcall calling convention
int __stdcall stdcallAdd(int a, int b)
{
int c = a + b;
return c;
}
// __fastcall calling convention
int __fastcall fastcallAdd(int a, int b, int c, int d)
{
int e = a + b + c + d;
return e;
}
// __thiscall calling convention
class Temp {
public:
int __thiscall thiscallAdd(int a, int b)
{
int c = a + b;
return c;
}
};
// driver code
int main()
{
int result;
Temp obj;
// Function calls and output
result = cdeclAdd(1, 2);
std::cout << "Result: " << result << std::endl;
result = stdcallAdd(3, 4);
std::cout << "Result: " << result << std::endl;
result = fastcallAdd(7, 8, 9, 10);
std::cout << "Result: " << result << std::endl;
result = obj.thiscallAdd(5, 6);
std::cout << "Result: " << result << std::endl;
}
결과!
Result: 3
Result: 7
Result: 34
Result: 11
1. __cdecl (C declaration)
__cdecl 규약은 C/C++의 기본 규약으로 다음의 특징을 갖는다.
- 인자는 오른쪽에서 왼쪽 순으로 stack에 push된다(제일 왼쪽 인자가 stack 최상단에 올 수 있도록).
- caller가 stack을 정리한다.
- __stdcall보다 더 큰 실행 파일을 생성한다. 왜냐하면 각 함수 호출마다 스택 정리 코드가 포함되어야 하기 때문이다.
caller가 스택을 정리하기 때문에, __cdecl 호출 규약을 사용하는 함수에는 가변 인자(variable arguments)를 전달할 수 있다.
바로 윗 문장이 무슨 말이냐면,
printf("값은 %d입니다.", x);
printf("hello");
printf("%d %d %d", a, b, c);
위 printf처럼 인자 개수가 매번 다른 함수를 가변 인자 함수라고 하는데, 이 가변 인자 함수는 컴파일 타임에 정확한 인자의 개수나 크기를 특정할 수 없다. 따라서 stack에 몇 byte를 pop해야 하는지를 알 수 없다. 그래서 stack에 push한 크기를 정확히 알고 있는 caller가 정리하는 __cdecl 호출 규약을 사용하면 가변 인자를 전달할 수 있는 것이다!
위의 코드를 Debug모드로 컴파일해 어셈블리코드를 확인해보면 다음과 같다.
(다시 한번 코드의 출처는 https://www.geeksforgeeks.org/cpp/calling-conventions-in-c-cpp/ 입니다.)
cdeclAdd()
?cdeclAdd@@YAHHH@Z PROC ; cdeclAdd, COMDAT
; File c:\users\ruchit\documents\code\visual studio\visual studio\source.cpp
; Line 6
push ebp
mov ebp, esp
sub esp, 204 ; 000000ccH
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-204]
mov ecx, 51 ; 00000033H
mov eax, -858993460 ; ccccccccH
rep stosd
mov ecx, OFFSET __F81044A6_source@cpp
call @__CheckForDebuggerJustMyCode@4
; Line 7
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
mov DWORD PTR _c$[ebp], eax
; Line 8
mov eax, DWORD PTR _c$[ebp]
; Line 9
pop edi
pop esi
pop ebx
add esp, 204 ; 000000ccH
cmp ebp, esp
call __RTC_CheckEsp
mov esp, ebp
pop ebp
ret 0
?cdeclAdd@@YAHHH@Z ENDP ; cdeclAdd
cdeclAdd() 함수의 어셈블리 코드를 보면, 마지막 명령어는 `ret 0` 이다.
이는 callee가 스택 포인터에 대해 아무 작업도 하지 않고, 제어를 main()으로 반환한다는 의미이다.
main()::result = cdeclAdd(1,2):
line 42:
push 2
push 1
call ?cdeclAdd@@YAHHH@Z ; cdeclAdd
add esp, 8
mov DWORD PTR _result$[ebp], eax
main()의 어셈블리 코드를 확인해보면, 함수 호출 이후에 `add esp, 8`이라는 명령어가 있다.
제어가 main()으로 돌아오면, 스택포인터(esp)에 8byte를 더하기 위함이다.
메모리에서 값을 add하는 것은 스택에서 값을 pop하거나, 스택을 정리한다는 의미이다. 반대로, 스택포인터(esp)에 값을 sub(빼는)것은 스택에 값을 넣는다는 의미이다. 왜냐하면 스택은 주소가 작아지는 방향으로 자라나기 때문에..!
참고로,
여기서 esp에 8바이트를 더한 이유는, 전달된 2개의 int 변수를 스택에서 제거하기 때문이다. 32비트 환경에서 int는 4byte이기 때문에, 총 2개 8byte를 위한 스택 공간을 정리한 것.
2. __stdcall
이 규약은 Win32 API 함수들에서 쓰이는 마이크로소프트 특정 호출 규약이다. 특징은 다음과 같다.
- 인자는 오른쪽에서 왼쪽 순으로 stack에 push된다(__cdecl과 동일).
- Callee가 stack을 정리한다.
main()::result = stdcallAdd(3, 4);
line 45:
push 4
push 3
call ?stdcallAdd@@YGHHH@Z ; stdcallAdd
mov DWORD PTR _result$[ebp], eax
cdeclAdd()처럼, 인자는 우측의 4가 먼저 push됨을 확인할 수 있다.
아까 __cdecl 과는 다르게, 스택 포인터(esp)를 호출하지 않음을 알 수 있다.
stdcallAdd()
?stdcallAdd@@YGHHH@Z PROC ; stdcallAdd, COMDAT
; File c:\users\ruchit\documents\code\visual studio\visual studio\source.cpp
; Line 13
push ebp
mov ebp, esp
sub esp, 204 ; 000000ccH
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-204]
mov ecx, 51 ; 00000033H
mov eax, -858993460 ; ccccccccH
rep stosd
mov ecx, OFFSET __F81044A6_source@cpp
call @__CheckForDebuggerJustMyCode@4
; Line 14
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
mov DWORD PTR _c$[ebp], eax
; Line 15
mov eax, DWORD PTR _c$[ebp]
; Line 16
pop edi
pop esi
pop ebx
add esp, 204 ; 000000ccH
cmp ebp, esp
call __RTC_CheckEsp
mov esp, ebp
pop ebp
ret 8
?stdcallAdd@@YGHHH@Z ENDP ; stdcallAdd
이번엔 stdcallAdd() 정의부를 컴파일한 결과를 봐보자. 마지막 명령어가 `ret 8`로 끝난다. 이 명령어는 intel의 ISA로, 두가지 동작을 한번에 수행한다. 복귀 주소를 pop하고, 스택포인터에 8byte를 더해 전달된 인자를 제거한다(출처 stackoverflow).
다시말해, callee에서 스택을 정리하는 것!
3. __fastcall
__fastcall 호출 규약에선, 가능하다면 인자가 레지스터로 전달된다.
- 첫 두 인자가 ECX, EDX 레지스터로 전달된다. 남은 인자들은 다른 규약처럼 오른쪽에서 왼쪽 순으로 스택에 push 된다.
- Callee가 스택을 정리한다.
main()::return = fastcallAdd(7, 8, 9, 10);
; Line 48
push 10 ; 0000000aH
push 9
mov edx, 8
mov ecx, 7
call ?fastcallAdd@@YIHHHHH@Z ; fastcallAdd
mov DWORD PTR _result$[ebp], eax
main함수의 fastcallAdd() 호출부 어셈블리어를 봐 보자. 첫 두 인자는 ecx, edx레지스터로 각각 이동되었다. 그리고 우측에서부터 stack에 push되었음을 알 수 있다.
과연 __stdcall 처럼 callee가 스택을 잘 정리하는지도 확인해보자.
fastcallAdd()
?fastcallAdd@@YIHHHHH@Z PROC ; fastcallAdd, COMDAT
; _a$ = ecx
; _b$ = edx
; File c:\users\ruchit\documents\code\visual studio\visual studio\source.cpp
; Line 20
push ebp
mov ebp, esp
sub esp, 228 ; 000000e4H
push ebx
push esi
push edi
push ecx
lea edi, DWORD PTR [ebp-228]
mov ecx, 57 ; 00000039H
mov eax, -858993460 ; ccccccccH
rep stosd
pop ecx
mov DWORD PTR _b$[ebp], edx
mov DWORD PTR _a$[ebp], ecx
mov ecx, OFFSET __F81044A6_source@cpp
call @__CheckForDebuggerJustMyCode@4
; Line 21
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
add eax, DWORD PTR _c$[ebp]
add eax, DWORD PTR _d$[ebp]
mov DWORD PTR _e$[ebp], eax
; Line 22
mov eax, DWORD PTR _e$[ebp]
; Line 23
pop edi
pop esi
pop ebx
add esp, 228 ; 000000e4H
cmp ebp, esp
call __RTC_CheckEsp
mov esp, ebp
pop ebp
ret 8
?fastcallAdd@@YIHHHHH@Z ENDP ; fastcallAdd
함수 정의부의 마지막을 보면 역시 `ret 8`로 stack에 push된 2개의 int인자를 정리하는 것을 알 수 있다.
두 인자는 ecx, edx 레지스터에 담았기 때문에 8yte(2개)만 pop하는 것!
calling convention의 이점
C/C++에서 호출 규약이 가지는 이점은 다음과 같다.
- 호출 규약은 컴파일러가 함수를 호출하고 매개변수를 전달하는 방식을 표준화하는데 도움을 준다.
- 표준화는 프로그래밍 언어 간의 상호 운용성을 가능하게 한다.
- 호출 규약은 요구사항에 맞게 함수를 호출할 수 있도록 효율적인 방법을 구현한다.
'소프트웨어 > C' 카테고리의 다른 글
| [C언어] C의 파일 입출력 (0) | 2025.12.03 |
|---|---|
| [C] 매크로 함수 (0) | 2025.11.20 |
| [C언어] const 키워드 (0) | 2025.08.24 |
| [C] void* 포인터 (0) | 2025.08.04 |
| [C] 헤더파일에 대하여 (0) | 2025.08.02 |