Egloos | Log-in


실전! 디스어셈블리로 디버깅하기

C/C++코드로 작성된 실행파일의 릴리즈 빌드의 경우 디버그 빌드와 달리 컴파일러가 성능을 위한 다양한 최적화를 통해 기계어 코드를 만들어 내므로 크래쉬 덤프에서 각 소스코드의 변수에 해당하는 모든 값을 제대로 볼 수 없는 경우가 빈번하다.
많은 프로그래머들이 디버거에서 쉽게 그 내용을 볼 수 없다는 이유로 근성 없이 크래쉬 덤프 보기를 포기하곤 하나, 빨리 해결하지 못하면 자신의 퇴근 시각이 늦춰지고 팀 전체가 괴로워질 뿐이다.
그나마 있는 정보를 가지고 문제를 빨리 잡기 위해서는 실행파일의 기계어 코드 수준에서 변수나 메모리의 내용을 확인해야 하는것이 훨씬 건강에 이로울 수 있다. 처음이 두려울 뿐이지 한두번 하다보면 금방 익숙해지게 마련이다. 더욱 익숙해지면 변수의 값이 이상하다고 생각함과 동시에 Alt-8을 누르는 자신을 발견하게 될지도 모른다.

비주얼스튜디오의 디버그 모드에서 소스 코드에 중단점이 걸려 있을 때, Alt-8를 누르면 각 소스코드 라인별로 disassembly를 볼 수 있다. 여기부터는 CPU와 C++ 컴파일러에 대한 지식이 다수 필요한데, 초보적인 내용은 모두 알고 있다고 가정하고 기술하고자 한다. 혹시 각 레지스터의 용도에 대해 가물가물한 사람이라면 다음의 문서를 참고하자. http://en.wikipedia.org/wiki/X86

어셈블리 코드에 익숙한 사람이라고 해도, 고급 언어로부터 컴파일 된 결과에 대해서는 익숙치 않을 수 있으므로 이들의 호출 규약에 대해서는 아래 문서를 참고하면 된다.
http://en.wikipedia.org/wiki/X86_calling_conventions#x86-64_calling_conventions

컴파일러에 의한 최적화 때문에 각 레지스터와 스택 내 주소는 기계어 코드 단락마다 다른 용도로 사용될 수 있으므로, 기계어 코드를 따라가며 원하는 변수가 어느 레지스터 혹은 어느 메모리에 저장되어 있는지 유추해 내야 한다. 그나마 이 과정을 쉽게 추적할 수 있는 몇가지 규칙을 설명하면 다음과 같다.


스택부터 시작
(스택이 거꾸로 증가한다는 사실을 모르는 사람이라면 우선 어셈블리 기본을 먼저 공부하시길 추천)

각 함수에 대한 콜스택 내에서 그나마 가장 신뢰할 수 있는 레지스터는 EBP와 ESP이다.(EBP는 늘 현재 함수 범위의 최상위 스택을 가리키며, ESP는 현재 함수 범위에서 사용된 마지막 스택의 위치를 가리킨다) 비주얼 스튜디오 디버거의 경우 콜스택의 실행 위치를 옮길 때 마다 자동으로 스택을 통해 EBP와 ESP를 찾아서 갱신해 주므로 이것을 바탕으로 하여, 스택의 어느 영역에 현재 함수와 연관된 변수들이 저장되어 있는지 유추할 수 있다. 다만 지역 변수의 경우 기계어 수준의 최적화를 통해 레지스터/스택 등에 예측이 어렵게 할당되므로, 다른 방법을 먼저 시도함이 좋을 것이다.
 함수에 인자로 전달되는 값들은 모두 스택을 통해 전달되므로 이들은 대체로 신뢰할 만 하다. 다만 스택을 통해 전달된 인자라 해도, 컴파일러 최적화에 의해 스택 내의 값이 피호출자(callee) 내에서 덮어씌워지는 경우도 있으므로 스택 내의 값이 최초 호출된 인자의 값 그대로라고 맹신할수만은 없다는 것에 주의가 필요하다.
