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언어 기반으로 프로그램을 작성한 후 일부 코드만 어셈블리를 채용할 수 있다는 점에서 어셈블리어 공부에도 큰 도움이 됩니다.

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

C프로그래머가 알아야 할 것들 - 06 자료 구조

C프로그래머가 알아야 할 것들 - 06 자료 구조

자료 구조란?

프로그램이 어떤 일을 할 때에는, 그 일을 하기 위해 필요한 데이터가 존재 합니다. 예를 들어, 비디오 대여점 관리 프로그램을 작성한다고 생각해 봅시다.

우선 비디오 정보들이 필요합니다. 비디오의 정보에는, 비디오에 담긴 미디어의 작품 명, 감독, 출연진 (혹은 성우) 등과, 비디오의 위치, 대여료, 대여기일 등의 정보를 포함합니다. 그리고, 회원 정보도 필요합니다. 회원 정보에는, 회원의 이름, 나이, 주소, 전화번호, 대여한 비디오 정보가 포함 될 것입니다. 이 정보들이 있어야만, 비디오 대여 관리를 프로그램으로 처리 할 수 있습니다.

이런 정보들을 어떻게 저장하고 관리 할까요? 그 저장하는 저장 데이터들이 바로 자료구조 (Data Structure) 입니다. 보통 자료구조와 함께 알고리즘 (Algorithm: 프로그래밍에서는 문제 해결을 위한 방법을 의미함)을 함께 다루는데요, 그 이유는 자료 구조에 따라서 효율적인 알고리즘이 각기 다르기 때문입니다.

자료구조가 엉터리로 짜여 있으면 쓸모 없는 데이터나, 중복된 데이터를 가지게 되고, 느린 데이터 처리, 오랜 처리 시간 등의 문제점을 발생시킵니다. 그렇게 되지 않기 위해, 이번 챕터에서는 좋은 자료구조를 만들기 위한 여러 가지에 대해 알아보도록 하겠습니다.

자료구조의 종류

가장 흔히 접할 수 있는 자료구조 중 하나는 배열입니다.

배열(Array)은 동일한 형태를 가지고, 메모리의 연속된 공간에 위치하는 데이터 집합을 의미하죠.

배열은 미리 크기만큼 선언 되어 있어야 하고, 중간에 데이터를 삭제 할 수 없으며, 데이터의 순서를 바꾸는 것도 힘듭니다. 아래 예제를 보시길 바랍니다.

int main(int argc, char *argv[])
{
    int number[10]={1,2,3,4,5,6,7,8,9,10}; // 배열 number의 초기 값으로 1~10을 차례대로 대입

    number[5] = 0; //배열의 중간 위치에 있는 6번째 원소 (0부터 시작하기 때문에, [5]는 6번째 원소를 가리킵니다)의 값에 0을 대입

    int temp = number[5];  //0이라는 수가, 삭제된 데이터라는 것을 의미하므로, number[5]에 저장된 값 0을 임시 변수 temp에 대입

    for(int i = 6; i < 10; i++) //데이터가 삭제된 위치인 6번부터 하나씩 앞으로 당긴다
    {
        number[i-1] = number[i];
    }

    number[9] = temp; // number[5]에 저장되어 있던 값을 저장해놓은 temp를 배열의 마지막 위치에 대입
}

위 코드는 배열 내에 원소를 삭제하는 코드 입니다.

배열에서 중간 위치의 데이터를 삭제하고, 그 공간을 채우는 일은 번거롭습니다. 여러 번의 연산이 필요하죠. 게다가 배열의 크기 자체는 그대로입니다. 이렇게, 크기의 변화가 불가능한 자료구조를, 정적 자료구조(Static Data Structure)라고 합니다.

또 다른 자료 구조로는, 리스트(Linked List : 링크드 리스트를 의미하고, 줄여서 리스트라고도 함)도 있습니다. 이 리스트도 마찬가지로 데이터 집합입니다만, 연속된 메모리 공간에 위치하지 않고, 자신의 앞에 위치한 데이터와 자신의 다음 데이터를 가리키는 포인터를 갖고 있습니다.

리스트는 자신의 다음에 위치한 데이터를 언제든지 바꿀 수 있습니다. 다음 데이터에 대한 정보를 포인터로 갖고 있기 때문이죠.

struct Node{
    Node *Next;
};

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

    Node1 = (Node*)malloc(sizeof(Node));

    Node *Node2;

    Node2 = (Node*)malloc(sizeof(Node));

    Node *Node3;

    Node3 = (Node*)malloc(sizeof(Node));

    Node1->Next = Node2; //Node1의 다음 데이터는 Node2

    Node2->Next = Node3; //Node2의 다음 데이터는 Node3

    Node3->Next = NULL; //Node3가 마지막이다 (NULL)

    free(Node2); //Node2가 삭제 되었다

    Node1->Next = Node3; //이제 Node1의 다음 데이터는 Node3
}

위 예제를 보시면, Node 구조체는 자신의 다음에 올 데이터를 가리키는 포인터인 Next를 가지고 있습니다. 중간에 위치한 데이터였던 Node2가 삭제되었지만, Node2의 공백은 Node1의 다음 데이터로 Node3를 가리키게 하는 것만으로 쉽게 해결 됐습니다. 이처럼, 크기의 변화가 가능한 자료구조를 동적 자료구조(Dynamic Data Structure)라고 하죠.

정적 자료구조로는, 앞서 설명한 배열과, 레코드(Record)가 있습니다.

레코드(Record)는, 기본 단위로써 다뤄지는 데이터 묶음. C언어에서는 구조체나 공용체를 떠올리시면 됩니다.

동적 자료구조로는, 리스트(List), 스택(Stack), 큐(Queue), 덱(Deque)이 대표적입니다.

스택(Stack)은 후입선출(LIFO : Last In First Out)의 자료 구조로 서랍에 옷을 넣었다가 꺼낼 때를 떠올리시면 쉽습니다. 한번에 한 개씩만 물건을 꺼내야 한다면, 앞부분에 있는 나중에 들어간 옷들을 꺼내야만 먼저 들어간 옷을 꺼낼 수 있죠.

스택은 위에서 이야기했듯이 역 순서의 특성을 가집니다. 먼저 들어간 데이터가 나중에 처리 되고, 계산기의 내부 처리나, 연산장치에서 호출된 곳으로 되돌아가기 위해서 사용되곤 합니다.

큐(Queue)는 선입선출(FIFO : First In First Out)의 자료구조로, 버스를 기다리는 줄을 생각하시면 됩니다. 오랫동안 기다린 사람이 먼저 버스를 타게 됩니다. 새치기하는 사람이 없거나, 버스가 원래 정류장이 아닌 이상한 곳에서 정차해서 나중에 오는 사람을 태우는 특이한 상황이 아니라면 말이죠.

버스를 기다릴 때와 마찬가지로, 순서대로 데이터가 처리되어야 할 때 쓰입니다. 컴퓨터는 한번에 한가지 일 밖에 할 수 없기 때문에, 여러 개의 처리를 한꺼번에 요청 받는다면 한 개를 제외한 나머지 데이터는 나중에 처리해야 하는데, 그 순서를 정할 때 쓰이곤 하죠.

덱(Deque)은 양방향에서 입/출력 가능한 자료구조로, 스택이나 큐가 사용될 수 있는 모든 상황에서 사용할 수 있습니다. 덱은 양방향 입/출력이 가능하지만, 입/출력에 제한을 건 특수한 덱 들도 있습니다. 한 방향에서만 입력이 가능한 덱을 스크롤(Scroll)이라고 부르고, 한 방향에서만 출력이 가능한 덱을 셸프(Shelf)라고 부릅니다.

