2008. 1. 10. 17:25 C++/General

static을 이해하자

Preface 프로그래밍을 하다 보면 static만큼 다양한 곳에서 다양한 의미로 많이 쓰이는 키워드가 없는 것 같습니다.

C/C++과 같은 정적인 언어 뿐만 아니라 JAVA와 같이 동적 바인딩을 기본으로 하는 언어에서조차 static이 사용되는 것을 보면 옛 속담처럼 '귀에 걸면 귀걸이, 코에 걸면 코걸이'가 되는 것이 static이라는 키워드인 것 같습니다. 그러나 유감스럽게도 이렇게 자주 쓰이고 중요하게 사용되는 static을 잘못 이해하고 있거나 많은 특징들을 모르고 있는 사람들이 의외로 많습니다.

특히 시중에 판매되는 대다수의 프로그래밍 입문 서적들이 static에 대해서 정확하고 자세하게 설명하고 있지 않다라는 사실이 이렇게 부족하게 나마 static에 대한 글을 쓰게 된 이유라 할 수 있습니다.

static 용어의 개념 및 성질 제가 그 동안 공부를 하면서 가장 절실하게 느낀 점이 하나 있다면 바로 어떤 개념을 익히기 위해서는 우선 용어의 뜻을 바로 이해하는 것이 가장 중요하다라는 것입니다.

특히 컴퓨터 분야와 같이 정말 많고 다양한 용어와 약어들이 판을 치는 분야에서 용어의 뜻을 정확하게 이해하는 것은 말 그대로 '반은 먹고 들어가는 것'입니다.

그런 의미에서 우리가 가장 처음 할 일은 static이라는 용어에 대한 사전적 뜻을 알아보는 것이 아닐까 생각합니다.

static
1 정지(靜止)하고 있는, 변화하지 않는; 정적인, 움직임이 없는(반:dynamic). (또는 statical)
2〈물리〉 정적인, 정압(靜壓)의. ~ pressure 정압. 3 〈전기〉 정전(靜電)(기(氣))의; 공전(空電)의.
[출처 : 야후 영어사전]

야후에 물어보니 static이 위와 같은 뜻이라고 하는군요...

프로그래밍 언어에서만이 아니라 영어 자체에서도 생각보다 다양한 뜻을 가지고 있는 것 같습니다.('정전기'라는 뜻을 가지고 있다는 사실을 전 오늘 처음 알았습니다.)

어쨌든 대충 static이라는 단어가 풍기는 이미지는 '불변성, 고요함' 정도로 생각되며 실제 앞으로 설명할 static의 여러 가지 성질들은 대체로 이러한 의미들과 관련이 있습니다.

그럼 C/C++에서 static이 갖는 성질들을 제가 아는 대로 한번 나열해 보겠습니다.(참고로 이제부터 제가 사용하는 '객체'라는 용어는 클래스 객체뿐만이 아니라 C++컴파일러가 제공하는 built-in type 변수까지 포함하는 추상적인 개념으로 이해하시기 바랍니다.

왜냐하면 실제 static의 성질은 그 타입에 상관없이 동일하게 적용되기 때문입니다. 때에 따라서 객체라는 용어와 변수라는 용어를 혼용하더라도 양해하여 주시기 바랍니다.)

1. 정적 지속성(static storage duration) : static 객체들은 일단 한번 생성이 되면 프로그램이 종료될 때까지(C++표준에서는 main()함수에서 리턴 될 때 혹은 exit()를 호출했을 때 소멸된다고 명시되어있습니다.) 유지됩니다. 즉, 객체의 소멸 시점이 scope에 영향 받지 않고 항상 일정합니다.

2. 유일성(singleton) : 어떤 모듈 단위(function, class, file)에서든지 static 객체는 단 한번만 생성됩니다.

3. 내부 연결성(internal linkage) : 전역 static 객체나 함수는 link 단계에서 외부 바인딩이 일어나지 않습니다. 즉, 외부 파일에서는 내부 전역 static 객체/함수를 참조하거나 호출할 수 없습니다. 그렇다면 왜 static 키워드가 붙은 변수나 함수가 이런 성질을 가지게 되는지에 대해서 차근차근 알아보도록 하겠습니다.

binding(바인딩) static에 대해서 이해하기 위해서는 우선 binding이라는 개념을 이해하여야 합니다. 바인딩이라는 단어 역시 다양한 의미로 사용되는 용어인데 프로그래밍 언어에서 말하는 바인딩이란 어떤 심볼(변수나 함수)의 속성을 결정짓는 것을 의미합니다.
int main()
{
	int x;
	x = 3;
	return 0;
}
위와 같은 C 소스가 있을 때 이것을 컴파일러가 컴파일 하게 되면 우선 컴파일러는 int x; 라는 선언문을 보고서 x라는 이름의 변수를 자신의 심볼 테이블에 기록을 하며 이 때 그 변수의 속성(type, scope, value 등)을 저장하게 됩니다.

그리고 그 다음에 있는 x = 3; 이라는 구문에서 x라는 변수가 자신의 심볼 테이블에 있는지 살펴보고, 있으면 그 속성을 참고하여 해당 구문의 타입 체크와 같은 문법 검사를 수행하게 됩니다.(물론 x가 심볼 테이블에 없다면 그런 변수를 찾을 수 없다는 컴파일 에러를 발생시킵니다.)

이제 좀 더 복잡한 예를 살펴보겠습니다.
 
/// test1.cpp
#include 

int global; // ---> 1)

void extern_fun(int a);

int main()
{
	global = 5; // ---> 2)

	extern_fun(3);

	std::cout << global << '\n';

	return 0;
}

/// test2.cpp
extern int global;  // ---> 3)

void extern_fun(int a)
{
	global += a;      // ---> 4)
}
위 소스에서는 global이라는 전역 변수를 두 개의 파일에서 참조하고 있습니다.

위에서 언급했듯이 컴파일러는 변수 선언문이 나타나면 해당 변수를 심볼 테이블에 등록하고 이후에 변수를 사용하는 구문을 만날 때마다 해당 변수가 자신의 심볼 테이블에 등록되어 있는지 살펴보고 그 정보에 따라 문법 검사를 수행하게 됩니다.

그런데 컴파일러는 항상 파일 단위로 컴파일을 수행하며 새로운 파일을 컴파일 할 때 이전에 이미 컴파일 한 파일에 대한 정보 같은 것들은 따로 기록하지 않습니다. 즉, 항상 새로운 마음가짐으로 각각의 파일들을 독립적으로 컴파일 하게 됩니다.

그래서 위의 경우 test1.cpp를 먼저 컴파일 했다고 해서 test2.cpp에서 global변수를 사용하는 구문을 컴파일 할 때 test1.cpp에서 만든 심볼 테이블을 참조하지는 않습니다.

때문에 두 개 이상의 파일에서 같은 전역 변수를 참조하게 되면 그런 사실을 미리 소스 코드를 통해 컴파일러에게 알려줘야 합니다.

만약 그렇지 않으면 test2.cpp에서 4)구문을 컴파일 할 때 global이라는 변수가 선언되어 있지 않다 라고 에러를 발생시키거나 혹은 test1.cpp와 test2.cpp의 global변수를 별개의 변수로 취급하게 될 것입니다.

그러므로 프로그래머는 사전에 test1.cpp와 test2.cpp에서 사용하는 global 변수가 동일한 것이다라는 사실을 컴파일러에게 알려주게 되는데 위의 소스에서 3)에 있는 extern은 바로 그런 역할을 수행하는 키워드입니다.

extern의 의미는 '해당 변수는 외부에서 참조하여 사용하겠다'라는 뜻입니다.

만약 3)이 없다면 test2.cpp를 컴파일 할 때 4)에서 global이라는 변수를 찾을 수 없다는 에러가 발생할 것이며, 3)에서 extern이 없다면 test1.cpp와 test2.cpp의 global은 별개의 변수로 취급되어 프로그램 결과가 8이 아니라 5가 나오게 될 것입니다.

그렇다면 컴파일러는 각각의 파일을 컴파일 할 때 다른 파일의 심볼 값들을 참조하지 않음에도 불구하고 어떻게 전역 변수를 공유할 수 있을까요? 그것은 바로 링크 과정이 있기 때문에 가능합니다.

컴파일 시 컴파일러는 전역 변수나 함수에 대한 참조/호출을 직접적인 어셈블리어 구문으로 변환하는 것이 아니라(다시 말하면 해당 변수의 메모리 주소나 함수의 코드 주소로 바로 변환하지 않고) 특정한 이름을 부여하여 해당 이름의 전역 변수나 함수에 대한 참조/호출 구문으로 바뀌게 되는 것입니다.

