컴퓨터 구조에 대한 세 번째 이야기
CPU가 함수 호출을 어떻게 처리하는지 이해하는 게 주 목표
절차적 함수 호출(Procedure Call) 지원 CPU 모델
스택 프레임(Stack Frame) 이란?
임의의 함수내에서 선언된 메모리 공간
함수 호출 과정에서 할당되는 메모리 블록(지역 변수의 선언으로 인해 할당되는 메모리 블록)을 가리켜 스택 프레임이라 한다. 아래 그림을 보면 main 함수 내에 변수 a와 b가 선언되어 있다. 따라서 변수 a와 b가 할당되어 main 함수의 스택 프레임을 구성한다.
함수 호출이 완료되면(return 하고 나면) 기존에 선언된 지역변수에 접근이 불가능하다. 주소를 알고 있다 하더라도 말이다. 이는 할당되었던 메모리가 반환되었음을 의미하는 것이다. 따라서 다음과 같이 말할 수 있다.
"fct2 함수가 호출되면서 이 함수 내에 선언된 변수 e와 h가 스택에 할당되는데, 이 메모리 블록을 가리켜 스택 프레임이라 한다. fct2 함수가 반환되면 이 스택 프레임은 모두 반환된다."
SP 레지스터
지역 변수를 위한 메모리 공간을 스택이라 이름 붙인 이유는 메모리의 구조적 특성(LIFO) 때문이다.
스택 프레임은 가장 먼저 할당되면, 가장 나중에 반환된다.
그리고 가장 나중에 할당되면, 가장 먼저 반환된다. 이것이 스택이라 불리는 이유이다.
계속해서 스택에 데이터를 쌓거나 반환하기 위해서 현재 어느 위치까지 데이터를 저장했는지 기억해야 한다.
다시 말해서 쌓아 올린 스택 위치를 기억해야만 한다는 뜻이다.
이를 위해 CPU 내에 SP(Stack Pointer)라는 이름의 레지스터가 존재한다.
sp 레지스터 값은 변수가 하나하나씩 할당될 때마다 증가한다.
증가하면서 다음 변수가 할당될 메모리 위치를 가리키게 된다.
반면에 호출된 함수가 종료될 경우, 스택 프레임 단위로 sp 레지스터값을 이동시켜야 한다.
호출된 함수가 종료될 경우 그 안에선 선언된 변수들을 동시에 모두 반환해야 하기 때문이다.
변수가 선언되면 현재 sp 가 가리키는 위치에 할당되기 때문에, sp 위치를 아래로 이동시키는 것 만으로도 이전에 선언된 변수를 반환할 수 있다. sp를 아래로 내려도 위에 있는 기존값은 어떻게 되냐면 새로운 변수 할당 시 이전에 저장된 기존값들을 덮어 쓸 것이다. 때문에 sp가 가리키는 위치를 아래로 이동시키는 방식으로 스택 프레임을 반환한다. 즉 기존값을 덮어쓰면 지우는 것과 마찬가지다
문제는 CPU는 우리가 시키는 일만 단순하게 처리할 뿐이다. 프로그램 코드를 통해서 우리가 명령하는 것들을 순차적으로 이행만 할 뿐이다. 따라서 정작 호출이 완료된 함수를 빠져 나오는 시점에서, 앞서 얼마만큼의 메모리 공간을 할당했는지 알지 못한다. 때문에 이를 이한 특별한 장치나 방법이 추가적으로 필요하다. 프레임 포인터 레지스터가 이 역할을 하게 된다.
프레임 포인터(Frame Pointer) 레지스터
되돌아갈 (함수 호출 이전의) sp 위치를 저장해 놓는 레지스터를 가리켜 fp(Frame Pointer) 라고 한다.
쉽게 얘기해 프레임 포인터 레지스터는 SP의 백업이다. 함수가 호출 되자마자 프레임 포인터에 백업시켜준다. 그다음 스택 프레임을 반환하면 FP가 가지고 있는 값을 스택 프레임에 저장하면은 SP는 함수 호출 이전의 위치를 가리키게 된다.
FP 레지스터의 문제점
이 구조는 여러 함수가 호출 될 때 fp 레지스터에 저장되어 있는 값이 덮어써지게 되고 sp 레지스터가 가리켜야 할 위치를 찾지 못한다.
예를 들어 main에서 fct1을 호출했다고 하자 그러면 FP에는 fct1을 호출하기 이전의 위치를 FP에 저장한다. 이때 fct1에서 fct2를 호출하면 FP에 fct2를 호출하기 이전의 위치를 FP에 저장한다. 따라서 기존 fct1을 호출하기 이전의 위치 값이 덮어져 버려 fct1을 반환한 후에는 sp 레지스터가 가리켜야 할 위치를 찾지 못하게 된다.
이를 해결하기 위해서??
스택에 저장하자, 프레임 포인터(Frame Pointer)
- fct2 함수가 호출되기 직전에 sp 레지스터에는 주소값 20이 들어가 있다. 현재 스택 주소를 가리키는 것이다.
- fct2 함수가 호출되기 직전에 fp 레지스터에는 주소값 8이 들어가 있다. main 함수와 fct1 함수의 경계에 해당하는 주소 정보이다.
- fct2 함수가 호출되면서, fp 레지스터에 저장된 값(주소값 8)을 현재 sp 레지스터가 가리키는 위치 20번지에 먼저 저장한다. 그 다음 fp 레지스터에 sp 레지스터값 20을 저장한다. 이 값은 fct1 스택과 fct2 스택의 경계가 된다.
- 이후 fct2 함수 호출이 완료되어 반환하고자 한다면, fp 레지스터에 저장된 값을 참조해서 sp 레지스터값을 20으로 변경한다. 이는 fct2 함수의 스택 프레임을 날리는 효과를 가져온다.
- 현재 sp 레지스터가 가리키는 위치(주소 20번지)에 저장되어 있는 값을 fp 레지스터에 옮겨다 놓는다. 이로써 fct1 함수 호출이 완료되는 상황에서 sp의 위치를 9번지에 가져다 놓을 수 있게 되었다. 이는 main 스택과 fct1 스택의 경계가 된다.
정리
함수 호출이 발생하면 FP는 자기가 가지고 있는 값을 스택에 저장하고 SP는 FP에 현재 SP가 가리키고 있는 위치값을 저장한다.
함수가 반환되면 FP가 가지고 있는 값을 SP에 저장한다. 그러면 SP가 가리키고 있는 위치에는 FP의 백업 주소값이 있는데 그 값을 다시 FP에 가져온다. 함수 호출 발생시 fp가 가리키는 주소값을 sp가 가리키고 있는 주소에 넣는다.(스택에 넣는다) 그리고 fp는 sp가 가리키고 있는 주소를 가리킨다. 함수가 반환되면 fp가 가리키는 주소를 sp가 가리키게 한다. 그러면 sp가 가리키고 있는 주소에 값이 있는데 이 값을 fp가 가리킨다.
PUSH & POP
함수 호출 인자의 전달과 PUSH & POP 명령어 디자인
함수 호출시 전달되는 인자도 지역변수와 같이 스택에 쌓인다. 앞에서 얘기한 지역변수가 선언될 때마다 SP, FP 동작 과정이 인자 전달에도 그대로 통용된다. 스택에 값들을 저장하는데 이 저장이라는게 매우 많이 발생한다. 그래서 스택에다가 데이터를 쌓기 위한 명령어인 PUSH를 한번 디자인해 보자. 컴퓨터 구조 두 번째 시간에서 배웠던 명령어들을 가지고 PUSH의 역할을 하게끔 명령어를 디자인하는 게 이번 시간의 핵심이다.
함수 호출 인자의 전달 방식
함수 호출 시 전달되는 인자를 어디에다 둘 것이냐에 대한 해답도 CPU 마다, 혹은 CPU 를 제조한 제조사의 표준에 따라 달라진다.
이 책에서는 “함수 호출 시 전달되는 인자들은 모두 스택에 저장하자” 주의이다.
sp가 가리키는 현재 위치에 전달되는 인자값을 저장하고 나서 sp를 증가시켜 다음 메모리 주소를 가리킨다.
함수 호출 인자의 전달방식(문제점은?)
우리는 STORE 명령어를 아래와 같이 디자인 했다.
STORE 대상(레지스터), 목적지(메모리 주소) ⇒ STORE 7, sp
첫 번째 문제점
레지스터 정보가 와야 되는데 7이 왔다.
그러면 7이라는 값을 레지스터에 저장한다. 예를 들어 r1에 7을 저장하고 r1을 대상에 적는다.
두 번째 문제점
메모리 주소값이 와야 되는데 레지스터 정보가 왔다
SP가 가지고 있는 값을 메모리에 저장하고 메모리의 주소를 목적지에 적는다.
그러면 최종적으로 r1에 저장되어 있는 값을 대상주소가 가리키고 있는 메모리 공간에 저장해라라고 바꿔주면 된다.
그러면 명령어가 3개가 필요하다.
- 7 값을 레지스터에 저장하는 명령어
- sp가 가지고 있는 값을 메모리에 이동시켜 놓는 명령어
- 레지스터 정보 주소값 정보
STORE 7, sp의 문제점들을 해결해보자
문제점 해결
첫 번째 문제점 해결
STORE 명령어 문제 해결 ⇒ ADD r1, 7, 0
두 번째 문제점 해결
sp 문제 해결 ⇒ STORE sp, 0x40
최종 해결
STORE r1, [0x40]
sp가 가리키는 주소값을 0x40에 저장했다. 우리는 sp가 가리키는 주소에 r1을 저장을 하고 싶은데 STORE r1, 0x40을 해버리면 r1을 0x40에 저장하게 돼버린다. 따라서 인다이렉트 모드를 사용해서 0x40 주소번지로 가고 0x40에 있는 주소값으로 다시 가서 해당 주소값에 r1을 저장한다.
ADD r1, 7, 0
STORE sp, 0x40
STORE r1, [0x40]
ADD sp, sp, 4
위 명령어의 조합을 push라는 명령어가 역할을 한다.
함수 호출에 의한 실행의 이동
실행의 이동은 pc에 키가 있다.
다시 살펴보는 메모리 구조와 프로그램 카운터
프로그램 실행 시 프로세스가 생성되면 다음과 같은 메모리 구조가 형성됨을 알았다.
위 메모리 구조에서 이번에 관심을 가질 영역은 코드 영역이다. 이 코드 영역은 그 이름이 의미하듯이 프로그램이 동작하기 위한 프로그램 코드(컴파일된 명령어들의 집합)가 올라가는 위치이다. 프로그램을 실행시키면 위와 같은 메모리 구조가 형성되고 코드 영역에, 실행되어야 할 명령어들이 올라가서 순차적인 실행이 이뤄지게 된다. 명령어의 실행이 세 단계(Fetch, Decode, Execution)로 구분되어 진행되는데 이 중 첫 번째 단계가 명령어를 CPU 내부로 가져오는 Fetch 단계이다. 이 단계에서 명령어를 가져오게 되는 위치는 프로그램 코드가 존재하는 코드 영역이다. 따라서 컴파일된 프로그램 코드가 코드 영역에 올라간 다음부터 명령어는 순서대로 Fetch, Decode 되고 Execution 되는 것이다. CPU가 메모리 영역 중 스택을 컨트롤하기 위해서 sp 레지스터를 두었던 것처럼, 명령어를 순차적으로 fetch 하기 위해서 프로그램 카운터라 불리는 pc 레지스터를 둔다. CPU 내부로 명령어를 가져오고 난 다음, 다음번에 가져올 명령어 위치를 가리키기 위해서 pc 값을 증가시켜야만 하는데 이러한 작업은 프로그래머가 직접 하는 게 아니라 Fetch 연산이 일어날 때마다. 자동적으로 pc값이 증가한다. 그래서 직접 pc 값을 컨트롤하지 않아도 된다.
정리
프로그램 카운터(pc)는 다음번 명령어의 주소값을 가진다.
CPU는 프로그램 카운터가 가리키는 위치의 명령어를 실행한다.
그래서 A 함수의 코드를 실행 중이다가 B 함수를 실행시키고 싶으면 B 함수의 시작 위치를 PC에 저장하면 된다.
즉 함수 호출에 의한 실행의 이동은 pc의 값을 변경하는 거다. pc값을 변경하는 것이 함수 호출이다.
pc의 역할
- 순차적인 실행
- 함수 호출(실행의 이동)
SP와 FP의 관계
- SP를 FP에 저장하는 이유는 스택을 반환하기 위해서다.
- SP가 계속 실행되기 위해서는 FP에 백업값을 저장하고 FP는 메모리에 저장한다.
PC와 LR의 관계
- PC를 LR에 저장하는 이유는 실행하는 이전 위치로 돌아가기 위해서다.
- PC가 계속 실행되기 위해서는 돌아갈 주소를 LR에 저장하고 LR은 메모리에 저장한다.
위에서 말한 메모리 둘 다 스택 영역이다!
'운영체제 > 뇌를 자극하는 윈도우즈 시스템 프로그래밍' 카테고리의 다른 글
뇌를 자극하는 윈도우즈 시스템 프로그래밍 12장 (0) | 2024.09.07 |
---|---|
뇌를 자극하는 윈도우즈 시스템 프로그래밍 11장 (0) | 2024.09.07 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 9장 (0) | 2024.09.07 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 8장 (0) | 2024.09.05 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 7장 (0) | 2024.09.05 |