지금까지 자료구조에 대해서 간략하게 알아보았고, 이제부터는 자료구조들이 어떻게 사용되는지, 어떤 장단점을 갖고 있는지 살펴보도록 하겠습니다.

검색 알고리즘

검색(Search)이란 말 그대로, 무언가를 찾는 것을 말합니다. 무엇을 찾을까요? 지나간 첫사랑? 내 돈 떼먹고 도망간 친구? 초등학교 동창? 보통 오랜만에 연락처를 잊어버린 친구를 다시 찾을 때 어떤 방법을 사용하시나요?

1. 다른 친구에게 물어본다.
2. 다 모임, 아이러브스쿨, 싸이 월드 등에서 찾아본다.
3. 졸업 앨범의 연락처를 뒤져본다.
4. 전국 전화번호부를 펴놓고 하나씩 전화해서 물어본다.

친구를 찾기 위한 여러 가지 방법들을 모두 검색이라 부를 수 있습니다. (무식한 4번 방법까지도 말이죠)

졸업 앨범의 연락처에는 반 단위로 나뉘어져 있으며, 가나다 순으로 정렬이 되어있어 찾기 쉽습니다. 싸이 월드의 경우, 년도와 성별, 이름으로 찾습니다. 임의대로 오라클, SQL같은 데이터베이스를 쓰지 않는다고 가정하겠습니다. 각 년도 마다 남녀로 데이터를 나눠두고, 가나다 순 정렬을 해놓으면, 수천만 명의 데이터를 처음부터 하나씩 검색하는 것에 비해 매우 효율적으로 데이터를 찾을 수 있습니다.

여러 가지 검색 방법 중 먼저 비효율적인 검색으로 알려져 있음에도 널리 쓰이는 검색인, 순차검색(Sequence Search)에 대해 알아보겠습니다. 순차검색은 처음 데이터부터 차례대로 하나씩 찾는 검색 방법입니다.

search_01

찾았습니다. 4번째 데이터가 찾고자 하는 데이터인 24였군요. 이 경우는, 데이터가 앞쪽에 위치해서 4번 만에 찾을 수 있었지만, 찾고자 하는 데이터가 59였거나, 존재하지 않는다면 데이터를 모두 검색하고 나서야 결과를 알 수 있기 때문에 비효율적입니다.

아래 코드는, 배열내의 원소를 정해진 크기만큼 순차검색하며, 키 값이 배열 내에 존재하는지 검사합니다.

int SequenceSearch(int *ar,unsigned int size, int key)
{
    unsigned int i;

    for(i = 0; i < size; i++)
    {
        if(ar[i]== key){
            return i;
        }

        return -1;
    }
}



int main(int argc, char *argv[])
{
    int ar[] = {25,11,43,71,38,33,59,21,56,22,45,75,64,59,93,112,159,124,163,9};

    unsigned int size = sizeof(ar) / sizeof(int);

    int key;
    scanf("%d",&key);


    int result = SequenceSearch(ar,size,key);
    if(result == -1)
        printf("찾으시는 키 값을 배열 내에서 찾을 수 없었습니다");
    else
        printf("배열 내에서 %d번째에 존재하는 값을 찾았습니다. %d", result);
}

순차 검색은 사실 단점이 많은 검색 방법이지만 정렬이 되지 않은 데이터에도 사용할 수 있다는 장점도 있습니다. 그리고 사용하기도 편해서 많이 쓰이는 검색 방법이죠.

자주 쓰이는 다른 검색으로는, 이진검색(Binary Search)도 있습니다. 이진 검색은 대소 비교를 통해 데이터를 찾는 범위를 반씩 줄여가며 찾는 방식을 말합니다.

주의 할 점은, 이진 검색을 하기 위해서 검색 전에 반드시 데이터가 오름차순 정렬(Sort)되어 있어야만, 제대로 된 결과를 얻을 수 있다는 점입니다. (사실, 이진 검색만이 아니라 순차검색을 제외한 대부분의 검색이 정렬을 필요로 합니다)

이진 검색 시에는 유효한 범위의 최소값 + 최대값 / 2. 즉, 중간 값에서부터 대소 비교를 하며 범위를 좁혀 원하는 값을 찾습니다.

search_02

아래 코드는, 이진 검색을 통해 입력 받은 수를 찾는 코드입니다.

int BinarySearch(int *ar,unsigned int size, int key)
{
    unsigned int half_value;
    unsigned int lower_value = 0;
    unsigned int upper_value = size -1;

    while(1)
    {
        half_value = (lower_value + upper_value) / 2;

        if(ar[half_value] == key)
            return half_value;
        else if(ar[half_value] < key)
            lower_value = half_value;
        else
            upper_value = half_value;

        if(lower_value == upper_value-1)
            return -1;
    }
}


int main(int argc, char *argv[])
{
    int ar[] = {5,8,15,28,32,45,48,52,69,71,85,94,103,112,118,124,125,138,143,157};

    unsigned int size = sizeof(ar) / sizeof(int);

    int key;
    scanf("%d",&key);

    int result = BinarySearch(ar,size,key);
    if(result == -1)
        printf("찾으시는 키 값을 배열 내에서 찾을 수 없었습니다");
    else
        printf("배열 내에서 %d번째에 존재하는 값을 찾았습니다. %d", result);
}

순차 검색이 가장 마지막에 데이터를 찾게 될 수도 있는데 비해, 20개의 데이터가 있을 때 최대 5번 검색만으로 데이터를 찾을 수 있을 정도로 매우 효율적입니다.

순차검색보다 이진검색이 훨씬 빠르니까, 이진검색만 쓰면 된다고 생각하실 분도 있으실 겁니다만, 이진 검색의 경우 정렬되어 있어하는 것만이 아니라, 배열처럼 임의 접근 가능한 자료구조에서만 사용할 수 있다는 점도 생각하셔야 합니다.

검색은 데이터를 단순히 찾는 것에서 의미를 두는 것이 아니라, 빨리 찾는 것에 의미가 있습니다. 이진 검색은 반드시 정렬되어야 하기 때문에 데이터를 정렬하는 시간이, 검색 시간에 포함되어 있는 것과 마찬가지라서, 검색 방법만이 아니라, 정렬 방법도 매우 중요합니다. 그래서 검색을 위한 선행 조건인 정렬에 대해 알아보도록 하겠습니다.

정렬 알고리즘

정렬에는 작은 것부터 큰 것으로 정렬하는 오름차순(Ascending)과, 큰 것부터 작은 순서로 정렬하는 내림차순(Descending)이 있습니다. 정렬이라는 말 자체가, 특정 기준에 맞춰 데이터를 나열한다는 의미인데, 그 기준에 따라 오름 차순, 내림 차순 정렬을 하는 것입니다. 레코드들을 기준 값(Key)끼리 대소비교를 통해 순서를 바꿔주는 과정을 정렬이라고 부르죠.

간단한 정렬 방식 중 하나인, 버블 정렬(Bubble Sort)을 먼저 알아보겠습니다.

버블 정렬은, 현재 데이터가 바로 다음 데이터가 작은지 (오름차순일 때. 내림차순의 경우 큰지를 비교해야 함) 비교해서 위치 바꿈을 반복해가며 정렬하는 방법을 말합니다.

sort_01

이렇게 정렬하고 나면, 마지막 원소인 122는 정렬되어있는 것이기 때문에, 검사할 필요가 없어집니다. 그러므로, 다음 정렬 시도 시에는 59와, 122는 비교하지 않아도 됩니다.

