ㅇ공부#임베디드/시스템 프로그래밍

3. 메모리 관점에서 C언어 이해하기

BrainKimDu 2023. 9. 3. 11:09

이 글은 다음의 강의를 수강하여 정리 및 복습하는 글임을 밝힙니다.

http://kocw.or.kr/home/cview.do?mty=p&kemId=1223639&ar=relateCourse 

 

시스템 프로그래밍 기초

이 교과목은 컴퓨터 하드웨어 시스템과 운영체제 기반에서 프로그래밍 언어를 어떻게 사용하는지에 관한 기초에 대해서 학습한다.

www.kocw.net

VS 에서 C언어 파일을 하나 만들어주세요.


참조, 주소 연산자

& 해당 연산자는  Reference operator 라고 부릅니다. 한국어로는 참조연산자 혹은 주소연산자라고 합니다. 간단하게 변수를 선언하고, 변수의 주소를 확인하자면 다음처럼 할 수 있습니다.

초기화는 안되었지만, 정적으로 메모리를 할당받은 상태이기 때문에 주소값은 나오는 상태입니다. 그림으로 설명하자면 이런 상태입니다.

여기서 잠시만 추가하자면 int형 변수는 4byte를 가지는 변수로 1Byte는 8bit입니다. 그러니 메모리는 32bit가 됩니다. 다시 돌아와서 일단 메모리를 할당받았는데 초기화가 안된 상태입니다. 

그래서 a변수를 초기화하면 다음과 같이 그릴 수 있을 것입니다.

 

포인터 변수

포인터 변수는 C프로그래밍에서 메모리에 접근하기 위해서 사용하는 변수입니다. 일단 예시로 살펴보도록 합시다.

일단 작동은 하지 않지만 우선 메모리 관점에서 이해를 해보겠습니다. 우선 p의 메모리는 다음과 같이 정의되어 있습니다.

포인터 변수의 size는 32bit입니다. 그리고 주소값은 현재 0x1000 (16진수) 입니다. 아직 초기화가 되어 있지 않은 상태입니다. p의 값은 주소값을 가져야합니다.

다음처럼 p포인터변수가 num의 주소값을 가리키게 하였습니다.  그러면 어떻게 되는지 그림으로 살펴봅시다.

다음처럼 p 포인터 변수는 num변수를 가리키게 됩니다. int형 정수는 32bit의 사이즈를 가집니다.  여기서 코드의 결과를 봅시다.

포인터 변수를 선언할 때는 *를 사용하여 선언하고, 포인터 변수를 그대로 출력하면 주소값을 출력하게 되고, 포인터 변수에 *을 취할경우 포인터 변수가 가리키는 값을 출력하게 됩니다.

 

이제 조금더 들어가 봅시다. num 값을 수정하고 포인터로 출력하는 경우입니다.

그러면 그림으로 확인하면 다음과 같은 상태입니다.

그러면 출력값은 어떻게될까요?

값은 11을 출력하게됩니다. 이번에는 포인터 변수로 값을 수정하게 하는 경우입니다.

*p 즉 p가 가리키고 있는 값을 15로 변경해라. 그러므로 결과는 다음처럼 나오게됩니다.

여기까지는 쉽습니다. 

 

포인터 변수 선언의 실수

4가지의 포인터 변수 선언 방법이 있습니다. 

4가지의 방법 중에서 정상적으로 동작하는 것은 무엇일까요? 

가장 먼저 p = num을 살펴봅시다. 간단하게 틀리다는 것을 알 수 있습니다. p는 주소값과 매칭되기 때문에 num은 값을 나타내므로 성립하지 않습니다. 이와 마찬가지로 *p 는 값을 나타내지만 &num은 주소를 나타내므로 성립하지 않습니다.

그러므로 *p = num 과 p = &num이 정상적인 포인터 변수의 선언방법이며, 원하는 동작을 실현시킬 수 있습니다.

그러나... 위 두 가지 방법 중에 틀린 방법이 있습니다. 그림으로 한 번 살펴봅시다. 

 

우리가 정석적인 방법이라고 배웠던 p = &num부터 살펴봅시다.

