[C++] MSVC Array Param Mangling
MSVC에서 만난 LNK2019 — int arr[][2] vs int (*arr)[2]는 정말 같은가?#
들어가며#
C/C++을 좀 써본 사람이라면 한 번쯤 들어봤을 이야기.
“함수 파라미터에서 배열 표기는 전부 ‘가짜’다.
int arr[3]이라고 써도 사실은int* arr로 조정되어 처리된다.”
이건 표준이 명시하는 사실이고, 나도 당연히 그렇게 알고 있었다. 그래서 다음 두 함수 정의가 완전히 같은 함수라고 생각했다.
void process(int mCoordinates[][2]) // (1)
void process(int (*mCoordinates)[2]) // (2)
그런데 Visual Studio에서 (2)로 쓰면 링크 에러가 나고, (1)로 바꾸면 정상 컴파일된다. 분명히 같은 함수일 텐데 왜?
결론부터 말하면 — MSVC는 이 둘을 다른 심볼로 mangling한다. 표준을 어기는 동작이고, g++/clang에서는 발생하지 않는다. 이 글은 그 디버깅 과정을 정리한 기록이다.
name mangling이란,
C++은 함수 오버로딩, 네임스페이스, 템플릿 같은 기능을 지원한다. 그래서 소스 코드에서 같은 이름의 함수라도 파라미터 타입이 다르면 서로 다른 함수가 된다. 그런데 링커는 결국 심볼 이름 하나로 함수를 식별하기 때문에, 컴파일러는 함수의 이름·파라미터 타입·반환 타입·네임스페이스 등을 모두 인코딩해서 유일한 심볼 이름을 만들어 낸다. 이 과정을 name mangling이라고 부른다.
예를 들어
void f(int)와void f(double)은 소스에선 같은 이름이지만 mangling을 거치면 서로 다른 심볼이 되어 링커가 구분할 수 있다. mangling 규칙은 ABI에 종속되어 컴파일러마다 다르다 — g++/clang은 Itanium ABI를 따르고(_Z...형태), MSVC는 자체 규칙을 쓴다(?...@@...형태).
ABI란,
ABI(Application Binary Interface)는 컴파일된 바이너리들끼리 어떻게 맞물려 동작할지를 정한 약속이다. 소스 코드 레벨의 규칙이 API라면, 기계어 레벨의 규칙이 ABI다. 여기엔 함수 호출 규약(인자를 어느 레지스터/스택에 어떻게 넘기고 반환값은 어디에 두는지), 구조체·클래스의 메모리 레이아웃, 가상 함수 테이블 구조, 예외 처리 메커니즘, 그리고 위에서 말한 name mangling 규칙까지 포함된다.
같은 ABI를 따르는 컴파일러끼리는 한쪽에서 만든 .o/.obj/.lib을 다른 쪽에서 가져다 링크할 수 있다. 반대로 ABI가 다르면 똑같은 소스를 컴파일해도 바이너리는 서로 호환되지 않는다. 윈도우에서 MSVC로 빌드한 .lib을 MinGW에서 그대로 못 쓰는 이유, 같은 g++이라도 메이저 버전이 바뀌면 STL 호환성 이슈가 생기던 이유가 다 ABI 차이에서 온다.
표준이 말하는 것#
C++ 표준 [dcl.fct]/5는 함수 파라미터 타입에 대해 두 가지 조정을 강제한다.
- Array-to-pointer decay —
T[N]형태의 파라미터는T*로 조정된다. - Top-level cv-qualifier 제거 — 파라미터 타입의 최상위
const/volatile은 함수 타입을 만들 때 제거된다.
그래서 다음은 모두 같은 함수 타입 void(int*)을 갖는다.
void f(int a[10]);
void f(int a[]);
void f(int *a);
void f(int * const a); // top-level const는 무시됨
배열이 2차원이어도 마찬가지다.
void f(int a[][2]); // → int(*)[2]
void f(int (*a)[2]); // → int(*)[2]
void f(int (* const a)[2]); // → int(*)[2] (top-level const 제거)
따라서 int a[][2]와 int (*a)[2]는 함수 시그니처 차원에서 완전히 동일해야 한다. name mangling 결과도 같아야 한다.
그런데 실제로 일어난 일#
문제 상황은 이랬다.
main.cpp:
extern void process(int co_bak[][2]);user_mojo.cpp:
void process(int (*mCoordinates)[2]) { ... }빌드하면 다음 에러가 떴다.
error LNK2019: unresolved external symbol
"void __cdecl process(int (* const)[2])" (?process@@YAXQEAY01H@Z)
referenced in function main여기서 두 가지가 눈에 띈다.
- 사람이 읽기 좋은 시그니처에
int (* const)[2]— top-levelconst가 붙어 있다. - mangling된 이름이
?process@@YAX**Q**EAY01H@Z—Q로 시작한다.
MSVC name mangling 규칙에서:
P= pointer (mutable)Q= pointer (top-level const, 즉* const)
즉 MSVC는 extern void process(int co_bak[][2])라는 선언을 보고 파라미터 타입을 int (* const)[2]로 mangling한 것이다. 표준대로라면 top-level const는 제거되어 P가 나와야 하는데, MSVC는 Q로 처리해 버렸다.
반면 정의 쪽 void process(int (*mCoordinates)[2])은 정상적으로 P로 mangling되어 ?process@@YAXPEAY01H@Z가 된다.
선언 (main.cpp) : ?process@@YAX Q EAY01H@Z
정의 (user_mojo) : ?process@@YAX P EAY01H@Z
↑
서로 다른 심볼심볼이 다르니 링커 입장에서는 정의가 없는 함수를 호출하는 셈이고, LNK2019가 나는 거다.
왜 MSVC만 이럴까#
MSVC는 오래전부터 이런 동작을 가지고 있다. 배열 형태로 쓴 함수 파라미터에 대해 decay된 포인터에 암묵적으로 const를 붙이고, mangling 단계에서도 그 const를 떼지 않는다. 표준 비준수지만 ABI 안정성 때문에 고쳐지지 않고 그대로 유지되는 것으로 보인다.
g++(Itanium ABI)와 clang은 표준대로 동작하므로 두 형태가 동일한 mangling된 이름을 갖고, 어느 형태로 섞어 써도 링크된다. 그래서 리눅스에서 잘 돌아가던 코드를 MSVC로 옮기다가 이 문제를 처음 만나는 경우가 많다.
요약하면:
| 컴파일러 | int a[][2] |
int (*a)[2] |
같은 심볼? |
|---|---|---|---|
| g++ / clang | int(*)[2] |
int(*)[2] |
✅ |
| MSVC | int(* const)[2] |
int(*)[2] |
❌ |
해결 방법#
선언과 정의의 표기를 둘 중 하나로 통일하면 된다.
옵션 A — 양쪽 다 배열 형태로
// main.cpp
extern void process(int co_bak[][2]);
// user_mojo.cpp
void process(int mCoordinates[][2]) { ... }옵션 B — 양쪽 다 포인터 형태로
// main.cpp
extern void process(int (*co_bak)[2]);
// user_mojo.cpp
void process(int (*mCoordinates)[2]) { ... }어느 쪽이든 mangling된 이름이 일치하면 링크된다.
마무리#
“함수 파라미터에서 배열 표기는 가짜고, 결국 포인터로 조정된다"는 명제는 언어 표준 차원에서는 여전히 맞다. 다만 MSVC는 이 조정 과정에서 표준을 어기고, 그 결과가 mangling된 심볼 이름까지 영향을 준다.
배운 점:
- 멀티 컴파일러 환경에서 헤더의
extern선언과.cpp의 정의는 파라미터 표기 형태까지 일치시키는 게 안전하다. - LNK2019를 만나면 mangling된 이름을 직접 비교해 보자. 사람이 읽는 시그니처는 거의 비슷해 보여도,
Q/P같은 한 글자에서 갈리는 경우가 있다. - 표준이 보장한다고 모든 컴파일러가 똑같이 동작하는 건 아니다. ABI 호환성 때문에 한 번 굳어진 quirk는 잘 안 고쳐진다.