목차
에러 검출 툴 : 타당하지 못한 메모리 엑세스, System API와 COM 인터페이스에 대한 타당하지 못한 매개 변수 전달, 메모리 누수, 그리고 리소스 누수와 같은 것들을 찾는다.
성능 툴 : 응용 프로그램이 어디에서 느려지는지를 추적하는 데 도움을 준다.
코드 커버리지 툴 : 프로그램을 실행할 때 실행되지 않은 소스 코드를 보여준다. 코드 커버리지 정보는 만약 여러분이 버그를 찾고 있을 때, 실행중인 코드에서만 버그를 찾고 싶은 경우에 유용하다.
Compuware NuMega 에서는 BoundsChecker(에러 검출 툴), TrueTime(성능 툴), 그리고 TrueCoverage(코드 커버리지 툴)를 만든다.
Rational은 Purify(에러 검출), Quantify(성능), 그리고 PureCoverate(코드 커버리지)를 만든다.
만약 여러분이 디버깅을 위해서 서드파티 툴을 사용하고 있지 않다면, 디버깅하는 데 필요 이상으로 시간을 보내고 있는 것이다.
버전 컨트롤과 버그 트래킹 시스템은 프로젝트의 히스토리를 제공하기 때문에 가장 중요한 두 가지 기반 구조 툴이다. 버그 트래킹 시스템에서 눈에 띄는 버그와 버그 수정 비율을 모니터링함으로써 제품이 언제쯤 배포될 수 있는지를 보다 확실하게 예상할 수 있다. 버전 컨트롤 시스템을 이용하면 "코드 교반기"에 대한 개념이나 변경된 양을 얻을 수 있으며 얼마만큼의 추가적인 테스트가 필요한지를 확인할 수 있다.
디버깅 심볼은 디버거가 여러분에게 소스와 줄 정보, 변수 이름, 그리고 프로그램에 대한 데이터 형식 정보를 보여주기 위한 데이터이다. 그러한 모든 정보는 모듈과 연관된 .PDB(Program Database) 파일에 저장된다. 릴리즈 빌드를 포함한 모든 빌드를 완전한 디버깅 심볼로 빌드해야 한다. 물론 릴리즈 빌드를 심볼로 디버깅하는 방법은 단점이 있다. 예를 들어, JIT 컴파일러나 네이티브 컴파일러가 생성하는 최적화된 코드는 실행 코드에 있는 실행 흐름과 항상 일치하지는 않을 것이기 때문에, 여러분은 아마도 릴리즈 코드를 한 단계씩 실행하는 것이 디버그 코드를 한 단계씩 실행하는 것보다 약간 더 어렵다는 것을 알게 될 것이다. 네이티브 릴리즈 빌드에서 주의해야 하는 또 다른 문제점은 때때로 컴파일러가 여러분이 스택 레지스터를 최적화하기 때문에 디버그 빌드에서와 달리 완전한 호출 스택을 확인할 수 없다는 점이다. 또한 여러분이 바이너리에 디버깅 심볼을 추가할 때, 이 정보가 .PDB 파일을 구별하는 디버그 섹션 문자열을 계산하기 위해서 약간 커진다는 점을 주의해야 한다. 하지만 몇 바이트 정도 증가하는 것은 버그를 빠르게 해결할 수 있는 능력에 비교해보았을 때 무시해도 좋다.
이 기능은 마법사가 생성한 프로젝트에서 기본적으로 활성화되어 있어야 하지만, 릴리즈 빌드에서 디버그 심볼을 활성화하는 방법은 상당히 쉽다< 비주얼 스튜디오 2003 버전 >. C# 프로젝트의 경우에는 프로젝트 속성 대화 상자를 열고 [구성속성] 폴더를 선택한다. [구성]속성 드롭다운 리스트에서 [모든 구성]이나 [Release]를 선택한다. [구성 속성] 폴더에서 [빌드] 속성 페이지로 이동한 다음 [디버깅 정보 생성] 필드를 'True'로 설정한다. 이 설정은 CSC.EXE에 대해서 /DEBUG:FULL 플래그를 설정한다.
< 비주얼 스튜디오 2008 버전 >
어설션은 디버깅 무기 창고에 있는 가장 중요한 선행적인 프로그래밍 툴이다. 어설션은 프로그램의 특정한 위치에서 특정한 조건이 반드시 참이어야 한다는 것을 선언한다. 어설션은 조건이 false일 때, 실패했다는 것을 나타낸다. 여러분은 일반적인 오류 검사와 함께 어설션을 사용한다. 전형적으로 어설션은 디버그 빌드에서만 실행되고 무슨 조건이 실패했는지를 알려주는 메시지 상자를 띄우는 함수나 매크로이다. 어설션은 개발을 돕고 테스트 엔지니어들이 버그가 존재한다는 사실뿐만 아니라 왜 오류가 발생했는지를 결정할 수 있기 때문에 선형적인 프로그램의 핵심 요소이다. 훌륭한 어설션은 여러분이 문제가 발생한 시점에서 프로그램의 완전한 정보를 볼 수 있도록 타당하지 못한 조건이 어디에 있는지, 그리고 왜 발생했는지를 말해줄 것이다.
어설션은 절대로 프로그램의 변수나 상태를 변경해서는 안 된다. 어설션에서 모든 데이터는 반드시 읽기 전용으로 검사해야 한다. 어설션은 디버그 빌드에서만 활성화되기 때문에, 만약 어설션을 사용하여 데이터를 변경한다면, 디버그 빌드와 릴리즈 빌드가 서로 다르게 작동할 것이며, 이러한 차이점을 찾아내기란 굉장히 어려울 것이다.
예제에서 Debug.Assert는 System.Diagnostic 네임스페이스에 있는 .NET 어설션이며, ASSERT는 네이티브 C++ 메서드다.
어떻게 어설트할 것인가?
어설션을 사용할 때 지켜야 하는 첫 번째 규칙은 한번에 단일 항목만 검사하는 것이다. 만약 하나의 어설션으로 여러 개의 조건을 검사한다면, 어떤 조건이 실패했는지 알 수 있는 방법이 없다.
ASSERT ( ( i > 0 ) && ( NULL != szItem ) &&
( ( iLen > 0 ) && ( iLen < MAX_PATH ) ) &&
( FALSE == IsBadStringPtr ( szItem, iLen ) ) );
...
두 번째 규칙은 조건을 어설트할 때, 해당 조건을 완벽하게 검사할 수 있도록 노력해야 한다.
잘못된 예제에서는 근본적으로 nCount가 0이 아닌지에 대해서만 검사하고 있으며, 이는 어설트해야 하는 조건의 절반에 해당한다. 받아들일 수 있는 값을 명시적으로 검사함으로써, 어설션 자체가 문서 역할을 수행할 수 있도록 보장할 수 있으며, 어설션이 잘못된 데이터를 잡을 수 있다는 것을 보장할 수 있다.
무엇을 어설트할 것인가?
어설션은 절대로 일반 에러 처리를 해서는 안 된다.
첫 번째로 어설트해야 하는 항목은 메서드에 들어오는 매개 변수라는 것을 알아야 한다. 코드에 잘못된 매개 변수가 들어올 때마다 매개 변수 어설션을 추가한다.
어설션이 반드시 필요한 또 다른 영역은 메서드 리턴 값이다. 리턴 값은 메서드가 실패했는지 성공했는지를 알려주기 때문이다. 리턴 값을 전혀 검사하지 않은 채 메서드를 호출하는 것은 문제다.
마지막으로 여러분이 세운 가정을 검증해야 할 때 어설션을 사용하도록 한다. 예를 들어, 만약 어떤 클래스가 3MB의 디스크 공간이 필요하다면, 호출하는 쪽에서 요구하는 사항을 처리할 수 있도록 이 클래스 내부에서 조건 컴파일 어설션으로 이러한 가정을 검사해야 한다.
ILDASM을 이용하면 Microsoft Intermediate Language(MSIL)라고 불리는 .NET용 슈도-어셈블리 언어를 볼 수 있다. .NET은 역공학이나 역컴파일이 쉽다. Visual Studio .NET 2003부터 Microsoft는 PreEmptive Solutions의 "Dotfuscator Community Edition"을 배포하고 있다. 이 버전은 여러분이 작성한 클래스와 메서드의 이름을 바꾸는 것 이외에는 아무것도 하지 않지만, 아마도 이 기능만으로 충분할 것이다. 이 툴은 사용자 인터페이스가 심각할 정도로 나쁘기 때문에 사용하는 데 시간이 좀 걸릴 것이다. 만약 Windows Forms나 콘손 응용 프로그램에서 지적 재산권을 보호해야 된다면, WiseOwl( Winllectural Brent Rector로 알려져 있기도 하다 )이 작성한 Demeanor for .NET이라는 뛰어난 암호화 툴을 사용해 보기를 바란다. Demeanor는 여러분의 지적 재산을 완벽하게 보호할 것이다.
ILDASM은 <Visual Studio .NET Install Directory>\SDK\v2.0\Bin 디렉토리에 있으며(Visual Studio 2005버전) 아마도 이 경로는 기본 PATH 경로에 포함되어 있지 않을 것이다. ILDASM을 실행한 후 디스어셈블하기 위한 파일을 선택하면 초기 화면이 밑의 그림과 같이 표시된다.
만약 파일에 대한 보다 많은 정보를 표시하고 싶다면, /ADV 명령 줄 옵션으로 ILDASM을 실행한다. 이 옵션은 고급 정보 표시 기능을 활성화하고 [보기] 메뉴에 다음과 같은 세 가지 메뉴를 추가한다.
[COR 헤더] 메뉴는 파일 헤더 정보를 [디스어셈블리] 창에 표시한다.
[통계] 메뉴는 PE 파일 통계를 [디스어셈블리] 창에 표시한다.
[메타정보] 메뉴는 뷰에 표시한 정보를 구체적으로 선택할 수 있는 하위 메뉴를 포함한다.
특정 항목의 실질적인 MSIL을 확인하기 위해서, 항목을 더블 클릭하면 새로운 창이 뜰 것이다. 여러분이 어떤 항목에서 더블 클릭하느냐에 따라서, 여러분은 디스어셈블리, 선언 정보, 또는 일반적인 정보를 보게 될 것이다. 밑의 그림은 메서드에 대한 MSIL이다.
CLR은 본질적으로 MSIL 명령을 위한 CPU이다. 전통적인 CPU가 명령을 처리하기 위해서 레지스터와 스택에 의존하는 반면, CLR은 스택만 사용한다. 이 말은 두 개의 숫자를 더하기 위해서 CLR은 두 값을 스택에 모두 로드한 후 명령을 호출한다는 것을 의미한다. 해당 명령은 스택에서 두 값을 제거하고 결과를 스택의 가장 위에 놓는다.
CLR 계산 스택은 스택 슬롯에 어떤 형태의 값이든 보관할 수 있다. 메모리에 있는 값을 스택에 복사하는 작업을 로딩(loading)이라고 하고 스택에 있는 항목을 메모리에 복사하는 것을 스토어링(storing)이라고 한다. Intel CPU와 달리, CLR 스택은 로컬 데이터를 보관하지 않는다. 대신 로컬 데이터는 메모리에 위치한다. 스택은 작업을 처리하고 있는 메서드에 지역적이며 CLR은 메서드 호출 사이에 스택을 저장한다. 마지막으로 스택은 메서드 리턴 값이 있는 곳에도 사용된다.
명령 테이블 전체를 제공하는 대신, 여러분이 자주 보게 될 중요한 명령어와 이들 명령어를 사용하는 예제를 보여주고자 한다.
우선 로딩 명령어부터 살펴보고 명령어가 갖는 옵션에 대해서 설명할 것이다.
ldc 상수를 로드한다.
이 명령은 하드 코딩된 숫자를 스택에 푸시한다. 명령어의 형식은 ldc.size[.num]이며, size는 값의 바이트 크기이고 num은 크기가 i4인 경우에 -128부터 127 사이의 정수를 위한 특수하고 짧은 인코딩이다. 크기는 i4(4바이트 정수형), i8(8바이트 정수형), r4(4바이트 부동 소수점), 또는 r8(부동 소수점)이다. 다음과 같이 이 명령의 opcode 수를 줄이기 위한 다양한 형식이 존재한다.
ldarg 인수를 로드한다.
ldarga 인수의 주소를 로드한다.
인수 번호는 0에서 시작한다. 인스턴스 메서드의 경우, 0번째 인수는 this 포인터이며 첫 번째 인수는 0이 아닌 1부터 시작한다.
ldarg.2 // 2번째 인수를 스택에 로드한다.
// 이 형식에서는 3이 가장 높은 번호이다.
ldloc 로컬 변수를 로드한다.
loloca 로컬 변수 주소를 로드한다.
이 명령은 로컬 변수를 스택에 로드한다. 모든 로컬 변수들은 로컬에 선언되는 순서대로 명시된다. ldloca 명령은 로컬 변수의 주소를 로드한다.
ldfld 클래스의 객체 필드를 로드한다.
ldsfld 클래스의 정적 필드를 로드한다.
이 명령은 객체에 있는 일반 필드나 정적 필드를 스택에 로드한다. 완전한 필드 값이 명시되기 때문에 객체를 MSIL 디스어셈블리하기가 쉽다. ldflda 명령은 해당 필드의 주소를 로드한다.
ldelem 배열의 요소를 로드한다.
이 명령은 1차원 배열에서 지정된 요소(0부터 시작한다)를 로드하여 스택에 로드한다. 앞의 두 명령은 배열 항목과 인덱스를 (순서대로) 스택에 저장한다. ldelem 명령은 배열과 인덱스를 스택에서 제거한 후, 지정된 요소를 스택의 최상위에 저장한다. ldelem 명령 다음에는 타입 필드가 위치한다. 기본 클래스 라이브러리에서 가장 공통적인 타입 필드는 요소를 객체로 얻는 ldelem.ref이다. 또 다른 공통적인 타입으로는 부호가 있는 4바이트 정수로 요소를 얻는 ldelem.i4와 8바이트 정수로 얻는 ldelem.i8이 있다.
locals ( System.String[] V_0, // []는 배열 선언을 가리킨다.
int32 V_1 ) // 인덱스
ldlen 배열의 길이를 로드한다.
이 명령은 0부터 시작하는 1차원 배열을 스택에서 제거한 후, 배열의 길이를 스택에 푸시한다.
starg 값을 인수 슬롯에 저장한다.
지정된 인덱스에 있는 인수 슬롯에 계산 스택 맨 위에 있는 값을 저장한다.
stelem 배열의 요소를 저장한다.
앞의 세 명령이 0부터 시작하는 1차원 배열과 인덱스와 값을 (순서대로) 스택에 저장했던 반면에, stelem 명령은 값을 배열에 있는 값으로 이동시키기 전에 적절한 배열 타입으로 캐스트한다. stelem 명령은 세 가지 항목을 모두 스택에서 제거한다. ldelem 명령처럼, 타입 필드는 데이터 형식을 명시한다. 가장 일반적인 변환 형식은 값 타입을 객체로 변환하는 stelem.ref이다.
stfld 객체의 필드에 저장한다.
이 명령은 스택 맨 위에 있는 값을 가져와서 객체의 필드에 저장한다. 필드를 로딩할 때, 전체 참조 이름을 입력한다.
ceq 같은지 비교한다.
이 명령은 스택 맨 위에 있는 두 값을 비교한다. 두 항목이 스택에서 제거되고 만약 값이 같다면 스택에 1을 푸시하고 그렇지 않다면 0을 푸시한다.
cgt 다른 값보다 큰지 비교한다.
이 명령도 스택 맨 위에 있는 두 값을 비교한다. 두 항목이 스택에서 제거되고 만약 첫 번째로 푸시된 값이 두 번째 값보다 크다면, 1이 스택에 푸시되고 그렇지 않다면 0이 푸시된다.
cgt 명령은 부호 없이 비교한다는 것을 가리키도록 .un 수정자를 가질 수 있다.
clt 다른 값보다 작은지 비교한다.
이 명령은 첫 번째 값이 두 번째 값보다 작은 경우에 1을 푸시한다는 것을 제외하면 cgt와 동일하다.
br 무조건 이동한다.
이 명령은 MSIL의 goto 명령이다.
brfalse false일 때 이동한다.
brtrue true일 때 이동한다.
두 가지 명령은 스택에 있는 값을 살펴보고 그 값에 따라서 이동한다. brtrue 명령은 값이 1일 때에만 이동하는 반면, brfalse는 값이 0일 때에만 이동한다. 두 가지 명령 모두 스택에 있는 값을 제거한다.
beq 같으면 이동한다.
bgt 크거나 같으면 이동한다.
ble 작거나 같으면 이동한다.
blt 작으면 이동한다.
bne 같지 않으면 이동한다.
각 분기 명령은 스택 맨 위에 있는 두 값을 가져와서 첫 번째 값을 다음 값과 비교한다. 각 분기 명령은 조건문 다음에 Boolean 분기문이 있는 형태를 취한다. 예를 들어, bgt는 cgt 명령과 같지만 brtrue 명령이 따른다.
conv 데이터를 변환한다.
이 명령은 스택 맨 위에 있는 데이터를 새로운 타입으로 변환한 후 변환된 값을 스택 맨 위에 입력한다. 최종 변환 타입은 conv 명령 다음에 위치한다. 예를 들어, conv.u4는 부호가 없는 4바이트 정수로 변환한다. 변환 타입만 명시하여 사용하는 conv 명령은 오버플로우가 발생한다고 하더라도 예외를 throw하지 않는다. 만약 conv와 타입 사이에 .ovf가 위치한다면(예를들어, conv.ovf.u8), 오버플로우가 예외를 발생시킨다.
newarr 인덱스가 0부터 시작하는 1차원 배열을 생성한다.
이 명령은 스택 맨 위에 있는 값의 크기만큼 요소를 갖는 새로운 배열을 생성한다. 배열의 크기를 나타내는 값은 스택에서 제거되고 새로운 배열이 스택 맨 위에 위치한다.
newobj 새로운 객체를 생성한다.
새로운 객체를 생성하고 객체의 생성자를 호출한다. 생성자의 모든 인수는 스택에 넘겨진다. 만약 객체를 성공적으로 생성한다면, 인수들이 제거되고 해당 객체의 참조가 스택에 남는다.
box 값 타입을 객체 참조로 변환한다.
이 명령은 값을 객체로 변환한 후 변환이 끝나면 해당 객체를 스택에 남긴다. 박싱에 대한 작업을 이 명령이 처리한다. 매개 변수들을 전달할 때 다음과 같은 코드를 많이 보게 될 것이다.
class System.Object,
class System.String)
unbox 박싱된 값 타입을 원래 형태로 변환한다.
이 명령은 박싱되어 있는 값 타입에 대한 managed 참조를 반환한다. 반환된 참조는 복사본이라기보다 실제 객체의 상태에 가깝다. C#과 Visual Basic .NET으로 컴파일된 코드에서는 unbox 명령 다음에 ldind 명령( 값을 스택에 간접적으로 로드한다 )이나 ldobj( 값 타입을 스택에 복사한다 ) 명령이 온다.
call 메서드를 호출한다.
callvirt 실행 시에 객체와 연관된 메서드를 호출한다.
call 명령은 정적이고 가상이 아닌 일반 메서드를 호출한다. 가상 메서드와 인터페이스 메서드는 callvirt 명령을 사용한다. 인수들은 왼쪽에서 오른쪽 순서대로 위치한다. 이 순서는 IA32 환경에서 사용되는 대부분의 호출 방법과 정 반대된다는 사실을 주의하도록 한다. 다음은 callvirt를 사용하는 예제이다.
ILDASM은 훌륭한 툴이지만, ILDASM 이외에도 두 가지 훌륭한 툴이 있다. 이 두 가지 툴 모두 공짜다. 첫 번째로 소개하고 싶은 툴은 Lutz Toeder의 .NET Reflector(http://www.aisto.com/roeder/dotnet/)인데, 이 툴은 ILDASM에서 제공하는 기능은 기본으로 제공하고 훨씬 많은 기능을 제공한다. .NET Reflector의 핵심 기능 중 하나는 여러분이 어셈블리 내의 타입을 쉽게 검색할 수 있는 기능이다. 때때로, 특정한 메서드가 어떤 메서드를 호출하는지 한눈에 볼 수 있는 기능이 매우 유용할 때가 있다. .NET Reflector의 트리 컨트롤에서 여러분이 확인하고자 하는 메서드를 선택하고 [View]-[Call Tree] 메뉴를 선택한다. Call Tree 창에서 메서드가 어떤 메서드를 호출하는지를 확인하기 위해서 하위 호출을 확장한다.
두 번째로 소개하고 싶은 툴은 그리스어로 "판단하다"라는 의미를 나타내는 Anakrino라고 불리는 툴이다. Anakrino는 C#이나 Managed Extensions for C++ 코드의 어셈블리를 보여주는 .NET 역컴파일러이다. Anakrino는 Jay Freeman이 작성하였으며 http://www.saurik.com/net/exemplar/에서 다운로드할 수 있다. .NET Feflector와 달리, Anakrino는 소스 코드도 제공한다.
<Ctrl + B> 를 눌러서 [중단점] 대화 상자에 접근할 수 있다.
만약 브레이크 포인트를 설정하고자 하는 클래스와 메서드의 이름을 알고 있다면, [중단점] 대화 상자의 [함수]탭에 있는 [함수] 에디트 컨트롤에 이름을 직접 입력할 수 있다.
[중단점] 대화 상자의 [언어] 드롭다운 목록에서 선택된 언어는 반드시 해당 코드를 작성한 언어와 일치해야 한다.
[중단점] 대화 상자에 해당 클래스 이름을 입력하고 난 후 [중단점 선택] 대화 상자에서 [모두] 버튼을 클릭한다. 이렇게 하면 프로그램이 모든 메서드를 호출하고 있는지를 확인할 수 있도록 모든 메서드에 브레이크 포인트가 설정된다.
적중 횟수는 특정한 횟수만큼 반복되기 전까지 브레이크 포인트를 활성화하지 않도록 디버거에게 지시한다.
이 수정자를 이용하면 루프 내에서 적절한 시기에 브레이크 포인트를 활성화하는 일이 간단해진다.
적중 횟수 추가하는 법은 우선, 브레이크 포인트를 설정한다. [중단점 속성] 메뉴를 선택한다. [적중횟수] 버튼을 클릭한다.
조건 표현식을 갖는 위치 브레이크 포인트는 조건이 참이거나 마지막으로 비교된 후로 변경되었을 때에만 활성화된다.
브레이크 포인트에 조건 표현식을 추가하기 위해서는 조건 브레이크 포인트에 대한 [중단점] 대화 상자를 열고 [조건] 버튼을 클릭하면 [중단점 조건] 대화 상자가 띄워진다.
managed 코드를 이용하면 조건 표현식 브레이크 포인트 수정자에서 메서드와 속성을 호출할 수 있지만, 네이티브 코드는 그렇지 않다. 또한 조건 표현식은 C++ 매크로 값을 확인할 수 없기 때문에 만약 TRUE에 해당하는 값을 비교하고 싶다면, (true와 false가 정확하게 평가된다고 할지라도) 1을 대신 사용해야 할 것이다. managed 코드에서 사용되는 언어처럼 C++ 코드에서도 조건 표현식은 반드시 C++ 값을 사용해야 한다. 이러한 제한 사항에도 불구하고, 위치 브레이크 포인트 조건 표현식 수정자는 변수 값을 확인하는 것뿐만 아니라 슈도레지스터라고 불리는 특별한 값을 엑세스할 수 있기 때문에 매우 강력한 기능을 제공한다.
대부분의 경우 슈도레지스터는 CPU에 나타내는 레지스터 값이다. Visual Studio .NET은 여러분이 사용하고 표시할 수 있는 레지스터 타입을 매우 향상시켰다. 보통의 CPU 레지스터 이외에, Visual Studio .NET은 이제 MMX, SSE, SSE2, 그리고 3DNow!까지 지원한다. 슈도레지스터의 몇 가지 예제가 밑의 표에 나와 있다.
| 슈도레지스터 | 설명 |
|---|---|
| @EAX | 리턴 값 레지스터 (32 비트 값) |
| @BL | EBX의 하위 워드 레지스터 (16비트 값) |
| @MM0 | MMX 레지스터 0 |
| @XMM1 | Streaming SIMD Extensions (SSE) 레지스터 1 |
| $ERR | 마지막 에러 값 ( 특별한 값 ) |
| $TIB | Thread information block ( 특별한 값 ) |
실제 CPU 레지스터는 @기호를 가지며 $로 시작하는 두 가지 특별한 값이 있다는 사실을 알아둔다. 레지스터 값에 대한 전체 리스트는 Intel과 AMD의 CPU 문서에서 확인할 수 있다. Visual C++ 6에서는 슈도레지스터 앞에 @를 명시할 수 있다. 하위 호환성을 유지하기 위해 때문에 Visual Studio .NET 2003에도 사용할 수 있지만, 앞으로 나올 버전에서는 슈도레지스터에 $만 지원할 것이다.
[조사식] 창을 자유자재로 다루기 위해서 여러분이 습득해야 하는 첫 번째 트릭은 밑의 두 표에 있는 형식 기호(Visual Studio .NET 문서에서 가져왔다)를 암기하는 것이다. [조사식] 창은 데이터를 표현하는 방법에 있어서 매우 유연하며 그러한 유연성을 만들어내기 위해서는 이 표에 있는 형식 코드를 사용해야 한다.
< [조사식] 창 변수에 적용하는 형식 기호 >
| 지정자 | 형식 | 값 | 표시 |
|---|---|---|---|
| d, i | 부호 있는 10진 정수 | (int)0xF000F065, d | -268373915 |
| u | 부호 없는 10진 정수 | 0x0065, u | 101 |
| o | 부호 없는 8진 정수 | 0xF065, o | 0170145 |
| x, X | 16진 정수 | 61541, X | 0x0000F065 |
| l, h | d, i, u, o, x, X에 대한 long 또는 short 접두사 | 0x00406042, hx | 0x0c22 |
| f | 부호 있는 부동 소수점 | 3./2., f | 1.500000 |
| e | 부호 있는 공학용 표기법 | 3./2., e | 1.500000e+000 |
| g | 부호 있는 부동 소수점 또는 부호 있는 공학용 표기법 중에서 짧은 형식 | 3./2., g | 1.5 |
| c | 단일 문자 | 0x0065, c | 101 'e' |
| s | 문자열 | szHiWorld, s | "Hello world" |
| su | 유니코드 문자열 | szWHiWorld, su | "Hello world" |
| hr | HRESULT 또는 Win32 오류 코드, 이제는 디버거가 자동으로 HRESULT를 디코딩하므로 이 지정자가 필요하지 않다. | 0x00000000, hr | S_OK |
| wc | Window 클래스 플래그 | 0x00000040, wc | WC_DEFAULTCHAR |
| wm | Windows 메시지 번호 | 0x0010, wm | WM_CLOSE |
< [조사식] 창 메모리 덤프에 적용하는 형식 기호 >
| 기호 | 형식 | 값 | 표시 |
|---|---|---|---|
| ma | ASCII 문자 64개 | 0x0012ffac, ma |
0x0012ffac .4...0...".0W&.....1W&.0.:W..1...."..1.JO&.1.2.."..1...0y....1 |
| m | 16바이트 16진수 뒤에 ASCII 문자 16개 | 0x0012ffac, m |
0x0012ffac B3 34 CB 00 84 30 94 80 FF 22 8A 30 57 26 00 00 .4...0...".0W&.. |
| mb | 16바이트 16진수 뒤에 ASCII 문자 16개 | 0x0012ffac, mb |
0x0012ffac B3 34 CB 00 84 30 94 80 FF 22 8A 30 57 26 00 00 .4...0...".0W&.. |
| mw | 단어 8개 | 0x0012ffac, mw | 0x0012ffac 34B3 00CB 3084 8094 22FF 908A 2657 0000 |
| md | 더블워드 4개 | 0x0012ffac, md | 0x0012ffac 00CB34B3 80943084 308A22FF 00002657 |
| mq | 쿼드워드 2개 | 0x0012ffac, mq |
0x0012ffac 7ffdf00000000000 5f441a790012fdd4 |
| mu | 2바이트 유니코드 문자 | 0x0012ffac, mu | 0x0012fc60 8478 77f4 ffff ffff 0000 0000 0000 0000 |
| # | 지정된 숫자만큼 메모리 위치에 대한 포인터를 확장한다. | pCharArray, 10 | +/- 확장자를 사용하여 문자 10개가 확장된다. |
이 표에서 확인할 수 있는 것처럼, 형식 코드는 사용하기 쉽다. 변수 다음에 콤마를 찍고 원하는 형식을 입력한다. COM 프로그래밍에서 가장 유요한 형식 지정자는 hr이다. 만약 [조사식] 창에서 @EAX,hr 표현식을 입력하면, COM 메서드에 대해서 프로시저 단위 실행 명령을 실행하고 난 후에 함수 호출 결과를 이해할 수 있는 형태로 확인할 수 있다.
데이터 브레이크 포인트를 사용하면, 외부에서 특정한 메모리를 변경할 때마다 메모리가 변경되기 직전에 해당 위치에서 프로그램이 중단된다.
데이터 브레이크 포인트는 메모리 충돌이나 덮어쓰기와 같은 문제들을 해결하기 위한 방법이다.

비주얼 스튜디오 설치 디렉토리에 보면 Common7\IDE\Remote Debugger 경로에 원격 디버깅 툴이 32비트, 64비트용이 있다
자신의 환경에 맞는 툴을 디벙깅 하고자 하는 원격 컴퓨터에 설치하고 실행하면 된다. 64비트 툴의 경우 옵션 설정이 필요하다.
그리고 비주얼 스튜디오 프로젝트에서 [디버그] -> [프로세스에 연결] 또는 [프로세스] 항목을 선택한 후
[전송]항목에서 [원격]을 선택
[한정자] 항목에 연결하고자 하는 원격 컴퓨터의 IP를 입력하면
현재 그 원격 컴퓨터에서 실행되고 있는 프로세스 목록이 뜨게 된다.
그중에 디버깅 하고자 하는 항목을 선택한 후 [연결] 버튼을 클릭하면 된다.
이 섹션에서는 어셈블리 언어를 이해하기 위해서 필요한 정보를 소개하고자 한다. 어셈블리 언어에서 한 명령은 오직 하나의 일을 수행할 뿐이다. 일단 CPU가 명령을 어떻게 실행하는지에 대한 패턴을 확인하고 이해하고 나면, 어셈블리 언어가 실제로 매우 세련되었다는 것을 깨닫게 될 것이다.
여기서 소개할 어셈블리 언어는 Intel과 AMD와 같이 모든 x86 아키텍처 CPU에 호환되는 32비트 명령 집합(흔히 IA32라고 부른다)일 것이다.
Intel CPU 계열에 대해서 더 자세히 알고 싶어진다면, www.intel.com에서 Intel Architecture Software Developer's Manual Adove PDF 파일 3개를 다운로드 해야 한다. 가장 중요한 설명서는 두 번째 Instruction SetReference이다. 첫 번째와 세 번째는 각각 CPU 아키텍처에 대한 기본적인 정보와 운영 체제 개발자들을 위한 내용이다.
한 가지 기억해야 할 사항은 x86 CPU는 매우 유연하며 동일한 명령을 실행할 수 있는 다양한 방법을 제공한다는 점이다. 다행히도, Microsoft 컴파일러는 연산을 수행하기 위한 최고로 빠른 방법을 선택하고 적용할 수 있을 때마다 재사용하기 때문에 코드 섹션이 무엇을 수행하는지 이해하기가 쉬워졌다.
응용 프로그램이 처리하는 모든 데이터는 한번 혹은 두 번에 걸쳐서 레지스터를 통과하기 때문에 각 레지스터의 목적을 알면 코드를 이해하는 데 도움이 된다. x86 CPU는 8개의 범용 레지스터(EAX, EBX, ECX, EDX, ESI, EDI, ESP, 그리고 EBP)를 갖고 있다. 6개는 세그먼트 레지스터(CS, DS, ES, SS, FS, 그리고 GS)이며, 하나는 명령 포인터(EIP), 그리고 다른 하나는 플래그 레지스터(EFLAGS)이다. CPU는 디버그와 컴퓨터 컨트롤 레지스터와 같은 다른 레지스터들을 갖고 있지만, 이들은 특정한 목적을 지닌 레지스터이며 일반적인 사용자 모드 디버깅 도중에는 이 레지스터들을 만나지 않을 것이다.
밑의 그림은 범용 레지스터의 레이아웃을 보여준다. 기억해야 할 점은 몇몇 레지스터들은 32비트 레지스터 전체의 일부분들을 엑세스할 수 있도록 니모닉을 제공한다는 점이다.
.bmp)
밑의 표는 모든 범용 레지스터를 여러 조각으로 나누어 나열한 것이다. 흥미를 끄는 유일한 세그먼트 레지스터는 현재 실행중인 스레드를 기술하는 Theread Information Block(TIB)을 보관하는 FS 레지스터이다. 다른 세그먼트 레지스터들도 사용되지만 일반 연산에서 사용되도록 운영체제가 구성한다. 명령 포인터는 현재 실행중인 명령의 주소를 보관한다.
| 32비트 레지스터 | 16비트 접근 | 하위 바이트 접근(0-7 비트) | 상위 바이트 접근(8-15 비트) | 특별한 용도 |
|---|---|---|---|---|
| EAX | AX | AL | AH | 정수형 함수의 리턴 값이 저장된다. |
| EBX | BX | BL | BH | |
| ECX | CX | CH | CL | 루프 명령 카운터는 횟수를 계산하기 위해서 이 레지스터를 사용한다. |
| EDX | DX | DH | DL | 64비트 값의 상위 32비트 값이 저장된다. |
| ESI | SI | 메모리를 옮기거나 비교하는 명령에서 소스 주소가 저장된다. | ||
| EDI | DI | 메모리를 옮기거나 비교하는 명령에서 대상 주소가 저장한다. | ||
| ESP | SP |
스택 포인터이다. 이 레지스터는 함수를 호출할 때, 함수로부터 반환될 때, 로컬 변수를 위한 공간을 스택에 만들 때 그리고 스택을 정리할 때 암시적으로 변경된다. |
||
| EBP | BP | 베이스/프레임 포인터이다. 이 레지스터는 프로시저에 대한 스택 프레임을 보관한다. |
< Visual Studio .NET 레지스터 창 >

