01. 절차적 함수 호출(Procedure Call) 지원 CPU 모델
우리가 쉽게 생각하는 함수 호출이라는 기능은 보통 소프트웨어적인 기능으로 이해하는 경향이 강하다. 그러나 함수 호출이라는 기능은 하드웨어 종속적인 부분이 상당수 존재한다.
함수가 호출되는 방식은 CPU에 따라서 차이를 보인다. 예시로, ARM 코어에서는 ATPCS(ARM-Thumb Procedure Call Standard)라는 것을 정의하였는데, 이는 함수의 전달인자와 리턴 어드레스를 레지스터에 저장하며, 이 저장방식에 대한 표준을 정의한 것이다.
이번 장에서 설명하는 모델은 상용 컴퓨터와 완전히 일치하지 않다. 컴퓨터 구조를 이해하기 위해 대부분의 CPU에 해당하는 공통분모 위주로 설명할 것이다.
스택 프레임(Stack Frame) 구조
다음 그림은 함수 호출과 스택 관계를 보여준다.
함수 호출 과정에서 할당되는 메모리 블록(지역변수의 선언으로 인해 할당되는 메모리 블록)을 가리켜 스택 프레임이라 한다. 위의 그림을 정리하면 다음과 같이 말할 수 있다.
“fct2 함수가 호출되면서 이 함수 내에 선언된 변수 e와 h가 스택에 할당되는데, 이 메모리 블록을 가리켜 스택 프레임이라 한다. fct2 함수가 반환되면 이 스택 프레임은 모두 반환된다.”
sp 레지스터
지역 변수를 위한 메모리 공간을 스택이라 이름 붙인 이유는 메모리의 구조적 특성(Last In, First Out) 때문이다. 이러한 스택에 데이터를 쌓거나 반환하기 위해서는 쌓아 올린 스택 위치를 기억해야만 한다. 이를 위해서 CPU 내에 sp(Stack Pointer)라는 이름의 레지스터가 존재한다.
위 그림에서 보면, 변수 a, b, c… 의 순서대로 스택에 할당되고 있다. sp 레지스터 값은 이렇게 변수가 하나씩 할당될 때마다 증가하면서 다음 변수가 할당될 메모리 위치를 가리키게 된다.
또한, 변수를 반환하기 위해서는 sp 위치를 아래로 이동시키는 것만으로도 이전에 선언된 변수를 반환할 수 있다(변수 할당 시 이전에 저장된 값들을 덮어쓴다). 때문에 sp가 가리키는 위치를 아래로 이동시키는 방식으로 스택 프레임을 반환한다.
위와 같은 방식에는 한 가지 문제가 존재한다. 메모리 공간을 할당할 경우에는 선언된 변수의 크기에 따라 sp 값을 증가시키면 되지만, 호출이 완료된 함수를 빠져나오는 시점에 함수 내에서 할당된 메모리 공간을 반환할 때, sp를 스택 프레임 단위로 아래로 이동시키려면 얼마나 아래로 이동시켜야 할지 모르기 때문에 문제가 발생한다.
이러한 문제를 해결하기 위해 프레임 포인터 레지스터가 이 역할을 한다.
프레임 포인터 (Frame Pointer) 레지스터
새로운 함수가 호출될 때마다 이 레지스터 값을 0으로 초기화한다. 그리고 변수가 선언될 때마다 그 크기 값을 증가시키는 방식이다.
이 방법은 변수가 선언할 때마다 덧셈 연산을 해야만 한다는 단점이 있다. 이는 스택 연산에 드는 비용을 상당히 늘리는 결과를 초래한다. 따라서, 되돌아갈(함수 호출 이전의)sp 위치만 저장한다. 이 역할을 하는 레지스터를 가리켜 fp(Frame Pointer) 레지스터라 한다.
하지만, 이러한 방식에도 연속적인 함수 호출로 함수 호출이 중첩된다면 문제가 발생할 수 있다.
위처럼 fct1 함수를 반환할 때 다시 한번 fp 레지스터값을 참조할 수 없게 되었다. 왜냐하면 fct1 함수를 호출할 때 저장해 놓은 fp 레지스터 값을 fct2 함수를 호출하면서 덮어써버렸기 때문이다.
스택에 저장하자, 프레임 포인터(Frame Pointer)
앞서 말했던 문제점을 해결하는 방법은 fp의 값을 어딘가에 저장하면 된다. 즉, 함수 호출이 일어날 때마다 fp 레지스터에 저장되어 있는 값을 스택에 저장하는 것이다. 그리고 나서 새로운 값으로 fp 레지스터를 채운다.
위 그림은 함수 호출 시 fp 레지스터에 저장되어 있는 값을 스택에 쌓는 방식을 보여준다. sp 레지스터에 저장된 값을 fp 레지스터에 옮기기 전에, fp 레지스터에 저장된 값을 스택에 쌓아두면, 모든 스택 프레임의 경계 정보를 저장할 수 있다.
02. 함수 호출 인자의 전달과 PUSH & POP 명령어 디자인
보통 함수 호출과 프로시저(Procedure) 호출을 구분하는데, 입력에 대한 출력이 반환값으로 존재하면 함수 호출이라 하고, 출력에 해당하는 반환값 없이 모듈화해 놓은 서브 루틴(Sub-Routine)의 실행을 위한 호출을 가리켜 프로시저 호출이라 한다. 그러나 이번 장에서는 다음과 같은 보다 실질적인 내용에 관심이 있다.
“함수 호출 시 실행 위치의 이동은 어떻게 이뤄지는가?”
“함수 호출 시 전달되는 인자들은 어떻게 내부로 전달되는가?”
“함수 호출이 끝나고 나면 어떻게 이전 실행위치로 복귀하는가?”
함수 호출 인자의 전달방식
함수 호출 시 전달되는 인자를 어디에 둘 것이냐에 대한 해답도 CPU마다, 혹은 CPU를 제조한 제조사의 표준에 따라 달라진다. 지역변수와 마찬가지로 스택에 할당된다고 용기 내어 말할 수도 있다. 실 제로 그렇다! 그러나 모든 전달인자들이 반드시 스택에 할당되는 것은 아니다. 성능 향상을 위해서 일부 전달인자들은 레지스터를 할당해서 이곳에 저장하도록 제품의 표준을 정의하기도 한다.
우리가 디자인한 CPU에는 8개의 레지스터만이 존재하기 때문에, 다음과 같은 결정을 내린다.
"함수 호출 시 전달되는 인자들은 모두 스택에 저장하자!"
그렇다면 호출된 함수 내부에서 선언되는 지역변수 이외에도, 호출 시 전달되는 인자 값과 스택 프레임의 경계 정보(위에서 설명까지 스택에 저장되는 구조라고 말할 수 있다.
PUSH & POP 명령어 디자인
인자 전달을 위한 핵심 연산은 다음과 같다.
"sp가 가리키는 현재 위치에 전달되는 인자값을 저장하고 나서, sp를 증가시켜 다음 메모리 주소를 가리키게 한다."
위와 같은 연산을 이전에 정의했던 LOAD&STORE 명령어로 구성하고자 한다. 그러나 단순하게 다음과 같이 STORE 명령어를 사용할 수 없다.
STORE 7, sp
왜냐하면 STORE 명령어는 다음과 같은 구조를 가지기 때문이다
STORE 대상(레지스터), 목적지(메모리 주소)STORE 7, sp
따라서, STORE 명령어 전에 처리 과정을 거쳐 다음과 같은 명령어를 따른다.
ADD r1, 7, 0
STORE sp, 0x40
STORE r1, [0x40]
이를 말로 설명하면 다음과 같다.
1. 레지스터 r1에 7과 0을 더한 값을 저장한다.
2. sp의 값을 0x40 번지에 저장한다.
3. Indirect 모드를 사용하여 0x40번지에 저장된 값을 주소로 참조하여 r1의 값을 저장한다.
이러한 과정을 거친 후 sp 레지스터 값을 증가시켜야 한다. 저장된 데이터가 4바이트이고, 스택 위로 올라갈수록 메모리 값이 증가한다고 가정할 경우 최종적인 명령어 조합은 다음과 같다.
ADD r1, 7, 0
STORE sp, 0x40
STORE r1, [0x40]
ADD sp, sp, 4
위 그림에서 왼쪽은 명령어 PUSH의 기능을 보여주며, 데이터를 스택에 넣고자 하는 경우 다음과 같은 형태로 명령어를 사용한다.
“PUSH 0x02” or “PUSH r1”
이는 현재 sp 값을 참조하여 해당 위치에 데이터 0x02(or r1) 값을 저장하고 sp의 값 또한 자동으로 증가하는 명령어로 정의하자.
위 그림에서 오른쪽은 POP의 기능을 보여주며, 이는 sp 레지스터에 저장된 값을 감소시키는 것이 전부이다. 따라서 다음과 같이 명령어를 구성할 수도 있다.
“ADD sp, sp, -4” or “SUB sp, sp, 4”
그러나 의미상 POP이라는 명령어를 사용하자.
POP
03. 함수 호출(Procedure Call)에 의한 실행의 이동
이번에는 프로그램이 실행되는 원리에 대해서 살펴보고, 프로그램 작성 시 정의하고 호출되는 함수의 원리도 고민해 보자. 이 과정에서 여러분은 pc 레지스터(프로그램 카운터라 불린다)의 역할에 대해 알게 된다.
다시 살펴보는 메모리 구조와 프로그램 카운터(Program Counter)
우리는 앞서 프로세스를 공부하면서, 프로그램 실행 시 프로세스가 생성되면 다음과 같은 메모리 구조가 형성됨을 알았다.
위 메모리 구조에서 이번에 관심을 가질 영역은 "코드(Code) 영역이다. 이 코드 영역은 프로그램이 동작하기 위한 프로그램 코드(컴파일된 명령어들의 집합)가 올라가는 위치이다.
앞서 명령어의 실행이 세 단계(Fetch, Decode, Execution)로 구분되어 진행됨을 설명하였다. 이 세 단계 중에서 첫 번째 단계가 명령어를 CPU 내부로 가져오는 Fetch 단계인데, 이때 명령어를 가져오게 되는 위치는 프로그램 코드가 존재하는 코드 영역이다.
CPU가 메모리 영역 중 스택을 컨트롤하기 위해서 sp 레지스터를 두었던 것처럼, 명령어를 순적적으로 fetch 하기 위해서 프로그램 카운터라 불리는 "pc 레지스터"를 둔다.
그렇다면 CPU 내부로 명령어를 가져오고 난 다음, 다음 번에 가져올 명령어 위치를 가리키기 위해서 pc 값을 증가시켜야만 한다. 따라서, CPU는 Fetch, Decode, Execution 과정을 계속해서 진행하도록 구현되어 있기 때문에, Fetch 연 산이 일어날 때마다 자동적으로 pc값이 증가한다.
경우에 따라서는 프로그램상에서 pc 값을 직접 조절해야 하는 경우도 생긴다.
함수 호출과 함수 종료
위 그림에서도 보여 주지만 함수 호출이 가능하기 위해서는 순차적인 실행만으로는 부족하다. 특정 위치로의 이동이 가능하도록 해야만 한다. 함수 호출이 발생할 때, 그리고 호출된 함수에서 복귀할 때 특정 위치로의 이동이 필요하다. 이를 위해 Program Counter를 조작한다.
32비트 명령어 기준으로 pc는 명령어를 실행할 때마다 4씩 증가한다. 이 pc에 함수 호출로 인해 이동해야 할 주소 값을 저장해 두면 자연스럽게 실행의 위치는 이동하게 된다. 또한, 마찬가지로 pc값도 백업(Back-Up)해야한다. 만약에 백업해 두지 않는다면 함수 호출이 완료된 이후에 돌아오는 길이 막연해진다. 따라서, 함수 호출 시 스택에 저장하게 된다.
+추가
호출과 반환과정은 두 가지 관점에서 살펴볼 수 있다. 하나는 스택의 관리방법이고, 하나는 프로그램의 실행 위치의 관리 방법이다. 함수 호출 시 스택의 관리를 위해 fp가 있다면, 프로그램 흐름의 관리를 위해 lr이 있다.
04. 함수 호출규약(Calling Convention)
함수 호출규약이란?
전달인자의 스택을 쌓는 방법에 두 가지가 존재하듯이, 함수 호출과정에서 할당된 스택 프레임을 반환하는 방법에도 두 가지가 존재한다(Hybrid 방식이라는 것이 있는데 이를 포함하면 세 가지가 된다. 스택 프레임의 반환은 함수 호출이 완료된 이후의 동작(sp 레지스터값을 복원하는 등)을 의미하는데, 이 주체는 호출자(Caller)가 될 수도 있고, 호출이 된 함수(Function)가 될 수도 있다.
이처럼 함수 호출 시 인자를 전달하는 방식과 스택 프레임을 반환하는 방식을 약속해 놓은 것을 가리켜 함수 호출규약이라 부른다.
__cdecl, __stdcall + a
이는 주로 함수의 선언부에서 확인할 수 있는 키워드로, 함수 호출규약을 지정하는 데에 사용된다. 즉, 다음과 같이 함수를 선언하면, 이는 __stdcall 호출규약에 따라 STDCallFunction 함수의 호출과 반환을 처리하라는 뜻이다.
int__stdcall STDCallFunction(int a, int b, int c);
Windows에는 다음과 같은 매크로가 정의되어 있다.
WINAPI, APIENTRY, CALLBACK
이들은 다음과 같이 정의되어 있다.
#define CALLBACK __stdcall
#define WINAPI __stdcall
Windows 시스템 함수(UI 관련 API 포함) 선언에서는 키워드_stdcall를 직접 사용하지 않는다. 이보다는 CALLBACK이나 WINAPI라는 또 다른 이름을 부여해서 그 함수의 특성 파악에 도움을 주도록 하고 있다.
호출규약의 종류와 의미
32bit의 __cdecl은 C/C++의 디폴트 호출규약으로 알려져 있다. 인자 전달방식은 C 언어 스타일을 따르는데, C 언어 스타일이라는 것은 오른쪽에 전달되는 인자가 먼저 스택에 쌓이는 방식을 의미한다. 반환 시에는 함수를 호출하는 호출자가 스택 프레임을 반환하도록 정의되어 있다.
_stdcall와 _cdecl의 차이점은 스택 프레임을 반환하는 주체이다. stdcall은 호출된 함수 내에 서 스택 프레임을 반환하도록 정의되어 있다.
+추가
콜백(Callback) 함수란, Windows 시스템에 의해 자동으로 호출되는 함수를 의미한다. 특정 상황에서 호출되어야 할 함수를 등록시키는 것이 가능한데, 이때 등록이 되는 함수를 가리켜 콜백 함수라 한다.
fastcall은 말 그대로 함수 호출을 빠르게 처리하기 위한 호출규약이다. 위 표에서 "Parameters in registers" 부분은 전달되는 인자를 저장할 때 레지스터의 사용유무를 설명한다. 첫 번째 전달인 자와 두 번째 전달인자는 레지스터 ecx와 edx를 통해 저장된다.
이 호출 규약에서는 2개의 인자에 대해서 레지스터를 사용하여 함수 호출 속도를 빠르게 하고 있다.
64비트 기반의 함수 호출규약의 경우, 운영체제에 따라 나뉘게 된다. Windows 기반에서는 총 8개의 레지스터를 활용해서 전달되는 인자를 저장하게 되는데, 실제 저장되는 전달인자의 개수는 4개에 지나지 않는다. 위 표의 다음과 같은 설명은 첫 번째 전달인자가 rcx 혹은 xmm0 레지스터에 저장됨을 의미한다.
rcx / xmm0
참고 자료:
윤성우. 『뇌를 자극하는 윈도우즈 시스템 프로그래밍』.한빛미디어, 2007.
'독서 > [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ]' 카테고리의 다른 글
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 12. 쓰레드의 생성과 소멸 (2) | 2023.10.29 |
---|---|
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 11. 쓰레드의 이해 (3) | 2023.10.27 |
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 09. 스케줄링 알고리즘과 우선순위 (3) | 2023.10.03 |
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 08. 프로세스간 통신(IPC) 2 (2) | 2023.10.03 |
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 07. 프로세스간 통신(IPC) 1 (2) | 2023.09.24 |