관리 메뉴

Kim's Programming

C언어 - 포인터 고급(3/4) 본문

Programming/C

C언어 - 포인터 고급(3/4)

Programmer. 2015. 9. 6. 15:06

가변 인수 함수의 조건


가변 인수 함수는 인수의 개수와 타입에 대한 제약이 없지만 그렇다고 해서 아무 인수나 마음대로 전달할 수 있는 것은 아닙니다. 가변 인수 함수에도 지켜야 할 규칙들이 있는데 이 규칙에 대해서 알아 보겠습니다.


    1. 가변 인수 함수는 반드시 하나 이상의 고정인수를 가져야 합니다. 첫 번째 인수부터 가변 인수일 수도 없는데 왜냐하면 가변 인수를 읽기 위한 포인터 ap를 초기화하기 위해서 마지막 고정 인수의 번지를 알아야 하기 떄문입니다. _crt_va_start 매크로는 마지막 고정 인수의 번지에 길이를 더해 가변 인수가 시작되는 번지를 계산하는데 고정인수가 없으면 이 매크로가 동작하지 않습니다. GetSum 함수는 인수의 개수를 전달하는 num 고정인수를 가지며 printf 함수도 서식 문자열 format을 첫 번째 인수로 가집니다. 만약 고정 인수를 가지지 않는 변수 인수 함수를 꼭 만들고 싶다면 _crt_va 매크로를 쓰는 대신 스택을 직접 뒤지는 방법을 사용할 수는 있습니다. 하지만 컴파일러마다 함수를 호출할 때 스택을 조작하는 방법이 다르고 어셈블리를 직접 사용해야하기 떄문에 일반적으로 불가능하다고 보는 편이 옳습니다.또한 바로 다음의 2번 3번 규칙을 만족하기 위해서도 고정 인수가 필요합니다. 가변 인수들을 일관된 방법으로 읽기 위해서는 방드시 하나 이상의 고정 인수가 있어야합니다.

    2. 함수 내부에서 자신에게 전달된 가변 인수의 개수를 알 수 있도록 해야 합니다. 전달될 수 있는 인수의 개수에는 제한이 없으며 컴파일러는 함수가 호출될 때 인수의 개수를 점검하지 않습니다. 그래서 호출측에서 가변인수가 몇 개나 전달되었는지를 알려 주지 않으면 함수 내부에서 인수의 개수를 알 수 있는 방법이 전혀 없습니다. 함수 스스로 인수의 개수를 파악할 수 있도록 호출측이 정보를 제공해야합니다. GetSum 함수는 첫 번째 고정 인수 num을 통해 뒤쪽의 가변 인수가 몇 개나 전달되었는지를 알려주도록 되어있으며 함수 내부에서는 num만큼 루프를 돌면서 _crt_va_arg로 인수들을 읽었습니다. 만약 num인수가 없다면 GetSum 함수는 루프를 얼마나 돌아야 할 지 결정할 수 없을 것입니다. GetSum함수의 예처럼 가변 인수의 개수를 고정 인수로 알려주는 것은 가장 쉽기는 하지만 개수를 바꿀 때마다 고정인수를 수정해야 하므로 불편할 수도 있습니다. 고정 인수로 개수를 전달하는 것이 귀찮다면 가변 인수의 목록 끝에 특이값을 전달하는 방법을 쓸 수도 있는데 예를들어 인수값중 0을 만나면 이 값을 가변 인수의 끝으로 인식하도록 약속을 하는 것입니다. 이런 방법으로 전에 썼던 GetSum함수를 수정해보았습니다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      #include<stdio.h>
       
      void GetSum(const char *msg, ...)
      {
          int sum = 0;
          va_list ap;
          int arg;
       
          _crt_va_start(ap, msg);
          for (;;)
          {
              arg = _crt_va_arg(ap, int);
              if (arg == 0)
              {
                  break;
              }
              sum += arg;
          }
          _crt_va_end(ap);
          printf(msg, sum);
      }
      void main()
      {
          GetSum("1+2=%d\n"120);
          GetSum("3+4+5+6=%d\n"34560);
          GetSum("10~15=%d\n"1011121314150);
      }
      cs

       GetSum 함수를 수정하였습니다. 서식 문자열과 여러개의 정수값들을 인수로 전달받되 가변 인수의 끝에는 0을 두어 0을 만날 때까지 모든 인수의 값을 합해 그 결과를 메세지와 함꼐 직접 출력합니다. 함수 내부의 루프는 무한 루프로 수정되었으면서 읽은 값이 0일 때까지 루프를 돌도록 했습니다. 실행 결과는 앞에서 만들었던것과 같습니다.

      GetSum 함수는 가변 인수의 개수를 고정 인수를 통해 직접적으로 알려 주도록 했으며 새로 작성한 GetSum함수는 개수는 알려 주지 않되 가변 인수의 끝을 나타내는 특별한 표지값을 약속함으로써 이 값이 나올 때까지 가변인수를 취할 수 있도록 했습니다. 어떤 방법을 쓰든지 어쨋든 함수 내부에서 가변 인수의 개수를 알 수 있도록만 해주면 됩니다. 그렇다면 printf는 인수의 개수를 어떻게 파악할 까요? 개수를 전달하는 고정 인수도 없고 끝을 나타내는 특이값도 없어서 함수 내부에서 가변 인수의 개수를 알 수 없는 것과 같습니다. 그러나 자세히 관찰해 보면 서식 문자열에 포함된 서식의 개수가 바로 가변 인수의 개수와 일치한다는 것을 알 수 있습니다. printf는 첫 번째 고정인수로 전달되는 서식 문자열에서 %d, %f, %s, 같은 서식의 개수만큼 가변 인수를 읽음으로써 사실상 가변 인수의 개수를 전달받습니다.

    3. 개수와 마찬가지로 함수 내부에서 각각의 가변 인수 타입을 알 수 있어야 합니다. 기존의 GetSum이나 새로쓴 GetSum 함수 처럼 모든 인수를 정수형으로 고정하든가 아니면 첫 번쨰, 두 번째는 실수 , 세번째는 인수의 타입을 판별하는데 %d가 제일 처음 나왔으면 첫 번째 가변 인수는 정수, 다음으로 %f가 나왔으면 두 번째 가변 인수는 실수라는 것을 알게 됩니다. 가변 인수들의 타입을 알아야 하는 이유는 _crt_ca_arg 매크로가 ap 번지에서 가변 인수를 읽을 때 얼마만큼 읽어서 어떤 타입으로 해석해야 할지를 알아야 하기 떄문입니다. 가변 인수의 타입을 전달하는 방식도 여러 가지를 생각할 수 있는데 printf와 같이 하나의 고정 인수를 통해 모든 가변 인수의 타입을 판단할 수 있는 힌트를 제공하는 방식이 가장 좋습니다.
      다음 소스에서의 GetSum함수는 type고정 인수에 이후 전달되는 가변 인수들의 개수와 타입을 문자열로 전달합니다. 정수형에 대해서는 i, 실수형에 대해서는 d라는 문자를 할당해서 이 문자들을 순서대로 쭉 적어주는 것입니다. 예를 들어 types가 didi면 정수 실수 정수 실수 이며 총 가변인수는 4개라는 정보가 전달됩니다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      #include<stdio.h>
       
      double GetSum(const char *types, ...)
      {
          double sum = 0;
          va_list ap;
          const char *p;
       
          _crt_va_start(ap, types);
          for (p = types; *p; p++)
          {
              switch (*p)
              {
              case 'i':
                  sum += _crt_va_arg(ap, int);
                  break;
              case'd':
                  sum += _crt_va_arg(ap, double);
                  break;
              }
          }
          _crt_va_end(ap);
          return sum;
      }
       
      void main()
      {
          printf("1+2=%f\n", GetSum("ii"), 12);
          printf("3.14+5.12+1.414+5=%f\n", GetSum("dddi"3.145.121.4145));
          printf("1+2.345+6+7.891=%f\n", GetSum("idid"12.34567.891));
      }
      cs

      types 고정 인수를 사용해서 인수의 개수와 타입까지도 한꺼번에 전달할 수 있기 떄문에 정수형, 실수형을 마구 섞어서 전달해도 함수 내부에서 다양한 타입의 이수들을 제대로 읽을 수 있을 것입니다. 실행결과는 다음과 같이 나옵니다.

      Getsum함수에서는 types의 길이 만큼 루프를 돌되 이 문자열의 처음부터 순서대로 문자를 읽으면서 i 이면 _crt_va_arg(ap,int)로 인수를 읽고 d이면 _crt_va_arg(ap,double)로 인수를 읽었습니다. 정수, 실수 외에도 더 다양한 타입을 전달하고 싶다면 types의 의미를 확장하고 switch문의 case만 늘리면됩니다.

      규칙들이 다소 복잡하다고 생각이 되지만 당연한 규칙들입니다. 모든 규칙들은 함수가 어떤 식이로든 인수들을 정확하게 파악할 수 있도록 존재합니다. 전달된 인수의 개수나 타입을 전혀 알 수 없다면 값을 정확하게 읽지 못하므로 이런 규칙이 필요합니다. 규칙만  지킨다면 인수에 대한 정보를 알려주는 방법에 대해서만은 자유를 누릴 수 있습니다. 가변 인수 함수는 인수의 개수나 타입에 대해 호출측에서 자유롭게 결정할 수 있는 편리한 함수입니다. 그러나 규칙을 제대로 지키지 않았을때의 결과는 컴파일러도 책임을 지지 않습니다.  