밑의 표는 레지스터 창에 표시된 플래그 값들을 나열한 것이다. 불행하게도 Visual Studio .NET이 사용하는 연상기호는 Intel 니모닉과 같지 않기 때문에 여러분은 Intel 문서를 참고할 때 새로운 의미로 해석해야 할 것이다. 하지만 Visual Studio .NET 레지스터 창에서 한 가지 좋아진 점은 레지스터 값이 변경될 때 플래그 값이 빨간색으로 변경된다는 점이다.
| 레지스터 창 플래그 | 의미 | Intel 설명서 니모닉 | 설명 |
|---|---|---|---|
| OV | Overflow 플래그 | OF |
만약 명령을 실행하고 난 후 정수 오버플로우나 언더플로우가 발생했다면 1로 설정한다. |
| UP | Direction 플래그 | DF |
만약 문자열 명령이 상위 주소에서 하위 주소로 처리된다면 1로 설정한다. 0은 문자열 명령이 하위 주소에서부터 상위 주소로 처리된다는 것을 나타내며 C/C++ 코드가 사용하는 방법이다. |
| EI | 인터럽트 활성화 플래그 | IF |
만약 인터럽트가 활성화된다면 1로 설정한다. 이 플래그는 만약 인터럽트가 비활성화되어 있는 경우 키보드를 입력하거나 화면이 갱신되는 것을 확인할 수 없기 때문에 사용자 모드 디버거에서는 항상 1로 설정될 것이다. |
| PL | 부호 플래그 | SF |
명령 결과의 most significant 비트를 반영한다. 양수값은 0으로 설정하고 음수 값은 1로 설정한다. |
| ZR | Zero 플래그 | ZF |
만약 명령 결과가 0이면 1로 설정한다. 이 플래그는 비교 명령에서 매우 중요하다. |
| AC | 보조 캐리 플래그 | AF |
만약 Binary-Coded Decimal(BCD) 연산이 캐리 또는 빌림(borrow)을 필요로 하는 경우 1로 설정한다. |
| PE | 패러티 플래그 | PF |
만약 결과의 least significant 바이트에서 1의 개수가 짝수 개인 경우 1로 설정한다. |
| CY | 캐리 플래그 | CF |
만약 산술 연산이 결과의 most significant 비트로부터 캐리 또는 빌림이 필요한 경우 1로 설정한다. 또한 부호 없는 정수형 산술 연산에서 오버플로우가 발생한 경우 에도 1로 설정한다. |
레지스터 창에서 한 가지 중요한 기능은 레지스터 창에서 레지스터 값을 편집할 수 있다는 것이다. 변경하고자 하는 레지스터의 등호 기호의 오른쪽에 있는 값 위에서 커서를 놓고 값을 입력한다. 커서가 위치한 곳부터 값이 변경될 것이다. 실행 취소 기능도 지원한다.
Intel CPU의 기본 명령 형식은 다음과 같다. 모든 명령은 동일한 패턴을 따른다.
[접두사] 명령 [피연산자]
대부분의 경우 여러분은 접두사는 몇몇 문자열 함수에 대해서만 보게 될 것이다( 문자열 함수가 접두사를 사용하는 일반적인 상황에 대해서는 "문자열 처리" 섹션에서 다룰 것이다). 피연산자 형식은 연산의 방향을 가리킨다. 소스는 목적지로 가기 때문에 오른쪽에서 왼쪽으로 피연산자를 읽는다.
단일 명령 피연산자 : XXX 소스
이중 명령 피연산자 : XXX 목적지, 소스
소스 피연산자는 레지스터나 메모리 참조 또는 immediate 값, 즉 직접 입력된 값이 될 수 있다. 목적지 피연산자는 레지스터나 메모리 참조일 수 있다. Intel CPU에서는 소스와 목적지가 모두 메모리 참조일 수 없다.
메모리 참조는 대괄호 안에 표시되는 피연산자이다. 예를 들어, 메모리 참조 [0040129Ah]는 "메모리 위치 0x0040129A에 있는 값을 가져온다."는 의미이다. h는 16진수를 명시하기 위한 어셈블리 언어적인 방법이다. 메모리 참조는 "EAX에 있는 주소에서 메모리를 가져온다"라는 의미의 [EAX]처럼 레지스터를 통해서도 가능하다. 또 다른 일반적인 메모리 참조는 레지스터의 값에 오프셋을 더한 주소를 명시하는 것이다. [EAX+0Ch]는 "EAX에 있는 값에 0xC를 더한 후 해당 메모리를 가져온다."라는 의미이다.
메모리 참조의 크기를 구별하기 위해서 포인터 크기가 앞에 위치하는 메모리 참조를 종종 보게 될 것이다. 포인트 크기는 BYTE PTR, WORD PTR, 그리고 DWORD PTR로 표시되며, 만약 디스어셈블리가 포인터 크기를 명시하지 않는다면, 기본 크기는 더블 워드이다.
위 < Visual Studio .NET 레지스터 창 >그림에서 맨 아래에 있는 04A9D7D4=012EDB34 줄을 주목한다. 이 줄은 effective 주소를 표시한다. 현재 실행되는 명령이 줄의 왼쪽에 있는 0x04A9D7D4를 참조하고 있다. 오른쪽에는 0x4A9D7D4 메모리 위치에 있는 값(0x012EDB34)이다. 메모리를 접근하는 이러한 명령들만 레지스터 창에서 effective 주소를 표시할 것이다. x86 CPU는 피연산 중 하나만 메모리 참조일 수 있기 때문에 effective 주소를 표시하는 줄을 살펴보면 여러분이 어떤 메모리를 접근하고 해당 메모리 위치에 어떤 값이 있는지 확인할 수 있다.
만약 메모리 접근이 타당하지 않다면, CPU는 General Protection Fault(GPF) 또는 페이지 결함을 보고한다. GPF는 여러분이 접근할 수 없는 메모리를 접근하려 한다는 것을 가리킨다. 페이지 결함은 여러분이 존재하지 않는 메모리 위치를 접근하려 한다는 것을 가리킨다.
어셈블리 언어 명령을 살펴보기 전에, Visual C++ 에서의 인라인 어셈블러에 대해서 간단하게 얘기하고자 한다. 여러분이 작성하는 C/C++ 프로그램은 응용 프로그램 골격을 제공할 것이다. 인라인 어셈블러를 프로그램에서의 확대 기능으로 생각할 수 있다. 인라인 어셈블러를 이용하면 C/C++로 전체적인 그림을 그린 후에 어셈블리 언어 명령을 제어해야 하는 부분에서 확대할 수 있다. 이 장에서 보여주는 명령들을 사용하여 이 명령들이 어떻게 작동하는지 확인하기 위해서 인라인 어셈블러를 사용할 수 있다. 인라인 어셈블리 언어의 형식을 보여주기 위해서, 첫 번째 명령을 소개하도록 하겠다.
NOP는 아무것도 하지 않는 명령이다. 때때로 컴파일러는 함수를 적절한 메모리 참조 영역에 정렬시키기 위해서 함수 내에서 NOP를 사용한다. 인라인 어셈블러 키워드는 __asm이다. __asm 다음에 실행하고 싶은 어셈블리 언어 명령을 입력한다. 여러 개의 명령을 입력하기 위해서 __asm을 사용한 후 여러 개의 어셈블리 언어 명령을 중괄호 안에 입력한다. 다음 두 루틴은 인라인 어셈블리 언어 명령에 대한 형식으로 보여주고 있다. 이 루틴들은 기능적으로 동일하다.
Microsoft 코드 생성기가 자주 사용되는 명령들과 그러한 명령들이 주로 어떠한 상황에서 필요할 것인지에 대해서만 다룰 것이다.
스택관리
PUSH 스택에 워드 또는 더블 워드를 푸시한다.
POP 스택에서 값을 가져온다.
Intel CPU는 대부분의 매개 변수를 스택으로 전달한다. 스택은 상위 메모리에서 시작해서 아래로 커진다. 이 명령들은 암시적으로 스택의 최상위 부분을 가리키는 ESP 레지스터를 변경한다. PUSH 후에는 ESP 레지스터에 있는 값이 감소한다. POP한 후에는 ESP가 증가한다.
여러분은 레지스터, 메모리 위치 또는 하드-코딩된 숫자를 푸시할 수 있다. 스택에 있는 항목을 팝(POP)하는 작업은 보통 항목을 레지스터로 이동시킨다. CPU 스택에서 가장 큰 특징은 LIFO 데이터 구조라는 점이다. 만약 3개의 값을 저장하기 위해서 스택에 푸시하면, 다음과 같이 반드시 반대 순서로 팝해야 한다.
비록 값을 교환하는 데 훨씬 효율적인 방법들이 있지만, PUSH와 POP 명령을 사용하면 레지스터 값을 변경할 수 있다. 교한은 여러분이 POP 명령 순서를 바꾸었을 때 발생한다.
PUSHAD 모든 범용 레지스터를 푸시한다.
POAD 모든 범용 레지스터를 팝한다.
이따금 시스템 코드를 디버깅하다 보면, 이 두 명령들을 만나게 될 것이다. 매우 긴 PUSH, POP 명령을 사용하는 대신에 Intel CPU는 범용 레지스터들을 저장하고 가져오기 위한 이 두 명령을 제공한다.
MOV 이동시킨다.
MOV 명령은 값을 한 곳에서 다른 곳으로 옮기기 위한 명령이기 때문에 가장 많이 사용되는 명령이다. 이번에 MOV 명령을 사용하여 두 값을 교환하는 방법을 소개할 것이다.
SUB 뺀다.
이 명령은 목적지 피연산자에서 소스 피 연산자를 뺀 후, 목적지 피연산자에 결과를 저정한다.
이 코드를 실행하고 난 후, EAX는 3을 포함하고 EBX는 2를 포함할 것이다.
ADD 더한다.
ADD 명령은 소스 피연산자를 목적지 피연산자에 더한 후, 결과를 목적지 피연산자에 저장한다.
INT 3 브레이크 포인트
INT 3은 Intel CPU의 브레이크 포인트 명령이다. Microsoft 컴파일러는 파일 안에서 함수 사이의 공간을 채우기 위해서 이 명령을 사용한다. 링커의 /ALIGN 옵션(기본값 4KB)을 토대로 Portable Executable(PE) 섹션을 정렬시킨다. INT 3 명령에 해당하는 16진수 값은 0xCC이다. 바로 이 점 때문에 /RTCs 옵션으로 스택 변수를 초기화할 때 뿐만 아니라 공간을 채우고자 할 때 사용된다.
LEAVE 고급 프로시저 종료
LEAVE 명령은 함수를 떠날 때 CPU 상태를 복원한다.
Windows와 여러분이 작성한 프로그램에 있는 대부분의 함수는 동일한 방법으로 함수를 설정하고 종료한다. 설정을 프롤로그(prolog)라고 하며, 종료를 에필로그(epilog)라고 한다. 컴파일러는 이 둘을 자동으로 생성한다. 프롤로그를 설정할 때, 코드는 함수의 지역 변수와 매개 변수들을 접근할 수 있도록 구성한다. 엑세스 포인트를 스택 프레임이라고 부른다. 비록 x86 CPU가 명시적으로 스택 프레임 스키마를 명시하고 있지는 않지만, CPU가 설계된 형태나 몇몇 명령들을 보면 운영 체제가 스택 프레임 포인터를 보관하기 위해서 EBP 레지스터를 매우 쉽게 사용할 수 있도록 만들었음을 알 수 있다.
이 순서는 디버그와 릴리즈 빌드 모두에 공통적이다. 하지만 몇몇 릴리즈 빌드 함수에서는, PUSH와 MOV 사이에 다른 명령들이 추가되어있는 것을 볼 수 있다. 다중 파이프라인을 갖는 CPU(Pentium 계열)는 여러 개의 명령을 한번에 처리할 수 있기 때문에 옵티마이저가 이 기능을 사용하기 위해서 명령 스트림을 설정하려고 할 것이다. 여러분이 설정한 최적화 기능에 따라서 코드를 파일할 때, 프레임 포인터로 EBP를 사용하지 않는 함수를 만들 수도 있다. 이 프로시저들은 Frame Pointer Ommision(FPO) 데이터를 갖는다. FPO 데이터를 갖는 함수를 [디스어셈블러] 창에서 살펴보면, 함수 안에 있는 코드는 마치 데이터를 조작하려고 하는 것처럼 보일 것이다.
다음 일반적인 에필로그는 프롤로그의 작업을 돌려놓는 작업을 수행하며 디버그 빌드에서 가장 많이 보게 되는 코드이다. 이 에필로그는 앞의 프롤로그와 일치한다.
릴리즈 빌드에서는 앞에서 소개한 LEAVE 명령을 사용하는 것이 MOV/POP 순서를 사용하는 것보다 빠르기 때문에 에필로그 부분에 LEAVE 명령만 보게 될 것이다. LEAVE 명령은 MOV/POP 순서와 동일하다. 디버그 빌드에서는 컴파일러가 MOV/POP를 기본적으로 사용한다. 흥미로운 점은 x86 CPU가 프롤로그의 기능을 수행하기 위한 ENTER 명령을 갖고 있지만 PUSH/MOV/ADD 순서보다 느리기 때문에 컴파일러가 이 명령을 사용하지 않는다.
컴파일러가 코드를 어떻게 생성할 것인지는 프로그램이 속도에 최적화되어 있는지 크기에 최적화 되어 있는지에 가장 큰 영향을 받는다. 만약 크기에 최적화시켰다면, 많은 함수들이 표준 스택 프레임을 보다 많이 사용할 것이다. 속도에 최적화되어 있으면 FPO를 더 많이 사용할 것이다.
LEA effective 주소를 로드한다.
LEA는 소스 피연산자의 주소를 목적지 레지스터에 로드하며 거의 항상 로컬 변수 접근을 나타낸다. 다음 코드는 LEA 명령을 사용한 두 가지 예제이다. 첫 번째 예제는 주소를 정수형 포인터에 설정하는 방법을 보여준다. 두 번째 예제는 LEA 명령을 사용하여 로컬 문자 배열에 있는 주소를 가져와서 GetWindowsDirectory API 함수의 매개 변수로 주소를 전달한다.
CALL 프로시저를 호출한다.
RET 프로시저에 리턴한다.
CALL 명령은 직관적이다. CALL을 실행할 때, CALL은 암시적으로 리턴 주소를 스택에 넣기 때문에 만약 호출된 프로시저의 첫 번째 명령에서 멈춘다면 스택의 맨 위에 있는 주소가 리턴 주소이다. CALL 명령의 피연산자는 아무거나 될 수 있기 때문에 만약 [디스어셈블리] 창에서 확인해보면 레지스터, 메모리 참조, 매개 변수 그리고 전역적인 오프셋으로 CALL 명령을 사용하고 있음을 확인할 수 있다. 만약 CALL이 포인터 메모리 참조를 사용한다면 어떤 프로시저를 호출하는지 확인하기 위해서 레지스터 창의 effective 주소 필드를 사용할 수 있다. 만약 로컬 함수를 호출한다면, 곧장 해당 주소를 호출할 것이다. 하지만 대부분의 경우, 임포트된 함수나 가상 함수를 호출하기 위해서 Import Address Table(IAT)을 사용하여 포인터를 호출할 것이다. 만약 여러분이 프로시저 안으로 들어가고자 하는 바이너리의 심볼이 로드되어 있다면, 다음 CallSomeFunctions 예제는 로컬 함수를 호출하는 방법도 보여준다. 코드 다음에 위치한 주석을 통해서 심볼이 로드되어 있는지에 따라서 함수 호출이 디스어셈블리에서 어떻게 나타나는지를 보여주고 있다.
RET 명령은 명령이 실행되었을 때 아무것도 검사하지 않고 스택의 가장 위에 있는 주소를 사용하여 호출자로 리턴한다. 버퍼 오버런 보안 공격은 프로그램이 리턴할 리턴 주소를 바이러스를 구현한 코드로 변경한다. 예상할 수 있는 것처럼, 훼손된 스택때문에 응용 프로그램의 다른 곳으로 리턴할 수 있다. 때때로 RET 명령 다음에 상수가 오기도 한다. 이 숫자는 스택에 푸시되어 함수에 전달된 매개 변수를 계산하여 스택에서 몇 바이트만큼 팝을 해야 하는지를 지정한다.
호출 규칙은 매개 변수가 함수에 어떻게 전달되며 함수가 리턴할 때 스택 정리가 어떻게 일어나는지를 명시한다. 함수를 작성하는 프로그래머는 모든 사람들이 함수를 호출할 때 반드시 따라야 하는 호출 규칙을 지시해야 한다.
모두 5가지 호출 규칙이 있지만, standard call(__stdcall, 표준 호출), C declaration(__cdecl, C 선언), 그리고 this 호출이 일반적이다. 표준 호출과 C 선언은 직접 지정할 수 있지만, this 호출은 C++ 코드를 사용할 때 자동으로 적용되며 this 포인터가 전달되는 방법을 지시한다. 다른 두 개의 호출 규칙은 fast call(__fastcall)과 naked 호출 규칙이다. 기본적으로 Win32운영체제는 fast-call 호출 규칙이 다른 CPU와 호환되지 않기 때문에 사용자 모드 코드에서 fast-call 호출 규칙을 사용하지 않는다. naked 호출 규칙은 프롤로그와 에필로그 생성을 제어하고 싶은 경우에 사용된다.
밑의 표는 모든 호출 규칙을 나열한 것이다. 시스템 함수에 브레이크 포인트를 설정하기 위해서 이름 데코레이션 스키마를 지정하고 있는 것을 확인할 수 있다.
| 호출 규칙 | 인자 파싱 | 스택 관리 | 이름 데코레이션 | 알아두기 |
|---|---|---|---|---|
| __cdecl | 오른쪽에서 왼쪽으로 전달된다. | 호출하는 쪽에서 스택에 있는 매개 변수를 제거한다. 이 호출 규칙만 가변 매개 변수를 허용한다. | _Foo처럼 함수 이름 앞에 밑줄(_)이 추가된다. | C와 C++ 함수의 기본 호출 규칙이다. |
| __stdcall | 오른쪽에서 왼쪽으로 전달된다. | 호출되는 쪽에서 스택에 있는 매개 변수를 제거한다. | 함수 이름 앞에 밑줄이 추가되고 함수 이름 끝에 @이 추가되고 @뒤에 다시 매개 변수의 전체 바이트가 10진수로 추가된다. 예를 들면, _Foo@12와 같다. | 거의 모든 시스템 함수에서 사용되는 호출 규칙이다. Visual Basic 내부 함수에 대해서도 기본 호출 규칙이다. |
| __fastcall | 처음 두 DWORD 매개 변수들은 ECX와 EDX에 전달된다. 나머지는 오른쪽에서 왼쪽으로 전달된다. | 호출되는 쪽에서 스택에 있는 매개 변수를 제거한다. | 이름 앞에 @가 추가되고 함수 이름 끝에 @이 추가되고 @뒤에 다시 매개 변수의 전체 바이트가 10진수로 추가된다. 예를 들면, @Foo@12와 같다. | Intel CPU에만 적용된다. 이 호출 규칙은 Borland Delphi 컴파일러의 기본 호출 규칙이다. |
| this | 오른쪽에서 왼쪽으로 전달된다. this 매개 변수가 ECX 레지스터에 전달된다. | 호출하는 쪽에서 스택에 있는 매개 변수를 제거한다. | 없음 | 만약 표준 호출을 지정하지 않는다면, C++ 클래스 메서드에서 자동으로 사용된다. COM 메서드는 표준 호출로 선언된다. |
| naked | 오른쪽에서 왼쪽으로 전달된다. | 호출하는 쪽에서 스택에 있는 매개 변수를 제거한다. | 없음 | 사용자 지정 프롤로그와 에필로그가 필요할 때 사용된다. |
호출 규칙 변경은 함수의 선언과 정의에서 일어난다. 예를 들어, 다음은 호출 규칙이 어디에 들어가야 하는지를 보여준다. 또한 컴파일러의 기본 호출규약을 변경할 수 있도록 CL.EXE에 컴파일 옵션이 있다. 하지만 이 옵션을 사용하지 않고 각 함수마다 호출 규칙을 명시적으로 지정하여 혼란이 없도록 하는 방법을 추천하고 싶다.
만약 여러분이 지금까지 한번도 다른 호출 규칙을 사용해 본적이 없다면, 아마도 왜 서로 다른 타입이 존재하는지 궁금할 것이다. C 선언과 표준 호출의 차이점은 미묘하다. 표준 호출 함수에서는 호출되는 함수가 스택을 정리하기 때문에 얼마나 많은 매개 변수가 들어오는지 정확하게 알아야 한다. 따라서 표준 호출 함수는 printf 함수처럼 가변 매개 변수 함수가 될 수 없다. C 선언 함수는 호출자가 스택을 정리하기 때문에 가변 매개 변수 함수가 될 수 있다. 또한 표준 호출 함수는 C 선언 함수보다 코드의 크기가 작다. C 선언을 선언하면 함수를 호출할 때마다, 컴파일러가 스택을 정리하는 코드를 생성해야 한다. 여러 곳에서 C 선언 함수를 호출할 수 있기 때문에, 함수를 호출할 때마다 스택 정리 코드가 추가될 것이고 프로그램의 크기가 커진다. 반면에 표준 함수 호출 함수는 함수 내에서 스스로 스택을 정리하기 때문에 컴파일러가 함수를 호출한 후에 코드를 생성할 필요가 없다. 표준 호출은 이와 같은 이유로 WIn32 시스템 함수의 기본 호출 규칙이다. 하지만 Win32 개발을 잘 알고 있는 누군가가 이런 질문을 하고 싶다. "Win32에서 표준 호출을 하지 않는 두 개의 함수가 무엇이며 그 함수들은 어떤 함수 호출을 사용합니까?" 이 질문의 정답은 wsprintA와 wsprintfW이다.
호출 규칙 예제
밑의 소스는 Visual Studio .NET 디버거의 [디스어셈블리] 창으로부터 모든 호출 규칙을 사용한 예제를 보여준다. 코드를 알아보기 쉽도록 /RTCs와 /GS와 같은 추가적인 옵션을 제거한 상태에서 디버그 빌드를 수행하였다. 또한 이 코드는 실제로 아무것도 하지 않는다. 단지 각 함수 호출 규칙 함수를 차례대로 호출했을 뿐이다. 매개 변수들이 각 함수에 어떻게 푸시되는지 그리고 스택에 어떻게 정리되는지를 주의 깊게 살펴본다. 코드를 읽기 쉽게 만들기 위해서 각 함수 사이에 NOP 명령을 삽입하였다.
전역 변수는 고정된 메모리 주소를 갖는 메모리 참조이기 때문에 엑세스하기가 가장 쉽다. 만약 해당 주소에 있는 모듈에 대한 심볼을 갖고 있다면, 전역 변수의 이름을 얻게 될 것이다. 다음 예제는 인라인 어셈블러를 통해서 전역 변수를 엑세스하는 방법을 보여준다. 인라인 어셈블러를 사용하면 C 프로그래밍에서처럼 명령어에 따라서 소스 또는 목적지로 변수 이름을 사용할 수 있다.
만약 함수가 표준 스택 프레임을 갖고 있다면, 매개 변수는 EBP 레지스터로부터 양수(+) 오프셋에 있다. 만약 함수를 실행하는 동안에 EBP를 변경하지 않는다면, 함수를 호출하기 전에 스택에 매개 변수를 푸시하였기 때문에 동일한 양수 오프셋에 매개 변수가 표시된다. 다음 코드는 매개 변수 엑세스를 보여준다.
두 번째 피연산자에서 첫 번째 피연산자로 집게 손가락을 옮길 때 "소스에서 목적지로"라는 문구 다음으로 기억해야 할 사항이 "매개 변수는 양수다!"라는 문구이다.
표준 스택 프레임이 EBP로부터 일정한 오프셋을 유지한다는 사실 덕분에 첫 번째 매개 변수가 항상 [EBP+8]에 있고, 두 번째 매개 변수는 [EBP+0Ch] 그리고 세 번째 [EBP+10h]와 같은 순서로 증가하기 때문에 여러분이 엑세스하고자하는 매개 변수를 쉽게 찾을 수 있다. 만약 수학 공식을 만들고 싶다면, n 번째 매개 변수는 [EBP + ( 4 + (n*4) )]를 사용하여 계산할 수 있다. 이 장의 뒷부분에서는 로컬 변수를 설명한 후에 표준 스택 프레임을 사용하는 예제를 소개하고 왜 이 값들이 하드 코딩되어 있는지에 대해서 설명할 것이다.
만약 최적화된 코드를 디버깅하고 ESP 스택 레지스터로부터 양수 오프셋이 있는 참조를 보게 된다면, 여러분은 FPO 데이터를 갖는 함수를 보고 있는 것이다. ESP는 함수가 실행되면서 계속해서 변경될 수 있기 때문에, 매개 변수를 직관적으로 유지하기 위해서 좀더 어려운 작업이 필요하다. 최적화된 코드를 다룰 때에는 [ESP+20h]에 대한 참조가 [ESP+8h]와 같을 수 있기 때문에 스택에 푸시되는 항목을 추적해야 한다. 최적화된 코드를 디버깅할 때에는 어셈블리 언어를 한 단계씩 실행할 때마다 항상 매개 변수의 위치를 적어둔다.
만약 표준 프레임이 사용된다면, 로컬 변수들은 EBP로부터 음수 오프셋에 있다. SUB 명령은 "공통적인 순서 : 함수 시작과 종료"에서 살펴본 것처럼 메모리 공간을 예약한다. 다음 코드는 로컬 변수를 새로운 변수에 설정하는 방법을 보여준다.
만약 표준 프레임이 사용되지 않을 때 모든 로컬 변수를 찾으려고 한다면, 이는 매우 어려울 수 있다. 문제는 로컬 변수가 매개 변수처럼 ESP에서 양수 오프셋으로 보인다는 점이다. 이 경우에는 SUB 명령을 찾아서 로컬 변수로 몇 바이트가 할당되었는지를 찾아야 한다. 만약 ESP 오프셋이 로컬 변수로 할당된 바이트 수보다 크다면, 해당 메모리에 대한 오프셋 참조는 아마도 매개 변수일 것이다.
스택 프레임은 처음 볼 때는 다소 혼란스러울 수 있기 때문에, 다음에 소개하는 마지막 예제 하나와 2가지 그림이 이 주제에 대해서 명확하게 이해하는 데 도움을 줄 것이다. 매우 간단한 다음 C 함수 코드는 왜 매개 변수가 EBP의 양수 오프셋이며 왜 로컬 변수가 표준 스택 프레임의 음수 오프셋인지를 보여줄 것이다. AccessLocalsAndParamsExample 함수 다음은 실제로 함수를 호출하고 매개 변수를 설정하기 위한 코드이다. 마지막 부분은 ASMer 샘플 프로그램에서 컴파일되었을 때처럼 함수의 디스어셈블리 코드이다.
만약 AccessLocalsAndParamsExample 함수의 처음(0x0040107A 주소)에 브레이크 포인트를 설정한다면, 밑의 그림 처럼 묘사된 스택과 레지스터 값을 확인할 수 있을 것이다.

