42 Starter 스터디 모임에서 진행하는 C 언어 A to Z 스터디에 내용을 정리해서 포스팅을 진행합니다.

해당 스터디에서는 42 Piscine 과정에서 채우지못한 부족한 C언어 역량을 키우는 것을 목적으로 합니다.


1. What is Pointer


포인터는 저장된 상수 또는 변수의 메모리 주소를 갖고있는 자료형이다. 사용자 정의 타입의 변수이든 기본적인 타입의 변수이든 모두 포인터를 이용해서 메모리 주소를 핸들링할 수 있다.

컴퓨터는 값을 어떠한 메모리 공간에 저장하고 그 값을 핸들링한다. 그렇기 때문에 프로그램에서 특정한 시점에서 정해진 규칙에 맞게 배치되어 있는 값들을 얻기 위해서는 주소가 필요하고 이렇기 때문에 C언어에서는 주소를 통해 해당 위치의 값을 접근할 수 있는 기능을 제공한다.

포인터는 크게 2가지 정보를 갖고있다.

  1. 값의 시작 주소

    어느 지점에 값이 저장되어 있는지 나타내는 주소이다. C언어에서는 자료 타입에 따라서 메모리 사용의 크기가 다른데, 그렇기 때문에 포인터는 값의 시작 주소를 갖고있다. 예를 들어 int 형의 경우는 4byte 메모리 공간이 필요로하는 타입이기 때문에 값의 시작 주소 X 부터 4byte 만큼의 주소까지 공간을 차지하고 있을것이다.

  2. 값의 시작 주소로부터 크기

    위에서 말했다싶이 int 형은 4byte 메모리 공간을 필요로 하는 타입이기 때문에 값의 시작 주소 X 부터 4byte 만큼의 주소까지 공간을 차지하고 있다. 포인터는 여기서 4byte 만큼의 주소까지 공간을 차지하고 있다는 정보 또한 갖고있다.

위에 내용처럼 포인터의 메커니즘은 시작 주소로부터 타입에 따라서 어느 주소까지 메모리 읽어서 포인팅하는 방식으로 진행된다. 하지만 여기서 혼란을 일으킬 수 있는 경우가 생긴다.

예를 들어 4byte 크기인 int 타입의 주소를 나타내는 int * 로 값을 참조할 때에는 포인터의 주소로부터 메모리를 4byte 만큼을 읽어 값을 참조하여 정수형 값을 갖고온다. 하지만 위와 다르게 int 타입의 주소를 나타내는 포인터를 short * 로 캐스팅(형변환)하여 값을 참조한다면 어떻게 될 것인지 한번 생각해보자. 결과적으로 short 는 2byte 크기의 정수형이므로 int 크기인 4byte 만큼을 읽지 않고 2byte 만큼을 읽게되고 이전에 값과 다른 결과를 초래할 수 있을 것이다.

#include <stdio.h>

void	real_change_value(int *p_target, int number)
{
	*p_target = number;
}

void	fake_change_value(int target, int number)
{
	target = number;
}

int		main(int argc, char *argv[])
{
	int		source;
	int		*p_source;

	source = 50;
	p_source = &source;

	fake_change_value(source, 5); // call by value
	printf("\n-----------------------\n");
	printf("source = %d\n", source);
	printf("p_source = %p\n", p_source);
	printf("*p_source = %d\n", *p_source);

	real_change_value(&source, 7); // call by refer
	printf("\n-----------------------\n");
	printf("source = %d\n", source);
	printf("p_source = %p\n", p_source);
	printf("*p_source = %d\n", *p_source);

	return (0);
}

위에는 포인터를 왜 사용하는가를 생각해볼 수 있는 대표적인 예제 코드이다.