예를 들어 위의 소스의 경우에 test2.cpp에서 참조하는 global이 외부에서 참조되는 변수이다라는 사실을 컴파일 한 목적 코드(object code)에 기록해 두면 링커는 해당 변수와 동일한 이름으로 정의된 파일을 검색하고 test1.cpp에서 일치된 이름을 발견하면 해당 전역 변수 참조 구문들을 올바른 속성(상대 주소)값으로 바꾸게 됩니다.

이렇게 어떤 변수나 함수의 속성값을 결정짓는 과정을 바인딩이라고 합니다.

그리고 위의 경우처럼 링크 과정에서 그런 속성값을 결정짓는 것을 링크 바인딩(link binding), 혹은 동적 바인딩(dynamic binding)이라 합니다. 위의 야후 사전에서도 나와 있듯이 컴퓨터 분야에서 dynamic에 반대되는 용어는 static이며 거의 항상 이 두 존재는 양립합니다.

즉, 앞에 dynamic이라는 단어가 붙는 어떤 용어가 있다면 거의 항상 static이 앞에 붙는 반대되는 의미의 용어가 존재합니다. 바인딩 역시 예외가 아니어서 동적 바인딩에 반대되는 개념으로 정적 바인딩(static binding)이 있습니다.

이것은 바인딩이 컴파일 시점에 이루어 지는 것을 의미합니다. 즉, 해당 변수나 함수의 속성이 컴파일 과정에서 결정됩니다.

정적 바인딩에 의해 정의되는 변수나 함수는 링크 과정 이전에 이미 속성이 결정되기 때문에 아래와 같은 특징을 가지게 됩니다.

1. 정적 바인딩 변수는 extern 키워드를 통해 외부 파일에서 참조가 불가능하다.
2. 정적 바인딩 함수는 외부 파일에서 호출이 불가능하다. static 전역 변수나 함수를 사용해 보신 분들은 아시겠지만 위의 특징은 바로 static 전역 변수와 함수의 특징과 일치합니다. 즉, static 키워드는 해당 전역 변수나 함수를 컴파일러가 정적으로 바인딩을 하도록 프로그래머가 '지시'하는 역할을 합니다.

컴파일 타임 바인딩을 정적(static) 바인딩이라고 말하는 이유는 아마도 한번 컴파일 시에 속성이 결정되고 나면 '변하지 않는다' 라고 하는 불변성 때문이 아닌가 추측됩니다. 어쨌든 이런 특징을 가지고 있기에 static 변수는 독특한 성질을 지니고 있습니다. 아래의 예제는 초보자가 흔히 하게 되는 실수입니다.
 
/// header.h
int global;


/// a.cpp
#include 
#include "header.h"

void b_func();

int main()
{
	global = 3;

	b_func();

	std::cout << global << std::endl;

	return 0;
}



/// b.cpp

#include "header.h"

void b_func()
{
	global = 5;
}

위 소스는 정상적으로 컴파일 됩니다.

앞에서 언급했듯이 컴파일러는 파일 별로 독립적으로 컴파일을 수행합니다. 따라서 a.cpp 컴파일 시 header.h에 있는 global 선언문을 통해 global 변수를 하나 생성하여 3을 할당합니다.

또한 b.cpp 역시 header.h에 있는 global 선언문을 통해 global 변수를 하나 생성하여 5를 할당합니다. 따라서 둘 다 적법한 소스입니다. 하지만 링크 과정에서 링커는 a.cpp와 b.cpp가 동일한 이름(global)의 전역 변수를 두 개 생성한 것을 보고 링크 에러를 발생시킵니다.(이 때 발생하는 에러는 redefinition error입니다.

아마 초보자들이 가장 많이 접하는 에러 중 하나일 것입니다.)
그러면 위 소스를 아래와 같이 수정해 보겠습니다.
// header.h
static int global; /// int global을 static 전역 변수로 수정

 
// a.cpp
///원 소스와 동일...
 
// b.cpp
/// 원 소스와 동일....
이렇게 수정하면 정상적으로 컴파일 및 링크가 수행됩니다.

왜냐하면 static 전역 변수는 컴파일 시점에 바인딩이 완료되고 따라서 링크 과정에서 해당 변수에 대한 바인딩 처리를 하지 않으므로 중복 정의를 검사하지 않기 때문입니다.

그러나 대신 a.cpp와 b.cpp에서 사용하는 global 변수는 완전히 별도의 객체로 처리됩니다.

따라서 위 프로그램에서 a.cpp가 출력하는 global값은 - b_func()함수를 호출함으로써 바뀌는 값- 5가 아니라 - 원래 a.cpp에서 할당한 값 - 3이 됩니다.

즉, a.cpp와 b.cpp에서 사용하는 global 전역 변수는 사실 이름만 똑같을 뿐 별개로 취급되는 다른 변수가 됩니다. (따라서 이런 식의 사용은 프로그래머에게 혼란을 줄 뿐이며 버그를 야기시키는 좋지 않은 코딩 방식입니다.

그러므로 static 전역 변수를 선언할 때는 항상 header 파일이 아닌 c/cpp 파일에 해줘야 합니다.) 어쨌든 static 전역 변수는 항상 해당 변수가 선언된 파일 내부에서만 참조가 가능합니다.

그리고 이것은 static 전역 함수 역시 마찬가지이며 static으로 선언된 전역 함수는 외부 파일에서는 사용이 불가능합니다.(만약 외부에서 호출하려고 하면 링크 에러가 발생합니다.)

때문에 보통 C프로그래머들은 외부 파일에서 사용할 필요가 없는 혹은 외부에서 사용하기를 원치 않는 함수들은 static으로 정의하곤 합니다. 이렇게 함으로써 이름 중복에 의한 혼란 등을 피할 수 있는 부수적인 장점을 얻을 수도 있습니다.(C++에서는 namespace와 class가 생기면서 이런 장점이 많이 사라졌습니다.)

그리고 이렇게 파일 내부에서만 참조 가능한 static 의 성질을 internal linkage(내부 연결성)이라고 부릅니다. storage duration and scope 다 아시는 이야기 하나 해보겠습니다. 변수는 관점에 따라 다양하게 분류가 될 수 있습니다.

우선 공간(scope)범위에 따라 전역 변수와 지역 변수로 분류가 가능합니다. 그리고 메모리 할당 주체에 따라 정적 할당 변수와 동적 할당 변수로 분류할 수도 있습니다.

또한 메모리 할당 위치에 따라 스택(stack) 변수와 정적 영역 변수, 동적 영역(heap) 변수로 나눌 수 있으며 그 외에도 정적(static)변수, 상수(const)변수, 포인터 변수 등등 여러 종류로 구분이 가능합니다. 게다가 이러한 분류는 복합적으로 정의될 수 있습니다.

가령, 정적 전역 변수(static global variable), 정적 지역 변수(static local variable), 전역 상수 변수(global const variable) 등등이 있습니다. 심지어 어떤 분류 정의는 서로 밀접한 관계(혹은 동등한 의미)를 가집니다.

스택 변수와 비정적(non-static) 지역 변수는 사실 같은 의미를 가지고 있으며, 전역(global) 변수와 정적(static) 변수는 정적 영역 변수에 해당합니다. 이렇게 변수의 종류가 다양하게 분류되는 것은 프로그래밍 시 여러 가지 상황이 발생하게 되고 이때 이런 상황에 맞는 변수 설정을 위한 여러 가지 개념들이 필요했기 때문이며 이러한 여러 개념들이 서로 복합적인 관계를 맺으며 사용되는 것은 그러한 개념들을 구체화 시키는 과정에서 구현 상의 이유로 만들어진 부수효과(side-effect)라 할 수 있습니다.

가령 포트란 같은 경우 모든 변수가 전역 변수로 지정됩니다(참고로 저는 포트란을 직접 사용해 본적은 없습니다.). 즉, 지역 변수라는 개념이 없습니다. 따라서 파라미터 전달이나 스택 변수 생성에 따른 함수 호출 오버헤드가 없기 때문에 간단한 프로그래밍 시 좋은 성능을 보여줍니다.

그러나 대신 일회성 변수를 사용하더라도 지속적인 변수 유지가 필요하기 때문에 이름 짓기(naming) 문제나 혹은 같은 변수를 다른 용도로 계속 재사용하게 되고 따라서 복잡한 프로그래밍 시 코드가 난해해지는 단점이 있습니다.

