ARM 호출 규약 (Calling Convention)
이 글에서는 임베디드 환경에서 널리 사용되는 ARM 아키텍처에서 리눅스 환경 하에 사용되는 함수 호출 규약에 대해서 살펴보도록 하겠습니다. 앞서 본 것처럼 함수 호출 규약은 어셈블리 레벨에서 어떤 식으로 함수의 인자를 전달하고 리턴값을 반환하는지 등의 내용을 표준으로 정의한 것입니다.
가장 기본적으로 일반적인 인자들은 (즉, floating-point 값이 아닌 것들) 아래와 같은 (최대) 4개의 (코어) 레지스터를 통해 전달합니다.
r0, r1, r2, r3
함수의 반환값도 이와 동일한 레지스터를 사용하는데, 대부분은 32비트 값을 사용하여 r0 하나 만을 사용하지만, 64비트 값의 경우에는 r1에 상위 32비트 값이 저장됩니다. 마찬가지로 128비트 벡터를 리턴하는 경우라면 r0부터 r3까지를 모두 사용하게 됩니다.
이는 마치 이들 레지스터를 마치 연속된 메모리 공간처럼 생각하고 사용하는 것으로 볼 수 있습니다. 인자를 전달하는 경우도 마찬가지로 생각할 수 있는데 64비트 인자를 전달하는 경우 2개의 (32비트) 레지스터를 하나로 묶어 사용하며 추가로 64비트 정렬을 요구하기 때문에 항상 짝수번 째의 레지스터에서만 이를 저장할 수 있습니다.
예를 들어 다음과 같은 함수가 있다고 생각해 보겠습니다.
int foo(int32_t a, uint64_t b)
a 인자는 r0 레지스터 한 개를 통해 전달하는데, b 인자는 64비트 값이기 때문에 두 개의 레지스터를 사용해야 합니다. 하지만 r1과 r2를 사용하지 않고, r1을 남겨둔 채 r2와 r3 레지스터를 이용하여 전달하게 됩니다.
만약 32비트 크기의 c 인자가 (b의) 뒤에 있었다고 하더라도 r1은 사용하지 않습니다. 이렇게 사용 가능한 레지스터가 없을 때는 스택을 통해 인자를 전달하는데 스택의 시작주소도 인자의 정렬 요구사항에 맞춰 자동으로 조정됩니다.
call-by-vaule 형태로 큰 구조체를 전달하는 경우, 구조체의 전부 혹은 일부가 (가용한) 레지스터를 통해 전달되고 나머지는 스택을 통해 전달하게 됩니다.
비슷한 경우로 (32비트 이상의 크기를 가지거나 그 크기를 컴파일 시에 명확히 알 수 없는) 구조체를 리턴하는 경우, 메모리 (스택)를 통해 이를 전달하는 형태가 되는데, 해당 메모리를 가리키는 포인터를 (마치 새로운 인자처럼) r0 레지스터를 통해 전달하고, 이후의 인자들은 r1 레지스터에서부터 전달합니다.
스택은 설정에 따라 full/empty ascending/descending 형태로 사용할 수 있는데 리눅스에서는 full descending 방식으로 사용합니다. 이는 스택 포인터가 가장 최근에 push된 값을 가리키고 있으며, push 될 때마다 스택 포인터의 값이 감소하는 방식입니다. 스택 포인터는 항상 4의 배수의 값을 저장하고 있어야 하며 외부에서 호출가능한 함수의 경우 호출 당시 스택 포인터의 값은 8의 배수여야 합니다.
외부 (라이브러리) 함수 호출에 대해서 좀더 살펴보자면, ARM ISA의 한계로 인해 한 명령어 내에서 32비트 전체 주소를 지정하는 것이 불가능하다는 점이 있습니다. 하나의 모듈 내에서라면 이러한 제약이 크게 문제가 되지 않을 수도 있지만 라이브러리 함수는 현재 실행 중인 코드와 멀리 떨어진 곳에 코드가 존재할 수 있으므로 직접 해당 위치로 호출이 불가능합니다.
따라서 PLT와 같은 중간 코드 (veneer)를 두어 32비트 전체 주소를 만든 후에 별도의 IP 레지스터에 저장하여 간접 호출 (indirect call) 하는 방식을 사용합니다. 위에서 언급한 r0 ~ r3 그리고 SP, LR, PC와 같은 특별한 레지스터들을 제외한 나머지 코어 레지스터들은 callee-saved 방식으로 보존됩니다.
floating-point 타입의 경우 좀더 복잡한데, 예전 버전의 ARM cpu들은 이를 처리하는 하드웨어가 없는 경우가 많았습니다. 따라서 float이나 double 타입의 변수들을 정수 타입과 동일하게 간주하여 전달하고 처리하였습니다. 이를 soft-float 방식 혹은 단순히 soft 방식이라 합니다.
하지만 최근의 cpu들은 대부분 하드웨어를 탑재하고 있어 floating-point 타입을 처리하는 별도의 레지스터를 가지고 있습니다. 이는 SIMD를 위한 vector 연산처리도 겸하고 있어 VFP (Vector Floating-Point) 하드웨어라고 부릅니다. 이 때는 floating-point 타입의 변수들은 위의 코어 레지스터를 사용하지 않으며 s0, d0과 같은 VFP 레지스터를 통해 직접 전달하고 이러한 방식을 hardfp 혹은 hard 방식이라고 부릅니다.
그런데 문제는 hardfp 방식과 soft-float 방식이 호환되지 않는다는 데에 있습니다. 예를 들어 double 타입의 인자를 받는 함수는 floating-point 처리 방식에 따라 인자를 코어 레지스터에서 받거나 VFP 레지스터에서 받게됩니다. 만약 개발자가 작성한 코드가 최신 cpu에서 컴파일되어 VFP 레지스터를 사용하도록 했다고 가정합시다. 이 코드에서 어떤 연산을 위해 라이브러리 함수를 호출합니다. 하지만 해당 라이브러리가 예전 soft-float 방식으로 컴파일되었다면 인자를 VFP가 아니라 코어 레지스터에서 찾을 것이므로 잘못된 결과를 계산하게 됩니다.
이를 해결(?)하기 위해 나온 방식이 softfp 방식인데, 이는 floating-point 연산을 VFP 하드웨어를 통해 처리하지만 외부 함수 호출 시에는 코어 레지스터를 사용하는 방식입니다. 즉 겉으로 보기에는 soft 방식이지만 실제로는 VFP 하드웨어를 통한 성능 향상을 꾀하는 것입니다. 다만 함수 호출 시 불필요한 레지스터간 복사가 발생하고 더욱이 정수 타입의 인자들이 사용하는 레지스터의 갯수를 줄이기 때문에 불필요한 스택 접근이 일어날 수 있어서 hardfp 방식 보다 성능이 떨어지게 됩니다.
단 printf()
함수와 같이 가변인자를 받는 함수들은 모두 코어 레지스터 (+ 스택) 만
사용하여 인자를 전달합니다.
참고
- http://infocenter.arm.com/help/topic/com.arm.doc.ihi0042d/IHI0042D_aapcs.pdf
- https://www.raspberrypi.org/forums/viewtopic.php?t=7796
man gcc
(-mfloat-abi
)