C언어 전처리기 매크로 제대로 쓰기 — 괄호, do-while(0), #과 ##, X-macro, _Generic

매크로(macro)는 함수 호출 비용 없이 코드를 재사용하고, 타입에 상관없이 동작하는 코드를 만들 수 있어 C에서 자주 쓰인다. 문제는 전처리기가 문법을 이해하지 않고 토큰을 그대로 치환한다는 점이다. SQUARE(1 + 2)가 5가 되거나, if 문 안에 넣은 매크로가 else를 망가뜨리는 버그가 여기서 나온다.

이 글에서는 매크로를 쓸 때 반드시 알아야 할 괄호 규칙, 이중 평가(double evaluation), do { } while (0) 관용구와 #/## 연산자, 가변 인자 매크로, X-macro, _Generic, 조건부 컴파일을 예제로 정리한다. 모든 예제는 GCC 13.3.0(Ubuntu 24.04, x86_64)에서 직접 컴파일해 실행한 결과다.

연산자 우선순위 — 인자와 전체를 괄호로 감싼다

#include <stdio.h>

#define SQUARE_BAD(x)  x * x
#define SQUARE(x)      ((x) * (x))

#define DOUBLE_BAD(x)  (x) + (x)
#define DOUBLE(x)      ((x) + (x))

int main(void)
{
    printf("SQUARE_BAD(1 + 2) = %d\n", SQUARE_BAD(1 + 2));
    printf("SQUARE(1 + 2)     = %d\n", SQUARE(1 + 2));
    printf("10 / DOUBLE_BAD(5) = %d\n", 10 / DOUBLE_BAD(5));
    printf("10 / DOUBLE(5)     = %d\n", 10 / DOUBLE(5));
    return 0;
}
$ gcc -Wall -o precedence precedence.c && ./precedence
SQUARE_BAD(1 + 2) = 5
SQUARE(1 + 2)     = 9
10 / DOUBLE_BAD(5) = 7
10 / DOUBLE(5)     = 1

결과가 틀린 이유는 gcc -E로 전처리 결과만 뽑아보면 바로 보인다. -P# 1 "..." 형태의 라인 마커를 빼준다.

$ gcc -E -P precedence.c | tail -8
int main(void)
{
    printf("SQUARE_BAD(1 + 2) = %d\n", 1 + 2 * 1 + 2);
    printf("SQUARE(1 + 2)     = %d\n", ((1 + 2) * (1 + 2)));
    printf("10 / DOUBLE_BAD(5) = %d\n", 10 / (5) + (5));
    printf("10 / DOUBLE(5)     = %d\n", 10 / ((5) + (5)));
    return 0;
}

SQUARE_BAD는 인자를, DOUBLE_BAD는 전체 식을 괄호로 감싸지 않아 주변 연산자와 결합이 바뀌었다. 식을 만드는 매크로는 인자마다 괄호, 전체에 한 번 더 괄호가 기본이다.

인자 이중 평가 — 문장 표현식과 typeof

괄호를 제대로 쳐도 인자가 두 번 전개되는 문제는 남는다. 부수 효과(side effect)가 있는 인자를 넘기면 그 효과가 두 번 일어난다.

#include <stdio.h>

#define MAX_BAD(a, b)  ((a) > (b) ? (a) : (b))

/* GCC/Clang 확장: 문장 표현식 + typeof */
#define MAX(a, b) ({            \
    typeof(a) _a = (a);         \
    typeof(b) _b = (b);         \
    _a > _b ? _a : _b;          \
})

static int calls;
static int next_value(void) { return ++calls * 10; }

int main(void)
{
    int i = 5, j = 3;
    int m = MAX_BAD(i++, j);
    printf("MAX_BAD: m=%d i=%d\n", m, i);

    i = 5;
    m = MAX(i++, j);
    printf("MAX    : m=%d i=%d\n", m, i);

    calls = 0;
    m = MAX_BAD(next_value(), 5);
    printf("MAX_BAD(next_value(), 5) = %d, calls=%d\n", m, calls);

    calls = 0;
    m = MAX(next_value(), 5);
    printf("MAX(next_value(), 5)     = %d, calls=%d\n", m, calls);
    return 0;
}
$ gcc -Wall -o double_eval double_eval.c && ./double_eval
MAX_BAD: m=6 i=7
MAX    : m=5 i=6
MAX_BAD(next_value(), 5) = 20, calls=2
MAX(next_value(), 5)     = 10, calls=1

