C언어에서 선언된 배열을 포인터로 표현이 가능하다.

아래 예제 코드는 선언된 배열을 포인터로 표현하여 출력하는 간단한 코드이다.

 

 


위의 예시코드를 컴파일하여 실행하면, 다음과 같이 동일한 값이 나오는 것을 확인할 수 있다.

 

 


이를 통하여 [] = *() 연산자가 동일함을 알 수 있는데, 이를 교환법칙으로 괴랄스럽게 변형을 해보았다.

 

 


1. Temp[0]을 동일한 의미인 *(Temp+0)으로 변환

2. 교환법칙에 의한 *(0+Temp)으로 변환

3. 동일한 표기인 0[Temp]으로 변환

 

위의 예시코드를 컴파일하여 실행하면, 에러 없이 컴파일이 되며 값 또한 정상적으로 나오는 것을 확인할 수 있다.

 

 


물론, 이와 같은 방법의 코딩 스타일은 절때 추천하지 않는다.

 

 

scanf_samplecode.c

printf() 함수와는 다르게 scanf()에서는 변수에 &(앰퍼샌드) 연산자를 이용하여 변수에 값을 대입한다.

하지만 &(앰퍼샌드) 연산자를 사용하는 이유에 대해서는 의문은 가지고 있지만, 정작 대다수가 scanf() 함수를 사용하면서도 동작에 대한 소스 코드를 분석할 방법이 없어 scanf()이기 때문에 &(앰퍼샌드)를 사용하는 것이라는 공식처럼 여기고 넘어갈 것이다.


이번 포스팅에서는 scanf()에 대한 라이브러리 분석을 통해 &(앰퍼샌드) 연산자를 쓰는 이유에 대하여 분석해본다.

해당 기본 라이브러리는 MinGW를 기준으로 분석을 진행하여 scanf() 함수가 어떠한 경로를 통해 구동되는지 흐름까지 설명할 것이며, 자세한 동작 로직 분석은 오픈 소스를 통해 직접 확인해보기를 바란다.


아래와 같이 scanf() 함수를 호출하여 temp 배열에 입력 값을 넣은 예제 코드가 있다.
 

 


scanf() 함수를 사용하기 위해서는 정의된 라이브러리인 stdio.h 헤더 파일을 include 해줘야 한다. stdio.h는 Standard Input/Output의 약자로 개발 도구에 따라서는 stdio.h ​헤더 파일을 include 하지 않아도 자동으로 삽입하여 컴파일 시켜준다.

stdio.h​ 파일을 열어 scanf() 함수의 항목을 보면 다음과 같은 코드로 되어 있다.

 


 

기본적인 C 언어 서적에서는 잘 언급되지 않는 register, 가변인자​ 등등을 사용하여 구현된 것을 확인할 수 있는데, 이 코드에서 핵심은 __retval = __mingw_vscanf( __stream, __format, __local_argv ); 이다. __mingw_vscanf() ​함수에 대한 정보는 헤더 파일을 검색해도 나오지 않으며, 이 함수를 확인하기 위해서는 MinGW 오픈소스 코드를 찾아서 확인해봐야 한다. 소스는 아래의 사이트에서 얻을 수 있다.

 

MinGW Git : https://github.com/Alexpux/mingw-w64

 

소스를 받아 압축을 풀고 __mingw_vscanf() ​함수를 검색하면, 다음과 같이 mingw_scanf.c ​소스 코드에 __mingw_vscanf()​가 구현된 것을 확인할 수 있다.

 

 


__mingw_vscanf() ​함수는 다시 __mingw_vfscanf() ​함수를 호출하여 결과를 return 한다. __mingw_vfscanf() 함수를 찾아가보자. __mingw_vfscanf() ​함수는 mingw_vfscanf.c 소스 파일에 구현되어 있으며, 코드는 아래와 같다.

 


 

__mingw_vfscanf() ​함수는 다시 __mingw_sformat() ​함수를 호출하게 되며, 최종적으로는  __mingw_sformat() ​함수를 이용하여, 우리가 알고 있는 scanf()의 서식 문자에 대한 처리를 하는 수행한다. 아래 그림은 서식 문자 중, 자주 자용되는 %d, %i, %o, %p, &u, %x, %X​의 파싱 과정의 간단한 예시 코드이다.

 


 

