Part4. 어셈블리어(Assembly Language)와 C 그리고 호출 규약(Calling Convension)

원문 :  http://kkamagui.springnote.com/pages/339546

 

들어가기 전에...

 

0.시작하면서...

OS를 개발하면서 초반에 어셈블리어로 작성한 코드를 보면, 사용한 어셈블리어 명령이 몇 종류 없는 것을 알 수 있다. 그것도 아주 기초적인 수준의 어셈블리어만 사용했는데, 역으로 말하면 몇가지 종류의 어셈블리어만 알고 있으면 부트로더(Boot Loader), 커널로더(Kernel Loader), 그리고 기타 초기화 함수를 작성할 수 있다.

 

1.어셈블리어(Assembly Language) 기초 명령

 아래는 기초 명령의 리스트이다(Intel Style의 명령이라 가정한다).

 

  • mov A, B : B에서 A로 값을 이동
  • cmp A, B : 두 값을 비교하여 결과를 Flags 레지스터에 업데이트
  • rep instruction : insturction을 CX 레지스터의 값 만큼 반복 수행
  • call X : Stack에 Return Address를 삽입하고 jump 수행
  • jmp X : 무조건 해당 주소로 jump
  • je, ja X : 조건 분기 명령. Flags 레지스터의 플레그 값에 따라서 jmp 수행(보통 cmp와 같은 명령어와 함께 사용)
  • push X: 스택에 값을 저장
  • pusha, pushad : 스택에 모든 레지스터 값을 저장. EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI 저장
  • pop X : 스택에서 값을 꺼냄
  • popa, popad : 스택에서 모든 레지스터의 값을 꺼냄. 위의 pushad 명령과 같은 순서의 레지스터 사용
  • add A, B : A에 B의 값을 더함
  • sub A, B : A에서 B의 값을 뺌
  • mul A : EAX의 값과 A의 값을 곱하여 A에 저장
  • inc A : A의 값을 1 증가시킴
  • int X : X번째의 Software Interrupt를 발생시킴
  • ret, retn : Stack에 포함된 Return Address를 꺼내서 해당 주소로 복구(보통 Call 명령과 같이 사용)
  • iret, iretd : 인터럽트 처리 시에 모든 처리를 완료하고 다시 태스크로 복구
  • or A, B : A에 B값을 OR
  • xor A, B : A에 B값을 XOR
  • not A : A의 값을 반전(0->1, 1->0)
  • lgdt : GDT를 설정(Intel Architecture 특수 명령)
  • lidt : IDT를 설정(Intel Architecture 특수 명령)
  • lldt : LDT를 설정(Intel Architecture 특수 명령)
  • ltr : Task Register에 TSS를 설정(Intel Architecture 특수 명령)
  • clts : Task Switching 플래그를 0으로 설정(Intel Architecture 특수 명령)
  • cli : 인터럽트 불가 설정
  • sti : 인터럽트 가능 설정
  • fninit : FPU 초기화 명령(x87 Floating Point Unit 관련 명령)
  • ... 기타 등등

 

 물론 전부를 나열하지는 않았지만 척 봐도 알 수 있는 기본적인 명령어들이다. 물론 성능을 고려한다면 더 많은 어셈블리어 명령어들이 리스트에 포함되겠지만, 성능적인 면을 고려하지 않는다면 위의 함수 정도면 OK다.

 위의 함수에 대한 기본적인 기능들은 Intel Architecture Manual Volume 2 Instruction Set 문서를 참고하면 된다. 위의 명령어를 사용하여 프로그램을 작성하고 싶은 사람은 New Wide Assembler(NASM)을 이용하면 테스트 가능한데, 10 참고자료를 참고하면 간단히 함수를 생성하고 빌드 할 수 있다.

 http://nasm.sourceforge.net/ 홈페이지에 가면 컴파일러를 다운받을 수 있고 예제 및 문서도 제공하므로 한번 해보는 것도 괜찮을 듯 하다.

 

 

2.호출 규약(Calling Convention)

2.1 stdcall, cdecl, fastcall

 실제로 어셈블리어를 아는 것도 중요하지만 이 함수를 C 언어에서 어떻게 호출하여 사용할 것인가 하는 문제도 중요하다. 흔히들 호출 규약(Calling Convention)이라고 표현하는 이것은 함수를 호출하는 규약인데, 몇가지 방식이 존재한다.

  • stdcall(pascal) 방식 : 스택에 파라메터를 역순으로 삽입하고 함수를 호출. 스택의 정리작업을 호출된 함수에서 수행. 파스칼 언어 및 베이직 언어에서 사용하는 방식
  • cdecl 방식 : 스택에 파라메터를 넣는 방식은 stdcall과 같음. 단 스택의 정리작업을 호출한 함수에서 수행. C언어에서 사용하는 방식
  • fastcall 방식 : 몇개의 파라메터는 레지스터를 통해 넘기고 나머지 파라메터는 스택을 사용하는 방식

 위의 세가지 중에서 보편적인 방식 두가지는 stdcall 및 cdecl 방식이다. 이 두가지 방식의 가장 큰 차이점은 스택의 정리를 누가 하는 가이다.

 stdcall 방식 같은 경우 Callee(호출 된 함수)에서 스택 정리를 하므로 Caller(호출하는 함수)와 Callee 모두 파라메터의 개수를 알고 있어야 정상적인 처리가 가능하다.

 반면 cdecl 방식 같은 경우 Caller에서 스택 정리를 하므로 Callee는 파라메터의 개수를 정확하게 몰라도 된다. 바로 이 점이 C 언어의 가변인자(Variable Argument)를 가능하게 하는 것이다(printf와 같은 함수를 생각해보자).

 가변인자에 대해서는 나중에 알아보고 우리가 사용할 cdecl에 대해서 자세히 알아보자.

 