MAX_BAD는 비교에서 한 번, 결과로 한 번 i++next_value()를 평가했다. MAX는 GCC/Clang 확장인 문장 표현식 ({ ... })로 인자를 한 번만 평가한다(리눅스 커널 max()/min()과 같은 방식).

여러 문장을 묶는 매크로 — do { } while (0)

문장 여러 개를 중괄호로만 묶으면 if/else 사이에서 컴파일이 깨진다.

#include <stdio.h>

#define SWAP_BRACE(a, b)  { int _t = (a); (a) = (b); (b) = _t; }
#define SWAP(a, b)        do { int _t = (a); (a) = (b); (b) = _t; } while (0)

int main(void)
{
    int x = 1, y = 2;

    if (x < y)
        SWAP_BRACE(x, y);
    else
        printf("already sorted\n");

    printf("x=%d y=%d\n", x, y);
    return 0;
}
$ gcc -Wall -o dowhile dowhile_bad.c
dowhile_bad.c: In function ‘main’:
dowhile_bad.c:12:5: error: ‘else’ without a previous ‘if’
   12 |     else
      |     ^~~~

전개하면 { ... };가 되어 ;가 빈 문장으로 if를 끝내버린다. do { ... } while (0)로 감싸면 뒤의 ;까지 한 문장으로 소비된다.

#define SWAP(a, b)        do { int _t = (a); (a) = (b); (b) = _t; } while (0)

    if (x < y)
        SWAP(x, y);
    else
        printf("already sorted\n");
$ gcc -Wall -o dowhile dowhile.c && ./dowhile
x=2 y=1

# 문자열화와 ## 토큰 결합

#include <stdio.h>

#define STR(x)        #x
#define XSTR(x)       STR(x)
#define CONCAT(a, b)  a##b

#define CHECK(expr) \
    printf("%-18s -> %s\n", #expr, (expr) ? "OK" : "FAIL")

#define VERSION_MAJOR 2

int CONCAT(counter_, 1) = 100;

int main(void)
{
    int n = 7;
    CHECK(n > 5);
    CHECK(n % 2 == 0);

    printf("STR(__LINE__)  = %s\n", STR(__LINE__));
    printf("XSTR(__LINE__) = %s\n", XSTR(__LINE__));
    printf("XSTR(VERSION_MAJOR) = %s\n", XSTR(VERSION_MAJOR));
    printf("counter_1 = %d\n", counter_1);
    return 0;
}
$ gcc -Wall -o stringify stringify.c && ./stringify
n > 5              -> OK
n % 2 == 0         -> FAIL
STR(__LINE__)  = __LINE__
XSTR(__LINE__) = 21
XSTR(VERSION_MAJOR) = 2
counter_1 = 100
연산자동작
#x인자를 전개하지 않고 그대로 문자열 리터럴로STR(__LINE__)"__LINE__"
a##b두 토큰을 이어 새 식별자로CONCAT(counter_, 1)counter_1
XSTR(x)한 단계 거쳐 인자를 먼저 전개한 뒤 문자열화XSTR(__LINE__)"21"

가변 인자 매크로 — __VA_ARGS__

printf 계열 로그 매크로를 만들 때 포맷 문자열 뒤 인자가 없는 경우를 처리하는 것이 핵심이다. 단순히 __VA_ARGS__만 쓰면 인자가 없을 때 쉼표가 남는다.

#include <stdio.h>
#define LOG_BAD(fmt, ...)  printf("[%s:%d] " fmt "\n", __func__, __LINE__, __VA_ARGS__)
int main(void)
{
    LOG_BAD("done");
    return 0;
}
$ gcc -Wall -o /dev/null varargs_bad.c
varargs_bad.c: In function ‘main’:
varargs_bad.c:2:87: error: expected expression before ‘)’ token
    2 | #define LOG_BAD(fmt, ...)  printf("[%s:%d] " fmt "\n", __func__, __LINE__, __VA_ARGS__)
      |                                                                                       ^
