시스템 프로그래밍 chap 5. Machine-Level 프로그래밍 과정

2023. 12. 4. 14:19컴퓨터공학 전공공부/시스템 프로그래밍

0. 기존 내용 복습.  Stack & 호출 관습 : 제어 전송

 

1. 호출 관습 : 데이터 전송

데이터 흐름의 절차

데이터 저장소는 레지스터와 스택으로 구성된다. 처음 6개의 인자는 레지스터로 구성되며, 7번째 인자부터는 스택으로 구성된다. 스택 공간은 필요할 때 할당된다.

  • 레지스터 : %rdi, %rsi, %rdx, %rcx, %r8, %r9 + return value %rax
  • 스택 : 

스택 구조

 

데이터 흐름의 예시

//C code
void multstore (long x, long y, long *dest) {
    long t = mult2(x,y);
    *dest = t;
}

long mult2 (long a, long b) {
    long s = a*b;
    return s;
}
//assembly
0000000000400540 <multstore>:
    # x in %rdi, y in %rsi, dest in %rdx (가정하고 시작)
    ...
    400541: mov %rdx, %rbx #save dest
    400544: callq 400550 <mult2> #mult2(x,y)
    # t in %rax
    400549: mov %rax, (%rbx) #save at dest
    ...
    
0000000000400550 <mult2>:
    # a in %rdi, b in %rsi
    400550: mov %rdi, %rax #a
    400553: imul %rsi, %rax #a*b
    # s in %rax
    400557: retq #return

 

데이터 흐름 예시 문제

정답 (2)

6개까지 레지스터에 존재하고, 7개부터는 스택에 존재하므로 일단 스택 공간에 존재한다. 또한 스택의 top에는 return address가 있으므로, top의 다음 칸과 다다음 칸에 순차적으로 a7과 a8이 존재한다. 현재 long 타입이므로 a7과 a8은 8바이트 떨어져 있을 것이고,  return address는 8바이트이므로 a7은 %rsp에서 8바이트 떨어져 있을 것이다.

만약 a7과 a8이 int 타입이었다면, int 타입은 4바이트이므로 a7 = 8(%rsp), a8 = 12(%rsp)였을 것이다.

 

2. 호출 관습 : 지역 데이터의 관리

스택과 저장

스택은 arguments, local variables, return pointer를 저장할 수 있다. 

 

스택 규율

스택 규율이란 스택이 불린 후부터 return될 때까지 제한된 시간 동안 필요한 특정 절차에 대한 상태를 말한다. 피호출자가 호출자보다 항상 먼저 끝나며, 피호출자(callee) 스택이 전부 해제된 후 호출자(caller) 스택이 해제된다.

 

스택 프레임

  • 내용
    • return 정보
    • local 저장소 (필요 시)
    • 일시적인 공간 (필요 시)
  • 관리
    • 처리를 시작할 때 공간이 할당된다. call instruction을 통해 push 기능을 한다.
    • return될 때 공간이 해제된다. ret instruction을 통해 pop 기능을 한다.
  • %rsp, %rbp
    • %rsp : 스택 포인터로, 제일 최근에 쌓인 스택의 top을 가리킨다.
    • %rbp : 프레임 포인터로, 베이스 포인터의 일종이다. 선택적으로 존재한다. 제일 최근에 쌓인 스택과, 다음 스택의 구분 점(frame)을 가리킨다.
    •  

%rbp, %rsp

 

x86-64 리눅스 스택 프레임

  • 현재 스택 프레임 (피호출자)
    • argument build : 호출할 함수의 매개 변수
    • 지역 변수
    • 임시로 대피시킨 레지스터의 값
    • (선택) 이전 프레임의 포인터
    • 반환 주소 : 반환 주소까지의 현재 스택의 프레임
  • 호출자 스택 프레임
    • 반환 주소
    • 호출에 필요한 인자

 

예시 : incr 호출

  • incr
