포스트

디스어셈블리 코드로 살펴보는 점프문과 함수 포인터

디스어셈블리 코드를 통해 점프문인 continue, break 명령과 함수 포인터에 대해 분석한다

디스어셈블리 코드로 살펴보는 점프문과 함수 포인터

1. “continue” 와 “break”

C/C++ 에서 continue와 break 명령문들은 어떻게 동작할까?
다음은 continue와 break를 다룬 아주 간단한 C/C++ 코드이다.

디스어셈블리 스크린샷 1

다음은 이에 대한 디스어셈블리 코드이다.

디스어셈블리 스크린샷 2

위의 디스어셈블리 코드들 중에서 중요한 부분만 따로 간추려 최대한 간단하게 설명해 보도록 하겠다.

1
2
3
4
5
6
7
8
9
                if(cnt == 1) continue;
00AB144D  cmp         dword ptr [cnt],1  
00AB1451  jne         main+45h (0AB1455h)  
00AB1453  jmp         main+2Eh (0AB143Eh)
  
  if(cnt == 3) break;
00AB1455  cmp         dword ptr [cnt],3  
00AB1459  jne         main+4Dh (0AB145Dh)  
00AB145B  jmp         main+58h (0AB1468h)

먼저 “continue” 부분을 살펴 보자.
이미 알고 있는 것 처럼, 프로세서는 조건문이 만족되어 상태 플래그가 참이되면 실행 순서를 주소 “0AB143Eh”로 분기 시킨다.
주소 “0AB143Eh”는 명령어 “cnt++”의 주소이다.

“break” 부분도 위와 유사하다. 조건문이 만족되어 상태 플래그가 참이되면 프로그램은 “0AB1468h”로 분기 되어 루프문을 탈출 한다.

추가로 “goto”문에 대해서도 약간 다루어 보겠다.

1
2
3
4
5
6
7
8
9
 Lable:

 num++;
00381468  mov         eax,dword ptr [num]
0038146B  add         eax,1
0038146E  mov         dword ptr [num],eax

 goto Label;
00381471  jmp         Lable (381468h)

보는 바와 같이 MSVC 컴파일러는 “goto”문을 “jmp”으로 바꿔 처리한다.
물론 “goto”를 사용하려면 레이블을 추가적으로 사용해야 하는 불편함이 따른다.

여기서 우리는 “goto”문이 “continue”문이나 “break”문과 같이 “jmp”로 컴파일 된다는 사실을 알 수 있었다.
그러나 “goto”문은 코드를 복잡하게 만들기 때문에 사용하지 않는 것이 좋다.
제대로 꼬인 스파게티 코드를 만들고 싶지 않다면 말이다.

2. 함수가 호출되는 방식

이제 함수 포인터가 내부적으로 어떻게 작동하는지 디스어셈블리 코드를 통해 살펴 볼 차례이다.

C++ 코드


디스어셈블리 스크린샷 3

main 함수의 디스어셈블리 코드


디스어셈블리 스크린샷 4

점프 테이블 코드


디스어셈블리 스크린샷 5

func 함수의 디스어셈블리 코드


디스어셈블리 스크린샷 6

지금으로써는 우선 func() 함수가 호출 되는 부분만 살펴 보도록 하자.

1
2
3
4
 result = func(10, 20);
003134E5  push        14h  
003134E7  push        0Ah  
003134E9  call        func (31101Eh)

프로세서가 func() 함수 호출 명령을 만났을때 func() 함수의 주소는 “31101Eh”다.
그런데 어째서 호출되는 함수의 주소가 “BD2F60h”가 아니라 “31101Eh”일까?

func()의 주소는 “BD2F60h” 인데도 말이다.
이는 컴파일러에 의해 만들어진 점프 테이블에 대한 맵핑 작업 때문이다.

맵핑 작업에 대해 설명해 보자면,
소스코드가 컴파일 될때 MSVC 컴파일러는 분기를 원활하게 할 수 있도록 코드 영역 앞쪽에 모든 함수의 주소를 수집, 정리하여 점프 테이블을 생성하는데 이 테이블을 이용하여 분기하는 일련의 과정을 맵핑 작업이라고 볼 수 있다.

불행하게도 아직 컴파일러 공부를 제대로 하지 않아서 명확하게 설명하기는 어렵다.
혹여라도 설명이 잘못 되었다면 따끔한 질책을 부탁 드린다.

그럼 컴파일러에 의해 생성된 점프 테이블 코드 부분을 살펴 보자:

디스어셈블리 스크린샷 7

빨간색 박스로 감싸진 영역이 보이는가?
각각 위에서 부터 아래 방향으로 “func()” 함수와 “func2()” 함수의 실제 코드 영역에서의 주소이다.
이를 통해 우리는 두 가지 사실을 알 수 있다.

1) 프로세서는 점프 명령을 만나면 점프 테이블을 이용해 프로그램을 분기 시킨다. 2) 점프 테이블은 함수들의 주소를 갖고있다.

이 흐름의 과정을 그림으로 나타내 보면 다음과 같다 :

디스어셈블리 스크린샷 8

요컨대, “프로세서가 점프 명령을 만나 분기 작업을 할때 점프 테이블을 이용한다.” 라고 할 수 있겠다.

3. 함수 포인터

그럼 이제 func() 함수에 대한 함수 포인터 부분을 살펴 보도록 하자. 만약 위의 글을 제대로 읽었다면 이번 설명은 무척이나 이해하기 쉬울 것이다.

1
2
3
4
5
6
7
 pfunc = func;
000D1B14  mov         dword ptr [pfunc],offset func (0D101Eh)
 result = pfunc(30, 40);
000D1B1B  mov         esi,esp
000D1B1D  push        28h  
000D1B1F  push        1Eh  
000D1B21  call        dword ptr [pfunc]

아시다시피 함수의 이름은 함수의 시작 주소를 의미한다.
배열의 이름이 그 배열의 시작 주소를 의미하는 것과 같다.

위의 코드에서 볼 수 있는 것 처럼, func() 함수의 주소 “0D101Eh”는 함수 포인터 변수 “pfunc”에 할당 되어 진다.
나머지 코드는 위의 함수 설명에서 다룬 것과 유사하다.
다만 이번 처럼 함수 포인터를 사용해 함수를 호출할때에는 점프 테이블을 이용하지 않고 함수 포인터 변수에 들어있는 주소를 직접적으로 이용해 분기 한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.