C프로그래머가 알아야 할 것들 - 07 어셈블리

C프로그래머가 알아야 할 것들 - 07 어셈블리

어셈블리 언어

C언어는 다른 언어들 보다 어셈블리에 근접한 언어입니다. 인라인 어셈블리가 가능한 데다가, 메모리를 직접 다루는 것이 가능하며, C언어는 어셈블리어와 1:1대응까진 아니지만 대응 되는 언어이기 때문입니다.

C언어로 작성한 코드는 컴파일러를 통해서 어셈블리어와 대응되는 오브젝트 파일로 반드시 변환이 되어야 해당 코드가 실행 될 수 있습니다.

어셈블리 언어는 그 코드가 어떤 일을 할지를 추상적이 아니라, 직접적으로 보여줍니다. 논리상의 오류나, 수행 속도, 수행 과정에 대해 명확히 해준다는 점에서 직관적인 언어입니다.

어셈블리 언어를 사용하면 메모리에 대한 이해도도 높아집니다. 우리가 포인터에 대해 어려워하는 이유도 메모리에 대해 명확히 파악하지 못하고, 메모리를 다루기 때문입니다.

C프로그래머라면 어셈블리 언어를 알고 있는 것과 아닌 것과의 차이가 크기 때문에, 어셈블리 언어에 대해 알아 두는 것이 좋다고 이야기 하는 것입니다.

Debug.exe를 이용한 어셈블리 맛보기

Debug.exe는 파일 이름 그대로, 디버깅을 위한 목적으로 만들어진 프로그램이지만, 간단한 수준의 어셈블리 프로그래밍도 가능한 프로그램입니다.

다음 표는 Debug.exe의 기능들 중 자주 사용되는 기능을 요약한 표 입니다.

명령어 기능
A [주소] Assemble 80x86 명령을 받아 어셈블
D [범위] Dump 메모리에 수록되어 있는 자료를 표시
E 주소 Enter 메모리에 자료를 수록
F 범위자료 Fill 메모리에 자료를 수록
G [=주소1][주소2] Go 프로그램을 실행
Q Quit Debug.exe를 종료
R [레지스터명] Register 레지스터의 값을 표시/변경
U[범위] Unassemble 기계어 코드를 역 어셈블

디버그 실행은 간단합니다. 도스 프롬프트상에서,

debug

라고 입력하면, debug.exe 가 실행됩니다.

debug 파일명

이렇게 디버그 실행 시 파일도 메모리에 올려놓을 수가 있습니다.

실행에 성공하면

-

위와 같은 디버그 프롬프트가 표시되기 시작하면 디버그가 정상적으로 실행 된 것입니다.

이제부터는 디버그로 어셈블리 코드를 작성 해볼 건데, 처음 시작은 화면에 대문자 V를 출력하는 코드로 시작 해보겠습니다.

먼저, 오프셋 주소 100부터 어셈블리 코드를 입력합니다.

A 100

DL 레지스터에 56(대문자 V. 16진수임을 의미하는 H는 Debug에선 표기 하지 않아야 함)를 집어 넣습니다

MOV DL,56

MOV는 이동(Move)명령으로써, MOV A, B라면, B에 있는 값을, A로 복사하라는 뜻이 됩니다.

화면에 한 문자를 출력하라는 명령(숫자 2)을, AH레지스터에 넣어둡시다.

MOV AH,2

다음으로, 도스 시스템 루틴 실행 시킵니다.

INT 21

INT 라는 인터럽트(가로채기)로써, 뒤에 오는 번호의 명령에 해당하는 기능을 수행하러 동작하고 오라는 것을 의미합니다.

실행 시킨 후에는 실행 종료 루틴을 실행합니다.

INT 20

이 코드를 다 입력한 후, 엔터를 치면, 다시 디버그 프롬프트로 돌아 오면, 실행 명령인 G를 입력해주면 입력한 어셈블리 코드가 실행됩니다.

G

실행된 결과는 다음과 같습니다.

V프로그램이 정상적으로 종료 되었습니다.

생각보다 간단하죠? 어셈블리 언어가 잘 사용되지 않는 이유는 하드웨어에 종속적이며, 같은 기능을 구현할 때 작성해야 하는 코드 수가 많아 생산성이 떨어지고, 다른 언어에 비해 가독성이 떨어진다는 점 때문입니다. 하지만 어셈블리 언어 자체는 직관적이라 이해하기 수월하다고 할 수 있습니다. 다음으로 어셈블리 언어 더 잘 이해하기 위해 꼭 알아두어야 할 레지스터에 대해서 알아보죠.

