Windows 64-bit Calling Convention (호출 규약)
글 작성자: cjwoov
반응형
1. 왜 알아야 할까?
릴리즈(Release) 빌드에서 서버가 죽었을 때, 범인을 찾기 위해서
금요일 밤 10시, 동시 접속자 5천 명인 서버가 갑자기 크래시(Crash)를 내며 죽었습니다. 남은 건 OS가 뱉어낸 메모리 덤프 파일(.dmp) 하나뿐입니다.
Visual Studio나 WinDbg로 덤프를 엽니다. 소스 코드가 멈춘 줄은 가리키고 있는데, 변수 값을 확인하려고 마우스를 올려보면 이렇게 뜹니다.
<optimized out> (최적화되어 값을 알 수 없음)
릴리즈 빌드는 속도를 위해 컴파일러가 변수들을 다 날려버리고 레지스터에 쑤셔 넣습니다.
지역 변수 창을 봐도 다 쓰레기 값입니다. "도대체 어떤 유저가, 어떤 스킬을 쓰다가 죽은 건지" 알 길이 없습니다.
이때 Calling Convention(호출 규약)을 알면, CPU 레지스터(Register) 창을 열어서
"아! RCX 레지스터에 있는 주소가 죽은 유저 객체(this)고, RDX에 있는 주소가 때린 유저 객체구나!"
하고 역추적할 수 있습니다.
즉, 최적화된 코드에서 길을 찾는 지도(Map)가 바로 호출 규약입니다.
2. 배경
- 과거 (32비트 시절): "스택(Stack)의 시대"
- 옛날엔 함수에 인자를 넘길 때 무조건 메모리(Stack)에 넣어서 넘겼습니다. (예: __cdecl, __stdcall)
- 문제점: 메모리(RAM)는 CPU 입장에서 너무 느립니다. 함수를 호출할 때마다 메모리에 접근하니 성능이 떨어졌습니다.
- 현재 (64비트 시대): "레지스터(Register)의 시대"
- 64비트 CPU가 되면서 빠르고 널널한 '레지스터(CPU 내부의 초고속 메모리)'가 많아졌습니다.
- MS의 결단: "야, 이제 메모리(스택) 쓰지 마! 앞에 있는 인자 4개는 무조건 초고속 레지스터에 직접 넣어서 보내!"
- 이렇게 탄생한 것이 Windows x64의 __fastcall 기반 단일 호출 규약입니다. 빠르지만, 사람이 디버깅할 땐 레지스터를 직접 까봐야 하는 불편함이 생겼죠.
3. 개념
복잡한 건 빼고, 실무에서 덤프를 깔 때 필요한 규칙 3가지만 알고있으면 됩니다.
- 함수를 호출할 때, 앞의 4개 인자는 메모리(스택)가 아니라 CPU 레지스터에 들어갑니다. 그 순서는 정해져 있습니다.
- 1번째 인자 -> RCX
- 2번째 인자 -> RDX
- 3번째 인자 -> R8
- 4번째 인자 -> R9
- (5번째 인자부터는 자리가 없어서 기존처럼 스택 메모리에 들어갑니다.)
- C++의 비밀 (this 포인터): C++에서 클래스의 멤버 함수를 호출할 때, 눈에 보이지 않는 첫 번째 인자는 무조건 자기 자신(this 포인터)입니다.
- 즉, 멤버 함수 내에서 RCX 레지스터는 항상 this 객체의 메모리 주소를 가리킵니다.
- 그림자 공간 (Shadow Space): 호출자는 레지스터로 인자를 넘기더라도, 스택에 '만약을 대비한 32바이트(인자 4개 크기)의 빈 공간'을 무조건 만들어 두어야 합니다. (이건 디버깅할 때 가끔 쓰이지만, 지금은 몰라도 됩니다.)
4. 실전 활용법
서버에서 아래와 같은 전투 로직을 처리하다가 서버가 터졌다고 가정해 봅시다.
[소스 코드]
class User {
public:
int mHp;
int mUserId;
// 공격받는 함수
void TakeDamage(User* attacker, int damage, int skillId, int hitType) {
// ... 뭔가 복잡한 로직 ...
// 여기서 크래시(Crash) 발생!!
this->mHp -= damage;
}
};
// 메인 로직에서 누군가 호출함
targetUser->TakeDamage(attackerUser, 500, 1045, 2);
[크래시 발생 후 디버깅 상황]
서버가 죽어서 덤프를 열었습니다.
- Visual Studio 지역 변수 창: targetUser = <optimized out>, attackerUser = <optimized out>
- 변수 값이 다 날아가서 멘붕이 옵니다. 누가 누굴 때린 건지 알 수가 없습니다.
[호출 규약을 이용한]
이때, 상단 메뉴에서[디버그] -> [창] -> [레지스터(Registers)]를 엽니다.
방금 배운 지식을 대입해 봅니다. C++ 멤버 함수의 인자는 어떻게 들어간다고 했죠?
- 숨겨진 1번째 인자 (this): targetUser -> RCX 레지스터에 있음!
- 명시적 1번째 인자 (attacker): attackerUser -> RDX 레지스터에 있음!
- 명시적 2번째 인자 (damage): 500 -> R8 레지스터에 있음!
- 명시적 3번째 인자 (skillId): 1045 -> R9 레지스터에 있음!
해결 과정:
- 레지스터 창을 보니 RCX 값이 0x000001A4B0C0 입니다.
- 디버거의 [조사식(Watch)] 창에 (User*)0x000001A4B0C0 이라고 칩니다.
- 짜잔! 날아갔던 targetUser의 정보(HP, 유저 아이디 등)가 완벽하게 복원되어 보입니다.
- R8 레지스터를 보니 0x1F4 (십진수로 500)가 들어있습니다. "아! 대미지 500짜리 스킬을 맞았구나!"
- RDX 주소를 까서 때린 놈(attackerUser)이 누군지 찾아내어 버그의 원인을 밝혀냅니다.
반응형
'Development > System' 카테고리의 다른 글
| 디버거(Debugger) 브레이크 포인트(Break Point)의 구현 원리 (0) | 2026.02.27 |
|---|---|
| Windows DPC(Deferred Procedure Call), ISR(Interrupt Service Routine) (0) | 2026.02.27 |
| 컨텍스트 스위칭(Context Switching)에 대한 고찰, 정리 (0) | 2025.11.06 |
| [윈도우즈 시스템 프로그래밍] 3. 프로그램 구현 관점에서의 32비트와 64비트 & 오류의 확인 (0) | 2019.06.30 |
| [윈도우즈 시스템 프로그래밍] 3. WIN32 vs WIN64 (0) | 2019.06.27 |
댓글
이 글 공유하기
다른 글
-
디버거(Debugger) 브레이크 포인트(Break Point)의 구현 원리
디버거(Debugger) 브레이크 포인트(Break Point)의 구현 원리
2026.02.27 -
Windows DPC(Deferred Procedure Call), ISR(Interrupt Service Routine)
Windows DPC(Deferred Procedure Call), ISR(Interrupt Service Routine)
2026.02.27 -
컨텍스트 스위칭(Context Switching)에 대한 고찰, 정리
컨텍스트 스위칭(Context Switching)에 대한 고찰, 정리
2025.11.06 -
[윈도우즈 시스템 프로그래밍] 3. 프로그램 구현 관점에서의 32비트와 64비트 & 오류의 확인
[윈도우즈 시스템 프로그래밍] 3. 프로그램 구현 관점에서의 32비트와 64비트 & 오류의 확인
2019.06.30