여기서 우리가 사용한 %s ​서식 문자를 처리하는 로직을 따라가보자. 아래 그림과 같이 in_ch() 함수를 사용하는 것을 확인할 수 있다.

 


 

in_ch() 함수의 경우 아래와 같이 최종적으로 가변인자로 받은 값들을 getc() 함수를 이용하여, 입력된 버퍼를 넣는다.

 


 

결과적으로, scanf() 함수에서 넘겨 받는 가변인자들의 변수 주소들을 이용하여 처리하기 때문에 &(앰퍼샌드)의 연산자를 이용하여 선언된 변수의 주소값을 넘겨줘야 정상적인 처리가 가능함을 볼 수 있다.

 

 

지금까지 분석된 오픈소스 코드를 전부 가지고 와서 나만의 scanf()를 만들고 테스트를 해보자.

위에서 설명한 소스 코드들에서 필요한 함수들(__mingw_vscanf(), __mingw_vscanf(), __mingw_sformat() ​등등..)을 모두 긁어온다.


예를 들어, 나의 경우 문자열을 받을 때 쓰는 서식 문자가 '%s'​로 하는 것이 마음에 들지 않기 때문에, 이를 scanf()에서 사용하지 않은 서식 문자 중 하나인 '%b'​로 수정하여 문자열을 입력 받을 것이다.

아래는 일부 함수명이 헤더 파일로 정의되어 겹치는 부분이 있어, 일부 함수명을 변경하고, 나만의 scanf() 함수의 이름을 programist_input() 함수로 명명하였다.

 


 

그리고, 위에서 로직을 설명한 것처럼 문자열에 대한 서식 문자가 '%s'​가 아닌 ​'%b'​로 변경하여 컴파일을 하였다.

 

 


아래 그림과 같이 scanf() 함수를 사용하지 않은 채, scanf() 함수에서 사용하는 로직을 그대로 가져와서 임의의 scanf()와 동일한 동작을 하는 함수를 만들었으며, 프로그래머가 원한다면 다음과 같이 scanf() 유사한 함수를 프로그래머 입맛대로 수정해서 사용해도 될 것이다.

해당 테스트 코드는 참고용으로 업로드하였다.

 

 


프로그래밍 언어를 공부할 때, 문법을 배우고 직접 코드를 작성하는 것도 좋은 방법이지만, 다음과 같이 라이브러리에 대한 동작 방식과 원리를 파악하기 위해 분석을 하다보면 직접 코드를 작성하는 것보다 지금처럼 더 큰 경험을 얻을 수 있다는 것을 염두해야 할 것이다.

 

C 언어에서 포인터 변수는 각각의 자료형에 대응하는 포인터 자료형을 선언(char → char *, int → int *)한다. 하지만, 사실상 모든 포인터 변수가 4 Byte 크기의 표현 범위를 가지고 있으므로 일반 변수의 주소 값을 대입하는 것에 대한 문제는 없을 것이다.


포인터 변수 자료형에 대하여 동일한 크기를 가지는 이유에 대해서는 이전 포스팅에서 설명하였다.
(2017/09/16 - [Programming/C] - C 언어 모든 포인터 변수 자료형이 똑같은 크기를 가지는 이유)

 

 

그렇다면 포인터 변수 자료형을 따로 두는 이유는 무엇일까? 이 부분에 대해서 포인터 자료형에 대하여 메모리에 접근하는 방식을 다뤄본다. 각 포인터 변수 자료형에서 메모리를 어떻게 읽어드리는지 확인하기 위해 아래와 같은 예시 코드를 작성하였다.

int 형 정수 변수에 임의의 수인 671,482,627를 입력하고, 해당 변수에 대하여 (char *), (short *), (int *) 자료형에 대하여 어떠한 값이 출력되는지 확인하는 코드이다.

 


 

위의 예시 코드를 컴파일하여 실행한 결과이다.

 


 

이를 메모리상에 도식한 형태는 아래와 같다.

 


 

이를 각각의 포인터 자료형에 맞게 정리하여 표현한다면, 아래와 같은 형태로 각각의 포인터 자료형이 지정된 크기에 맞게 메모리를 읽어드리는 것을 확인할 수 있다.

 


 