레지스터

레지스터란, CPU의 내부의 기억 장소로, 자료를 바이트 단위 또는 워드 단위로 수락합니다. 어찌 보면, RAM과 비슷하다고도 볼 수 있는데, 레지스터는 메모리와는 다른 몇 가지 기능들을 갖고 있습니다.

가장 먼저 알아볼 레지스터로는 범용 레지스터가 있는데요, 말 그대로 범용적으로 사용되는 레지스터들 입니다.

범용 레지스터

|32Bit|16Bit|16Bit-상위8Bit|16Bit-하위8Bit|기능| |—|—|—|—|—| |EAX|AX|AH|AL|누산기(Accumulator, 중간 결과를 저장해 놓음)레지스터라 불리며, 곱셈이나 나눗셈 연산에 중요하게 사용| |EBX|BX|BH|BL|베이스 레지스터라 불리며 메모리 주소 지정시에 사용됩니다.| |ECX|CX|CH|CL|계수기(Counter)레지스터라 불리며, Loop등의 반복 명령에 사용됩니다.| |EDX|DX|DH|DL|데이터(Data)레지스터라 불리며 곱셈, 나눗셈에서 EAX와 함께 쓰이며 부호 확장 명령 등에 사용됩니다.| |ESI|SI| | |다량의 메모리를 옮기거나 비교할 때 그 소스(Source)의 주소를 가진다| |EDI|DI| | |다량의 메모리를 옮기거나 비교할 때 그 목적지의 주소를 가리킨다.| |ESP|SP| | |스택 포인터로 스택의 최종점을 저장한다. Push, Pop 명령에 의해 늘었다 줄었다 한다.| |EBP|BP| | |ESP를 대신해 스택에 저장된 함수의 파라미터나 지역 변수의 주소를 가리키는 용도로 사용된다|

상태 레지스터

|32Bit|16Bit|기능| |—|—|—| |EIP|IP|EIP는 현재 실행되고 있는 프로그램의 실행코드가 저장된 메모리의 주소를 가리키는 레지스터로 프로그램의 실행이 진행됨에 따라 자동으로 증가하고 프로그램의 실행 순서가 변경되는 제어 문이 실행될 때 자동으로 변경된다. 그래서 직접 접근해서 값을 저장하거나 읽거나 하는 일이 없기 때문에 응용 프로그램에서는 손 댈 일이 없는 레지스터이다| |EFLAGS|FLAGS|비트 단위의 플래그 들을 저장하는 레지스터로 아주 특별한 용도로 사용된다|

세그먼트 레지스터

|16Bit|기능| |—|—| |ES|보조 세그먼트 레지스터다. 두 곳 이상의 데이터 저장영역을 가리켜야 할 때 DS와 함께 사용된다. 하지만 32비트 프로그램에서는 DS와 ES가 같은 영역을 가리키고 있기 때문에 굳이 신경 쓰지 않아도 된다.| |CS|코드 세그먼트를 가리키는 레지스터.| |SS|스택 세그먼트를 가리키는 레지스터.| |DS|데이터 세그먼트를 가리키는 레지스터.| |FS|보조 세그먼트 레지스터. FS, GS는 286 이후에 추가된 것으로 운영체제를 작성하는 게 아니라면 없듯이 여겨도 된다| |GS|FS와 동일|

Debug.exe 에서 레지스터 값을 직접 변경할 때에는 Debug의 R명령을 사용하면 됩니다.

R

AX=0000 BX=0000 CX=0000 DX=0000 SP=FFEE BP=0000 SI=0000 DI = 0000
DS=2096 ES=2096 SS=2096 CS=2096 IP=0100 NV UP EI PL NZ NA PO NC

만약 AX레지스터의 값을 변경하고 싶을 때에는 다음과 같이 입력해주면 된다.

R AX

입력 후에는 다음과 같이 AX 레지스터의 값이 나오고 입력 프롬프트가 뜬다.

AX 0000
:

이 때 입력해주는 값이, AX레지스터에 들어갈 값이 됩니다.

:1234

AX레지스터에 값이 변경됐는지 확인해보죠

R

AX=1234 BX=0000 CX=0000 DX=0000 SP=FFEE BP=0000 SI=0000 DI = 0000
DS=2096 ES=2096 SS=2096 CS=2096 IP=0100 NV UP EI PL NZ NA PO NC

제대로 처리 된 것을 확인 할 수 있었습니다.