varargs_bad.c:5:5: note: in expansion of macro ‘LOG_BAD’
    5 |     LOG_BAD("done");
#include <stdio.h>

#define LOG_GNU(fmt, ...)  printf("[%s:%d] " fmt "\n", __func__, __LINE__, ##__VA_ARGS__)
#define LOG(fmt, ...)      printf("[%s:%d] " fmt "\n", __func__, __LINE__ __VA_OPT__(,) __VA_ARGS__)

static void load_config(const char *path)
{
    LOG_GNU("loading %s", path);
    LOG_GNU("done");
    LOG("retry=%d timeout=%dms", 3, 500);
    LOG("no args");
}

int main(void)
{
    load_config("/etc/app.conf");
    return 0;
}
$ gcc -Wall -o varargs varargs.c && ./varargs
[load_config:8] loading /etc/app.conf
[load_config:9] done
[load_config:10] retry=3 timeout=500ms
[load_config:11] no args

같은 코드를 표준 모드로 컴파일하면 두 방식의 차이가 드러난다.

$ gcc -std=c11 -pedantic -Wall -o /dev/null varargs.c 2>&1 | grep warning
varargs.c:4:75: warning: __VA_OPT__ is not available until C2X
varargs.c:9:19: warning: ISO C99 requires at least one argument for the "..." in a variadic macro
varargs.c:11:18: warning: ISO C99 requires at least one argument for the "..." in a variadic macro

$ gcc -std=c2x -pedantic -Wall -o /dev/null varargs.c 2>&1 | grep -c warning
0
방식인자 없을 때 쉼표표준
, __VA_ARGS__남아서 컴파일 에러C99
, ##__VA_ARGS__GCC/Clang이 제거GNU 확장
__VA_OPT__(,) __VA_ARGS__인자가 있을 때만 쉼표 삽입C23

X-macro — 목록을 한 곳에서 관리한다

enum 값과 그 이름 문자열 테이블을 따로 관리하면 항목을 추가할 때 한쪽을 빠뜨리기 쉽다. X-macro는 목록을 한 번만 정의하고, 전개 방식만 바꿔 여러 번 재사용한다.

#include <stdio.h>

#define ERROR_LIST(X)                          \
    X(ERR_OK,       0,  "success")             \
    X(ERR_NOMEM,   12,  "out of memory")       \
    X(ERR_BUSY,    16,  "device busy")         \
    X(ERR_INVAL,   22,  "invalid argument")

#define AS_ENUM(name, code, msg)  name = code,
enum err_code { ERROR_LIST(AS_ENUM) };

#define AS_CASE(name, code, msg)  case name: return #name ": " msg;
static const char *err_str(enum err_code e)
{
    switch (e) {
    ERROR_LIST(AS_CASE)
    }
    return "unknown";
}

#define AS_COUNT(name, code, msg) + 1
enum { ERR_COUNT = 0 ERROR_LIST(AS_COUNT) };

int main(void)
{
    printf("ERR_COUNT = %d\n", ERR_COUNT);
    printf("%s\n", err_str(ERR_BUSY));
    printf("%s\n", err_str(ERR_INVAL));
    printf("%s\n", err_str(99));
    return 0;
}
$ gcc -Wall -o xmacro xmacro.c && ./xmacro
ERR_COUNT = 4
ERR_BUSY: device busy
ERR_INVAL: invalid argument
unknown
$ gcc -E -P xmacro.c | grep -E '^enum|case ERR_OK'
enum err_code { ERR_OK = 0, ERR_NOMEM = 12, ERR_BUSY = 16, ERR_INVAL = 22, };
    case ERR_OK: return "ERR_OK" ": " "success"; case ERR_NOMEM: return "ERR_NOMEM" ": " "out of memory"; case ERR_BUSY: return "ERR_BUSY" ": " "device busy"; case ERR_INVAL: return "ERR_INVAL" ": " "invalid argument";
enum { ERR_COUNT = 0 + 1 + 1 + 1 + 1 };

ERROR_LIST에 한 줄만 추가하면 enum, switch 문, 개수가 모두 함께 바뀐다.

_Generic — 타입에 따라 다른 식 선택

C11의 _Generic은 컴파일 타임에 인자 타입을 보고 연관 목록 중 하나를 고른다. C++의 오버로딩 흉내를 낼 때 쓴다.

