IT_Programming/Assembly

[전광성의 어셈블리어 이해하기] 고급 프로시져

JJun ™ 2007. 7. 2. 10:39
LONG

 

  • INVOKE 디렉티브

    INVOKE는 call 인스트럭션에 스택 파라미터를 넘기는 기능이 추가된 디렉티브이다. 사용방법은 다음과 같다.


            INVOKE 프로시져이름 [인자1, 인자2, ...]

    인자가 없다면 생략 가능하다. 다음의 예제를 보라.

        .data
        val1 DWORD 12345h
        val2 DWORD 23456h
        .code

    진한 글씨로 된 INVOKE 부분은 다음의 코드와 같은 일을 한다.

        push val2
        push val1
        call AddTwo

    두번째 인자부터 스택에 push되는 것에 유의해야 한다. 그 이유는 프로시져 호출 규약과 관련되어 있으니, 본 회의 뒷쪽에서 설명할 예정이다. 그렇다면 스택 파라미터를 이용하는 프로시져는 어떻게 정의할까?

  • 스택파라미터를 사용하는 프로시져의 정의

    정의하는 방법은 다음과 같다. 일반 프로시져 정의에 파라미터가 추가된 것 뿐이니 어렵게 생각하지 말자.

        프로시져이름 PROC,
            파라미터1,
            파라미터2,
            ......
            파라미터n

    정의할때 위와같이 적어주면 된다. 간단한 예를 하나 보자
        AddTwo PROC,
            val1:DWORD,
            val2:DWORD
            ...
        AddTwo ENDP


    다음은 지역변수까지 있는 프로시져의 정의이다.

        ReadFile PROC,
            pBuffer:PTR BYTE        ;포인터
            LOCAL fileHandle:DWORD
            ...
        ReadFile ENDP


    여기서 한 가지 주의해야 할 사항이 있다. 바로 위의 예제코드, 즉 ReadFile 프로시져를 보자. 이를 호출할 때 첫번째 파라미터가 포인터이므로 다음과 같이 호출하면 될 것이라 착각하기 쉽다.

        .data
         myArray BYTE 30 DUP (?)
    	 hFile DWORD
        .code
        INVOKE ReadFile, OFFSET myArray, hFile


    그러나 위와같이 하면 잘 동작하지 않는다. 프로시져를 INVOKE로 호출할 때, 인자로 변수의 주소를 넘기면 반드시 ADDR연산자를 붙여서 다음과 같이 호출해야 한다.

        INVOKE ReadFile, ADDR myArray, hFile

    이렇게 해야 함수가 잘 동작한다. 이제 스택 파라미터를 이용하는 프로시져를 어떻게 정의해야 할지 감이 왔으리라 믿는다.

  • PROTO 디렉티브

    PROTO 디렉티브는 고급언어의 함수 선언과 같은 역할을 한다. 미리 프로시져의 생김새가 어떠한지 선언해 두는 것이다. 따라서 프로시져의 이름과 파라미터 리스트를 적게 되며, 이를 미리 선언해 두면 그 프로시져를 정의하기 이전에 호출 문장을 사용할 수 있다. 본래 프로시져의 정의에서 PROC을 PROTO로 바꾸고 USES디렉티브가 붙어있다면 제거한 후에, 파라미터 리스트까지만 적으면 된다. 다음 같은과 프로시져가 정의되어있다고 하자.

        ArraySum PROC USES esi ecx,
            ptrArray:PTR DWORD,
            szArray:DWORD
    		...
        ArraySum ENDP
    

    이 프로시져의 원형은 다음과 같이 적어주면 된다.

        ArraySum PROTO,
            ptrArray:PTR DWORD,
            szArray:DWORD

  •  
    =============================================================================================
     
     
  • 프로시져 호출 규약

    매크로 어셈블러는 .MODEL디렉티브를 이용해서 메모리 모델과 모델 옵션을 정할 수 있는데, 모델 옵션으로는 프로시져 호출에 대한 규약을 정할 수 있다. 여러 가지 프로시져 호출 규약 중 stdcall과 cdecl, pascal에 대해 알아보겠다.

    stdcall을 사용할 때는 .model flat, stdcall이라고 소스코드에 적어주면 된다.(flat은 메모리 모델을 이야기한다) stdcall은 파라미터가 넘어갈때 역순으로 넘어간다. 즉,
        INVOKE AddTwo, 5, 6
    
    이렇게 호출 했을 경우 6이 먼저 스택에 들어가고난 다음 5가 스택에 들어간다. 그리고 함수가 종료될 때, 호출된 함수는 스택에서 파라미터를 제거 시켜줘야 한다. AddTwo함수가 DWORD형 파라미터를 두 개 받는다고 하면
        AddTwo PROC
            ...
            ret 8
        AddTwo ENDP
    
    ret인스트럭션 뒤에 숫자 8은 무엇을 의미할까? 함수가 끝나고 리턴될 때, esp에 8을 더하란 뜻이다. (왜 양수냐고 묻기 전에 아래로 자라는 스택임을 상기시키길 바란다) 다시말해서, 스택에 있는 DWORD형(4바이트) 변수 2개를 삭제시키겠다는 뜻이다. 이렇게 함으로서 함수가 호출될 때 스택에 쌓였던 것들이 모두 제거 되었다. 물론 우리가 프로시져를 정의할 때 PROC디렉티브 뒤에 직접 파라미터 리스트를 적은 경우에는 함수 마지막 부분에 그냥 ret라고만 써주면 된다. 뒤에 8을 붙이는 것은 어셈블러가 알아서 해 주기 때문이다.

    cdecl을 사용할 때는 .model C라고 적어주면 된다. cdecl은 파라미터가 스택에 쌓이는 순서는 stdcall과 같다. 하나 다른 점은 함수호출이 끝난 후 스택에 있는 파라미터에 대한 삭제를 호출된 함수가 아니라 그 함수를 호출한 쪽에서 담당한다는 것이다. 즉, 위에서 예로 든 AddTwo를 cdecl으로 작성했다면
        push 6
        push 5
        call AddTwo
        add esp, 8
    
    이렇게 호출 한 다음에 호출했던 쪽에서 스택에 8을 더해 주어야 한다.(stdcall은 호출된 함수 내에서 제거시켜 주었다)

    pascal을 사용할 때는 .model pascal이라고 적어주면 된다. 이는 파라미터를 스택에 넣는 순서가 순방향이다. 즉, stdcall과 반대라는 뜻이다. 그리고 스택에서 파라미터를 누가 지우느냐 하는 것은 stdcall과 같다.

    참고로 MS-Windows 함수는 기본적으로 stdcall을 따른다.

  • LEA 인스트럭션

    지역변수에 대한 포인터는 어떻게 얻어낼까? 우리가 이전에 사용하던 변수들은 대부분 .data에 정의되어 있었고, 이것의 주소는 OFFSET연산자로 알아낼 수 있었다. 이는 메모리의 스태틱 데이터 영역에 있기 때문에 데이터 세그먼트로 부터의 오프셋만 알면 참조할 수 있었다. 또한 그 오프셋은 어셈블할 때 결정되기 때문에 단순히 '연산자'차원에서 가능한 것이다.

    하지만 지역변수나 스택 파라미터는 이야기가 틀려진다. 프로시져는 어디서든 호출될 수 있는데, 그 때마다 스택에 쌓여있는 양이 다르므로 지역변수의 주소는 그때마다 달라지게 된다. 따라서 지역변수의 주소를 얻어오는 인스트럭션이 존재하는데 그것이 바로 lea인스트럭션이다.(Load Effective Address의 약자이다)
    lea (주소를 저장할 곳), (대상)
    이렇게 사용하며 (대상)의 주소를 읽어 (주소를 저장할 곳)에 저장해 준다. 다음에 사용 예가 있다.
        abc PROC USES esi
            LOCAL string[20]:BYTE
            lea esi, string   ; esi는 배열을 가리키는 포인터가 되었다.
            ...
        abc ENDP
    
    mov esi, OFFSET string 이라고 쓰는 오류를 범하지 않도록 주의해야 한다.

  • 마치는 글

    오늘은 프로시져에 대해서 자세히 배워 보았다. 이제 어셈블리어에서도 고급언어와 같이 프로시져에 파라미터를 넘기고, 지역변수를 만들 수 있게 되었다. 만들 수 있다는 그 자체보다 어떻게 지역변수와 파라미터가 스택에 저장되게 되는지, 어떻게 사라지게 되는지를 아는 것이 이 강좌를 통해 전달하고자 하는 것이다. 다음 회에서는 스트링 처리에 대한 인스트럭션들과 이차원 배열 사용법에 대해 배울 것이다. 필자는 다음 회를 어떻게하면 좀 더 쉽게 설명할 수 있을지 고민하고 있을 테니, 그동안 건강하길 빈다.

  •  
    ARTICLE
  • 시작하기에 앞서...

    5회에서 프로시져에 대해 배운 적이 있다. 그러나 우리가 고급언어에서 사용해 왔던 함수와는 약간 다른점이 있었을 것이다. 지역변수도 없었고, 파라미터도 레지스터를 이용한 방법밖에는 없었다. 이번 회에서는 실제 스택에서 지역변수가 어떻게 생성될 수 있는지, 파라미터는 스택에서 어느 위치에 존재하는지에 대해서 배울 것이다. 따라서 우리는 고급언어에서의 함수와 매우 유사한 형태의 프로시져를 정의할 수 있게 될 것이다.

  • LOCAL 디렉티브

    먼저 지역변수를 선언하는 방법부터 언급하겠다. 이는 LOCAL 디렉티브를 이용해서 사용할 수 있으며 스택에 지역변수에 대한 메모리가 할당된다. 사용 방법은 프로시져의 정의 첫 줄에 다음과 같이 적어주면 된다.
    LOCAL label1:type1, label2:type2, ...
    label은 변수 이름이고, type은 데이터형이다. 배열을 사용하고 싶다면 다음과 같이 선언하면 된다. 다음은 DWORD형의 크기 10을 갖는 배열을 선언한다.

            LOCAL TempArray[10]:DWORD

    LOCAL디렉티브를 사용하면 어셈블러는 이것으로부터 일정한 코드를 생성해 내게 된다. 예를 들어 다음과 같이 프로시져의 정의 내에 LOCAL디렉티브를 사용했다고 하자.

        BubbleSort PROC
    LOCAL temp:DWORD, SwapFlag:BYTE

    ; 함수 안에서 할 일들...

    ret
    BubbleSort ENDP

    위 코드는 어셈블러에 의해 다음과 같이 변환되며, 스택은 그림 1과 같은 모양이 된다.
        BubbleSort PROC
    push ebp ; 이전에 사용해오던 ebp를 저장
    mov ebp, esp ; 지역변수 참조 기준점 설정(ebp)
    add esp, 0FFFFFFF8h ; esp에 -8을 더한다.

    ; 함수 안에서 할 일들...

    mov esp, ebp ; 지역변수 제거
    pop ebp ; 이전에 사용했던 ebp 다시 불러옴
    ret
    BubbleSort ENDP


    < 그림 1 : 함수호출시 스택의 모양>

    그림 1의 스택을 볼 때는 아래로 자라는 스택임을 잊지 않았으면 한다. 또한 그림의 윗쪽으로 갈수록 높은 숫자의 주소를 갖는다. 먼저 위의 코드를 보자. 함수가 호출되면 일단 호출한 곳의 주소가 스택에 쌓일 것이다.(그림1의 return address) 그 다음 이전에 사용중이던 ebp를 스택에 push함으로써 저장시켜놓는다. 그리고 나서 현재 스택의 제일 윗부분(즉 스택의 ebp가 쌓여있는 공간)의 주소를 ebp에 복사한다. 이 레지스터 ebp는 지역변수를 참조하는데 기준이 되는 레지스터이다. 즉, [ebp - 4]와 같은 방법으로 temp변수에 접근할 수 있고, [ebp - 8]과 같은 방법으로 SwapFlag변수에 접근할 수 있다. 그러나 이것은 어셈블리어가 해주는 일이고, 우리는 그저 변수이름을 사용함으로써 그 지역변수에 접근할 수 있다.

    프로시져에서 할 일을 모두 끝냈다면 이제 이전에 했던 초기화 작업을 거꾸로 함으로써 지역변수를 정리하고 이전의 ebp값을 되돌린다. 그림이나 코드에는 나오지 않았지만 ret인스트럭션을 만났을 때 최종적으로 return address까지 스택에서 꺼내어 eip에 값을 복사해 넣었을 것이다.(이는 5회에 잘 나와있다.)

  • 스택 파라미터

    우리가 이전에 5회에서 배웠던 프로시져에서는 레지스터 파라미터를 사용하였다. 즉, 프로시져에 값을 넘길때 레지스터를 이용하였다. 파라미터를 넘기는 또다른 방법은 스택 파라미터를 이용하는 것이다. 이것은 우리가 고급언어에서 사용하던 방법으로, 스택에 파라미터가 쌓여 함수 내에서 사용한 뒤 함수가 리턴되면 파라미터로 사용되었던 변수는 사라지게 된다. 레지스터 파라미터는 상대적으로 빠른 반면에, 자칫 코드가 꼬일 수 있으며 그 개수에 제한이 크다. 스택 파라미터를 이용하면 프로시져 호출 속도는 조금 느려지겠지만, 더 안정적이며 가독성이 높고, 파라미터 개수 제한이 없다. 스택 파라미터도 지역변수와 마찬가지로 프로시져가 호출 된 후 설정되는 ebp값을 기준으로 참조된다. 파라미터에 대해서 표1에 비교해 놓았다.


    pushad

    mov esi, OFFSET array

    mov ecx, LENGTHOF array

    mov ebx, TYPE array

    call DumpMem

    popad

    push TYPE array

    push LENGTHOF array

    push OFFSET array

    call DumpMem


    < 표 1 : (좌)레지스터 파라미터, (우)스택 파라미터>

    표 1의 좌측은 DumpMem이 레지스터 파라미터를 이용하여 작성되었을 때의 호출 방법이고, 표 1의 우측은 스택 파라미터를 이용하여 작성되었을 때의 호출 방법이다.(파라미터의 순서가 뒤바뀐 이유는 뒤에서 설명할 예정이다.) 표 1 좌측과 같이 레지스터 파라미터를 이용한 호출을 안전하게 하려면 pushad와 popad를 이용하여 모든 레지스터를 스택에 저장했다가 불러와야하는 불편함이 있다. 하지만 스택 파라미터를 이용하면 조금더 깔끔하다. 그러나 이것도 한줄에 끝내버릴 수는 없을까? 그 대답은 INVOKE 디렉티브에 있다. 다음과 같이 사용해 보자.
        INVOKE DumpMem, OFFSET array, LENGTHOF array, TYPE array

    어떠한가? 한층 더 간결하면서 보기도 좋지 않은가? 내부적으로 어떤 일을 하는지는 다음문단에서 자세히 설명하겠다.