본문 바로가기

Programming/C&CPP

가변 인자(Variable Arguments) 내부 구조

반응형

가변 인자 함수는 printf 같이 인자의 형식이나 수가 정해지지 않은 형식의 함수입니다.

제가 예전에 함수 호출 규약을 설명하면서 __cdecl로 호출되는 함수는

가변 인자 함수 호출 방식이라고 설명을 드리면서 호출하는 쪽에서 스택을 정리한다고 했습니다.

이유는 호출당하는 함수에서는 몇 개가 넘어오는 지 알 수가 없기 때문입니다.

혹시 모르시는 분들은 아래 링크를 확인해주세요.

2014/11/25 - [Programming/C&C++] - Calling Convention(함수 호출 규약)

가변 인자 함수는 말그대로 인자를 변경 가능할 수 있다는 의미입니다.

가장 자주 사용하게 되는 것은 printf류의함수가 아닐까 생각됩니다.

Visual Studio에서 printf를 입력해 보았습니다.

const char* 타입의 _Format을 전달받고 뒤에는 ...으로 표시가 되어 있습니다.

바로 이 ...이 가변 인자를 전달받는 함수의 형식입니다.

printf와 같은 형식으로 가변인자를 받는 함수를 하나 만들어 보도록 하겠습니다.

int MyFunc(int nSize, ...)
{
	return 0;
}

일단 MyFunc를 printf와 유사한 형태의 가변인자를 받는 함수로 작성합니다.

가변 인자를 사용하는데 쓰이는 매크로가 몇 가지 존재합니다.

va_start
va_arg
va_list
va_end

가변 인자 매크로는 stdarg.h에 정의되어 있습니다.

각각의 매크로의 기능은 다음과 같습니다.

va_start는 Visual C++에서는 _crt_va_start라고 되어 있으며 header 안에는

Error : Only Win32 target supported! 라고 되어 있는 걸 알 수 있습니다.

이렇게 되어 있는 이유는 뒤에 설명하도록 하겠습니다.

일단 _crt_va_start를 따라가 보면 다음과 같이 되어 있습니다.

#define _crt_va_start(ap,v)  ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )

ap와 v를 전달 받아서 v의 주소에 _INTSIZEOF(v)를 통해서 나온 값을 더해서 ap에 대입합니다.

ap는 va_list로 받으며 va_list는 실제로는 단지 char*입니다.

즉, v의 주소에 _INTSIZEOF(v)를 더해준 주소를 char*로 된 포인터에 넣어주는 것입니다.

_INTSIZEOF(v)를 하는 이유는 다음과 같습니다.

32bit 환경에서는 변수의 크기 단위가 32bit, 4 Byte가 됩니다.

그래서 일반적으로 구조체 등의 변수를 선언하면 실제 크기는 4 Byte의 배수로 할당됩니다.

함수에 전달될 때도 마찬가지로 char나 short 등의 1, 2 Byte의 형식도 4 Byte로 전달되는 것입니다.

이것을 위해서 _INTSIZEOF(v)는 4의 배수로 맞춰주는 역할을 합니다.

_INTSIZEOF(char)의 형태로 하면 실제로는 1 Byte지만 4바이트로 결과를 얻을 수 있습니다.

첫 번째로 받는 인자의 처음 시작 위치에서 실제 스택에서 차지하는 크기만큼

뒤로 이동시키고 그 포인터를 리턴하는 역할을 하는 것입니다.

그 리턴을 받는 것이 va_list로 되어 있는, char*를 재정의한 형태인 것입니다.

여기서 한가지 알고 넘어가셔야 할 것은 위의 Win32만 지원한다는 에러에 대한 내용입니다.

_INTSIZEOF는 int를 이용해서 스택에 할당되는 기본 사이즈 단위를 구하게끔 되어 있습니다.

이것이 무슨 의미일까요?

Win32의 경우는 늘어나는 Stack 사이즈(4 Byte 단위)와 int 형의 크기가 일치합니다.

하지만 64bit OS로 넘어가면 어떻게 될까요?

Stack의 사이즈는 8배수(8 Byte 단위)로 커지는데 int형은 64bit에서도 여전히 4 Byte입니다.

이렇기 때문에 Win32에서만 지원이 가능한 것입니다.

