코딩시 유념할 점 들

추가 하는 코드에 대해 명확히 이해하라

  • 코드를 추가할 때, 그 코드가 어떤 기능을 하는지 간단한 테스트라도 해라. 처음 시도, 사용하는 알고리즘이나 라이브러리 사용시에는 특히 주의하라. 그 기능을 명확히 이해하지 못하고 사용한다면, 그 것은 우연에 맡기는 프로그래밍을 하는 것이다.  

    적은 조건을 만족해도 잘 돌아가는 코드를 작성해라 (복잡도를 줄여라)

  • 다른 코드나 외부 데이터에 적게 영향 받는 코드를 만들어라.  

    가정 하지 마라. 코드에서 직접 제약을 걸어라.

  • 어떤 처리에 대한 클라이언트와의 규약 (예를 들면, 비번 방 지정은 방장만 할 수 있다던가, 게임 방에서만 가능하다던가 하는 가정)은 반드시 지켜질 수 있도록 코드에서 그 동작이 불가능 하도록 만들어라.

자료구조에서 넣는 키 값과, 빼는 키 값에 주의하라.

  • 온라인 서버 게임에서 호환을 위해 대소문자 캐릭터 아이디를 모두 메모리에 들고 있는 경우가 있다. 이 때 자료구조에 소문자 캐릭터 아이디로 넣고, 대문자 캐릭터 아이디로 빼는 경우문제가 생겼다. 이런식으로 스트링의 경우 키의 대 소문자 구분에 주의 하자.  

    코드 재 사용시 가정이 깨지는 것에 주의하라

  • 같은 코드를 다른 곳에서 재 사용시에 기존에 수립한 가정을 다시 검토하라.  

    증명을 하자

  • 프로그래머는 추론이 아닌 증명을 해야 한다. 특정 상황이 의심스럽다면 그 상황이 맞는지 입증해라.

성급한 일반화의 오류를 범하지 말자

  • 특정 플랫폼, 특정 컴파일러, 특정 라이브러리의 구현이 일반적이라고 믿는 일반화의 오류를 범하지 말자. 구현은 천차만별이다.

표준 규격이 있다면 그 것을 사용하라무언가가 표준으로 정해진 데에는 그만한 메리트가 있기 마련이다.

  • 예를 들어 시간을 1000분에 1초 단위로 사용한다던가 하는 것인데, 이런 규격은 합당한 이유가 없다면 표준 규격을 사용하도록 하자.

데이터 형의 특성을 잊지 마라데이터 형 마다 담을 수 있는 값의 범위와, 특징이 존재한다.

  • 예를 들면, 오라클에서의 문자열 데이터형은 문자 셋에 따라 C와 같은 스타일의 저장 크기를 가진다. (유니코드 2바이트, 아스키는 한글,한자등은 2바이트, 영어는 1바이트). 또한 DBMS에서 varchar2와, char의 특성을 명확히 이해하라. char에서는 뒤에 공백 문자가 존재해서 varchar2와 비교 시 유의해야 하기 때문이다.

표준 규격이 있다면 그 것을 사용하라무언가가 표준으로 정해진 데에는 그만한 메리트가 있기 마련이다.

  • 예를 들어 시간을 1000분에 1초 단위로 사용한다던가 하는 것인데, 이런 규격은 합당한 이유가 없다면 표준 규격을 사용하도록 하자. 표준 규격은 유지보수시에 큰 잇점이 있다. 또한 호환성도 높기에 표쥰규격을 이용 하는 것이 좋다.

키 값이 필요한 자료구조에 주의하자.

  • 대부분의 키 값을 필요로 하는 자료구조 들은 키 값으로 정렬한다. (맵, 멀티맵, 셋, 멀티셋 등)
  • 이 키 값을 변경했을 때 퍼포먼스가 낮아지는 현상이 있을 수 있고, 객체를 키로 잡았을 때 객체 안의 값이 바뀜으로 인해 로직상의 문제가 생길 수 있다.
  • 맵을 사용할 때에는 객체 안의 특정 변수를 키로 잡고, 객체를 값으로써 관리하는 경우도 있는데 이때도 마찬가지로 키로 잡은 변수를 변경시키지 않도록 주의하자.

C프로그래머가 알아야 할 것들 - 08 프로세스와 스레드

C프로그래머가 알아야 할 것들 - 08 프로세스와 스레드

