본문 바로가기
TIL & WIL

[TIL] 크래프톤 정글 3주차 CS:app 5(Procedure)

by 적용1 2024. 4. 14.
728x90

3.7 Procedure란?

- procedure란 특정 작업을 수행하는 명령어의 모음 또는 코드 블럭을 의미한다.(쉽게 말하면 함수)

 

-잘 설계된 소프트웨어는 프로시저가 프로그램 상태에 무슨 영향을 주는지 명확한 인터페이스 정의를 알려주며 구체적인 구현은 감추는 방식으로 추상화 메커니즘으로 이용한다.

 

-프로시저 P가 Q를 호출하고 Q가 실행된 후 다시 P로 리턴하는 과정은 다음 중 하나 이상의 메커니즘을 포함한다.

1) 제어권 전달(passing control) : PC(Program Counter)는 진입할 때 Q의 코드의 시작 주소로 설정되고, 리턴할 때는 P에서 Q를 호출하는 instruction 다음의 것으로 설정되어야 한다.

2) 데이터 전달(passing data) : P는 하나 이상의 매개변수를 Q에 제공할 수 있어야하고, Q는 P로 하나 이상의 값을 리턴할 수 있어야한다.

3) 메모리 할당과 해제(allocating and deallocating memory) : Q는 시작할 때 지역 변수들을 위한 공간을 할당할 수 있고, 리턴할 때 이 공간을 해제할 수 있다.

 

3.7.1 런타임 스택(The Run-Time Stack)

- C를 포함한 대부분의 언어에서의 프로시저 호출 방식의 주요 특징은 스택의 LIFO(Last-In-First-Out) 메모리 관리 방식을 활용할 수 있다는 점이다.

 

- 프로그램은 스택을 이용하여 프로시저들이 요구하는 저장 장소를 관리할 수 있으며, 여기서 스택과 프로그램 레지스터들은 제어와 데이터를 정송하기 위해 그리고 메모리를 할당하기 위해 필요한 정보를 저장한다.

 

일반적인 스택 프레임 구조

 

- x86-64의 스택은 작은 주소 방향으로 성장하며 스택 포인터 %rsp는 스택의 top을 가리킨다.

 

- 데이터는 pushq, popq instruction을 이용해서 스택에 저장되고 읽어올 수 있다.

 

- x86-64 프로시저가 레지스터들에저장할 수 있는 개수 이상의 저장 공간이 필요할 때, 공간을 스택에 할당하는 데, 이 영역을 프로시저의 stack frame이라 한다.

 

- 현재 실행 중인 프로시저에 대한 프레임을 스택의 맨 위에 위치시킨다.

 

- 프로시저 P가 프로시저 Q를 호출할 때 return address를 스택에 푸시해서 Q가 리턴할 때 P에서 프로그램이 실행을 재시작해야하는 위치를 가리킨다.

└> 이 때 리턴 주소는 P에 관계된 상태(state)들을 저장하므로 P의 스택 프레임에 속하는 것으로 간주한다.

 

- 대부분의 프로시저의 스택 프레임들은 고정된 크기를 할당받지만, 일부 프로시저들은 가변 크기의 프레임을 갖는다.

 

- 프로시저는 최대 6개의 정수 값들(포인터와 정수)을 레지스터로 전송할 수 있지만, 그림과 같이 Q가 더 많은 인자를 요구한다면 6개를 넘어가는 인자들은 호출하기 전에 자신의 스택 프레임 내에 P에 의해 저장될 수 있다.

 

- 시간과 공간의 효율을 위해 x86-64 프로시저는 프로시저들이 6개 이하의 인자들을 갖게 하여 매개 변수들을 레지스터로 전달하게 하는 등, 요청 받은 스택 프레임의 부분만을 할당한다. (많은 함수들은 스택 프레임을 요청하지 않기도 한다.)

 

- 프로시저가 다른 프로시를 호출하지 않는 경우, 그 프로시저를 leaf procedure라 부른다.

 

3.7.2 제어의 이동(Control Transfer)

- 제어를 함수 P에서 Q로 전달하는 것은 단순히 PC를 Q의 코드의 시작 주소로 설정하는 것과 연관된다.(Q가 리턴할 때 P로 돌아가야하므로 다시 시작해야하는 코드 위치의 일부 기록을 갖고 있어야함)

 

