이 에코서버는 "윈도우 네트워크 프로그래밍 [한빛미디어, 김선우]"의 책에 있는 내용을 기초로 작성된 소스 입니다.

소스 설명에 앞서… 우선 이 에코 서버의 소스는 이미 약간의 네트워크 프로그래밍 지식이 있다는 전제 하에 설명합니다.

여기서 약간은 서버와 클라이언트의 1:1 TCP 에코 서버를 기준으로 이해를 하신 뒤에 그 이후 과연 1대의 서버로 여러 클라이언트의 접속을 어떻게 할까에 대한의문점을 풀어 들이는데 약간이나마 도움이 될 꺼 같아 작성합니다.

음.. 아래 소스는 class 형식으로 작성되었습니다.
허나! 서버 작성에 class 는 uml 차트를 그리신후 설계등 이런전문적인게 아니면 그냥 C로 작성하는걸 권유하는 편입니다.

이유로는

  1. 절차적 흐름으로 가는 네트워크 프로그래밍에 굳이 객체 지향을 할 필요가 없다.
  2. Class로 설계 하다보면 send, recv 쪽의 소스가 꼬일 가능성이 높다
  3. 유지보수 할 때 햇갈리기 쉽상이다

로 느껴집니다..

자세히는 이 위의 select server와 비교를 하시면, 왜 class로 하면 안되는지 이해가 가실꺼라 생각합니다.

일단 소스를 들어갑니다.
ws2_32.lib 의 링크를 설정 방법까진 설명하진 않습니다.


NetworkUtil.h // 여기는 윈도우 네트워크 프로그래밍 할 때 반복으로 쓰인 util함수 모음 헤더

#include <winsock2.h>
#include <stdlib.h>
#include <stdio.h>

// 소켓함수오류출력후종료
void err_quit(char *msg)
{
    LPVOID lpMsgBuf;
    FormatMessage(
        FORMAT_MESSAGE_ALLOCATE_BUFFER|
        FORMAT_MESSAGE_FROM_SYSTEM,
        NULL, WSAGetLastError(),
        MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
        (LPTSTR)&lpMsgBuf, 0, NULL);
    MessageBox(NULL, (LPCTSTR)lpMsgBuf, msg, MB_ICONERROR);
    LocalFree(lpMsgBuf);
    exit(-1);
}

// 소켓함수오류출력
void err_display(char *msg)
{
    LPVOID lpMsgBuf;
    FormatMessage(
        FORMAT_MESSAGE_ALLOCATE_BUFFER|
        FORMAT_MESSAGE_FROM_SYSTEM,
        NULL, WSAGetLastError(),
        MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
        (LPTSTR)&lpMsgBuf, 0, NULL);
    printf("[%s] %s", msg, (LPCTSTR)lpMsgBuf);
    LocalFree(lpMsgBuf);
}

// 사용자정의데이터수신함수
int recvn(SOCKET s, char *buf, int len, int flags)
{
    int received;
    char *ptr = buf;
    int left = len;
    while(left > 0){
        received = recv(s, ptr, left, flags);
        if(received == SOCKET_ERROR)
            return SOCKET_ERROR;
        else if(received == 0)
            break;
        left -= received;
        ptr += received;
    }
    return (len - left);
}


Server.h

#include <iostream>
#include "NetworkUtil.h"
using namespace std;
#define BUFSIZE 4096

DWORD WINAPI ProcessClient(LPVOID arg);     // 쓰레드
class Cilent_Connect
{
private:
    int retval;
    SOCKET client_sock;          // 접속하는 클라이언트의 소켓
    SOCKADDR_IN clientaddr;      // 접속하는 클라이언트의 구조체
    char buf[BUFSIZE];           // 버퍼
    void Client_Disconnect();    // 클라이언트를 종료하는 함수
    bool Send_Proc();            // 클라이언트에서 받은 Text 보내기
    bool Recv_Proc();            // 클라이언트로부터 Text 받기

public:
    Cilent_Connect(SOCKET client);
    ~Cilent_Connect();
    bool Server_Proc();
};

class
Server
{
private:
    int retval;                 // 공용변수ㅡㅡ.. 대략 어느함수의 리턴값에 활용
    WSADATA wsa;                // 윈소켓
    SOCKADDR_IN serveraddr;     // 서버소켓의 구조체
    SOCKET listen_sock;         // 대기중인 서버 소켓
    SOCKET client_sock;         // 접속하는 클라이언트의 소켓
    SOCKADDR_IN clientaddr;     // 접속하는 클라이언트의 구조체
    bool Client_Accept();       // 클라이언트 접근시 처리 함수

public:
    Server(char *ip, int port);    // socket 선언에서listen 단계까지 일괄처리단계
    ~Server();                     // 프로그램 종료시 마무리작업
    void Server_Loop();            // 서버루프
};



Server.cpp

#include "Server.h"