p포인터는 num 변수의 주소값을 가리키게 되어 정상적으로 사용이 가능합니다. 그러나 문제는 *p = num입니다.

처음에 선언될 때 포인터 p는 무언가 메모리를 가리키고 있습니다. 그리고 이 가리키고 있는 포인터의 값이 num이야 라고 말해버리면 다음처럼 됩니다.

실제로 a변수를 가리키고 있는 상황이 아닙니다.

VS에서는 초기화되지 않는 변수이기 때문에 컴파일러가 이를 찾아내 막아줍니다.

 

 

배열과 대표주소

우리가 일반적으로 배열을 선언해봅시다.

c의 배열을 선언하고, c를 출력시키면 주소가 나옵니다. 근데, 이 주소가 어떤 주소일까요? 일단 그림으로 접근해봅시다. 

c배열의 모습입니다. 우리가 만약 c를 출력하라고 한다면 출력되는 것은 c[0] 의 주소값입니다. 이를 우리는 대표주소라고 부릅니다. 

추가적으로 char형 변수이기 때문에 데이터의 크기는 1byte 입니다. 주소는 1byte 단위로 나나내기에 1000 1001 1002 1003이라고 볼 수 있습니다 int형 변수라면 데이터의 크기는 1byte 이기 때문에 주소는 1000 1004 1008 1012로 볼 수 있습니다.

 이는 포인터변수라고 생각해볼 수 있습니다. 그래서 다음처럼 출력을 할 수도 있습니다.

값은 a b c d 가 정상적으로 출력되게 됩니다. 그래서 배열에서는 두 가지를 기억하면 됩니다.

c는 포인터 변수로 볼 수있고, c[] 는 값으로 볼 수 있습니다. 그래서 &c[0] = c 와 같은 느낌이라고 이해할 수 있습니다.

 

 

참고삼아서 scanf를 사용할 때 왜? 주소값을 적어야할까요?

키보드를 통해서 입력을 받는 상황입니다. 키보드를 통해 입력을 받기위해서는 주소값을 알아야합니다.

 

 

swap 예제

다음과 같은 swap을 하는 예제가 있다고 합시다. 만약에 포인터가 아니였다면, swap을 하고서 반환값을 지정해주었어야합니다. (call by value) 여기서는 매개변수로 call by address를 실현하였습니다.

그러면 매개변수로 주소값이 넘어가게되는데, 그러면 이걸 포인터 변수로 받아서 *을 통해 값을 확인하면 num1 과 num2의 값을 확인할 수 있습니다.

그리고 num1과 num2의 값을 수정하면 별도의 반환없이 main함수에서 사용하는 변수를 수정할 수 있습니다.

 

 

구조체

구조체는 여러개의 변수들을 그룹으로 묶은 것이라 생각하면됩니다. 그래서 다음처럼 선언하여 사용합니다.

다음처럼 선언하는 것입니다. 클래스와의 차이점이라고 한다면, 접근지정자를 설정할 수 없고, 메소드를 선언할 수 없습니다.

다음처럼 구조체를 이용해서 변수를 선언할 수 있습니다. 다음의 경우 메모리를 어떤식으로 사용하게될까요? 우선 student1 같은 경우에는 다음의 그림일 것입니다.

그리고 사이즈 같은 경우에는 32bit(*char) + 32bit(int) + 32bit(float) 이기 때문에  총 96bit의 size를 가집니다. 그러면 classA 같은 경우에는 960bit의 size가질 것입니다. 

그러나 classB는 포인터형 변수이고 주소를 가리키기 때문에 32bit의 사이즈만을 가지게 됩니다. 

 

구조체 접근 방법

구조체에 접근하는 방법은 변수를 적고 .을 찍는 것입니다.

그래서 변수를 적어주고, 값에 접근을 할 수가 있습니다.  그러나 포인터형으로 선언이된경우 조금 달라집니다. 포인터형 변수는 주소를 가리키고 있기 때문에 *연산을 하면 값을 볼 수 있다고 했습니다. 그러므로 다음처럼 선언을 하면 접근이 가능하겠습니다.