- x86-64는 이 정보를 call Q instruction으로 프로시저 Q를 호출해서 기록한다.

└> 주소 A를 스택에 push하고 PC를 Q의 시작으로 설정(A : return address, P에서 call Q 다음 instruction)

 

- ret instruction은 스택에서 주소 A를 pop해오고 PC를 A로 설정한다.

 

일반적인 call, ret instruction 형태(역어셈블된 출력에서는 각각 callq, retq로 명명)

 

예제 1(제어를 프로시저로 전달하고, 프로시저로부터 전달받는 예제)

3.2.2장에서 나온 main과 multstore 함수를 역어셈블한 일부
위의 두 함수에 대한 call과 ret instruction의 실행을 보여주는 그림(%rsp : 스택 포인터, %rip : 프로그램 카운터)

 

main의 주소 0x400563을 인자로 갖는 call instruction은 함수 multstore를 호출한다.(이 과정은 (a)에 나와있다.)

 

call로 인해 리턴 주소 0x400568을 stack에 저장하고 0x400540에 위치한 함수 multstore의 첫 번째 instruction으로 점프하게 된다.

 

함수 multstore은 주소 0x40054d의 ret instruction을 만날 때까지 실행된다.

ret instruction은 스택에서 0x400568을 pop해서 이 주소로 점프한 후에 main에서 call 다음의 instruction부터 실행을 재개한다.((c)에 나와있다.)

 

예제2(예제1보다 자세한 예제)

(b)에서 Instruction은 인스트럭션 레이블, PC, 인스트럭션 유형을 통해 실행되는 인스트럭션을 설명하고,                                      State values는 인스트럭션 실행 전의 프로그램 상태를 보여준다.

 

(a)에는 top을 호출하는 main함수의 코드 일부분과 두 함수 top과 leaf를 역어셈블한 코드를 보여준다.

 

각 instruction은 L1~L2(leaf에서), T1~T4(top에서), main에서는 M1~M2 레이블로 식별된다.

 

(b)에서 볼 수 있듯이, main함수에서는 top(100)을 호출해서 top이 leaf(95)를 호출하게 된다.

 

함수 leaf는 97을 top으로 리턴하고, 그 후 main으로 194를 리턴한다.

 

leaf에서 return할 때 stack에서 top의 callq 다음 instruction 주소 0x40054e(%rsp에 담겨있음 => *%rsp로 표현)를 pop해서 돌아가는 것을 볼 수 있고, top에서 main으로 return할 때, main의 callq 다음 instruction 주소 0x400560을 pop해서 돌아가는 것을 볼 수 있다.

 

스택 포인터 또한 top 호출 전인 0x7fffffffe820으로 돌아간 것을 볼 수 있다.

 

3.7.3 데이터 전송(Data Transfer)

- x86-64에서 프로시저끼리의 데이터 전달은 레지스터를 통해 일어난다.

ex) 앞의 예제들에서 인자들이 %rdi, %rsi 등으로 전달되고 값들이 레지스터 %rax로 리턴된다.

프로시저 P가 프로시저 Q를 호출할 때, P에 대한 코드는 먼저 인자들을 적절한 레지스터에 복사해야하고, Q가 P로 리턴할 때, P에 대한 코드는 리턴 값을 %rax에서 접근할 수 있다.

 

- x86-64에서는 앞서 말했듯, 최대 6개의 정수형(정수와 포인터) 인자가 레지스터로 전달될 수 있다.(이 레지스터들은 전달되는 데이터 형의 길이에 따라 레지스터 이름을 이용해서 정해진 순서로 이용된다.)

- 64비트보다 작은 인자들은 64비트 레지스터의 적절한 일부분을 이용해서 접근할 수 있다.

ex) 첫 번째 인자가 32비트라면, %edi로 접근할 수 있다.

 

- 함수가 6개 이상의 정수형 인자를 가질 때, 다른 인자들은 스택으로 전달된다.

ex) 프로시저 P가 n > 6인 n개의 정수형 인자를 가지면서 프로시저 Q를 호출한다고 하자. 그러면, P에 대한 코드는 그림 3.25에 나와있는 것처럼 인자 7에서 n까지를 위한 충분한 크기의 저장 공간을 스택 프레임에 할당해야 한다.

이 때, 인자 1~6은 적절한 레지스터에 복사한다. 그리고 인자 7~n은 인자 7을 스택 top에 넣는 방법으로 저장한다.

 