어셈블리 언어는 레지스터와 밀접한 연관이 있습니다. 대부분의 명령어 들이 레지스터와 연동되어 처리 되기 때문이죠.

이제 레지스터에 대해서도 알았으니, 간단한 C프로그램을 디스 어셈블리 한 후 그 코드를 분석해보도록 하겠습니다.

(4) 디스 어셈블리 디스 어셈블리에 앞서 C언어로 덧셈 함수를 만들고, 그 함수를 main함수에서 호출 하는 코드를 만들어보겠습니다.

int Sum(int x,int y)
{
    return x + y;
}

int main(int argc, char *argv[])
{
    int x = 10, y = 15;
    int result = Sum(x, y);
    return printf("%d", result);
}

이 코드는 정수형 변수 x와, y에 10과 15으로 각각 초기화 해주고, 그 값을 Sum함수에 넘겨 더한 결과를 반환 받아, printf함수로 출력해주었습니다.

8줄만으로 작성된 이 간단한 C언어 프로그램을 디스 어셈블리 하면 몇줄이나 나올까요? 궁금하지 않으신가요? 이 코드를 디스 어셈블리하고 살펴보겠습니다.

먼저 메인 함수부터 살펴 보겠습니다.

int main(int argc, char *argv[])
{
    00411A80  push        ebp 

    00411A81  mov         ebp,esp

    00411A83  sub         esp,0E4h

    00411A89  push        ebx 

    00411A8A  push        esi 

    00411A8B  push        edi 

    00411A8C  lea         edi,[ebp-0E4h]

    00411A92  mov         ecx,39h

    00411A97  mov         eax,0CCCCCCCCh

    00411A9C  rep stos    dword ptr [edi]

            int x = 10,y = 15;

    00411A9E  mov         dword ptr [x],0Ah

    00411AA5  mov         dword ptr [y],0Fh

            int result = Sum(x, y);

    00411AAC  mov         eax,dword ptr [y]

    00411AAF  push        eax 

    00411AB0  mov         ecx,dword ptr [x]

    00411AB3  push        ecx 

    00411AB4  call        Sum (4114F1h)

    00411AB9  add         esp,8

    00411ABC  mov         dword ptr [result],eax

            return printf("%d", result);

    00411ABF  mov         eax,dword ptr [result]

    00411AC2  push        eax 

    00411AC3  push        offset string "%d" (42401Ch)

    00411AC8  call        @ILT+1170(_printf) (411497h)

    00411ACD  add         esp,8

    }

    00411AD0  pop         edi 

    00411AD1  pop         esi 

    00411AD2  pop         ebx 

    00411AD3  add         esp,0E4h

    00411AD9  cmp         ebp,esp

    00411ADB  call        @ILT+935(__RTC_CheckEsp) (4113ACh)

    00411AE0  mov         esp,ebp

    00411AE2  pop         ebp 

    00411AE3  ret

헉.. 생각보다 길군요. 차근차근 살펴보도록 하죠.

00411A80  push        ebp 

00411A81  mov         ebp,esp

지금까지의 스택의 기준 포인터를 스택에 저장(push)합니다. 이 함수가 종료된 후, 스택의 기준 포인터를 원래 대로 되돌려야 하기 때문에 저장해 놓는 것이죠. 그리고나서 현재의 스택 포인터를 ebp에 저장해두고 있습니다.

00411A83  sub         esp,0E4h

이번엔 실제 스택을 잡고 있습다. 스택은 위에서 아래로 자라므로 sub (뺄셈)이고, 스택을 0E4H로 잡았습니다.

00411A89  push        ebx 

00411A8A  push        esi 

00411A8B  push        edi 

ebx,esi,edi 이 세 개의 레지스터에 담긴 값이 사용되고 난 후 복원되어야 하므로, 스택에 저장해줍니다.

00411A8C  lea         edi,[ebp-0E4h]

ebp-0E4h의 주소(주소에 담긴 값이 아닌, 주소 그 자체)를, edi에 저장하고 있습니다.

00411A92  mov         ecx,39h

ecx레지스터에 39h값을 담습니다.

mov         eax,0CCCCCCCCh

eax레지스터에 0CCCCCCCCh를 넣어주는데, 이 것은 리턴 값이 담기는 eax레지스터의 초기 값을 0CCCCCCCCh로 초기화 해주는 것입니다.

00411A9C  rep stos    dword ptr [edi]