프로세스와 스레드

스레드를 이해하려면 프로세스에 대한 이해가 선행되어야 합니다. 프로세스란 프로그램이 실행되는 단위를 말합니다. 지금 제가 이 문서를 작성하고 있는 OpenOffice도 프로세스고, 음악을 듣고 있는 aimp2도 프로세스, 메신져인 pidgin 모두 프로세스입니다. 일반적으로 프로그램의 실행 단위가 프로세스라고 보시면 됩니다. (한 프로그램 내에 여러 프로세스를 묶어 하나처럼 보이게 하는 경우도 있지만, 이런 경우는 예외로 생각하겠습니다.)

저는 지금 메신져를 켜놓고, 음악을 들으며 문서 작성을 하고 있는데요, 이렇게 세가지 작업을 한꺼번에 할 수 있는 것은, 윈도우즈가 멀티 프로세스를 지원하기 때문입니다.

반면 윈도우즈 자체는 싱글 프로세스입니다. 메모리에 대한 소유권, CPU 제어권을 혼자 독점합니다. 그렇게 독점한 자원을 바탕으로, 프로세스들을 실행/관리 해주는 것이죠.

그렇다면 스레드는 무엇일까요? 프로세스 내의 실행 단위를 말합니다. C언어 계열의 프로그램은 기본적으로 main() 함수에서 시작합니다. 그리고, main()함수 에서부터 순차적으로 실행되죠. main()부터 시작되는 스레드를 메인 스레드라 부릅니다.

메인 스레드 외에 다른 스레드와 교차되어 실행 되는 것이 멀티 스레드 입니다. 여기서 키 포인트는 ‘교차’ 입니다. 스레드가 교차되어 실행 되기에 파생되는 문제점 들에 대해서 자세히 얘기해보죠.

멀티 스레드에서 교차 실행에 따른 문제점

교차된다는 게 무엇일까요? 직렬이 아니라 병렬이라는 뜻이겠죠? 스레드를 여러개 만든 다는 것은 코드의 실행 흐름을 여러개 만든다는 의미가 됩니다. main()에서 시작되서, main()내의 코드 흐름만을 따르는게 아니라, 스레드를 만드는 수 만큼 코드 흐름이 늘어나는 것이 되죠.

흐름을 여러개 만들어놓고, 한 흐름이 끝날 때까지 기다려야 한다면 직렬. 즉 단일 스레드와 전혀 다를바 없어집니다.

그래서, 각 스레드가 실행 되던중에, 다른 스레드로 실행 흐름이 옮겨가게 되고, 이 것을 교차라고 표현한 것입니다.

#include <Windows.h>

int g_nTest = 0;

DWORD WINAPI IdleThread(void *lpVoid)
{
    while(g_nTest < 30)
    {
        printf("%s %d\n", __FUNCTION__, ++g_nTest);
    }
    return 0;
}

int _tmain(int argc, _TCHAR* argv[])
{
    HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

    while(g_nTest < 30)
    {
        printf("%s %d\n", __FUNCTION__, ++g_nTest);
    }

    if(hThread != INVALID_HANDLE_VALUE)
    {
        WaitForSingleObject(hThread, INFINITE);
        CloseHandle(hThread);
    }
    return 0;
}

위 코드는 스레드를 하나 추가로 생성하고, 전역 변수를 0에서 시작해서 100이 될 때까지 실행 되도록 한 코드입니다.

실행 결과는 어떨까요?

image_01 image_02

어떤 스레드에서 g_nTest값을 썼던간에, g_nTest에 해당 하는 값은 1~30까지 순차적으로 찍혀야 된다고 생각하시죠? 하지만 실제 수행 결과는 차이가 있습니다.

wmain() (= main(). 이하 main())의 while문이 실행 되던중 g_nTest 변수가 13이었을 때, IdleThread의 printf 구문이 실행되었고, g_nTest변수를 ++g_nTest 시킨 상태에서 printf()에 값을 전달했으나 아직 printf()가 실행되지 않은 상태에서 코드 흐름이 main()으로 다시 넘어가 30까지 실행 되었습니다.

그리고 다시 코드 흐름이 IdleThread()로 넘어오게 오게 되어, printf()함수에 넘겼던 %d에 해당하는 값인 14가 출력되고 프로그램이 종료 되었죠.

