글 작성자: 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가지만 알고있으면 됩니다.

  1. 함수를 호출할 때, 앞의 4개 인자는 메모리(스택)가 아니라 CPU 레지스터에 들어갑니다. 그 순서는 정해져 있습니다.
    • 1번째 인자 -> RCX
    • 2번째 인자 -> RDX
    • 3번째 인자 -> R8
    • 4번째 인자 -> R9
    • (5번째 인자부터는 자리가 없어서 기존처럼 스택 메모리에 들어갑니다.)
  2. C++의 비밀 (this 포인터): C++에서 클래스의 멤버 함수를 호출할 때, 눈에 보이지 않는 첫 번째 인자는 무조건 자기 자신(this 포인터)입니다.
    • 즉, 멤버 함수 내에서 RCX 레지스터는 항상 this 객체의 메모리 주소를 가리킵니다.
  3. 그림자 공간 (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. 숨겨진 1번째 인자 (this): targetUser -> RCX 레지스터에 있음!
  2. 명시적 1번째 인자 (attacker): attackerUser -> RDX 레지스터에 있음!
  3. 명시적 2번째 인자 (damage): 500 -> R8 레지스터에 있음!
  4. 명시적 3번째 인자 (skillId): 1045 -> R9 레지스터에 있음!

해결 과정:

  1. 레지스터 창을 보니 RCX 값이 0x000001A4B0C0 입니다.
  2. 디버거의 [조사식(Watch)] 창에 (User*)0x000001A4B0C0 이라고 칩니다.
  3. 짜잔! 날아갔던 targetUser의 정보(HP, 유저 아이디 등)가 완벽하게 복원되어 보입니다.
  4. R8 레지스터를 보니 0x1F4 (십진수로 500)가 들어있습니다. "아! 대미지 500짜리 스킬을 맞았구나!"
  5. RDX 주소를 까서 때린 놈(attackerUser)이 누군지 찾아내어 버그의 원인을 밝혀냅니다.
반응형