문자열 처리 명령을 cx레지스터의 값만큼 반복 수행시킵니다. 지금의 경우엔, 39h만큼 반복 되겠죠? stos명령과 같이 수행되었기 때문에, eax에 있는 값을 edi로 복사해주고 있습니다. eax에 담긴 값? 0CCCCCCCCh겠죠? edi에 담긴 주소에 있는 값을 초기화 한다는 것을 알 수 있습니다. dword ptr이란게 처음 나왔죠? 아래표를 보시면 차이점을 아실 수 있습니다.

사용법 설명
[값] 포인터 개념. 값을 주소로 인식해서, 그 주소에 담긴 값을 사용.
값을 그대로 사용.
int x = 10, y = 15;

00411A9E  mov         dword ptr [x],0Ah

00411AA5  mov         dword ptr [y],0Fh

변수 x에, 0Ah(10진수 10), 변수 y에 0Fh(10진수 15)를 담고 있습니다. 변수의 초기화 과정이 이뤄진 것입니다.

        int result = Sum(x, y);

00411AAC  mov         eax,dword ptr [y]

00411AAF  push        eax 

00411AB0  mov         ecx,dword ptr [x]

00411AB3  push        ecx 

00411AB4  call        Sum (4114F1h)

그리고, printf함수로 출력할 결과 값을 얻기 위해 Sum함수를 호출 하는데, 변수 y를 eax에, 변수 x를 ecx에 저장하고 있습니다. 그리고 그 넘긴 값들은 스택에 저장하고 있습니다. 이 값들을 Sum함수에서 사용하기 위해서죠. 그 후, Sum함수를 호출(Call)하고 있습니다.

00411AB9  add         esp,8

00411ABC  mov         dword ptr [result],eax

함수가 종료된 후에는, esp에 8을 더해주고, 리턴값이 담겨 있는 eax레지스터에 값을 변수 result에 담아줍니다.

        return printf("%d", result);

00411ABF  mov         eax,dword ptr [result]

00411AC2  push        eax 

00411AC3  push        offset string "%d" (42401Ch)

00411AC8  call        @ILT+1170(_printf) (411497h)

00411ACD  add         esp,8

result값을 출력해 주어야 하기 때문에, 매개변수로 넘길 변수 result의 값을 eax에 넣어주었고, 그 값을 스택에 저장했습니다. %d는 정적 문자열이므로 어딘가에 저장되어 있을 테니, offset string 으로 %d를 찾아서 스택에 저장해주었습니다. (주소:42401Ch) 그리고, printf함수를 호출해주는 과정을 취하고 있습니다. 호출이 끝난 후에는 esp에 8을 더해주었죠.

00411AD0  pop         edi 

00411AD1  pop         esi 

00411AD2  pop         ebx 

메인 함수가 종료되고 스택에 저장했던 edi,esi,ebx레지스터를 복원해주고 있습니다.

00411AD3  add         esp,0E4h

그리고 esp도 스택으로 잡았던 오프셋만큼을 더 해서 복원해 주었습니다

00411AD9  cmp         ebp,esp

ebp와 esp를 비교해서 ebp가 클 경우 SF(부호플래그)를 0으로, ebp가 작을 경우 1로, 같을 경우 ZF(제로 플래그)를 1로 설정 해줍니다.

00411ADB  call        @ILT+935(__RTC_CheckEsp) (4113ACh)

CheckEsp함수를 불러서, Esp가 유효한지 확인하는 과정입니다.

00411AE0  mov         esp,ebp

00411AE2  pop         ebp 

00411AE3  ret

Ebp를 esp로 옮겨서 스택을 되돌리고, ebp를 스택에서 꺼내서 이전으로 복원 시키고 ret (리턴) 하면서 프로그램을 종료 시키고 있습니다.

int Sum(int x,int y)

{

00411A40  push        ebp 

00411A41  mov         ebp,esp

00411A43  sub         esp,0C0h

00411A49  push        ebx 

00411A4A  push        esi 

00411A4B  push        edi 

00411A4C  lea         edi,[ebp-0C0h]

00411A52  mov         ecx,30h

00411A57  mov         eax,0CCCCCCCCh

00411A5C  rep stos    dword ptr [edi]

        return x + y;

00411A5E  mov         eax,dword ptr [x]

00411A61  add         eax,dword ptr [y]

}

00411A64  pop         edi 

00411A65  pop         esi 

00411A66  pop         ebx 

00411A67  mov         esp,ebp

00411A69  pop         ebp 

00411A6A  ret