그래서 C와 같은 프로그래밍 언어에서는 지역 변수라는 개념을 도입했으며 그에 따라 scope라는 개념이 생겨났습니다.

그리고 이런 scope라는 개념이 들어가면서 동시에 storage duration이라는 개념이 생겨났습니다.

더 이상 참조가 필요 없는, 다시 말하면 scope를 벗어난 지역 변수에 대해서 프로그래머가 일일이 지정하지 않아도 자동적으로 해당 지역 변수의 메모리를 컴파일러가 알아서 해제해줌으로써 보다 추상화된 프로그래밍이 가능하게 되는 것입니다.

그래서 이런 지역 변수의 특징을 구체화하는 과정에서 가장 손쉽고 오버헤드가 적게 드는 기법을 구현한 것이 바로 스택을 통한 지역 변수 관리 기법인 것입니다.(따라서 스택 변수는 곧 비정적 지역 변수가 됩니다.)

어쨌든 점점 프로그래밍이 고난이도 작업이 되고 그에 따라 요구 조건이 까다로워지면서 보다 다양한 개념의 메모리 및 변수 관리가 필요하였고 그에 따라 C/C++와 같은 언어에서는 직접 사용자가 메모리를 관리하는 포인터 및 동적 할당 개념이 생겨났습니다.

그런 와중에 '특정 scope를 가지면서 해당 변수의 storage duration은 scope에 상관없이 프로그램 실행 시간 내내 유지 될 수 있는' 그런 기법이 필요하게 되었습니다.

전역 변수는 프로그램 실행 시간 내내 유지되지만 대신 다른 블럭이나 모듈, 파일들에서 해당 변수를 참조할 수 있게 되고 그러면 프로그램의 안정성이 떨어질 수 있기 때문에, 가급적 참조 범위를 최소화하는 것을 미덕으로 여기는 프로그래밍 세계에서는 다른 대안을 필요로 한 것입니다. 한편, static 변수는 앞서 말한 바와 같이 컴파일 시점에 바인딩이 되는 변수입니다.

그런데 이 변수를 전역이 아닌 지역 변수로 선언을 한다면 뭔가 비 논리적인 상황이 발생합니다.

왜냐하면 지역 변수는 스택을 통해 관리되므로 그 속성(여러 속성들이 있겠지만 여기서는 메모리 주소값)이 수시로 바뀌게 되는데 이는 static의 원칙에 어긋나는 동작입니다.

결국 static 지역 변수는 허용을 하지 말거나 혹은 지역 변수와는 독립적인 처리가 필요하게 되었습니다. 그리고 C/C++ 에서는 후자를 선택하였습니다. 그 이유는 아마도 앞서 언급한 필요성 때문이 아닐까 생각합니다.(사실 위의 이야기들은 다 제가 추측한 이야기입니다. 실제로 어떤 이유로 static 지역 변수가 생겨났는지는 K&R만이 알겠죠...)

따라서 static으로 선언된 지역 변수는 다른 지역 변수와 달리 스택이 아니라 정적 영역에 할당이 됨으로써 해당 scope를 벗어나 스택이 해제되더라도 그 상태를 유지할 수 있게 되었습니다.

어차피 static 전역 변수 역시 정적 영역에 할당되므로 구현 상으로도 통일된 처리가 가능하여 이는 충분히 합리적인 결정이라 생각됩니다. 어쨌든 결국 static 지역 변수는 지역 변수의 일부 특성(scope)과 전역 변수의 일부 특성(static storage duration)을 갖는 독특한 존재가 됐습니다.

예를 들자면,
#include 

int sum(int a)
{
	static int x = 0; /// 최초 sum 호출 시 한번 만 생성&초기화됨
	x += a;
	return x;         /// sum()함수 종료 시에도 소멸 안됨
}

/* 1부터 100까지 합을 출력하는 프로그램*/
int main()
{
	int i = 0;
	for (; i < 100; ++i)
		sum(i);

	std::cout << sum(100) << '\n';
	return 0;       /// main()함수 종료 시 sum()함수 내에 있는 x 변수 소멸
}
이런 식의 사용이 가능합니다. 또,
#include 

int* local_fun(int a)
{
	int x = 0;
	x += a;
	return &x;
}

int* static_fun(int a)
{
	static int x = 0;
	x += a;
	return &x;
}

int main()
{
	int* p = local_fun(3);
	std::cout << *p << '\n';   /// 비정상적인 값 출력
	p = static_fun(3);
	std::cout << *p << '\n';   /// 정상값(3) 출력
	return 0;
}
이처럼 지역 변수의 경우 스택을 통해 메모리가 관리되므로 외부 참조가 불가능하지만 static 지역 변수의 경우 정적 영역에서 메모리가 관리되므로 포인터 혹은 레퍼런스를 통한 참조가 가능합니다.

여기서 scope에 대한 제약은 어떤 실행 메카니즘에 의한 것이 아니라 단지 컴파일러에 의한 문법 검사에 의한 것임을 알 수 있습니다.(이런 특징 때문에 static 객체는 singleton 패턴을 구현하는데 사용되기도 합니다.)

어쨋든 이렇게 static 객체는 전역 객체로 선언되면 internal linkage 특성을 가지지만 지역 객체로 선언될 경우 static storage duration 특성을 가지게 됩니다. 그리고 이런 특성이 모두 static이라고 하는 용어가 가진 개념에 의해 발생된 것임을 알 수 있습니다.

C++에서의 static C에서 static이 가진 의미는 위에서 말한 두 가지가 전부입니다. 그러나 C++에서는 객체지향 패러다임이 도입되었고 그에 따라 클래스라고 하는 개념이 도입되었습니다.

클래스는 다 아시다시피 '무언가 공통된 책임을 수행하기 위해 같이 모아 놓으면 좋을 만한 변수와 함수들을 하나의 모듈로 캡슐화시킨 사용자 정의 타입'입니다.

그리고 이런 클래스 타입에 의해 생성된 변수들을 '객체(Object)'라고 합니다. 사실 이런 클래스나 객체라는 것은 이름은 그럴 듯 하지만 막상 그 세부 구조를 밑바닥까지 파헤쳐 보면 C에서 사용하는 구조체(structure)와 큰 차이가 없습니다.

멤버 변수들은 단지 구조체 멤버에 사용자 권한이라고 하는 컴파일 타임 제약 사항만을 추가한 것에 불과하며, 멤버 함수라고 하는 것은 실상 해당 클래스 객체의 포인터를 파라미터로 자동 추가해 주는 편이성 높은 함수에 불과합니다.

즉,
class CppClass
{
public:
	CppClass() : x_(0) {}

	void SetX(int a) { x_ = a; }

	int x_;
}
위의 클래스를 C언어로 바꾸게 되면
struct CStruct
{
	int x_;
}
 

void CStruct_ctor(CStruct* this)
{
	this->x_ = 0;
}

 

void CStruct_SetX(CStruct* this, int a)
{
	this->x_ = a;
}
이렇게 됩니다. 그래서 실제 사용시에
CStruct tmp;

CStruct_ctor(&tmp);

CStruct_SetX(&tmp, 3);
이렇게 사용할 것을 클래스를 이용해서
CppClass tmp;

tmp.SetX(3);
이런 식으로 사용함으로써 생성자나 SetX()와 같은 멤버 함수가 CppClass라는 클래스의 객체에 밀접하게 관련되었다는 것을 프로그래머가 보기에 보다 직관적이 될 수 있도록 해준 장치에 불과합니다.

물론 이렇게 맥 빠지게 말을 하였지만 C++, JAVA, Smalltalk들과 같은 객체 지향 언어들은 객체 지향 패러다임을 통해 상속, 다형성, 캡슐화라는 다양한 장치를 제공하여 현재 가장 널리 사용되는 프로그래밍 언어인 것은 틀림없습니다.(적어도 이제 사람들이 더 이상 C를 반드시 배우려고 하지는 않습니다.) 어쨋든 객체 지향 언어들은 모든 프로그래밍을 객체 단위로 구현하고 조직화하게 되며 따라서 모든 변수들은 자신이 속한 객체와 그 생명 주기(life time)를 같이 합니다.

다시 말하면 멤버 변수의 scope와 storage duration은 자신이 속한 객체에 영향을 받는다는 뜻입니다. 그런데 여기서도 지역 변수에서와 유사한 문제가 발생합니다. 즉, 클래스 멤버 변수 선언 시에 static을 앞에 붙히게 되면 어떻게 되느냐 하는 것입니다.

