매크로(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.0f는 float라서 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 if | do { ... } 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부터 돌려보는 습관이 가장 효과적이다.