#include <stdio.h>

#define TYPE_NAME(x) _Generic((x),      \
    int:          "int",                \
    unsigned int: "unsigned int",       \
    long:         "long",               \
    double:       "double",             \
    char *:       "char *",             \
    default:      "other")

#define PRINT(x) _Generic((x),          \
    int:    print_int,                  \
    double: print_double,               \
    char *: print_str)(x)

static void print_int(int v)          { printf("int    : %d\n", v); }
static void print_double(double v)    { printf("double : %.3f\n", v); }
static void print_str(char *v)        { printf("string : %s\n", v); }

int main(void)
{
    char name[] = "junorion";

    printf("%s %s %s %s %s\n",
           TYPE_NAME(1), TYPE_NAME(1u), TYPE_NAME(1L),
           TYPE_NAME(1.0f), TYPE_NAME('a'));

    PRINT(42);
    PRINT(3.14159);
    PRINT(name);
    return 0;
}
$ gcc -Wall -std=c11 -o generic generic.c && ./generic
int unsigned int long other int
int    : 42
double : 3.142
string : junorion

1.0ffloat라서 default로 빠졌고, 문자 상수 'a'는 C에서 int 타입이다. default가 없는데 맞는 타입이 없으면 컴파일 에러가 난다.

    long long big = 1;
    PRINT(big);
$ gcc -std=c11 -o /dev/null generic_err.c
generic_err.c: In function ‘main’:
generic_err.c:11:27: error: ‘_Generic’ selector of type ‘long long int’ is not compatible with any association

조건부 컴파일과 미리 정의된 매크로

#include <stdio.h>