fake_change_value 함수의 경우는 인자를 valuereal_change_value 함수의 경우는 인자를 reference (포인터) 로 하여 각각의 결과를 볼 수 있는 코드이다. 간단히 어떤것을 얘기하는 지를 말하자면, 전자의 경우 값을 인자로 넘기기 때문에 인자에는 값만 복사 되게 되는데 이것은 호출시점에 인자로 넣어준 원래의 변수에는 아무런 영향을 미치지 않는다. 반면에 후자의 경우 포인터를 인자로 넘기기 때문에 인자에는 주소 값이 복사 되게 된다. 그리고 주소를 통한 참조를 이용하여 값을 변경하기 때문에 같은 주소를 갖고 있는 변수들은 전부 영향을 받게 될 것이다.


2. 포인터의 크기


C언어에서 포인터의 크기는 타입과 상관없이 8byte이다. 즉, char *int * 모두 8byte이다. 물론 함수 포인터도 마찬가지로 주소이기 때문에 8byte이다.

아래 코드를 통해 확인해볼 수 있다.

#include <stdio.h>

typedef struct	s_type
{
	int		data;
	char	symbol;
}				t_type;

void	func(void)
{
	printf("it's func\n");
}

int		main(int argc, char *argv[])
{
	int			src;
	int			*int_p_src;
	char		*char_p_src;
	long long	*ll_p_src;
	void		*void_p_src;
	t_type		*t_type_p_src;
	void		(*func_p)(void);

	int_p_src = &src;
	char_p_src = (char *)&src;
	ll_p_src = (long long *)&src;
	void_p_src = &src;
	t_type_p_src = &src;
	func_p = func(void);

	printf("&src		: %lu\n", sizeof(&src));
	printf("int_p_src	: %lu\n", sizeof(int_p_src));
	printf("char_p_src	: %lu\n", sizeof(char_p_src));
	printf("ll_p_src	: %lu\n", sizeof(ll_p_src));
	printf("void_p_src	: %lu\n", sizeof(void_p_src));
	printf("t_type_p_src	: %lu\n", sizeof(t_type_p_src));
	printf("func_p		: %lu\n", sizeof(func_p));
}

*추가정보: 구조체의 메모리 크기는 안에 들어있는 8byte보다 작거나 같은 가장 큰 자료형의 크기를 단위에 의해 정해진다. 예를 들어 위에 t_type 구조체의 크기는 8byte 보다 작거나 같은 가장 큰 자료형인 int 형 4byte 만큼이 단위가 된다. 그리고 4byte의 배수인 값으로 구조체 안에 있는 모든 자료형의 크기를 커버할 수 있는 메모리 공간을 만든다. 따라서 t_type 구조체의 크기는 8byte가 될 것이다.

*추가정보: C에서는 구조체의 멤버 변수를 메모리에서 CPU로 읽을 때 한번에 읽을 수 있도록 바이트 패딩 이란 것을 해준다. 컴파일러가 레지스터의 블록에 맞춰 바이트를 패딩해주는 최적화 작업이다. 따라서 아래과 같이 구조체를 구성한다면 해당 구조체는 12byte의 크기를 갖을 것이다.

struct x {
	char	a;
	int		b;
	char	c;
};

// print - sizeof(x);

언뜻 보면 sizeof(x) 의 값은 6이 나올 것 같지만 12가 나온다. 컴파일러는 구조체를 구성하는 멤버들을 가장 크기가 큰 멤버 자료형의 배수가 되도록 정렬한다. 이 정렬을 위해 의미없는 바이트(패딩)들을 붙이게되고 위에 코드와 같은 상황에서는 char 뒤에 각각 3byte의 패딩이 붙게 되는 것이다. 따라서 구조체의 메모리 저장방식을 효율적으로 하려면 아래처럼 동일한 자료형의 크기를 붙여서 적어주어야 한다.

struct x {
	char	a;
	char	c;
	int		b;
};

3. 포인터의 연산


포인터에서 연산은 3가지를 생각해볼 수 있다.

  1. 포인터와 정수의 연산

    포인터는 시작 주소로부터의 크기를 갖고있다. 그렇기 때문에 C언어에서는 포인터와 정수의 연산은 시작 주소로부터의 크기가 이용된다.

     int a = 3;
     int *p = &a;
    
     printf("p:	%llu\n", p);
     printf("p + 1: %llu\n", p + 1);
    

    그렇기 때문에 위와 같은 상황에서는 p + 1은 4byte 만큼의 크기를 1만큼 더한 연산이 되고 p + 1은 p에서 4를 더한 값이 된다.

  2. 포인터들의 연산과 비교

    포인터 자체는 주소 값, 즉 값이기 때문에 같은 타입의 값끼리의 연산하는 것과 같이 포인터들의 연산은 주소의 덧셈 혹은 뺄셈, 비교가 된다.