- 매개변수들을 스택으로 전달할 때, 모든 데이터 길이는 8의 배수로 올림된다.

 

- 인자들이 배치된 후에 프로그램은 프로시저 Q로 제어를 전달하기 위해 call instruction을 실행할 수 있다.

 

- 만일 Q가 6개가 넘는 인자를 갖는 어떤 함수를 호출하려면, 자신의 스택 프레임에 그림 3.25에 나와있는 것처럼 "Argument build area"라고 이름 붙인 영역으로 이들을 위한 공간을 할당할 수 있다.

 

인자 전달 예제

 

위 예제에서 proc 함수는 여러 가지 크기(8, 4, 2, 1바이트)의 정수와 각각 8바이트 길이의 포인터형 인자를 포함하는 8개의 인자를 가지고 있다.

 

(b)의 어셈블리 코드를 보면 첫 6개의 인자들은 레지스터로 전달되지만 마지막 2개의 인자는 아래 그림과 같이 스택으로 전달된다.

리턴 주소가 top에 저장되어 있고 2개의 인자들은 스택 포인터 기준 8, 16에 저장되어있는 것을 볼 수 있다.

 

위의 도형은 proc 실행 중에 스택의 상태를 보여준다.

 

그림 3.29의 (b)에서 볼 수 있는 것은 오퍼랜드의 길이에 따라 여러 가지 버전의 ADD instruction이 사용된다는 것이다.

(a1(long)에 대해 addq, a2(int)에 대해 addl, a3(short)에 대해 addw, a4(char)에 대해 addb)

 

또, 6번 줄의 movl instruction은 메모리에서 4바이트를 읽는 반면, 바로 아래에서는 addb는 하위 바이트만을 활용한다는 것을 알 수 있다.

 

3.7.4 스택에서의 지역 저장 공간

다음과 같은 경우에는 지역 데이터가 레지스터를 넘어 메모리에 저장되어야 하는 경우가 있다.

 

· 지역 데이터 모두를 저장하기에는 레지스터의 수가 부족하다.

· 지역 변수에 연산자 '&' 가 사용되었으며, 이 변수의 주소를 생성할 수 있어야한다.

· 일부 지역 변수들이 배열 또는 구조체여서 이들이 배열이나 구조체 참조로 접근되어야 한다.

 

이 경우에는스택 포인터를 감소시켜 스택 프레임에 공간을 할당한 그림 3.25에 "Local variables"로 명명된 스택 프레임의 일부분에 저장해야한다.

 

위 예제에서 함수가 caller가 어떻게 스택 프레임을 이용해서 지역 변수 arg1, arg2를 구현하는지 볼 수 있다.

 

caller는 스택 포인터를 16 감소시키는 것을 통해 16바이트를 할당하는 것으로부터 시작된다.

그 후 &arg1과 &arg2를 각각 %rsp(스택 포인터)와 %rsp + 8 로 계산해서(5, 6번 줄) arg1과 arg2가 스택 포인터 기준 오프셈 0과 8의 위치에 스택 프레임 내에 저장되는 것을 알 수 있다.

 

그 후 swap_add 호출이 완료되면 스택에서 두 값을 가져와서 차이를 계산하고(8, 9번 줄) swap_add가 리턴한 값이 저장된 %rax에서 값을 가져와 곱하는 것(10번 줄)을 볼 수 있다.

 

마지막으로 스택 포인터를 16 증가시켜 스택 프레임을 반환한다.(11번 줄)

 

위 예제보다 더 복잡한 예제

 

이 예제는 지역 변수를 위해 스택에 저장 공간을 할당해야 하고, 8개의 인자를 갖는 함수 proc(그림 3.29)로 값들을 전달하는 함수를 보여준다.

 

 

call_proc은 위와 같이 스택 프레임을 생성한다.

2~15번 줄이 함수 proc을 호출하기 위해 사용되는 것을 볼 수 있는데, 이 부분은 지역 변수와 함수 매개 변수를 위해, 레지스터에 함수 인자들을 적재하기 위해 스택 프레임을 설정하는 것을 포함한다.

 