클래스 멤버 변수는 객체가 생성될 때 같이 생성되고 객체가 소멸될 때 같이 소멸됩니다.

게다가 객체가 지역 변수로 선언되면 멤버 역시 스택에 위치하게 되며 객체가 동적 할당되면 멤버 역시 동적 영역에 위치합니다.

그런데 앞서 언급했듯이 static은 지역 변수든 전역 변수든 상관없이 static storage duration을 갖습니다.

따라서 아무리 객체 지향이라 하더라도 이러한 기존의 법칙을 거스르는 행동을 하는 것은 논리적이지 못합니다. 따라서 이것 역시 두 가지 선택이 필요하게 되었습니다.

멤버 변수에 대한 static을 허용하느냐, 그렇지 않느냐...그리고 C++은 전자를 선택하였습니다. 단 이렇게 하고 나니 한 가지 문제가 발생하였습니다.

static 멤버 변수를 허용하게 되면 기존의 static 특성을 따르기 위해서 static storage duration을 가져야 하고 그러려면 이 변수는 정적 영역에 위치하여야 하는데 객체는 반드시 정적 영역에 위치하리라는 보장이 없기 때문입니다.

결국 이런 모순을 해결하기 위해서는 static 멤버 변수를 객체에서 분리하여야 합니다. 즉, 객체의 부분 집합으로써가 아니라 단지 클래스의 이름 공간에 한정 받는 전역 변수처럼 취급이 되는 것입니다.

다행스럽게도 이것은 기존의 static 정의에서 크게 벗어나지 않는 개념입니다.

즉, 1. static 변수는 한 번 생성되면 프로그램 종료 시까지 계속 유지된다.(static storage duration) 2. static 변수는 scope에 영향을 받는다. - 전역 객체는 정의된 파일 scope에 한정되어 참조 가능하며(internal linkage), 지역 객체는 정의된 local scope에 한정되어 참조 가능하다.(no linkage) static 클래스 멤버 변수는 위의 정의에서 1번과 일치하며 2번의 경우 영향 받는 scope를 클래스 이름공간(namespace)라는 것으로 확장하여 생각하면 역시 문제될 것이 없습니다.

결국 static 클래스 멤버 변수는 객체와 상관없이 클래스 범위 연산자(::)를 통해서 참조가 가능한 독특한 특징을 가진 전역 객체가 되었으며 동시에 '객체가 몇 개가 생성되든 오직 하나만 존재함(singleton)'이라는 성질을 가지게 되었습니다.

실제 프로그래밍에서 static 멤버 변수는 어떤 클래스에 관련되지만 특정 객체에 영향을 받지 않는 성질을 가진 데이터를 취급하는데 아주 유용하게 사용됩니다. reference counter가 그 대표적인 예라 할 수 있습니다.

한편 클래스 멤버 함수는 다른 경우가 됩니다. 함수라는 것은 어차피 storage duration이라는 것이 존재하지 않기 때문에 일반 함수의 경우 static, non-static의 구분이 'internal linkage인가 아니면 external linkage인가'로 결정이 됩니다.

여기서 클래스 멤버 함수의 경우는 internal linkage라는 것이 큰 의미가 없습니다. 어차피 private이나 protected와 같은 권한 지정 키워드가 있기 때문에 얼마든지 사용 범위에 제한을 가할 수 있기 때문입니다.

따라서 이것 역시 두 가지 선택 사항이 존재하게 됩니다. 멤버 함수에 대해서 static을 허용하느냐 그렇지 않느냐...C++는 역시 허용하는 쪽에 손을 들어 줍니다. 사실 static이라는 키워드는 기본적으로 모든 선언문 형식에 들어갈 수 있는 storage class specifier라고 하는 지정자의 일종이기 때문에(C에서 그렇게 정의했기 때문에) 자꾸 이런 저런 제약을 가하는 것은 C의 자유로운 문법을 계승한 C++ 입장에서 그다지 바람직하지 않다라고 생각했을 것입니다.

어쨌든 멤버 함수에 static을 허용하였고 그에 따른 어떤 의미 부여가 필요하였습니다.(앞서 말한 대로 internal linkage라는 성질은 큰 의미가 없으므로...) 여기서 C++은 멤버 변수가 객체에 영향을 받지 않는 클래스 이름 공간에 속한 전역 객체로써 취급되었듯이 멤버 함수 역시 동일한 성질을 부여하게 됩니다.

즉, static 멤버 함수는 객체가 없어도 호출이 가능하며 단지 클래스 이름 공간에 한정을 받는 함수가 된 것입니다. 따라서 static 멤버 함수는 다른 멤버 함수처럼 this 포인터를 암시적으로 받는 특성이 없이 일반 함수와 똑같은 호출 구조를 가지게 되었습니다.

결국
class StaticClass
{
public:
	static int x_;

	static int func() {};
}
이것은
 
namespace gimmesilver
{
	extern int x;
	int func() {};
}
이것과 거의 동일한 특성을 가집니다.

따라서 non-static 멤버 함수들은 암묵적으로 넘겨받은 this 포인터를 통해서 해당 객체의 멤버 변수나 다른 non-static 멤버 함수를 참조/호출할 수 있지만 static 멤버 함수들은 그러한 참조할 만한 객체 포인터가 없으므로 객체의 멤버 변수나 non-static 멤버 함수를 참조/호출할 수 없는 것입니다.(이것 역시 초보자들이 흔히 하는 실수입니다.)

Post face 정리 들어갑니다...

static 변수
1. static storage duration을 갖는다.
2. scope의 영향을 받는다.

- 전역 변수 : file scope의 영향을 받으며 internal linkage라고 한다.
- 지역 변수 : block scope의 영향을 받는다.
- 클래스 멤버 변수 : 클래스 이름공간의 영향을 받는 전역 객체이다.

static 함수
1. 일반 함수 : internal linkage를 갖는다. 즉, 외부 파일에서 해당 함수를 호출하지 못한다.
2. 클래스 멤버 함수 : this 포인터를 갖지 않는다. 따라서 객체의 멤버 변수나 멤버 함수를 직접 참조/호출할 수 없다.
단, 같은 클래스의 static 멤버 변수나 static 멤버 함수는 직접 참조/호출이 가능하다.

쓰다 보니 주저리 주저리 글이 너무 길어졌습니다.

부디 이 글을 통해서 static에 대한 개념 이해에 도움이 되셨으면 좋겠습니다. 혹시 내용 중에 잘못된 내용이나 이상한 부분이 있으면 귀찮으시더라도 메일 주시면 감사 드리겠습니다.

글쓴이: 이은조(gimmesilver@hanmail.net)
Posted by 엘키 엘키
 TAG c언어, static

댓글을 달아 주세요

  1. Favicon of http://toomanyid.springnote.com BlogIcon 이종상 2008.04.14 16:55  댓글주소  수정/삭제  댓글쓰기

    정말 좋은정보 감사히 잘 읽었습니다.
    허용해 주신다면 제 홈페이지에 원문 그대로를 올려 놓고 싶습니다.

  2. sunghun84@nate.com 2008.04.16 11:34  댓글주소  수정/삭제  댓글쓰기

    도움 되셨다면 다행이네요 ^^ 허락은 글쓰신 은조님께 받으심이 어떨까요?

  3. 이종상 2008.04.16 17:23  댓글주소  수정/삭제  댓글쓰기

    그랬군요. 미처 확인을 못했습니다.
    전 엘키님과 은조님을 동일인으로 봤습니다.
    다시한번 좋은 정보 주셔서 감사 드리구요.
    글쓰신분께 다시 여쭤 봐야겠군요.
    행복하세요.

char* GetStr()
{
	static char szStr[] = "Hello";
	return szStr;
}

void PrintStr(char **str)
{
	printf("%s",*str);
}


int main(int argc, char **arv)
{
	PrintStr(&GetStr());
	return 0;
}

 

위 코드는 아래와 같은 컴파일 에러를 발생시킨다.

error C2102: '&' requires l-value


컴파일러의 에러는, l-value. 즉, 어딘가에 저장된 값에만 주소 연산자를 사용할 수 있다는 말이다.

내가 이 코드를 작성한 의도는 char형 포인터의 포인터 (이중 포인터)를 매개 변수로 받는 PrintStr함수의 매개변수로, char형 포인터를 반환하는 GetStr함수의 반환 값의 주소를 넘기면 정상적으로 동작할 거라는 생각에서였다.