2.2 cdecl 분석

 아래는 간단한 C 프로그램을 작성한 것이다.

  1. int DoSomething( int a, int b )
    {
        int c;
        c = a+b;
        return c;
    }
  2. int main(int argc, char* argv[])
    {
        DoSomething( 1, 2 );
  3. }

 

 간단하게 파라메터 2개를 받아서 그중 첫번째 파라메터를 리턴하는 함수이다. 이것을 cdecl로 해서 컴파일 한 결과 나온 어셈블리어 결과는 아래와 같다.

  1. int DoSomething( int a, int b )
    {
        int c;
        c = a+b;
        return c;
       /* 여기가 어셈블리어로 변경된 코드
        push ebp
        mov ebp,esp
        push ecx
        mov eax,[ebp+08h]
        add eax,[ebp+0Ch]
        mov [ebp-04h],eax
        mov eax,[ebp-04h]
        mov esp,ebp
        pop ebp
        retn
       */
    }
  2. int main(int argc, char* argv[])
    {
        DoSomething( 1, 2 );
       /* 여기가 어셈블리어로 변경된 코드
        push ebp
        mov ebp,esp
        push 00000002h
        push 00000001h
        call SUB_L00401000
        add esp,00000008h <== 스택을 정리하는 부분
        pop ebp
        retn
       */
    }


 위에서 보면 파라메터를 역순으로 Push 하는것을 알 수 있으며 main 함수에서 "add esp,08" 명령을 통해 스택 정리를 수행함을 볼 수 있다. 여기서 주의해서 봐야 할 부분은 DoSomething 함수에서 어떻게 파라메터에 접근하고 또한 어떻게 함수 내부적으로 사용하는 레지스터를 관리하고 복원하는가 이다.

 아래는 Caller(main)Callee(DoSomething)의 스택의 상태를 표시한 것이다.

 CallingConvension.PNG

<Caller와 Callee의 Stack>

 왜 ESP로 접근하지 않고 EBP를 통해 파라메터에 접근하는 것일까? 위의 그림을 보면 왜 ebp + Index로 접근을 하는 지 알 수 있다. 스택의 Top을 의미하는 ESP 레지스터의 경우 코드 중간 중간에 스택을 사용하면서 계속 변하는 값이다. 그 반면에 파라메터의 위치는 항상 고정적이므로 스택의 Top을 이용해서 파라메터에 접근하려면 문제가 발생한다. 따라서 EBP에 ESP의 값을 처음 설정해 두고 EBP를 이용해서 고정된 Offset으로 접근하는 것이다.

 이와 같이 하면 스택의 Top이 계속 바뀌더라도 EBP가 초기의 스택 Top의 위치를 가지고 있으므로 EBP + 8, EBP+ 12과 같은 값으로 접근 가능하다. 위에서 초기에 Callee의 Stack에서 Push ebp를 하고 난 뒤에 Stack의 Top은 esp1을 가르키고 있다. 이 값을 ebp에 넣게 되므로 ebp를 이용하면 Parameter에 고정된 Index( 8, 12, 16...)으로 접근을 할 수 있는 것이다.

 

2.3 stdcall

 stdcall의 경우에는 cdecl과 거의 차이가 없고 스택을 정리하는 부분만 차이가 있다.

  1. int DoSomething( int a, int b )
    {
        int c;
        c = a+b;
        return c;
       /* 여기가 어셈블리어로 변경된 코드
        push ebp
        mov ebp,esp
        push ecx
        mov eax,[ebp+08h]
        add eax,[ebp+0Ch]
        mov [ebp-04h],eax
        mov eax,[ebp-04h]
        mov esp,ebp
        pop ebp
        retn 08h <== 스택을 정리하는 부분
       */
    }
  2. int main(int argc, char* argv[])
    {
        DoSomething( 1, 2 );
       /* 여기가 어셈블리어로 변경된 코드
        push ebp
        mov ebp,esp
        push 00000002h
        push 00000001h
        call SUB_L00401000
        pop ebp
        retn
       */
    }

 

2.4 프롤로그(prologue) 및 에필로그(epilogue)

 Callee의 스택을 다시 Caller의 스택으로 복원해야 하는데 스택 top을 저장하고 복원하고 하는 작업을 프롤로그(prologue), 에필로그(epilogue)라고 한다. 위에서 본 스택을 복구하는 작업이다.

 만약 우리가 어셈블리어 함수를 만든다면? 그리고 그 함수를 C에서 호출한다면? 아니면 그 반대의 경우라면 어셈블리어 함수를 어떻게 만들어야 할까? 그렇다. 위에서 본 것과 같은 형태 즉 cdecl의 형태를 그대로 따라서 만들면 된다.

 DoSomething() 함수의 프롤로그 에필로그 형태는 아주 일반적인 형태이므로 알아두도록 하자.(꼭 저렇게 구성할 필요는 없지만 일반적이므로 알아두자.)

 

 

3.마치면서...

 자 오늘 우리는 어떻게 어셈블리어 함수를 만들어서 파라메터를 넘겨 받을 것이며, 어셈블리어 함수에서 C 함수를 어떻게 호출해야 하는지, 혹은 그 반대의 경우 어떻게해야 하는지에 대해서 알아보았다. 다음에는 좀 더 깊은 내용에 대해서 알아보자.

 

 

 

이 글은 스프링노트에서 작성되었습니다.

+ Recent posts