//C code
long incr(long *p, long val) {
    long x = *p;
    long y = x + val;
    *p = y;
    return x;
}
//assembly
incr : 
    movq	(%rdi), %rax //x = *p
    addq	(%rax), %rsi //y = x + val
    movq	%rsi, (%rdi) //*p = y
    ret
    
//%rdi : p, %rsi : val / y, %rax = x / return value
  • incr 호출
//C code
long call_incr() {
    long v1 = 15213; //v1이 레지스터에 잠깐 있는 게 아니라, 메모리공간을 가져야 함.
    long v2 = incr(&v1, 3000); //&v1는 %rdi에, 3000은 %rsi에 넣음.
    return v1+v2;
}
//assembly
call_incr :
    subq	$16, %rsp //%rsp를 16 감소시켜 long 2개를 차지함.
    movq	$15213, 8(%rsp)
    movl	$3000, %esi
    leaq	8(%rsp), %rdi
    call	incr
    addq	8(%rsp), %rax //%rax에 incr()의 return 값이 담겨 있음.
    addq	$16, %rsp //%rsp를 16만큼 증가시켜서 스택을 해제함. 
    			//-8(%rsp)로도 값을 읽을 수 있음.
               	 	//스택 공간만 줄이고 값을 지우지는 않음.
    ret

incr 호출에 따른 스택의 변화

 

레지스터 대피 관습

yoo가 호출자(caller), who가 피호출자(callee)라고 해보자. yoo와 who는 다음과 같은 코드를 가진다.

//assembly
yoo : 
    ...
    movq	$15213, %rdx
    call	who
    addq	%rdx, %rax
    ...
    ret
    
who :
    ...
    subq $18213, %rdx //who가 마음대로 %rdx를 바꿈.
    ...
    ret

yoo가 who를 호출함으로써, who에서 %rdx가 덮어씌워진다. 따라서 who 호출 전, yoo가 %rdx에 담고 있던 정보를 대피시켜 놓아야 한다. 대피시키는 방법에는 크게 두 가지가 있다.

  • caller saved
    • 호출자가 피호출자를 호출하기 전, 자신이 사용하던 값을 스택에 대피시킨다. 이는 호출자의 의무이다.
    • 피호출자가 쓸 것 같으니 호출자가 쓸 수 있는 상태로 넘긴다.
  • callee saved
    • 피호출자가 자신이 쓰려고 하는 레지스터에 원래 있던 값을 스택에 대피시킨다. 이후 레지스터를 사용하고, 반환하기 전 스택에서 값을 다시 꺼내 와 원상복구 시킨 후 반환한다.
    • 피호출자가 쓰고 싶으면 원래의 값을 다른 곳에 대피시킨 후 자유롭게 쓰고, 원상복구 시켜놓아라.

 

x86-64 리눅스 레지스터 사용

 

  • %rax
    • return value
    • caller saved : 피호출자가 사용할 것이 명확한 레지스터이다. 보통 이런 경우 caller saved이다.
  • %rdi ,..., %r9
    • argument : 인자 전달에 사용한다.
    • caller saved : 피호출자가 사용할 것이 명확한 레지스터들이다.
  • %r10, %r11
    • caller saved : 피호출자가 사용할 것이 명확하진 않지만, 관습 상 caller saved이다.

위의 것들을 제외하면 전부 callee saved이다.

  • %rbx, %r12, %r13, %r14
    • callee saved  : 스택과 관련된 것은 아니지만, 관습 상 callee saved이다.
  • %rbp
    • 이전 스택 프레임의 base(처음 주소)가 적혀있는 레지스터이다. 즉, 프레임 포인터로써 기능한다.
    • 이처럼 스택과 관련된 것은 보통 callee saved이다.
    • callee saved
  • %rsp
    • 스택의 top을 저장한다. 
    • callee saved

 

callee saved 예시

//C code
long call_incr2(long x) {
    long v1 = 15213;
    long v2 = incr (&v1, 3000);
    return x + v2;
}

첫 줄에서 x는 %rdi에 들어있다. 그런데 3줄을 수행하기 위해선, %rdi에 &v1이 들어가야 한다. 그렇다면 %rdi의 값을 다른 곳으로 피신시켜야 한다. 피신시키기로 선택한 곳이 %rbx(다른 레지스터)이다.

