사진에 정말 잘 나와있으니 각각의 영역에 대한 설명은 생략하겠다.
각각의 변수를 생성해서 주소를 출력해보자.
// mem.c
#include <stdio.h>
#include <stdlib.h>
int global_noinit; // 전역변수 초기화 X
int global_init = 20; // 전역변수 초기화 O
static int static_noinit; // 정적변수 초기화 X
static int static_init = 30; // 정적변수 초기화 O
const int constant = 10; // 상수
int func() { // 함수
return 40;
}
int main(int argc, const char * argv[]) {
char *dynamic_alloc = malloc(sizeof(char) * 8); // 메모리 동적 할당
int local_noinit; // 지역변수 초기화 X
int local_init = 50; // 지역변수 초기화 O
printf("Input : ");
scanf("%d", &local_noinit);
/* ==================== Address ===================== */
printf("초기화 X 전역변수 : \t %p \n", &global_noinit);
printf("초기화 O 전역변수 : \t %p \n", &global_init);
printf("초기화 X 정적변수 : \t %p \n", &static_noinit);
printf("초기화 O 정적변수 : \t %p \n", &static_init);
printf("상수 : \t\t\t %p \n", &constant);
printf("함수 : \t\t\t %p \n", func);
printf("동적할당 변수 : \t %p \n", dynamic_alloc);
printf("초기화 X 지역변수 : \t %p \n", &local_noinit);
printf("초기화 O 지역변수 : \t %p \n", &local_init);
}
결과 : 실행 결과는
ASLR때문에 매번 다를 수 있음!
아래 두 지역변수는 STACK에 할당되므로 역시나 차이가 큰 주소에 위치해 있다. 초기화하지 않은 지역 변수는 독특한 위치에 저장될까 싶었는데 그렇지는 않는 것 같다.
함수는 가장 낮은 주소인 text영역에 저장되므로 31a9라는 가장 작은 주소에 저장되는 걸 확인할 수 있다.
전역 변수와 정적 변수는 그 차이가 "파일 외부에서 접근할 수 있냐?"에 있고 저장되는 위치는 같기 때문에 주소가 비슷한 걸 볼 수 있다.
초기화한 전역변수 6010 → 초기화한 정적변수 6014 → 미초기화 전역변수 601c → 미초기화 정적변수 6020 순으로 할당되어 있다.
초기화 한 변수는 DATA영역이라 조금 더 낮은 주소에, 초기화하지 않은 변수는 BSS영역이라 조금 더 높은 주소에 위치해 있다.
그리고 선언 한 순서대로 낮은 주소부터 할당되어 있는 것 같다.
상수는 R/O DATA영역에 위치해있고, DATA영역보다 더 낮은 주소에 할당되므로 주소가 4008로 더 낮은 걸 알 수 있다.
마지막으로 malloc으로 할당한 동적 할당 변수는 HEAP 영역에 위치하여 낮은 주소부터 높은 주소로 성장하기 때문에 0x5c07로 시작하는 주소를 갖고 있다.
한 가지 의문을 가졌던 점은, STACK은 높은 주소에서 낮은 주소로 자라기 때문에 먼저 선언한 지역 변수가 더 높은 주소를 가지고 있을 줄 알았는데...
먼저 선언한 local_noinit은 4e48에, 나중에 선언한 local_init은 4e4c로 나중에 선언한 변수가 더 높은 주소를 가지고 있다.
어셈블리어로 보면 <+8> sub $0x30, %rsp로 48바이트만큼의 공간을 확보하고 있다.
%edi, -0x24(%rbp)와 %rsi, -0x30(%rbp)는 main함수의 두 매개변수를 저장한 것이고, %fs:0x28, %rax는 stack canary를 %rax에 저장한 뒤 %rax, -0x8(%rbp) 위치에 저장하고 있다.
지역변수 두 개는 가장 마지막 두 줄 movl $0x5, -0x18(%rbp), $0x32, -0x14(%rbp)에 저장하고 있다.
이 흐름을 도식으로 나타내면
높은 주소
rbp+0x8 ┌─────────────────────┐
│ return address │
rbp ├─────────────────────┤
│ saved rbp │
rbp-0x8 ├─────────────────────┤
│ stack canary (8B) │
rbp-0x10 ├─────────────────────┤
│ heap pointer (8B │
rbp-0x14 ├─────────────────────┤
│ local_init = 50 │
rbp-0x18 ├─────────────────────┤
│ local_noinit = 5 │
├─────────────────────┤
│ padding / unused │
rbp-0x24 ├─────────────────────┤
│ argc (4B) │
├─────────────────────┤
│ padding │
rbp-0x30 ├─────────────────────┤
│ argv (8B) │
rsp └─────────────────────┘
낮은 주소
정도로 표현할 수 있고, 실제 mem.c 코드의 흐름과 일치한다는 것을 알 수 있다.
어셈블리어로 봐도 먼저 선언한 변수가 스택에 나중에 추가된 것처럼 보이는데, 뭔가 배운 지식과 다르다.
결론은, 컴파일러의 최적화 작업과 관련이 있다.
코드에 추가한 변수 순서 그대로 스택에 얹는 게 아니라, 스택 프레임 전체 크기를 한번에 확보한 뒤 그 안에서 변수의 오프셋을 정해 배치한다.
alignment, padding최소화, stack canary배치 등의 이유때문이라고 한다.
요약 : 스택은 낮은 주소 방향으로 성장하지만, 지역변수는 선언 순서대로 한 칸씩 쌓이는 게 아니다. 스택 프레임 전체를 확보한 뒤 컴파일러가 그 안의 배치를 결정한다.