출력된 결과를 바탕으로 정리를 해보면 아래 그림과 같은 방식으로 정리가 될 것이다. (char *)의 경우 선언된 메모리 시작 주소에서 1 Byte 크기의 메모리 크기를 읽어드리고 이를 출력한다. 마찬가지로 (short *)의 경우도 선언된 메모리 시작 주소에서 2 Byte 크기씩 메모리를 읽어드려 값을 표현한다.
위의 그림의 메모리 주소를 자세히 보면, 메모리 순서가 역으로 표현되어 있는 것을 확인할 수 있다.

가령 (short *)인 2 Byte의 경우, 데이터 표현이 0xbfe30846~0xbfe30847가 아닌, 0xbfe30847~0xbfe30846​ 역순으로 표현되고 있으며, (int *)인 4 Byte의 경우도 0xbfe30844~0xbfe30847가 아닌, 0xbfe30847~0xbfe30844​의 순서로 값이 표현되었다.

 - 참고) 만약 정방향의 순서로 값을 읽어드려 (int *) 4 Byte를 표현한다면, 0xbfe30844~0xbfe30847 = 50,529,832 값이어야 한다.

 

그 이유로는 현재 사용되는 CPU에서는 지정된 포인터의 크기만큼 메모리를 읽어드릴 때, 이를 역으로 읽어서 데이터를 표현하게 되는데 이를 Little Endian 표기법이라고 하며, 아래 그림은 다음 설명한 내용을 Little Endian 표기법​으로 정리하여 요약한 것이다.

 


 

다음과 같이 포인터 변수마다 각각의 고유 자료형 이름을 가지는 이유는 변수가 시작되는 주소 값에서 얼마만큼의 크기를 읽어드릴지 위한 단위의 지정​하기 위함​임을 알 수 있다.

 

일반적인 자료형(char, int, float, double 등등...)와 다르게 포인터 변수 선언 시, 자료형들은 동일한 크기를 가진다. 이번 포스팅은 포인터 자료형이 동일한 크기를 가지는 이유에 대해 설명한다.


예를 들어, 아래의 코드를 컴파일해보자.

 

 


이 코드를 32 Bit 프로그램으로 컴파일하였을 경우는 아래와 같다.

 


 

다음으로 64 Bit 프로그램으로 컴파일하였을 경우 아래와 같다.

 

 


왜 동일한 크기를 가지는 것일까? 그리고 32/64 Bit에서 포인터 크기가 변한 이유는 무엇일까? 모든 자료형이 그렇듯이 각각의 자료형은 해당 자료 표현을 위해 그 범위 만큼의 크기를 가진다. char 자료형은 1 Byte의 크기를 가진다. 즉, 1 Byte = 8 Bit이므로 표현할 수 있는 범위는 아래 그림과 같을 것이다. 아래 그림에서 char 자료형을 예를 들어 보자.

 

 


총 표현할 수 있는 범위는 0~255까지 256의 경우의 수를 표현하며, 이를 ASCII 코드에 대응하여, 지정된 문자를 출력한다.

다음으로, int 자료형의 경우의 수를 예를 들어보자.

 

 


32 Bit 프로그램 기준으로 int 자료형은 4 Byte의 크기를 가지며, 이는 총 32 Bit으로 4,294,967,296개의 경우의 수를 가진다. 이처럼 자료형이 고유의 크기를 가지는 이유는 그만큼 표현할 수 있는 경우의 수에 해당하는 크기를 의미하며, 따라서 각각의 자료형마다 크기가 다르게 지정되어 있다는 것을 알 수 있다.


다시 돌아가서 포인터 자료형의 경우, char, int, double 자료형과는 다르게 모든 포인터 자료형에 대하여 동일한 크기를 가진다. 포인터 변수는 주소 값을 넣는 자료형이라는 것은 다들 알고 있을 것이다. 즉, 주소 값을 저장하기 위한 자료형이기 때문에 OS Bit에 따른 해당 메모리 주소 값을 모두 포인터 변수로 넣을 수 있는 경우의 수가 필요한 자료형이어야 할 것이다.