sort_02

쉽게 사용할 수 있는 또 다른 정렬로는 선택 정렬(Selection Sort)이 있습니다.

선택 정렬이란, 자신의 다음에 위치한 원소들 중, 최소값 (오름차순의 경우. 내림차순일 때는 최대값)을 찾아서 자신과 위치 바꿈을 반복하면 됩니다.

먼저, 제일 처음 위치한 원소인, 22보다 작은 값을 찾아보겠습니다.

sort_03

sort_04

이제 정렬대상에서 제외된 7과, 8이후에 위치한 원소인 15보다 작은 값을 찾아보겠습니다.

sort_05

이번에는 또 다른 정렬 방법인 삽입 정렬 (Insertion Sort)에 대해서 알아보겠습니다.

삽입 정렬이란, 정렬할 키 값을 임의의 장소에 저장해 두었다가, 왼쪽 데이터와 차례대로 비교해가며 자신보다 큰 데이터는 뒤로 보내는 방식으로 정렬합니다. 삽입 정렬은 현재 데이터의 왼쪽 데이터와 비교하기 때문에, 첫 번째 데이터가 아닌, 두 번째 데이터부터 정렬을 시작합니다.

sort_06

sort_07

sort_08

삽입 정렬도 버블 정렬, 선택 정렬과 마찬가지로, 두 번 루프를 돌지만, 새로운 데이터가 들어왔을 때, 루프 한번만으로도 정렬 완료 할 수 있는 등 여러 가지 장점이 있죠. 실제로, 삽입 정렬의 단점을 보완한 여러 정렬 (쉘 정렬 등) 방법이 있고, 사용되고 있죠.

일반적으로 많이 사용되는 범용적인 정렬 중에 가장 빠른 정렬로는, 퀵 정렬 (Quick Sort)이 있습니다.

퀵 정렬은 전체 데이터를 기준 값을 경계로, 그 값보다 큰 값, 작은 값들의 두 개의 블록으로 나누고, 그 분할된 블록들을 같은 방법으로 반복해서 정렬하는 방법을 사용합니다.

퀵 정렬을 하기 위해서, 배열의 첫 번째 값인 22를 기준 값으로 잡고 정렬을 시도해보겠습니다. 먼저, 왼쪽 끝에서부터 22보다 작은 값을 찾고, 오른쪽 끝에서부터 22보다 큰 값들을 찾고, 기준 값보다 크고 작은 각각의 값을 찾았을 때, 서로 값을 바꿔줍니다. 찾는 위치가 겹쳐지면 정렬을 종료하고 다음 원소를 정렬하면 됩니다.

sort_09

sort_10

sort_11

퀵 정렬은 기본적으로 데이터를 빠르게 정렬할 수 있지만, 정렬해야 할 데이터가 적은 경우에는 버블 정렬이나, 선택 정렬 등 루프를 두 번 순회하는 알고리즘과 큰 차이를 보여주진 못합니다.

어떤 상황에서나 퀵 정렬이 가장 빠르게 동작하는 알고리즘은 절대 아니며, 가장 좋은 정렬 알고리즘은 더더욱 아닙니다. 퀵 정렬은 기본적으로 다른 정렬보다 뛰어난 성능을 보여주지만, 역순으로 정렬되어있는 경우 (예를 들면, 오름차순으로 정렬해야 할 때 현재 데이터가 9, 8, 7, 6, 5.. 인 경우)에는 그다지 성능이 좋지 않습니다.

상황에 따라서는, 좋지 않은 효율을 보여주는 버블 정렬이 좋은 선택일 때도 많습니다. 200개 미만의 데이터를 정렬해야 하는 상황이고, 그 코드가 매우 적게 호출 된다면 굳이 퀵 정렬을 사용할 필요는 없습니다. (물론, 조금이라도 더 빠르게 하고 싶으신 마음은 이해합니다만, 대부분의 상황에서 그 정도 차이는 사실 중요하지 않습니다.)

여기서 설명한 알고리즘들은 일반적으로 널리 알려졌고, 많이 사용되는 알고리즘들일 뿐입니다. 최적의 알고리즘은 여러 가지 주변 상황을 모두 염두에 두고 생각해야만 찾을 수 있는 것이기 때문에, 알고리즘의 장/단점을 잘 이해하고, 상황에 맞게 응용하도록 노력 해야 할 것입니다.

C프로그래머가 알아야 할 것들 - 05 메모리와 포인터

C프로그래머가 알아야 할 것들 - 05 메모리와 포인터

메모리를 알자

우리가 계산을 할 때에 일반적으로 데이터와 연산자가 필요합니다.

예를 들어, 1 + 2 라는 식을 계산 하기 위해선, 1과 2라는 데이터가 필요하고, + 라는 연산자가 필요하죠.

우리가 노트에 계산을 할 때에는 계산 결과를 노트에 표기 합니다.

계산 결과를 기록해 두는 이유는 그 계산 결과를 가지고 다른 연산을 해야 하거나, 그 결과 자체가 의미를 갖기 때문입니다.

컴퓨터에서 계산된 결과를 위해서 어떻게 해야 할까요?

바로 변수에 저장하면 됩니다.

int a = 1 + 2;

이렇게 하면, 1 + 2 의 결과가 변수 a에 저장 됩니다.

아쉽게도(?) 변수 a도 결국 어딘가에 저장이 되어야 합니다. 대부분의 C언어 문법 서적들에선, 변수가 데이터를 저장하는 곳이라고 배웁니다. 하지만, 좀 더 자세히 파고들어서 설명하자면 변수의 이름 a는 데이터가 저장된 위치(주소)의 이름이지, 데이터를 저장하는 곳이 아닙니다.

데이터가 실제로 저장 되는 곳은 바로 메모리(Memory)입니다.

메모리에는 변수, 구조체 등의 데이터만 기록되는 것아 아니고, 우리가 사용하는 명령어도 저장됩니다.

int a = 1 + 2;

int b = a + 3;

위 코드를, 디스 어셈블 하면 다음과 같은 코드를 얻습니다.

int a = 1 + 2;
0041137E   mov     dword ptr[a],3

int b = a + 3;
00411385   mov     eax, dword ptr[a]
00411388   add      eax, 3
0041138B   mov     dword ptr[b], eax

디스 어셈블 된 코드를 보시면, 1 + 2된 결과인 3을 a에 옮기고 (mov), eax(누산기 레지스터)에 a의 값인 3을 대입한 후, eax 에 3 을 더한 후 (add), 그 값을 다시 b에 옮기는 (mov) 과정을 보여주고 있습니다.

지금 보았던 연산 과정이 모두 메모리에 담겨 있는 명령어를 통해서 이루어 집니다.

메모리에는 데이터뿐만 아니라 명령어들도 담겨 있습니다. 우리가 프로그램을 실행 시키면, 해당 프로그램의 코드가 메모리에 담기고, 코드의 흐름에 따라 여러 코드가

이제 메모리에 대해 감이 오시나요?

변수와 포인터의 차이점

변수는 데이터가 저장 된 메모리 위치(주소)의 다른 이름이고, 포인터는 메모리(=메모리 주소)를 가리키는 변수입니다.

포인터는 데이터가 아닌 메모리 주소를 가지고 있어서, 그 메모리의 주소에 있는 데이터를 제어 할 수 있죠. 아래 표는, 변수와 포인터의 차이점을 정리한 것입니다.

변수의 포인터의 차이점