4. 포인터 연산 활용


포인터를 제대로 이해하기 위해 연산을 활용해보자.

포인터를 이해하기에 도움이 되는 예제를 갖고와 보았다.

직접 하나씩 결과를 적어나가면서 실행을 해보면 포인터 이해의 도움이 많이 될 것이다.

#include <stdio.h>

int		main(int argc, char *argv[])
{
	int		source;
	void	*p_source;

	source = 0;
	p_source = (void *)&source;
	*(char *)p_source = 0x87;
	//printf("p_source	= %p\n", p_source);
	//printf("*p_source	= 0x%d\n", *(char *)p_source); // 해당 값의 이유를 살펴보세요
	p_source = (char *)p_source + 1;
	*(char *)p_source = 0x65;
	//printf("p_source	= %p\n", p_source);
	//printf("*p_source	= 0x%X\n", *(char *)p_source);
	p_source = (char *)p_source + 1;
	*(char *)p_source = 0x43;
	//printf("p_source	= %p\n", p_source);
	//printf("*p_source	= 0x%X\n", *(char *)p_source);
	p_source = (char *)p_source + 1;
	*(char *)p_source = 0x21;
	//printf("p_source	= %p\n", p_source);
	//printf("*p_source	= 0x%X\n", *(char *)p_source);
	printf("source		= 0x%X\n", source);

	printf("-------------------------\n");
	source = 0;
	p_source = (void *)&source;
	*(short *)p_source = 0x1234;
	p_source = (char *)p_source + 3;
	*(char *)p_source = 0x78;
	printf("source		= 0x%X\n", source);

	printf("-------------------------\n");
	source = 0;
	p_source = (void *)&source;
	*(int *)p_source = 0x12;
	printf("source		= 0x%X\n", source);
}

*추가정보: 위에 코드를 이해하기 위해서는 주소를 읽는 방식에 대해서 알아야한다.

바로 리틀 엔디안과 빅 엔디안이다. 우선, 엔디안(Endianness)은 컴퓨터의 메모리와 같은 1차원 공간에 여러 개의 연속된 대상을 배열하는 방법을 뜻한다. 보통 큰 단위가 앞에 나오는 것을 빅 엔디안, 작은 단위가 앞에 나오는 것을 리틀 엔디안이라고 한다.


5. 포인터와 배열의 관계


포인터와 1차원 배열과의 차이는 크게 없다. 둘 다 *(p + index) 로 접근하는 것과 p[index] 로 접근이 가능하며 주소를 통한 접근이다.

아래는 포인터와 1차원 배열의 결과를 비교해본 코드이다. 또한, 인자로 배열을 넣을 때와 포인터를 넣었을 때 어떻게 다른지 확인해볼 수 있다. 결과를 말하자면 위에서 말했듯이 포인터와 1차원 배열은 차이가 없다. 그리고 인자로 특정 크기의 1차원 배열을 받든 포인터를 받은 모두 포인터로 받게 되고 인자에 int array[50]과 같이 특정 크기를 정의한 것은 의미가 없어진다. 하지만 다차원 배열에서는 의미가 달라지니 뒤에서 한번 더 살펴보자.

#include <stdio.h>

void	get_length_50(int array[50])
{
	printf("array size = %lu\n", sizeof(array) / sizeof(array[0]));
}

void	get_length_40(int array[40])
{
	printf("array size = %lu\n", sizeof(array) / sizeof(array[0]));
}

void	get_length_pointer(int *array)
{
	printf("array size = %lu\n", sizeof(array) / sizeof(array[0]));
}

