Info
함수 호출시 자주 확인되는 어셈블리 패턴이 헷갈려서 정리해보고자한다.
테스트 환경
| 항목 | 값 |
|---|---|
| OS | Windows 11 Pro |
| g++ | 14.2.0 |
전개
바로 예제를 확인해보자.
extern "C" __attribute__((noinline))
int frame_probe(int arg) {
int local = 20;
return arg + local;
}
extern "C" __attribute__((noinline))
int frame_probe2(int arg) {
int local = 21;
return arg + local;
}
int main() {
int val = frame_probe(1);
return frame_probe2(val);
}
extern "C" __attribute__((noinline))해당 부분은 맹글링으로 함수 이름이 뒤바뀌는 것을 막고, 최적화 과정에서inline이 이루어지는 것을 막기위해 정의하였다.
해당 함수를 최적화 없이 -O0 옵션으로 컴파일한 asm코드는 다음과 같다.
.file "main.cpp"
.intel_syntax noprefix
.text
.globl frame_probe
.def frame_probe; .scl 2; .type 32; .endef
frame_probe:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR 16[rbp], ecx
mov DWORD PTR -4[rbp], 20
mov edx, DWORD PTR 16[rbp]
mov eax, DWORD PTR -4[rbp]
add eax, edx
leave
ret
.globl frame_prob2
.def frame_prob2; .scl 2; .type 32; .endef
frame_probe2:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR 16[rbp], ecx
mov DWORD PTR -4[rbp], 21
mov edx, DWORD PTR 16[rbp]
mov eax, DWORD PTR -4[rbp]
add eax, edx
leave
ret
.globl main
.def main; .scl 2; .type 32; .endef
main:
push rbp
mov rbp, rsp
sub rsp, 48
call __main
mov ecx, 1
call frame_probe2
mov DWORD PTR -4[rbp], eax
mov eax, DWORD PTR -4[rbp]
mov ecx, eax
call frame_probe
nop
leave
ret
.def __main; .scl 2; .type 32; .endef
.ident "GCC: (MinGW-W64 x86_64-ucrt-posix-seh, built by Brecht Sanders, r3) 14.2.0"주목할 만한 부분을 하나씩 살펴보자.
main:
...
...
sub rsp, 48
...
mov ecx, 1
call frame_probe2main함수에서 초기화 부분 이후, 스택을 48byte 할당하고 ecx에 1을 할당하여 인자를 넘기는 것을 확인할 수 있다. 이후 frame_probe2 함수를 호출한다.
main함수는 다음과 같이 int형 지역 변수 하나와 함수 호출밖에 존재하지 않는다. 근데 왜 48byte의 공간을 할당할까?
int main() {
int val = frame_probe(1);
return frame_probe2(val);
}이는 인자를 넘기는 공간을 사전에 32byte할당하고, int타입 지역 변수 4bye 및 패딩 12byte를 포함하기 때문이다.
따라서 32 + 12 = 48 byte가 할당된다.
sp는 호출 규약에 의거하여 16byte로 정렬된다.
Most structures are aligned to their natural alignment. The primary exceptions are the stack pointer and
mallocorallocamemory, which are 16-byte aligned to aid performance.
또한 주목할 점은 함수당 인자의 공간이 할당되는 것이 아니라 한번만 할당된다는 점이다. 동시에 함수가 호출되는 경우는 없으므로 하나의 공간을 재사용하도록 구성되는 것이다.
이때, 인자의 개수가 32byte를 넘어갈 경우 이에 맞춰서 크기가 증가한다.
(asm코드를 확인 했으나 추가로 작성하지는 않겠다)
이제 frame_probe함수를 살펴보자.
주요 코드는 다음과 같다.
frame_probe2:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR 16[rbp], ecx
mov DWORD PTR -4[rbp], 21먼저 이전 호출 함수의 bp를 stack에 push한다. 그리고 sp를 bp로 만들어 함수의 기준점을 만든다.
이는 평소 아는것과 다른 점인데,
stack frame이전 주소에 함수의 파라미터를 위한 공간이caller에 의해 주어지고, 함수에서 이를 이용한다.
왜 스택이 16byte 할당되는지는 이전 맥락과 같으므로 생략한다.
굳이 레지스터로 넘긴 변수를 왜 stack에 다시 저장하냐고 할 수 있는데, 이는 -O2로 컴파일된 어셈블리 파일이기에 기계적으로 지역변수를 할당하기에 그렇다.
이제 일반적으로 컴파일하는 -O2옵션을 준 어셈블리 코드를 확인해보자.
.file "main.cpp"
.intel_syntax noprefix
.text
.p2align 4
.globl frame_probe
.def frame_probe; .scl 2; .type 32; .endef
frame_probe:
lea eax, 20[rcx]
ret
.p2align 4
.globl frame_probe2
.def frame_probe2; .scl 2; .type 32; .endef
frame_probe2:
lea eax, 21[rcx]
ret
.section .text.startup,"x"
.p2align 4
.globl main
.def main; .scl 2; .type 32; .endef
main:
sub rsp, 40
call __main
mov ecx, 1
call frame_probe
add rsp, 40
mov ecx, eax
jmp frame_probe2
.def __main; .scl 2; .type 32; .endef
.ident "GCC: (MinGW-W64 x86_64-ucrt-posix-seh, built by Brecht Sanders, r3) 14.2.0"먼저 이전과 다른 점은
sp를 40만큼 빼는 것인데, 이는-O0에서는push명령으로bp를 저장한 것에 반에, 이번에는 해당 명령을 주지 않았기에call호출시 생성되는return address8byte에 나머지 패딩만 주면 되기에 그렇다.
코드를 확인하면 -O0에서 보였던 스택에 레지스터 값을 복사하는 불필요한 과정이 전부 제거된 것을 확인할 수 있다.
결론
call호출은return address를 8byte로 할당한다.- 함수의 호출은 ABI 규약에 따라 최소 32byte의 파라미터를 위한 공간을 할당한다.
- 파라미터는 스택 프레임 이하의 주소에 위치하지 않을 수 있다. (환경에 따라 다를 수 있다)