스택은 (함수 인자, 함수 반환 주소, EBP) 를 기본으로 하며, 여기에 부가하여 각종 레지스터 값들이 임시 저장된다. 어찌보면 크래쉬 당시의 가장 많은 정보가 이곳에 들어 있다. (함수 인자들은 가장 뒤의 인자부터 먼저 push 됨)
예를 들어, 엉뚱한 주소로의 함수 호출로 인해 크래쉬가 발생한 경우 다음과 같은 과정을 통해 그 원인을 파악할 수 있을 것이다.
1. 가장 마지막 호출에서의 ESP가 가리키는 메모리 주소를 통해 스택 꼭대기를 찾음
2. (ESP 근처의 메모리에서 이전 호출자의 주소를 찾아냄 - 사실 대부분의 경우 VS 디버거의 콜스택이 이미 그 위치를 보여줄 것이나, 그 정보가 의심스러운 경우 이 방법을 사용할 수 있다)
3. 분명 call edx와 같은 동적인 주소를 통해 호출이 일어났을 것이므로, 호출이 일어난 주소를 갖고 있는 레지스터를 찾음
4. 그 레지스터가 어떻게 계산이 되었으며, 소스코드의 어떤 문제에 의해 그 값이 나올 수 있는지 유추
5. 유추한 가설에 확신이 들면 소스를 고치고 커밋


this 포인터 찾기

잘못된 값으로 인한 크래쉬가 발생했을 때, 당장 그 값에 해당하는 변수의 위치를 찾기보다는 클래스의 this 포인터 등 좀 더 큰 범위의 값을 살펴봄으로 시작하는 것이 빠른 경우가 많다.
VC++이 stdcall 방식으로 클래스의 멤버 함수를 호출 할 때, this 포인터는 ECX를 통해 전달된다. 따라서 release 빌드의 크래쉬 덤프에서 this 값이 제대로 보이지 않는다면 disassembly를 통해 ECX에 값이 할당되고 전달되는 통로를 추적하면 this를 찾을 수 있다. 호출자(caller)에서 찾고자 하는 경우, call 명령 이전에 ECX에 할당되는 값 혹은 메모리가 무엇인지 추적하는 것이 쉬울 것이며, 피호출자(calee)에서 찾고자 하는 경우, 함수 진입부에서 ECX를 stack에 push 하는 경우 혹은 현재 breakpoint에서 가까운 위치에서 this 포인터를 사용하는 메모리나 레지스터를 찾으면 된다. 간혹 this가 ESI, EDI, EBX 등에 할당된 경우에는 더욱 쉽다. 이 세 레지스터들은 다른 함수로 호출이 일어나더라도 스택을 통해 이전 호출자의 값을 유지하는 것이 보장되며, 비주얼스튜디오가 이를 자동으로 찾아주기 때문이다.
this가 어느 레지스터에 들어 있는지 찾기 쉬운 방법중의 하나는 클래스의 멤버에 접근하는 소스에 대한 disassembly에서 dword ptr [ecx+10h] 와 같은 클래스 내 멤버 변수에 대한 상수 offset이 나타나는 부분을 찾는 것이다. (이 경우에 대한 예상이 맞다면 ecx가 바로 this 포인터일 것이다)
그러나 만약 찾고 있는 객체의 포인터가 상위 콜스택에서 함수 호출 인자로 전달된 적이 있다면 구태여 이런 수고를 할 필요가 없을 것이다.


찾은 메모리 주소/포인터로부터 소스코드 수준의 변수 내용 보기

위의 복잡한 과정들은 사실상 소스코드 상에서 표현된 변수내용을 보기위한 준비작업이었다. 이제, 디버거의 Watch 윈도우에 찾아낸 포인터를 대상 변수 타입으로 캐스팅하여 살펴보면 된다. 예) (Namespace::MyClass*)ecx


Watch 윈도우의 확인으로 충분한 것은 아니다

VC++의 Watch 윈도우는 위와 같이 캐스팅 된 포인터를 나름의 방법으로 보기 쉽게 꾸며준다. 다만, 이것에 의해 실제 문제점이 가려지는 경우가 있는데, 한 예를 들자면 std::vector가 대표적이다. 사실 std::vector는 내부적으로 첫요소의 주소, 마지막요소의 주소 만으로 배열을 표현한다. VC++은 이러한 내용을 감추고 마치 count와 pointer가 멤버인것처럼 표현하기 때문에 vector의 내부 포인터에 문제가 생긴경우 쉽게 알아차리기 어렵다. 따라서 이와 같은 복잡한 컨테이너의 내용이 의심스럽다면 그 주소의 memory 내용을 memory view를 통해 직접 살펴봄이 좋다. 가령, vector에 대해 쓰레드 동시 접근 문제가 있다면 vector의 first, last 멤버가 전혀 엉뚱하게 깨진 것으로 보일 것이다.

이 글과 관련있는 글을 자동검색한 결과입니다 [?]

by 김성균 | 2011/11/27 08:32 | 일(Work) | 트랙백 | 덧글(0)

트랙백 주소 : http://littles.egloos.com/tb/3267067
☞ 내 이글루에 이 글과 관련된 글 쓰기 (트랙백 보내기) [도움말]

:         :

:

비공개 덧글

◀ 이전 페이지          다음 페이지 ▶