C++

__stdcall과 __cdecl

Keisa 2024. 1. 26. 10:12

__cdecl에 대한 마소의 설명문

 

__cdecl은 C 및 C++ 프로그램의 기본 호출 규칙입니다. 스택은 호출자에 의해 정리되므로 vararg 기능을 수행할 수 있습니다. __cdecl 호출 규칙은 각 함수 호출에 스택 정리 코드를 포함해야 하기 때문에 __stdcall보다 더 큰 실행 파일을 생성합니다. 다음 목록은 이 호출 규칙의 구현을 보여줍니다. __cdecl 수정자는 Microsoft에만 적용됩니다.

 

변수나 함수 이름 앞에 __cdecl 수식자를 붙입니다. C 명명 규칙과 호출 규칙이 기본이므로 x86 코드에서 __cdecl을 사용해야 하는 경우는 /Gv(vectorcall), /Gz(stdcall) 또는 /Gr(fastcall) 컴파일러 옵션을 지정한 경우뿐입니다. /Gd 컴파일러 옵션은 __cdecl 호출 규칙을 강제로 적용합니다.

ARM과 x64 프로세서에서 __cdecl은 허용되지만 일반적으로 컴파일러에 의해 무시됩니다. ARM과 x64에 대한 규칙에 따라 인수는 가능하면 레지스터에 전달되고 이후 인수는 스택에 전달됩니다. x64 코드에서 __cdecl을 사용하여 /Gv 컴파일러 옵션을 재정의하고 기본 x64 호출 규칙을 사용합니다.

정적이 아닌 클래스 함수의 경우 함수가 Out-of-line으로 정의된 경우 호출 규칙 수식자를 Out-of-line 정의에 지정할 필요가 없습니다. 즉 클래스 비정적 멤버 메서드의 경우 선언 중에 지정된 호출 규칙이 정의 지점에서 가정됩니다. 클래스 정의가 주어지면 다음과 같습니다:

 

 

__stdcall에 대한 마소의 설명문


__stdcall 호출 규칙은 Win32 API 함수를 호출하는 데 사용됩니다. 호출 수신자가 스택을 정리하므로 컴파일러는 vararg 함수를 __cdecl로 만듭니다. 이 호출 규칙을 사용하는 함수에는 함수 프로토타입이 필요합니다. __stdcall 수정자는 Microsoft에만 해당됩니다.


vararg은 가변인수를 사용하기 위해 어셈블러에서 사용.  vararg는 가변인자 함수라는 뜻.

Vararg (Variable Arguments)**는 C와 C++에서 함수가 고정된 개수의 인수 대신 가변 개수의 인수를 받을 수 있도록 하는 기능입니다. 주로 printf와 같은 함수에서 볼 수 있습니다. 이를 위해 C 표준 라이브러리에서는 stdarg.h 헤더 파일을 제공합니다.

기본 사용 방법

1. 헤더 파일 포함

#include <stdarg.h>

 

2. va_list 선언

가변 인수 목록을 저장할 변수를 선언합니다.

va_list args;

 

3. va_start 매크로

가변 인수 목록의 처리를 시작합니다. 첫 번째 인수는 고정 인수 중 마지막 인수입니다.

va_start(args, last_fixed_arg);

 

4. va_arg 매크로

다음 인수를 가져옵니다. 이때 타입을 명시해야 합니다.

type arg = va_arg(args, type);

 

5. va_end 매크로

가변 인수 목록 처리를 종료합니다.

va_end(args);

예제 코드

다음은 가변 인수를 사용하는 함수의 예제입니다. 이 함수는 전달된 정수 인수들을 모두 더합니다.

#include <iostream>
#include <stdarg.h>

int sum(int count, ...) {
    int total = 0;

    // va_list 선언
    va_list args;

    // 가변 인수 목록 처리 시작
    va_start(args, count);

    // count 만큼 반복하며 인수들을 더함
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);
    }

    // 가변 인수 목록 처리 종료
    va_end(args);

    return total;
}

int main() {
    std::cout << "Sum of 1, 2, 3: " << sum(3, 1, 2, 3) << std::endl; // 출력: 6
    std::cout << "Sum of 5, 10, 15: " << sum(3, 5, 10, 15) << std::endl; // 출력: 30
    return 0;
}

