포인터

Dingool95·2021년 9월 27일
0

C언어

목록 보기
5/5

들어가기

포인터는 프로그래밍 언어를 처음 배울 때 포기하게 만드는 아주 무시무시한 녀석이다. 이제 좀 친해졌나 싶었는데, C언어를 사용하지 않는다면 써먹을 일이 없어서 또 멀어졌다. 아주 멀어지기 전에 정리를 해놓아야 할 필요성을 느낀다. 어려울게 전혀 없다고 말하는 나쁜 사람들이 간혹 있는데.. 사기꾼이다. 어려운건 어렵다고 인정하고 열심히 공부하자.




컴퓨터 구조

포인터는 메모리의 주소값을 저장하는 변수이다. 정의에서 알 수 있듯이 포인터는 컴퓨터의 메모리와 밀접하게 연관되어 있으므로, 포인터를 이해하기 위해서는 어쩔 수 없이 컴퓨터 구조에 대한 배경지식이 필요하다. 컴공의 주요 과목인 컴퓨터 구조와 운영체제를 공부해 본 적은 없지만 C언어 강의 중에 들었던 기초적인 내용들을 기반으로 정리해 보겠다.



폰노이만 구조 (Von Neumann Architecture)

CPU, 메모리, 프로그램 세 가지 요소로 구성되어 있다. 컴퓨터에 메모리가 내장되었다는 것이 핵심이다. 하드웨어는 그대로 유지한 채, 소프트웨어만 바꿔주면 원하는 작업을 수행할 수 있으므로 범용성을 가지는 것이 큰 장점이라고 한다.

하드 디스크와 같은 보조기억장치에 저장된 실행 파일을 프로그램이라고 한다. 프로그램은 명령어(기계어)의 집합이다. 프로그램을 실행하면 램에 올라가는데, 실행 중인 프로그램을 프로세스라고 한다. 포인터에서 말하는 메모리는 RAM을 의미하며 CPU는 RAM에만 접근할 수 있다. 프로그램 실행 과정을 그림으로 간단히 나타내면 아래와 같다.


폰노이만 구조의 단점으로 주로 언급되는 것이 Von Neumann Bottleneck 현상이다. 메모리의 속도보다 CPU의 레지스터 속도가 더 빠르기 때문에, CPU가 붕 뜨는 시간(idle time)이 발생하는 것을 말한다. DRAM 뿐만 아니라 SRAM을 쓰면 이 문제가 어느 정도 개선된다고 한다. SRAM의 예시로 cache memory가 있다. 사용 빈도가 높은 데이터를 SRAM에 미리 올려 놓는 방식이다. SRAM에 미리 올라간 데이터가 사용되는 것을 cache hit 라고 하는데, 이를 높이려면 프로그램의 크기가 작을수록 좋다.

위에 언급한 내용은 상세하게 아는 내용이 아니다. 단어들의 정확한 의미도 알지 못한다. 나중에 공부를 위해서 남겨 두겠다.




RAM에 올라간 프로세스의 구성을 좀 더 자세히 살펴보면 아래 그림과 같다. 엄밀히 말하면 명령어의 집합인 프로그램을 메모리의 code 영역에 올리는 것을 프로그램 실행이라고 한다.

프로세스의 Data 영역은 다시 3가지 영역으로 나뉘는데, 각 영역마다 저장되는 변수의 종류가 다르다. 여기서는 따로 정리하지 않겠다.


기계어는 2진수로 이루어져 있다. 이를 사람이 보기 편하게 (프로그래밍이 가능하도록) 한 단계 발전(?)된 언어가 어셈블리어다. 현대 운영체제의 모태인 UNIX 운영체제가 최초에 어셈블리어로 작성되었다고 한다.

기계어(명령어)는 연산자와 피연산자들로 구성되어 있다 ( Instruction = Operator + Operand ). 연산자를 기계어로 나타낸 것을 op-code라고 한다. 피연산자는 정수값 그 자체일 수도 있고, 주소값을 나타낼 수도 있다. 어셈블리어는 다음과 같이 기계어와 일대일 대응된다.


