[C] variadic function: Calling Convention
[!info] C99 기준으로 작성되었습니다.
Overview
C에서 함수는 기본적으로 유한 개의 지정된 argument만을 받게 되어있지만, parameter lists의 마지막에 ‘...
’(ellipsis) 키워드를 두면 가변적인(variable) 개수를 받도록 할 수 있다.
variable argument lists를 사용하는 대표적인 함수로는 printf
가 있다. 이렇게 variable한 개수의 arguments를 받을 수 있는 함수를 variadic function이라 한다.
A function that takes a variable number of arguments is called a variadic function. In C, a variadic function must specify at least one fixed argument with an explicitly declared data type. Additional arguments can follow, and can vary in both quantity and data type. In the function header, declare the fixed parameters in the normal way, then write a comma and an ellipsis: ‘, …’. Here is an example of a variadic function header:
int add_multiple_values (int number, ...)
Problem
AMD64 ABI 문서의 3.5.7절에 따르면, ‘가변 인자 함수가 portable하기 위해서는 <stdarg.h>의 va_ 시리즈를 이용해야’ 한다. 무엇이 서로 다른 architecture로 하여금 porting이 불가능하게 만드는 것일까? 아래의 코드를 보자.
#include <stdio.h>
void do_not_use_va_list(int first, ...)
{
int *second = (&first) + 1; // move to second variable
printf("%d\n", first);
printf("%d\n", *second);
}
int main()
{
do_not_use_va_list(42, 123);
return (0);
}
System V i386 ABI 환경에서의 실행 결과 ARM64 (AArch64) ABI 환경에서의 실행 결과
전자에서는 의도(?)한 바대로 출력되고 후자에서는 그렇지 않다.
Why this happened?
위 코드는
- 모든 인자가 stack에 pass된다
- 각 인자는 stack에 increasing order 순서로 저장된다
라는 전제 하에 직접 스택을 따라가 인자에 접근하는 코드이다.
그렇지만 사실 ARM64 환경에서는 ‘모든 인자가 stack에 pass되지 않기’ 때문에, 예상한 대로 동작하지 않았다.
[!note] ARM64과 AMD64 등 최근의 환경에서는 인자를 stack 대신 레지스터에 우선적으로 저장한다.
이처럼 같은 코드더라도 실행되는 플랫폼(CPU 아키텍처와 운영체제의 조합)에 따라 다른 결과가 나올 수 있다. 그러나 이러한 차이를 온전히 환경에만 의존한다면, 코드의 이식성과 호환성이 크게 저하될 것이다. 혹시 이러한 차이를 정의해두는 별도의 규약(protocol)이 없을까?
함수를 호출할 때 우리는 단순히 인자를 나열해 함수를 호출하지만, 실제로는 이 인자들이 어떻게 메모리에 저장되고, 어떤 순서로 전달되는지, 함수가 종료된 뒤에는 어떻게 정리되는지와 같은 많은 저수준 작업(Low-level behavior)이 동반된다. 그러나 앞선 예에서도 보았지만 이는 언어 차원에서는 드러나지 않으며 CPU 아키텍처, 운영체제 등 실행 환경의 플랫폼에 의해 결정된다. 이러한 이유로 각 플랫폼에서는 함수 호출과 관련된 저수준 동작 방식을 공식적으로 명세화해두고 있다. 이것이 바로 ‘Calling Convention(호출 규약)‘이다. Calling Convention이 존재함으로써 서로 다른 플랫폼에서도 바이너리 수준에서 호환성을 유지할 수 있다.
[!Note] 바이너리 레벨에서의 실행 명세를 알고싶다면 각 플랫폼에서 작성한 ABI(Application Binary Interface) 문서를 보면 된다. Calling convention 또한 ABI 문서에서 확인할 수 있다. 플랫폼에 따라 다르지만 ABI 문서는 주로 CPU 제조사(e.g. ARM64)나 운영체제 개발사(e.g. Windows x64) 혹은 표준화 기구(e.g. Linux x86_64)에서 작성한다.
Calling Convention
caller와 callee간의 호출 규약이다. caller-callee간 이행 과정에 필요한 항목들을 결정한다. caller와 callee function의 calling convention은 일치해야한다.
In computer science, a calling convention is an implementation-level (low-level) scheme for how subroutines or functions receive parameters from their caller and how they return a result. Calling conventions are usually considered part of the application binary interface (ABI). They may be considered a contract between the caller and the called function.
- Parameter Allocation Order: atomic(scalar) 및 complex parameter의 allocation 순서와 할당 위치 (특히 complex parameter의 경우, 내부 변수를 각기 다른 위치에 할당 가능)
- Parameter Passing Mechanism: 인자들이 어떤 방식으로 전달되는지 (e.g 스택에 push하는지, 레지스터에 넣는지, 둘을 혼합하는지)
- Register Preservation: 어떤 레지스터들을 보존해야 하는지 (caller-saved / callee-saved)
- Stack Cleanup Responsibility: 호출 전후로 스택을 보존 & 복원하는 작업을 caller와 callee 중 누가 담당하는지
variable argument lists에 있어 중요한 항목은 ‘Stack Cleanup Responsibility‘이다.
variadic function을 호출하는 상황을 가정해보자, caller가 variable한 개수의 인자를 callee에게 넘긴다.
그러나 callee 입장에서 이는 run-time 정보로, 호출 이전 시점에서는 몇 개의 인자가 올지 알 수 없다. 인자의 개수와 필요한 스택 크기를 모르므로, subroutine을 종료하고 caller로 돌아가는 시점에서 ret N
구문으로 stack pointer를 적절히 옮길 수 없다. 따라서 가변 인자 함수를 실행하는 callee는 stack을 cleanup 할 수 없다.
[!info] 함수의 선언부에 keyword를 넣어 calling convention을 지정할 수 있다. 키워드의 위치는 운영체제마다 상이하다.
// calling convention - cdecl 지정 예시 int __cdecl msvc_func1(int a, int b); // MSVC 계열 int posix_func2(int a, int b) __attribute__((cdecl)); // POSIX 계열
cdecl은 caller가 스택을 cleanup하는 대표적인 calling convention이다. x86 시절 대부분의 컴파일러가 채택한 default calling convention이기도 했다. x86-64 환경에서는 성능상의 이점을 위해 인자 전달에 레지스터를 우선적으로 사용하며, 이에 따라 기존의 cdecl 호출 규약은 사실상 폐기되었다. 대신, POSIX 계열 시스템에서는 System V AMD64 ABI, Windows에서는 Microsoft x64 Calling Convention이 기본 호출 규약으로 사용된다.
[!note] 과거에 오직 스택만을 사용하던 환경에서는, 가변 인자 함수를 구현하려면 반드시
__cdecl
Calling convention을 사용해야 했다.
References
아래와 같이 깔끔하게 참고문헌을 정리할 수 있다.
References
- GNU C Intro & Reference: Variable Number of Arguments
- The Open Group Base Specifications Issue 6:
<stdarg.h>
- System V Application Binary Interface, AMD64 Architecture Processor Supplement (Section 3.5.7)
- OSDev Wiki: System V ABI
- ARM IHI 0055C: Procedure Call Standard for the ARM 64-bit Architecture (AAPCS64)
- Linux MIPS ABI Supplement
- Wikipedia: x86 Calling Conventions