그래서 내가 생각한 해답은, GetStr함수가 반환할 char형 포인터 주소인

GetStr() 0x00427b60 "Hello"           char *
가 아니라, GetStr의 반환 값이 임시 저장되어 있는, 레지스터의 주소를 대입하려 하는 건 아닐까 생각했다.

 
main함수를 이렇게 바꾸면 정상적으로 동작한다.

int main(int argc, char **arv)
{
	char *str = GetStr();
	PrintStr(&str);
	return 0;
}
이 코드를 디스 어셈블 해본 결과다.
char *str = GetStr();

00412BDE  call        GetStr (411500h) 

00412BE3  mov         dword ptr [str],eax
역시 추측이 맞았다. GetStr함수를 부른 결과는 eax 레지스터에 저장되어 있었고, 그 값을 어딘가에 복사하기 전까진, 레지스터에 있는 값이므로, 레지스터에 주소 연산자를 사용할 수 없기 때문에 컴파일 에러를 낸 것이었다.

Posted by 엘키 엘키

댓글을 달아 주세요

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

성훈 (sunghun84@nate.com) 

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

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

 

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

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

 

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

(2) 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프로그램이 정상적으로 종료 되었습니다.


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

 

(3) 레지스터

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

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

 

범용 레지스터

32Bit

16Bit

상위8Bit

하위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

 

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언어 안에 어셈블리어를 포함시켜 동작하는 인라인 어셈블리에 대해 알아보겠습니다.

(5) 인라인 어셈블리

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

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

 

*참고 서적

- 알기 쉬운 어셈블리. 신동준.
- 해킹 파괴의 광학.
김성우.

Posted by 엘키 엘키

댓글을 달아 주세요

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

성훈 (sunghun84@nate.com) 

(1) 자료 구조란?

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

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

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


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

(2) 자료구조의 종류
가장 흔히 접할 수 있는 자료구조 중 하나는 배열입니다.
배열(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)라고 부릅니다.


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

(3) 검색 알고리즘
검색(Search)
이란 말 그대로, 무언가를 찾는 것을 말합니다.
무엇을 찾을까요? 지나간 첫사랑? 내 돈 떼먹고 도망간 친구? 초등학교 동창?
보통 오랜만에 연락처를 잊어버린 친구를 다시 찾을 때 어떤 방법을 사용하시나요?
1. 다른 친구에게 물어본다.
2. 다 모임, 아이러브스쿨, 싸이 월드 등에서 찾아본다.
3. 졸업 앨범의 연락처를 뒤져본다.
4. 전국 전화번호부를 펴놓고 하나씩 전화해서 물어본다.

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

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


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

아래의 데이터 (7,8,15,24,27,35,42,59)중 24를 순차 검색으로 찾아보겠습니다. 먼저 첫 번째 데이터부터 검색해보겠습니다.

7

8

15

24

27

35

42

59

(노란색: 찾고 있는 현재 위치)


7은 찾고자 하는 데이터가 아니니, 다음 데이터 검색합시다.

7

8

15

24

27

35

42

59


8도 찾는 데이터가 아니니, 그 다음 데이터를 검색해야 합니다.

7

8

15

24

27

35

42

59

 

15도 찾는 데이터가 아니었습니다. 그럼 다음 데이터를 찾아보죠.

7

8

15

24

27

35

42

59


찾았습니다. 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. 즉, 중간 값에서부터 대소 비교를 하며 범위를 좁혀 원하는 값을 찾습니다.

아래의 8개 데이터 (7,8,15,24,27,35,42,59)중에서 15를 이진 검색으로 찾아보겠습니다.
중간 값에서부터 찾아야 하니, 최소값인 1과, 최대값 8의 중간 값, 1 + 8 / 2 = 4. 즉, 4번째 데이터부터 검색해보죠.

7

8

15

24

27

35

42

59

(노란색: 찾는 위치. 회색: 유효하지 않은 범위)


4번째 데이터인 24는 15보다 크죠. 그렇기에 24보다 작은 데이터에서 찾아야 합니다. 유효한 범위 중 최소값 1과, 최대값 3 중에서 찾아봅시다. 1 + 3 / 2 = 2이므로 2번째 데이터가 찾는 값인지 검사하겠습니다.

7

8

15

24

27

35

42

59

 

2번째 데이터인 8도 찾고자 하는 값은 아니었습니다. 15를 찾기 위해선 유효한 범위(흰색) 중 8보다 큰 값에서 찾아야 합니다. 유효한 범위의 최소값 3에서부터, 최대값 3중에서 찾으니, 3 + 3 / 2 = 3. 즉, 15가 3번째 데이터라는 것을 알 수 있었습니다.

7

8

15

24

27

35

42

59

 

 

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

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번 검색만으로 데이터를 찾을 수 있을 정도로 매우 효율적입니다.

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

 

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

(4) 정렬 알고리즘

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

 

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

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

먼저, 제일 처음 위치한 원소인 22부터 다음 원소를 비교해나가 보도록 하겠습니다.

22

8

15

24

7

14

122

59

22와 8을 비교한 결과 8이 더 작으므로, 서로 바꿔줍시다.

8

22

15

24

7

14

122

59

그 다음 원소인 15보다 22가 작으므로, 15와 22를 바꿔줍니다.

8

15

22

24

7

14

122

59

22는 24보다 작으므로, 그대로 둡니다.

 

8

15

22

24

7

14

122

59

7은 24보다 작으니, 바꿔줍니다.

 

8

15

22

7

24

14

122

59

14가 24보다 작으니, 바꿔주어야합니다.

 

8

15

22

7

14

24

122

59

24는 122보다 작으니 그냥 두시면 됩니다.

 

8

15

22

7

14

24

122

59

59는 122보다 작으니 바꿔 주시면 됩니다.

 

8

15

22

7

14

24

59

122

맨 마지막 원소는 다음 데이터가 없기 때문에, 정렬을 위한 비교를 할 필요가 없습니다.

 

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

다시 첫 번째 원소인 8부터 차례대로 비교해보시죠. 아까처럼 바로 뒤 원소가, 현재 대상 데이터보다 작을 때만 바꿔주시면 됩니다.

8

15

22

7

14

24

59

122

 

8

15

22

7

14

24

59

122

 

8

15

22

7

14

24

59

122

 

8

15

7

22

14

24

59

122

 

8

15

7

14

22

24

59

122

 

8

15

7

14

22

24

59

122

 

8

15

7

14

22

24

59

122

 

이 과정을, 정렬하려는 데이터 개수 -1 만큼 반복하면, 아래와 같이 정렬된 결과가 나옵니다.

7

8

14

15

22

24

59

122



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

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

 

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

22

8

15

24

7

14

122

59

바로 뒤에 위치한 원소인 8이 22보다 작으니, 최소값으로 등록해둡시다.

22

8

15

24

7

14

122

59

그 다음 원소인 15와 비교했지만, 8보다 크기 때문에 8을 최소값으로 등록합니다.

22

8

15

24

7

14

122

59

24도 8보다 크기 때문에 여전히 8이 최소값입니다.

22

8

15

24

7

14

122

59

그 다음 원소인 7은 8보다 작기 때문에 최소값으로 등록되었습니다.

 

22

8

15

24

7

14

122

59

14보다 7이 작으니 여전히 최소값은 7입니다.

 

22

8

15

24

7

14

122

59

이번에도 마찬가지로, 7이 최소값입니다.

 

22

8

15

24

7

14

122

59

데이터를 모두 비교한 결과, 7이 가장 작은 값이었습니다. 7과, 22를 바꿔줍시다.

 

7

8

15

24

22

14

122

59

이제 7은 정렬된 원소이니, 정렬 대상에서 제외해줍시다.

 

정렬된 원소가 된 7은 제외하고, 그 다음 원소부터 정렬을 위해 비교 해보시죠.

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

8보다 작은 원소를 찾지 못했으므로, 그대로 놔두시면 됩니다.

 

7

8

15

24

       22

14

122

59

정렬된 원소 8을 정렬 대상에서 제외시킵시다.

 

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

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

 

7

8

15

24

       22

14

122

59

15보다 작은 원소인, 14를 찾았으니, 15와 바꿔줍시다.

7

8

14

24

       22

15

122

59

14를 정렬 대상에서 제외시킵니다.

 

이 과정을, 버블 정렬과 마찬가지로, 정렬하려는 데이터 개수 -1 만큼 반복해주면 아래와 같은 정렬 결과를 얻을 수 있습니다.

