Variable Argument Lists (Va_list)
[!info] C99 기준으로 작성되었습니다.
Related topics:: stack frame, calling convention, opaque type
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, ...)
Implementation
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);
}
![[../Attachment/Pasted image 20250501025525.png|System V i386 ABI 환경에서의 실행 결과|600]] ![[../Attachment/Pasted image 20250501025744.png|ARM64 (AArch64) ABI 환경에서의 실행 결과|600]] 전자에서는 의도(?)한 바대로 출력되고 후자에서는 그렇지 않다. 주된 이유는 ‘ARM64 환경에서 모든 인자가 stack에 pass되지 않기 때문‘이다(ARM64과 AMD64 등 최근의 환경에서는 인자를 stack 대신 레지스터에 우선적으로 저장한다). 위의 코드는
- 모든 인자가 stack에 pass된다
- 각 인자는 stack에 increasing order 순서로 저장된다 …라는 전제 하에 직접 스택을 따라가 인자에 접근하는 코드이다. 우연히 그 기준에 들어맞아 의도대로 동작할 수도 있지만(실제로 이런 가정을 두고 작성한 코드는 과거 많은 시스템에서 잘 동작했다) 그렇지 않을 수도 있다. 이처럼 각 아키텍처에 따라 필요한 내부 구현이 상이하기 때문에, portable한 C 프로그램을 작성하려면 반드시 C 표준에 정의되어있는 공식 인터페이스인 <stdarg.h> 헤더를 사용해야한다.
[!note] 과거에 오직 스택만을 사용하던 환경에서는, 가변 인자 함수를 구현하려면 반드시
__cdecl
Calling convention을 사용해야 했다.
<stdarg.h>
#include <stdarg.h>
void va_start(va_list _ap_, _argN_);
void va_copy(va_list _dest_, va_list _src_);
type va_arg(va_list _ap_, _type_);
void va_end(va_list _ap_);
메뉴얼을 읽어보면, 사용법 자체는 굉장히 간단하다. 우리는 제공되고 있는 함수 매크로들을 적재적소에 활용만 하면 된다. 위 함수들의 prototype을 보면, 모두 va_list 구조체를 인자로 받고 있다. 그리고 넘겨받은 va_list를 알맞게 조작하고 있다. (포인터로 넘겨받지 않는다는 점에도 주목하자. 주소 값을 이용한 조작이 가능하다는 특장점이 있는 C언어임에도 불구하고 포인터를 사용하고 있지 않다. 의도가 매우 다분한 설계이다) 이는 객체지향 언어에서 캡슐화(encapsulation)의 원칙 아래 클래스에 여러 메서드를 제공하고, 내부 동작을 사용자에게 노출시키지 않는 것과 매우 유사한 구조이다. 그러나 클래스의 메서드와는 큰 차이점이 있는데, 바로 사용하고 있는 ‘내부의 어떤 저장소’가 stack과 register라는 점이다. 이를 조작하는 동작은 C코드로 구현되어 있지 않다. 그렇기 때문에 어셈블리 코드를 봐야만 그 동작 원리를 이해할 수 있다.
x86/x86-64
[!warning] 세상에는 수많은 시스템이 있으며 아래 설명이 모든 구현을 대변하지 않습니다.
x86 그리고 x86-64, 크게 두 방식으로 구현되어 있다.
Where argument passed | Definition of va_list |
implementation of va_ functions |
|
---|---|---|---|
x86 | 모든 인자를 stack에 순서대로 전달 | 포인터 구현 (컴파일러 built-in을 wrapping하는 경우도 있음) |
매크로 함수 구현 (컴파일러 built-in을 wrapping하는 경우도 있음) |
x86-64 | 인자를 register에 우선 전달, 부족하면 stack 사용 | 컴파일러 built-in 구현 (크기 1인 구조체 배열 wrapping) |
컴파일러 built-in 구현 |
x86-64에서 컴파일러 built-in으로 구현한 이유는 ‘C코드로는 register 관련 동작을 정확히 정의할 수 없기 때문‘이다. 단순 포인터 연산으로 가능했던 x86과 달리 x86-64에서의 구현은 컴파일러의 도움이 필요할 수밖에 없었다. |
![[../Attachment/Pasted image 20250501060657.png|x86-64: <stdarg.h>에서의 va_list 정의 |375]] 앞서 x86-64에서의 구현은 컴파일러 built-in이라고 하였다. 실제로 header의 경로를 따라가 확인해보면 va_list는 위와 같이 정의되어있다. 즉 x86-64 환경에서 <stdarg.h> 라이브러리는 인터페이스(껍데기)만 제공할 뿐이고 내부는 컴파일러의 built-in으로 구현되어 있다. C코드로는 여기까지 밖에 볼 수 없으며, 오직 ABI 문서 명세상으로만 x86-64의 va_list 구조를 확인할 수 있다.
va_list의 구조에도 아래와 같은 차이가 있다. x86 시스템에서는 인자를 저장하는데 있어 오직 스택만 사용했기 때문에 void 포인터로 단순하게 구현되었지만, x86-64에 들어서는 레지스터를 추가로 사용하기 때문에 별도의 offset 변수들이 있다. ![[../Attachment/Pasted image 20250501050919.png|x86: Intel386 System V|650]]
![[../Attachment/Pasted image 20250501045907.png|x86-64: AMD64|425]]
x86-64 시스템은 가변 인자를 관리하는데 있어 정수 레지스터와 부동소수점 레지스터를 모두 사용한다. gp_offset
은 general-purpose register의 offset, fp_offset
은 floating-point register의 offset이다.
시스템마다 레지스터의 개수는 상이하지만, 거의 대부분의 시스템에서 위와 동일한 구조이다. 만약 레지스터에 담지 못한 인자가 있다면 stack에 담는다. overflow_arg_area
변수는 이때 사용한다.
opaque type in C
앞서 ‘va_ 함수들의 parameter lists에서 va_list를 포인터로 받고 있지 않는 설계에 주목하라’ 하였는데, 위 코드를 보면 그 원리를 명확히 알 수 있다. 결국 va_list는 포인터였다. 애초에 type 자체가 포인터였기 때문에 parameter를 굳이 포인터로 선언할 필요가 없는 것이었다.
왜 포인터로 구현된걸까? 심지어 x86-64 환경에서는 va_list[1]
라는 기이한(?) 형태로 정의됐다.
이렇게 C에서 포인터로 선언된 변수(특히 구조체)와 그 변수를 조작하는 interface 함수들을 제공할 때, 여러 장점이 있다.
- 사용자는 변수의 내부에 관심이 없어도 된다. (심지어 포인터인지 몰라도 된다, 사용자는 코드에 ‘&’, ‘*’ 문자를 사용하지 않아도 무방)
- 사용자가 변수의 내부를 모른다는 사실은 변수를 사용하는데 전혀 지장을 주지 않는다.
- (특히 구조체로 선언할 경우에 유용) 사용자는 변수를 함부로 복사할 수 없다.
va_list va1 = va2;
와 같은 value copy (shallow copy)를 방지해 UB(undefined behavior)을 막을 수 있다. 결국, 이는 C언어의 문법적 특성을 이용한 특유의 은닉 기법이라고 할 수 있다.
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이 기본 호출 규약으로 사용된다.
References
- https://www.gnu.org/software/c-intro-and-ref/manual/html_node/Variable-Number-of-Arguments.html
- https://pubs.opengroup.org/onlinepubs/000095399/basedefs/stdarg.h.html
- https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf (3.5.7절)
- https://wiki.osdev.org/System_V_ABI
- https://github.com/ARM-software/abi-aa/blob/c51addc3dc03e73a016a1e4edf25440bcac76431/aapcs64/aapcs64.rst
- https://refspecs.linuxfoundation.org/elf/mipsabi.pdf
- https://en.wikipedia.org/wiki/X86_calling_conventions#