|분류|변수|포인터| |—|—|—| |가지고 있는 값|데이터|메모리 주소| |사이즈|데이터 형에 따라 다름|4Byte (32비트 운영체제에서)|

포인터는 메모리를 가리킬 때 데이터가 있는 곳만을 가리켜야 합니다. 그래서, 대부분 포인터를 NULL로 초기화 (NULL포인터라 부릅니다.) 한 후, 사용할 때 포인터의 NULL 검사를 통해서, 데이터가 담긴 포인터인지, 아닌지를 검사하고 사용합니다.

변수를 사용할 때, 0으로 초기화 하는 것과 비슷한 이유지만, 포인터에서 잘못된 데이터를 사용할 때의 문제가 더 크기 때문에, 변수보다 조금 더 신중하게 사용하셔야 합니다.

포인터를 써보자

포인터에 대해 알아보았으니 이제 포인터를 사용해봅시다. 포인터 선언은 다음과 같이 해주면 됩니다.

int *ptr; //int 형 포인터 pointer 선언

포인터는 메모리 주소를 가지는 변수이기 때문에, 포인터 변수에는 주소 값을 대입해 주어야 합니다.

int no = 10;

int *ptr = &no; //포인터를 선언 하면서 주소를 대입할 때

포인터를 선언한 후에, 변수의 주소를 대입하는 코드입니다.

int no = 10;
int *pointer; //포인터를 선언만 함

pointer = &no; //포인터를 미리 선언 한 후에 주소를 대입할 때

포인터도 데이터 형(int, char, float, 구조체형 등)을 가지고 있으며, 그 형식에 맞는 데이터만을 가리킬 수 있죠.

포인터에 데이터 형이 있는 이유는 메모리에는 명령어와 데이터가 함께 담기고, 데이터도 크기에 맞춰서 (데이터 형의 크기만큼) 배치 되어 있는데, 실제 데이터가 존재하는 메모리를 그 크기만큼 가리키고 사용해야만 하기 때문입니다. (캐스팅 연산자를 이용하여 포인터 형 강제 변환이 그래서 위험합니다)

예외로 void형 포인터가 있는데, void형 포인터는 데이터 형에 관계 없이 데이터의 시작 주소를 저장하기 위해서 사용됩니다.

포인터에 주소를 대입해 주고 나면, 이제부터 포인터가 가리키는 주소의 데이터를 제어 할 수 있습니다. 아래의 표는 포인터의 표현 방식을 보여주는데요, 포인터 자체의 주소는 별로 쓰이지 않는 편이지만, 나머지 두 표현 방식은 빈번하게 쓰이기 때문에, 반드시 이해를 해두세요.

포인터의 세가지 표현 방식

|분류|의미| |—|—| |*ptr|포인터가 가리키고 있는 주소의 값| |ptr|포인터가 가리키는 주소| |002|포인터 자체의 주소|

포인터를 쓰는 이유

포인터의 사용법에 대해 알아보았으니, 이번에는 포인터를 왜 사용하는지 알아보겠습니다.

개인 신상정보 (이름, 생년월일, 성별, 전화번호, 주소, 취미 등)를 담고 있는 데이터가 있습니다. 그런데, 친구들의 주소록을 구성해야 해서, 신상 정보 중에, 이름, 전화번호, 주소가 필요합니다.

주소록에서 필요로 하는 이름, 전화번호, 주소 모두 이미 신상 정보로써 존재하는 데이터입니다. 이 데이터를 복사해서 주소록과, 신상 정보의 데이터를 각각 따로 가져 보겠습니다. (변수를 생성하여 데이터 복사) 데이터를 각각 따로 가지고 관리 할 때엔 이사를 가거나, 전화번호가 바뀌어서 정보를 수정할 때 두 곳의 정보를 모두 갱신해 주어야 합니다. 만약 실수로 한 곳의 정보만 갱신해주고, 다른 한곳의 정보는 그대로 놔둔다면, 두 정보는 일치해야만 하는 정보임에도 불구하고, 서로 다른 정보를 가질 수도 있다는 문제가 생기는 것이죠.

이번에는 데이터를 복사하지 않고, 신상 정보에 있는 데이터를 가져다 써 봅시다. (해당 데이터가 있는 주소를 가리키는 포인터를 사용) 신상 정보나, 주소록 어디에서 수정을 하던, 바뀐 전화번호나 주소는, 동일하게 적용되므로, 데이터에 대한 신빙성을 높여주며, 동일한 데이터를 중복해서 갖고 있지 않기 때문에 메모리도 절약할 수 있습니다.

포인터는 이미 존재하는 데이터를 가리키기 때문에, 데이터가 필요 할 때, 가리키고 있는 주소에서 데이터를 읽어옴으로써, 데이터의 중복을 막을 수 있는 것이죠.

함수에 값을 받을 때도, 포인터로 매개변수를 전달받으면, 주소가 전달 (call by reference) 되기 때문에, 값의 전달(call by value)할 때 변수가 복사되면서, 이뤄지는 부하가 없습니다.

매개변수로 주소를 전달하게 되면, 그 위치에 있는 데이터를 직접 사용하는 것과 같기 때문에 값이 변할 수 있는데, 값을 변하지 않게 하려면 포인터 상수를 매개변수를 받으면 됩니다.

아래 코드는 성립하며, 함수 호출 후에, src의 값이 src + dest로 변합니다.

void add(int *src, int *dest)
{
    *src = *src + *dest;
}

아래 코드는 컴파일 되지 않습니다. 상수인 src에 값을 대입할 수 없기 때문입니다.

void add(const int *src, const int *dest)
{
    //*src = *src + *dest; 불가능함.
}

포인터를 쓰는 또 다른 이유는 동적 메모리 할당을 위해서 입니다. 변수나, 배열을 사용하기 위해 메모리 할당을 받는 크기가 정해지는 시점은, 컴파일시기 입니다. 프로그램 실행 시, 변수의 경우 해당 데이터 형의 크기만큼 할당 받고, 배열의 경우는 (배열의 크기 * 데이터 형의 크기)만큼 메모리 할당을 받습니다. 할당 받은 배열의 크기를 벗어나 데이터를 사용하면 오류가 발생하기 때문에, 평균적으로 사용될 크기가 아닌, 만약을 대비하여 충분히 큰 크기를 할당해야 하기 때문에, 메모리 낭비가 될 때가 많게 되죠. 그리고 프로그램이 자동으로 메모리를 할당하였기 때문에, 원하는 시기에 메모리를 해제하는 것도 불가능합니다. 이 것이 정적 메모리 할당 (Static Memory Allocate)입니다.

정적 메모리 할당의 단점을 해결하기 위해, 프로그램 실행 도중 필요한 크기만큼 메모리를 할당 받을 수 있는 동적 메모리 할당 (Dynamic Memory Allocate)이 있습니다.

아래 코드는 a*b (입력 받은 두 수의 곱) 만큼 메모리를 할당해서 char형 변수 str에 메모리 위치를 저장하는 코드입니다. 입력 받는 수는 컴파일 시점에 알 수 없고, 프로그램 실행 도중 알 수 있기 때문에, 동적 메모리 할당이라 부르는 것이죠.

int a,b;
char* str;
scanf(“%d%d”,&a,&b);
str = (char*)malloc(a*b);
if(str != NULL)
printf(“동적 메모리 할당 성공”);
else
printf(“동적 메모리 할당 실패”);
free(str);

메모리를 할당해 주는 함수인 malloc은, 메모리 할당 실패 시 NULL을 리턴 해주기 때문에, NULL인지 여부를 검사해서, 메모리 할당에 성공했는지 알아내야 합니다.