7

8

14

15

       22

24

59

122

 

 

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

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

현재 키 값 : 8

 

22

8

15

24

7

14

122

59

먼저, 현재 정렬 시도할 키 값인 8을 임의의 장소에 저장해 둡니다.

그리고 정렬을 시도해보면, 8보다 왼쪽에 있는 원소인 22가 8보다 크니 두 번째 자리에 22를 넣어줍니다.

22

22

15

24

7

14

122

59

22가 8이 있던 두 번째 위치로 옮겨갔으니, 첫 번째 위치가 비었고, 더 이상 왼쪽 데이터가 없으니 그 자리에, 정렬을 시도한 키 값이었던 8을 넣어줍니다.

8

22

15

24

7

14

122

59

세 번째 원소의 정렬을 시작합니다.

현재 키 값 : 15

 

8

22

15

24

7

14

122

59

이번에도 정렬을 시도하는 현재 키 값을 저장해두고 정렬을 시도하겠습니다. 15보다 22가 더 크니, 15의 자리에 22를 넣어줍시다.

 

8

22

22

24

7

14

122

59

현재 키 값인 15보다, 8이 작으니, 8의 오른쪽 자리인 두 번째 자리에 현재 키 값인 15를 넣어줍니다.

8

15

22

24

7

14

122

59

더 이상 비교할 데이터가 없으니, 네 번째 데이터의 정렬을 시작하겠습니다.

현재 키 값 : 24

 

8

15

22

24

7

14

122

59

24와 22를 비교했지만, 24가 더 크기 때문에 그냥 넘어갑니다.

8

15

22

24

7

14

122

59

24와 15를 비교해도, 24가 크므로 그냥 넘어갑니다.

 

8

15

22

24

7

14

122

59

15와 비교해도 24가 크므로 그냥 내버려 둡니다.

 

8

15

22

24

7

14

122

59

정렬을 시도한 데이터들 중 24보다 큰 값이 존재하지 않았으므로, 정렬을 시작했던 위치에 다시 넣어줍니다.
이제, 다섯 번째 데이터를 정렬합시다.

 

현재 키 값 : 7

 

8

15

22

24

7

14

122

59

7과 24를 비교했더니, 24가 더 큽니다. 24를 현재 위치에 넣어줍니다.

 

8

15

22

24

24

14

122

59

7과 22를 비교했더니, 22가 더 크네요. 22를 현재 비교를 시도했던 위치에 넣어줍시다.

 

8

15

22

22

24

14

122

59

그 다음 원소인 15도 7보다 큽니다. 현재 위치에 15를 넣어줍니다.

 

8

15

15

22

24

14

122

59

가장 왼쪽에 위치한 원소인 8도 7보다 크네요. 현재 위치에 8을 넣어줍니다.

 

8

8

15

22

24

14

122

59

더 이상 비교할 왼쪽 데이터가 없네요. 8이 오른쪽으로 이동했으니, 첫 번째 자리가 비었네요. 그 자리에 현재 키 값을 넣어줍니다.

7

8

15

22

24

14

122

59

정렬을 시도한 데이터도 자기 위치를 찾았으니, 다음 정렬을 시도하면 됩니다.

 

이렇게 마지막 원소까지 왼쪽 데이터와의 비교, 정렬 과정을 반복하면, 아래와 같은 결과를 얻을 수 있습니다.

7

8

14

15

       22

24

59

122

 

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

 

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

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

 

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

22

8

15

24

7

14

122

59

22보다 큰 수를 왼쪽부터 찾으니, 24를 찾았습니다.

 

22

8

15

24

7

14

122

59

오른쪽에서부터 22보다 작은 수를 찾아보니 14가 있네요.

 

22

8

15

24

7

14

122

59

왼쪽에서부터 찾은 22보다 큰 수와, 오른쪽에서부터 찾은 22보다 작은 수를 바꿔줍니다.

 

22

8

15

14

7

24

122

59

다시, 왼쪽부터 찾은 22보다 큰 값과, 오른쪽부터 찾은 22보다 작은 값의 위치가 교차됐군요. 교차됐을 때에는 오른쪽부터 찾아온 값과 기준 값을 바꿔줍니다.

7

8

15

14

22

24

122

59

22를 기준으로, 22보다 큰 값을 오른쪽에, 22보다 작은 값을 왼쪽에 정렬 했습니다.

 

7

8

15

14

기준 값이었던 22보다 작은 원소가 모인 왼쪽 배열을 정렬해봅시다.

이번에는 7을 기준 값으로 잡았고, 왼쪽에서부터 7보다 큰 수인 8을 찾았습니다.

 

7

8

15

14

7보다 작은 수는 찾지 못했으므로, 그대로 두고, 정렬 완료 시킵니다. 그리고, 8을 새 기준 값으로 잡습니다.

7

8

15

14

7보다 작은 수는 찾지 못했으므로, 그대로 두고, 정렬 완료 시킵니다.
그리고, 8을 새 기준 값으로 잡습니다. 왼쪽부터 찾은 8보다 큰 수인 15를 찾았습니다.

7

8

15

14

오른쪽에서부터 8보다 작은 수를 찾았지만, 찾지 못했으므로 그대로 둡시다.

 

7

8

15

14

15를 기준 값으로 잡고, 15보다 큰 수를 찾았지만 없군요.

7

8

15

14

15보다 작은 수인 14를 찾았으니, 14와 바꿔줍니다.

7

8

15

14

더 이상 정렬할 대상이 없으므로 22보다 작은 블록의 정렬을 완료 합니다.


22

24

122

59

자 이번엔 22보다 큰 수들을 모아두었던 오른쪽 배열을 정렬해보겠습니다.
가장 왼쪽 원소인 22를 기준 값으로 잡고, 그 보다 큰 값을 왼쪽에서부터 찾던 중 22보다 큰 수인 24를 찾았습니다.

22

24

122

59

하지만, 22보다 작은 수가 없네요. 22는 정렬 완료시키고, 24를 다시 기준 값으로 잡겠습니다.

 

22

24

122

59

24를 기준 값으로 잡고, 24보다 큰 값을 찾았더니 122를 찾았습니다.

 

22

24

122

59

24보다 작은 값이 배열 내에 없군요. 24도 정렬 완료 시키고, 122를 다시 기준 값으로 잡겠습니다.

 

22

24

122

59

122를 기준 값으로 잡고 검색한 결과, 122보다 큰 수는 없군요.

 

22

24

122

59

122보다 작은 수에는 59가 있었습니다. 위치를 바꿔 준 후, 정렬 완료 하면 다음과 같은 결과를 얻을 수 있습니다.

22

24

59

122

오른쪽 배열도 정렬이 끝났네요.

 

7

8

14

15

       22

24

59

122

정렬이 끝난 왼쪽 배열과, 오른쪽 배열을 합쳐보면, 모든 데이터가 정렬 완료 되었다는 것을 알 수 있습니다.


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

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

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

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

Posted by 엘키 엘키

댓글을 달아 주세요

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

성훈 (sunghun84@nate.com)

(1) 메모리를 알자

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

예를 들어, 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) 과정을 보여주고 있습니다.

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

 

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

 

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

 

(2) 변수와 포인터의 차이점

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

 

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

 

) 변수의 포인터의 차이점

분류

변수

포인터

가지고 있는 값

데이터

메모리 주소

사이즈

데이터 형에 따라 다름

4Byte (32비트 운영체제에서)

 

 




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

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


(3) 포인터를 써보자

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

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


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

int no = 10;

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


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

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

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

 

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

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


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

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

) 포인터의 세가지 표현 방식

분류

의미

*ptr

포인터가 가리키고 있는 주소의 값

ptr

포인터가 가리키는 주소

&ptr

포인터 자체의 주소

 

 




 

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

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

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

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

포인터는 이미 존재하는 데이터를 가리키기 때문에, 데이터가 필요 할 때, 가리키고 있는 주소에서 데이터를 읽어옴으로써, 데이터의 중복을 막을 수 있는 것이죠.
 
함수에 값을 받을 때도, 포인터로 매개변수를 전달받으면, 주소가 전달 (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) : 프로그램이 사용할 수 있는 메모리 영역으로써, 임시로 사용되는 값들이나, 지금과 같이 동적으로 할당한 데이터가 존재할 수 있는 데이터 영역.

(6) 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포인터가 의미 없어졌지만, 아직도 여기저기 그 흔적이 남아있는 만큼, 알아두는 것도 나쁘지 않겠죠?

 

