응용 프로그램 이진 인터페이스
더 많은 작업
- Application Binary Interface, ABI; 응용 프로그램 이진 인터페이스
- 컴파일된 기계어 코드끼리, 또는 기계어 코드와 운영체제가 서로 맞물리기 위해 지켜야 하는 이진 수준의 약속
응용 프로그램 이진 인터페이스(ABI)는 이미 컴파일된 프로그램, 라이브러리, 운영체제 커널이 서로를 호출하고 데이터를 주고받을 때 따라야 하는 규칙이다. 함수 인자를 어느 레지스터에 넣는지, 구조체의 각 필드가 메모리에서 몇 바이트 떨어져 있는지, 함수 이름이 목적 파일에 어떤 기호로 적히는지, 시스템 호출 번호가 무엇인지 같은 것들을 정한다.
API가 소스 코드 수준의 약속이라면 ABI는 기계어 수준의 약속이다. API가 같아도 ABI가 다르면 다시 컴파일해야 하고, ABI가 같으면 소스 없이 이진 파일만으로 섞어 쓸 수 있다. 서로 다른 언어로 만든 코드가 연결되는 것(외부 함수 인터페이스)도, 운영체제를 업데이트한 뒤에 예전 프로그램이 그대로 도는 것도 ABI가 지켜지기 때문이다.
| 구분 | API | ABI |
|---|---|---|
| 수준 | 소스 코드 | 기계어, 목적 파일, 실행 파일 |
| 정하는 것 | 함수 이름, 인자 타입, 의미, 헤더 | 호출 규약, 레지스터 사용, 자료형 크기와 정렬, 구조체 배치, 기호 이름, 예외 전파, 시스템 호출 번호, 파일 형식 |
| 깨지면 | 컴파일 오류가 난다 | 컴파일과 링크는 되는데 실행 중 값이 틀리거나 비정상 종료한다 |
| 호환을 지키는 방법 | 함수 원형과 의미를 유지 | 원형뿐 아니라 구조체 크기, 필드 순서, 가상 함수 표 순서, 기호 버전까지 유지 |
| 의존 대상 | 언어, 라이브러리 | CPU 아키텍처, 운영체제, 컴파일러, 표준 라이브러리 구현 |
같은 C 소스라도 x86-64 리눅스와 x86-64 윈도우에서는 ABI가 달라서 서로의 이진 파일을 그대로 쓸 수 없다. 반대로 함수 원형은 그대로 두고 인자로 받는 구조체에 필드 하나만 추가해도 API는 호환되지만 ABI는 깨진다. 예전 크기를 가정하고 컴파일된 호출자가 새 라이브러리에 짧은 구조체를 넘기기 때문이다.
ABI가 정하는 항목은 대략 다음과 같다.
함수를 부를 때 인자를 어디에 두고, 반환값을 어디서 받고, 호출 뒤 스택은 누가 정리하는지 정한다.
- 인자 전달: 몇 번째 인자까지 어떤 레지스터로 넘기고 나머지는 스택에 어떤 순서로 쌓는지.
- 반환값: 정수는 어느 레지스터, 부동소수점은 어느 레지스터, 큰 구조체는 호출자가 마련한 메모리에 쓰는지.
- 레지스터 보존: 호출된 함수가 마음대로 써도 되는 레지스터(caller-saved, volatile)와 원래 값을 되돌려 놓아야 하는 레지스터(callee-saved, nonvolatile)의 구분.
- 스택 정리: 호출자가 인자 영역을 치우는지(cdecl), 피호출자가 치우는지(stdcall).
- 가변 인자 함수(
printf등)를 부를 때의 추가 규칙.
스택이 높은 주소에서 낮은 주소로 자라는지, 함수 호출 순간 스택 포인터가 몇 바이트 경계에 맞아야 하는지(x86-64, AArch64, RISC-V는 16바이트), 프레임 포인터를 두는지, 호출된 함수가 스택 포인터 아래 영역을 임시로 써도 되는지(System V의 red zone) 같은 것이다. 정렬이 어긋나면 SSE 명령처럼 정렬을 요구하는 명령에서 바로 예외가 나기도 한다.
int, long, 포인터가 몇 바이트인지, 각 자료형을 몇 바이트 경계에 두는지, 바이트 순서(엔디언)가 무엇인지 정한다. 64비트에서 흔한 두 모델은 다음과 같다.
| 모델 | int | long | long long | 포인터 | 쓰는 곳 |
|---|---|---|---|---|---|
| LP64 | 4 | 8 | 8 | 8 | 64비트 리눅스, macOS, BSD 등 유닉스 계열 |
| LLP64 | 4 | 4 | 8 | 8 | 64비트 윈도우 |
그래서 long으로 파일 크기나 포인터를 다루는 코드는 리눅스에서는 멀쩡하고 윈도우에서는 잘린다.
필드를 선언 순서대로 놓고, 각 필드를 자기 정렬에 맞추려고 사이에 채움 바이트(padding)를 넣고, 구조체 전체 크기를 가장 큰 정렬의 배수로 맞추는 것이 C ABI의 일반 규칙이다.
struct S {
char c; /* 오프셋 0 */
/* 채움 3바이트 */
int i; /* 오프셋 4 */
short s; /* 오프셋 8 */
/* 채움 2바이트 */
}; /* sizeof(struct S) == 12, 정렬 4 */
비트 필드 배치, 공용체, 128비트 정수, 빈 구조체의 크기도 ABI마다 조금씩 다르다. 필드 순서를 바꾸거나 필드를 끼워 넣으면 이 오프셋이 바뀌므로 공개 라이브러리의 구조체는 함부로 고치면 안 된다.
C++처럼 함수 오버로딩, 이름공간, 템플릿이 있는 언어는 같은 이름의 함수가 여럿일 수 있어서, 컴파일러가 인자 타입까지 기호 이름에 새겨 넣는다. 이를 이름 맹글링(name mangling)이라 한다. Itanium C++ ABI를 따르는 GCC와 Clang은 void f(int)를 _Z1fi로, MSVC는 ?f@@YAXH@Z로 적는다. 방식이 다르므로 두 컴파일러가 만든 C++ 목적 파일은 서로 링크되지 않는다. C는 맹글링이 없어서(플랫폼에 따라 앞에 밑줄 하나가 붙는 정도) 여러 언어가 C 이름으로 만난다. C++에서 extern "C"를 붙이는 이유다.
예외가 던져졌을 때 호출 스택을 거슬러 올라가며 소멸자를 부르고 처리기를 찾는 방법도 ABI의 일부다. 리눅스의 Itanium 방식은 DWARF 기반 되감기 표(.eh_frame)를 쓰고, 64비트 윈도우는 함수마다 pdata/xdata라는 되감기 정보를 두고 구조적 예외 처리(SEH)와 통합한다.[1] C++ 예외가 C 함수나 다른 컴파일러가 만든 라이브러리를 통과하면 이 규칙이 맞지 않아 프로그램이 종료될 수 있다.
사용자 프로그램이 커널에 시스템 호출을 요청하는 방법이다. x86-64 리눅스는 syscall 명령을 쓰고 호출 번호를 rax에, 인자를 rdi, rsi, rdx, r10, r8, r9에 넣는다. 일반 함수 호출과 달리 넷째 인자가 rcx가 아니라 r10인 것은 syscall 명령이 rcx에 복귀 주소를 덮어쓰기 때문이다.
| 형식 | 쓰는 곳 | 비고 |
|---|---|---|
| ELF (Executable and Linkable Format) | 리눅스, BSD, 솔라리스, 안드로이드, 대부분의 임베디드 툴체인 | System V ABI의 일부로 정의됨. readelf로 확인
|
| PE/COFF (Portable Executable) | 윈도우(.exe, DLL, .sys), UEFI | MS-DOS 헤더 뒤에 PE 헤더가 붙는다 |
| Mach-O | macOS, iOS | 여러 아키텍처를 한 파일에 담는 유니버설 바이너리 지원 |
파일 형식에는 동적 링커가 어떤 공유 라이브러리를 어떤 기호로 찾아 붙이는지(재배치, PLT/GOT, 가져오기 표)도 포함된다.
범용 레지스터가 8개뿐이라 전통적으로 인자를 스택으로 넘겼고, 운영체제와 컴파일러마다 규약이 여럿 생겼다. 그래서 소스에 규약을 직접 적는 일이 많다.
| 규약 | 인자 전달 | 스택 정리 | 주로 쓰는 곳 |
|---|---|---|---|
| cdecl | 모두 스택, 오른쪽부터 | 호출자 | C 기본값, 가변 인자 함수 |
| stdcall | 모두 스택, 오른쪽부터 | 피호출자 | Win32 API(WINAPI)
|
| fastcall | 앞의 두 개는 ecx, edx | 피호출자 | MSVC 등 |
| thiscall | this는 ecx(MSVC) | 피호출자 | MSVC의 C++ 멤버 함수 |
64비트가 되면서 범용 레지스터가 16개로 늘어 레지스터 전달이 기본이 되었다. 다만 유닉스 계열과 윈도우가 서로 다른 규약을 쓴다.
| 항목 | System V AMD64 (리눅스, macOS, BSD) | Microsoft x64 (윈도우) |
|---|---|---|
| 정수·포인터 인자 | rdi, rsi, rdx, rcx, r8, r9 (6개) | rcx, rdx, r8, r9 (4개) |
| 부동소수점 인자 | xmm0~xmm7 (8개) | xmm0~xmm3, 정수 인자와 자리를 나눠 씀 |
| 반환값 | rax(와 rdx), xmm0(와 xmm1) | rax, xmm0 |
| 피호출자 보존 | rbx, rbp, r12~r15 | rbx, rbp, rdi, rsi, r12~r15, xmm6~xmm15 |
| 스택 여유 공간 | 스택 포인터 아래 128바이트 red zone | 호출자가 32바이트 shadow space를 항상 마련 |
| 구조체 전달 | 16바이트 이하는 분류 규칙에 따라 레지스터 두 개까지 나눠 전달 | 1, 2, 4, 8바이트만 값으로, 나머지는 포인터로 |
| long 크기 | 8바이트 (LP64) | 4바이트 (LLP64) |
윈도우 쪽은 첫 네 인자가 자료형과 상관없이 자리 번호로 레지스터가 정해지고, 인자 하나를 여러 레지스터에 나눠 싣지 않는다. 가변 인자 함수에 부동소수점을 넘길 때는 같은 값을 정수 레지스터에도 복사해야 한다.[2] System V 쪽은 AMD64 psABI 문서로 관리된다.[3] 윈도우 x64에서도 __vectorcall처럼 SIMD 인자를 더 많이 레지스터로 넘기는 선택 규약이 있다.
ARM은 절차 호출 표준(Procedure Call Standard for the Arm Architecture, AAPCS)을 포함한 ABI 문서 묶음을 공개한다. 32비트 AAPCS는 r0~r3으로 인자를 넘기고 r4~r11을 보존한다. 64비트 AAPCS64는 다음과 같다.[4]
- x0~x7: 인자와 반환값. x8: 큰 반환값을 받을 메모리 주소.
- x9~x15: 임시(호출자 보존). x16, x17(IP0, IP1): 링커가 끼워 넣는 중계 코드용. x18: 플랫폼 레지스터(윈도우와 macOS는 예약).
- x19~x28: 피호출자 보존. x29: 프레임 포인터, x30: 링크 레지스터(복귀 주소).
- v0~v7: 부동소수점·SIMD 인자, v8~v15는 하위 64비트만 보존.
- 공개 인터페이스에서 스택은 16바이트 정렬.
Apple 플랫폼과 윈도우는 AAPCS64를 조금씩 바꾼 자기 규약을 쓴다. Windows on Arm에는 x64 코드와 한 프로세스 안에서 섞어 돌리기 위한 ARM64EC ABI가 따로 있다.
RISC-V는 호출 규약을 psABI 문서로 표준화했고 레지스터마다 용도를 드러내는 ABI 이름을 붙였다. a0~a7은 인자와 반환값, s0~s11은 피호출자 보존, t0~t6은 임시, ra는 복귀 주소, sp는 스택 포인터이며, 함수 진입 시 스택은 16바이트(128비트) 경계에 맞춘다.[5] 부동소수점 레지스터 사용 여부에 따라 ilp32, ilp32f, ilp32d, lp64, lp64d 같은 변형이 있다. MIPS는 32비트용 o32, 64비트 레지스터에 32비트 포인터를 쓰는 n32, 완전한 64비트 n64처럼 여러 ABI가 공존했다.
C++는 표준이 ABI를 정하지 않는다. 원래 인텔 Itanium(IA-64)용으로 여러 회사가 함께 만든 Itanium C++ ABI가 사실상 표준이 되어, 지금은 윈도우를 뺀 거의 모든 플랫폼에서 GCC와 Clang이 x86-64, ARM 등 아키텍처와 상관없이 따른다.[6] 이 문서는 이름 맹글링, 가상 함수 표(vtable) 배치, 다중·가상 상속 시 객체 배치, RTTI, 예외 처리, 정적 지역 변수 초기화 같은 C++ 고유 요소를 정한다. 윈도우의 MSVC는 자체 C++ ABI를 쓴다.
임베디드 시스템용 ABI를 EABI라 부른다. 파일 형식, 자료형, 레지스터 사용, 스택 프레임, 인자 전달을 정하되, 자원이 적은 환경에 맞춰 동적 링크를 빼거나 고정 레지스터를 두는 식으로 단순화한다. PowerPC EABI, ARM EABI, MIPS EABI가 대표적이다. 리눅스 ARM에서는 예전 ABI(OABI)에서 ARM EABI 기반의 새 ABI로 옮겨 갔고, 데비안은 이를 armel이라 부른다. 부동소수점 인자를 VFP 레지스터로 넘기는 hard-float 변형(armhf)은 armel과 호출 규약이 달라 서로 섞을 수 없다. GCC와 LLVM은 이런 차이를 armv7-unknown-linux-gnueabihf 같은 타깃 트리플로 구분한다.
- C: 거의 모든 운영체제가 C ABI를 기준으로 시스템 API를 제공하므로, 많은 언어가 C ABI를 공용어로 삼아 서로를 부른다. C ABI는 구조체 배치와 호출 규약 정도만 정하면 되어 단순하지만,
long,time_t,intmax_t의 크기가 ABI에 박혀 있어 바꾸기 어렵다. 32비트time_t의 2038년 문제를 고치려면 ABI를 바꿔야 하는 것이 그 예다. - C++: 이식 가능한 C++ ABI는 없다. 같은 운영체제에서도 컴파일러와 표준 라이브러리 조합마다 ABI가 다를 수 있다.
- Rust: 기본 표현(
repr(Rust))은 건전성에 필요한 최소한만 보장하며, 필드 순서가 선언 순서와 달라도 된다.[7] 컴파일러 버전 사이의 이진 호환도 약속하지 않으므로 라이브러리는 보통 소스로 배포하고 함께 컴파일한다. 다른 언어와 주고받을 타입에는#[repr(C)]를, 함수에는extern "C"를 붙여 C ABI를 따르게 한다. - Swift: Swift 5.0(2019년)에서 Apple 플랫폼용 ABI가 안정화되었다. 그 뒤로 Swift 런타임과 표준 라이브러리가 운영체제에 들어가 앱마다 싣고 다닐 필요가 없어졌다.[8] 서로 다른 컴파일러 버전의 모듈을 가져다 쓰는 모듈 안정성은 그 뒤 별도로 추가되었다.
- 자바, C# 같은 가상 머신 언어는 바이트코드 형식이 이진 인터페이스 역할을 하고, 네이티브 코드와 만날 때만 JNI나 P/Invoke로 C ABI를 쓴다.
라이브러리 쪽에서 말하는 ABI는 위 규칙을 적용해 나온 구체적인 이진 인터페이스, 곧 내보내는 기호 목록과 각 타입의 배치를 뜻한다. 다음 변경은 대개 ABI를 깨뜨린다.
- 함수의 인자나 반환 타입 변경, 공개 함수 삭제.
- 공개 구조체나 클래스에 필드 추가·삭제·순서 변경(크기와 오프셋이 바뀜).
- C++ 클래스에 가상 함수 추가나 순서 변경(vtable 배치가 바뀜), 상속 구조 변경.
- 인라인 함수나 템플릿의 동작 변경(이미 호출자 쪽에 컴파일되어 들어가 있음).
- 열거형 값 번호 변경.
ABI를 지키려는 라이브러리는 구조체를 불투명 포인터로만 노출하거나(PIMPL), 구조체 첫 필드에 크기를 적게 하거나, 예약 필드를 미리 두는 방법을 쓴다. 공유 라이브러리의 soname(libfoo.so.1)에 붙는 주 버전 번호는 ABI가 깨질 때 올린다.
C++11 표준은 문자열의 쓰기 시 복사(copy-on-write) 구현을 사실상 금지하고 std::list::size()가 상수 시간이기를 요구했다. 이를 맞추려고 GCC 5.1(2015년)의 libstdc++는 std::string과 std::list를 새로 구현했고, 새 구현은 std::__cxx11 인라인 이름공간에 넣어 옛 구현과 기호 이름이 겹치지 않게 했다. 어느 쪽을 쓸지는 _GLIBCXX_USE_CXX11_ABI 매크로로 고르며 기본값은 1(새 ABI)이다.[9] 라이브러리 하나는 두 ABI를 모두 담고 있지만, 옛 ABI로 빌드된 미리 컴파일된 라이브러리에 std::string을 넘기는 코드는 같은 설정으로 빌드해야 한다. 한동안 "std::__cxx11::basic_string에 대한 정의되지 않은 참조" 링크 오류가 흔했던 이유다. Visual C++도 오랫동안 컴파일러 버전마다 런타임 ABI가 달랐는데, 2015 이후 버전끼리는 이진 호환을 유지한다.[10]
GNU C 라이브러리(glibc)는 공유 라이브러리 이름을 libc.so.6으로 유지한 채, 기호마다 버전을 붙여 호환을 지킨다. 동작을 바꿔야 하는 함수는 새 버전 기호를 추가하고 옛 버전 기호도 남겨 둔다. 예를 들어 x86-64에서 memcpy는 glibc 2.14에서 겹치는 영역에 대한 동작이 바뀌면서 memcpy@GLIBC_2.14가 새로 생겼고, 옛 프로그램은 계속 memcpy@GLIBC_2.2.5에 연결된다. 링크할 때는 그 시점의 최신 버전 기호가 기록되므로, 새 배포판에서 빌드한 프로그램은 옛 glibc에서 "GLIBC_2.xx not found" 오류로 실행되지 않는다. 뒤로의 호환은 되고 앞으로의 호환은 안 되는 구조다. 필요한 버전은 objdump -T나 readelf --dyn-syms로 볼 수 있다.
리눅스는 사용자 공간에 대한 인터페이스, 곧 시스템 호출은 깨지 않는다는 원칙을 지킨다. 아주 오래된 커널용으로 컴파일된 프로그램도 최신 커널에서 돌아간다. 반면 커널 내부 인터페이스와 모듈 이진 인터페이스는 일부러 안정성을 약속하지 않는다. 컴파일러 버전, 구조체 정렬, 커널 설정 옵션, 아키텍처에 따라 내부 구조체가 달라지고, 내부 API를 고정하면 보안 수정과 재설계를 막게 된다는 것이 이유다. 그래서 커널 트리 밖의 이진 드라이버는 커널 버전마다 다시 빌드해야 한다.[11] sysfs 파일처럼 사용자 공간에 드러나는 인터페이스는 Documentation/ABI에 stable, testing, obsolete, removed로 나눠 기록하며, stable로 분류된 것은 최소 2년간 뒤로의 호환을 보장한다.[12] 윈도우는 반대로 커널 시스템 호출 번호를 공개 ABI로 보지 않아 빌드마다 바뀌는 것으로 알려져 있고, 안정적인 경계는 ntdll.dll과 Win32 DLL이다.
리눅스 배포판끼리는 커널 시스템 호출 ABI는 같지만 glibc 버전, C++ 표준 라이브러리 버전, 기타 공유 라이브러리 목록과 버전이 달라 이진 파일이 그대로 옮겨지지 않는 일이 많다.
- Linux Standard Base(LSB)는 배포판이 제공해야 할 라이브러리와 기호 목록을 정하려 했으나 널리 지켜지지 않았다.
- 파이썬의 manylinux 규격은 "glibc X.Y 이상인 주류 배포판에서 동작"을 약속하는 태그(
manylinux_2_17_x86_64등)를 쓴다. glibc가 뒤로의 호환을 무기한 유지하므로 오래된 glibc에서 빌드하면 새 배포판에서도 돈다.[13] - 알파인 리눅스처럼 musl을 쓰는 배포판에서는 glibc용 이진 파일이 동작하지 않는다(파이썬은 musllinux 태그를 따로 둔다).
- Flatpak, Snap, AppImage, 도커 컨테이너는 필요한 사용자 공간 라이브러리를 함께 묶어 배포판 차이를 피한다. 이때 공유하는 것은 커널 시스템 호출 ABI뿐이다.
- 1980~1990년대 x86 유닉스 업체들은 인텔 이진 호환 표준(iBCS)으로 서로의 이진 파일을 돌리려 했다.
- 1980년대 말 System V Release 4와 함께 System V ABI가 여러 아키텍처별 부록(psABI)을 거느린 유닉스 계열의 기준이 되었고, ELF도 이 흐름에서 정해졌다.
- 2001년 GCC 3.0이 IA-64용 다중 업체 C++ ABI(뒤의 Itanium C++ ABI)를 채택하면서 리눅스의 C++ ABI가 한 번 크게 바뀌었다.[14]
- 2003년 AMD64가 나오면서 System V AMD64 ABI와 Microsoft x64 규약이 갈라졌다.
- 2015년 GCC 5.1이 libstdc++ 이중 ABI를 도입했다.
- 2019년 Swift 5.0이 Apple 플랫폼에서 ABI 안정성을 달성했다.
- ↑ x64 Calling Convention, Microsoft Learn
- ↑ x64 Calling Convention, Microsoft Learn
- ↑ System V Application Binary Interface, AMD64 Architecture Processor Supplement (x86-64 psABI)
- ↑ Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64), Arm
- ↑ RISC-V Calling Conventions, RISC-V ELF psABI
- ↑ Itanium C++ ABI
- ↑ Type layout, The Rust Reference
- ↑ ABI Stability and More, Swift.org (2019-02-07)
- ↑ Dual ABI, GCC libstdc++ Manual
- ↑ C++ binary compatibility between Visual Studio versions, Microsoft Learn
- ↑ The Linux Kernel Driver Interface (stable-api-nonsense), Linux kernel documentation
- ↑ Linux ABI description, Linux kernel documentation
- ↑ PEP 600: Future manylinux Platform Tags for Portable Linux Built Distributions
- ↑ GCC 3.0 New Features