메모리 할당에 성공하면, 힙(heap)에 할당된 메모리의 시작 주소를 반환하게 되고, 그 주소를 포인터에 저장한 후, 할당 받은 메모리를 사용할 수 있습니다. 메모리 영역의 데이터를 사용한 후, 더 이상 사용하지 않게 되었을 때는 free함수를 써서 할당 해제 시켜주면 됩니다.

유의할 점은, 이 힙에 할당된 메모리는 전역적으로 접근이 가능하다는 것입니다. 할당된 힙의 주소를 안다면, 어디든지 접근이 가능하기에 사용시 주의를 기울여야 합니다.

  • 힙 (Heap) : 프로그램이 사용할 수 있는 메모리 영역으로써, 임시로 사용되는 값들이나, 지금과 같이 동적으로 할당한 데이터가 존재할 수 있는 데이터 영역.

DOS시절의 메모리와 포인터

예전 도스 시절에는, 세그먼트와 오프셋이라는 개념으로 메모리를 제어 했습니다. 세그먼트는 주 기억장치를 나눈 영역을 의미합니다. 세그먼트 주소는, 그 나눈 영역을 가리키는 주소를 의미하죠. 오프셋은 세그먼트 영역 내의 세부 주소를 의미합니다.

AF00 : 0002 (세그먼트 주소 : 오프셋 주소) = AF00 0002 (실제 주소)
* 32비트 메모리 환경에서의 메모리 주소

도시 시절에는 16비트로는 65535byte (64Kb)만큼의 메모리 영역밖에는 표현할 수 없었기에, 좀 더 큰 메모리 영역을 사용하기 위해서, 주소를 표현할 때 20비트(2^20. 1,048,576Byte = 1Mb)로 표현하게 됐죠. 문제는 20비트의 주소를 16비트로 표현하는 것이었습니다. 그래서, 세그먼트와 오프셋의 값을 계산해서 20비트의 메모리 주소를 표현했습니다.

AF00 (세그먼트 주소)
+ 0002 (오프셋 주소)
------------------
AF002 (실제 주소)


AF00 : 0002 (세그먼트 주소 : 오프셋 주소) = AF002 (실제 주소)
*16비트 메모리 환경에서 20비트 메모리 주소를 표현

그 당시 프로그래밍할 때 C언어에서 사용하던 포인터는 두 종류가 있었습니다. 하나는 near포인터로, 16비트 포인터(표현 가능 범위: 0x0000)입니다. 이 포인터에는 오프셋 주소만을 저장할 수 있기 때문에, 현재 세그먼트 영역에 있는 데이터만을 가리키는 포인터입니다.

int near *ptr; //near포인터 선언

다른 하나는 far포인터로 32비트 포인터(표현 가능 범위: 0x0000 0000)이며, 세그먼트 16비트, 오프셋 16비트씩 값을 가지고, 이를 통해 20비트로 이뤄진 메모리 영역 전체를 가리킬 수 있는 포인터입니다.

int far *ptr; //far포인터 선언

지금은 32비트(2^32, 4,294,967,296 = 4Gb) 메모리 영역을 사용하게 되면서, near포인터, far포인터가 의미 없어졌지만, 아직도 여기저기 그 흔적이 남아있는 만큼, 알아두는 것도 나쁘지 않겠죠?

배열과 포인터

배열이 같은 데이터 형을 가진 데이터 집합이라는 사실은 다들 아실 겁니다.

배열은 같은 데이터 형을 가진 데이터를 한번에 생성 해준다는 것 외에, 메모리 관점에서의 장점도 있습니다.

// int 형 크기 10의 배열 i
int i[10] = {1,2,3,4,5,6,7,8,9,10};


// 배열 i 의 주소
&i 0x0012FF3C


// 배열 i 에 담겨 있는 값
0x0012FF3C 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 05 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00 09 00 00 00 0a 00 00 00

보시다시피, 배열로 잡은 데이터는, 메모리상에 연속되어 데이터가 위치하고 있습니다. 메모리는 선형 구조 (linear)이기 때문에, 가까운 데이터에 접근 하는 것이 더 빠르게 동작합니다.

배열도 선형 구조이기 때문에, 빠르게 동작하는 효율적인 자료 구조이며, C언어는 문자열도 char형 배열로 처리합니다.

배열의 이름이 의미하는 것은 배열의 시작 주소를 가리키는 포인터 상수입니다.

즉, 배열의 이름을 포인터처럼 다룰 수 있다는 이야기입니다.

int main(int argc, char *argv[])
{
    int number[10] = {1,2,3,4,5,6,7,8,9,10}, i;
    int *pNumber = number;

    for(i = 0; i < 10; i++)
    {
        printf("number 배열을 첨자로 출력. %d번째 수는 %d\n", i, number[i]);

        printf("number 배열의 주소로 찾아가 출력. %d번째 수는 %d\n", i, (*number) + i);

        //printf("number 배열을 증가 연산자로 가리키는 위치를 증가 시키며 출력. %d번째 수는 %d\n", i, (*number)++); 불가능

        printf("pNumber 배열을 첨자로 출력. %d번째 수는 %d\n", i, pNumber[i]);

        printf("pNumber 배열의 주소로 찾아가 출력. %d번째 수는 %d\n", i, (*pNumber) + i);

        printf("pNumber 배열을 증가 연산자로 가리키는 위치를 증가 시키며 출력. %d번째 수는 %d\n", i, (*pNumber)++);

    }
}

위 코드를 보시면 아시겠지만, 배열의 이름은 포인터 상수인 특성에 따라, 증가 연산자를 통한 포인터 값 증가가 불가능 한 것을 제외하면 포인터와 동일하게 사용 할 수 있습니다.

유심히 보시면 포인터도 배열에서 사용하는 연산자인 [] (첨자 연산자)를 사용한 것을 보실 수 있는데, 이 것은 포인터가 가리키는 주소를 기준으로 첨자 안에 쓰여진 위치의 데이터를 가리키는 역할을 하는 것으로, 첨자 연산자가 배열에서 쓰일 때, 배열 시작 주소를 기준해서 특정 위치에 접근하는 것이지, 배열만을 위한 연산자가 아니라는 것을 알 수 있게 해주죠.

배열의 특징

1. 배열은 같은 형식의 데이터를 메모리 상에 연속적으로 나열한 데이터 집합이다.

2. 배열의 데이터 접근 속도는 빠르다.

3. 배열의 이름은, 배열의 시작 주소를 가리키는 포인터 상수다.

함수 포인터

포인터 중에는 변수만이 아니라, 함수의 위치를 가리킬 수 있는 함수 포인터(Function Pointer)라는 것도 있습니다. 함수 포인터는 대상 함수와, 반환 형과 매개변수 형이 같다면, 그 함수를 가리킬 수 있습니다.

int (*pfunc)(int,int);  //int형 반환 값과, int형 매개변수 2개를 갖는 함수를 가리킬 수 있는 함수 포인터


float divide(float value1, float value2)
{
return value1 / value2;
}

//pfunc = divide; //불가능. 함수 포인터와 대상 함수는 반환 형, 매개변수 형이 같아야 함.

int multiple(int value1,int value2) //int 형 반환 값과, int형 매개변수 2개를 갖는 함수 선언.
{

return value1 * value2;
}           

pfunc = multiple; //함수 포인터 pfunc에, multiple 함수 주소를 대입.

int result; //결과 값을 저장할 변수 result 선언