(6) 배열과 포인터

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

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

 

int i[10] = {1,2,3,4,5,6,7,8,9,10};

int 형 크기 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

배열 i 에 담겨 있는 값


보시다시피, 배열로 잡은 데이터는, 메모리상에 연속되어 데이터가 위치하고 있습니다.
메모리는 선형 구조 (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. 배열의 이름은, 배열의 시작 주소를 가리키는 포인터 상수다.

 

 

 

 

 

 

 

 

(7) 함수 포인터
포인터 중에는 변수만이 아니라, 함수의 위치를 가리킬 수 있는 함수 포인터(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가 반환됨


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

 

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

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

Posted by 엘키 엘키

댓글을 달아 주세요

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

성훈 (sunghun84@nate.com) 

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

컴퓨터는 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언어)등이 나오게 되었습니다.

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

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

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

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

(2) 내가 이걸 배워서 과연 실전에 사용할 수 있을까?
많은 분들이 하시는 고민.
바로 그것이 과연 지금 배운 문법들로 프로그램을 만들 수 있겠는가 하는 것입니다.
결론부터 말하자면 "그렇다"는 것입니다.

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

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

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

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

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

(3) 최적화
프로그램을 만들 때에, 여러분이 생각하시는 최적화는 이 중에서 무엇인가요?
1. 유지보수가 쉬운 읽기 쉬운 코드
2. 빠른 속도
3. 메모리 사용의 효율
4. 유저 편의
5. 시스템 리소스 사용의 효율
6. 시간 투자 효율


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

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

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

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

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

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

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

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

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

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

 

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

 

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

Posted by 엘키 엘키

댓글을 달아 주세요

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

성훈 (sunghun84@nate.com) 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(5) 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게임을 위해 실수 연산 속도를 끌어 올린 그래픽 카드들로 인해 이런 문제는 많이 해결됐죠.

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

Posted by 엘키 엘키

댓글을 달아 주세요

C 프로그래머가 알아야 할 것들 - Chapter 2 비트의 법칙

성훈 (sunghun84@nate.com) 

(1) 비트가 뭐지?
비트란 이진수(Binary Digit )의 약자로써 컴퓨터에서 제어 가능한 데이터의 최소단위입니다.
하지만, 컴퓨터에서 입 출력할 때 사용하는 최소 단위는 바이트죠. 둘 다 최소단위라는 건 알겠는데 정확한 차이가 뭐냐고요?

비트란 저번 강좌에서 배웠던 2진수 10 (10진수 2)을 2비트(2진수 2자리 수이기에)로 표현 가능하고 제어 가능하단 의미고, 바이트는 비트 8개가 모여서 구성된 것이 1바이트로, 파일이나 데이터 형의 최소단위로 쓰입니다.

(2) 프로그래밍과 비트는 무슨 상관일까?
비트가 2진수를 의미한다는 건 알겠는데 도대체 프로그래밍에서 비트가 무슨 의미가 있는 것인지 궁금해하실 분들이 계실 거라고 생각합니다.

하드웨어의 발전에 따라 조금 더 빠른 프로그램 보다는 쉬운 사용법과, 코드 재사용 또는 유지 보수가 쉬운 프로그램을 만드는 것이 더 중요해졌고, 그로 인해 C++을 대신할 차세대 언어라 불리는 자바, C#이 등장했습니다. 하드웨어가 발전함에 따라 그 하드웨어의 기능을 활용하기 위한 기술이 적용 되다 보니 여전히 빠른 프로그램을 작성할 필요성은 존재합니다.

속도 면에서 타의 추종을 불허한다는 C/C++에서도, 속도 최적화에 크게 민감하지 않은 자바에서도, 코드 수행의 근본적인 속도 문제는 벗어 날 수 없습니다.

어떤 언어를 사용하건 간에, 결국엔 기계어로 번역되어 수행되기 때문에 가능하다면 빠르고 효율적인 프로그램을 작성하도록 하는 것이 좋습니다.

대부분의 알고리즘은 N (임의의 수)에 비례해서 수행속도가 증가하는데, N이 증가할 때마다 제곱으로 수행 시간이 증가해 100년 이상 걸려야만 수행이 완료되는 경우도 있기에 여전히 프로그램의 수행 시간은 중요합니다.

최적화 된 프로그램이란 메모리와 속도 모두 만족시키는 프로그램을 말하는데, 그것을 만족하기 위해선 비트 단위 연산 또는 처리가 필요 할 때가 많기 때문에, 비트 연산에 대해서도 알아 두는 것이 좋다고 볼 수 있는 것이죠.

(3) 데이터 형
C언어에서는 다양한 데이터 형을 제공합니다.
여기서는 주로 사용되는 몇 개의 데이터 형만 가지고, 비트와 관련해 알아보도록 하죠.

문자 형으로 알려진 char (캐릭터 형)는 1바이트로 이루어져있습니다.
1바이트=8비트이므로, 0000 0000 이렇게 8자리 2진수만큼 사용할 수 있는데, 2의 7승은 256 (첫 자리가 2의 0승이므로, 8비트의 경우 2의 7승만큼 사용 가능합니다) 이므로, 부호가 없는 경우는 0~255, 부호가 있는 경우는 -128~127까지 사용 가능하게 되는 겁니다. 부호가 있는 경우는 최상위 비트를 부호 비트로 사용하게 되므로 사용 가능한 수의 범위가 반으로 줄 게 됩니다.

2바이트 데이터 형인 short int 도, 2의 15승인 65536만큼의 수를 사용할 수 있는데, 부호 없는 경우 0~65535, 부호가 있는 경우 -32768~32767까지의 수를 사용할 수 있습니다.

데이터 형

크기(바이트)

부호

표현 범위

int

4

있음

-2147483648~2147483647

short int

2

있음

-32768~32767

long int

4

있음

-2147483648~2147483647

unsigned int

4

없음

0~4294967295

unsigned short int

2

없음

0~65535

signed char

1

있음

-128~127

unsigned char

1

없음

0~255


위 표에서 알 수 있는 것은, C언어 계열에서는 별도 표기를 하지 않는 이상 signed(부호 있는)로 인식한다는 점 입니다.

실수 형은 단순히 2진수로 데이터를 담고 있지 않습니다.
다음과 같은 형식으로 실수를 표현하고 있습니다.

부호

지수부

가수부


부호는 말 그대로 +- 부호를 의미하고, 가수부는 값을 의미합니다. 지수부는 10의 거듭승을 의미합니다.


좀 더 정확히 말하자면,
지수부는 그 자체가 지수부의 값이 아니라 이 수치에서 bias 값을 빼 주어야 그 값이 나옵니다. 일반적으로 float에서 bias의 값은 127 입니다. 가수부는 가장 좌측의 값이 20를, 그 다음이 2-1, 그 다음은 2-2… 입니다.

부호를 s, 지수부를 e, bias를 b, 가수부를 m 이라고 한다면 그 값은


(-1)s * 1.m * 2e - b
로 표현하면 됩니다.

6 을 부동 소수점 실수로 표현해봅시다.

6 = 1.5 * 2^2 = 0100 0000 1100 0000 = 40 C0


부동 소수점 실수를 우리가 일반적으로 사용하는 인텔 프로세서에서 float형으로 표현할 경우, 순서가 조금 다릅니다.

6.0f = 0000 0000 0000 0000 1100 0000 0100 0000 = 00 00 C0 40

이를 리틀 엔디안 표기법이라고 하구요, 이는 6번째 주제로 다루고 있으니 거기서 더 자세히 얘기해보도록 하고 넘어가죠.

 

데이터형

크기(바이트)

표현 범위

float

4

3.4*10-38~3.4*1038

double

8

1.7*10-308~1.7*10308

long double

10~16

1.2*10-4932~3.4*104932


실수 형의 경우 값의 표현 범위가 넓은 대신 정밀도가 떨어지고 오차가 존재합니다. 그렇지만 고정 소수점 소수에 비해 표현 범위가 넓은 것이 장점이 되어 현재 사용되고 있습니다. 어떤가요? 도움이 조금 되시나요?
 

이렇듯 컴퓨터에서 사용되는 데이터들은 모두 비트 기반으로 이루어져 있습니다.

(4) 바이트로 구성된 파일
비트 8개가 모여서 만든 바이트가 모여서 만들어진 것이 파일입니다.
우리가 흔히 사용하는 이미지 파일들도 알고 보면 색상 정보를 담고 있는 바이트의 집합입니다. 여기서 조금 부연 설명을 하자면, BMP(비트맵)파일의 경우는 헤더나 컬러 테이블 정보도 담고 있긴 하지만, 일반적으로는 RGB색상 값만 가지고 있다고 보시면 됩니다. RGB 색상 값은 Red 1바이트, Green 1바이트, Blue 1바이트씩 저장됩니다.

색상 값을 가지고 있다가 프로그램에서 BMP파일을 읽어 들였을 때, 색상 정보를 읽어서 RGB색상 값을 조합 한 후 점을 찍어서 (BitBlt함수로도 찍을 수 있는데 라고 생각 하실 분도 있으시겠지만, BitBlt함수도 결국 내부적으론 점을 찍어서 표현해줍니다) 그 색상을 모니터에 표현하도록 명령을 내려주기 때문에, 우리 눈에는 컬러 이미지를 볼 수 있는 겁니다.

저장되어 있는 RGB색상 값을 아무런 압축도 하지 않고 모두 가지고 있는 경우가 위에서 설명한 BMP파일 포맷이고, JPG의 경우는 고도의 압축 기법(압축률도 지정 가능 합니다)을 통해서 용량을 줄였지만 결과적으로는 색상 값을 가지고 있다 신장(압축해제)후 화면에 뿌려주는 원리는 비슷합니다.

벡터 그래픽 파일의 경우에는 점, 선, 곡선 등의 정보를 저장하고 있는 포맷이지요. 그래서 화면에 축소나 확대에서도 같은 이미지를 볼 수 있는 것 이지요.

동영상 파일도 많은 양의 그림 정보를 담고 있다가, 1초에 몇 번 이상(1초에 몇 번 화면이 갱신되는지를 프레임이라고 하는데, 초당 24내지 30프레임은 되어야 깜빡임이 사람 눈에 보이지 않는다고 합니다. 일반적으로 모니터의 경우는 60번 이상 갱신되고 있죠)갱신되는지에 따라 그것을 재생시켜줍니다.

음악파일도 마찬가지로, 소리 정보를 디지털 값(수치 값)으로 가지고 있다가, 그 정보를 재생시간에 맞춰 재생하는 방식을 취하고 있죠.

Star Craft의 Replay 파일의 경우에도 비슷하게 첫 위치 값을 저장한 후에 거기서 변화한 값과 내린 명령,시간 값들을 저장했다가, Replay 메뉴에서 재생 시 그 정보를 바탕으로 게임을 재생시키는 방식을 취합니다. 그래서 Replay파일의 용량이 그다지 크지 않은 것입니다.

게임 세이브 파일의 경우에도, 그 캐릭터의 레벨, 무기 일람, 체력, 공격력, 방어력 등의 Parameter와, 그 캐릭터의 현재 위치, 플레이 시간 등의 정보를 저장하고 있습니다. 물론 Edit가 힘들 게 하기 위해 단순한 파일 구조(순차적)으로 구성하지 않는 경우가 대부분이긴 하지만요.

핵심은 모든 데이터는 바이트 단위로 저장된다는 것. 그 바이트를 구성하고 있는 것은 비트라는 것입니다.

(5) 비트 연산자
자 비트에 대해 배웠으니 비트 연산을 한번 해봐야겠죠?
기본적으로 C언어에서의 대입은, 데이터 형 단위로 제어가 되죠. 하지만, 비트 연산이란, 비트 단위로 데이터를 제어하는 것을 말합니다.

비트연산에 사용되는 비트연산자란 논리 곱 (&) , 논리 합 (|), 논리 부정 (~), 베타적 논리합(^)이 있습니다. 부울 대수를 배우셨던 분들은 익숙하실 겁니다.

논리곱(AND : &) 둘 다 1일 때만 참(TRUE)이 됩니다.

입력 값 1

입력 값 2

결과

0

0

0

0

1

0

1

0

0

1

1

1

논리합(OR : |) 둘 중에 하나라도 1이면 참(TRUE)이 됩니다.

입력 값 1

입력 값 2

결과

0

0

0

0

1

1

1

0

1

1

1

1


베타적 논리합 (XOR : ^) 두수의 값이 달라야만 참(TRUE)이 됩니다.

입력 값 1

입력 값 2

결과

0

0

0

0

1

1

1

0

1

1

1

0

논리 부정 (NOT : ~) 0은 1로, 1은 0으로 만들어줍니다.

입력 값

결과 값

1

0

0

1


비트의 위치를 이동 시키는 시프트 연산자 (SHIFT) 라는 것도 있습니다.

좌측 시프트 연산자 (<<)는 비트를 왼쪽으로 옮겨주고, 빈자리엔 0을 넣어줍니다.

0011 0010 << 2

0011 0010 (10진수 50)에 <<연산자로 비트를 왼쪽으로 두 번 옮겨보겠습니다.

1100 1000

왼쪽으로 2번 이동했더니 수가 10진수 200이 되었습니다. 시프트 하기 전 값에 4배 (50 x 4 = 200)가 되었죠? 비트를 왼쪽으로 한번 옮길 때마다 수가 2배가 된다는 것을 아실 수 있을 겁니다.

우측 시프트 연산자 (>>)는 비트를 오른쪽으로 옮겨줍니다.
오른쪽 시프트의 경우는 좌측 시프트와는 다르게 항상 0 이 들어 가는 것이 아니라. (시프트 하는 데이터 타입이 무엇인가에 따라, 혹은 컴파일러에 따라 다릅니다) 일반적으로, unsinged 타입일 경우 -> 0 이 들어가고 (`논리적 시프트`), singed 타입일 경우 첫 비트가 1 이면 1을 0이면 0을 채우게 됩니다. (`산술적 시프트`)

0011 0011 >> 2

0011 0010 (10진수 50)에 >> 연산자로 비트를 오른쪽으로 두번 옮겨보겠습니다.

0000 1100

우측으로 2번 이동했더니 수가 0000 1100 (10진수 12)가 되었습니다. 4분의 1(12.5여야 하지만, 소수점 이하는 버립니다) 이 되었죠?

이렇게 됩니다.


(6) 생각보다 간단한 압축
압축방법에는 Run Length Code, Huffman Code 등 다양한 방법이 있지만, 여기선 시프트 연산자와 비트 연산자의 조합으로 간단한 압축을 구현해보겠습니다.

아까 데이터를 압축하려면 현재 데이터에서 변화한 정보를 담는 것이 좋다고 했었죠? 그 원리를 이용하는 것입니다.

주로 다른 파일보다는 이미지, 음악 파일, 동영상 파일 등에 사용되는 데이터 압축의 원리는 이 바이트를 기본으로 한 데이터들이 비트로 이루어져있다는 것이 핵심입니다.

런 렝스 코드 (Run Length Code)는 특정 값이 몇번 반복되는지를 저장하는 방식입니다.

AAAABCCDDDEEEFFFFFFF

라는 데이터가 있다면,

A4B1C2D3E3F7

로 원래 데이터와 반복 횟수를 표기하는 방법입니다.

문자열 반복이 잦다면 압축률이 높겠지만, 문자열의 반복 빈도가 낮다면 런 렝스 코드를 적용했을 때 용량이 더 커지기도 합니다.


ABCDEFG

라는 문자열이 있을 때, 이 문자열을 런 렝스 코드를 사용하면,

A1B1C1D1E1F1G1

이렇게 반복이 없을 땐 오히려 용량이 두 배가 되기 때문이죠.

허프만 코드(Huffman Code)는 자주 사용되는 문자를 짧은 코드로, 덜 사용되는 문자를 긴 코드로 변환해서 압축하는 방법의 압축 법입니다.

AAAABCCDDDEEEFFFFFFF

위 데이터가 있을 때, 원래 대로 라면 21바이트가 필요하겠죠?

허프만 코드를 이용해서 표현해보겠습니다.

AAAA B CC DDD EEE FFFFFFF


반복 횟수가 적은 순에 따라 비교해가며 값을 대입해주면, 아래와 같은 결과를 얻을 수 있습니다. (같은 값 처리나, 트리 만들어 나가는 과정은 세부 알고리즘에 따라 다릅니다. 현재는 같은 값일 경우 왼쪽에 위치한 값을 더 작다고 판정하였습니다.)



데이터

변환된 값

변환된 비트 크기

반복횟수

소요 비트

F

0

1

6

6

A

10

2

4

8

E

110

3

3

9

D

1110

4

3

12