간단 지식/System Programming

09. stack frame, register saving convention

납작한돌맹이 2020. 11. 25. 03:06
반응형

컴파일을 하면 스택에 코드가 쌓인다는 것은 우리가 다 아는 사실이다. 그렇다면 스택의 내부는 어떻게 생겼을까? 이 질문에 대한 해답을 얻기 전에 procedure의 data flow와 메커니즘 3가지에 대해 이해할 필요가 있다.

procedure의 data flow는 저장되는 argument가 어디에 어떤 식으로 저장되는지를 말한다. procedure는 레지스터에 총 6개의 argument까지 저장할 수 있다. (저장 순서: %rdi -> %rsi -> %rdx -> %rcx -> %r8 -> %r9) 그리고 별도의 return 값을 저장하는 %rax를 제외하고 나머지 argument는 스택에 저장된다. 즉 인자값이 6개를 넘어가면 7번째 인자부터는 스택에 저장된다는 의미이다.

procedure의 메커니즘은 크게 3가지를 뽑을 수 있다. 첫째, procedure는 코드를 시작하고 함수 호출이 끝났을 시 돌아올 return point를 알려준다. 둘째, argument를 이용한 값 전달과 함수의 return 값을 관리한다. 셋째, 메모리를 할당하고 사용이 끝났을 시 할당을 해제한다. 이 중에서 첫번째 메커니즘에 집중해보자.

 

procedure의 메커니즘을 위해 존재하는 것이 스택이다. 가장 간단하게 요약한 64bit 스택의 구조는 아래와 같이 생겼다.

스택은 아래로 증가하며 스택이 증가할수록 낮은 주소번지가 쌓이게 된다. 그리고 스택의 가장 낮은 주소에는 레지스터 %rsp가 존재한다. 참고로 스택은 메모리 영역이고 레지스터는 하드웨어 영역이기 때문에 %rsp에는 스택의 top에 위치한 저장공간의 주소가 저장된다.

스택의 grow를 결정하는 명령어는 pushq src 또는 popq dest 이다. push는 src를 fetch하는 연산자, pop은 %rsp에 있는 값을 read하는 연산자이다. 그리고 각각의 명령문이 실행됐을 때 스택은 다음과 같이 변한다.

왼쪽이 pushq, 오른쪽이 popq 했을 때의 스택이다. push를 한다는 것은 스택이 자란다는 것과 같은 의미이며 스택은 낮은 주소 방향으로 자라기 때문에 %rsp의 위치도 감소한다. 64bit 스택이기 때문에 8byte만큼 감소한다. 반대로 pop은 스택이 작아진다는 것을 의미하기 때문에 %rsp의 위치는 8byte만큼 증가한다.


push와 pop은 단순히 스택에 코드를 쌓는 역할만 하지 않는다. 프로시져의 메커니즘 중 '코드를 시작하고 함수 호출이 끝났을 시 돌아올 return point를 알려준다' 는 push와 pop원리로 가능한 일이다. 이해를 돕기 위해 스택의 구조를 좀 더 세분화해보자. 

caller's stack frame에는 7번째 이상의 인자값을 위한 공간과 return address를 위한 공간이 존재한다. return address를 위한 공간은 필수이며 이 공간은 call instruction에 의해 push된다. 따라서 callee에서 작업을 마치고 ret instruction에 의해 callee의 stack frame이 pop되면 caller의 stack frame에 저장된 return address로 돌아간다.

current stack frame은 caller 함수에서 call instruction이 실행되는 순간 생긴다. 스택의 top을 가리키는 %rsp와는 달리 current frame의 base point, 즉 시작점을 알리는 %rbp를 저장하는 공간이 있다. 이 부분은 optional 하다. 그 외에도 레지스터, 변수, 임시값, 인자값을 저장하는 공간도 있다.


아래 예제를 보자.

 

long callee(long *p, long val){
         long x = *p;
         long y = x+val;
         *p = y;
         return x;
}

long caller(){
         long v1 = 10;
         long v2 = callee(&v1, 20);
         return v1+v2;
}

callee는 *p가 가리키는 메모리에 저장된 값과 val의 합을 *p에 업데이트하고 *p 영역의 원래 값을 리턴하는 함수이다. 실행에 따른 어셈블리 코드는 다음과 같다.

callee:
         movq  (%rdi), %rax
         addq   %rax, %rsi
         movq  %rsi, (%rdi)
         ret
caller:
        subq   $16, %rsp
        movq  $10, 8(%rsp)
        movl   $20, %rsi
        leaq    8(%rsp), %rdi 
        call     callee
        addq   8(%rsp), %rax
        addq   $16, %rsp
        ret

caller를 시작으로 스택의 내부 변화에 대해 이해해보자.

 

subq 연산으로 %rsp의 위치가 16byte만큼 작아진다는 것을 알 수 있다. 그 말은 스택이 증가했다는 말과 같다.

그리고 movq 연산으로 %rsp의 위치로부터 8byte만큼 떨어진 곳에 10을 저장하라는 명령이 수행된다. 따라서 현재 스택의 내부는 오른쪽 그림과 같다.

movl 연산으로 %rsi에 20을 저장하는 것을 보아하니 %rsi에는 callee에 넘겨줄 두 번째 인자 val이 저장되었다는 사실을 알 수 있다.