32 Bit 프로그램을 예로 들어보자. 32 Bit의 경우 표현하여 사용 가능한 메모리양은 4,294,967,296 경우의 수이므로, OS에서 총 가용할 수 있는 메모리는 4 GB(4,294,967,296 Byte)가 된다. 32 Bit에서 포인터가 4 Byte인 이유는 여기서 분명해진다. 가용할 수 있는 모든 메모리의 경우의 수를 표현하기 위해서는 4 Byte 크기의 자료형이어야 때문이다.

그렇다면 64 Bit의 경우는 어떨까? 총 가용할 수 있는 메모리양은 이론상으론 ​16 EB(18,446,744,073,709,551,616 Byte)​일 것이다. 즉, 산술적으로 따져봤을 때 16 EB까지 지원이 가능한 것이지만, 현재 OS에서는 이 정도까지의 대용량 메모리는 지원하지 않는 것으로 알고 있다.

 

포인터를 학습하고 난 뒤, 첫번째로 겪게 되는 어려움 중 하나가 Call-by-Value(값에 의한 호출) & Call-by-Reference(참조에 의한 호출) 일 것이다.

C++ 언어에서는 레퍼런스 혹은 참조자(&)라는 개념이 따로 있으므로, 이를 Call-by-Address(주소에 의한 호출)라고 하지만 C 언어라는 것에 한정을 두고 Call-by-Reference(참조에 의한 호출)의 명칭으로 설명한다.


포인터와 메모리에 대한 개념을 명확하게 알고 있다면 이에 대한 이해가 어렵지 않으나, 사실상 메모리에서 어떻게 주소와 값이 흘러가는지 제대로 파악을 할 수 없다면, Call-by-Value와 Call-by-Reference에 대하여 많은 어려움을 겪을 것이다.


이번 포스팅은 Call-by-Value과 Call-by-Reference에 대한 간단한 코드와 어떻게 흘러가는 지에 대한 도식화를 통해 두 가지 방법에 대한 차이점을 살펴보고 Call-by-Reference의 유용한 점을 파악해본다.

 

 

1. Call-by-Value (값에 의한 호출)

Call-by-Value는 함수에 인자를 변수에 대입된 값을 던져주는 것을 의미한다. 일단 아래의 코드를 보자.

 


 

다음과 같이 int a 변수에는 10을 대입하고, int b 변수에는 20을 대입하였다. 아래는 소스에 대한 변수의 주소와 값의 결과이다.

 


 

main() 함수에서 swap() 함수로 변수 a, b를 넘겨주면, swap() 함수는 a, b 두개 변수의 값을 서로 치환하는 역할을 한다.

하지만 다음의 결과처럼 swap() 함수 내에서는 a, b 값이 변경 되었지만, 정작 main() 함수에서는 a, b 값이 그대로 인 것을 확인할 수 있다.

이해를 돕기 위해, Call-by-Value 코드의 메모리 흐름에 대하여 아래 그림처럼 도식화하였다.


최초 프로그램이 실행되면, 변수에 대하여 아래 그림처럼 메모리에 할당될 것이다.

 


 

a, b 인자값을 넘기는 swap() 함수가 호출되면, 다음과 같이 swap() 함수의 a = 10, b = 20으로 값들이 대입 될 것이다.

 

 


호출된 swap() 함수 내부에서는 다음과 같은 순서로 변수 a↔b 값을 서로 치환하게 될 것이다. 하지만 여기서 중요한 것은 아래의 흐름처럼 할당된 변수들이 swap() 함수 내부의 지역 변수이며, 이는 main() 함수 내의 지역변수에 아무런 영항을 미치지 않는다는 것이다.

 


 

결과적으로 다음과 같이swap() 함수 내부에서는 변수 값이 치환된 것을 확인할 수 있다. 하지만, 이는 main() 함수의 변수에 아무런 영향을 미치지 않으며, 오직 swap() 함수 내의 지역 변수에서만 유효함을 확인할 수 있다.

 


 

메모리의 변화 과정을 요약하면 동작은 다음과 같다.



 

2. Call-by-Reference (참조에 의한 호출)

다음으로, 지역 변수의 주소와 포인터를 활용한 Call-by-Reference를 한다. Call-by-Reference는 주소값을 참조하고 이를 호출하는 의미이다. 일단 아래의 코드를 보자.

 


 