포인터와 직접적으로 관련된 내용이 드디어 나왔다. 우리가 변수에 값을 할당하면 할당된 값은 메모리의 특정 위치에 저장된다. 일일이 주소를 지정하여 할당하면 힘들기 때문에 변수라는 것으로 주소값을 대체하는 것이다. C언어는 어셈블리어를 기반으로 개발된 언어이므로 여기서부터가 포인터의 출발점이다. 포인터를 사용하는 이유는 메모리의 원하는 위치에 직접 접근하기 위함인 것을 알 수 있다.




포인터가 뭔데

앞서 말했듯이 포인터는 메모리의 주소값을 저장하는 '변수'다. 포인터 변수라고 부르기도 한다. 앞에서 보았듯이 변수는 본디 메모리의 주소값을 대신해서 표현된 것이다. 값을 저장하기 위해 사용되는 일반변수는 메모리의 위치를 신경쓰지 않고 값을 할당하거나 가져올 수 있게 한다. 그런데 주소를 알아야만 하는 경우가 있다. 주소 자체를 알아야 한다기보다는, 주소를 통해서 변수를 특정해야 하는 경우가 있다.

각기 다른 함수에서 동일한 변수를 사용하고자 한다면 지역변수이기 때문에, 이름은 같지만 서로 다른 메모리 위치를 가지게 된다. (함수의 인자 전달)

같은 반에 이름이 같은 두 명의 학생이 있다고 치자. 두 학생 중 한 명을 불러야 할 때, 이름으로는 둘을 구별할 수 없다. 각각 번호가 1번, 17번 이라고 했을 때, 번호로 부른다면 이제 둘을 구별할 수 있다. 이 상황이 포인터가 필요한 당위성과 비슷하다고 볼 수 있다. 이처럼 메모리의 특정 위치를 가리킬 때 사용되므로 이름도 포인터라고 붙여졌다.

변수의 종류
1. 값을 저장하는 변수
2. 주소를 저장하는 변수 --> 포인터



기본 사용법

int *p;	  -- (1)
int * p;  -- (2)
int* p;   -- (3)

포인터를 선언할 때는 변수 앞에 *(aesterisk)를 붙여준다. 위의 세 방법 모두 가능한데, 딱히 표준이 있는 것 같지는 않다. 사용빈도는 (1)이 제일 많은 것 같으므로 (1)의 방법을 계속 사용해야겠다.


int a;
int *p; // 포인터 선언

a = 10;
p = &a; // 변수 a의 주소 할당

*p = 20; // 저장된 주소의 값 변경
printf("%d", a);

----------------
20 // 포인터로 변경된 값이 출력됨

* (역참조 연산자)와 & (주소 연산자), 두가지 연산자가 등장한다. C언어는 값을 할당할 때 자료형이 일치하지 않으면 컴파일 경고가 발생하는데, 두 연산자는 모두 자료형을 바꾸는 효과를 낸다.

역참조(dereference)는 포인터가 가리키는 메모리에 접근하여, 저장된 값을 가져오거나 새로운 값을 할당하는 것을 말한다. 포인터는 항상 대상을 가리키고 있지만, 그 역방향으로 값을 가져오기 때문에 역참조라고 하는 것 같다. 주소 연산자는 해당 변수의 값이 아닌 주소를 할당할 때 사용한다.

역참조 연산자 ( * ) : 포인터가 가리키는 (담고있는 주소의) 자료형으로 변환
주소 연산자 ( & ) : 해당 자료의 포인터형으로 변환


위 코드를 통해서 메모리에서 어떤 일이 일어나는지 그림으로 알아보면 다음과 같다.
우선 알아야할 것이 있다. 주소값 하나가 가리키는 메모리 공간은 크기가 1byte 이다. 이를 byte machine 이라고 하는데, 모든 컴퓨터가 이 체계를 사용하고 있다고 보면 된다. 예외는 있는 것 같지만 생각하지 않겠다.

변수 aint형이다. int형은 4byte 이므로, 4개의 주소를 가지는 것을 볼 수 있다. 또 하나 알아야 할 것은 포인터의 크기인데, 포인터도 변수이므로 메모리에 저장된다. 포인터의 크기는 자료형에 관계없이 4byte이다. 정확히는 32bit 시스템에서 4byte이고, 64bit 시스템에서는 8byte이다. 중요한 것은 크기가 고정되어 있다는 점이다.