여기서 핵심은 g_nTest 변수가 13이었을 때 IdleThread의 ++g_nTest 구문이 실행되었다는 점입니다. main스레드의 while 루프를 돌던 중에, 코드 수행이 IdleThread로 옮겨 간 것이죠. 정확히 하자면 스레드는 병행 수행이 아니라, 스레드의 수만큼 실행 흐름을 만들고, 스레드의 동작 도중 다른 스레드로 실행 흐름이 바뀐다는 것입니다.

그런데 이 스레드의 교차 수행으로 인해 장점이자 단점이 생깁니다. 뭘까요?

(3) 교차와 메모리 공유에서 오는 문제들

멀티 스레드라고 해도, 메모리 할당은 프로세스 단위로 이루어지기 때문에 같은 메모리를 공유하게 되어있습니다.

게다가 교차되서 실행 되기에 같은 메모리에 덮어 썼을 때 실행 결과가 예상과 달라질 수 있게 됩니다.

예제 코드를 보시죠.

#include <Windows.h>

int *g_pTest = new int(0);

DWORD WINAPI IdleThread(void *lpVoid)
{
    while(g_pTest && *g_pTest < 20)
    {
        printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);
        (*g_pTest)++;
    }

    delete g_pTest;

    g_pTest = NULL;
    printf("%s %p delete g_pTest\n", __FUNCTION__, g_pTest);
    return 0;
}

int _tmain(int argc, _TCHAR* argv[])
{
    HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

    while(g_pTest && *g_pTest < 20)
    {
        printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);
        (*g_pTest)++;
    }

    if(hThread != INVALID_HANDLE_VALUE)
    {
        WaitForSingleObject(hThread, INFINITE);
        CloseHandle(hThread);
    }

    getchar();
    return 0;

}

이 코드는 메모리 공유로 인한 문제를 보여줍니다. g_pTest를 IdleThread에서, 20까지 증가 시킨 후, g_pTest를 초기화 합니다. main()함수에는 g_pTest가 유효할때만 로그를 찍고, 값을 1 증가 시켜주죠.

하지만, IdleThread가 먼저 종료되고 난 후, 메인 스레드로 복귀 했을때, 수행중이었던 첫번째 printf은 무난하게 넘어가지만 (스택에 이미 값을 넘긴 이후기에), 두번째 printf에서, (*g_pTest)에 ++를 하려다 NULL포인터 접근 오류가 발생했습니다.

image_03 image_04

이상하죠? 메모리 공유나 교차 문제가 아니라면, while(g_pTest)에서 널포인터 검사를 했기에 유효한 포인터여야 할텐데 라고요.

하지만, g_pTest가 전역 데이터이기에 쓰레드간에 공유됩니다. 그래서 main()쓰레드가 첫번째 printf함수에 값을 전달해놓은 상태에서 멈췄고, IdleThread가 수행되며 g_pTest가 NULL이 되고 난 후에야 main()로 코드 흐름이 돌아왔고, 그래서 NULL포인터 접근 오류가 발생 한 것입니다. (이 예제 코드 수행 시 항상 NULL포인터 접근 오류가 발생하진 않다는 점에 유의하십시오.)

현재 두가지 경우 모두다 전역 변수/포인터로 설명했지만, 함수에 포인터로 전달된 값들도 같은 문제를 안고 있습니다.

그러면 이 문제를 해결하기 위해 어떻게 해야 할까요? 이 문제의 해결책은 바로 스레드 동기화입니다.

스레드 동기화

우선 우리의 문제부터 다시 정리해보면 공유된 메모리를 사용하는데, 다른 스레드가 언제 끼어들지 몰라 어떤 값이 언제 변할지 모른다는 점이다.

이 문제를 해결하기 위해 일반적으로 어떤 값에 대한 사용이 끝나기 전까지는, 그 값을 사용하려는 다른 스레드가 멈춰있게 함으로써 그 값에 대한 신빙성을 유지해주는 방법을 쓴다.

이 방식으로 데이터 신빙성을 유지해주는 방법을 크리티컬 섹션 동기화 방식 (이하 CRITICAL_SECTION또는 크리티컬 섹션)라고 합니다. 다음 코드를 보시죠.

#include <Windows.h>

CRITICAL_SECTION cs;

int *g_pTest = new int(0);