int는 호환성을 이유로 32bit나 64bit OS 양쪽에서 4 Byte이고 long형이 OS에 따라서 달라집니다.

그럼 다시 va_start를 보겠습니다.

맨 처음 받는 인자의 주소값에서 실제 스택에서 차지하는 공간만큼 이동시킨 위치를 포인터에 넣습니다.

고정된 인자 다음으로 들어오는 인자를 가리키는 위치로 이동시킨다는 의미 입니다.

이해를 돕기 위해 간략하게 그림으로 보겠습니다.

이렇게 nSize 이후에 가변 인자의 시작 지점의 주소를 받아오는 것입니다.

여기서 나오는 가변 인자의 큰 특징은 다음과 같습니다.

1. 반드시 첫 번째 인자는 고정 인자로 받아야 한다.

이유는 위에 보이는 그림과 같이 기준이 되는 주소가 필요하기 때문입니다.

va_start와 va_list(char*) 2개의 매크로를 확인하였습니다.

그리고 가장 중요한 va_arg 매크로입니다.

va_arg는 실제로 값을 가져올 수 있는 매크로입니다.

사용 방법은 va_arg(lpStart, int); 이런 형식입니다.

가변 인자의 위치를 가리키는 va_list(char*) 타입의 변수를 전달하고 int 등의 변수를 타입을 전달합니다.

위와 같이 사용하게 되면 va_list가 가리키는 위치의 int 타입의 변수를 하나 얻을 수 있습니다.

va_arg는 상당히 복잡한 구조로 만들어져 있는 매크로입니다.

#define _crt_va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )

상당히 복잡하게 얽혀있는 매크로라고 할 수 있습니다.

안쪽의 괄호부터 읽도록 하겠습니다.

처음 (t *)는 뒤에 계산한 내용을 t* 타입으로 변환하는 것입니다.

그리고 * 연산자를 통해서 해당 위치의 값을 가져옵니다.

뒤의 연산인 ((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) ) 은 다음과 같습니다.

먼저 ap를 _INTSIZEOF(t)만큼 뒤로 이동시킵니다.

즉, 다음 가변 인자(그림에서 가변 인자 2)로 먼저 포인터를 이동시키는 것입니다.

값을 먼저 읽지 않고 ap를 이동시키는 가에 대한 의문이 생긴다면 잘 따라오고 있는 것입니다.

뒤에 있는 - _INTSIZEOF(t)가 존재하기 때문에 ap가 이동된 상태에서 다시 원래 위치로 돌아오는 위치를

포인터로 받고 그것을 역참조 할 수 있는 것입니다.

ap는 상승된 포인터를 미리 대입받고 그 이후에 위치를 임시적으로 복구시켜서 사용하는 것입니다.

그런 식으로 ap가 계속 다음의 가변 인자를 참조할 수 있는 것입니다.

va_arg의 이런 특징을 통해서 가변 인자의 중요한 규칙이 하나 더 나옵니다.

2. 넘어오는 가변 인자의 수를 알아야 하고, 타입을 알아야 한다.

지금은 int형으로 설명을 했지만 printf에서는 포맷(%s, %d 등)으로

넘어오는 타입과 인자의 개수를 알 수 있는 것입니다.

printf는 포맷 문자가 실제 전달되는 가변 인자의 개수보다 적으면(포맷 문자 > 가변 인자)

오류를 내지 않습니다.

실제 전달되는 가변인자의 개수가 많으면 문제가 생길 수 있습니다.

포맷 문자가 많은데 전달 된 인자의 수가 적으면 사용 가능한 스택 영역을 넘어서기 때문입니다.

마지막으로 va_end는 단순히 va_list를 NULL(0)로 초기화하는 역할을 합니다.

다음의 소스 코드로 가변 인자 함수의 동작 원리를 이해할 수 있을 것입니다.

#include <stdarg.h>
#include <iostream>
using namespace std;

int MyFunc(int nSize, ...)
{
	int nVal = 0;
	int nResult = 0;

	va_list lpStart;
	va_start(lpStart, nSize);

	for (int i = 0 ; i < nSize ; ++i)
	{
		nVal = va_arg(lpStart, int);
		nResult += nVal;
	}

	return nResult;
}

int main()
{
	int nRes = MyFunc(3, 1, 2, 3);
	cout << "Result : " << nRes << endl;

	return 0;
}


반응형