이번엔 덧셈 함수였던 Sum함수를 살펴보죠. 거의 대부분 main함수와 비슷한 구조를 갖고 있습니다. 틀린 부분만 살펴보도록 하죠.

    return x + y;

00411A5E  mov         eax,dword ptr [x]

00411A61  add         eax,dword ptr [y]

이 부분이 main함수와 다른 부분입니다. x와 y를 더한 결과를 리턴 해야 하므로, 먼저 x를 eax로 복사한 후, eax에 y값을 더해주었습니다. 이 상태에서 리턴 되면, x + y인 25를 eax에 담아 놓았기 때문에, 리턴 되자마자 eax에 담긴 값은 리턴 값을 의미 하게 되는 것이죠.

C언어로는 짧고 간결한 프로그램이, 어셈블리어로는 굉장히 길죠? 이렇기 때문에 어셈블리 언어로 프로그램을 작성하는 대신 C언어와 같은 고급 언어가 나오게 된 것입니다.

이어서, C언어 안에 어셈블리어를 포함시켜 동작하는 인라인 어셈블리에 대해 알아보겠습니다.

인라인 어셈블리

인라인 어셈블리의 기본 골격은 다음과 같습니다.

__asm
{
        //어셈블리 코드
}

인라인 어셈블리로는 대부분의 어셈블리 기능을 사용할 수 있습니다.

지난번 디스 어셈블리 했던, 덧셈 코드를 어셈블리 언어로 작성해볼까요?

int main(int argc, char *argv[])
{
        int result;
        __asm
        {
            mov eax,10
            mov ebx,15
            add eax,ebx
            mov result,eax;
        }

        printf("%d", result);
}

지금까지 착실히 따라와주신 분들은 어렵지 않게 이해하실 거라고 생각 됩니다. eax레지스터에 10을 넣어주고, ebx레지스터에 15를 넣은 후 두 값을 더 해, eax레지스터에 저장한 후, 그 값을 result변수에 담아주면, result에는 결과 값인 25가 들어가있게 되는 것이죠.

이번엔 포인터를 사용하는 코드를 작성해보도록 하죠. int형 변수와, int형 포인터 변수를 하나씩 생성 한 후, 그 두 변수의 사용하는 코드를 작성해보겠습니다.

int main(int argc, char *argv[])
{
    int var;
    int *p;

    __asm

    {

        lea eax,[var]

        mov dword ptr [p],eax

        mov var,10

        mov ebx,dword ptr [p]

        mov ecx,dword ptr [ebx]

        add var,ecx

    }

    printf("%d", var);
}

조금 복잡해졌죠? 코드를 자세히 살펴보도록 하죠.

lea eax,[var]

lea명령어로, var의 주소를 eax에 저장했습니다.

mov dword ptr [p],eax

eax레지스터에 저장된 주소를, p에 저장해주고 있습니다. int형 포인터 p는 변수 var의 주소를 가지고 있게 됩니다. 이 코드를 C언어로 표현하면, 다음과 같습니다.

int *p = &var;

mov var,10

변수 var에 10을 대입해주었습니다.

mov ebx,dword ptr [p]

mov ecx,dword ptr [ebx]

ebx레지스터에 p가 가리키는 주소를 담아 주었습니다. (변수 var의 주소) 그리고, ebx레지스터의 담긴 주소 안의 값(var의 값인 10)을 ecx레지스터에 대입해주고 있습니다.

add var,ecx

ecx레지스터에 담긴 값은 10이고, var의 값도 10이니, var에는 20이 담기게 됩니다.

이 인라인 어셈블리 코드는, 아래 C코드와 정확히 동일하게 동작 합니다.

int main(int argc, char *argv[])
{
    int var;

    int *p = &var;

    var = 10;

    var = *p + var;

    printf("%d", var);
}

인라인 어셈블리로 프로그램을 작성해야 할 필요성은, 네이티브한 어셈블리 사용하는 개발 환경이 줄어 듬에 따라 그 중요도와 함께 감소했지만, 속도 최적화가 필요한 일부 상황에서는 선택되기도 하죠.

무엇보다 어셈블리만으로 프로그램 전체를 작성하는 것은 무리이기에, C언어 기반으로 프로그램을 작성한 후 일부 코드만 어셈블리를 채용할 수 있다는 점에서 어셈블리어 공부에도 큰 도움이 됩니다.

  • 참고 서적
    • 알기 쉬운 어셈블리. 신동준 저.
    • 해킹 파괴의 광학. 김성우 저.

Comments

comments powered by Disqus