- 프로그램 메모리 구조
사용자가 C언어를 컴파일한다고 했을 때 지역변수가 저장될 것이고, 함수가 실행될 것이다. 하지만 이런 정보들은 어디에 저장하고, 어떻게 컴파일러는 많은 변수들과 명령어들을 효율적으로 관리하여 프로그램을 실행할까? 아래 그림은 프로그램 메모리 구조의 일부를 도식화한 것이다. 더 많은 영역이 있지만 프로그램을 컴파일할 때 쓰이는 구역들만 살펴보도록 하자.
- Text Area
여기에는 프로그램의 코드가 저장된다. 이 때 사용자 친화적인 C코드로 저장되는 것이 아닌, 컴파일러에 의해 어셈블리어로 변환되어 저장된다.
Read-Only 영역이다. 프로그램이 실행되는 동안에는 수정, 삭제가 불가능하다.
- Initialized Data
선언과 동시에 초기화된 전역변수, static 변수가 이 곳에 저장된다.
- Uninitialized Data
선언되었지만 초기화되진 않은 전역변수가 이곳에 저장된다. 초기화되지 않을 시 쓰레기값이 저장되는 일반적인 지역변수들과는 다르게, =0으로 초기화된다.
Initialized Data와 함께 컴파일러가 파일을 컴파일할 때, 코드를 하나하나 실행해나가는 단계 이전에 정적 변수, 전역 변수를 마킹한 다음 메모리를 할당을 한다. 그 이후, 코드를 하나하나 실행해나가는 과정을 거친다.
여러 c파일로 나눠진 솔루션이 컴파일되는 과정을 살펴보자.
project 아래 main.c, func1.c, func1.h 하위 폴더들이 있다고 가정 하에
- Step 1. 컴파일 단계
각 .c 파일은 개별 오브젝트 파일(.o)로 컴파일된다. (main.c -> main.o)
이 때 컴파일러는 정적 변수, 전역 변수를 마킹하여 Initialized Data, Uninitialized Data영역으로 들어갈 대상으로 지정해 둠
이 때 변수들의 주소는 절대 주소가 아닌, 상대적인 주소이다.
- Step 2. 링크 단계
링커는 컴파일 단계에서 변환된 오브젝트 파일들을 스캔하면서 여러 개의 폴더들을 하나의 실행파일 구조로 만든다. 이때 정적 변수, 전역 변수들의 주소는 절대 주소로 바뀌며 메모리가 할당된다.
- Heap Area
동적 메모리 할당 함수가 사용하는 영역이다. C언어에서는 malloc()같은 함수가 사용될 때, C++언어에서는 new가 사용될 때 사용된다. 주소가 낮은 쪽에서 높은 쪽으로, 쌓아올라가면서 저장된다.
int* ptr1 = (int*)malloc(sizeof(int) * 5);
char* ptr2 = "JANE";
malloc()함수를 이용하여 Heap 영역에 int type 5개짜리 공간을 마련했고, ptr1에는 그 영역의 포인터값을 저장했다. 두 번째는, Heap영역에 JANE\0이란, 각 영역이 char type인 5개짜리 배열을 Heap 공간에 마련했다. 그리고 그 포인터값을 ptr2에 저장하였다.
- Stack Area
일반적으로 사용되는, 함수가 호출될 때 지역변수와 리턴주소를 저장한다. Heap Area와 다르게 주소가 높은 쪽에서 낮은 쪽으로 내려오면서 저장된다. 서로를 향하는 방향으로 값을 저장하는 방법은, 최대한 메모리 저장공간을 남기기 위함과 동시에 기존 저장되었던 메모리의 효율을 최대로 하기 위함이다.
어느 대규모 프로젝트가 존재한다. 프로젝트가 컴파일됨에 따라 지역변수와 전역변수가 계속 증가한다고 생각했을 때, 서로를 향하는 방향으로 값을 저장한다면 메모리부족 문제를 회피할 수 있다.
시간이 지남에 따라 stack, heap영역이 증가해도, 서로의 영역을 침범하지 않는 모습을 볼 수 있다.
- Read - Only 영역
Read-Only 영역이란, 최초에 한 번 초기화할 때를 제외하곤, 수정이 불가능하게 설정된 영역을 뜻한다. Read-Only영역은 Text Area, 그 위에 바로 붙어있는 rodata(read-only data)영역이다. 이 영역은 수정이 불가능한 만큼, 절대로 바뀌지 않는 영역이던가 바뀌면 안되는 값을 저장할 때 유용하게 쓰인다.
#include <stdio.h>
const double PI = 3.14;
double Pizza_Area(int r) {
return r * r * PI;
}
int main() {
const char* const Pizza_Name = "Olive Pizza";
int r = 20;
printf("반지름이 %dcm인 넓이 %lfcm^2 %s!!!!!\n", r, Pizza_Area(r), Pizza_Name);
return 0;
}
위 코드를 살펴보자. PI값은 절대 변하지 않는 상수이므로 read-only영역에 저장했다.
또한 Pizza_Name라는 const character pointer변수에 "Olive Pizza"리터럴 문자열을 저장했다. 왜 이런 일을 했을까?
리터럴 문자열은 read-only영역에 저장되는 값이다. 하지만 개발자가 이 사실을 잊고, Pizza_Name[0] = "A"라는 실수를 범했을 때는 오류가 날 것이다. 그렇기 때문에, 사전에 불상사를 방지하기 위해 const char*이라고, 포인터값의 정보를 수정할 수 없다는 일종의 표지판을 만들어놓은 것이다.
하나 더, Olive Pizza와 같은 리터럴 문자열은 Read-Only data영역에 저장된 값이다. 따라서 character 배열과 다르게 식별자가 존재하지 않는다. 그 결과, rodata에 선언된 리터럴 문자열에 접근하는 방법은 선언 때 리터럴 문자열의 포인터값을 저장했던 포인터 뿐이다.
하지만 그 유일한 포인터가 다른 리터럴 문자열의 포인터값을 저장하는 '불륜'을 저질러 버린다면? 기존의 리터럴 문자열은 아무도 불러주지 않는, 불려질 수 없는 히토리봇치가 되고 말 것이다. 결론적으로 메모리 낭비다.
이런 일을 방지해 주기 위해서, '한번 커플은 영원한 커플'. 포인터 변수에도 const를 달아준다면 서로 평생 떨어지지 않을 것이다.
const char* const Pizza_Name = "Olive Pizza";
Pizza_Name이란 변수는, const character type인 리터럴 문자열의 포인터값을 저장하는 포인터 변수인데, 포인터 변수는 정적 변수이다.
- Read-Only data영역에 들어가는 기준
위에 리터럴 문자열이 rodata영역에 들어가는 것은 이해했는데, 저 포인터 변수는 const이니까 rodata영역에 들어가는 것인가? 궁금해진다. 답은 X이다.
const가 붙는다고 무조건 rodata영역에 들어가는 것은 아니다. rodata에 들어가는지 여부는 변수의 저장 위치(scope), 상수성(const + 초기화 유무)이 결정한다.
저장 위치 | 저장 위치(scope) | 상수성(const, 초기화) | |
const + 전역변수 + 초기화O | rodata | 전역변수 | const 有 , 초기화 有 |
const + 전역변수 + 초기화X | 에러 | 전역변수 | const 有, 초기화 無 |
const + 지역변수 + 초기화O | stack | 지역변수 | const 有, 초기화 有 |
문자열 리터럴 | rodata | 전역변수 | const 有, 초기화 有 |
매크로 선언(#define PI 3.14;) | 메모리에 안 올라감. 그저 숫자로 치환 | - | - |
아까 코드를 한 번 다시 보자. 이제는 읽을 수 있다.
const char* const Pizza_Name = "Olive Pizza";
리터럴 문자열이기 때문에 "Olive Pizza"는 rodata에 저장된다. 반면 Pizza_Name변수는 const이고, 초기화도 되었지만 지역 변수이기 때문에 stack에 저장된다.
여기까지 온 이상 포인터가 rodata에 저장되는 꼴을 보고싶다. 위 표에 표기된 방식이라면 const에, 전역변수에, 초기화까지 진행된 변수라면 rodata에 저장될 수 있다.
#include <stdio.h>
const char* const Pizza_Name = "Olive Pizza";
int main() {
printf("%s", Pizza_Name);
return 0;
}
성공적으로 rodata에 저장되었다!
- Read-Only영역에 저장된 문자열은 '공용'이 될 수 있다
Read-Only영역에 저장된 리터럴 문자열은, 수정이 불가능하기 때문에 메모리 입장에서 보았을 때 공간이 아까울 수 있다. 따라서 컴파일러는 재량껏 Read-Only영역에 저장된 값을 공유하여 사용한다. (C언어의 공식적 기능은 아니고, 컴파일러의 재량이므로 무조건 기능하는 Feature은 아니다.)
#include <stdio.h>
int main() {
char* ptr1 = "APPLE";
char* ptr2 = "APPLE";
printf("%p, %p\n", ptr1, ptr2);
return 0;
}
실행해보았을 때 두 포인터 모두 같은 주소를 가리킨다는 것을 알 수 있다. 기존에 이미 같은 문자열이 존재한다면, 같은 포인터값을 부여하여 중복을 피한 것이다!
그렇다면 int type의 전역 변수 포인터도 값을 공유할까?
#include <stdio.h>
const int a = 3;
const int* ptr1 = &a;
const int b = 3;
const int* ptr2 = &b;
int main() {
printf("%p, %p\n",ptr1, ptr2);
return 0;
}
아쉽게도 같은 포인터값을 공유하지는 않는다. a와 b가 애초에 다른 주소에 할당되어서 그렇다고 생각할 수 있으니 아래 코드도 보자.
#include <stdio.h>
const int* ptr1 = &(const int) { 3 };
const int* ptr2 = &(const int) { 3 };
int main() {
printf("%p, %p\n",ptr1, ptr2);
return 0;
}
ptr1, ptr2에게 복합 리터럴을 이용하여 rodata에 익명 저장된 3의 값을 저장시켰다. [ &(const int) { 3 } 의 뜻: '값이 3인 익명의 const int인 변수를 생성 후, 저장하라' ]
하지만 ptr1, ptr2가 저장하고 있는 포인터값은 서로 다르다.
결론적으로, 컴파일러가 rodata의 정보를 공유하는 행동은 리터럴 문자열에 한했을 때만이다. 고 알고 있으면 좋겠다.
- 정리
1. 프로그램 메모리 구조
Text, Initialized, Uninitialized, Stack, Heap Area로 구성된다.
이때 Text, rodata영역은 Read-Only영역이다.
Stack, Heap영역은 서로 바라보는 방향으로 정보가 저장된다.
2. Read-Only에 저장하는 Method
1. 절대 바뀌지 않는 상수 (PI, 자연상수e, etc)
2. 리터럴 문자열
3. 리터럴 문자열의 포인터값을 담고 있는 포인터 변수
위 3가지는 rodata영역에 저장하거나, const를 이용하여 수정불가로 만들어서 안정성을 높일 수 있다.
3. rodata영역에 저장되는 조건
저장 위치 | 저장 위치(scope) | 상수성(const, 초기화) | |
const + 전역변수 + 초기화O | rodata | 전역변수 | const 有 , 초기화 有 |
const + 전역변수 + 초기화X | 에러 | 전역변수 | const 有, 초기화 無 |
const + 지역변수 + 초기화O | stack | 지역변수 | const 有, 초기화 有 |
문자열 리터럴 | rodata | 전역변수 | const 有, 초기화 有 |
매크로 선언(#define PI 3.14;) | 메모리에 안 올라감. 숫자로 치환 | - | - |
4. 리터럴 문자열은 공유될 수 있다
리터럴 문자열에 한하여, 컴파일러의 판단 하에 같은 리터럴 문자열로 초기화된 두 포인터는 같은 포인터값을 받아올 수 있다.
'C' 카테고리의 다른 글
C / 함수의 인자 평가 순서 (0) | 2025.04.09 |
---|---|
C / 포인터를 향하여 - 포인터 값 초기화 및 값 수정 - Ch.2 (0) | 2025.04.04 |
C / 포인터를 향하여 - Ch.1 (3) | 2025.04.02 |