그런데 %rbx에는 이미 특정한 값이 들어있으며, callee saved인 레지스터이다. 따라서 %rbx에 있던 값을 스택에 옮겨놓고, %rbx를 사용한 후 원상복구해 놓아야 한다.

결론적으로 어셈블리 코드는 다음과 같은 순서를 따를 것이다.

%rbx의 값을 스택에 피신시킨다. → %rdi에 있던 x를 %rbx에 피신시킨다. → %rdi에 &v1을 넣고 incr를 수행한다. → 4줄까지 수행한다. (x+v2의 값까지 구해놓는다.) → 스택을 해제하고, %rbx를 스택에서 꺼내온다. (원상복구)
//assembly
call_incr2 : 
    pushq	%rbx //%rbx 피신.
    subq	$16, %rsp //long 두 개가 들어갈 공간을 만듦.
    movq	%rdi, %rbx //%rdi의 값을 %rbx로 피신.
    movq	$15213, 8(%rsp)
    movl	$3000, %esi
    leaq	8(%rsp), %rdi
    call	incr
    addq	%rbx, %rax //x+v2
    addq	$16, %rsp //스택 해제
    popq	%rbx //피신시켰던 %rbx 꺼냄.
    ret

 

3. 회귀

재귀함수 예시

//C code
//recursive popcount : binary data에 대하여 1의 개수를 세는 함수
long pcount_r (unsigned long x) {
    if (x==0) {
        return 0;
    }
    else {
        return (x&1) + pcount_r(x>>1); //재귀를 통해 다음 칸으로 넘어가 비교
    }
}
//assembly
pcount_r : 
    movl	$0, %eax
    testq	%rdi, %rdi
    je		.L6
    pushq	%rbx
    movq	%rdi, %rbx
    andl	$1, %ebx
    shrq	%rdi #by 1
    call	pcount_r
    addq	%rbx, %rax
    popq	%rbx
.L6 : 
    rep; ret

 

재귀함수 예시 : terminal case (함수 종료)

//C code
if (x==0) {
    return 0;
}

//assembly
pcount_r : 
    movl	$0, %eax //%eax(rax)에 0 값을 넣음.
    testq	%rdi, %rdi //자기 자신을 검사.
    je		.L6 //위의 결과가 0이면 .L6으로.

.L6 : 
    rep; ret //return

현재 %rdi에는 x가, %rax에는 return value가 담겨 있다.

 

재귀함수 예시 : 레지스터 대피

//assembly
pcount_r : 
    pushq	%rbx //%rbx의 기존 값을 스택에 대피.
    movq	%rdi, %rbx //%rbx에 %rdi, 즉 x 값을 대피.

c에서 (x&1)+pcount_r(x>>1)을 수행하기 위해선 기존 x값을 다른 곳으로 대피시켜야 한다. 기존 x값은 %rdi에 들어 있으며, 이를 %rbx로 대피시킬 것이다. %rbx는 callee saved이므로 %rbx의 값을 스택에 대피시킨 후, %rbx에 x를 대피시킨다.

현재 %rdi, %rbx 모두에 x가 담겨 있다.

 

재귀함수 예시 : call setup

//assembly
pcount_r : 
    andl	$1, %ebx //x&1
    shrq	%rdi //x>>1

현재 %rdi에는 x>>1, %rbx에는 x&1이 담겨 있다.

 

재귀함수 예시 : 재귀 호출

//assembly
pcount_r :
    call pcount_r

현재 %rbx에는 x&1, %rax에는 재귀 호출 return value가 들어 있다.

 

재귀함수 예시 : 반환 값 도출 및 반환

//assembly
pcount_r : 
    addq	%rbx, %rax //x&1의 값과 pcount_r(x>>1)의 값을 더함.
    popq	%rbx //피신시켰던 %rbx의 원상복구.
.L6 :
    rep; ret //return

 

재귀함수 예시 : 스택

다음과 같은 형태로 스택에 값이 쌓인다.