변수 a에 10을 할당해서(a = 10) 4byte 공간에 10이 저장되어 있고, 포인터 p에 변수 a의 주소를 할당해서 (p = &a) 포인터가 저장된 4byte 공간에 a의 시작 주소 100이 저장되어 있다. 중요한 것이 또 나왔는데, 주소값 하나는 1byte의 공간을 가리키므로 포인터는 변수의 시작 주소를 저장한다. 변수의 시작 주소만 저장되므로 포인터의 자료형이 다양한 것이고, 역참조 할 때 해당 자료형의 크기만큼 값을 가져오거나 할당할 수 있는 것이다. 위 코드에서는 포인터 p가 역참조를 통해 변수 a의 시작 주소로부터 4byte 크기만큼 값을 20으로 할당한다.

편의상 변수 a가 저장된 공간 바로 옆에 포인터 p를 배치했는데, 실제로는 포인터가 저장되는 공간은 따로 구분되어 관리되는 것 같다.



주소의 표현

주소는 0x8F42A23C처럼 16진수 8자리로 표현한다. 16진수를 사용한 것은 사람이 보기 편하기도 하고, 2진수로 된 수를 축약해서 표현할 수 있기 때문이다. 그렇다면 왜 8자리인가? 32bit 운영체제에서 포인터의 크기는 4byte로 고정이라고 했다. (포인터 = 주소) 이므로 메모리의 주소 개수도 (4byte = 2의 32제곱개) 라는 뜻이 된다. 32bit 운영체제는 한 번에 32비트의 데이터를 처리할 수 있다. 마찬가지로 CPU의 레지스터의 크기도 32비트임을 의미한다. 따라서 32bit CPU는 인식할 수 있는 RAM의 한계가 (2의 32제곱 byte = 4GB) 가 되어, 주소를 16진수 8자리로 표현하는 것이다.



역참조 주의사항

int *p;
*p = 200;

역참조는 반드시 포인터가 대상을 가리키는 상태에서 할 수 있다. 저장된 주소가 없으므로 어디에 접근할지 알 수 없다. 따라서 역참조 전에 포인터에 주소값이 할당되어 있어야 한다.




포인터의 용도

포인터를 처음 배울 때 개념 이해를 위해서 기본 사용법 정도만 배우게 되는데, 저것만 알아서는 응용이 안 된다. 어따 써먹을지 전혀 알 수 없다. 결론부터 말하면 함수의 인자 전달에 사용된다. 같은 변수를 여러 함수에서 사용하기 위해서, 함수의 경계를 넘나들 때 반드시 필요한 것이 포인터이다.



Swap 함수

#include <stdio.h>
/*
void swap(int a, int b)
{
    int tmp = a;
    a = b;
    b = tmp;
}
*/
void swap(int *pa, int *pb)
{
    int tmp = *pa
    *pa = *pb;
    *pb = tmp;
}

int main(void)
{
    int a, b;
    a = 100;
    b = 200;
    printf("a: %d\tb: %d\n", a, b);
    
    //swap(a, b);
    swap(&a, &b);
    
    printf("a: %d\tb: %d\n", a, b);
    return 0;
}

swap 함수는 포인터를 함수의 인자 전달로 사용하는 대표적인 예시로, 두 변수의 값을 교환하는 기능을 한다. 주석 처리된 swap 방식과 포인터를 활용한 swap 방식이 어떤 차이가 있는지 그림으로 알아보자.
함수가 호출되면 그림처럼 메모리의 stack영역에 호출된 순서대로 쌓이게 된다. (1)이 주석 처리된 swap 방식이다. (1)은 swap함수가 호출되면 main함수의 변수 a, b'값'이 복사된다. 따라서 변수를 교환하면 swap함수 내부의 지역변수 a, b가 교환되며, swap함수의 외부 변수인 main함수의 a, b는 변하지 않고 그대로 유지된다.

(2)는 swap함수가 호출되었을 때, 값이 아니라 main함수의 변수 a, b'주소값'이 전달되어 포인터 pa, pb로 복사된다. 포인터로 main함수의 변수를 가리키고 있기 때문에, 역참조 연산자를 통해 값을 교환하면 main함수의 두 변수 a,b의 값이 교환된다.

함수의 호출 방식 중에서 (1) 방식을 call by value라 하고, (2) 방식을 call by pointer라 한다. call by reference라는 호출 방식도 있는데 C에는 존재하지 않으며, C++에 있다.

profile
내 맘대로 요점정리

0개의 댓글