주의사항

  • 가변 인수의 타입과 개수를 호출자가 정확히 알고 있어야 합니다.
  • 잘못된 타입을 전달하면 정의되지 않은 동작이 발생할 수 있습니다.
  • 가변 인수를 사용할 때는 va_start와 va_end를 항상 쌍으로 사용해야 합니다.

이와 같이 vararg를 사용하면 함수가 유연하게 다양한 인수를 처리할 수 있게 됩니다. 하지만 안전성 문제 때문에 사용할 때 주의가 필요합니다.

 

 

아래는 설명이 좋아서 퍼온 내용

 

출처: https://mattlee.tistory.com/77

 

 함수 호출 규칙(calling convention)은 호출자 함수가 피호출자 함수를 호출하는 과정에서 매개변수를 전달하는 순서 및 매개변수가 사용한 메모리 관리방법 등에 관한 규칙이다. 대표적으로 __cdecl, __stdcall, __fastcall 등 세 가지 정도가 있는데 이 세 가지가 C 언어의 표준에서 정의하는 것은 아니다. 모두 약간씩 차이가 있는데 C/C++ 컴파일러의 기본 함수 호출 규칙은 __cdecl이다.

 

 우리가 자동변수를 선언할 때 auto를 생략해도 되는 것처럼 __cdecl도 생략할 수 있다. 따라서 아무것도 기술하지 않으면 함수 호출 규칙은 __cdecl이다. 제대로 표시하자면 함수의 반환 자료형과 이름 사이에 아래와 같은 호출 규칙이 명시되어야 한다.

1
2
3
4
5
6
7
#include <stdio.h>
 
int __cdecl main(void)
{
  printf("Hello world!\n");
  return 0;
}

 세 함수 호출 규약의 특징을 요약하면 아래의 표와 같다.

 

 호출 규칙 매개변수 스택 정리  매개변수 메모리 
__cdecl Caller  Stack 
__stdcall  Callee  Stack 
__fastcall  Callee  Stack + Register 

 

 

# __cdecl


 

 

 프로젝트의 속성에서 '[구성 속성] -> [C/C++] -> [고급]'의 '호출 규칙' 항목을 보면 기본 설정이 __cdecl로 되어 있다. 따라서 별도로 명시하지 않았을 때 모든 함수 호출 규칙은 __cdecl인 것이다.

 

 __cdecl 호출 규칙은 매개변수를 오른쪽부터 스택에 Push한다. 그리고 매개변수로 인해 증가한 스택을 호출자 함수가 본래 크기로 줄인다. 자동변수는 모두 스택 영역 메모리를 사용한다. 메모리가 자동으로 관리된다는 말은 결국 사용되는 스택의 영역이 늘거나 줄어드는 것에 불과하다.

 

 힙 영역 메모리처럼 운영체제에 반환되는 형태로 관리되는 것이 아니다. 아래 코드의 gMax( )함수는 int형 매개변수 세 개를 받아 그 중 최대값을 반환해주는 함수다. 따라서 매개변수로 인해 증가하는 스택의 크기는 12바이트이다. // 4바이트 int 변수 * 3 = 12바이트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
 
int __cdecl gMax(int a, int b, int c)
{
  int nMax = a;
  if (b > nMax)
    nMax = b;
  if (c > nMax)
    nMax = c;
 
  return nMax;
}
 
int __cdecl main(void)
{
  int nResult = 0;
  nResult = gMax(1, 2, 3);
  return 0;
}

 위의 코드를 작성하여 디스어셈블리 화면을 확인하면 아래와 같은 내용을 확인할 수 있는데, 잘 보면 gMax( )함수를 호출하는 부분에서 오른쪽 인수 3부터 스택에 Push하는 것을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
       int nResult = 0;
0003171E  mov         dword ptr [nResult],0
       nResult = gMax(1, 2, 3);
00031725  push        3
00031727  push        2
00031729  push        1
0003172B  call        _gMax (03126Ch)
00031730  add         esp,0Ch
00031733  mov         dword ptr [nResult],eax
       return 0;
00031736  xor         eax,eax