DWORD WINAPI IdleThread(void *lpVoid)
{
    printf("%s Start\n", __FUNCTION__, g_pTest);

    EnterCriticalSection(&cs);

    while(g_pTest && *g_pTest < 20)
    {
        printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);
        (*g_pTest)++;
    }

    delete g_pTest;

    g_pTest = NULL;

    printf("%s %p delete g_pTest\n", __FUNCTION__, g_pTest);

    LeaveCriticalSection(&cs);
    return 0;
}


int _tmain(int argc, _TCHAR* argv[])
{
    InitializeCriticalSection(&cs);

    HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

    EnterCriticalSection(&cs);

    while(g_pTest && *g_pTest < 20)
    {
        printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);
        (*g_pTest)++;
    }

    LeaveCriticalSection(&cs);


    if(hThread != INVALID_HANDLE_VALUE)
    {
        WaitForSingleObject(hThread, INFINITE);
        CloseHandle(hThread);
    }

    DeleteCriticalSection(&cs);
    getchar();
    return 0;
}

이 코드는 크리티컬 섹션으로 데이터 사용중임을 다른 스레드에 알린다는 점을 제외하고는 위 예제와 동일합니다.

그리고 이렇게 수정함으로써 메모리 접근 오류도 발생하지 않게 되죠.

코드 수행 결과부터 보시죠.

image_05

EnterCriticalSection() 은 같은 CRITICAL_SECTION 객체를 사용하지 않는 상태에서만 수행 됩니다. 어딘가에서 사용할려는 CRITICAL_SECTION 객체가 사용중이라면, 그 사용이 끝날때까지 대기 합니다.

그래서 IdleThread가 수행되려 했음에도 불구하고, main스레드의 while문이 종료 될때까지 대기한 후, 종료 되고 나서야 IdleThread가 실행 되었죠.

여기서 중요한 것은 g_pTest에 변경을 시도하는 코드들이 모두 EnterCriticalSection()과, LeaveCriticalSection()로 감싸져 있다는 점입니다.

만약 지금의 수행결과와 다르게 IdleThread가 main스레드보다 먼저 실행되어, IdleThread에서 g_pTest를 NULL로 만든 후에 main스레드로 돌아온다고 해도, while문 실행 도중에 main스레드로 돌아오는 것이 아니라, while문의 시작부분 g_pTest의 포인터 유효성 검사 코드 부터 수행 되어, NULL포인터 접근 하는 일은 없게 됩니다.

정리하자면, CRITICAL_SECTION 방식의 동기화는 “내가 데이터 사용 끝낼 때 까지, 다른 녀석들은 쓰지마!”로 요약할 수 있는 것이죠.

이 방식 이외에도 이벤트, 세마포어, 뮤텍스 등을 이용한 동기화 방법들이 존재하지만, 커널 모드/ 유저 모드에 대한 설명도 필요하고, 이 강좌에서 집중하려는 내용에서 벗어나기에 해당 내용은 차 후에 다루도록 하겠습니다.

그렇다면…이렇게 여러 문제가 있어, 동기화도 해주어야 하는 번거로운 녀석인 멀티 스레드가 필요할 때와, 필요하지 않을 때는 과연 언제 일까요?

멀티 스레드를 사용해야 할 때

몇년전만해도 멀티 스레드 프로그램은 지금보다 훨씬 적은 수 였습니다.

멀티 스레드가 다루기 어렵기 때문에 포기한 경우도 적지 않았는데요, 위에서 말한 문제를 비롯해서 데드락, 스레드 동기화를 위한 대기로 인한 성능 저하, 스레드 동기화 실패 등의 문제등이 발생할 수 있죠.

많은 문제 발생의 소지가 있음에도 불구하고 현재의 많은 프로그램들은 멀티 스레드를 선택하고 사용하고 있는데 그 이유는 멀티 스레드가 가진 장점들 때문이죠.

그 장점중 하나로는 블럭 함수들은 스레드 단위로 블럭에 걸린다는 것입니다.

다음 코드를 보시죠.

#include <Windows.h>

#pragma comment(lib, "winmm.lib")

int _tmain(int argc, _TCHAR* argv[])
{
    printf("Are you want to quit? You type 'Q' or 'q'\nIf you want Replay? You type other key.\n");

    while(1)
    {
        sndPlaySound(_T("Play.wav"), SND_SYNC);
        char key = getchar();
        if(key == 'q' || key == 'Q')
            break;
    }

    return 0;
}