result = pfunc(1,4); //pfunc함수 포인터를 통해, multiple 함수 호출. 1*4된 결과값 4가 반환됨.

C언어에서는 함수 자체를 매개변수로 넘길 수 없는데, 함수 포인터를 이용하게 되면 함수를 다른 함수의 매개변수로 전달할 수 있게 됩니다.

함수 포인터도 포인터처럼 여러 개의 함수 중에서 선택적으로 가리킬 수 있기 때문에, 이를 이용해 코드 분기 기능을 가질 수 있습니다. 위에 코드에서는 pfunc가 multiple함수를 가리키는 함수 포인터였습니다. 이번에는 pfunc가 add함수를 가리키게 한후, pfunc함수를 호출해 보겠습니다.

int add(int value1,int value2) //덧셈함수 add 선언. int형 반환 값을 갖고, int형 매개변수 2개를 받음.
{
    return value1 + value2;
}

pfunc = add; //함수 포인터 pfunc는 add함수를 가리킴

result = pfunc(1,4); //pfunc함수 포인터를 통해 add함수가 불려져, 1+4된 결과인 5가 반환됨

같은 코드가 실행됐지만, 결과값은 다르죠? 즉, 함수 포인터를 사용하면 같은 코드가 실행되더라도, 가리키고 있는 함수에 따라 결과가 달라지게 할 수 있는 것이죠.

이처럼 포인터를 이용하면, 메모리를 다룰 수 있기에 여러 가지 장점이 있습니다.

하지만, 포인터는 기본적으로 할당 받은 영역을 넘어서는 주소에 접근하면 오류를 발생시키기 때문에 반드시 조심해서 다뤄야 하는 점 잊지 않으시면서 사용하셨으면 좋겠습니다.

C프로그래머가 알아야 할 것들 - 04 프로그램 언어

C프로그래머가 알아야 할 것들 - 04 프로그램 언어

왜 문법을 배워야 하는가?

한국어를 할 줄 모르는 독일인과, 독일어를 할 줄 모르는 한국인과 대화가 가능할까요? 바디 랭귀지로 하면 되지 않느냐는 분도 계시겠지만 그것도 어느 정도 한계가 있기에, 제대로 된 의사소통은 불가능할겁니다.

컴퓨터는 0과 1 (2진수)밖에 인식하지 못한다고 배웠습니다. 컴퓨터에게 명령을 내릴 때,

00001110 01010101

이런 식으로 모든 명령을 내려야 한다면 프로그램을 만드는 데에 드는 시간이 막대할 것입니다.

그래서, 기계어에서는 16진수 (2진수를 4개씩 묶어서) 표현하고 있습니다.

00001110 01010101 -> 0x0E55

2진수일 때보다 조금 나아졌지만, 과연 이걸로 프로그램을 만들 수 있을까요? 0x0E55가 무슨 뜻인지 알 수 있는 사람이 몇이나 될까요??

프로그램을 작성하는 사람이 이해하기 쉽고, 컴퓨터가 이해할 수 있는 언어가 필요했습니다.

그래서 나온 것이 어셈블리어였습니다. 어셈블리어로의 5와 6의 덧셈은 아래와 같습니다.

mov ax,5
add ax, 6

계산 결과는 ax레지스터에 담겨 있게 됩니다.

이진수나 16진수로 표현된 기계어보다는 어셈블리어가 이해하기 쉬웠지만, 규모가 큰 프로그램에는 적합하지 못했습니다.

게다가 어셈블리 언어는 기계어와 1:1대응이다 보니, 하드웨어에 종속적인 언어였습니다.

이 같은 문제들을 해결하기 위해, 프로그래머가 사용하기도 편하고 하드웨어에 상관없이 구동 될 수 있으며, 기능도(비교적) 우수한 구조 형 언어 (파스칼, 코볼, 포트란, C언어)등이 나오게 되었습니다.

프로그램 언어를 통해 컴퓨터에게 일을 시키기 위해선 어떻게 해야 할까요? “야 지금부터 시간 재라.” 혹은 “야 지금부터 음악 재생 좀 해봐.”

이렇게 컴퓨터에게 우리가 사용하는 말로 말하면 알까요? 컴퓨터가 알 수 있는 말로 해야 한다고 했는데 기계어로 말하는 건 사실상 불가능에 가까우니 프로그래밍 언어의 힘을 빌려야 합니다.

그 프로그래밍 언어에 해당하는 문법을 분석해주는 컴파일러도 만능이 아니다 보니, 자신이 알 수 있는 내용으로 말해주길 바라죠. 컴파일러가 이해할 수 있는 내용에 대한 규칙이 바로 문법입니다.

사용하는 프로그래밍 언어에 맞는 문법을 지켜야만 컴퓨터에게 원하는 일을 시킬 수 있기 때문에 문법에 대해 알아야 하는 건 당연하겠죠?

내가 이걸 배워서 과연 실전에 사용할 수 있을까?

많은 분들이 하시는 고민. 바로 그것이 과연 지금 배운 문법들로 프로그램을 만들 수 있겠는가 하는 것입니다. 결론부터 말하자면 “그렇다”는 것입니다.

왜 난 문법은 이해했는데 원하는 프로그램을 만들 수 없을까 하는 생각을 하시는 분들이 많은데, 그 이유는 프로그래밍에 대한 막연함, 프로그램을 구성하고 있는 기본 법칙이나 내부 원리에 대한 이해가 부족한 것 등의 이유가 있습니다.

프로그래밍에 대한 막연함이란, 입문서 혹은 문법 서에 나온 예제 정도만 작성해보았지, 실제 사용할 만한 프로그램 개발 경험이 전무하기 때문에 가지는 부담감이라 할 수 있을 겁니다.

추상화를 통해 이런 부분까지 알지 않아도 가능한 시대가 왔다는 사람들도 있지만, 여전히 이 것에 대한 이해는 중요합니다. 우리는 API를 사용함으로써 키보드 장치에 대한 직접적인 제어를 하지 않아도, 키보드 입력에 관한 정보를 얻을 수 있고, 어떤 식으로 화면이 점을 찍는지 내부원리를 알지 못해도, API에서 지원해주는 점 찍기 함수를 통해서 점을 찍을 수 있습니다. 이 것은 매우 유용하고 대다수의 프로그래머들이 이런 기능을 사용하고 있습니다. 그렇지만, 점을 어떤 식으로 화면상에 표시하는지에 대한 원리, 키보드 입력이 어떻게 하여 프로그램으로 메시지로 전달되는지에 대한 이해가 되어있는 사람과, 그렇지 않은 사람과의 실력차이는 분명합니다.

근본 원리를 알고 있는 사람은 좀 더 멀리 내다볼 수 있고, 신 기술에 대한 적응도 빠릅니다. 음악파일의 근본 원리를 아는 사람은, 새로운 포맷을 만들어 낼 수도 있고, 새로운 알고리즘을 만들어 낼 수 있고, 자신이 작성하는 프로그램을 구성할 때 사용할 효율적인 사용법을 알아 낼 수도 있지만, 원리는 모르고 라이브러리나, 컴포넌트 등에서 제공하는 함수의 사용법만으로 프로그램을 개발하는 사람은 그 함수가 지원하는 기능으로 프로그램을 구성하는 그 이상은 구현 불가능합니다.

프로그래밍을 잘하기 위해, 좋은 프로그램을 만들기 위해선, 프로그램 언어 이외에도 배워야 할 것들이 많고, 그것들을 놓치면 안 되겠죠?

최적화