그리고 call 연산으로 gMax( )함수를 호출한 후 이 함수가 반환하면 main( )함수 내부에서는 add esp, 0Ch라는 연산을 수행한다. 여기서 '0Ch'는 0x0C 즉, 10진수 12를 의미하며, esp(extended stack pointer)는 스택 메모리에 대한 '포인터'이다. 포인터에 대해 ADD 연산을 수행하므로 주소값이 증가한다. // 스택의 주소가 증가하는 건 스택이 감소한다는 걸 뜻한다. 상식적으로 생각할 때 이해가 안될 수 있지만 실제로 스택은 쌓일 때마다 주소가 감소한다. 먼저 한계 선을 긋고 그 안에서 왔다갔다 하는 개념이기 때문에 이렇게 설계가 되었다고 생각하면 된다.

 

 위의 코드에서는 12바이트(4바이트 int형 변수 3개)만큼 증가한다. 주소가 증가했다는 건 스택의 감소를 의미한다. 즉, main( )함수에 들어가는 이 한줄의 코드로 스택은 gMax( )함수호출 전 상태로 복원되는 것이다. 이러한 점 때문에 자동변수 메모리는 자동으로 관리된다는 말을 할 수 있는 것이다.

 

 

# __stdcall


 __stdcall 호출 규칙 또한 __cdecl 호출 규칙처럼 매개변수를 오른쪽부터 스택에 Push 한다. 그러나 매개변수로 인해 증가한 스택을 호출자 함수가 정리하는 것이 아니라 피호출자 함수가 정리한다. // 피호출자 함수를 어렵게 생각할 것 없이 불려지는 함수라고 생각하면 된다. A called by B 라는 문장이 있다면 A가 피호출자 함수가 된다.

 

 이 역시도 어셈블리를 살펴봄으로써 확인할 수 있는데, 아래의 코드의 어셈블리는 좀 복잡한 관계로 호출자 함수의 어셈블리에서 스택을 정리하는 코드가 사라졌다는 정도만 확인하려 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h&g;
 
int __stdcall gMax(int a, int b, int c)
{
  int nMax = a;
  if (b > nMax) nMax = b;
  if (c > nMax) nMax = c;
  return nMax;
}
 
int __cdecl main(void)
{
  int nResult = 0;
  nResult = gMax(1, 2, 3);
  return 0;
}

 main( ) 함수__cdecl 호출 규칙을 사용하지만, gMax( ) 함수는 __stdcall 호출 규칙을 사용하도록 명시했다. 따라서 main( )함수가 gMax( )를 호출하면서 증가한 12바이트의 스택 메모리는 피호출자 함수인 gMax( )에 의해 정리될 것이다. 그러므로 앞서 살펴봤던 add esp, 0Ch 는 main( ) 함수의 어셈블리에 존재하지 않을 것이다.

1
2
3
4
5
6
7
8
9
10
11
  int nResult = 0;
00F7171E  mov         dword ptr [nResult],0 
  nResult = gMax(1, 2, 3);
00F71725  push        3 
00F71727  push        2 
00F71729  push        1 
00F7172B  call        _gMax@12 (0F71073h) 
00F71730  mov         dword ptr [nResult],eax 
  return 0;
00F71733  xor         eax,eax 
}

gMax( ) 함수를 호출한 이후로 mov dword ptr [nResult], eax 라는 연산이 수행되었는데 이는 gMax( ) 함수가 반환한 값을 nResult에 대입하는 C 코드에 대한 어셈블리 코드다. call 연산과 mov 연산 사이에 스택을 정리하는 코드가 더는 보이지 않는다. gMax( ) 함수 내부에서 스택을 정리하기 때문이다.

 

 

# __fastcall


  __fastcall은 __stdcall 처럼 매개변수는 오른쪽부터 스택에 Push하고 피호출자 함수가 스택을 정리한다. 단, 매개변수가 여러 개면 가장 나중에 Push 되어야 할 왼쪽 첫 번째, 두 번째 매개변수는 스택 대신 CPU의 레지스터(EDX, ECX)를 이용해 전달한다. 따라서 매개변수가 메모리에 복사되는 횟수를 줄이고, 이 효과로 연산속도가 일부 향상될 수 있다. // 레지스터에 대해선 OS 2일차 게시물을 살펴보도록 하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
 
int __fastcall gMax(int a, int b, int c)
{
  int nMax = a;
  if (b > nMax) nMax = b;
  if (c > nMax) nMax = c;
  return nMax;
}
 
int __cdecl main(void)
{
  int nResult = 0;
  nResult = gMax(1, 2, 3);
  return 0;
}
</stdio.h>
1
2
3
4
5
6
7
8
9
10
11
  int nResult = 0;