// 파일을 모두 받았으면 해당 클라이언트를 종료하는 함수
void Cilent_Connect::Client_Disconnect()
{
    // closesocket()
    closesocket(client_sock);
    printf("클라이언트: IP 주소=%s, 포트번호=%d의접속이끊김\n",
        inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
}

// 개별 클라이언트 에게 워드보내기
bool Cilent_Connect::Send_Proc()
{
    retval = send(client_sock, (char *)&buf, BUFSIZE, 0);
    if(retval == SOCKET_ERROR)
    {
        err_display("send()");
        closesocket(client_sock);
        return false;
    }        
    return true;
}

// 개별 클라이언트의 워드 받기
bool Cilent_Connect::Recv_Proc()
{
    // 대화 워드 받기
    ZeroMemory(buf, BUFSIZE);
    retval = recvn(client_sock, buf, BUFSIZE, 0);
    if(retval == SOCKET_ERROR)
    {
        err_display("recv()");
        closesocket(client_sock);
        return false;
    }
    printf("[Client IP = %s, port = %d] : %s\n",inet_ntoa(clientaddr.sin_addr),
                                               ntohs(clientaddr.sin_port), buf);
    return true;
}

// 초기화 쓰레드로부터 값 가져오기
Cilent_Connect::Cilent_Connect(SOCKET client)
{
    client_sock = client;
    int addrlen = sizeof(clientaddr);
    getpeername(client_sock, (SOCKADDR*)&clientaddr, &addrlen);
}

// 쓰레드종료시..
Cilent_Connect::~Cilent_Connect()
{
    Client_Disconnect();        
}

// 쓰레드 루트
bool Cilent_Connect::Server_Proc()
{
    while(1)
    {
        if(!Recv_Proc())            
            break;
        if(!Send_Proc())    
            break;
    }
    cout << "접속이종료됨." << endl;
    return true;
}

// 5. 개별 클라이언트 클래스호출... 정의는 해더파일 참고
DWORD WINAPI ProcessClient(LPVOID arg)
{
    Cilent_Connect Idle((SOCKET)arg);
    Idle.Server_Proc();
    return 0;
}

// 4.
bool Server::Client_Accept()            
{
    int addrlen;        // 의미없음ㅡㅡ.. 단순주소길이저장변수
    addrlen = sizeof(clientaddr);
    client_sock = accept(listen_sock, (SOCKADDR *)&clientaddr, &addrlen);
    if(client_sock == INVALID_SOCKET)
    {
        err_display("accept()");
        return false;
    }
    HANDLE hThread;
    DWORD ThreadId;
    printf("\n클라이언트접속: IP 주소=%s, 포트번호=%d\n",
        inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
    // 스레드생성
    hThread = CreateThread(NULL, 0, ProcessClient, (LPVOID)client_sock, 0,
                          ThreadId);
    if(hThread == NULL)
        printf("[오류] 스레드생성실패!\n");
    else
        CloseHandle(hThread);
    return true;
}

// 2. socket 선언에서listen 단계까지일괄처리단계
Server::Server(char *ip, int port)        // IP주소와port번호는따로입력받는다.
{
    cout << "******** 서버프로그램시작********\n";
    cout << "멀티스레드를 적용해서 여러 클라이언트의 말을 되돌려 주는 서버\n";
     
    // 윈속초기화
    if(WSAStartup(MAKEWORD(2,2), &wsa) != 0)
        exit(1);
   
    // 소켓정의, TCP사용임
    listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if(listen_sock == INVALID_SOCKET) err_quit("socket()");    
   
    // bind()        
    ZeroMemory(&serveraddr, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(port);
    serveraddr.sin_addr.s_addr = inet_addr(ip);
    retval = bind(listen_sock, (SOCKADDR *)&serveraddr, sizeof(serveraddr));
    if(retval == SOCKET_ERROR) err_quit("bind()");
 
    // listen()
    retval = listen(listen_sock, SOMAXCONN);
    if(retval == SOCKET_ERROR) err_quit("listen()");
}

// 프로그램 종료시 마무리작업
Server::~Server()
{
    closesocket(listen_sock);        // closesocket()
    WSACleanup();                // 윈속종료
}

// 3. 프로그램루프
void Server::Server_Loop()
{
    while(1)
    {
        if(!Client_Accept())        // accept 처리
            continue;
    }
}

// 1. 서버의 IP와 들어올 Port 넣어줌.
int main()
{
    Server process("127.0.0.1",9000);
    process.Server_Loop();
    return 0;
}


음.. 그러면 이 다중 클라이언트의 개별적인 소통 하는건 언제 쓰일까요?

간단히 생각하면 로그인 서버를 예로 들 수 있겠네요.

각각 클라이언트로부터 IP/Password 를 입력 받아 그것을 DB로부터 검색 및 그 결과를 클라이언트에 돌려주는 작업이죠.


그보다 위의 소스는 이해가 되시나요?

Main 함수부터 따라가시면…

  1. Server Class 를 생성 하고, 생성자에 초기값으로 IP, Port를 넘겨 서버의 listen 단계까지 처리합니다.
  2. 그리고 어느 클라이언트로부터 accept 이 들어오면 그 부분에서 쓰레드를 나눠서 만든 쓰레드로 클라이언트와 네트워크 처리 (send, recv)를 하고 원래 루트는 계속 listen 단계를 넣어 다른 클라이언트의 접속을 기다립니다.

클래스로 하다보니 아마 보기 여려울꺼라 생각됩니다.

이 프로그램에 대한 클라이언트 소스는 아래와 같습니다.

 

Client.h

#include <iostream>

#include "NetworkUtil.h"

 

#define BUFSIZE 4096

 

using namespace std;
 

class Client

{

private:

        int retval;                           // 여러통신간리턴값을저장하는변수

        WSADATA wsa;                          // 윈속

        SOCKET sock;                          // 클라이언트소켓

        SOCKADDR_IN serveraddr;               // 소켓구조체

 

        char buf[BUFSIZE];                    // 보낼 Text

 

        void Send_Proc();                     // send 처리

        void Recv_Proc();                     // recv 처리

 

public:

        Client();                             // 파일클라이언트connect 작업까지처리

        ~Client();                            // 기타소켓종료등. 마무리

        void ClientProc();            // 구동부분

};

 

 

 

Client.cpp

#include "Client.h"

 

void Client::Send_Proc()

{

        ZeroMemory(buf, BUFSIZE);

        cout << "보낼내용";

        cin >> buf;

              

        retval = send(sock, buf, BUFSIZE, 0);

        if(retval == SOCKET_ERROR) err_quit("send()");

}

 

void Client::Recv_Proc()

{

        ZeroMemory(buf, BUFSIZE);

       

        retval = recvn(sock, (char *)&buf, BUFSIZE, 0);

        if(retval == SOCKET_ERROR) err_quit("recv()");

 

        cout << "서버로부터받은메시지: " << buf << endl;

}

 

// 2.

// connet 까지일괄처리

// 아래define을주석처리하면ip, port를입력할수있는소스로바뀝니다

#define test

Client::Client()

{

        // 윈속초기화

        if(WSAStartup(MAKEWORD(2,2), &wsa) != 0)

               exit(1);

 

        // socket()

        sock = socket(AF_INET, SOCK_STREAM, 0);

        if(sock == INVALID_SOCKET) err_quit("socket()");    

 

#ifdef test

        char ip[20] = "127.0.0.1";

        int port = 9000;

#else

        char ip[20];

        int port;

        printf("접속하려는서버의IP주소를대시오: ");

        scanf("%s",ip);

        printf("접속하려는서버의Port를대시오: ");

        scanf("%d",&port);

#endif

 

        // connect()  

        ZeroMemory(&serveraddr, sizeof(serveraddr));

        serveraddr.sin_family = AF_INET;

        serveraddr.sin_port = htons(port);

        serveraddr.sin_addr.s_addr = inet_addr(ip);

        retval = connect(sock, (SOCKADDR *)&serveraddr, sizeof(serveraddr));

        if(retval == SOCKET_ERROR) err_quit("connect()");

}

 

Client::~Client()

{

        closesocket(sock);            // closesocket()

        WSACleanup();                 // 윈속종료

}

 

// 3. 기본루트

void Client::ClientProc()

{

        while(1)

        {

               Send_Proc();

               Recv_Proc();

        }

}

 

// 1.

int main()

{

        Client Client;

        Client.ClientProc();

        return 0;

}

 

 

'리눅스 서버에 대해서' 카테고리의 다른 글

Epoll 채팅 서버 소스  (0) 2008.05.25
Select 채팅 서버  (0) 2008.05.25
밑의 epoll 예제가 좀 복잡해서 좀 간단하게 수정함  (0) 2008.03.29
epoll 서버 프로그래밍  (0) 2008.03.29
겜서버 숙제  (0) 2008.03.24
쿨러 제작기에 앞서 사건의 계기를 올리겠습니다.

제대한후 □P社의 노트북을 샀었던 저는
 산지 9개월만에 화면이 나가버려 A/S 신청 1달만에 새 노트북을 보상 판매 조건으로
가져올수 있었습니다... (멍.................... 78만원 주고 산걸 9개월후 35만원주고 보상판매 된거니.. 이거 뭐..)

뭔가 옵션이 바뀐건 HDD 100 -> 120GB, 정도.. 그걸로 봐달라곤 하지만..
1달 동안 노트북 없이 작업했던 나날은 지옥이었죠.

하여간 이번엔 좀 조심히 쓰자 하고 쓰던 어느날...

사용자 삽입 이미지

이게 뭐야!? orz

A/S를 작년 9월에 했는데 저 현상이 올해 5월에 났으니... 딱 9개월 만이더군요.

아무래도 H●노트북엔 9개월 시한폭탄이 있는거 같다고 생각합니다.

거기에 한술 더떠서...
사용자 삽입 이미지

이건 뭥미... orz
아답터 끊김... 그것도 상당히 애매한 부분...
아답터를 뜯어볼려고 했지만.. 잘 안될꺼 같아 그냥 남은 부분 피봇 다 벗기고

군시절 통신병때의 야전선 까기 스킬을 발동시키고 말았습니다...
사용자 삽입 이미지
하지만.. 이거.. 묶은곳이 워낚 짧아서 언제 빠질지 모르는 아슬아슬한 상황..

일단 저 위의 현상을 해결하기 위해...
비스타를 깔았습니다.

이상하게 비스타는 저렇게 화면이 나가지는 않는것이.. 드라이버 이상인건지...?_?
그런 상태에서.. 작업(좀 몇몇 프로그래밍 루프)를 돌려 본 결과입니다.
사용자 삽입 이미지
음... 사진을 축소하다 보니 좀 안보이는거 같아서 확대 사진..
사용자 삽입 이미지
cpu 온도가 무려 80~81도에서 놀고 있었습니다...

렘.. 2GB인데 A/S 당시 원래 2GB로 업그레이드 했던 렘을 그쪽에서 가져올때 1.5GB로 꼿아서 돌려주지 않나..
따지니까 2GB로 주긴 했는데.. 렘 클럭이 좀 다른거 같은지.. 인식이 2GB가 되는거 봐선 이상이 없는거 같은데
왠만해선 1GB램을 넘기는일이 없습니다 (지금 1.2GB는 1GB + 비디오램 256MB 일껍니다 ..)
..... 정말 생각할수록 괘씸한 H● A/S orz

하지만 모든일에 대해 시비걸면 끝이 없으므로 여기선 어떻게든 해결할 방법을 모색해야 했습니다.
그래서 만든것이 USB 쿨러.. (.. 서장이 넘 길어~!)

우선 준비물은
1. 니퍼 (이건 만능 도구 이것만 있으면 연장통이 필요 없다고 생각합니다)
2. 전기 테잎
3. 80mm 쿨러팬
4. 안쓰는 usb 케이블
5. 못쓰는 CD 같은거 하나
6. 드릴(옵션)

참고로 만들려고 하는건
사용자 삽입 이미지
이런거로서...
노트북 배기구의 열을 강제로 빼주는 쿨러죠..
시중에선 만원정도에 구입 가능 합니다... 평가는 잘 모르겠습니다만.
뭔가 힘이 약하다는 평가가 많은거 같습니다..

어쨋든.. 헝그리 정신을 발휘해서, 재활용 측면에서 만들어 봅시다.

우선.. 옛컴에서 때어낸 쿨러와 usb 케이블입니다.
당연히 먼지가 많이 쌓여있기때문에 씻어내는 편이 좋겠죠
사용자 삽입 이미지
usb 케이블은 휴대폰 살때 준건데... 전원이 안들어오는 단순 데이터 케이블 입니다..
처음엔 전원선이 없으면 어떻게 하나 했는데...

되더군요.. 전원...... 아마 24pin 말단 부분의 전원 선만 끊어놓은거 같더군요.. (이 무슨 비효율적인 생산 ㅡㅡ..)

아마 여기까지만 하셔도 이 이후로는 다른 분야 응용하고 싶다는 욕구가 많이 드실겁니다..
예를들어 선풍기라던가 선풍기라던가 선풍기라던가.. ^^..

사용자 삽입 이미지
여기서 주의할점은... 검은선과 빨간선... 빨간게 + 이고 검은게 - 인가... 하여간 극을 맞춰줘야 합니다

처음엔 반대로 바람 불게 하려고 극을 바꿔서 달았다가 모터가 안도는걸 보고 고장난게 아닌가 삽질했답니다.
사용자 삽입 이미지
대충 피복으로 감싼다음에
이제 밑에 CD를 드릴을 사용해서 구멍을 뚫은뒤에 나사로 고정합니다.

박을떄.. 드릴을 사용했지만, 드라이버로 해도 될꺼같습니다..
(고도의 집중력이 필요하고 CD가 잘 부서져서 추천하진 않습니다..)
사용자 삽입 이미지

통풍은 전선이 윗쪽이 있는 윗면을 향해 붑니다

그리고 사진에 보듯 옆구리를 잘라놓은건 저쪽으로 노트북 통풍구에 붙일생각이므로 알맞게 잘라냈습니다.
사용자 삽입 이미지
이건 플래쉬를 써서 셔터 속도가 1/200 초 정도... 멈춰있는거 같지만 사실 뱅뱅 돌고 있는 상태입니다


그리고 이것이 현재 사용하고 있는 사진입니다.
노트북 통풍구 뒤에다가 붙여서 돌려보고 있는 사진이긴한데 어두워서 잘 안보이는 군요;;;
◎P의 노트북은 통풍구가 왼쪽 뒷면에 있습니다.
사용자 삽입 이미지


온도가.. 약 75도로 됩니다
아직 높다고 생각됩니다만..... orz 약 5,6도 떨어트렸네요;;
사용자 삽입 이미지

음.. 잡설로..
노트북은 조금 비싸더라도 온도가 아무리 올라도 60도 가량, A/S 보장이 되는걸로 사는것이 좋다고 봅니다

코드 최적화는 상당히 어려운 분야이고 그 내용도 역시 어렵다.

사실 어렵다기 보다는 눈에 잘 띄지 않는 내용이라서 생소하다고 보는 것이 더 맞을런지도 모르겠다. 예전에 386 PC에서 MS C/C++ 7.0 또는 볼랜드 C++ 3.1/4.0 가지고 프로그래밍할 때는 자연스럽게 다양한 컴파일러 최적화 옵션들을 요리조리 다루면서 설정을 했던 기억이 난다. 글로벌 최적화, 함수 호출 최적화, 분기 최적화, 루프 최적화, 등등... 최적화 옵션을 어떻게 설정하느냐에 따라 프로그램의 크기나 체감 실행 속도가 많이 차이가 났다. 요즘은 워낙에 CPU가 빨라서 최적화 옵션의 유용성이 많이 감소된 감이 없지 않다. 그저 디버그 빌드냐 릴리즈 빌드냐의 선택만을 한다.

 

그러나 임베디드 분야에서는 최적화의 요구가 필수적이다.

포스트 PC급의 임베디드 시스템에서는 CPU의 성능(보통 수백 Mhz의 동작 클럭)이나 탑재되는 메모리의 용량(보통 256MB 이상) 등이 상대적으로 여유롭기 때문에 컴파일러가 제공하는 최적화 정도만으로도 별 문제가 없을 수 있다. 그러나 임베디드 프로세서는 이런 포스트 PC급만 있는게 아니다. 8051, PIC, AVR 같은 마이크로 컨트롤러들이 여전히 많이 사용되고 있다. 이런 마이크로 컨트롤러들은 기껏해야 수십 Mhz의 동작 클럭으로 작동하며 내장된 메모리의 크기도 16KB~128KB 정도로 제한되어 있다. 요즘처럼 수백 MB의 메모리를 갖춘 PC에서 프로그램을 개발하는 사람들에게는 수십 KB의 메모리에서 프로그램을 개발해야 된다는 것이 상당한 압박으로 느껴질 수도 있다. 이러한 하드웨어적인 환경의 제약으로 인해 컴파일러 수준의 코드 최적화는 물론이고 소스 코드 수준에서도 최적의 소스 코드를 작성할 필요가 있다.

 

일반적으로 컴파일러가 제공하는 최적화의 종류는 크기 최적화와 속도 최적화의 두 부류로 나눠볼 수 있다. 임베디드 시스템 개발에서는 이 중에 크기 최적화를 통해 작은 크기의 실행 파일을 생성하는 것이 더 유리하다. 속도 향상은 알고리즘의 개선이나 소스 코드 튜닝을 통해 상당 부분 개선할 수 있는 여지가 있다. 또한 개발 툴에서 제공하는 프로파일러 등을 이용하면 속도 개선이 필요한 부분을 선정하는데 많은 도움을 받을 수 있다. 그러나 실행 파일의 크기를 소스 코드 레벨에서 수동으로 줄이기는 사실상 매우 어렵고 한계가 있다.

 

실행 시간을 줄이는 최적화

 

인라인 함수: C++에서는 모든 함수에 inline 키워드를 선언할 수 있다. 인라인 함수는 마치 매크로 확장처럼 함수 호출이 함수 자체의 구현 코드로 대체된다. 일반적인 함수 호출에는 리턴 어드레스와 파라미터들이 스택에 푸시되고 팝되는 과정이 수반되지만 인라인 함수가 사용되면 호출에 따른 이런 부수적인 처리들이 생략된다. 그러나 인라인 함수는 사용(호출)되는 회수에 비례해서 실행 파일의 크기가 늘어난다.

 

참조 테이블: 컴파일러의 최적화 옵션에 따라 달라질 수도 있지만 일반적으로 switch 문의 case 문들은 어셈블러의 비교 명령과 점프(분기) 명령의 연속으로 구현된다. 각 case 문의 발생 빈도가 다르다면 상당한 비효율이 내재될 수 있다. 그러므로 상대적으로 발생 빈도가 높은 case 문을 선두에 위치시키고 빈도가 낮은 case 문을 후미에 배치시킨다. 만약 각 case 문의 상대적인 발생 빈도를 가늠하기 어려운 상황이라면 switch 문 전체를 함수 포인터 테이블로 바꾸어 단 한 번의 간접 함수 호출로 처리하는 것이 바람직하다. 예전에 볼랜드 C++ 컴파일러가 이런 방식으로 switch 문을 컴파일 했던 것으로 기억한다.

 

인라인 어셈블리어: 대부분의 C/C++ 컴파일러들은 syntax는 다소 다를 수 있지만 C/C++ 소스 코드 중간에 어셈블리어를 포함시킬 수 있는 방법을 제공한다. 이런 방법을 통해 별도의 어셈블러를 구동하지 않고도 C/C++ 함수 내에서 어셈블리어의 장점을 활용할 수 있다. 메모리 맵 I/O 장치가 아닌 포트 I/O 장치를 제어하기 위해서는 이런 인라인 어셈블리어가 필수다. 또한 이미지 필터링처럼 루프 구조를 통한 픽셀 작업 등의 경우에 레지스터의 활용을 극대화할 수 있는 어셈블리어가 실행 시간을 몇 배 이상 향상시킬 수 있다.

 

레지스터 변수: 지역 변수를 선언할 때 register 키워드를 추가하면 해당 변수는 스택에 할당되지 않고 레지스터에 할당된다. 그러나 CPU의 범용 레지스터 수에는 제한이 있기 때문에 register로 선언됐다고 모든 변수가 레지스터에 할당될 수 있는 것은 아니다. 일반적으로 RISC 계열의 CPU들이 더 많은 범용 레지스터를 가지고 있으므로 register 변수가 더 많이 할당될 수 있다. 동시에 지원되는 register 변수의 최대 수는 대상 CPU와 컴파일러마다 다를 수 있으므로 컴파일러의 리스트 파일을 생성하여 직접 확인해 볼 필요가 있다. 루프 카운터 변수처럼 함수 내에서 자주 참조되는 변수를 register 키워드로 선언하면 실행 시간 단축에 도움이 된다.

 

전역 변수: 함수에 파라미터를 전달하는 것보다 전역 변수를 사용하는 것이 함수 호출에 따른 오버헤드를 줄일 수 있어 효율적이다. 그러나 전역 변수의 사용은 일반적인 소프트웨어 개발론에서 극단적으로 피해야될 방법이다. 함수 단위의 모듈화를 불가능하게 만들며 재진입 가능한 함수의 구현 역시 불가능해진다. 절충안은 클래스의 구현을 통해 전역 변수의 효과를 얻는 방법이다. 공유되는 데이터를 클래스 안으로 숨기고 이 데이터에 접근해야되는 관련 함수들을 클래스 메소드로 구현한다.

 

폴링: 이벤트 드리븐 방식에 익숙해져 있는 사람들에게 폴링 방식은 상당히 비효율적인 방식으로 치부되곤 한다. 임베디드 시스템에서 이벤트 드리븐은 궁극적으로 인터럽트를 의미한다. 그런데 인터럽트 자체의 CPU 오버헤드로 인해 프로그램이 비효율적이 되는 경우가 있을 수 있다. 즉, 모니터해야 되는 인터럽트의 발생이 매우 빈번하여 그 발생 간격이 인터럽트 지연 시간(latency)에 육박하는 경우라면 차라리 폴링 방식이 더 효율적이다.

 

정수 연산: 아주 예외적인 경우가 아닌 한, 거의 모든 경우의 부동소수점 연산은 정수(고정소수점) 연산으로 대체 가능하다. CPU에 매스코프로세서가 내장된 경우가 아니라면 부동소수점과 고정소수점의 성능 차이는 수십에서 많게는 수백배까지 차이가 날 수 있다. 설령 매스코프로세서가 내장된 경우라하더라도 일반적으로(대상 CPU마다 조금식 다를 수 있다) 고정소수점 연산이 몇 배 정도 더 빠르다. 아무 생각없이 double, float 타입을 사용하는 것은 무조건 피해야 되며 고정소수점 연산으로의 변환 가능성을 심도있게 고려해야 한다. 본인의 글 [알고리즘]고정소수점(fixed point) 연산을 참조하자.

 

코드 크기를 줄이는 최적화

 

"사용하지 않는 코드 제거" 최적화: 컴파일러에 의해 수행되는 최적화 기법들 중의 하나로 C/C++ 언어의 volatile 키워드의 용도를 설명할 때 자주 설명되는 기법이다. 이 최적화 기법에 의해 전후 문맥상 없어도 되는 코드들이 자동으로 제거된다. 이런 최적화를 통해 코드의 크기를 줄일 수 있지만 컴파일러는 코드의 syntax만을 보고 semantics를 보지 못한다는데 문제가 있다. 예를 들면 일정 시간의 지연 효과를 위해 더미 코드를 반복시키는 루틴을 생각할 수 있다. 물론 시간 지연을 위해 더 좋은 방법이 있을 수도 있지만 펌웨어 수준의 임베디드 프로그래밍에서는 종종 이런 더미 코드 루틴이 사용된다. 컴파일러는 이런 더미 코드가 전후 문맥상 결과 값에 아무 영향을 미치지 않기 때문에 제거해 버리는 최적화를 수행한다. 또 아래의 예제 코드같은 경우에 "*pCtrl"의 값이 세번째 라인에서 변경될 때까지 사용되지 않기 때문에 첫번째 라인을 최적화 과정에서 제거해 버린다.

    *pCtrl = DISABLE;

    *pData = 'A';

    *pCtrl = ENABLE;

그러나 만약에 pCtrl이 메모리 맵 방식의 장치 레지스터에 대한 포인터라면 문제가 심각해진다. 첫번째 라인과 세번째 라인 모두 장치를 구동시키는 분명한 동작 코드들이기 때문에 최적화 과정에서 제거가 되면 오동작이 유발될 수 밖에 없다. 최적화로 인한 이런 문제를 방지하기 위해 메모리 맵 방식의 모든 포인터 변수, 쓰레드 간에 또는 스레드와 ISR 간에 공유되는 모든 공유 데이터(변수), 그리고 논리적으로(semantics상으로) 반드시 수행되어야 하는 루틴의 변수들에 대해 volatile 키워드로 선언을 해야한다. volatile 키워드로 선언된 변수에 대해서는 컴파일러가 "사용되지 않는 코드 제거" 최적화를 수행하지 않는다.

 

표준 라이브러리를 사용하지 않는다: 코드 크기를 줄이기 위한 가장 간단한 방법은 표준 C/C++ 라이브러리를 사용하지 않는 것이다. 대부분의 표준 C 라이브러리 함수들은 발생 가능한 모든 경우에 대비하도록 구현되어 있기 때문에 상당히 크기가 크다. 예를 들어 sprintf, sscanf 류의 함수는 다양한 타입의 포맷팅을 처리하는 기능을 가지고 있다. 만일 프로그램에서 몇 가지 정형화된 타입의 포맷팅만이 사용된다면(거의 대부분의 프로그램이 그렇다) 필요한 포맷팅만을 처리하는 함수를 직접 구현하는 것이 코드 크기를 줄이는데 큰 도움이 된다. 또한 C++의 STL 같은 경우는 템플리트 기반으로 구현되어 있기 때문에 아무 생각없이 사용하다가는 코드 크기가 엄청나게 증가하게 된다. 표준 라이브러리의 소스 코드는 어렵지 않게 구할 수 있다. 필요없는 기능을 잘라낸 스몰 라이브러리를 구축하는 것이 적어도 임베디드 프로그래밍에 있어서는 의미없는 일은 아니다.

 

기본 워드 크기: C/C++에서 int 타입은 유일하게 플랫폼 디펜던트한 데이터 타입이다. 즉, 사용되는 프로세서에 따라 16비트, 32비트, 64비트 크기로 가변한다. ANSI C/C++ 표준에서 int 타입은 프로세서의 기본 워드 크기를 사용하도록 규정하고 있다. 반면에 short, long 같은 타입은 플랫폼에 상관없이 각각 16비트, 32비트로 크기가 고정되어 있는 타입이다. 어셈블리어 레벨에서는 프로세서의 레지스터와 동일한 크기의 데이터를 다룰 때 가장 적은 코드가 사용된다. 프로세서의 기본 워드 크기라 함은 레지스터의 데이터 크기(비트수)를 말하며 C/C++에서 int 타입의 크기가 이에 해당한다. 즉, 프로그램에서 short나 long을 사용하게 되면 프로세서에 따라서(기본 워드 크기와 다를 경우) 부수적인 코드들이 더 추가될 수 있다. 일례로 "long lVal = n;" 같은 단순한 할당문이 32비트 워드 크기의 프로세서에서는 두 개의 어셈블러 명령만으로 처리될 수 있지만 16비트 워드 크기의 프로세서에서는 4개 이상의 어셈블러 명령을 사용해야만 처리가 된다. "lVal += n;" 같은 연산문이라면 6개 이상의 어셈블러 명령이 사용될 수도 있다. 꼭 short나 long 타입을 써야만 되는 경우가 아니라면 int 타입을 일관되게 사용함으로써 코드 크기를 최적화할 수 있다.

 

goto 문: goto 문은 전역 변수와 함께 일반적으로 사용하지 말아야 될 방법들이다. 스파게티 로직이 뭔지를 아는 프로그래머라면 goto 문의 폐해를 잘 알 것이다. 그러나 크지않은 함수 단위의 블럭 내에서는 간간히 요긴하게 사용될 수 있다. goto에서 다시 goto로 연결되는 구조는 바람직하지 않지만 여러 겹으로 중첩된 제어문에서 한번에 빠져 나오기 위해 goto를 사용하는 것은 매우 유용하다. 또한 그렇게 하는 것이 정상적인 제어 구조를 완벽하게 구현하는 것보다 종종 더 적은 크기의 코드를 사용한다.

 

램 사용량 줄이기

 

앞서 설명한 코드 크기를 줄이는 최적화는 결국 롬의 사용량을 줄이기 위한 방법이다. 그런데 임베디드 프로세서에서는 롬뿐만 아니라 램도 알뜰하게 사용해야 될 매우 제한된 자원이다. 프로그램에서 램은 전역 데이터, 스택 그리고 동적 메모리 할당을 위한 힙의 용도로 사용되므로 이들의 사용량을 줄여야 한다.

 

전역 데이터 줄이기: 프로그램이 실행되는 동안 값이 바뀌지 않는 전역 데이터들은 const 키워드를 추가하여 상수로 선언한다. 대부분의 C/C++ 컴파일러들은 상수로 선언된 데이터들을 일반 데이터들과는 다른 세그먼트에 위치시켜 링커/로케이터로 하여금 롬의 주소 영역으로 배치하도록 만든다.

 

스택 줄이기: 프로그램에서 사용될 스택의 주소와 크기는 링킹 과정에서 파라미터로 링커에게 전달된다. 스택의 크기를 줄이려면 프로그램에서 사용하는 스택의 최대 사용량을 먼저 알아내야 한다. 스택 영역을 임의의 초기값(예를 들면 0xCD)으로 채우고 나서 일정 시간 동안 일반 조건과 최악의 조건 두 가지 경우로 프로그램을 동작시킨다. 디버거를 통해 스택 영역의 변경된 값을 확인하면 최대 스택 사용량을 예측할 수 있다. 이런 예측이 의미를 갖기 위해서는 테스트가 충분히 길어야 하면 동작 가능한 모든 시나리오를 시험해야만 한다. 이렇게 예측된 최대 스택 사용량에 약간의 여분을 더 추가하여 스택 크기를 설정하는 것이 안전하다. 특히 RTOS를 사용하는 경우, 태스크마다 별도의 스택이 할당되므로 태스크 단위로 스택 사용량 예측을 따로 해야 된다. 이 태스크 단위의 스택은 태스크 내의 함수 호출과 지역변수 그리고 ISR(Interrupt Service Routine)을 위해 사용된다. 태스크의 수를 줄이거나 모든 ISR에 대해 독립된 하나의 스택을 별도로 운영함(실제로 이렇게 ISR 스택을 따로 운영하는 RTOS도 있다)으로써 스택 사용량을 많이 줄일 수 있다.

 

힙 사용량 줄이기: 힙 영역은 전체 램 영역에서 전역 데이터와 스택 영역을 제외한 나머지 영역으로 제한된다. 그러므로 프로그램에서 사용되는 전역 데이터나 스택의 사용량이 커지면 커질 수록 힙 영역은 작아질 수밖에 없다. 프로그램에서 malloc(), new 등으로 할당받는 동적 메모리는 바로 힙 영역에서 할당된다. 앞이 두 방법을 통해 힙의 크기를 최대로 확보했음에도 불구하고 malloc()과 new의 결과가 NULL인 경우가 발생한다면 동적 메모리의 사용량을 줄이도록 프로그램을 튜닝하는 수밖에 없다.

 

C++의 단점을 피하는 방법

 

C++이 처음 소개되던 시절에 C++은 순수 C에 비해 컴파일된 코드의 크기는 커지고 속도는 느려진다고 알려진 적이 있었다. 그 때는 그런 면도 있었다. 그러나 인간사 모든게 다 그렇듯 C++도 잘 쓰면 장점은 유지하면서 성능상의 단점은 배제하거나 타협할 수가 있다. 골라쓰는 재미 바로 그것이다.

 

class 정의: C++에서 struct와 class는 컴파일된 결과물(코드)을 놓고 봤을 때 동일하다. 멤버 데이터, 멤버 함수, 그리고 public, protected, private 등의 키워드는 컴파일 시에 사용되는 syntax일뿐 성능상의 어떤 단점도 없다. 안쓸 이유가 있나?

 

디폴트 파라미터: 디폴트 파라미터는 주로 이미 만들어진 함수의 기능을 확장할 때 요긴하게 사용된다. 함수를 호출할 때 생략된 파라미터는 컴파일 시에 자동적으로 디폴트 값이 추가된다. 단순히 모든 파라미터를 다 사용한 함수 호출과 성능상 100% 동일하다.

 

함수 오버로딩: 함수 이름은 같지만 전달되는 파라미터(즉, 프로토타입)의 개수와 타입이 다른 함수들을 오버로딩 함수라고 말한다. 소스코드 상에서는 함수 이름이 같지만 컴파일러는 프로토타입에 따라 서로 다른 이름을 부여한다. 일반 함수와 성능상 100% 동일하다.

 

연산자 오버로딩: C언어의 연산자(+, -, *, /, =, ++, --, ==, !=, <, >, <=, >=, 등)를 새로운 데이터 타입(즉, 클래스)에 사용할 수 있도록 재정의하는 것이다. 연산자를 사용한다는 표기법만 다를 뿐 함수 오버로딩과 개념과 성능상 동일하다.

 

생성자와 소멸자: 클래스 객체가 선언될 때 그리고 선언된 객체가 스코프(중괄호 { }로 묶인 영역)를 벗어날 때 눈에 보이지는 않지만 생성자와 소멸자를 호출하는 코드를 컴파일러가 자동으로 추가한다. 즉, 묵시적으로 호출되는 함수다. 그래서 루프 구조(for, while 등)의 로컬 스코프 내에서 임시 변수로 클래스 객체를 사용하게 되면 루프를 매번 돌 때마다 생성자와 소멸자가 호출되는 성능상의 오버헤드가 생길 수 있다. 가능하면 클래스 객체는 루프 구조 밖에 선언하여 루프 내에서는 재사용되도록 하는 것이 상책이다. 그러나 이런 약간의 단점에도 불구하고 생성자와 소멸자를 원칙적으로 구현하는 노력만 한다면 초기화 문제로 인한 버그나 메모리 누수 같은 문제를 원천적으로 방지하는 매우 강력한 부수 효과를 얻을 수 있다.

 

가상함수: 가상함수는 C++을 객체지향적 언어로 만들어 주는 핵심이다. 가상함수에 대한 호출은 컴파일 시에 static하게 결정되는 것이 아니라 런타임 시에 테이블 룩업을 통해 다이나믹하게 찾아가도록 코드가 생성된다. 즉, 매번 함수를 호출하기 전에 테이블 룩업이라는 과정을 거치게 되므로 성능상의 오버헤드가 있다. 그러나 클래스에 가상함수가 선언되더라도 다른 일반 멤버 함수에 대한 호출은 성능상의 어떤 영향도 받지 않는다. 과하지 않게 꼭 필요한 경우만 가상함수로 설계하는 센스가 필요하다.

 

절대적으로 피할 것: 템플리트, 예외처리(try, catch, throw), 런타임 타입확인(RTTI). 솔직히 말해 이 3가지는 없어도 견고한 프로그램을 만드는데 아무 지장이 없다. 과유불급이란 말이 이 3가지에 딱 어울리는 말이다. 템플리트는 선언하는 데이터 타입의 수에 비례해서 코드 크기가 딱 정비례해서 늘어난다. 예외처리와 RTTI는 코드 크기를 증가시킬뿐만 아니라 CPU의 성능까지도 많이 잡아 먹는다. 적어도 임베디드 프로그래밍에서 C++을 사용하겠다면 이 3가지 기능은 거들떠 보지도 말자. 공공의 적이라 하겠다.

 

임베디드 C++ 표준: 간단히 말하면 방대한 C++의 원래 표준에서 객체지향적 언어로서의 특징을 해치지 않고 제거할 수 있는 상당한 부분을 생략한 버전이다. 다중상속, Pure 가상 클래스, RTTI, 예외처리, 템플리트, 네임스페이스, 새로우 방식의 캐스트(const_cast, dynamic_cast, reinterpret_cast, static_cast) 등이 제거됐다. 결과적으로 객체지향적이며 C언어를 포함하고 런타임 오버헤드가 적으며 런타임 라이브러리의 크기가 작은 단순한 C++ 버전이다. 이미 많은 임베디드용 C++들이 임베디드 C++ 표준을 지원하거나 수동으로 개개의 언어적 특징을 금지할 수 있는 방법을 제공한다.

(사실 난 아직 이 버전의 C++을 사용해볼 기회는 없었다. 하지만 어떠랴, 생략된 저 기능들을 거의 사용하지 않고도 10수년간 프로그래밍하는데 아무 불편을 못느끼는 걸...)

 

참고)

[Programming Embedded Systems in C and C++], 1999, O'Reilly, Michael Barr

(2000년, 한빛미디어, 이석주 역)

출처 : Tong - naghun님의 기술자료통

'C/C++언어' 카테고리의 다른 글

Lex와 Yacc의 사용법 강좌  (0) 2008.06.07
고정 소수점 (C++언어 버젼)  (0) 2008.05.09
[알고리즘]고정소수점(fixed point) 연산  (0) 2008.05.09
음 고정소수점 만들기..  (0) 2008.05.09
고정 소수점  (0) 2008.05.09
이 글은 97년 8 / 9월 마이크로소프트웨어 '그래픽 애플리케이션에 제트엔진을 달자 - 고속 그래픽 계산을 위한 고정소수점 연산'을 참고로 일부 추가, 일부 수정한 겁니다.(오타도 있구, 코드도 틀린데가 있더군요)

고속 그래픽 계산을 위한 고정소수점 연산

 

- 고정 소수점 연산이란

 이미지 처리, 3D 렌더링 등 그래픽 연산에서는 아주 많은 부동소수점 연산을 필요로 한다. 대부분의 프로세서는 배정도 부동소수값(double)을 IEEE754라는 규격의 포맷을 사용해 기억장소에 저장하는데, IEEE754는 부동소수를 정수 부분과 지수 부분으로 나누어 기억장소에 저장한다. IEEE754는 전체 62비트 중 첫 번째 비트에는 부호를, 두 번째 비트부터 11개의 비트는 10의 승수부분을, 그리고 나머지는 유효숫자(mantissa)를 저장하는 형태로 되어 있다.(오늘날의 프로세서들은 거의 모두 IEEE 부동소수 형식을 지원한다.) 예를 들어 1234.5678라는 값이 있다면 이 값은 다음과 같은 형태로 바뀐다.

      1.234567 × 10^3

 부동소수값이 들어있는 62비트 중 첫 번째 비트는 양의 부호를 나타내는 0, 두 번째부터 11개의 비트에는 10의 승수인 3, 그리고 나머지 부분에는 유효자리수인 1234567이 들어가게 된다. 이처럼 컴퓨터에서 실수형 숫자를 저장하는 방식은 정수형에 비해 매우 복잡하다. 그러니 실수형 연산이 정수형 연산에 비해 연산속도가 매우 느린 것은 당연하지 않을까?

 그래픽에서 부동소수 계산은 아주 필수적이고 계산량이 많기 때문에 많은 사람들이 보다 빠른 방법으로 부동소수를 처리하는 방법을 고안해 냈다. 그중 대표적인 것이 바로 고정소수점 연산이다. 고정소수점 연산이란 정수형을 이용해 소수의 정수부와 소수부를 저장하도록 하는 수치해법적인 방법이다. 다시 말해 고정소수점 연산이란 실수형 연산을 정수형 타입의 변수를 이용해 연산하는 방법을 말한다.

 

<리스트 1> Clock()을 이용한 수행시간 측정

    //
    // 시간 측정
    //

    #include<stdio.h>
    #include<stdlib.h>
    #include<time.h>

    void main(void)
    {
       clock_t start, finish;
       double duration;

       start = clock();

       // 시간을 측정하고 싶은 작업 !!
       // double a, b, c;
       // for(int i=0; i<6000000; i++)
       //   c = a+b;

       finish = clock();

       duration = (double)(finish-start) / CLOCKS_PER_SEC;
       printf("%2.1f seconds\n", duration);
    }


- 수행 시간을 측정하는 프로파일링

 속도를 빠르게 하려면 계산하는 속도를 측정해야 하므로 프로그램의 연산속도를 측정하기 위한 방법들을 먼저 살펴보자. 프로그램의 수행시간을 측정하기 위해 쓸 수 있는 첫 번째 방법으로 clock() 함수를 이용하는 것이 있다.

 <리스트 1>을 잘 살펴 보자. clock() 함수는 현재 시스템의 클럭을 리턴하는데, 계산시간과 끝난 뒤, 이 클럭의 차이값과 초당 몇 개의 클럭이 지나갔는지를 따져보면 계산하는 동안 얼마의 시간이 흘렀는지 확인할 수 있다. 그러나 PC의  Win32 환경에서 시간 계산은 이론적으로 1/1000초까지 측정이 가능하지만 실제로는 1/18초 단위까지만 가능하다. 또한 윈도우 95나 NT와 같은 멀티태스킹 운영체제에서는 수행할 때마다 약간씩 다르게 측정되기 때문에 이 방법으로는 Win32 환경에서의 수행시간을 정확하게 측정하기 힘들다. 하지만 유닉스와 같은 시스템에서는 상당히 정확하게 계산되어 종종 사용된다.

 이렇게 프로그램 자체에서 시간을 측정하는 방법이 유용할 때도 있지만 보다 정교한 측정을 위해서는 프로파일링(profiling)을 이용하는 것이 좋다. 비주얼 C++에 기본으로 포함돼 있는 프로파일링은 각 함수의 수행시간을 측정해 주는 도구로 전체 프로그램의 어떤 부분에 부하가 많이 걸리는지를 알아낸다. 따라서 전체 시스템의 수행속도를 높이고자 할 때 유용하게 사용된다.

 비주얼 C++ 4.x에서 프로파일링을 사용하려면 프로그램을 컴파일하기 전에 다음과 같은 조치를 취해야 한다.(비주얼 C++ 5.0 이상에서는 Project 메뉴 아래에서 Settings...을 선택하면 된다.)

 ■ Release 모드면 프로파일링이 제대로 되지 않기 때문에 반드시 디버그 모드여야 한다.
 ■ Build/Settings 메뉴에서 Project Settings 다이얼로그, Link 탭을 선택하면 Enable Profiling이 보이는데, 이를 체크하고 컴파일한다.

 그 다음 Tools/Profile 메뉴를 선택하고 'function timming' 라디오 버튼을 선택해 OK를 누른다. 그러면 실제 프로그램이 돌아가면서 각 함수를 수행하는데 경과된 시간을 Output 윈도우에 보여준다.

 

<리스트 2> 각 타입의 사칙연산에 대한 수행시간 측정

    #include <iostream.h>

    template <class T>
    class ClacTime
    {
            T a, b, c;
    public :
        ClacTime() { a = (T)123456, b = (T)123456; }

        void add() { for(register i=0; i<1000000; i++) a+=b; }
        void sub() { for(register i=0; i<1000000; i++) a-=b; }
        void mul() { for(register i=0; i<1000000; i++) a*=b; }
     
        void div() { for(register i=0; i<1000000; i++) a/=b; }
    };

    void main()
    {
        ClacTime <double> DoubleLoop;
        ClacTime <float> FloatLoop;
        ClacTime <int> IntLoop;

        DoubleLoop.add();
        DoubleLoop.sub();
        DoubleLoop.mul();
        DoubleLoop.div();

        FloatLoop.add();
        FloatLoop.sub();
        FloatLoop.mul();
        FloatLoop.div();

        IntLoop.add();
        IntLoop.sub();
        IntLoop.mul();
        IntLoop.div();
    }


 <리스트 2>는 템플릿을 이용해 double, float, int의 타입에 대한 사칙연산을 각각 백만 번씩 수행하는 프로그램이다. 이것을 프로파일링해 보면 정수형과 실수형 연산의 속도차이를 확인할 수 있다.


<표 1> 프로그램의 프로파일링 결과 (숫자는 연산에 걸린 시간, 작을수록 빠름)

Pentium - 166


int

float

double

덧셈

67.5

88.3

251.9

뺄셈

76.5

86.2

280.0

곱셈

111.8

441.1

994.8

나눗셈

307.3

481.1

815.3


 이 프로파일링의 결과 (<표1>)는 펜티엄 166MHz에서 수행한 것이다. 자세히 살펴보면 int와 float 두 타입의 덧셈, 뺄셈은 차이가 거의 없지만 곱셈과 나눗셈은 확연히 차이가 난다는 것을 알 수 있다. 곱셈의 경우 거의 3.5~4배, 나눗셈의 경우 1.5배 정도 정수형 연산이 빠르다. 그리고 double 타입과 비교해 보면 최소 3배에서 곱셈의 경우 무려 9배까지 연산속도의 차이를 보이고 있다.

 수행할 때에 따라 혹은 시스템에 따라 약간의 차이가 있지만 펜티엄 프로세서의 경우 int 형과 float형의 덧셈과 뺄셈 연산은 거의 차이가 없다. 하지만 486의 경우는 float의 덧셈, 뺄셈이 int형의 경우에 비해 확연히 느리다.

 앞서 이야기했듯 고정소수 연산은 정수형을 이용해 실수 연산을 대신하는 방법인데, 이 실험을 통해 알 수 있는 것처럼 부동소수 연산대신 정수연산만 사용하면 약 3~4.5배 정도의 속도 향상을 기대할 수 있다. 구성상의 문제이지만 그보다 훨씬 빠른 속도를 얻을 수도 있다.

(참고로 다음의 표는 PII - 233에서의 프로파일링 결과이다.- float 형의 곱셈의 경우 int 형의 13.5 배, 나눗셈의 경우 거의 3배 정도 차이가 난다.)

Pentium II - 233


int

float

double

덧셈

39.246

38.661

40.044

뺄셈

38.242

38.506

40.400

곱셈

39.156

528.966

528.861

나눗셈

197.004

579.077

574.697


- 고정소수점 연산과 포맷

 예를 들어 32비트 정수형 변수가 있다면 위쪽의 16비트를 정수 부분, 아래쪽 16비트를 소수부분을 가정해 보자. <그림 1>과 같이 생각할 수 있는데, 여기서 S는 정수 부분의 비트로, X 부분은 소수점 이하 부분의 비트들로 볼 수 있다. 실제로 소수점이 존재하는 것이 아니라 가상으로 있다고 가정하는 점이라는 것을 잊지 말기 바란다.

 이해를 돕기 위해 10진수 4자리를 정수형으로 사용하는 가상의 기계를 생각해 보자. 예를 들어 처음 두 자리는 정수부분이고 나중 두 자리는 소수 부분이라면 0, 8, 4, 3 이라고 저장된 내용은 8.43을 뜻하게 되는 것이다. 이것을 형식을 부를 때 2.2라는 식으로 부른다. 즉, 처음 두 자리가 정수 부분, 나중 두 자리가 소수 부분이란 뜻이 된다. 그렇다면 4.0은 어떤 뜻이 될까? 소수 부분이 없으니 네 자리 모두 정수 부분이란 뜻이 된다. 즉, 정수형 변수임을 뜻한다. 이런 네 자리수의 사칙연산에 대해 알아보자.

         04.00          05.00
      +  03.00        - 07.00
      --------    ----------
         07.00        - 02.00

    <그림 1> 32비트 정수, 16.16 포맷의 고정소수

    S

    S

    S

    S

    S

    S

    S

    S

    S

    S

    S

    S

    S

    S

    S

    S

    X

    X

    X

    X

    X

    X

    X

    X

    X

    X

    X

    X

    X

    X

    X

    X


 더하기 빼기는 아주 쉽다. 그러나 곱셈과 나눗셈의 경우는 조금 문제가 있다. 즉 소수점이 없기 때문에 곱한 결과의 소수 이하 부분이 확대돼 나타난다(이 문제에 대해서는 뒤에서 좀더 자세하게 다룬다).

      12.34 × 67.89 = 838.10205
      1234 × 6789 = 83810205
      07.00 × 03.00 = 21.0000

 M.N 포맷이면 두 수를 곱한 뒤에 N번만큼 오른쪽으로 이동하면 되는데, 이 때 잘려나가는 값이 생기게 된다.

 서로 다른 포맷 간의 고정소수점 연산에도 적용이 가능하다. 예를 들어 2.30 포맷을 사용하는 사인값에 18.14 포맷과 곱셈을 하면 결과는 20.44 포맷이 된다. 이 결과를 18.14 포맷으로 바꾸려면 뒤부분 30비트만큼의 소수 부분과 정수 부분의 끝부분 2비트를 잘라내야 한다.  

 나눗셈에서는 몫과 나머지를 구해야 한다. 1 나누기 3을 하면 0.333 이지만 정수로 연산을 하면 소수 이하 부분이 버려져서 0이 되고 만다. 별로 바람직한 결과는 아니다. 나누려는 수 1에 0을 붙여 10을 만들고 나서 3으로 나눈 다음 그 결과를 다시 10으로 나눈다. 결과는 3이다. 즉, 나누기를 하기 위해 원래 피연산자보다 확대된 형태를 저장할 수 있어야 한다.

 만일 2.2 포맷의 1을 4.0 포맷의 3으로 나누면 어떻게 될까? 01.00/0003. 이면 00.33이 된다. 이는 고정소수점 연산에서도 실수와 정수 연산은 실수가 된다는 점을 보여준다. 고정소수 숫자를 고정소수로 나누려면, 예를 들어 2자리 소수 부분을 얻으려면 피젯수를 두 번 왼쪽으로 이동시켜야 한다.

      0100/0300 = 0000
      01000/0300 = 0003
      010000/0300 = 0033

 지금까지 설명했던 내용을 정리해 보면 다음과 같다.

 ■ 결과의 자리수는 곱해지는 두 수의 자리수를 더한 것보다 작거나 같다.
 ■ 결과의 소수 자리수는 두 수의 소수 부분 자리수를 더한 것과 같다.
 ■ 정수라는 것은 소수 부분이 없다(4.0고정 소수점).

 32비트 환경이라면 일반적으로 16.16을 사용하겠지만 오버플로우 등을 막고 보다 큰 값을 저장하려면 17.15 나 18.14 포맷을 사용하는 것이 좋다.

 이 외에도 다양한 포맷을 생각해 볼 수 있다. 예를 들어 0과 1 사이의 값을 아주 정밀하게 표시하기 위해 1.31 포맷을 사용할 수도 있다.

 또 하나 고려할 것이 있는데, 64비트 정수를 사용할 수 없는 상황이라면 8.8 이나 8.16 과 같은 포맷도 고려해 볼만하다. 특히 윈도우 3.1과 같은 16비트 환경에서는 유용하게 사용할 수 있다. 그러나 비주얼 C++에서는 __int32와 __int64란 타입을 지원하고 있어 32비트 정수형과 64비트 정수형을 자유롭게 쓸 수 있다.

 이상에서 짤막하게 살펴 본 몇 가지 문제점은 뒤에서 좀 더 자세히 다루도록 하고, 실제 32비트로 만들어진 16.16 포맷을 어떻게 구현하고 사용하는지 알아보자.


-Fixed 타입과 int, double 사이의 변환

 간단하게 다음과 같은 새로운 타입을 지정할 수 있다.

       typedef    __int32   Fixed;

 정수형값을 Fixed 타입으로 바꾸려면 다음과 같이 하면 된다. __int32는 32비트 정수형으로 비주얼 C++에서 기본으로 제공하는 타입이다. int나 long으로 바꾸어 써도 마찬가지다.

      Fixed fix;
      int iValue = 123;

      fix = (Fixed)(iValue << 16);

 실수형값을 fixed로 바꾸려면 16자리만큼 왼쪽으로 이동한다. 즉 65536을 곱한 값이 된다. 실수형의 경우도 마찬가지로 65536을 곱하면 된다. 이때는 시프트 연산을 쓰지 못한다.

      Fixed fix;
      double dValue = 123.0f;

      fix = (Fixed)(dValue * 65536.0);

 거꾸로 Fixed 타입의 정수를 int나 double형으로 바꾸려면 다음과 같이 한다.

      double dValue;
      dValue =  ((double)fix) / 65536.0;


      int iValue;
      iValue = fix >> 16;


- Fixed 타입의 사칙연산

 Fixed 타입 변수의 덧셈과 뺄셈은 int 나 double과 같은 기본적인 타입과 똑같다.

      Fixed fix1, fix2, fix3;
      fix3 = fix1 + fix2;
      fix3 = fix1 - fix2;

 덧셈, 뺄셈은 아무런 문제가 없지만 곱셈의 경우는 좀 다르다.

Fixed fix1, fix2, fix3;
fix3 = fix1 * fix2;

 이 경우 fix1과 fix2는 각각의 수에 65536이 곱해진 수를 다시 곱하므로 비례확대가 일어난다. 이 정수형이 2.2 포맷의 고정소수로 돼 있다면, 덧셈의 경우 12.0 + 4.6은 다음과 같이 표시될 수 있다.

         1200                   12.00
       + 0460               +   4.60
      --------            --------
         1660                   16.60

그러나 곱하기 연산의 경우는 약간의 문제가 있다.

         1200                   12.00
      x 0460                x   4.60
      --------            --------
      552000                   55.2000

 즉, 두 수를 곱하면 결과가 4자리수를 넘으면서(오버플로우), 소수 부분 자리수가 4자리가 된다(이런 현상을 '비례확대'라 한다). 따라서 계산한 뒤에 혹은 계산하기 전에 다음과 같이 소수 부분의 자리를 이동시켜 주어야 한다.

      fix3 = (fix1 * fix2) >> 16;         // ①
      fix3 = (fix1>>16 * fix2);           // ②

 그러나 여기에도 문제가 있다. 이 코드에서 ①의 경우 32비트 정수형을 곱하는 과정에서 오버플로우가 생긴다. 최악의 경우에 32비트 숫자의 곱은 64비트 결과를 만든다(피연산자 둘 중 하나가 1.0보다 크면 항상 오버플로우가 생긴다).

 ② 의 경우는 곱셈을 하기 전에 자리수를 이동시키지만 fix1의 소수 부분을 모두 없애므로 정확성이 감소하게 된다. 정확성을 어느 정도 유지하면서 오버플로우를 막을 수 있도록 fix1, fix2의 두 수를 각각 8비트만큼씩 오른쪽으로 시프트한 후 곱하는 방법을 사용할 수도 있다.

      fix3 = (fix1 >>8) × (fix2>>8);

 그러나 가장 정확한 방법은 32비트 정수형을 곱할 때 64비트 정수형 곱셈을 사용하는 것이다. 64비트 정수형 곱셈 연산은 float보다 2배,double 보다 3배 이상 빠르다.

 나눗셈의 경우도 곱셈의 경우와 비슷하다.

      fix3 = (fix1<<16) / fix2;
      fix3 = (fix1<<8) / (fix2<<8);

 이와 같이 쓸 수 있지만 시프트하는 도중에 fix1에 저장된 정수값의 일부를 잃어버릴 수 있기 때문에 별로 바람직하지 않다. 나눗셈의 경우도 곱셈과 마찬가지로 64비트연산을 사용하는 것이 바람직하다.


- 고정소수점 연산의 정밀도

 고정 소수점 연산을 사용하면 경우에 따라 4배이상 빠른 계산속도를 얻을 수 있다(부동소수점 연산 프로세서가 없는 386DX와 같은 프로세서에서는 11배 이상의 속도 차이를 내기도 한다)는 것을 앞에서 이미 밝힌 바 있다.

 그렇다면 고정소수점 연산의 정밀도는 어떨까? 물론 float, double과 같은 부동소수를 사용하는 것에 비해 고정소수점 연산은 계산의 정밀도를 떨어뜨린다. 그러나 결론부터 이야기하자면 고정소수점 연산의 정밀도는 그다지 우려할 만한 것이 아니다. 오히려 정밀도를 높일 수 있다.

 앞에서 고정 소수점을 표현하기 위해 정수와 소수 부분의 자리수를 배정하여 M.N이라는 포맷을 사용했다. M은 정수 부분, N은 소수 이하 부부능ㄹ 나타내는 데 사용되는 비트수이며, int가 32비트인 기계의 경우 M과 N을 더한수는 항상 32가 되게 한다. 16.16포맷을 기준으로 고정소수점의 정밀도에 대해 생각해 보자. 16.16포맷은 정수부분이 16비트, 소수 부분이 16비트로 각각 할당되어 있는 형태이다. 정수 부분의 16비트로 나타낼 수 있는 값의 범위는

부호가 없다고 가정하면(즉, unsigned로 취급) : 0 ~ 2^16 - 1
부호가 있다고 가정하면(즉, signed로 취급) : -2^15 ~ +2^15 - 1

이 된다. 부호가 있어도 값을 다루는데 충분하므로 여기서 부호가 없는 형태는 생각하지 않기로 한다. 즉, -32768 ~ +32767까지의 값의 범위를 가진다. 일반적으로 그래픽 표시장치가 1280×1280의 범위를 넘어서지 않으므로 이 정도의 범위는 그래픽에서 좌표계로 사용하기에 충분한 범위이다.

 이제 소수 부분을 생각해 보자. 소수 부분은 2^n (n은소수 이하 자리수)로 나타낼 수 있다. 예를 들어 2진수 0.011로 표시된 것은

    0.011 = 0 x 2^(0) + 0 x 2^(-1) + 1 x 2^(-2) + 1 x 2^(-3) = 0 + 0 + 0.25 + 0.125 = 0.375

로 다시 나타낼 수 있다. 소수 부분을 나타내기 위해 16비트를 사용하면 1/2^(16) = 1/65536 = 0.0000153의 정밀도를 가진다. 다른 예로 만일 18.14 포맷을 사용한다면 소수 이하 정밀도는 1/2^(14) = 1/16384 = 0.000061 이 된다.

 일반적으로 float 타입을 가지고 연산할 때 소수 6자리 이하의 정밀도는 신뢰할 수 없는 것으로 여긴다. 이런 사실을 놓고 볼 때, 16.16 포맷의 고정소수점 연산의 정밀도는 소수 이하 5자리까지 정밀도를 보장하므로 float 타입의 연산에 못지 않게 상당히 정밀하다고 볼 수 있다. 이 글에서는 주로 16.16 포맷을 사용하였으나 프로그래머의 필요에 따라 정수 부분이 많이 필요하면 18.14 포맷이나 24.8 포맷을 사용할 수 있고, 거꾸로 아주 세밀한 정밀도가 요구되어 소수 이하 부분이 많이 필요하면 14.18 포맷이나 8.24 포맷을 사용할 수도 있다. M과 N을 어떻게 정하는지는 전적으로 프로그래머에게 달려있다. 즉 고정 소수점 연산을 사용하면 다음과 같은 이점을 얻을 수 있다.

    첫째, float 타입을 이용하는 것보다 빠른 계산속도를 보장해 주며,

    둘째, 정밀도도 float 타입을 사용하는 것에 못지 않고,

    셋째, 소수 이하 부분 정밀도의 범위를 프로그래머가 정할 수 있다.

 이러한 사실이 그래픽 연산에서 고정 소수점 연산을 사용해야 하는 좋은 이유가 된다.


http://myhome.hanafos.com/~kukdas/doc/mfc/fixed.html

'C/C++언어' 카테고리의 다른 글

[알고리즘]고정소수점(fixed point) 연산  (0) 2008.05.09
음 고정소수점 만들기..  (0) 2008.05.09
각 언어별 3중 포인터  (0) 2008.05.06
쉬프트 연산과 곱셈  (0) 2008.03.26
[리눅스프로그래밍] makefile  (1) 2008.03.07

최근 하고 있는 것이 여러 뻘짓이다 보니 본이 아니게 3중 포인터를 쓰게 되더군요.

대략… 3차원 표현 이라던가…

2차원의 영역중, 각 픽셀의 갖고있는 간단한 값을 가져올 때, 안쓸꺼 같던것들이 자주 쓰이더군요.

일단 예제.. 여기서 예제는 int a[10][20][30] 과 같은 역활을 하는 동적 메모리 할당을 해봅니다.

일단 C언어부터…

// 만들때

int ***ptr;
ptr = (int ***)malloc(sizeof(int **) * 10);

for(i=0; i < 10; ++i)
{
    ptr[i] = (int **)malloc(sizeof(int *) * 20);   

    for(j=0; j < 20; ++j)
        ptr[i][j] = (int *)malloc(sizeof(int) * 30);
}

// 해제할때
for(i=0; i < 10; ++i)
{
    for(j=0; j < 20; ++j)
        free(ptr[i][j]);

    free(ptr[i]);
}

free(ptr);

그리고 C++, C++ 답게 new와 delete 명령어를 써서 (물론 위의 C방법으로 해도 상관없습니다.)

 

 //<!----- 3차원 배열 예제 -----//
//배열을 X,Y,Z에 각각 10,20,30 식 할당.
#define ARRAY_X  (10)
#define ARRAY_Y  (20)
#define ARRAY_Z  (30)
void main()
{
     //선언
     int ***ptr;
     //할당
     ptr = new int**[ARRAY_X];    //X차원 선언
     for(int i=0; i<ARRAY_X; ++i) {
         ptr[i] = new int *[ARRAY_Y];  //Y차원 선언
     } 
     for(int i=0; i<ARRAY_X; ++i){
         for(int j=0; j<ARRAY_Y; ++j) {
             ptr[i][j] = new int[ARRAY_Z]; //Z차원 선언
   
             for(int k=0; k<ARRAY_Z; ++k){
            //값을 씀.
                 ptr[i][j][k] = i*j*k;
             }
         }
    }
    // 해제할때, 역으로 Z부터 해제함.
    for(int i=0; i<ARRAY_X; ++i) {
        for(int j=0; j<ARRAY_Y; ++j) {
            for(int k=0; k<ARRAY_Z; ++k){
                printf("ptr[%d][%d][%d] = %d\n", i, j, k, ptr[i][j][k]);
            }
           delete [] ptr[i][j];  //Z차원 해제
        }
        printf("\n");
    }
    for(int i=0; i<ARRAY_X; ++i) {
        delete [] ptr[i];    //Y차원 해제
    }
    delete []ptr;      //X차원 해제
    return 0;
}

 마지막으로 Java ...
Java의 경우.. 포인터가 없죠;;
Java의 메모리는 C언어처럼 직접 건들이는 구조가 아니고,
물리적 메모리 공간과 별도로 여길 관리하는 메모리 관리자가 있습니다..(Virtual Machine(이하 VM)안에)
여기의 메모리 관리자가 알아서 할당합니다. (OS에서 자원 관리 하듯이라 생각하셔도 됩니다.)

즉 int a[10] 이라 하면, 내부적으로는 C언어처럼 malloc(sizeof(int) * 10)이 일어난다는거죠.
C언어적인 문법상으로 보면 그냥 정적 배열처럼 보이지만... 내부적으론 동적 배열 처리한다고 보시면 됩니다.

해제는.. 간단하게 null 해주면 VM의 메모리 관리자가 해당 영역을 없는 셈 칩니다.
(파일을 지울때 앞글자만 지워주고 없는셈 치는것과 같은 이치입니다)

완전히 메모리 상에서 지우고 싶을땐 가비지 컬렉터를 강제로 실행시켜줘야 하지만..
굳이 할필요는 없습니다.
만약 VM이 메모리 할당이 일어날때 더 이상 메모리가 없다면,
현재 사용되지 않는 메모리 할당자들,
즉 위의 null 처럼 되어있는것.. 이라던가, 클래스에서 나와 더이상 사용하지 않는 변수들 같은것들을
찾아 지워준뒤, Compaction등이 일어납니다. 그리고 빈곳에 메모리를 할당하죠.
개념은 이정도고 실제 작동원리나 자세한건 Java책을 참고하시기 바랍니다.

// 만들때
int ptr[][][] = null;
ptr = new int[10][20][30];

// 해제할때
ptr = null;
System.gc(); // 선택사항

전세계에서 제일 많이 사용하는 언어는 자바란 이유가.. 이런 편리함 때문일껍니다... T_T

C언어의 모토(motto)는
-> 어떤 머신이든 제어하는 언어를 만들자.
이지만,

Java의 모토(motto)는
-> 코딩은 1번, 코딩된 소스를 변형없이 모든 머신에서 돌아가는 프로그램을 만들자
라고 합니다.

실제... C언어는 ARM 계열이나. 리눅스의 GCC, MS의 Visual C++ ... 미묘하게 다릅니다.
int 의 경우만해도 보통은 4byte로 알고있지만, 사실은 해당 머신에서 숫자를 표현하기 적당한 공간을 뜻합니다.
무슨말이냐면
간단한 휴대폰의 경우 int의 할당은 2byte or 1byte 만 할당될수도 있고, PC에선 4byte, 서버나 과학용 컴퓨터에선 8byte 이상을 할당할수 있다는 얘기죠.
그게 무슨 대수냐..... 하실지도 모르지만.. 게임 만들다보면.. 이것까지 제어하는 부분까지.. orz

Java는.. int 는 4byte 라고 정해져 있습니다. 이건 어떤 머신이든 VM이 있다면 무족건 4byte로 쳐줍니다.

'C/C++언어' 카테고리의 다른 글

음 고정소수점 만들기..  (0) 2008.05.09
고정 소수점  (0) 2008.05.09
쉬프트 연산과 곱셈  (0) 2008.03.26
[리눅스프로그래밍] makefile  (1) 2008.03.07
ASSERT(), VERIFY(), TRACE()  (0) 2008.03.04

+ Recent posts