#ifdef DEBUG
# define DBG(fmt, ...) \
    fprintf(stderr, "DBG %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#else
# define DBG(fmt, ...) do { } while (0)
#endif

#if defined(__linux__) && defined(__x86_64__)
# define PLATFORM "linux/x86_64"
#elif defined(__linux__) && defined(__aarch64__)
# define PLATFORM "linux/arm64"
#else
# define PLATFORM "unknown"
#endif

#if !defined(BUF_SIZE)
# define BUF_SIZE 256
#endif

int main(void)
{
    DBG("start, BUF_SIZE=%d", BUF_SIZE);
    printf("platform=%s BUF_SIZE=%d\n", PLATFORM, BUF_SIZE);
    DBG("end");
    return 0;
}
$ gcc -Wall -o debug debug.c && ./debug
platform=linux/x86_64 BUF_SIZE=256

$ gcc -Wall -DDEBUG -DBUF_SIZE=4096 -o debug debug.c && ./debug
DBG debug.c:24: start, BUF_SIZE=4096
platform=linux/x86_64 BUF_SIZE=4096
DBG debug.c:26: end

-DNAME#define NAME 1, -DNAME=VAL#define NAME VAL과 같다. 컴파일러가 미리 정의해두는 매크로와, 소스에서 최종적으로 어떤 값이 정의됐는지는 -dM -E로 확인한다.

$ echo | gcc -dM -E - | grep -E '__x86_64__|__linux__ |__GNUC__ |__SIZEOF_LONG__|__BYTE_ORDER__ |__STDC_VERSION__'
#define __GNUC__ 13
#define __SIZEOF_LONG__ 8
#define __x86_64__ 1
#define __STDC_VERSION__ 201710L
#define __linux__ 1
#define __BYTE_ORDER__ __ORDER_LITTLE_ENDIAN__

$ echo | gcc -dM -E - | wc -l
401

$ gcc -DDEBUG -dM -E debug.c | grep -E 'define (DBG|PLATFORM|BUF_SIZE)'
#define BUF_SIZE 256
#define DBG(fmt,...) fprintf(stderr, "DBG %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#define PLATFORM "linux/x86_64"

ARRAY_SIZE와 _Static_assert

#include <stdio.h>
#include <stddef.h>

#define ARRAY_SIZE(arr)  (sizeof(arr) / sizeof((arr)[0]))

/* 포인터가 들어오면 컴파일 에러 (GCC/Clang) */
#define __must_be_array(a) \
    (sizeof(struct { int:(-!!__builtin_types_compatible_p(typeof(a), typeof(&(a)[0]))); }))
#define ARRAY_SIZE_SAFE(arr) (ARRAY_SIZE(arr) + __must_be_array(arr) * 0)

struct packet {
    unsigned char type;
    unsigned int  len;
    char          payload[8];
};
_Static_assert(sizeof(struct packet) == 16, "packet layout changed");

static void show(int table[])
{
    printf("in show(): ARRAY_SIZE(table) = %zu\n", ARRAY_SIZE(table));
}

int main(void)
{
    int table[10];
    printf("in main(): ARRAY_SIZE(table) = %zu\n", ARRAY_SIZE(table));
    show(table);
    return 0;
}
$ gcc -o array_size array_size.c && ./array_size
array_size.c: In function ‘show’:
array_size.c:4:33: warning: ‘sizeof’ on array function parameter ‘table’ will return size of ‘int *’ [-Wsizeof-array-argument]
    4 | #define ARRAY_SIZE(arr)  (sizeof(arr) / sizeof((arr)[0]))
in main(): ARRAY_SIZE(table) = 10
in show(): ARRAY_SIZE(table) = 2

매개변수 int table[]는 실제로 int *라서 경고만 나고 빌드는 통과한다. 커널의 __must_be_array() 방식을 쓰면 포인터가 들어왔을 때 컴파일 에러로 막을 수 있다.

printf("in show(): %zu\n", ARRAY_SIZE_SAFE(table));
$ gcc -o /dev/null array_size_safe.c 2>&1 | grep error
array_size_safe.c:8:20: error: negative width in bit-field ‘<anonymous>’

_Static_assert는 구조체 레이아웃처럼 컴파일 타임에 확정되는 조건을 검사한다. 조건을 == 12로 바꾸면 빌드가 멈춘다.

$ sed 's/== 16/== 12/' array_size.c > as3.c && gcc -o /dev/null as3.c 2>&1 | grep error
as3.c:16:1: error: static assertion failed: "packet layout changed"

주의사항

함정증상대응
인자·전체 괄호 누락우선순위가 바뀌어 엉뚱한 값((x) * (x))처럼 둘 다 감싼다
인자 이중 평가i++, 함수 호출이 두 번 실행({ typeof(a) _a = (a); ... }) 또는 static inline 함수
중괄호만으로 여러 문장 묶기else without a previous ifdo { ... } while (0)
#/## 인자 미전개__LINE__이 그대로 문자열이 됨매크로를 한 단계 더 거친다(XSTR)
매크로 내부 지역 변수 이름 충돌SWAP(_t, x)처럼 같은 이름을 넘기면 오동작밑줄 접두사 등 충돌하기 어려운 이름 사용
문장 표현식·typeof·##__VA_ARGS__-std=c11 -pedantic에서 경고/에러이식성이 필요하면 __typeof__, __VA_OPT__(C23) 또는 inline 함수
ARRAY_SIZE에 포인터 전달배열 길이 대신 sizeof(ptr)/sizeof(elem)-Werror=sizeof-array-argument 또는 __must_be_array
디버거에서 매크로가 안 보임gdb에서 매크로 이름으로 조회 불가-g3로 빌드하면 info macro/macro expand 사용 가능

타입 안전성이 필요하고 성능 때문에 매크로를 고른 경우라면, 대부분 static inline 함수가 같은 성능에 이중 평가 문제도 없다. 매크로는 #/##, __FILE__/__LINE__, X-macro처럼 함수로는 할 수 없는 일에 남겨두는 것이 좋다.

마무리

목적쓸 기법
전개 결과 확인gcc -E -P, gcc -dM -E
안전한 식 매크로괄호 + 문장 표현식(typeof)
여러 문장 매크로do { } while (0)
로그 매크로__func__/__LINE__ + ##__VA_ARGS__ 또는 __VA_OPT__
enum ↔ 문자열 동기화X-macro
타입별 분기_Generic
컴파일 타임 검사_Static_assert, __must_be_array

매크로 버그는 대부분 소스만 봐서는 보이지 않고 전개 결과를 보면 바로 보인다. 이상한 동작이 나오면 먼저 gcc -E부터 돌려보는 습관이 가장 효과적이다.

참고

답글 남기기