00B6171E  mov         dword ptr [nResult],0 
  nResult = gMax(1, 2, 3);
00B61725  push        3 
00B61727  mov         edx,2 
00B6172C  mov         ecx,1 
00B61731  call        @gMax@12 (0B6134Dh) 
00B61736  mov         dword ptr [nResult],eax 
  return 0;
00B61739  xor         eax,eax 
}

 위 예제의 디스어셈블리 (아래 코드)를 보면 왼쪽 첫 번째, 두 번째 인수인 1과 2는 각각 ECX, EDX 레지스터로 전달되고 오른쪽 첫 번째인 3은 스택에 Push 되었다. 그리고 __stdcall과 마찬가지로 호출자 함수에서 스택을 정리하는 코드도 보이지 않는다.

 

/* C++에는 이 세 가지 외에 '__thiscall'이란 함수 호출 규칙이 하나 더 있다. 이 호출 규칙은 객체의 멤버함수(method)를 호출하는 것에 관련된 호출 규칙이다. 존재한다는 사실만 일단 알아두자. */

 

/* 또한 우리가 이렇게 일일이 호출 규칙을 정해줄 일은 거의 없다고 봐도 된다. 컴파일러가 알아서 최적화를 시켜주기 때문이다. */

 

피호출자(Callee)가 스택을 정리하는 방식이 성능에 유리할 수 있는 이유는 함수 호출 빈도가 높은 상황에서 코드의 크기와 캐시 효율성 측면에서 몇 가지 이점이 있기 때문입니다. 이를 좀 더 구체적으로 설명하겠습니다.

1. 코드 크기 감소

  • 호출자 정리 방식 (__cdecl): 호출자가 스택을 정리할 때, 각 함수 호출 후 호출자 코드에 스택 정리 명령어가 추가됩니다. 이는 함수 호출이 빈번할 경우 코드 크기를 증가시킬 수 있습니다.
  • 피호출자 정리 방식 (__stdcall): 피호출자가 스택을 정리할 때, 스택 정리 코드가 호출된 함수 내부에 포함됩니다. 이는 호출자가 여러 번 같은 함수를 호출해도 호출자 측의 추가 코드가 필요 없으므로 코드 크기를 줄일 수 있습니다.

2. 캐시 효율성 향상

  • 명령어 캐시(I-Cache): 코드 크기가 줄어들면 명령어 캐시의 효율성이 증가합니다. 명령어 캐시는 CPU가 실행할 명령어를 저장하는 고속 메모리입니다. 코드 크기가 작을수록 더 많은 명령어가 캐시에 적재될 수 있어, 캐시 히트율이 증가하고 성능이 향상될 수 있습니다.
    • 호출자 정리 방식: 호출자가 매번 스택 정리 코드를 포함하므로 코드 크기가 증가하고, 캐시 미스가 발생할 확률이 높아집니다.
    • 피호출자 정리 방식: 스택 정리 코드가 피호출자에 포함되어 있어 코드 크기가 줄어들고, 명령어 캐시에 더 많은 코드가 적재될 수 있습니다.

3. 파이프라이닝과 분기 예측

  • 분기 예측의 단순화: 피호출자가 스택을 정리하면, 호출 후 추가적인 스택 정리 코드가 필요 없으므로 분기 예측이 단순화될 수 있습니다. 이는 특히 파이프라인 처리에서 중요한데, 파이프라인은 명령어를 효율적으로 처리하기 위해 여러 단계로 나누어 실행하는 방식입니다.
    • 호출자 정리 방식: 함수 호출 후 추가적인 스택 정리 코드가 포함되어 분기 예측이 복잡해질 수 있습니다.
    • 피호출자 정리 방식: 함수 호출 후 추가적인 스택 정리 코드가 없어, 파이프라인의 흐름이 단순화됩니다.

4. 다중 호출에서의 최적화

  • 반복적인 함수 호출: 피호출자 정리 방식에서는 호출자가 동일한 함수를 여러 번 호출할 때마다 스택 정리 코드가 중복되지 않으므로, 다중 호출 시 성능 최적화에 유리할 수 있습니다.
    • 예를 들어, 루프 내에서 반복적으로 함수를 호출하는 경우, 호출자 정리 방식은 각 호출 후 스택 정리 코드를 포함해야 하지만, 피호출자 정리 방식은 함수 내부에서 한 번만 정리하면 됩니다.