그러나 이 방법보다는 다음에 나오는 방법을 사용하는 것이 좋다고 합니다.

구조체의 내부에는 구조체가 들어갈 수 있다.

다음과 같은 사용이 가능합니다.

 

C언어의 배열의 정적할당(Static memory allocation)

간단하게 배열의 크기를 코드를 작성할 때 정해주는 것을 말합니다.

이런 느낌이 될겁니다. num 배열의 크기는 프로그램이 최대로 사용할 것이라 예상되는 크기만큼이 필요할 것입니다. 

만약에 배열의 크기가 지속적으로 바뀌거나, 사이즈를 늘려야하는 경우가 생길 수 있다거나, 메모리가 일시적이고 지역적일 경우에는 정적 할당은 좋지 못한 선택일 수 있습니다.

 

코드 동작 관점에서 메모리 할당

여기서 stack과 heap은 동적으로 메모리를 할당하게 되는 부분이고, 나머지 3개는 정적으로 메모리를 할당받는 부분이라고 볼 수 있습니다.

먼저 code segment를 알아봅시다.
code segment에서는 우리가 작성한 코드가 바이너리(읽기 전용)로 바뀌어 메모리에 들어갑니다. object file을 포함하여 메모리에 들어가는 부분입니다.

Data segment는 초기화된 변수들이 들어있는 메모리입니다. int a=1 처럼 값을 지정한 변수들이 여기로 들어갑니다. 값이 초기화된 global, static, constant와 external 변수가 들어가며, 동작 중에 메모리 size가 변하지 않습니다. obj이 들어갑니다.

Bss segment는 초기화되지 않은 변수들이 들어있는 메모리 입니다.  값이 초기화되지 않은 global, static, constant와 external 변수가 들어가며, 동작 중에 메모리 size가 변하지 않습니다. obj에 들어가지 않고, size만 obj에 알려줍니다.

여기까지는 컴파일 시에 사이즈가 결정됩니다. 

문제는 이 부분입니다. 이제 이 부분은 메모리가 동적으로 할당하는 부분이라고 볼 수 있습니다. 

먼저 Stack에 들어가는 요소를 살펴봅시다.
1. 지역변수 : 지역변수는 함수내부에서만 사용되며, 함수의 종료와 함께 소멸됩니다.
2. 매개변수 (fuction arguments) : 매개변수에 대한 값이며, 함수의 종료와 함께 소멸됩니다.
3. 돌아올 주소가 들어갑니다.

Heap이란 우리가 사용할 수 있는 부분입니다.
우리가 필요할 때 필요한 만큼 할당받을수 있는 공간입니다. 그러나 사용하지않을때는 직접 동적메모리를 해체해주어야합니다.

 

Heap 메모리에 동적할당 받기 (malloc)

malloc은 다음과 같은 원형을 가집니다.

void *malloc(size_t size);

malloc 앞에 포인터가 있기 때문에 주소가 리턴이 되는 방식입니다. 그리고 void로 선언이 되어 있기 때문에 이를 사용하는 유저가 직접 casting하여 사용합니다.

(int *)malloc(sizeof(int));

할당을 받고 0으로 초기화한다면 calloc을 사용합니다.

void * calloc(size_t num_elements, size_t element_size);

할당을 다시 받아야하는 경우에는 realloc에 우리가 할당 받았던 주소와 새로운 사이즈를 넣어줍니다. 

void *remalloc(void *ptr, size_t new_size);

 

그리고 가장 중요한 것은 메모리를 할당받으면 반드시 해체를 해주어야합니다.

void free(void *pointer);

 

malloc의 할당 받는 예시

보통 우리가 사용할 때는 그냥 바로 할당을 받았습니다. 그러나 이를 배운 시점부터는 malloc에 할당을 하는 경우에는 다음처럼 할당에 실패하는 경우에 error를 처리하는 코드를 추가하는 것이 좋다고 합니다.

동적할당과 정적할당은 코드상에서 많이 다르지만, 메모리 관점에서 접근해본다면 상당히 다른 일이 일어나고 있다는 것을 알 수 있습니다.