프로그램을 만들 때에, 여러분이 생각하시는 최적화는 이 중에서 무엇인가요?

1. 유지보수가 쉬운 읽기 쉬운 코드
2. 빠른 속도
3. 메모리 사용의 효율
4. 유저 편의
5. 시스템 리소스 사용의 효율
6. 시간 투자 효율

최적화의 기준이란 프로그램에 목적에 따라 가장 중요하게 여겨질 것이 결정되는 것이지, 늘 우선되어야만 기준은 없습니다.

다만, 자신이 작성할 프로그램이 가지고 있는 특징을 잘 파악하여, 어떤 부분을 최적화 할지 정하는 것이 좋습니다.

예를 들어, 임베디드 프로그램에서의 최고의 가치는 주로, 빠른 속도와 메모리 사이의 효율이 중요합니다. 일반적인 응용 프로그램에서라면, 유지보수가 쉬운 코드와, 유저 편의 사이에서의 밸런스를 맞추는 것이 좋죠. 게임 클라이언트 프로그램이라면, 빠른 속도와 유저 편의를, 서버 프로그램이라면 메모리, 속도, 안정성이 중요하죠.

시간도 중요한 의미를 가질 때가 많습니다. 지금 당장 해야 할 일이 산더미인데, 발생 빈도가 매우 낮고 치명적이지 않은 버그를 잡기 위해 한 달을 투자 할 순 없습니다.

버그 없는 프로그램은 존재하지 않습니다. 완벽에 가까운 프로그램은 있어도, 완벽한 프로그램은 없습니다.

0.1%의 완성도를 향상 시키기 위해서, 10%의 완성도를 높일 수 있는 시간을 소요한다는 것은 비효율적이죠.

좋은 프로그램을 만들려는 노력은 프로그래머로서 당연한 과제이기에, 완성도를 높이려는 노력은 당연한 것이지만, 당장 눈앞에 놓인 문제 해결만을 생각하기 보다, 어떤 부분에 노력을 기울이는 것이 더 좋은 결과를 낳을지 고민해보는 것이 좋을 것입니다.

언어의 선택

지금 현재 가장 많이 사용되고 있는 언어로는 C (C/C++ 두 가지를 함께 지칭. C와 C++은 다른 언어이고, 다른 점이 굉장히 많지만, 여기서는 같은 의미로 사용하겠습니다)와 자바를 꼽을 수 있습니다. 여전히 델파이나, 비주얼 베이직도 많이 쓰이며, 웹 언어인 PHP, ASP, 스크립트 언어인 파이썬, 루아, 루비 등 다양한 언어가 있습니다.

모든 언어들은 각기 장, 단점이 있습니다. C언어는 메모리를 자유 자재로 다룰 수 있는 대신, 그 만큼의 위험 부담을 가지고 있고, 코딩의 자유로움을 가진 대신, 잘못된 코드가 작성 됐을 경우 코드 분석의 어려움 (유지 보수의 어려움)의 위험성을 갖고 있습니다. 자바는 JVM을 통해서 멀티 플랫폼 프로그램을 구현했지만, JVM이 지원하지 않는 Low Level 접근은 불가능 하고, C++에서 코드 분석에 어려움이나 오해의 소지가 있는 문법은 없앤 대신, 그 문법들이 가지고 있던 장점들도 함께 사라졌죠. 다른 언어들도 각기 장단점을 갖고 있습니다.

자신이 능숙한 언어로 프로그램을 작성할 때 얻을 수 있는 이점은, 새로운 언어를 습득하는 데에 걸리는 시간 단축, 익숙한 언어 이기에 새로운 언어를 사용할 때 실수가 발생할 여지가 줄어드는 장점 등이 있습니다.

하지만, 최적화를 위해서 어느 한가지 면만이 우선시 될 수는 없죠. 처리 속도가 우선시 되는 프로그램에서 비주얼 베이직을 사용하거나, 웹 언어로 화면 전환이 잦은 채팅 프로그램을 작성 하거나, 스크립트 언어로도 작성할 수 있는 문자열 해석기를 어셈블리어로 만들거나 하는 것들은 비효율적인 일입니다.

언어는 구현을 위한 도구이지, 언어 자체는 프로그래밍에서 중요한 가치가 아닙니다. 한가지 언어만 잘해서는 여러 가지 상황에 유연하게 대처하는 것이 불가능하기에, 특정 언어를 능숙하게 다루는 것은 매우 긍정적인 일이지만, 특정 언어에만 집착해서 만드는 프로그램의 완성도를 떨어뜨리는 일은 없어야겠습니다.

C프로그래머가 알아야 할 것들 - 03 운영 체제와 컴퓨터 원리

C프로그래머가 알아야 할 것들 - 03 운영 체제와 컴퓨터 원리

운영체제란?

초기에 컴퓨터는 컴퓨터를 키자마자 프로그램이 담겨 있는 디스크를 삽입해야만 했습니다. 그리고 특별한 경우를 제외하고는 다른 프로그램 사용시에는 재 부팅 시켜야만 했습니다. 이 방법은 매우 불편했습니다. (비디오 게임기들은 이 방식을 채용하고 있는 경우가 많습니다)

그래서 유닉스, MS-DOS등의 운영체제가 나오게 됐습니다. (참고로 MDIR은 운영체제가 아닙니다. 인터페이스를 제공해주는 프로그램이죠) 각 운영체제하에 프로그램을 구동시킨 후, 프로그램 종료 시에는 그 운영체제로 돌아오게끔 하는 방식을 취한 것이죠.

이전에는 그래픽 카드나 프린터, 사운드 카드마다 출력을 지원해주는 방식이 달랐습니다. 점 하나 찍거나 소리를 내는 방법이 하드웨어에 따라 달랐죠.

그래서 각 하드웨어 장치(지금은 그래픽 카드와 사운드 카드를 의미합니다)를 컨트롤 하기 위한 작업들은 프로그램 개발 업체마다 따로 이루어져야 했고, 그렇기에 발매된 지 얼마 되지 않은 하드웨어나, 대중적이지 않은 하드웨어의 경우는 지원되지 않는 경우가 대부분

상황이 이렇다 보니 프로그래머들은 프로그래머 나름대로 다수의 하드웨어 장치를 지원하려다 보니 힘들었고, 사용자들은 사용자 나름대로 내 하드웨어가 내가 사려는 소프트웨어와 호환되는지를 따져봐야 하는 불편한 상황이었죠.

물론 DOS시절에도 VESA (Video Electronics Standard Association: 비디오 가전 표준 협회)등에서 그래픽 카드의 표준화를 시키고 표준에 맞는 그래픽 카드는 모두 지원되도록 노력을 기울였지만 만족스러운 결과를 얻어내진 못했습니다.

윈도우 이런 문제에 대한 대한을 가지고 있었습니다.

MS-DOS와 비교되는 윈도우의 장점으로 GUI (Graphic User Interface)를 꼽지만, 플러그 앤 플레이나, API (Application Programming Interface)도 빠지면 안될 정도로 중요한 요소입니다.

플러그 인 플레이는 자동 하드웨어 장치 인식 기능으로, MS-DOS의 단점을 보완해주기에 충분했습니다. 각종 장치에 대한 추상화를 이뤄내서, 사용자가 어떤 장치를 사용하던 간에, 운영체제가 그 장치를 지원하기만 한다면, 프로그래머는 그 장치를 이용할 수 있어졌습니다.