결론

피호출자가 스택을 정리하는 방식은 코드 크기를 줄이고, 명령어 캐시 효율성을 높이며, 파이프라이닝과 분기 예측을 단순화함으로써 성능에 유리할 수 있습니다. 특히 함수 호출 빈도가 높은 고성능 애플리케이션에서는 이러한 이점들이 성능 최적화에 크게 기여할 수 있습니다.

 

decl과 stdcall은 각각 C 언어와 C++ 언어에서 함수 호출 규약의 한 종류입니다. 이 규약은 함수가 호출될 때 스택을 어떻게 관리할지, 함수의 인수를 어떻게 전달할지 등을 정의합니다. 각 규약이 기본 설정으로 사용되는 이유는 다음과 같은 특성과 역사적 배경이 있기 때문입니다.

cdecl (C Declaration)

  1. 유연성:
    • cdecl은 인수의 수가 가변적인 함수를 지원합니다. 예를 들어, printf 같은 가변 인수 함수를 사용할 때 유용합니다. 이러한 유연성은 많은 C 표준 라이브러리 함수에서 요구되는 기능입니다.
  2. C 언어의 전통:
    • cdecl은 전통적으로 C 언어에서 사용되어 왔으며, 대부분의 컴파일러와 플랫폼에서 기본 호출 규약으로 설정되어 있습니다. 이는 많은 C 프로그램이 cdecl을 사용하도록 설계되었기 때문에 호환성과 이식성을 보장합니다.
  3. 스택 정리 책임:
    • cdecl에서는 호출자가 함수 호출 후 스택을 정리합니다. 이는 여러 함수를 호출하는 코드가 동일한 호출 규약을 따를 때 일관성을 유지할 수 있게 합니다.

stdcall (Standard Call)

  1. 고정된 인수 개수:
    • stdcall은 고정된 인수 개수를 가지는 함수에 적합합니다. 호출된 함수가 스택을 정리하므로, 호출자가 인수 개수에 대해 신경 쓸 필요가 없습니다.
  2. Windows API:
    • stdcall은 주로 Windows API 함수에서 사용됩니다. Windows에서 개발된 많은 라이브러리와 함수가 stdcall을 사용하여 작성되었습니다.
  3. 약간의 성능 향상:
    • 호출된 함수가 스택을 정리하기 때문에 cdecl에 비해 약간의 성능 이점을 가질 수 있습니다. 그러나 이 차이는 대부분의 애플리케이션에서 미미한 수준입니다.

cdecl이 기본 설정으로 되어 있는 이유

  1. 이식성:
    • cdecl은 C 표준 라이브러리와 많은 오픈 소스 프로젝트에서 기본 호출 규약으로 사용됩니다. 이는 다양한 플랫폼에서 동일한 코드를 사용할 수 있도록 합니다.
  2. 유연성:
    • cdecl의 가변 인수 지원은 많은 범용 프로그래밍에 필수적입니다. 특히, 많은 표준 C 함수와 유틸리티 함수에서 필요합니다.
  3. 역사적 배경:
    • C 언어의 초기 설계와 구현에서 cdecl이 기본 호출 규약으로 채택되었고, 이는 많은 시스템에서 표준이 되었습니다. 역사적 이유로 많은 코드베이스와 라이브러리가 cdecl을 기반으로 작성되어 있습니다.

결론

cdecl이 기본 설정으로 되어 있는 이유는 주로 그 유연성과 이식성, 그리고 역사적 배경에 기인합니다. 고성능 애플리케이션에서 stdcall이 약간의 성능 향상을 제공할 수 있지만, 그 차이는 대부분의 경우에 매우 미미하며, 코드의 호환성과 유연성을 고려할 때 cdecl이 더 적합한 선택입니다. 따라서, cdecl이 기본 설정으로 되어 있는 것은 이러한 이유들에 기초한 합리적인 결정입니다.

 
 
 

'C++' 카테고리의 다른 글

C++ 타입변환 연산자  (0) 2024.03.12
""과 L""과 _T("")의 구분  (0) 2024.02.20
객체 지향(OOP)  (0) 2023.10.02
Lambda(람다)  (0) 2023.10.01
좌측값(lvalue) & 우측값(rvalue)  (0) 2023.10.01