< AccessLocalsAndParamsExample 함수 프롤로그 전의 스택 >
AccessLocalsAndParamsExample 함수 안에 있는 처음 어셈블리 명령 3개는 프롤로그를 구성한다. 프롤로그를 실행하면 ESP와 EBP가 설정되고 매개 변수들은 EBP의 양수 오프셋으로 엑세스할 수 있으며 로컬 변수들은 EBP의 음수 오프셋으로 엑세스할 수 있다.
밑의 그림은 각 프롤로그 명령이 실행되고 난 후의 ESP와 EBP의 값을 보여준다.


< AccessLocalAndParamsExample 함수 프롤로그를 실행하면서 변경되는 스택의 모습 >
디스어셈블리 코드에서 스택이 변경되는 것을 이해하기 위한 핵심은 스택이 아래로 커진다는 점이다. 일반적으로 그림에서와 같이 스택을 푸시하면 스택의 위로 새로운 값이 입력된다고 생각하기 때문에 왜 스택의 값이 줄어드는지 이해하지 못하지만, 그림을 뒤집어 생각해보면 보다 쉽게 이해할 수 있을 것이다.
데이터 처리
AND 논리 - AND
OR 논리 - OR
AND와 OR 명령은 논리적인 비트 연산을 수행한다. 이 명령은 비트 데이터를 처리하는 데 가장 기본이 되기 때문에 모든 사람이 반드시 익숙해져야 한다.
NOT 1의 보수 부정
NEG 2의 보수 부정
NOT 명령은 각 바이너리 1을 0으로 변환시키고 0을 1로 변환시키는 비트 연산이다. NEG 명령은 0에서 피연산자를 뺀 것과 같다. 다음 코드는 이 두 명령의 차이점을 보여준다.
XOR 배타적 논리 - OR
어떤 값을 0으로 만드는 가장 빠른 방법이다. 두 피연자에 XOR을 사용하면 각각의 피연산자에 있는 같은 위치의 비트가 서로 다른 경우에 해당 비트를 1로 설정할 것이다. 만약 두 비트가 서로 같다면 0으로 설정할 것이다. XOR EAX, EAX는 MOV EAX, 0보다 빠르기 때문에( 클럭 사이클이 더 적기 때문이다), Microsoft 컴파일러는 레지스터를 0으로 만들기 위해서 이 명령을 사용한다.
INC 1 증가
DEC 1 감소
각 명령들이 단일 클럭 사이클로 실행되기 때문에 컴파일러는 종종 일련의 특정한 코드를 최적화할 때 이 명령들을 사용한다. 또한 이 명령들은 C 정수형 ++와 -- 산술 연산자와 일치한다.
SHL 왼쪽으로 시프트, 2로 나눔
SHR 오른쪽으로 시프트, 2를 곱함
바이너리 비트 시프트는 x86 CPU에서 이와 대응하는 곱셈/나눗셈 명령보다 빠르다. 이 명령들은 C의 << 와 >> 비트 연산자와 동일하다.
DIV 부호 없이 나눔
MUL 부호 없이 곱함
두 명령 모두 EAX 레지스터에서 부호 없는 연산을 수행한다. 하지만 결과는 암시적으로 EDX 레지스터를 사용한다. 더블 워드의 상위 바이트가 EDX 레지스터에 들어간다. DIV 명령은 EDX에 나머지를 저장하고 몫을 EAX에 저장한다. 두 명령 모두 레지스터나 메모리 값과 EAX에 있는 값으로 연산을 수행한다.
IDIV 부호 있는 나눔
IMUL 부호 있는 곱셈
이 명령들은 피연산자를 부호 있는 값으로 다룬다는 점을 제외하면 DIV/MUL 명령과 비슷하다. IDIV와 IMUL 명령도 DIV와 MUL 명령처럼 결과 값이 생성된다.
IMUL 명령은 때때로 피연산자가 3개이다. 첫 번째 피연산자는 목적지이고 나머지 두 개가 소스 피연산자이다. IMUL은 x86 명령 셋에서 유일하게 3개의 피연산자를 갖는 명령이다.
LOCK LOCK# 시그널 접두사 선언
LOCK 명령은 실질적인 명령이라기보다 다른 명령의 접두사에 가깝다. LOCK 접두사는 CPU에게 LOCK 다음에 오는 명령이 엑세스하는 메모리에 단일 연산이 필요하다는 것을 지시하기 때문에 해당 명령을 실행하는 CPU가 메모리 버스를 잠그고 다른 CPU가 메모리를 엑세스하지 못하도록 한다.
만약 LOCK 접두사가 사용되는 예를 보고 싶다면, Windows XP 또는 그 이후의 운영체제에서 InterlockedIncrement 함수에 대한 디스어셈블리 코드를 확인하도록 한다.
MOVSX 부호를 확장하여 이동
MOVZX 0으로 확장하여 이동
이 두 명령은 작은 값을 큰 값으로 복사하고 보다 큰 값이 상위 비트를 채우는 방법을 명령한다. MOVSX는 소스 피연산자에 있는 부호 값이 목적지 레지스터의 상위 비트를 통해서 확장되도록 명령한다. MOVZX는 목적지 레지스터의 상위 비트를 0으로 채운다. 이 두 명령은 여러분이 부호 에러를 추적할 때 확인해야 하는 명령이다.
비교와 테스트
CMP 두 피연산자를 비교한다.
CMP 명령은 첫 번째 피연산자에서 두 번째 피연산자를 뺀 후, 결과는 버리고 EFLAGS 레지스터에 적절한 플래그를 설정하는 방법으로 첫 번째와 두 번째 피연산자를 비교한다. CMP 명령은 C 언어의 if문으로 생각할 수 있다.
밑의 표는 CMP 명령이 실행되었을 때, 결과에 따른 플래그 설정을 보여준다.
| 결과(첫 번째 피연산자와 두 번째 피연산자를 비교) | 레지스터 창에서 확인한 플래그 설정 | Intel 설명서에 나온 플래그 설정 |
|---|---|---|
| 같다 | ZR = 1 | ZF = 1 |
| 작다 | PL != OV | SF != OF |
| 크다 | ZR = 0 이면서 PL = OV | ZF = 0 이면서 SF = OF |
| 같지 않다 | ZR = 0 | ZF = 0 |
| 크거나 같다 | PL = OV | SF = OF |
| 작거나 같다 | ZR = 1 이거나 PL != OV | ZF = 1 이거나 SF != OF |
TEST 논리적으로 비교한다.
TEST 명령은 피연산자들에 대해서 비트 논리 AND를 수행한 후, PL, ZR, 그리고 PE(Intel 설명서에서는 SF와 ZR, PF) 플래그를 설정한다. TEST 명령은 비트 값이 설정되어 있는지를 검사한다.
점프와 브랜치 명령
JMP 무조건 점프한다.
이름에서 알 수 있듯이, JMP 명령은 실행을 절대 주소로 점프한다.
JE 같다면 점프한다.
JL 작다면 점프한다.
JG 크다면 점프한다.
JNE 같지 않다면 점프한다.
JGE 크거나 같다면 점프한다.
JLE 작거나 같다면 점프한다.
만약 CMP와 TEST 명령르 실행한 후 겨로가 값을 가지고 아무것도 할 수 없다면 그다지 유용하지 않을 것이다. 조건 점프를 이용하면 결과에 따라서 점프할 수 있다. 이 명령들은 여러분이 [디스어셈블리] 창에서 가장 흔히 보게 될 명령들 중 하나이다. 비록 Pentium Xeon II 설명에 보면 62개의 서로 다른 조건 점프가 있지만, 그들 중 대부분은 니모닉이 "부정"을 나타낸다는 점을 제외하면 같은 작업을 수행한다. 예를 들어, JLE(작거나 같다면 점프한다)는 JNG(크지 않으면 점프한다)와 opcode가 같다. 만약 Visual Studio .NET 디버거이외의 다른 디스어셈블러를 사용하고 있다면, 다른 명령들을 보게 될 수 있다. 반드시 Intel 설명을 구해서 모든 점프 명령을 해석할 수 있도록 "Jcc" 코드들을 살펴보도록 한다.
디스 어셈블리를 살펴볼 때, 조건 검사가 보통 여러분이 입력한 내용과 정반대라는 사실을 알게 될 것이다. 다음 코드의 첫 번째 섹션이 바로 그러한 예이다.
CALL DWORD PTR [printf] // printf를 호출한다. 이 함수를 포인터를 통해서
// 호출하고 있기 때문에 printf 함수가 DLL에서
반복
LOOP ECX 카운터에 따라서 반복한다.
LOOP 명령을 사용하는 방법은 간단하다. ECX를 반복하고 싶은 횟수만큼 설정한 후, 코드 블록을 실행한다. 다음에 소개하는 코드는 ECX 값을 감소시키고 ECX가 0이 아니라면 블록의 처음으로 이동하는 LOOP 명령이다. ECX가 0이 될 때, LOOP 명령은 아래로 떨어진다.
여러분이 보게 될 대부분의 LOOP 명령들은 조건 점프와 무조건 점프를 혼합한 형태이다. 대부분의 경우 이러한 반복문들은 if 블록의 마지막이 처음으로 돌아가기 위한 JMP인 것을 제외하면 앞에서 살펴본 if 문 코드와 비슷하다. 다음 예제는 루프를 사용하는 코드의 대표적인 예이다.
문자열 처리
MOVS 문자열 데이터를 문자열로 이동시킨다.
MOVS 명령은 ESI에 있는 메모리 주소를 EDI에 있는 메모리 주소로 이동시킨다. MOVS 명령은 ESI와 EDI가 가리키는 값에 대해서만 작업을 수행한다. MOVS 명령을 C의 memcpy 함수를 구현한 것처럼 생각할 수 있다. 이동 명령이 실행되고 난 후에 ESI와 EDI 레지스터는 EFLAGS 레지스터의 Direction Flag(Visual Studio .NET 레지스터 창에서 UP 필드로 표시된다)에 따라서 증가하거나 감소한다. 만약 UP 필드가 0이면, 레지스터의 값이 증가한다. 만약 UP 필드가 1이면, 레지스터의 값이 감소한다. Microsoft 컴파일러가 생성하는 모든 코드에서는 UP 필드가 항상 1이다. 증가하고 감소하는 양은 연산의 크기에 따라서 다르다. 바이트의 경우에는 1이고, 워드는 2, 그리고 더블 워드는 4이다.
SCAS 문자열을 스캔한다.
SCAS 명령은 EDI 레지스터에 있는 메모리 주소에 있는 값을 크기에 따라 AL, AX 또는 EAX에 있는 값과 비교한다. EFLAGS에 있는 여러 가지 플래그 값들이 비교 값을 알리기 위해서 설정된다. 만약 NULL 종료 문자를 스캔한다면, SCAS 명령은 C의 strlen 함수로 사용될 수 있다. MOVS 명령처럼, SCAS 명령은 EDI 레지스터를 자동으로 증가시키거나 감소시킨다.
STOS 문자열을 저장한다.
SOTS 명령은 크기에 따라 AL, AX, 또는 EAX에 있는 값을 EDI 레지스터에 의해서 지정된 주소에 저장한다. STOS 명령은 C의 memset 함수와 비슷하다. MOVS와 SCAS 명령처럼, STOS 명령도 EDI 레지스터를 자동으로 증가시키거나 감소시킨다.
CMPS 문자열을 비교한다.
CMPS 명령은 두 문자열 값을 비교한 후, EFLAGS에 있는 플래그를 설정한다. SCAS 명령이 문자열과 단일 값을 비교하는 반면, CMPS 명령은 두 문자열에 있는 문자들을 차례대로 살펴본다. CMPS 명령은 C의 memcmp 함수와 비슷하다. 다른 문자열 처리 함수들처럼, CMPS 명령은 서로 다른 크기의 값을 비교하고 문자열에 대한 포인터들을 자동으로 증가시키거나 감소시킨다.
REP ECX 카운트만큼 반복한다.
REPE ECX 카운트가 0이 아니거나 같을 때까지 반복한다.
REPNE ECX 카운트가 0이 아니거나 같지 않을 때까지 반복한다.
문자열 명령들이 아무리 편리하다고 하더라도 한번에 문자열 하나만 처리할 수 있다면 그다지 유용하지 못할 것이다. 반복 접두사를 사용하면 문자열 명령을 주어진 횟수만큼(ECX에 있는 값) 반복하거나 지정된 조건이 일치할 때까지 반복한다. 문자열 반복문에서 충돌을 찾을 때 사용하는 한 가지 트릭은 몇 번째 실행에서 충돌이 발생했는지 확인하기 위해서 ECX 레지스터를 살펴보는 것이다.
문자열 명령에 대해서 얘기할 때, 각 명령들과 비슷한 C 런타임 라이브러리 함수에 대해서 언급했었다. 다음 코드들은 이 함수들을 에러 검사 없이 어셈블리 언어로 작성했을 때의 코드를 보여준다.
일반적인 어셈블리 언어 구성
FS 레지스터 엑세스
Win32 운영 체제에서, FS 레지스터는 Thread Information Block(TIB)이 저장되어 있는 포인터이기 때문에 특별하다. 또한 TIB를 Thread Environment Block(TEB)라고 부르기도 한다. TIB는 스레드에 관련된 모든 데이터를 보관하기 때문에 운영 체제가 스레드를 곧바로 엑세스할 수 있다. 이 스레드에 관련된 데이터에는 Structured Exception Handling(SEH)체인, 스레드 로컬 저장소, 스레드 스택, 그리고 내부적으로 필요한 다른 정보들이 포함된다.
TIB는 특별한 메모리 세그먼트에 저장되며 운영 체제가 TIB를 엑세스해야 할 때, FS 레지스터에 오프셋을 더하여 일반적인 선형 주소로 변경한다. FS 레지스터를 엑세스 하는 명령을 보면, 내부적으로 다음과 같은 작업 중 하나가 실행된다. SEH 프레임을 생성하거나 파괴하며, 또는 TIB를 엑세스 하거나 스레드 로컬 저장소를 엑세스한다.
SEH 프레임을 생성하거나 파괴하기
스택 프레임을 설정하고 난 후 처음으로 실행되는 명령이 종종 다음 코드와 같이 __try 블록을 시작하기 위한 표준 코드이다. SEH 핸들러 체인에서 첫 번째 노드는 TIB의 0번째 오프셋에 있다. 다음 디스어셈블리에서 컴파일러는 데이터와 포인터를 스택에 있는 함수에 푸싱하고 있다. 첫 번째 MOV 명령이 TIB를 엑세스하고 있다. 0번째 오프셋은 노드가 예외 체인의 맨 위에 추가되고 있음을 알린다. 마지막 두 명령은 실제 노드를 체인으로 이동시킨다.
다음 코드에서 확인할 수 있는 바오 같이 SEH 프레임을 파괴하는 일이 생성하는 것보다 훨씬 더 일반적이다. 기억해야 할 핵심 사항은 FS:[0]의 주소가 SEH를 의미한다는 점이다.
TIB 엑세스
FS:[18]의 값은 TIB 구조체의 선형 주소이다. 다음 코드에서 Windows XP는 TIB의 선형 주소를 얻는 GetCurrentThreadId 함수를 구현하고 0x24 오프셋에서 실제 스레드 ID를 구한다.
스레드 로컬 저장소 엑세스
스레드 로컬 저장소는 각 스레드마다 고유한 전역 변수를 가질 수 있는 Win32 메커니즘이다. TIB 구조체에서 0x2C 오프셋이 스레드 로컬 저장소 배열에 대한 포인터이다. 다음 디스어셈블리는 스레드 로컬 저장소 포인터를 엑세스하는 방법을 보여준다.
구조체와 클래스 참조
구조체와 클래스가 고급 언어에서는 다루기 쉬울지라도, 어셈블리 언어에는 실제로 그러한 개념들이 존재하지 않는다. 고급 언어에서 구조체와 클래스는 메모리에 대한 오프셋을 지정하는 간단한 방법일 뿐이다.
대부분의 경우 컴파일러는 여러분이 명시한 대로 구조체와 클래스를 위한 메모리를 할당한다. 경우에 따라서, 컴파일러는 메모리 경계를 유지하기 위해서 필드를 패딩(padding, 부족한 공간을 채움)할 것이다. x86 CPU에서는 4 또는 8 바이트가 사용된다. 구조체와 클래스 참조는 레지스터와 메모리 오프셋으로 나타낸다. 다음의 MyStruct 구조체에서, 각 멤버의 오른쪽에 있는 주석이 구조체의 시작으로부터의 오프셋을 보여준다. MyStruct의 정의 부분 다음에, 구조체 필드를 엑세스하는 다양한 방법을 소개하고 있다.
MOV EAX, pSt // pSt를 EAX에 저장한다. 아래를 보면, 이 구조체가 어떻게
// 생겼는지 보여주기 위하여 어셈브리 언어에서 오프셋을
// 사용하고 있다. 인라인 어셈블러를 이용하면 일반적인
// <struct>.<field> 참조를 사용할 수 있다.
디스어셈블리 창
탐색
[디스어셈블리] 창은 여러분이 디버기에서 원하는 부분으로 이동할 수 있는 효율적인 다양한 방법을 제공한다.
디버기의 특정한 위치로 이동하기 위한 첫 번째 방법은 [디스어셈블리] 창의 왼쪽 모서리에 있는 주소 콤보 상자를 이용하는 방법이다. 만약 이동하고자 하는 주소를 알고 있다면, 주소를 입력하고 곧장 이동할 수 있다. 또한 주소 콤보 상자는 심볼과 컨텍스트 정보를 해석할 수 있기 때문에 정확한 주소를 모른다고 할지라도 주소 근처로 이동할 수 있다.
유일한 작은 문제점은 심볼 형식을 지켜야 한다는 점이다. 여러분은 시스템이나 익스포트된 함수에 브레이크 포인트를 설정할 때 했던 것처럼 이름 데코레이션을 고려하여 변환 과정을 수행해야 할 것이다. 예를 들어 KERNEL32.DLL에 대한 심볼을 로드한 후, [디스어셈브리] 창에서 LoadLibrary 함수로 이동하고 싶다면 정확한 주소로 이동하기 위해서 주소 콤보 상자에 {,,kernel32}_LoadLibraryA@4를 입력해야 할 것이다.
[디스어셈브리] 창이 제공하는 훌륭한 기능은 드래그 앤 드롭이다. 만약 어셈블리 언어를 다루고 있고 연산이 메모리의 어디에서 진행되고 있는지 빠르게 확인해야 한다면, 메모리를 선택하여 주소 콤보 상자에 드래그한다. 마우스 버튼에서 손을 떼면, [디스어셈블리] 창이 자동으로 해당 주소로 이동할 것이다.
명령 포인터가 있던 곳으로 돌아가기 위해서는 [디스어셈블리] 창에서 마우스 오른쪽 버튼을 클릭한 후 [다음 문 표시]를 선택한다. 주소 콤보 상자가 여러분이 지금까지 이동한 모든 주소를 기억하고 있어서 언제든지 원하는 곳으로 돌아갈 수 있다.
스택에 있는 매개 변수 보기
디스어셈블리 창에서 브레이크 포인트를 설정하고 [디버그]-[창]-[메모리] 메뉴를 선택하여 [메모리] 창을 열 수 있다.
다음 문 설정 명령
소스 창에서처럼, [디스어셈블리] 창도 마우스 오른쪽 버튼을 클릭하여 [다음 문 설정] 명령을 사요할 수 있기 때문에 다른 위치를 실행하기 위해서 EIP를 변경할 수 있다. 디버그 빌드를 디버깅하는 도중에 소스 창에서 다음 문 설정을 사용하는 것보다는 확실하지만, [디스어셈블리] 창에서 다음 문 설정 명령을 설정할 때에는 매우 주의해야 한다.
EIP를 올바로 설정하는( 충돌이 발생하지 않도록 ) 확실한 방법은 스택에 주의하는 것이다. 스택 팝은 스택 푸시와 일치해야 한다. 만약 그렇게 되지 않으면 결국 프로그램이 충돌할 것이다.
예를 들어, 만약 프로그램이 당장 충돌하지 않도록 하면서 함수를 재실행하고 싶다면, 스택이 유지되도록 실행을 변경해야 한다. 이 예제에서는 0x00401005에 있는 함수를 두 번 호출하고 싶다.
디스어셈블리를 두 번 실행할 때, 스택의 균형을 맞추기 위해서 0x0040103F에 있는 ADD 명령이 실행되도록 해야 한다. 앞의 여러 가지 호출 규칙에 대해서 소개할 때 말했던 것처럼, 어셈블리 언어는 ADD 명령이 함수 호출 바로 다음에 위치하기 때문에 __cdecl 함수라는 것을 보여주고 있다. 함수를 재실행할 때, PUSH가 적절히 실행되도록 보장하기 위해서 실행 포인터를 0x00401035로 설정하였다.
스택을 직접 따라가기
[메모리]창과 [디스어셈블리]창은 상징적인 관계를 갖고 있다. 여러분이 어떤 일련의 어셈블리 연산이 실행되는지 [디스어셈블리] 창에서 확인하고 싶을 때, 여러분은 처리되고 있는 주소와 값을 살펴보기 위해서 [메모리] 창을 열어야 한다. 어셈블리 언어 명령은 메모리에 있으며, 메모리는 어셈블리 언어의 실행에 영향을 준다. [디스어셈블리] 창과 [메모리] 창 모두를 이용하여 이러한 관계를 동적으로 관찰할 수 있다. [메모리] 창은 그 자체만으로는 숫자를 나열한 것에 불과하다. 특히 프로그램이 충돌했을 때는 더욱 그러하다. 하지만 이 두 창을 결합함으로써, 몇몇 충돌 문제들을 해결할 수 있다. 이 창들을 함께 사용하는 일은 최적화된 코드를 디버깅하려고 하거나 디버거가 스택을 쉽게 따라갈 수 없을 때 특히 중요하다. 이러한 충돌이 발생하는 문제를 해결하기 위해서는 스택을 직접 따라가야 한다.
스택을 따라가는 방법을 알아내기 위한 첫 번째 과정은 바이너리 파일들이 메모리의 어디에 로드외어 있는지를 알아야 한다. 새로운 [모듈] 창은 어떤 모듈이 로드될 때 모듈의 이름과, 모듈의 경로, 로드 순서 그리고 가장 중요한 로드된 주소 범위를 표시하기 때문에 모듈이 어디에 로드되어있는지 알 수 있다. 스택을 주소 범위 목록과 비교하여, 어떤 항목이 모듈에 있는지 알 수 있다.
로드된 주소 범위를 살펴보고 난 후, [메모리] 창과 [디스어셈블리] 창을 열어야 한다. [메모리] 창에서 [주소] 필드에 스택 레지스터인 ESP를 입력하고 [메모리] 창에서 마우스 오른쪽 버튼을 누른 후 [4바이트 정수]를 선택하여 더블-워드 형식으로 값을 표시한다. 그리고 한 줄에 32비트 값만을 표시하기 위해서 [메모리] 창을 IDE의 도킹된 위치에서 밖으로 꺼내놓고 크기를 조절하면 훨씬 더 작업하기에 편하다. 이러한 형태는 여러분이 스택을 생각하는 것과 유사하게 스택을 보여준다. 다른 로드된 모듈에 포함되어 있는 것처럼 보이는 숫자를 발견하면, 숫자를 선택하여 [디스어셈블리] 창에 있는 [주소] 콤보 상자에 드래그한다. [디스어셈블리] 창이 해당 주소에 있는 어셈블리 언어를 표시할 것이다. 만약 디버깅 정보가 있다면, 호출 함수가 무엇인지도 확인할 수 있다.
만약 ESP 레지스터 덤프가 모듈 주소처럼 보이는 것을 표시하지 않는다면, [메모리] 창에 EBP 레지스터를 덤프하여 살펴볼 수 있다. 여러분이 어셈블리 언어에 익숙해지면, 충돌이 발생한 주소 근처에 있는 디스어셈블리가 보이기 시작할 것이다. 이러한 충돌 상황을 경험하다보면 리턴 주소가 ESP 혹은 EBP의 어디에 있는지에 대한 힌트를 얻을 수 있을 것이다.
스택에 있는 항목을 직접 찾는 방법은 이해할 수 있는 주소를 찾기 전까지 상당히 많이 스택을 추적해야 할지도 모른다는 단점이 있다. 하지만 만약 모듈이 어디에 로드되어 있는지를 알고 있다면, 적절한 주소를 빨리 찾을 수 있을 것이다.
CPU의 엔디안은 바이트의 어떤 바이트가 먼저 저장되는지를 가리킨다. Intel CPU는 리틀 엔디안이다. 즉, 멀티바이트 값의 마지막 부분이 먼저 저장된다는 것을 의미한다. 예를 들어 0x1234 값은 메모리에 0x34 0x12와 같이 저장된다. 디버거에서 메모리를 살펴볼 때 메모리에 저장된 데이터가 리틀 엔디안으로 저장되는 사실을 항상 기억해야 한다. 정확한 값으로 해석하기 위해서는 머리 속에서 메모리 값을 변환해야 할 것이다. 만약 링크드 리스트 노드에서 다음 포인터 값이 0x12345678인 노드를 살펴보기 위해서 [메모리] 창을 사용한다면, 그 값은 0x78 0x56 0x34 0x12와 같은 형태로 표시될 것이다.
[디스어셈블리] 창에서 충돌 덤프를 만난다면, 여러분이 실제 코드를 보고 있는지 아닌지를 결정해야 한다. 때때로 이를 결정하기란 쉽지 않다. 다음은 여러분이 실행 파일 코드를 보고 있는지 아닌지를 알아낼 때 도움을 줄 수 있는 몇 가지 팁들이다.
[디스어셈블리] 창에서 마우스 오른쪽 버튼을 클릭하여 [코드 바이트]를 활성화하면 명령에 대한 opcode를 확인할 수 있다. 다음에 소개하는 팁에서 알 수 있는 것처럼, 어떤 opcode 패턴을 찾는지 알고 있다면 타당한 코드를 보고 있는지 결정할 때 도움이 될 것이다.
만약 일련의 ADD BYTE PTR [EAX], AL 명령을 보게 된다면, 타당한 어셈블리 언어 코드를 보고 있다고 볼 수 없다.
만약 심볼에 더해진 오프셋이 매우 큰(일반적으로 0x1000 이상) 심볼을 확인한다면, 아마도 코드 섹션 밖에 있다고 봐야 한다. 하지만 매우 큰 숫자가 심볼이 없는 모듈을 디버깅하고 있다는 것을 의미할 수도 있다.
만약 이 장에서 다루지 않았던 명령들이 보여면, 아마도 데이터를 보고 있을 것이다.
만약 Visual Studio .NET 디스어셈블러가 명령을 디스어셈블할 수 없다면 opcede를 "???"로 표시한다.
만약 명령이 타당하지 않다면, 디스어셈블리는 "db" 다음에 숫자를 표시할 것이다. "db"는 data byte를 의미하며 이는 타당한 명령이 아니다. 즉 해당 위치에 있는 opcode가 Intel opcode 맵에 있는 명령과 일치되지 않는다는 것을 의미한다.
Visual Studio .NET 디버거 [조사식] 창은 모든 레지스터 값을 어떻게 값으로 디코딩해야 하는지 알고 있다. 따라서 [조사식] 창에 레지스터를 입력하고 원하는 타입으로 캐스팅할 수 있다.
예를 들어 문자열 처리 명령을 살펴보고 있다면, 데이터를 보기 편한 형식으로 보기 위해서 [조사식] 창에 (char*)@EDI나 (wchar_t*)@EDI를 입력할 수 있다.
만약 어셈블리 언어와 소스 일이 혼합되어 있는 소스 파일을 보고 싶다면, Visual Studio .NET을 이용하여 소스 파일에 대한 어셈블리 소스 코드를 생성할 수 있다.
프로젝트 [속성 페이지] 대화 상자에서 C/C++ 폴더에 있는 [출력 파일] 속성 페이지를 선택한 후 [어셈블리 출력] 필드에서 소스 파일마다 ASM 파일을 생성하도록 [소스 코드로 구성된 어셈블리(/Fas)]를 선택한다. 빌드를 할 때마다 ASM 파일을 생성하고 싶지는 않겠지만, 컴파일러가 무엇을 생성하는지 확인할 수 있기 때문에 도움이 될 것이다. ASM 파일을 생성하면 어셈블리 언어가 궁금할 때마다 응용 프로그램을 중단시킬 필요가 없다.
생성된 파일들은 Microsoft Macro Assembler(MASM)로 컴파일할 수 있는 바로 직전의 상태이기 때문에, 이 파일을 읽기가 어려울 수도 있다. 대부분의 파일들이 MASM 지시자로 구성되어 있지만, 중요한 부분은 어셈블리 언어 코드와 함께 C 코드를 보여준다.
해결 : 파일 -> 저장 고급 옵션 -> 인코딩 -> 유니코드 - 코드페이지 65001
#pragma comment ( linker, "/entry:WinMainCRTStartup /subsystem:console" )
디버그 메시지를 출력한다. 활성화된 디버거가 있는 경우에는 그 곳에 디버그 메시지를 출력하고 없는 경우라면 아무런 일도 하지 않는다.