매크로 분석

가변 인수에 대한 언어의 문법적인 지원은 인수 목록에 대한 점검을 무시하도록 하는 ... 밖에 없습니다. 그렇다고 라이브러리 차원의 함수 지원이 있는 것도 아니며 컴파일러가 가변 인수를 특별하게 해주는 것도 아닙니다. 가변 인수에 대한 모든 지원은 오로지 표준헤더 파일stdarg.h 파일에 정의되어 있는 매크로에 의해 구현이 됩니다. 이 헤더 파일을 직접 열어보면 플랫폼별로 _crt_va_매크로들이 각각 작성되어

있는데 대부분의 경우 인텔 계열의 CPU를 사용하고 있으므로 인텔 x86계열만 살펴보겠습니다.

1
2
3
4
5
typedef char * va_list;
#define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int- 1& ~(sizeof(int- 1) )
#define _crt_va_start(ap,v)  ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define _crt_va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define _crt_va_end(ap)      ( ap = (va_list))
cs

먼저 va_list에 대한 타입 정의를 볼 수 있는데 va_list는 단순한 char * 형으로 정의되어 있습니다. 여기서 char에 대한 포인터라는 것은 별다른 의미는 없고 증감할 때 1바이트씩 증감하도록 하기위해 char형 포인터로 선언된 것입니다. 실제로 어떤 컴파일러는 va_list를 void *로 정의해 놓고 증감할 때 캐스팅해서 사용하기도 합니다. 중요한 것은 va_list 타입이 스택의 인수들을 가리키는 포인터 타입이라는 것입니다. _INTSIZEOF(n) 매크로는 인수로 전달된 타입 n의 길이를 계산하는데 n의 값에 따라 이 매크로의 계산결과가 어떻게 되는지 조사해 보겠습니다. 매크로의 연산식을 엄밀하게 분석해보면 각 타입의 크기가 얼마로 계산될 지 예측할 수 있지만 직접 코드 치는게 더 간편합니다.