void	init_array(int array[], size_t length)
{
	int		idx;

	for(idx = 0; idx < length; idx++)
	{
		array[idx] = idx * 2;
	}
}

int		main(int argc, char *argv[])
{
	int		source[10];
	int		*p_source;

	p_source = source;
	// 두 값의 주소
	printf("p_source = %p\n", p_source);
	printf("source = %p\n", source);

	// 두 값의 저장된 값의 크기 + 첫번째 원소의 주소의 크기
	printf("sizeof(p_source) = %lu\n", sizeof(p_source));
	printf("sizeof(source) = %lu\n", sizeof(source));
	printf("sizeof(&source[0]) = %lu\n", sizeof(&source[0]));

	// 연산이 이루어질 때
	printf("p_source + 1 = %p\n", p_source + 1);
	printf("source + 1 = %p\n", source + 1);

	// 두 값의 첫번째 주소의 값
	printf("&(*(p_source + 0)) = %p\n", &(*(p_source + 0)));
	printf("&(*(source + 0)) = %p\n", &(*(source + 0)));

	// 배열의 크기 구하기
	printf("array size = %lu\n", sizeof(source) / sizeof(source[0]));
	get_length_50(source);
	get_length_40(source);
	get_length_pointer(source);
}

6. 다차원 배열과 포인터

2차원 배열은 포인터로 어떻게 표현할 수 있을지 한번 살펴보자. 우선 1차원 배열과 포인터를 혼용해서 썼던 것 처럼 다차원 배열을 포인터로 다루면 가능할지에서 생각해보자.

2차원 배열을 포인터로 표현을 하면 포인터를 두개를 사용해서 표현해볼 수 있을 것이다. 하지만 이것이 2차원 배열을 선언한 것과 완전히 같은 것인지 한번 확인해보아야한다. 배열은 특정 크기를 정해서 메모리 공간을 할당하고 이것의 주소를 이용한다. 따라서 int a[5][10] 이라는 변수가 있을 때, a + 1의 값은 a의 첫번째 주소에서 int 자료형의 크기인 4byte 10개가 지난 주소(열이 10개 이므로)라고 할 수 있다. 하지만 더블 포인터를 이용하여 int **b = a 라고 선언을 하고 이것을 앞에서와 똑같이 b + 1 를 하게되면 포인터의 포인터형은 a의 첫번째 주소에서 int * 자료형의 크기 8byte가 하나가 지난 주소라고 할 수 있다. 결과적으로 2차원 배열과 더블 포인터는 다르다.

그렇다면 어떤식으로 나타내어야 2차원 배열을 포인터로 표현할 수 있을지 생각해보자. 우선 포인터가 2차원 배열의 단위를 알아야한다. 즉, 포인터에 int a[5][10]에서 열의 개수를 정의 해주면 된다. 그렇다면 다음과 같은 포인터의 일차원 배열 형태가 될 것이다.

int		a[5][10] = {0,};
int		(*b)[10];
//int	*b[10];
b = a;
#include <stdio.h>

void			test1(int *a[10])
{
	// 포인터 10개 짜리 1차원 배열
	printf("test1 ok %d\n", a[2][2]);
}

void			test2(int (*a)[10])
{
	// int형 10개를 단위로 읽는 포인터
	printf("test2 ok %d\n", a[2][2]);
}

int             main(int argc, char *argv[])
{
	int             d_2_array[5][10] = {0,};
	int             **p_d_2_array;
	int				(*p_array)[10];

	for (int y = 0; y < 5; y++)
		for (int x = 0; x < 10; x++)
			d_2_array[y][x] = y * 10 + x;
	p_d_2_array = d_2_array;
	p_array = d_2_array;
	printf("origin:	%llu\n", d_2_array);
	printf("2_d_array:	%llu\n", d_2_array + 1);
	printf("p_dobule:	%llu\n", p_d_2_array + 1);
	printf("p_array:	%llu\n", p_array + 1);
	//test1(d_2_array); It's error!
	test2(d_2_array);
	return (0);
}
⤧  Next post Oh-My-C-P-P 03 ⤧  Previous post Oh-My-C-P-P 02