Call-by-Value 예제코드와는 다르게 main() 함수의 변수 a, b의 주소를 넘겨준다. 소스에 대한 결과는 아래 그림과 같다.

 

 


Call-by-Value와는 다르게 swap() 함수 내에서 변경된 a, b 값이 main() 함수에서도 똑같이 적용된 것을 확인할 수 있다. 이에 대한 설명을 아래 그림과 같이 Call-by-Reference 코드의 메모리 흐름에 대하여 도식화하였다.

 

Call-by-Value처럼 최초 실행 시, 다음과 같은 메모리가 할당될 것이다.


 


a, b의 주소를 인자값으로 넘기기 때문에 swap() 함수가 호출되면, 다음과 같이 swap() 함수의 포인터 변수 a, b에 주소값들이 대입될 것이다.

 

 


swap() 함수에서는 넘겨 받은 a, b의 주소 참조하여, 아래와 같이 치환을 한다. 단, swap() 함수 내의 변수들은 포인터 변수이며, 해당 포인터 변수의 주소를 참조하여, main()함수의 a, b 값을 바꾸므로 실질적으로는 참조된 주소값인 main() 함수의 a, b 값을 변경한다.

 

 


다음과 같이 포인터를 활용하여 main() 함수 내의 변수 a, b에 대한 값을 치환하는 것을 확인할 수 있다.

 


 

메모리의 변화 과정을 요약하면 동작은 다음과 같다.

 

 


3. Call-by-Reference의 유용성

​Call-by-Reference를 사용하는 것은 많은 이유가 있겠지만, 일단은 메모리에 대한 절감이다. 아래와 같은 그림을 가정하여 생각해보자.

 

 


프로그램이 수행되는 하드웨어 환경의 한계에 의해 다음과 같이 메모리가 거의 가득 찬 상태에서 프로그램이 구동되어야 한다고 가정해보자.

프로그램이 실행하는 시점에 다음과 같이 메모리가 거의 가득차 main() 함수의 int temp[1000]을 temp() 함수에 인자로 넘겨할 경우, temp() 함수는 이를 다시 메모리에 int temp[1000](약 1000 * int(4) = 4000 Byte)​의 크기로 할당하여야 한다.

하지만, 현재 메모리는 가득 찬 상태로 결국 변수에 대한 메모리 용량을 확보하지 못한 채로 프로그램 수행에 문제점이 발생할 것이다.


하지만, 이를 Call-by-Reference를 사용할 경우, 예를 들어보자.

 

 


포인터 변수인 4Byte의 할당만으로도 main()의 int temp[1000] 변수를 참조하여, 프로그램 수행이 가능하다. 물론 예제를 int temp[1000](약 1000 * int(4) = 4000 Byte)​로 설명하였기 때문에 실질적으로 그럴 일은 없겠지만 변수에 대한 할당량이 크면 클수록 다음과 같이 포인터 변수에 대한 유용성은 더 증가할 것이다.


다음으로는 속도일 것이다. 포인터 변수 1개에 대한 데이터 쓰기와 다량의 큰 할당이 필요한 데이터 크기에 대한 연산 작업에서 작업량이 많으면 많아질수록 그 격차는 더 심해질 것으로 생각된다.

 

C언어에서가 현재까지 사라지지 않고 언어 사용 순위 중 상위권을 차지하는 이유 중 하나는 포인터라고 해도 과언이 아닐 것이다.

그만큼이나 C언어를 할 줄 안다고 이야기를 하려고 한다면 포인터는 자유자재로 활용할 정도의 수준을 의미하며, 즉, C언어에서 포인터를 사용할 줄 모른다면 C언어를 할 줄 아는 것이 아니라 컴퓨터 프로그래밍이라는 것을 할 줄 안다라고 표현해야 맞을 것이다.


프로그램의 실행은 기본적으로 하드디스크에 저장된 실행파일을 메모리(RAM)로 로드하여, CPU를 통해 명령어를 처리하는 일련의 과정이다. 흔히, C언어를 포기하는 첫 번째 장벽 중 하나가 포인터이며, 이는 메모리 구조에 대한 기초적인 지식조차 없다면 추상적인 의미의 한계에 의해 포기할 수밖에 없게 된다.


