C언어의 메모리 구조에 대하여
메모리 구조는 진작에 짚고 넘어갔어야할 주제인데 이제서야 제대로 공부해봤다.
libasm 과제를 하면서 어셈블리어를 처음 접했다.
주먹구구식으로 하다보니 공부할수록 찜찜하게 이해가 안 되는 부분들이 많았다ㅠㅠ
아마도 메모리 구조를 어설프게 알고 있어서 그런게 아닐까.
- 어셈블리어란?
어셈블리어는 기계어와 일대일 대응이 되는 low level 언어이다. 101010001 이런 기계어와 C, Java, Python 등 high level 언어의 중간에 있다고 볼 수 있겠다.
- 어셈블리어가 왜 중요하지? 왜 알아야하지?
- 디버깅 결과가 어셈블리어로 나오니까. 어셈블리어를 모르면 결과를 분석할 수 없다.
gdb에서 -g 옵션을 주면 어셈블리어가 아닌 디버깅도 되긴 하지만 뭐.. 일단 gdb 잘 모르니까 넘어가고 - 어셈블리어를 알면 컴퓨터 시스템 내부의 동작 원리를 이해하기가 쉽다.
- 해커가 될 수 있다ㅋ. 소스코드가 없어도 리버스 엔지니어링을 통해 프로그램을 분석할 수 있다.
아무튼, 어셈블리어가 메모리 주소와 값에 접근하며 메모리와 밀접하게 작동하다보니 메모리 구조를 공부하지 않을 수 없었다.
메모리 구조
그림과 함께 우리가 실행한 프로그램이 메모리에 어떻게 올라가는지 보자.
그림 종류가 두 가지가 있다.
난 높은 주소가 위에 그려진 왼쪽 그림이 직관적이라 더 편한 것 같다.
스택에 데이터가 쌓이는 모습을 표현하기 위함 때문인지 오른쪽 그림처럼 거꾸로 그린 그림도 많다.
그림에 그려져있는 부분은 유저 영역이다.
그리고 그림엔 안 나와있지만, stack 영역 보다 더 높은 메모리 주소엔 커널영역이 있다.
커널 영역은 시스템 운영에 필요한 메모리로, OS 가 올라가있다. 사용자가 함부로 접근할 수 없는 영역이다.
이제 유저 영역의 각 영역들을 자세히 살펴보자.
1. Text Segment (=Code Segment)
- 작성한 코드가 기계어로 번역되어 저장되는 곳이다.
- 프로그램이 실행되면, 이 영역에 있는 코드들이 한 줄씩 실행되는 것이다.
- 여기는 read-only 영역이라 데이터들이 변경될 수 없다.
- rodata (read-only data)인 상수리터럴도 이 영역에 저장된다.
2. Data Segment
전역변수(global), 정적변수(static), 배열(array), 구조체(structure) 등이 저장된다.
이 영역은 read-only가 아니므로 프로그램 실행중에 값이 바뀌는 것이 가능하다
2.1 Initialized Data Segment
- 프로그래머가 직접 초기화를 해준 전역변수와 정적변수가 저장되는 곳이다.
2.2 Uninitialized Data Segment
- BSS segment (Block Started by Symbol) 라고 불린다.
- 초기화 되지 않은 전역변수(global)와 정적변수(static)가 저장되는 곳이다.
- 초기화 되지 않은 변수는 커널이 자동으로 0으로 초기화시킨다.
3. Stack
- 스택 영역은 힙 영역과 인접해있고, 메모리 주소가 높은 곳에서 낮아지는 방향으로 자란다.
- 함수의 호출과 관계되는 지역 변수와 매개변수가 저장되는 영역이다.
- 컴파일시 크기가 결정된다.
- 함수가 호출되면 함수의 스택 프레임(stack frame)이 생성되고, 스택 프레임이 스택에 추가 된다. (Push)
- 함수의 실행이 끝나 함수가 반활 될 때 스택 프레임이 스택에서 제거된다. (Pop)
- LIFO (Last In First Out) 구조를 갖는다.
4. Heap
- 동적할당(malloc, relloc, free)으로 할당한 메모리들이 위치하는 영역이다.
- 스택과 반대로 메모리 주소가 낮은 곳에서 높은 곳으로 증가한다.
- 런타임시 크기가 결정된다.
- 힙 영역은 라이브러리와 프로세스에서 동적으로 로드 된 모듈들끼리 공유할 수 있는 영역이다.
- free 제대로 해주자.
스택프레임(Stack Frame)에 대하여
아래와 같은 프로그램이 실행된다고 하자.
정수 두 개를 매개변수로 받아 그 합을 리턴하는 sum 함수이다.
int sum(int a, int b)
{
return (a + b);
}
int main(void)
{
int c = sum(1, 2);
return c;
}
|
👇 위에 스택 영역에 대해 이런 말을 써놨었다.
- 함수가 호출되면 함수의 스택 프레임(stack frame)이 생성되고, 스택 프레임이 스택에 추가 된다. (Push)
- 함수의 실행이 끝나 함수가 반활 될 때 스택 프레임이 스택에서 제거된다. (Pop)
위 예제 코드가 실행되면서 스택프레임이 어떻게 생성되고 제거되는지 살펴보자.
먼저 main 함수가 호출되면 아래와 같은 스택프레임이 생성되고 스택에 추가된다.
스택프레임의 구성
- RET : Return address
함수가 종료된 후 반환 되어 돌아갈 위치를 담고 있다. - RBP : Base Pointer
스택의 가장 낮은 위치를 가리킨다 (메모리에선 제일 높은 주소). 여기부터 스택이 쌓이기 시작할거라고 알려준다. - RSP : "스택 포인터" 레지스터. 스택에 새로운 값이 들어올 때마다(push) 스택의 top을 추적한다.
- 로컬변수 저장을 위한 메모리 공간
- 매개변수 저장을 위한 메모리 공간
다음으로 sum( ) 함수가 호출되면 sum() 함수의 스택프레임이 스택에 push 된다.
함수가 끝나면 해당 스택프레임은 스택에서 제거되고 RET 정보에 따라 돌아가야할 위치로 돌아간다.
스택프레임의 개념을 알고나니 재귀함수의 작동원리도 더 잘 이해됐다.
함수가 이런 원리로 실행되니까
같은 함수가 계속 실행되는데 변수가 충돌하지도 않고 실행했던 함수들의 반환도 착착 이루어질 수 있는 것이었다.
끝
다음엔 gdb랑 lldb로 디버깅 실습을 해볼까한다.
참조
👆 전역변수, 정적변수를 선언해 변수들의 생존 범위를 확인해보고, 지역변수를 여러개 선언한 후 %p로 메모리 주소를 출력해 stack에서 메모리주소가 낮아지는 방향으로 쌓이는지 확인해보는 예제가 있다.
Memory Layout of C Programs - GeeksforGeeks
Memory Layout of C Programs
www.geeksforgeeks.org
👆 동빈나의 시스템 해킹 강의 중 스택프레임에 대해 설명한 부분.
- goodgid.github.io/Memory-Structure/
- [책] 리처드 리스, <<C 포인터의 이해와 활용>> 3장 포인터와 함수