별도로 스레드를 만들어주지 않았기에 싱글 스레드 코드입니다. 이 코드의 경우 sndPlaySound()의 음악 재생이 끝날 때 까지 리턴되지 않습니다. 음악 재생이 끝난 이후라도 getchar()가 블럭 함수이기 때문에, 키 입력이 들어오기전에는 멈춰있게 되죠. 둘다 블럭되는 동작이기에 서로 동시에 이루어질 수 없죠.

그럼 멀티 스레드는 어떻게 될까요?

#include <Windows.h>

#pragma comment(lib, "winmm.lib")

DWORD WINAPI IdleThread(void *lpVoid)
{
    while(1)
    {
        sndPlaySound(_T("Play.wav"), SND_SYNC);
    }
    return 0;
}

int _tmain(int argc, _TCHAR* argv[])
{
    HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

    printf("Are you want to quit? You type 'Q' or 'q'. If you type other key, Sound Plays is Loop.\n");

    while(1)
    {
        char key = getchar();
        if(key == 'q' || key == 'Q')
            break;

    }

    if(hThread != INVALID_HANDLE_VALUE)
    {
        WaitForSingleObject(hThread, 1000);
        CloseHandle(hThread);
    }

    return 0;
}

IdleThread는 계속 음악 재생 역할을 하고 있고, 메인 스레드는 루프가 돌면서 q또는 Q가 입력 되길 기다리고 있습니다. 멀티 스레드에서는 음악 재생중임에도 불구하고 키 입력이 가능해지는 것을 아실 수 있습니다.

이처럼 멀티 스레드를 쓴다는 것 자체가 한 스레드가 블럭 상태에 놓여있어도 다른 스레드는 영향을 받지 않는다는 큰 장점을 가지고 있습니다.

현재는 음악 재생이었지만, 큰 용량의 파일을 읽어서 분석한다고 해봅시다. 그 파일을 완전히 읽고 분석을 끝내기 전까지 그 데이터는 의미를 갖지 못합니다.

그런데 굳이 프로그램 전체가 멈춰가며 기다려야만 할까요? 아니죠?

그럴때 스레드를 생성한 후 생성한 스레드는 파일 로딩에만 집중하도록 한 후, 다른 스레드는 기존에 하던일을 처리하도록 만들면 로딩이 끝날 때까지 다른일을 할 수 있게 됩니다.

그런데…이렇게 좋은 스레드가 뭐가 문제라는 걸까요??

멀티 스레드를 쓰는 것이 독이 될 때

스레드가 실행 흐름이 여러개로 나뉘어진다고 했죠? 그 여러개의 실행 흐름을 갖기 위해서, 또 동시에 실행되기 위해선 스택과 스레드 제어 블록(어떤 스레드가 먼저 실행 되어야 하는지 등의 다른 스레드와의 관계 등에서 쓰이는 데이터)을 스레드마다 가져야 합니다.

스레드가 하나 추가 될 때 마다 스택과, 스레드 제어 블록도 같이 생성 되는데, 이로 인한 부하가 사실 만만치않습니다. 또한, 스레드 간 스위칭 (다른 스레드로 실행 흐름이 옮겨지는 것을 스위칭이라 합니다)에도 적지 않은 비용이 듭니다.

게다가 동기화를 잘못 했을 경우, 다른 스레드를 기다리는 시간이 길어져 속도가 저하되는 문제도 발생할 수 있죠.

크리티컬 섹션이 데이터 사용 끝낼 때 까지 다른 사람은 건드리지말라는 의미라 말씀드렸죠? 크리티컬 섹션을 쓴다는 것은, 같은 크리티컬 섹션 객체를 사용하는 다른 스레드가 대기 상태에 빠지게 합니다. 즉 해당 스레드가 그 시간만큼 기다려야 한다는 것이죠.

#include <Windows.h>

#pragma comment(lib, "winmm.lib")

CRITICAL_SECTION cs;

DWORD WINAPI IdleThread(void *lpVoid)
{
    while(1)
    {
        EnterCriticalSection(&cs);
        sndPlaySound(_T("Play.wav"), SND_SYNC);
        LeaveCriticalSection(&cs);
    }

    return 0;
}