마찬가지로 leaq연산으로 %rdi에 %rsp에서 8byte만큼 떨어진 공간을 저장하는 것을 통해 %rdi에는 callee에 넘겨줄 첫 번째 인자 &v1이 저장된다.

(%rdi=&v1, (%rdi)=10, %rsi=20)

 

call callee를 만나 callee 어셈블리로 jump해보자. call instruction이 실행되었으므로 오른쪽 그림처럼 스택에도 변화가 생긴다. current frame이었던 부분이 caller frame으로 바뀌고 새로운 current frame이 생기게 된다. 

 

callee함수의 어셈블리 코드를 읽어보자. 코드와는 상관없이 가장 먼저 return 해야 하는 x가 %rax에 저장된다.

x에 첫 번째 인자를 저장하기 위해 %rax에 메모리 (%rdi)를 저장한다.

그리고 x와 두 번째 인자의 합을 위해 레지스터 %rsi를 %rax와 합하여 업데이트한다. 이때 코드 상에 새롭게 정의된 변수 y는 변수 val과 동일한 레지스터 %rsi를 공유한다.

마지막으로 첫 번째 인자에 y를 저장하기 위해 메모리 (%rdi)를 %rsi로 업데이트한다. (%rdi=p, &v1, (%rdi)=30, %rsi=y, val, %rax=x, v2)

 

 

 

callee함수에서 return이 수행되면 current frame에 해당했던 부분들은 전부 pop되고 caller's frame에 있는 return address가 가리키는 곳으로 이동하게 된다. 따라서 현재 스택의 구조는 오른쪽 그림과 같다.

 

caller함수의 return value는 v1+v2이기 때문에 %rax를 업데이트해야할 필요가 있다. 따라서 %rax를 8(%rsp)와의 합으로 업데이트한다. (%rax = 40)

 

caller함수의 return까지 끝나면 addq연산을 이용하여 할당받은 스택의 공간을 해제한다. 최종적인 스택의 내부에는 %rsp만 남을 것이다.


여기까지 알아도 충분히 프로세스가 어떻게 진행되는지 알 수 있지만 한 단계 더 나아가 register saving convention에 대해서 알아보자. 레지스터 저장 컨벤션은 '레지스터가 일시적인 저장공간을 위해 사용될 수 있는가?' 란 의문에서 시작한다. 아래 예제를 보자.

one: movq $100, %rdx
     call two
     add %rdx, %rax
     ret
    
two: subq $50, %rdx
     ret

두 함수의 기능은 신경쓰지 말고 %rdx에 집중해보자. one함수에서 %rdx = 100이 된 후 two를 호출한다. 그리고 two함수에서 %rdx = %rdx-50 = 50이 된다. 만일 함수의 기능이 %rdx값의 변화를 위해 two를 호출한 것이라면 문제가 없지만, 그렇지 않다면 one에서 원하는 100이 저장된 %rdx가 two로 인해 overwrite되는 문제가 발생한다. 코드를 짜다보면 자주 직면하는 이 문제에 우리는 자연스럽게 새로운 변수를 만들어 overwrite될 값을 백업을 한다. 마찬가지로 컴파일러 역시 그러한 해답을 내려 문제를 해결한다. 즉, '레지스터가 일시적인 저장공간을 위해 사용될 수 있는가?'에 대한 답은 yes이다.

 

레지스터 저장 컨벤션은 caller saved와 callee saved로 구분할 수 있다. caller saved는 caller가 call하기 전에 temoprary value를 스택 프레임에 저장하는 방식을 말한다. 해당되는 레지스터는 return value인 %rax, argument인 %rdi, %rsi, %rdx, %rcx, %8, %r9, caller saved temporay인 %10, %r11이다. 이들은 caller가 사용하기 전 백업해야하는 레지스터로, 만일 값이 보존될 필요가 없으면 백업이 되지 않을 수 있다. callee saved는 callee가 temporary value를 사용하기 전에 스택 프레임에 저장하는 방식을 말한다. 그리고 callee는 caller로 return되기 직전에 백업한 것들을 restore한다. 해당되는 레지스터는 callee saved temporary인 %rbx, %r12, %r13, %r14와 특수 레지스터인 %rbp, %rsp이다. 이들은 callee가 사용하기 전에 백업해야하는 레지스터로, callee는 caller가 백업을 필요로하는 레지스터가 무엇인지 모르기 때문에 반드시 백업해야한다.  따라서 위에서 사용한 caller()함수에 레지스터 저장 컨벤션까지 고려하면 아래와 같다.

적용 전 적용 후
caller:
        subq   $16, %rsp
        movq  $10, 8(%rsp)
        movl   $20, %rsi
        leaq    8(%rsp), %rdi 
        call     callee
        addq   8(%rsp), %rax
        addq   $16, %rsp
        ret
caller:
        pushq %rbx
        subq   $16, %rsp
        movq  %rdi, %rbx
        movq  $10, 8(%rsp)
        movl   $20, %rsi
        leaq    8(%rsp), %rdi 
        call     callee
        addq   8(%rsp), %rax
        addq   $16, %rsp
        popq  %rbx
        ret

caller가 call callee 전에 callee에 인자로 넘겨주는 %rdi를 %rbx에 백업했음을 알 수 있다. 따라서 스택도 아래와 같이 변한다.

 

 

 

(이 글이 도움이 됐다면 광고 한번씩만 클릭 해주시면 감사드립니다, 더 좋은 정보글 작성하도록 노력하겠습니다 :) )

반응형