1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
#include<stdarg.h>
 
void main()
{
    printf("char = %d\n", _INTSIZEOF(char));
    printf("short = %d\n", _INTSIZEOF(short));
    printf("int = %d\n", _INTSIZEOF(int));
    printf("float = %d\n", _INTSIZEOF(float));
    printf("double = %d\n", _INTSIZEOF(double));
}
cs

char, short, int 등의 타입을 인수로 넘겨 보았는데 타입을 함수의 인수로 넘기는 것이 어색해 보이지만 매크로 함수이기 떄문에 이것이 가능합니다. _INTSIZEOF로 전달된 타입은 결국 sizeof의 피 연산자로 사용됩니다. 크기별로 각 타입에 대해 _INTSIZEOF 매크로의 결과는 다음과 같습니다.


char형 크기는 1이지만 이 매크로에 의해 4로 계싼되며 short, float도 4가 되고 double은 8이 됩니다. 이 매크로가 하는 일은 타입의 크기를 4의 배수로 올림할 수 있는데 덩확히 하면 정수형의 크기에 대한 배수로 올림합니다. 정수형의 크기는 시스템 마다 다른데 16비트는 2바ㅣ트 32비트 에서는 4바이트이며 이 크기는 또한 스택 하나의 크기이기도 합니다. 결국 이 매크로는 각 타입의 변수가 스택을통해 함수로 전달될 때 몇 바이트를 차지하는가를 계산합니다. char형이 1바이트라도 함수의 인수로 전달될 떄는 int형으로 확장되므로 스택에는 4바이트로 들어가며 _INTSIZEOF는 인수가 스택에 들어가 있을 때의 크기를 계산하는 것입니다. 아주 간단한 동작을 하나는 매크로이지만 플랫폼에 따른 스택의 크기까지 고려하여 이식성을 보장할 수 있도록 잘 작성되어 있습니다. 4바이트 배수 타입에 대해서 _INTSIZEOF나 sizeof 연산자나 실질적으로 동일합니다.


