본문 바로가기
IT 공부/컴퓨터 하드웨어 및 구조

함수 호출 시 스택프레임이 관리되는 원리 (어셈블리 레벨)

by exdus3156 2023. 12. 26.

1. 스택프레임이란

call stack

프로그램이 실행되다가 함수를 호출하면 제어흐름은 호출된 함수 내부로 넘어간다. 이때 해당 함수가 작업하고 있는 데이터를 저장할 새로운 공간 메모리 블록이 생성되는데, 이 블록에는 함수가 전달받은 인자나 내부 지역 변수 등이 저장된다.

이러한 함수의 데이터 블록은 위 그림과 같이 스택 자료구조로 적재된다. 프로그램의 본질이 main 함수로부터 실행이 시작되어 하위 함수를 호출해나가는 top-down 방식이기 때문에 자신을 호출한 함수로 다시 제어흐름을 넘기기 위해서는 호출 히스토리가 필요하며, 이를 위해 스택이 사용되는 것이다.

 

 

2. SP(스택 포인터)와 BP(베이스 포인터)

스택이 실제로 어떻게 관리되는지 어셈블리어를 확인해보자.

2-1. 실행 환경

사실 어셈블리는 시스템마다 종류가 다양하다. 일단 내가 사용한 가상 컴퓨터 환경은 x86-64(64bit) intel i5 11th CPU로 설정되어 있었고, 운영체제는 Ubuntu 22.0.4.1이다. 컴파일러는 우분투에 내장된 gcc를 그대로 사용했다. 물론 어셈블리어는 OS보단 CPU 종류가 더 결정적이므로, 리눅스가 아니라 윈도우에서 작업해도 CPU가 같으면 대체적으로 비슷하게 동작한다.

 

2-2. 소스코드

사용한 예제 코드는 아래와 같다. main 함수가 func1을 호출하고, func1이 내부적으로 func2를 호출한다는 사실만 파악하고 나머지는 무시해도 된다. 아무렇게나 작성한 로직이기 때문이다. 호출 흐름이 중요하다.

# gcc -S test.c -o test.assemble;

위 리눅스 명령어를 통해 어셈블리어로 컴파일된 것을 얻을 수 있다.

 

2-3. 어셈블리어 코드

컴파일된 어셈블리어 코드 중 main 함수 부분이다. 중요한 부분만 따로 표시했다.

rbp와 rsp는 각각 베이스포인터와 스택포인터를 가리킨다. (r이라는 접두어는 현재 시스템이 64bit 기반이라는 것을 나타내며, 8바이트(64bit) 단위로 스택에서 push, pop된다. 만약 32bit 시스템이었다면 ebp, erp로 표기될 것이다.) 그러므로 간단하게 sp, bp 라고 하자.

프로그램이 시작하면 운영체제에 의해 rsp와 rbp는 초기화되어 실행된다. 대략적으로 아래와 같은 이미지일 것이라 생각된다. (대략적으로라는 말은 구체적인 숫자는 잠시 생각하지 말자는 뜻이다. 원리만 파악하는 것이 목적이다. 숫자를 대략적으로 기입했다.)

 

2-4. 구체적인 순서

#1. 초기 상태

표는 메모리의 구조를 그린 것이고, SP와 BP 화살표는 각각 SP, BP 레지스터에 담긴 메모리 주소를 통해 메모리의 어디를 가리키고 있는지 나타낸 것이다. 표는 위로 갈수록 메모리 주소 절대값이 낮아지는데, 스택이 실제로 위를 향해(낮은 주소값) 적재된다.

 

#2. push %rbp

BP에 있는 값을 스택 포인터가 가리키는 곳, 즉 스택에 적재한다. BP에는 100,000이 들어있었다.

 

#3. mov %rsp %rbp

SP에 있는 값을 BP에 옮긴다. BP에는 SP의 값이 그대로 들어간다.

 

#4. sub [number] %rsp

SP에 있는 값에서 특정 숫자를 뺀다. 위 그림과 같이 SP는 위를 향해 올라갈 것이며, 이제 이 공간이 main 함수가 사용하는 데이터 적재 블록이 된다. 이 다음부터는 베이스 포인터 BP를 기준점(offset) 삼아 적절한 위치에 데이터(인자나 지역변수)를 저장하고 처리한다. 

그리고 처리하면서 func1()을 호출(call)하게 된다.

 

#5. push %rbp (func1)

func1의 어셈블리어 코드다.

func1이 호출되면서 제어 흐름이 main에서 fun1으로 넘어가게 된다. push를 통해 SP가 가리키는 곳에 다시 BP의 값을 저장한다.

 

#6. mov %rsp %rbp

BP에 SP의 값을 복사한다.

 

#7. sub [number] %rsp

SP 값에서 특정 숫자를 뺀다. 가리키는 주소값이 작아졌으므로 위로 올라가 새로운 곳을 가리키게 된다. 아래 그림과 같이 SP와 BP의 차이가 새로운 func1의 작업 공간이 되며, func1은 BP를 기준(offset) 삼아 자신의 데이터를 적재해 로직을 수행한다.

로직을 수행하다가 func1은 func2 함수를 호출하게 된다.

 

#8. push %rbp, mov %rsp %rbp

func2 어셈블리어 코드

계속 같은 명령어를 수행하므로 설명은 생략

push %rbp
mov %rsp %rbp
func2()의 작업 공간 할당 완료

func2()에서는 rsp를 굳이 위로 올리진 않고 있는데 이는 성능 최적화를 위해 생략된 것이다.

 

#9. pop %rbp

func2()의 처리가 끝나면 이제 되돌아가야 한다. 스택을 해제해야 한다. func2는 성능 최적화를 위해 sp를 굳이 위로 올리지 않았기 때문에 해제는 매우 간단해진다. 그저 SP가 가리키고 있는 데이터의 값을 빼서(pop) BP에 넣으면 된다. BP는 아래로 내려오게 된다. (이전에 BP가 가리키던 값이다)

해제 완료

 

#10. leave

func1() 어셈블리어 코드

leave는 스택을 해제하는 명령어인데, 원리는 아래와 같다

  1. mov %rbp %rsp
  2. pop %rbp

익숙하다. 사실 func2를 해제한 것과 똑같다.

mov %rbp %rsp, bp의 값을 sp에 복사
pop해서 bp값 복원
해제 완료

 

#11. main의 leave

main() 어셈블리어 코드

설명은 완전히 생략.

mov %rbp %rsp
pop %rbp
완료