2013년 9월 3일 화요일

C Experiment (1)

C언어 기초에 대한 instruction 도중 돌발 질문이 들어왔다.
정수형 포인터를 선언할 때

int*j;

이렇게 띄어쓰기 없이 모두 붙여써도 에러가 나지 않는가?

순간 살짝 당황스럽기도 하고 궁금하기도 하여 한번 해 보기로 했다.

#include <stdio.h>

int main()
{
    int i[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int*j;

    j = i;
    printf("%d\n", *j);

    j += 5;
    printf("%d %d %d\n", *j, *(j+1), j[-3]);
    printf("%d %d %d\n", *i, *(i+1), i[-1]);

    return 0;
}

결론부터 말하면 컴파일이 아무 문제 없이 잘 된다. 그렇다면 실행은?

1
6 7 3
1 2 -1216678940

예상한 답이 그대로 나왔다.

결론은, 위의 돌발 질문과 같이 사용해도 에러가 나지 않으며, 심지어 잘 실행이 된다.
printf 부분에서 사용한 배열의 index에 음수가 들어간 부분 역시 잘 실행이 된다.
(물론 i[-1] 의 값은 unsafe하지만...)

여기서 다시 한번 확인할 수 있다.

배열을 사용할 때 선언부에서는 할당할 양을 지정해주어야 하므로 항상 [] 안에 묵시적, 혹은 명시적으로 양의 정수값이 들어가야 하지만, 선언부가 아닌 곳에서 [] 를 사용할 때에는 양수든 음수든 0이든 정수값이면 된다는 것이다.

왜냐하면 C언어에서는 선언부가 아닌 곳에서는 다음의 식이 서로 완전히 같기 때문이다.
즉 , *(i+1) 와 i[1] 은 완전히 같은 의미이다.

또한 void * 를 제외한 포인터는 모두 증감연산이 가능하므로 당연히 *(i-1) 과 같은 식이 유효하며, 그 뜻은 즉 i[-1] 도 가능하다는 뜻이다.

이상, trivial 한 사실일 지도 모르겠지만 C의 철학(?)을 다시금 느끼게 된다.

2013년 8월 27일 화요일

C 언어에서 나머지 연산자 % 에 대하여

우리가 통상 사용하고 있는 나머지 연산자 (%) 에 대한 짧은 이야기를 해 보겠다.

나머지 연산자는 얼핏 보면 나눗셈 연산자 (/) 와 더불어 정수의 몫과 나머지를 구하는
데에 사용되기도 한다. 그런데 다음의 경우를 비교해 보자.

[예 1]  5 /  2
[예 2] -5 / -2
[예 3]  5 / -2
[예 4] -5 /  2

[예 1]의 답은 당연히 2 이다. 정수에서는 소수점 이하는 버리기 때문이다. 또한 [예 2], [예 3] [예 4] 의 답도 각각 2, -2, -2 이다. (초등학교때 배웠던 양수와 음수의 곱셈과 나눗셈 법칙과 같다.)

그렇다면 다음의 예는 어떨까?

[예 5]  5 %  2
[예 6] -5 % -2
[예 7]  5 % -2
[예 8] -5 %  2

실제로 결과를 보면 알겠지만 결과는 각각 1, -1, 1, -1 로 계산되어 나온다. 이렇게 나오는 이유를 설명하겠다.

C 언어의 % 연산자에서 음수 처리는 다음과 같이 정의되어 있다.
X % Y 를 연산할 때 그 연산 결과의 부호는 X 의 부호를 따라간다. 즉 Y의 부호는 무시되며 오직 X가 양수이면 결과는 양수, 음수이면 결과는 음수가 된다. 

쉽게 말하면 [예 5] ~ [예 8]의 결과는, 일단 부호가 모두 양수라 가정하고 계산을 한 후 앞의 수의 부호를 붙인다는 뜻이다.

여기서 한 가지 고찰을 해 보자. 얼핏 보기에 저렇게 정의되는 나머지 연산이 별 것 아니라 느껴지겠지만, 우리가 나머지 연산을 사용하여 구현하는 것 중 일정 주기의 offset 을 구하는 등의 다음과 같은 경우가 있을 수 있겠다. 예를 들어 앞의 수가 차례로 증가 혹은 감소할 때마다 0, 1, 2, 3 의 번호를 주기적으로 부여하는 경우이다.

...
int i;
for( i=100; i>0; i-- )
{
    printf("번호 %d 는 %d 조 입니다.\n", i, i%4);
}
...

기대하는 것은 0, 3, 2, 1, 0, 3, 2, 1, 0, 3, 2, 1, 0, ... 식으로 주기적으로 조를 정해주는 것인데, 만일 i 가 0보다 작은 음수로 들어가게 되면, 즉

...
int i;
for( i=50; i>=-50; i-- )
{
    printf("번호 %d 는 %d 조 입니다.\n", i, i%4);
}
...

이런 경우에는 양수에서 음수로 바뀌는 부분에서 기대하는 것 처럼 조를 정해주지 않는다. 
즉, 결과가 다음과 같이 나오게 된다. 

... 3, 2, 1, 0, 3, 2, 1, 0, -1, -2, -3, 0, -1, -2, -3 ...

이런 경우는, 결과가 음수일 경우 4를 더하는 등의 방법 등으로써 해결하는 구문이 추가로
요구된다.

^^


즉, 요점은 이렇다.
연산자 자체의 기능만을 설명한 것을 본다면 어렵지 않고 별로 중요하지 않은 것처럼 보이지만, 실제로 이를 응용하여 실전에서 사용하게 될 경우에는 자신도 몰랐던 예상치 못한 결과가 나올 수도 있다는 것을 항상 염두해야 한다는 것이다.

이것이 올바르고 효과적인 디버깅 자세의 시작이 아닐까 한다.

-----------------------

또하나 (중요한 이야기를) 추가하면...

나머지 연산은 실수형에선 동작하지 않는다는 것이다. 즉

  5 % 2

의 값은 1 로 제대로 나오지만

  5.0 % 2.0
  5 % 2.0
  5.0 % 2

등은 컴파일이 되지 않는다는 것을 추가로 알아두자.


2013년 8월 17일 토요일

printf에 대하여

C언어를 공부할 때 가장 처음으로 접하게 되는 표준 출력 함수가 바로 printf 이다. C의 표준 출력 함수는 printf 말고도 fprintf, sprintf, 혹은 purchar, puts 등 여러가지가 있지만 사용자들이 가장 많이 사용하면서 또한 여러가지 기능을 간편하게 사용할 수 있는 함수인 printf를 많이 사용하고 있다.

그런데 C언어를 계속 공부하다 보면 이 printf 라는 함수가 상당히 막강한 기능을 가지고 있는 함수라는 걸 알 수 있다. 실제로 고급 C언어나 Embedded C언어를 공부하다 보면 printf 함수를 직접 구현해보라는 문제에 봉착하게 되는데, 실제로 구현이 좀처럼 쉽지 않은 함수이다. 이번 글에서는 이 막강한(?) printf 에 대한 이야기를 자세히 해 볼까 한다.

우선 printf 이 선언되어 있는 형태를 보면 다음과 같다.

int printf( const char *template, ... );

일단 반환값이 void 가 아니라 int 라는 것부터 신기하다. printf함수에 반환값이 있다니.... 실제로 printf 함수를 사용하면 그 결과값으로 실제로 찍은 문자의 개수가 정수 형태로 반환된다.

또한 입력값을 넣는 부분 또한 특이하다. 맨 앞에 const char * 형이라는 것은 알겠는데 그 다음에 ... 라고 적혀 있는 부분이 신기하다. 실제로 표준 C에서는 가변 입력값을 지원하는데 (관련 헤더 파일 : stdarg.h) 입력값이 몇 개인지 정해지지 않을 경우 쓰는 기법이다. printf의 경우는 입력값으로 맨 앞에 const char * 형태가 들어가야 한다는 필수 요소 이외에 나머지는 상황에 따라 입력값을 달리 넣을 수 있도록 선언된 형태이다.

printf에 대하여 하나 하나 살펴보자.
맨 처음 const char *template 에는 문자열이 들어간다. 이 문자열은 그냥 문자열이 아닌 printf 가 추가로 해석이 가능한 형태의 문자열이 들어간다. 다시 말하면, printf 라는 함수는 template 문자열 내에 % 라는 문자가 들어가면 자체적으로 이를 해석하여 다른 것으로 치환을 하게 된다. 그 치환을 하는 대상은 template 다음에 오는 입력값으로서 첫번째 %에는 template 다음 첫번째 입력값, 두번째 % 에는 두번째 입력값... 이런 식이다.

가장 대표적인 정수 출력을 예로 들어본다. %d 는 정수 출력으로 치환된다.

printf("Value is : %d\n", 100);

출력:
Value is : 100

int age = 30;
int height = 168;
int weight = 70;
printf("age[%d], height[%d], weight[%d]\n", a, h, w);

출력:
age[30], height[168], weight[70]

%d 이외에 다음과 같은 것들이 있다.

-------------------------
[정수형]
%d : 정수형을 10진수로 출력한다. MSB를 체크하여 음수일 경우 음수로 출력한다.
   (MSB(Most Significant Bit)는 값을 2진수로 표시했을 때 가장 윗자리수를 뜻한다. 보통 signed 형일 경우 음수이면 1, 양수이면 0이다.)
%o : 정수형을 8진수로 출력한다 MSB를 체크하지 않는다. (무조건 unsigned 형태의 양수로 출력한다.)
%u : 정수형을 10진수로 출력한다. MSB를 체크하지 않는다. (unsigned 형태의 양수로 출력)
%x, %X : 정수형을 16진수로 출력한다. MSB를 체크하지 않는다. (unsigned 형태의 양수로 출력) x 가 소문자이면 알파벳 숫자가 소문자로, 대문자이면 대문자로 출력된다.

[실수형]
%f : 실수형을 고정소수점 형태로 출력한다. ( 예 : 3.500000 )
%e, %E : 실수형을 지수형 형태로 출력한다. ( 예 : 3.500000e+00 ) e가 소문자이면 소문자, 대문자이면 대문자( 3.500000E+00 )로 표시된다.
%g, %G : 가장 간단한 형태를 선택하여 출력한다. (역시 g가 소문자이면 소문자, 대문자이면 대문자로 표시함.)

[문자형]
%c : 문자 하나를 출력한다.
%s : 문자열을 출력한다. 문자열의 첫 문자의 주소값을 함수의 인자로 넣는다.

[기타]
%p : 포인터 값(주소값)을 출력한다.
%% : % 문자 자체를 출력한다. printf에서는 해석할 대상을 %로 구분하므로 % 자체를 출력할 경우를 위해서 이 기능을 지원한다.

예:
printf("%d%%!!!\nOK!!!\n", 100);

출력:
100%
OK

-------------------------

printf 는 포맷을 맞추어 출력한다는 뜻이다. 따라서 위의 정수형, 실수형, 문자형을 사용자가 원하는 형태로 출력할 수 있다. 예를 들어 다음과 같다.

printf("age[%5d]\nheight[%10d]\nweight[%3d]\n", 30, 168, 70);

출력:
age[   30]
height[       168]
weight[ 70]

위와 같이 % 와 d 사이에 적혀있는 수에 따라 출력할 수 있는 공간을 확보하여 정할 수 있다.

정수형일 경우 추가 flag의 예를 들으면 다음과 같다.

(1) printf("[%5d]", 20);     // [   20]
(2) printf("[%5.3d]", 20);   // [  020]
(3) printf("[%-5d]", 20);    // [20   ]
(4) printf("[%+5d]", 20);    // [  +20]
(5) printf("[%+5d]", -20);   // [  -20]
(6) printf("[%-+5d]", 20);   // [+20  ]
(7) printf("[%5x]", 20);     // [   14]
(8) printf("[%#5x]", 20);    // [ 0x14]
(9) printf("[%5.3x]", 20);   // [  014]
(10)printf("[%5o]", 20);     // [   24]
(11)printf("[%-#5o]", 20);   // [024  ]

. 뒤의 수는 "반드시 표시해야 할 자릿수"를 뜻한다. (2), (9)
- 는 왼쪽 정렬을 뜻한다. (보통은 오른쪽 정렬이다.)  (3), (6), (11)
+ 는 양수일 경우 +를 표시한다. (음수인 경우는 언제든지 -가 표시된다.) (4), (5)
# 는 8진수, 16진수를 표시할 경우에만 적용하며, 8진수는 0이 앞에 붙고, 16진수는 0x (혹은 0X) 가 앞에 붙는다. (8), (11)


실수형일 경우 추가 flag은 다음과 같다.

. 뒤의 수는 "소숫점 이하 몇자리로 표시할지"를 뜻한다.
-, + 는 정수형일때와 같다. (-: 왼쪽 정렬, +: 양수일 경우 + 표시)
# 는 %g, %G 일 경우에만 표시하며 소숫점 이하의 자리수를 반드시 표시할 경우 표시한다.

(1) printf("[%10f]", 2.5);     // [  2.500000]
(2) printf("[%10.3f]", 2.5);   // [     2.500]
(3) printf("[%-10f]", 2.5);    // [2.500000  ]
(4) printf("[%+10f]", 2.5);    // [+2.500000 ]
(5) printf("[%10e]", 2.5);     // [3.500000e+00]
(6) printf("[%10g]", 2.5);     // [       2.5]
(7) printf("[%10.3g]", 2.5);   // [       2.5]
(8) printf("[%#10.3g]", 2.5);  // [     2.500]

문자열형 (%s) 의 경우 추가 flag는 다음과 같다.

. 뒤의 수는 "앞에서 몇자만을 찍을지"를 뜻한다.
- 는 왼쪽 정렬을 뜻한다. (보통은 오른쪽 정렬이다.)

(1) printf("[%s]", "print");      // [print]
(2) printf("[%10s]", "print");    // [     print]
(3) printf("[%10.3s]", "print");  // [       pri]
(4) printf("[%-10.4s]", "print"); // [prin      ]


--------------------------
printf 의 경우 % 후에 수 대신 * 가 들어가면 수 또한 인자로 받겠다는 뜻이다.

(1) printf("[%*d]", 5, 100);           // [  100]
(2) printf("[%5.*s]", 3, "print");     // [  pri]
(3) printf("[%*.*s]", 10, 1, "print"); // [         p]

--------------------------

이 이외에도 더 많은 기법이 있다. 자세한 사항은 다음의 사이트를 참고한다.
https://www.gnu.org/software/libc/manual/html_mono/libc.html#Output-Conversion-Syntax

이상 printf 의 사용법에 대한 글을 마친다.

2013년 8월 13일 화요일

C의 문자와 문자열

C에서는 특별히 문자나 문자열에 관한 자료형이 존재하지 않는다. 다만 char 자료형이 8비트이므로 이를 문자나 문자열 자료형을 대신해서 사용한다.

(1) 문자

컴퓨터에서 사용하는 문자는 대부분 고유의 ASCII 코드를 가지고 있다. (물론 요즘은 다국어 지원 등으로 ASCII 외에 유니코드, UTF-8, UTF-16 등의 체계 또한 많이 사용하며 C 표준에도 이를 지원하는 함수가 존재하지만 이 모든 체계들이 기본적으로 ASCII 와 호환이 되도록 설계되었으므로 여기서는 생략하기로 한다.) 예를 들어 문자 'A' 는 65, 문자 '0' 은 48, 문자 '&' 는 38, 줄바꿈(라인피드) 문자는 10 등이다. 이 ASCII 코드가 127을 넘지 않으므로 char로 표현할 수 있는 것이다.

C에서는 모든 문자는 ASCII 코드로 저장한다. 즉, ABCDE 라는 문자 5개를 저장하고 싶다면 char의 배열을 사용하여

char c[5] = { 65, 66, 67, 68, 69 }; // 예 1

라고 하면 된다.

또 하나. 문자 하나에 작은 따옴표로 묶어도 같은 의미이다. 즉

char c[5] = { 'A', 'B', 'C', 'D', 'E' }; // 예 2

는 위의 [예 1]과 같다.

즉 'A' 와 65는 C언어에서는 같은 의미이다.

또한 화면 등으로 표현할 수 없는 문자(줄바꿈 등) 혹은 C언어의 문법상 직접 표현할 수 없는 문자 (따옴표 자체 등) 을 표현하기 위하여 특수문자 개념을 두었다. 특수문자는 역슬래시(\)를 이용하여 표현하며 역슬래시 바로 다음 문자를 보고 판단한다. 예를 들어 다음과 같다.

'\n' : 줄바꿈 문자
'\'' : 작은 따옴표
'\"' : 큰따옴표
'\t' : 가로 탭 문자
'\0' : null 문자
'\\' : 역슬래시 문자

이러한 식으로 거의 모든 문자를 표현할 수 있다.


(2) 문자열

앞서 말했듯이 C언어는 문자열 자료형이 따로 없다. 다만 위에서 약간 짐작은 했겠지만 char의 배열로 문자열을 대신 표현할 수 있다.

C에서 문자열 자료형은 따로 없지만 문자열 자체를 표현하기 위하여 " "(큰따옴표) 를 지원한다. 예를 들어 문자열 ABCDE 를 저장하기 위하여 다음과 같이 정의할 수 있다.

char c[10] = "ABCDE"; // 예 3

그런데 문제가 있다. C언어에서는 문자열이라는 자료형이 따로 없기 때문에 저렇게 char형의 배열을 이용하여 문자열을 저장했다 하더라도 그 문자열의 길이가 얼마인지를 알 수 없다. 그렇다고 배열 c 의 길이(10)를 문자열의 길이라고 할 수도 없다. 하지만 눈으로 봐도 알 수 있듯이 문자열의 길이는 5이며, 또한 10개의 공간에 5개의 문자를 저장하였으므로 저 구문도 전혀 틀리지 않았다.

그렇다면 위의 [예 3] 과 아래 구문과는 같은 것일까?

char c[10] = { 'A', 'B', 'C', 'D', 'E' }; // 예 4

얼핏 보기엔 같아 보인다. (실제로 문자열을 출력해 보아도 같다.) 하지만 실제로는 미세한 차이가 있다.

"ABCDE" 라는 문자열을 문자 하나 하나 풀었을 때 무엇일까?
'A', 'B', 'C', 'D', 'E' 라고 생각할 수 있겠지만, (C 프로그래밍을 하는 분들은 다 알겠지만) 한 문자가 더 들어가야 한다. 바로 '\0' 이라는 NULL 문자이다.

앞서 말했듯이 C언어는 문자열 자료형이라는 것이 따로 없기 때문에 문자열이 어디부터 어디까지인지를 알 수 있는 방법이 딱히 없다. 따라서 문자열의 끝을 알리는 '\0' 문자가 들어가게 된다. ( '\0' 문자의 ASCII 코드는 0 이다.)

따라서 [예 3] 의 경우는 다음과 같다고 보아야 한다.

char c[10] = { 'A', 'B', 'C', 'D', 'E', '\0' }; // 예 5

그런데 [예 3] 과 [예 4] 가 미세한 차이가 있음에도 불구하고 결국 같은 목적을 달성하는 이유는 무엇일까? 그것은 C 언어에서의 배열의 초기화 특성이 있기 때문이다.

char c[10]; 에서 c 는 10개의 char(문자) 값을 저장할 수 있는 배열이며, 원래 초기화할 때에는 10개의 값이 들어가야 한다. 하지만 [예 4] 를 보면 5개의 값만 들어가 있다. 이럴 경우에는 C에서는 나머지 5개의 값에 모두 0을 넣는다. 따라서 결국 [예 5] 와 같은 결과를 갖게 된다. ('\0' 의 ASCII값은 0이라고 앞서 이야기했다.)

[예 3]과 [예 4] 의 미세한 차이가 문제를 발생시키는 경우는 아래의 경우이다.

char c[5] = { 'A', 'B', 'C', 'D', 'E' }; // 예 6 : 문제 없음
char c[5] = "ABCDE"; // 예 7 : 문제??

[예 6]의 경우, 5개의 문자를 저장할 수 있는 배열에 딱 5개가 들어갔다. 이는 옳은 표현이다. (물론 [예 6] 과 같이 저장해 놓고 이를 문자열로 사용할 때에는 문제가 있지만...)
하지만 [예 7]의 경우는 문제가 있는 표현이다. C언어에서는 배열의 크기가 5라고 하여 이를 5개까지만 채우고 나머지를 무시하는 작업을 하지 않는다. 즉,

char c[5] = "ABCDEFG";

라고 한다고 F, G 를 무시하지 않는다는 것이다. F, G 도 어딘가에 저장된다. 그런데 그 저장되는 장소가 어디가될지 불분명하기 때문에 위와 같이 코딩을 하면 프로그램 실행시 치명적인 오류가 발생할 수 있다. (컴파일러는 이 오류를 알려주지 않거나 단지 warning으로만 알려주며, 오류 없이 정상적으로 컴파일은 완료된다.)

다음을 보자.

#include <stdio.h>

int main()
{
    char c[5] = "ABCDEFG";
    char d[5] = "123";

    printf("[%s] [%s]\n", c, d);
    return 0;
}

결과는 다음과 같이 나온다.

[ABCDE123] [123]

의도한 결과와는 다소 다르게 나오게 된다.
위에서 c 를 정의하는 코드를

char c[5] = "ABCDE";

라고 변경해도 같은 결과이다.

따라서 문자열을 저장하거나 다룰 때는 끝에 항상 '\0' 이 있다는 것을 고려하여 '\0' 까지 저장할 수 있는 공간을 마련하여야 한다.

2013년 8월 10일 토요일

C 언어의 자료형 크기에 관하여...

C언어의 자료형은 크게 정수형과 실수형으로 나눌 수 있다.
정수형은 0과 자연수만을 표현하는 형(unsigned)과 음수도 표현할 수 있는 형(signed)으로 또다시 나뉘는데 이 이야기는 여기서는 넘어가기로 한다.

대개 많은 블로그나 책자에서 자료형의 크기를 이렇게 설명하고 있다.

정수형 크기
char    1바이트 (8비트)
short   2바이트 (16비트)
int     4바이트 (32비트)
long    4바이트 (32비트)
long long   8바이트 (64비트)

실수형 크기
float   4바이트 (32비트)
double   8바이트 (64비트)
long double 12바이트 (96비트)

그러나 이것은 32비트 시스템에서 저렇게 정의된다. 요즘은 개인 PC에서도 64비트 시스템을 많이 사용하고 있다. C언어의 자료형은 JAVA등에서의 자료형과 달리 시스템에 따라 자료형의 크기가 달라진다. 다음을 보자.

[정수형 크기]

16비트 시스템
char : 1바이트
short : 2바이트
int : 2바이트
long : 4바이트
long long : 사용되지 않음

32비트 시스템
char : 1바이트
short : 2바이트
int : 4바이트
long : 4바이트
long long : 8바이트

64비트 시스템
char : 1바이트
short : 2바이트
int : 4바이트
long : 8바이트
long long : 8바이트 혹은 그 이상

int 는 시스템의 기본 연산 단위를 사용하였다. 따라서 16비트 시스템에서는 short와 같은 크기, 32비트 시스템에서는 long 과 같은 크기이다.

그런데 64비트 시스템으로 오면서 int 를 long보다 큰 8바이트로 하기에는 자료형의 본래 취지에서 벗어나기에 int 대신 long 을 8바이트로 확장하였고, int가 4바이트 크기를 그대로 사용함으로써 4바이트 크기의 자료형을 독자적으로 담당하게 된다.

long long 은 C99에서 표준화되었다. 따라서 1999년 이전에 이미 PC시장에서 거의 사라진 16비트 시스템에서는 long long을 사용하지 않는다.

일부 MS Window 계열에서만 프로그래밍을 했던 분이 블로그에 적어놓기를, long long 자료형은 리눅스의 gcc에서나 사용이 가능한 것이라고 비하 아닌 비하(?)를 하는 글을 보았는데, long long 은 이미 1999년에 C언어의 표준 자료형으로 채택이 되었다. MS의 컴파일러가 다른 컴파일러에 비해서 C의 표준을 매우 느리게 따라가는 경향이 있기에 생긴 오해라고 본다. (단적인 예가 stdint.h 라는 헤더파일인데, 여기서는 이에 대한 이야기는 넘어가기로 한다.)

실수형 크기는 시스템에 따라 달라지지 않는 것이 통상적이다. float 는 4바이트, double 은 8바이트로 거의 고정이라고 보아도 된다. long double은 시스템에 따라 12바이트보다 클 수도 있겠지만 아직 그런 경우를 본 적이 없어 확답을 하지 못하겠다.

16비트 시스템에서도 float는 4바이트를 사용한다. 그 이유는 두가지 정도인데, 첫째는 실수형에 대한 표준은 컴퓨터에서 부동소수점을 표기하는 표준으로 IEEE 754가 정의되어 있는데 그곳에서 4바이트 실수 자료형은 반드시 구현해야 한다는 기준이 있기 때문이며, 또 하나는 만일 실수형을 2바이트로 표현한다면 표현할 수 있는 수의 범위도 작고, 오차도 너무나 커서 사용할 수 없기 때문이다.

그런데, 사실상 이 4바이트 실수 자료형도 요즘은 오차가 커서 별로 사용하지 않는다. 그래서 현재는 특별한 경우가 아니면 일반적으로 실수 자료형으로 double을 많이 사용한다.

~~~~

2013년 8월 8일 목요일

C, C++의 연산자 * 와 & 의 기능에 관하여

C, C++에서 * 와 & 의 사용법은 매우 다양하지만 이를 한데 몰아서 간단하게나마 적으려 한다.

--------------------

[1] * 의 기능

첫 번째로 * 가 두 개의 변수, 혹은 상수 사이에 위치하게 되면 알다시피 곱하기 기능을 한다.

c = a * b;       // a 와 b 를 곱하여 c에 넣으라는 뜻이다.

아래를 보자.
int x=8;
int y=9;
int z;
int w;
z = x * y;     // z에는 72가 들어간다.
w = z * 3;     // w에는 216이 들어간다.

두 번째로 * 가 선언문에서 변수 타입 뒤에 붙으면 "포인터형 변수"를 선언한다는 뜻이 된다.

int* x;   // x 라는 정수형 포인터를 선언함.
int *x;   // 동일함.
int * x;  // 동일함.

세 번째로 * 가 일반적인 구문에 오면서 두 개의 변수, 혹은 상수 사이에 위치하지 않는다면 이는 "역참조"를 뜻한다. 즉 포인터형 변수가 가리키는 메모리 주소에 저장되어 있는 내용을 이야기한다는 뜻이다.
int i = 3;    // 정수형 변수 i 를 선언하고 3으로 초기화한다.
int *j = &i;  // 정수 포인터형 변수 j 를 선언하고 i의 주소값을 넣는다.
*j = 4;       // j의 역참조값에 4를 넣는다. 즉 여기서 i는 4가 된다.


[2] & 의 기능

첫 번째로 & 가 두 개의 변수, 혹은 상수 사이에 위치하게 되면 알다시피 비트연산(AND) 기능을 한다.

int a = 3 & 4;   // a 에는 0이 들어간다.
int b = 6 & 10;  // b 에는 2가 들어간다.

왜 결과가 저렇게 되는지는 생략....

두 번째로 & 가 선언문에서 변수 타입 뒤에 붙으면 "별명"을 선언한다는 뜻이다(참조자). 그런데 이것은 C++에서만 가능하며 C언어에서는 불가능하다.

int a;
int &b = a;
b = 3;

여기서 b 는 a 의 별명이다(aliasing). 따라서 위의 코드에서 a에는 3이 들어간다. 쉽게 말하면 철수에게 "꼬맹이"라는 별명을 붙이면 꼬맹아! 라고 부를 때 철수가 돌아보는 것과 같은 이치...

세 번째로 & 는 변수의 메모리 주소값을 이야기한다.

이것도 설명은 생략...

~~~~~

이를 보아도 C, C++ 라는 언어는 정말로 자유도가 높은 언어임을 알 수 있겠다.
그런 만큼 사용하는 데에 주의를 요한다.

프로그램의 메모리 구조

프로그램의 메모리 구조에 대해 간단히 언급하려 한다.
사용자가 컴퓨터에서 어떤 프로그램을 실행하게 되면 그 프로그램이 사용할 컴퓨터의 메모리(RAM)을 운영체제에서 배정받는다.
운영체제마다 프로그램에게 메모리를 달리 배정할 수 있으나 일반적으로 다음과 같다.
예를 들어 myprogram 이라는 프로그램을 실행했다고 가정해 보자.

myprogram 실행 메모리 구조

0x10000000 (하위 주소)
| ...
| Code(.text)
|-------------------------------------
| Data (.data)
|-------------------------------------
| Data (.bss)
|-------------------------------------
| Heap
|-------------------------------------
| Stack
|-------------------------------------
| 환경변수, 명령창 데이터 저장 영역
0x1FFFFFFF (상위 주소)

위의 메모리주소 (0x10000000, 0x1FFFFFFF)는 예를 들어 기술한 것이다.

간단히 살펴보면...
(1)
Code (.text) 영역 (영역을 세그먼트라고도 한다.) 에는 실제로 이 프로그램을 수행하는 기계어와 전역 상수가 들어있다. 기계어는 함수 와 연산 구문 등이 해당되며, 전역 상수는 C언어를 예로 들면 전역으로 설정된 const 타입 variable 등이다. 이 영역의 정보는 read only로서 절대 변경되지 않아야 한다.

(2)
Data (.data) 는 초기화가 된 전역 변수 혹은 정적 변수를 저장하는 영역이며,
Data (.bss) 는 초기화가 되지 않은 전역변수 혹은 정적 변수를 저장하는 영역이다.
bss는 Block Started Symbol의 약자이다.

프로그램이 시작하자마자 Code 영역과 Data 영역은 곧바로 메모리에 탑재된다.

(3)
Heap 영역은 프로그램이 자유롭게 할당하고 해제할 수 있는 영역이다. Heap 영역에서 메모리를 할당받고 사용하려면 명시적으로 메모리를 할당하는 구문(C 에서 malloc 같은 함수)을 수행해야 하며, 그리고 사용을 마쳤으면 역시 메모리를 명시적으로 해제해야 한다. (C 에서 free 함수)

(4)
Stack 영역은 특정 구문 내에서 임시로 할당되는 변수를 저장하는 영역이다.
구문이 시작되고 변수가 선언되면 Stack 영역에 할당되었다가 구문이 종료되면 자동적으로 변수는 메모리에서 해제된다. 우리가 자주 사용하는 지역변수와 함수의 인자(입력 파라미터) 등이 Stack에 할당되어 저장된다.

Heap 과 Stack은 프로그램 시작과 동시에 정보가 들어가는 것이 아니라, 프로그램 수행 중에 사용하게 되는 영역이다.
보통 Heap의 경우는 하위 주소에서 상위 주소쪽으로 변수가 채워지며, Stack의 경우는 상위 주소에서 하위 주소쪽으로 변수가 채워진다. 이는 프로그램 도중 Stack이 얼마나 필요할지 알 수 없으므로 Stack은 거꾸로 채워나간다.

요즈음의 프로그래밍 언어는 Heap과 Stack을 구분지어 이해할 필요가 없어도 되는 경우가 많지만, C언어를 다룬다면 적어도 포인터 등을 배우는 시점에서는 차이를 이해하고 있어야 한다.

다음의 예제들로 갈음한다.

#include <stdio.h>

int j=0;
int k;
int l=1;
const int m=2;

int main()
{
 int a;
 static int b=3;
 static int c;
 const int d=4;
 int e;
 int f=5;

 printf("       int j[%010p] init\n",&j);
 printf("       int k[%010p] uninit\n",&k);
 printf("       int l[%010p] init\n",&l);
 printf("const  int m[%010p] init\n",&m);
 putchar('\n');
 printf("       int a[%010p] uninit\n",&a);
 printf("static int b[%010p] init\n",&b);
 printf("static int c[%010p] uninit\n",&c);
 printf("const  int d[%010p] init\n",&d);
 printf("       int e[%010p] uninit\n", &e);
 printf("       int f[%010p] init\n", &f);

 return 0;
}

결과

       int j[0x0804a028] init
       int k[0x0804a030] uninit
       int l[0x0804a018] init
const  int m[0x080485f0] init

       int a[0xbfa00e70] uninit
static int b[0x0804a01c] init
static int c[0x0804a02c] uninit
const  int d[0xbfa00e74] init
       int e[0xbfa00e78] uninit
       int f[0xbfa00e7c] init

위의 결과를 보면 좀 특이한 사항을 발견할 수 있다.
우선 data 영역은 0x0804a018 ~ 0x0804a01c 근방 정도로 보여지고
bss 영역은 0x0804a028 ~ 0x0804a030 근방 정도로 보여진다.

차근차근 살펴보면
전역 변수인 j, k, l, m 중 코드 상에서 초기화가 된 전역변수는 j, l, m 이다.
여기서 m 은 const 키워드가 들어가서 전역 상수가 되었으므로 맨 하위 주소에 위치해 있는 Code(.text) 영역에 들어간 것으로 보여진다.

여기까지는 이해가 되었으나 나머지 초기화된 변수 j, l 중 j 가 data 영역이 아닌 bss 영역에 들어가 있는 것이 의아하다. 하지만 여기엔 이유가 있다.

C 언어에서 모든 전역변수는 별도로 초기화되지 않으면 모두 0으로 초기화된다. 따라서 결국 이 말을 바꿔 하면 0으로 초기화된 전역변수는 bss영역에, 0이 아닌 값으로 초기화된 전역변수는 data영역에 들어간다는 뜻이 된다.

j 의 경우 코드 상에서 초기화는 되었지만 0으로 초기화되었기 때문에 bss에 들어가는 전역변수 (k) 와 다를 바가 없다. 따라서 j는 bss 영역에 들어가게 된다.

전역 변수 외에도 정적 변수도 data, bss 영역에 들어간다. 따라서 (0이 아닌 값으로 초기화된) 변수 b 는 data 영역에, 초기화되지 않은 c는 bss 영역에 들어간다.

그 이외의 로컬 변수 a, d, e, f 는 모두 stack 영역에 들어간다. 로컬 변수는 const 키워드가 들어간다 하더라도 scope가 종료되면 삭제되어야 하므로 Code(.text) 영역에 들어가지 않는다.

그런데 stack 영역에는 상위주소에서 하위주소쪽으로 변수가 채워진다고 했는데 위의 예제에서는 그런것 같지 않아보인다. 그 이유는 로컬 변수가 코드 상으로는 a, d, e, f 의 순서로 선언이 되었지만 실제로는 같은 scope 내에서 동시에 생성되었다가 동시에 사라지므로 a, d, e, f를 한꺼번에 할당을 했기 때문이다. 그러나 scope이 달라지게 되면 분명히 하위주소쪽으로 변수가 채워진다.

다음 예를 보자.

#include <stdio.h>

int global_var;
int global_initialized_var = 5;

void function()
{
    int stack_var; // main() 에도 같은 이름의 변수가 있음.

    printf("function의 stack_var는 주소 0x%08x에 있다.\n", &stack_var);
}

int main()
{
    int stack_var; // function() 에도 같은 이름의 변수가 있음.
    static int static_initialized_var = 5;
    static int static_var;
    int *heap_var_ptr;

    heap_var_ptr = (int *)malloc(4);

    // 이 변수들은 data 세그먼트에 있다.
    printf("global_initialized_var는 주소 0x%08x에 있다.\n", &global_initialized_var);
    printf("static_initialized_var는 주소 0x%08x에 있다.\n", &static_initialized_var);

    // 이 변수들은 bss 세그먼트에 있다.
    printf("static_var는 주소 0x%08x에 있다.\n", &static_var);
    printf("global_var는 주소 0x%08x에 있다.\n\n", &global_var);

    // 이 변수들은 heap 세그먼트에 있다.
    printf("heap_var는 주소 0x%08x에 있다.\n\n", heap_var_ptr);

    // 이 변수들은 stack 세그먼트에 있다.
    printf("stack_var는 주소 0x%08x에 있다.\n", &stack_var);
    function();
}

이 결과는 아래와 같다.

global_initialized_var는 주소 0x0804a018에 있다.
static_initialized_var는 주소 0x0804a01c에 있다.
static_var는 주소 0x0804a028에 있다.
global_var는 주소 0x0804a02c에 있다.

heap_var는 주소 0x0963c008에 있다.

stack_var는 주소 0xbfe469b8에 있다.
function의 stack_var는 주소 0xbfe4698c에 있다.

위의 결과에서 stack_var 의 부분을 보면 main의 stack_var 보다 function의 stack_var 이 분명히 나중에 stack에 할당이 되므로 function의 stack_var 이 상위주소에 위치해 있다. 즉 stack 영역에서는 하위주소에서 상위주소 쪽으로 할당이 되어진다는 것을 확인할 수 있다.

이정도로 메모리 영역에 대한 설명을 마치고자 한다.