Info

함수 호출시 자주 확인되는 어셈블리 패턴이 헷갈려서 정리해보고자한다.

테스트 환경

항목
OSWindows 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_probe2

main함수에서 초기화 부분 이후, 스택을 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 malloc or alloca memory, 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

먼저 이전 호출 함수의 bpstackpush한다. 그리고 spbp로 만들어 함수의 기준점을 만든다.

이는 평소 아는것과 다른 점인데, 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 address 8byte에 나머지 패딩만 주면 되기에 그렇다.

코드를 확인하면 -O0에서 보였던 스택에 레지스터 값을 복사하는 불필요한 과정이 전부 제거된 것을 확인할 수 있다.

결론

  • call호출은 return address를 8byte로 할당한다.
  • 함수의 호출은 ABI 규약에 따라 최소 32byte의 파라미터를 위한 공간을 할당한다.
  • 파라미터는 스택 프레임 이하의 주소에 위치하지 않을 수 있다. (환경에 따라 다를 수 있다)