API는 프로그램 개발용 함수 모음으로, 점 찍기, 타이머, 텍스트 출력, 마우스 입력, 키보드 입력 등등 프로그램 개발에 필요한 기본적인 기능을 지원해줍니다. 각 프로그램마다 자체적으로 지원하기 위해 시간투자를 해왔던 작업들을 운영체제에서 추상화를 통한 지원이 이뤄짐에 따라, 프로그램 개발이 한결 편해진 것입니다.

이벤트

MS 윈도우(이하 윈도우)에서 이벤트란 윈도우에서 발생하는 정보들을 말합니다. 즉, 마우스 이동, 마우스왼쪽 버튼 클릭, 마우스오른쪽 버튼 클릭, 키보드 누름, 키보드 뗌, 문자 키 누름, 프로그램 시작, 프로그램 종료 등 다양한 상황마다 이벤트가 발생하는데, 그렇게 발생되는 이벤트를 메시지로 프로그램에 보내줍니다.

윈도우에서 응용 프로그램에 전달 해 주는 메시지 중 원하는 메시지를 이용하여 처리해주는 것이 윈도우 프로그래밍에서의 이벤트 프로그래밍이라고 합니다.

윈도우가 하드웨어의 접근을 직접 관리하기 때문에, 윈도우용 프로그램을 개발하는, 프로그래머들은 하드웨어 제어에 대해 그다지 신경 쓰지 않아도 되는 것이죠.

프로세스와 쓰레드

프로세스는 프로그램의 실행 단위를 의미 합니다. 다른 말로는 작업이라는 의미로 태스크라 부르기도 하죠.

멀티 태스크나 멀티 프로세스란, 다중 프로그램 구동이라고 생각하시면 됩니다.

멀티 태스크를 통해 우리는 동시에 프로그램이 실행되고 있다고 생각하시는 분도 많을 겁니다. 그러나 실상은 눈 깜짝 할 사이에 여러 개의 프로그램이 번갈아 가면서 실행되고 있는 것인데, 그것이 매우 빠른 속도로 이루어지기에, 우리는 동시에 작동하는 것으로 느끼는 것이죠.

쓰레드는 프로세스 내부의 실행 단위를 말합니다. 프로세스 내에서 쓰레드가 여러 개 존재하여 처리되는 것을 멀티 쓰레드라 하죠.

예를 들면 메신저라는 한 프로그램 내에서 음악 재생하면서 채팅(메시지 입력)을 할 수 있는 것은, 음악 재생과 채팅기능이 쓰레드 단위로 구동되기 때문입니다.

멀티 쓰레드도 멀티 태스크와 마찬가지로 한 프로세스의 시간을 쪼개 씀으로 인해, 동시에 여러 가지 작업이 이루어 지는 것처럼 보여지는 것입니다.

컴퓨터는 계산기다

컴퓨터는 계산기라고 한다면, 아니? 계산기에서 동영상도 볼 수 있고, 게임도 할 수 있고, 그림도 볼 수 있고, 음악도 나온다는 게 말이 되냐고 하시는 분도 있으시겠지만 사실입니다. 컴퓨터라는 이름 자체가 Compute (계산하다)에서 파생된 것도 우연은 아니겠죠?

초기 컴퓨터(최초의 컴퓨터는 애니악으로 알려져 있는데, 최초의 컴퓨터는 앨런 튜닝이 2차 세계 대전에서 독일군 암호 해독을 위해 만들어진 콜로서스입니다)는 계산을 하기 위해 만들어졌습니다. 대형 고속 계산기쯤이었다고 생각해도 되겠죠? 그 당시의 컴퓨터는 연산속도도 느리고, 연산을 위한 저장 장소가 작았기 때문에 간단한 처리밖에 못했습니다.

시간이 흐르면서 컴퓨터는 발전을 거듭했습니다. CPU의 연산 속도도 이전과는 비교도 안될 정도로 빨라졌고, 메모리 용량이 증가했으며, 보조 기억 장치의 용량도 증가했습니다.

소프트웨어도 하드웨어 장치를 활용할 수 있도록 발전해왔습니다.

하드웨어는 사용자로부터 입력을 받아 그 것을 비트 정보로 변환하고 프로그램(혹은 운영체제)에 전달합니다.

프로그램은 어떤 소리를 출력해야 하는지, 어떤 이미지를 보여주어야 하는지가 결정해서 다시 출력 장치로 전송합니다.

출력 장치인 모니터나 스피커는 전달 받은 디지털 데이터를 분석해서 그에 맞는 출력을 해주면서 컴퓨터를 통한 하드웨어 연동이 이루어지고 있습니다.

이런 과정이 다 계산으로 이루어 질 수 있는 것은, 챕터2에서 설명한 비트의 법칙 덕분입니다. 컴퓨터에서는 소리도, 영상도 모두 디지털 데이터인 비트 값으로 저장하고 있습니다. 비트 값은 수치화된 값입니다. 저장된 값에 맞는 출력을 장치에 요청함으로, 소리가 재생되고, 영상도 출력되는 것입니다.

2D게임이 3D게임보다 빠르다?

우리가 흔히 하는 착각은 2D게임이 3D게임보다 빠르다. 혹은 2D게임은 저 사양이다라는 생각입니다.

컴퓨터에서 이뤄지는 모든 것들은 계산에 의한 것입니다.

그렇기 때문에, 2D게임이던, 3D게임이던 간에 게임의 속도는 얼마만큼 많은 계산을 필요로 하는지에 달려있는 것이지, 같은 (혹은 비슷한) 기능을 가진 게임이라면 2D와 3D의 기본적인 연산 속도의 차이(3D는 일반적으로 다각형으로 이루어져 있기에 기본적으로 이루어져야 할 연산이 많고, 실수 연산이 많이 필요하기 때문에 2D보다는 확실히 연산할 것이 많긴 합니다)가 있지만, 게임의 규모가 커지다 보면 오히려 2D게임이 느려지는 경우가 발생하기도 합니다.

할 수 있는 액션이 별로 없는 경우에는, 2D쪽이 월등이 빠르겠지만, 많은 수의 액션, 많은 수의 프레임 갱신, 부드러운 화면 처리, 시각적 효과 등이 필요할 경우에는 3D보다 많은 연산을 해야 하는 경우도 많습니다. 특히나 화면 확대를 해야 할 경우, 3D는 카메라를 당기기만 하면 되지만, 2D는 현재 픽셀 값을 기반으로 확대 했을 때 영상 비를 유지 시키면서 보간 및 확대 연산을 해야 합니다. 2D일 때의 계산 량이 더 많을 가능성이 높습니다.

심지어는 화면 전환마저 거의 없는 게임인 Football Manage시리즈의 경우 웬만한 3D게임보다도 속도가 느린데, 이 것은 이 게임이 처리해야 될 데이터가 많기 때문입니다. 모든 경기 결과는 랜덤이 아닌, FM시리즈의 규칙(데이터에 기반하되, 그 데이터가 전부가 아닌)에 따른 결과가 나와야 하기 때문에, 모든 경기 결과를 시뮬레이션을 통해 얻어내야 되는데, 그 계산해야 될 데이터가, 웬만한 3D게임보다 많기 때문에 느린 것입니다.

3D게임이 느렸던 것에는 실수 연산도 한몫 했는데요, 부동 소수점 실수의 연산은 정수처럼 간단하지가 않아서 실수 연산을 많이 필요로 하는 3D게임이 느렸었죠. 요새는 3D게임을 위해 실수 연산 속도를 끌어 올린 그래픽 카드들로 인해 이런 문제는 많이 해결됐죠.

어때요? 컴퓨터의 속도에 대한 감이 오시나요?