위 그림에서 볼 수 있듯이, 지역변수 x1 ~ x4는 스택에 할당되며 여러 크기를 갖는다. 각각의 위치를 스택 포인터에 대한 상대 오프셋으로 나타내면 x1은 24~31 byte, x2는 20~23 byte, x3는 18~19 byte, x4는 17 byte에 위치한 것을 볼 수 있고,

인자 7과 인자 8을 각각 오프셋 0과 8 위치에 스택에 저장된다.

 

이 때, x1, x2, x3, x4의 경우는 &연산자가 붙어있기 때문에 stack에 push해야해서 들어간 것인데 매개변수로서 stack에 들어간게 아니기 때문에 8의 배수로 올림한 단위로 들어가지 않은 것이다.

 

3.7.5 레지스터를 이용하는 지역 저장소

- 프로그램 레지스터들은 모든 프로시저들이 공유하는 단일 자원의 역할을 한다.

 

- 한순간에 하나의 프로시저만이 활성화될 수 있지만, 하나의 프로시저가 다른 프로시저를 호출할 때, 피호출자는 호출자가 나중에 사용할 일부 레지스터 값은 덮어쓰지 않는다.

 

- x86-64에서는 "관습적으로" 레지스터 %rbx, %rbp, %r12 ~ %r15는 피호출자-저장(callee-saved) 레지스터로 구분한다.

 

- 피호출자는 위 레지스터에 담겨있는 값들을 변경하지 않거나 원래의 값을 스택에 push해두고 이 값을 변경하고 리턴하기 전 이전 값을 pop해오는 방식으로 레지스터를 보존한다.

 

- 레지스터의 값들을 push하는 것은 그림 3.25에서의 "Saved registers"로 이름 붙인 스택 프레임의 일부분을 생성하는 효과를 갖는다.

 

- 스택 포인터 %rsp를 제외한 모든 레지스터들은 호출자-저장(caller-saved) 레지스터로 구분된다.

(함수에 의해 변경될 수 있음)

(피호출자가 이 레지스터를 변경할 수 있기 때문에 호출자가 호출 전에 데이터를 저장해야한다.)

 

예제

 

위 예제의 함수 P는 Q를 두 번 호출한다.

 

첫 번째 호출 동안에 x값을 다음 번에 사용하기 위해 보존해야 한다.

 

두 번째 호출 동안에는 Q(y)가 계산된 값을 보존해야 한다.

 

이 코드에서 2개의 피호출자-저장 레지스터들을 사용한다는 것을 알 수 있다.

%rbp는 x를 보관하고, %rbx는 Q(y)를 계산한 값을 저장한다. 이 함수의 시작 부분에서 두 레지스터의 값을 스택에 보관하고 Q의 첫 번째 호출 전에는 x를 %rbp에 복사하고, 두 번째 호출 전에 첫 번째 호출 결과를 %rbx로 복사한다.

 

함수의 마지막 부분에서는 2개의 피호출자-저장 레지스터들의 값을 스택에서 pop해와 복원한다.

 

3.7.6 재귀 프로시저

- 그동안 나온 레지스터와 스택을 사용하는 관습들은 x86-64 프로시저가 재귀적으로 호출하는 것을 허용한다.

 

- 각 프로시저 call은 스택 상에 자신만의 공간을 가지므로 다수의 call들의 지역 변수들은 서로 간섭하지 않는다.

 

- 스택 규정은 프로시저가 호출될 때 지역 저장소를 할당하고 리턴하기 전에 저장소를 반환하는 적절한 정책을 제공한다.

 

 

위 예제에서 재귀적인 팩토리얼 함수에 대해 생성된 어셈블리 코드를 볼 수 있다.

 

먼저, 기존 값을 스택에 보관하고(2번 줄) 레지스터 %rbx를 매개 변수 n을 저장하는 용도로 사용하는 것을 볼 수 있다.

이 기존 값은 나중에 리턴하기 전에(11번 줄) 복원하는 것을 볼 수 있다.

 

스택 규정과 레지스터 저장 관습으로 인해 언제 rfact(n-1)로의 호출이 리턴될 때 (1) 호출의 결과가 레지스터 %rax에 저장되는 지와 (2) 인자 n의 값이 레지스터 %rbx에 보관되는 것을 알 수 있다.

 

위 예제를 통해 재귀 함수의 경우에도 다른 함수와 같이 자신만의 스택의 개별 저장 공간을 통해 상태 정보를 저장하는 것을 알 수 있다.

728x90