시스템 개발이나 디버깅을 하다 보면 어셈블리 코드 덤프에서 가장 많이 마주치는 두 레지스터가 있다. 바로 sp (Stack Pointer)와 fp (Frame Pointer)다. 특히 함수가 시작될 때 나오는 아래의 세 줄은 나를 포함한 초보 엔지니어들을 혼란에 빠뜨린다.
push {fp, lr}
add fp, sp, #4
sub sp, sp, #32
"함수가 시작됐는데 왜 fp 주소를 sp에서 4만큼 더했다가 다음에는 다시 sp에서 32를 빼는 걸까? 주소가 커지면 스택을 적게 쓰는 것 아닌가? 왜 더하는 것인가? fp와 sp는 무슨 관계일까?"
이 질문에 답하기 위해, 먼저 sp와 fp가 정확히 무엇을 하는 녀석들인지 개념부터 확실하게 짚고 시작해야 한다.
1. sp와 fp는 무엇인가?
(1) sp (Stack Pointer) : 실시간으로 움직이는 스택의 top
스택의 최상단, 즉 현재 가장 마지막 데이터가 들어있는 주소를 가리킨다. 함수 안에서 새로운 데이터를 넣거나(Push) 빼면(Pop) sp의 값은 실시간으로 위아래로 변경된다.
(2) fp (Frame Pointer) : 움직이지 않는 불변의 기준점
현재 실행 중인 함수가 사용하는 메모리 방(Stack Frame)의 시작점을 가리킨다. 함수가 실행되는 동안 fp는 절대로 변하지 않는다. 함수 내에 선언된 수많은 지역 변수들은 이 고정된 fp를 기준으로 "앞에서 8바이트 뒤", "12바이트 뒤"와 같이 일관되게 접근한다.
2. 스택은 "거꾸로" 자란다!
이 개념이 흔들리면 어셈블리 코드의 주소 연산을 이해할 수 없다. 아키텍처 규격상 스택은 값이 쌓일수록 주소가 작아지는 방향(Descending)으로 자란다.
- 고주소: 이미 채워진 공간 / 옛날 함수 데이터
- 저주소: 새로 자라날 공간 / 현재 함수 데이터
즉, 어셈블리에서 주소를 뺀다(sub)는 것은 스택 공간을 넓힌다(늘린다)는 뜻이고, 주소를 더한다(add)는 것은 과거 주소로 올라간다는 뜻이다.
3. fp, sp의 역할 분석 (코드 분석)
push {fp, lr}
add fp, sp, #4
sub sp, sp, #32
이제 위의 세 줄의 코드가 어떻게 메모리를 정렬하는지 순서대로 보도록 하자.
push {fp, lr}
함수가 호출되자마자 이전 함수가 쓰던 fp 값과, 이 함수가 끝나면 돌아갈 주소인 lr을 스택에 안전하게 백업(Push)한다. 이 명령이 실행된 직후의 스택 모양은 다음과 같다.

add fp, sp, #4
주소를 더했으니 한 칸 위(고주소 방향)로 이동한다. 그런데 헷갈린다. 갑자기 왜 sp에 4를 더해서 fp에 넣을까?

이 연산을 통해 fp는 돌아갈 주소(lr)가 저장된 주소를 가리키게 된다. sp는 함수 내부에서 수시로 움직이기 때문에 기준점으로 쓸 수 없다. 반면 이렇게 세팅된 fp는 현재 함수가 끝날 때까지 절대로 움직이지 않는 이정표가 된다. 나중에 함수가 끝날 때 컴파일러는 이 fp만 보고 안전하게 복귀 경로를 찾아낸다.
sub sp, sp, #32
주소를 32만큼 뺐다. 즉, 스택을 저주소로 확 늘린 것이다. 현재 함수 내부에서 사용할 지역 변수(Local Variables)들을 위한 임시 공간을 32바이트만큼 미리 확보한 셈이다. C 코드로 치면 int arr[8]; 같은 공간을 계약한 것과 같다.
최종 완성된 스택의 전체 구조는 아래와 같다.

지역 변수들을 접근해야 할 때, fp-8, fp-12 같은 고정 주소를 이용하여 접근한다.
str r3 [fp, #-8]
str r3 [fp, #-12]
ldr r1 [fp, #-12]
ldr r0 [fp, #-16]
위 코드와 같이 스택 공간에 접근할 때 고정 주소인 fp를 이용하는 것을 확인 할 수 있다.