_crt_va_start 매크로는 가변 인수의 위치를 가리키는 포인터 ap를 초기화하는데 이 초기화를 위해 마지막 고정 인수 v를 전달해야 합니다. ap는 마지막 고정 인수 v의 번지에 v의 크기를 더한 번지로 초기화됩니다. 스택에 인수가 들어갈 떄는 전달된 역순으로 들어가므로 가변 인수들이 먼저 전달(높은 번지)되고 고정 인수가 제일 끝에 전달(낮은 번지)됩니다. 이상태에서 &v는 고정 인수의 번지를 가리키며 이 번지를 char*로 캐스팅한 후 고정 인수의 길이만큼 더하면 바로 아래있는 첫 번째 인수의 번지를 구할 수 있습니다. _crt_va_start 매크로는 이 연산을 통해 ap를 가변 인수의 시작 번지로 초기화하여 가변 인수를 읽기 위한 준비를 마칩니다. 이후 ap에 있는 값을 읽기만 하면 가변 인수의 값을 구할 수 있는데 이 동작을 하는 매크로가 바로 가변 인수 액세스의 핵심 _crt_va_arg입니다. _crt_va_arg함수는 ap를 일단 가변 인수의 길이만큼 더해 다음 가변 인수 번지로 이동시킵니다. 그리고 다시 길이를 빼서 원래 자리로 돌아온 후 이 번지를 t타입의 포인터로 캐스팅하여 *연산자로 그 값을 읽습니다. 이 매크로는 ap의 값을 읽기만 하는 것이 아니라 다음 번 va_arg 호출을 위해 ap를 방금 읽은 가변 인수 다음의 번지로 옮겨 주는 동작까지 해야 하기 떄문에 길이를 더했다가 다시 뺀 후 그 위치를 읽도록 되어 있습니다.


중간 변수를 사용하지 않고 매크로 한줄로 읽기도 하고 ap를 다음 위치로 옮겨놓기도 해야하므로 조금 복잡하게 되어 있는데 매크로 구문의 연산 순서에 따라 어떤 동작들이 일어나는지를 보도록 하겠습니다.