이번 포스팅에서는 포인터라는 것이 무엇이고, 어떻게 사용되는지에 관련된 간단한 메모리 참조와 기초 지식에 대해서 다룬다.

Stack, Heap과 같은 메모리 영역에 대해서는 다음 포스팅에서 다룰 예정이며, 간단히 포인터 사용에 있어서 주소(Address)에 대한 개념과 포인터 사용원리에 대한 내용을 중점으로 다룰 것이다.

또한, 32-bit의 4GB 메모리를 기준으로 설명할 것이며, 테스트는 Ubuntu 14.04에서 메모리 가드에 대한 옵션을 제거한 상태에서 설명한다.

 


포인터 사용에 있어 가장 혼동이 오는 부분은 &(앰퍼샌드)와 *(에스테리스크) 사용일 것이다.

이번 포스팅에서는 &(앰퍼샌드)와 *(에스테리스크)​ 사용에 대한 설명을 상세히 설명하고자 한다.

 

 

1. &(앰퍼샌드)의 활용

아래와 같은 코드를 컴파일하여 각각의 변수들에 대한 주소값을 확인해보자.

 


 

&(앰퍼샌드)를 이용하면 할당한 변수의 주소값을 확인할 수 있으며, 해당 주소값은 실행한 시점에서 아래 그림과 같이 할당된 것을 확인할 수 있다.

 

 


이것을 메모리로 도식화한다면 다음과 같이 나타낼 수 있을 것이다.

 

 


포인터 변수는 다음과 같이 변수의 &(앰퍼샌드) 연산자를 이용하여 실질적으로 저장된 메모리 주소(Memory Address)를 저장하는 용도로 사용되며, 이와 같이 변수가 저장된 메모리의 실질적인 주소를 알기 위해서는 &(앰퍼샌드)연산자를 이용하면 가능하다.


이와 같은 원리로 포인터를 사용하여 메모리에 삽입되는 순서를 도식화해보자. 예제 코드는 아래 그림과 같다.

 

 


예제 코드를 실행하면 아래와 같이 각 변수들에 대한 주소값들을 확인할 수 있다.

 

 


이를 도식화하면 메모리에 들어간 변수와 값들은 아래와 같을 것이다.

 

 


이전에 설명한 것처럼 다음과 같이 &(앰퍼샌드)를 이용하여 변수 temp의 메모리 주소를 포인터 변수 ptemp에 저장하는 것을 확인할 수 있다.

 

 

2. *(에스테리스크)의 활용

&(앰퍼샌드)가 변수의 메모리 주소를 확인하는 연산자​라면, 반대로 저장된 변수의 메모리 주소 참조에 활용되는 연산자가 *(에스테리스크)이다.


아래 그림처럼 *(에스테리스크) 활용을 위한 예제 코드를 추가하였다.


 


이를 실행하면 다음과 같은 결과가 나온다.

 

 


이를 도식화하였을 때, *(에스테리스크)를 활용하여 temp2의 변수에 10이 대입​되는지 확인이 가능하다.

 

 

 

3. 흔히 벌어지는 실수

​포인터를 사용하다 보면, 다음과 같은 실수들을 흔하게 마주하게 된다.

 

 


이는 대부분이 포인터를 사용하면서 잘못된 메모리 주소를 참조할 경우 생기는 오류(Segmentation fault)이며, 다음과 같은 간단한 예제를 통해 원인을 살펴보겠다.

 

 


위의 그림처럼 포인터 변수에 프로그래머의 실수로 &(앰퍼샌드)​를 이용하지 않은 채 temp의 값(10)을 대입​하는 경우가 발생하였다. 이를 도식화한 것이 아래의 그림이다.

 

 


다음과 같이 잘못된 메모리 주소 참조로 인해 실행 중 에러가 발생하며, 포인터를 사용할 때 초보자들이 흔히 겪게 되는 오류 중 하나이다. 포인터에 대한 개념과 메모리 주소 참조에 대한 개념 그리고 &(앰퍼샌드), *(에스테리스크)에 대한 용도를 명확하게 이해하고 있는 프로그래머라면 이런 에러가 발생하더라도 적절하게 대응이 가능할 것이다.


+ Recent posts