int _tmain(int argc, _TCHAR* argv[])
{
    InitializeCriticalSection(&cs);

    HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

    printf("Are you want to quit? You type 'Q' or 'q'. If you type other key, Sound Plays is Loop.\n");

    while(1)
    {
        EnterCriticalSection(&cs);

        char key = getchar();

        if(key == 'q' || key == 'Q')
        {
            LeaveCriticalSection(&cs);
            break;
        }

        LeaveCriticalSection(&cs);

    }

    if(hThread != INVALID_HANDLE_VALUE)
    {
        WaitForSingleObject(hThread, 1000);

        CloseHandle(hThread);
    }

    DeleteCriticalSection(&cs);
    return 0;
}

main스레드의 getchar()가 리턴되기 전까지, IdleThread의 sndPlaySound()가 실행 될 수 없고, IdleThread의 sndPlaySound()가 리턴 될 때 까지 main스레드의 getchar()가 실행 될 수 없습니다.

동기화 객체의 사용이 끝났다고 알려주기 전까지는 다른 스레드는 대기 상태에 빠질 수 밖에 없습니다. 현재는 두개의 스레드이기 때문에 ‘아…상호 연관 데이터가 없네? 그럼 크리티컬 섹션을 쓰지 말자’라거나, ‘블럭함수니까 크리티컬 섹션으로 감싸면 안되겠구나’ 라는 판단을 바로 내릴 수 있지만, 스레드가 늘어나고 로직이 복잡해지면 이런 판단을 쉽게 내리기 어려워집니다.

그래서 멀티 스레드에 대한 고민은 코드 작성에서 중요한 부분을 차지하고, 동기화에 실수가 생겼을 때 파장이 크기 때문에 더 신중 해야 합니다.

하지만 이보다 더 큰 문제는 데드락 입니다. 수행 속도 저하는 어쨋거나 코드가 정상적으로 수행은 됩니다. 하지만, 데드락의 경우는 프로그램이 아예 정지해 버리죠. 아! 데드락이 뭐냐고요? 데드락이란, 한글로는 교착 상태라고 표현하곤 하는 상태를 말하는데요, 크리티컬 섹션 객체를 사용하는 동기화를 설명 드릴 때, ‘내가 이 데이터 사용을 끝내기 전에 다른 스레드는 사용하지마’라는 의미를 가진다고 말씀 드렸죠? 반대로 자신의 관점에서도 어떤 스레드가 내가 사용하려는 데이터를 사용 중이라면, 사용을 끝낼 때까지 기다려야 합니다.

그런데 만일 대기 중인 이유가, 내가 사용중인 데이터를 기다리기 때문이라면 어떻게 될까요? 이럴 경우 서로 기다리기만 할 뿐 (상호 대기 상태라 합니다) 더 이상 아무 일도 할 수 없는 상태가 되어버리죠. 이 상태가 바로 데드락(DeadLock: 교착) 상태입니다.

그래서 데드락에 빠지지 않기 위해, 큰 작업 분류 별로 스레드를 나눈 후 블럭이 걸리는 일은 별도의 스레드를 만들어 돌리고, 각 스레드 별로 처리한 결과를 다른 스레드에 전달 하거나 받아야 할 때에만 메시지 큐 등을 통해서 전달 받아 처리하는 방법이 사용되곤 합니다.

이는 윈도우즈 API에서도 내부적으로 사용하는 방식이기도 한데요, 타이머 처리나 윈도우 메시지, 예제로 사용했던 sndPlaySound()함수의 두 번째 인수로 SND_ASYNC가 사용될 경우 백그라운드 작업을 통해 처리하고 있다가, 그 결과를 어플리케이션에 알려줘야 할 경우 메시지 큐 등을 통해 전달하는 것과 같은 방식입니다.

이런 처리 방식에서 핵심은 동기화가 필요한 상황 자체를 줄이는 데에 있습니다. 동기화가 필요한 상황자체가 적다면 다른 스레드를 기다려야 하는 상황/시간도 줄어들고 그로 인한 효율성 낭비가 줄어든다는 것에 기반하고 있습니다.

세상 어떤 일에도 절대, 최고의 방법은 존재하지 않습니다. 제가 설명 드린 방식보다 더 좋은 멀티 스레드 활용법에 대해 한번쯤 고민 해보시는 건 어떨까요?

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가 반환됨

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

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

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