1
2
3
4
ap += _INTSIZEOF(t)  // ap를 일단 다음 가변 인수 위치로 보냄
(ap += _INTSIZEOF(t)) - _INTSIZEOF(t) //ap를 증가시킨 후 원래 번지로 재이동
(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) //t형 포인터로 캐스팅
(*(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t))) //ap번지의 t형 데이터를 읽음
cs


_crt_va_arg(ap,t) 호출문은 ap 번지에 있는 가변 인수를 t타입으로 읽고 그 길이만큼 ap를 증가시켜 다음 가변 인수를 읽을 수 있도록 준비합니다.  그래서 _crt_va_arg을 계속 호출하면 가변 인수들을 연속적으로 액세스 할 수 있습니다. 단 _crt_va_arg가 인수를 정확하게 읽고 그 길이만큼 다음 위치로 이동하기 위해서는 가변 인수의 타입을 반드시 알려주어야 합니다. va_arg 매크로의 동작을 좀더 작게 분할해 본다면 다음과 같습니다.

1
2
3
ret = *(t *)ap;
ap += _INTSIZEOF(t);
return ret;
cs

ap포인터를 t *로 캐스팅한 후 이 자리에 있는 값을 읽어 ret에 대입해 놓고 ap는 t의 크기만큼 증가시켜 다음 위치로 인동합니다. 그리고 전체 결과로 ret을 리턴하는 것입니다. 위 코드는 어디까지나 _crt_va_arg 매크로의 동작 설명을 위해 코드를 풀어 놓은 것이지 실제로 이런 코드를 작성할 수는 없습니다. 왜냐하면 매크로는 지역변수를 가질 수 없고 설사 블록 범위 변수를 쓴다 하더라도 가변적인 ret의 타입을 결정할 수 없기 떄문입니다. 그래서 이 세 동작을 한 매크로 구문으로 절묘하게 구겨 넣은 것이 바로 _crt_va_arg입니다.


마지막으로 _crt_va_end 매크로는 가변 인수를 가리키던 ap포인터를 NULL로 만들어 무효화 시키는데 사실은 이동작은 굳이 필요치는 않습니다. 어짜피 ap는 지역변수로 선언되었고 함수가 종료되면 사라지므로 어떤 값을 가지더라도 아무 문제가 없으며 실제로 _crt_va_end 매크로는 미래의 플랫폼에서 가변 인수를 읽는 방법이 달라질 경우 뒷정리를 할 수 있는 위치를 확보하는 역할 외에는 아무 의미가 없습니다.


가변 인수 함수의 예시로 최초로 작성했던 GetSum 함수를 매크로를 쓰지 않고 전개해서 강략하게 재작성 하면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int GetSum(int num, ...)
{
    int sum = 0;
    int i;
    //va_list ap;
    char *ap;
    int arg;
 
    //_crt_va_start(ap,num);
    ap = (char *)&num + sizeof(num);
    for (i = 0; i < num; i++)
    {
        //arg=va_arg(ap,int);
        arg = *(int *)ap;
        ap += sizeof(int);
        sum += arg;
    }
    //va_end(ap);
    return sum;
}
cs

보다시피 가변 인수 함수는 포인터 연산, sizeof 연산자, 캐스트 연산자들의 절묘한 조합에 의해 동작 한다는 것을 알 수 있습니다. 이 동작을 좀더 쓰기 쉽고 호환성과 이식성에 유리하도록 정리해 놓은 것이 _crt_va 매크로입니다.




'Programming > C' 카테고리의 다른 글

C언어 - 파일 입출력(1/3)  (2) 2015.09.12
C언어 - 포인터 고급(4/4)  (0) 2015.09.07
C언어 - 포인터 고급(2/4)  (0) 2015.09.05
C언어 - 포인터 고급(1/4)  (0) 2015.08.31
C언어 - 구조체(Structure)(2/2)  (23) 2015.08.21