ABI (Application Binary Interface)
응용 프로그램 바이너리 인터페이스(Application Binary Interface, ABI)는 컴파일된 프로그램이 커널, 라이브러리, 다른 바이너리와 만나는 실제 계약입니다. 호출 규약(Calling Convention), 시스템 콜(System Call) 번호, 구조체(Struct) 레이아웃, 정렬(Alignment), 패딩(Padding), ELF 심볼 규칙, compat ABI까지 한 흐름으로 묶어 이해해야 커널이 왜 사용자 공간(User Space)을 깨뜨리지 않으려 하는지 정확히 보입니다.
핵심 요약
- ABI - 컴파일이 끝난 뒤에도 남아 있는 바이너리 수준의 약속입니다.
- API - 소스 코드 수준의 인터페이스이며, 커널 내부 API는 바뀔 수 있습니다.
- UAPI - 커널이 사용자 공간에 공개하는 헤더와 상수, 구조체 집합입니다.
- 시스템 콜 ABI - ABI 전체 중 커널 진입 규약을 담당하는 일부입니다.
- compat ABI - 64비트 커널이 32비트 사용자 공간을 계속 실행하게 만드는 별도 호환 경로입니다.
단계별 이해
- 경계 확인
사용자 프로그램이 어디서 libc를 거치고 어디서 커널 ABI에 직접 닿는지 먼저 구분합니다. - 구성 요소 분해
레지스터 규약, 구조체 크기, 정렬, 시스템 콜 번호를 ABI의 개별 조각으로 나눠 봅니다. - 호환성 관점 추가
이미 배포된 바이너리를 다시 빌드하지 않아도 계속 동작해야 한다는 조건을 붙입니다. - compat 경로 확인
32비트 사용자 공간, x32, time64 같은 예외 경로가 왜 별도 처리되는지 봅니다. - 설계 규칙 연결
새 인터페이스를 만들 때 왜 size, flags, reserved 필드가 필요한지 실무 규칙으로 연결합니다.
include/uapi/ 헤더, ioctl 구조체, Netlink 속성, vDSO 엔트리, ELF 보조 벡터, 32비트 compat 경로까지 모두 사용자 공간이 기대하는 ABI의 일부입니다.
ABI 개요
ABI는 소스 코드 수준의 인터페이스가 아니라 컴파일된 결과물이 기대하는 규약입니다. 같은 함수 이름이라도 인자를 전달하는 레지스터가 달라지거나 구조체 필드 오프셋(Offset)이 바뀌면, 소스는 그대로여도 이미 배포된 바이너리는 즉시 오동작할 수 있습니다.
리눅스 커널 관점에서 특히 중요한 ABI는 사용자 공간과 만나는 경계입니다. 시스템 콜 번호, ioctl 명령 번호, struct stat 같은 공개 구조체, Netlink 속성, sysfs 속성 파일 의미, vDSO 엔트리 주소 해석 규칙은 모두 사용자 공간이 장기간 의존하는 계약입니다.
| 구분 | 주로 보는 대상 | 대표 예시 | 깨지면 나타나는 문제 |
|---|---|---|---|
| API | 소스 코드 | kmalloc(), printf() |
재컴파일 단계에서 오류가 드러납니다 |
| UAPI | 공개 헤더 | include/uapi/linux/*.h |
사용자 프로그램과 커널 간 구조체 해석이 어긋납니다 |
| 시스템 콜 ABI | 커널 진입 경로 | rax/rdi 규약, syscall 번호 |
잘못된 핸들러(Handler) 호출, 인자 손상, 반환값 오해가 발생합니다 |
| ABI 전체 | 실행 중인 바이너리 | 호출 규약, 정렬, 패딩, ELF, vDSO | 빌드는 성공해도 실행 중 충돌하거나 조용히 잘못 동작합니다 |
ABI를 이루는 요소
ABI는 여러 조각이 겹친 결과입니다. 하나만 바뀌어도 바이너리 호환성이 깨질 수 있으므로, 커널 문서를 읽을 때도 "이 변경이 ABI에 닿는가"를 따져야 합니다.
| 요소 | 무엇을 고정하는가 | 커널 문맥의 예시 |
|---|---|---|
| 호출 규약 | 인자 전달 레지스터, 반환값, 보존 레지스터 | x86_64의 rdi, rsi, rdx 순서, 시스템 콜의 r10 규칙 |
| 데이터 타입 크기 | long, 포인터, time_t 크기 |
32비트 compat, Y2038, x32 ABI |
| 구조체 레이아웃 | 필드 오프셋, 정렬, 패딩 | ioctl 인자 구조체, Netlink payload |
| 시스템 콜 번호 | 번호와 의미의 1:1 대응 | __NR_openat2, __NR_clone3 |
| ELF 규칙 | 심볼, 재배치(Relocation), 보조 벡터 해석 | AT_SYSINFO_EHDR, vDSO 로딩 |
| 공개 파일 인터페이스 | 텍스트/바이너리 표현과 의미 | 문서화된 sysfs 속성, procfs 항목 |
long, 포인터, 비정형 비트필드를 그대로 노출하면 아키텍처별 크기 차이가 ABI 문제로 번집니다.
공개 구조체는 가능하면 __u32, __u64, __aligned_u64 같은 고정 폭 타입으로 설계하는 편이 안전합니다.
/* 나쁜 예: 32비트와 64비트에서 크기와 정렬이 달라질 수 있습니다 */
struct bad_ioctl_args {
long addr;
long len;
int flags;
};
/* 좋은 예: UAPI에서는 고정 폭 타입을 사용합니다 */
#include <linux/types.h>
struct good_ioctl_args {
__u32 size;
__u32 flags;
__aligned_u64 addr;
__u64 len;
__u64 reserved[2];
};
아키텍처별 호출 규약 비교
ABI에서 가장 근본적인 약속은 호출 규약(Calling Convention)입니다. 같은 C 코드라도 아키텍처가 다르면 인자를 전달하는 레지스터, 반환값을 받는 레지스터, 호출자(Caller)와 피호출자(Callee)가 보존해야 하는 레지스터가 모두 다릅니다. 더 중요한 점은, 함수 호출 규약과 시스템 콜 규약이 같은 아키텍처 안에서도 다르다는 것입니다.
함수 호출 규약 (사용자 공간 내부)
사용자 공간 함수끼리 호출할 때의 레지스터 규약입니다. 컴파일러가 이 규약에 맞춰 코드를 생성합니다.
| 역할 | x86_64 (System V AMD64) | ARM64 (AAPCS64) | RISC-V (LP64) |
|---|---|---|---|
| 인자 1~6 | rdi, rsi, rdx, rcx, r8, r9 |
x0~x7 (8개) |
a0~a7 (8개) |
| 반환값 | rax (128비트: rax+rdx) |
x0 (128비트: x0+x1) |
a0 (128비트: a0+a1) |
| callee-saved | rbx, rbp, r12~r15 |
x19~x28, x29(FP) |
s0~s11, sp |
| 스택 정렬 | 16바이트 (call 직전) | 16바이트 | 16바이트 |
| 초과 인자 | 스택 (오른쪽→왼쪽) | 스택 | 스택 |
시스템 콜 호출 규약 (커널 진입)
시스템 콜은 사용자 공간에서 커널로 진입하는 특수한 호출이므로, 함수 호출 규약과 다른 레지스터를 사용합니다. 특히 x86_64에서 4번째 인자가 rcx가 아닌 r10으로 바뀌는 것은 대표적인 차이입니다.
| 역할 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| 시스템 콜 번호 | rax |
x8 |
a7 |
| 인자 1~6 | rdi, rsi, rdx, r10, r8, r9 |
x0~x5 |
a0~a5 |
| 반환값 | rax |
x0 |
a0 |
| 진입 명령어 | syscall |
svc #0 |
ecall |
| 커널이 덮어쓰는 레지스터 | rcx, r11 (하드웨어) |
없음 (커널이 보존) | 없음 (커널이 보존) |
; x86_64: write(1, buf, len) 시스템 콜 - 함수 호출과 레지스터 배치가 다릅니다
mov rax, 1 ; __NR_write (시스템 콜 번호)
mov rdi, 1 ; arg1: fd = STDOUT_FILENO
lea rsi, [rel msg] ; arg2: buf
mov rdx, 13 ; arg3: len
syscall ; rcx ← RIP, r11 ← RFLAGS (하드웨어가 덮어씀)
; rax에 반환값 (쓴 바이트 수 또는 음수 에러)
syscall 명령어는 하드웨어가 rcx에 다음 명령어 주소(RIP)를, r11에 RFLAGS를 자동 저장합니다.
따라서 4번째 인자는 함수 호출의 rcx 대신 r10을 사용하며, glibc의 syscall() 래퍼가 이 이동을 자동 처리합니다.
ARM64와 RISC-V는 이런 하드웨어 제약이 없어 함수 호출과 시스템 콜의 인자 레지스터가 동일합니다.
커널이 ABI를 바라보는 방식
리눅스 커널은 내부 구현을 계속 바꿉니다. 함수가 이동하고, 자료구조가 재구성되고, 잠금(Lock) 순서가 조정되고, 내부 API가 정리됩니다. 그러나 사용자 공간 ABI는 다릅니다. 이미 배포된 프로그램이 다시 빌드되지 않아도 계속 실행되어야 하므로, 커널은 사용자 공간을 깨뜨리는 변경을 매우 강하게 경계합니다.
이 원칙은 "시스템 콜 번호를 절대 바꾸지 않는다" 수준을 넘어섭니다. 예를 들어 ioctl 구조체 필드 의미를 바꾸거나, 문서화된 sysfs 텍스트 형식을 갑자기 바꾸거나, 기존 Netlink 속성의 의미를 뒤집는 것도 ABI 파손입니다. 반대로 debugfs처럼 디버깅(Debugging)용으로만 제공되는 인터페이스는 원칙적으로 안정 ABI로 간주하지 않습니다.
| 대상 | 안정성 기대치 | 비고 |
|---|---|---|
| 시스템 콜 | 매우 높음 | 번호와 의미를 유지하고, 확장이 필요하면 새 시스템 콜을 추가합니다 |
| 문서화된 UAPI 헤더 | 매우 높음 | 구조체 크기, 상수 값, 플래그 의미를 신중히 유지합니다 |
| 문서화된 sysfs/procfs | 높음 | 사용자 공간 도구가 파싱하는 형식이면 사실상 ABI로 취급해야 합니다 |
| Netlink 속성 | 높음 | 기존 속성 의미를 바꾸지 않고 새 속성을 추가하는 방식이 선호됩니다 |
| debugfs | 낮음 | 디버깅용이며 안정 ABI로 약속하지 않는 편입니다 |
| 커널 내부 API | 낮음 | 메인라인 내부 정리와 리팩터링이 계속 일어납니다 |
ABI 안정성 분류 체계
리눅스 커널은 모든 사용자 공간 인터페이스를 동일한 강도로 보호하지 않습니다. 커널 소스의 Documentation/ABI/ 디렉터리는 인터페이스를 안정성 수준에 따라 명시적으로 분류합니다. 이 분류를 이해해야 어떤 인터페이스에 의존해도 되고 어떤 인터페이스가 바뀔 수 있는지 판단할 수 있습니다.
| 분류 | 변경 가능성 | 사전 고지 | 사용자 공간 프로그램 권장 |
|---|---|---|---|
| stable | 극히 낮음 | 최소 2년 | 안심하고 의존할 수 있습니다 |
| testing | 있음 | 최소 1 릴리스 사이클 | 변경 알림을 구독하면서 사용합니다 |
| obsolete | 높음 (제거 예정) | 대체 경로 문서화 | 새 프로그램에서는 사용하지 않습니다 |
| removed | 이미 제거 | — | 사용 불가, 대체 인터페이스로 이동합니다 |
# 커널 소스에서 ABI 분류 문서를 확인하는 방법
ls Documentation/ABI/stable/ # 안정 보장 인터페이스
ls Documentation/ABI/testing/ # 안정화 진행 중
ls Documentation/ABI/obsolete/ # 사용 중단 권고
ls Documentation/ABI/removed/ # 이미 제거된 기록
# 특정 sysfs 속성의 ABI 문서 검색
grep -r "What:" Documentation/ABI/stable/ | grep "address"
# 출력 예시:
# What: /sys/class/net/<iface>/address
# Date: April 2005
# KernelVersion: 2.6.12
# Contact: netdev@vger.kernel.org
# Description: Hardware address (MAC)
debugfs에 공식 안정 보장이 없더라도 널리 쓰이는 경로를 갑자기 바꾸면 되돌림(revert) 대상이 될 수 있습니다.
UAPI, libc, 시스템 콜의 연결
사용자 프로그램은 보통 libc 래퍼를 먼저 통합니다. open(), clock_gettime(), pthread_create() 같은 호출은 곧바로 커널에 닿지 않고, glibc나 musl이 아키텍처별 ABI 규약에 맞춰 레지스터를 준비하고 에러를 errno로 변환합니다. 이때 커널이 직접 약속하는 영역은 시스템 콜 ABI와 UAPI 헤더입니다.
/* glibc 래퍼가 없더라도 UAPI 헤더와 syscall ABI만으로 호출할 수 있습니다 */
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/pidfd.h>
int open_pidfd(pid_t pid)
{
return (int)syscall(SYS_pidfd_open, pid, 0);
}
이 예제에서 사용자 프로그램은 glibc의 syscall() 헬퍼를 사용하지만, 실제 커널과 약속하는 내용은 SYS_pidfd_open 번호와 인자 ABI입니다. 반대로 pthread 같은 고수준 라이브러리 API는 내부적으로 여러 시스템 콜을 조합할 수 있으므로, API와 ABI는 항상 같은 경계를 가지지 않습니다.
include/uapi/는 커널이 사용자 공간에 노출하는 공개 계약이고, include/linux/는 커널 내부 구현 헤더가 많습니다.
내부 헤더의 구조체를 사용자 프로그램이 복사해 쓰기 시작하면, 공식 ABI가 아닌 구현 세부사항에 의존하는 문제가 생깁니다.
UAPI 헤더 분리의 역사와 구조
리눅스 커널 v3.5 이전에는 include/linux/ 아래에 커널 내부 정의와 사용자 공간용 정의가 뒤섞여 있었습니다. 사용자 공간에 노출할 부분은 #ifdef __KERNEL__으로 감싸서 구분했는데, 어느 정의가 공개 계약이고 어느 것이 내부 구현인지 경계가 모호했습니다.
v3.7(2012년)에 David Howells가 UAPI 분리 패치 시리즈를 통해 include/uapi/ 디렉터리를 도입했습니다. 사용자 공간에 공개하는 헤더를 물리적으로 별도 디렉터리로 옮기면서, 빌드 시스템이 make headers_install로 깔끔하게 추출할 수 있게 되었고, 개발자가 패치를 작성할 때 "이 변경이 사용자 공간에 닿는가"를 파일 경로만으로 즉시 판단할 수 있게 되었습니다.
| UAPI 디렉터리 | 포함 내용 | 대표 헤더 예시 |
|---|---|---|
include/uapi/linux/ |
시스템 콜 번호, ioctl 명령, 소켓 옵션, Netlink 속성, 공개 구조체 | types.h, ioctl.h, netlink.h, bpf.h, perf_event.h |
include/uapi/asm-generic/ |
아키텍처 공통 타입, 시그널 번호, errno 값, 시스템 콜 번호 기본값 | types.h, signal.h, errno.h, unistd.h |
arch/*/include/uapi/asm/ |
아키텍처별 재정의(레지스터 구조체, ptrace, 시그널 프레임) | ptrace.h, sigcontext.h, unistd.h |
include/uapi/drm/ |
DRM/KMS ioctl 구조체와 상수 | drm.h, i915_drm.h, amdgpu_drm.h |
include/uapi/sound/ |
ALSA 사용자 공간 인터페이스 | asound.h, compress_offload.h |
include/uapi/linux/netfilter/ |
Netfilter/iptables/nftables 공개 구조체 | nf_tables.h, x_tables.h |
git diff --stat include/uapi/만으로 ABI 변경 패치를 빠르게 걸러낼 수 있고, (2) make headers_install이 #ifdef __KERNEL__ 없이도 깔끔하게 동작하며, (3) 새 기여자가 "이 헤더를 고치면 사용자 공간이 깨질 수 있는가"를 파일 경로만으로 판단할 수 있습니다.
UAPI 인터페이스 유형별 분류
UAPI는 단일 메커니즘이 아닙니다. 커널이 사용자 공간에 기능을 노출하는 경로는 여러 가지이며, 각 경로마다 설계 제약과 확장 방식이 다릅니다. 새 기능을 추가할 때 어떤 경로를 택할지가 곧 ABI의 장기 유지보수 비용을 좌우합니다.
| 경로 | 통신 방식 | 확장 전략 | ABI 안정성 부담 |
|---|---|---|---|
| 시스템 콜 | 동기 트랩, 레지스터 인자 | 새 번호 추가 (기존 번호 재사용 금지) | 매우 높음 — 번호와 의미가 영구 고정 |
| ioctl | ioctl(fd, cmd, arg) |
새 명령 번호 추가, 구조체에 size/flags 필드 | 높음 — 명령 번호와 구조체 크기 고정 |
| Netlink | 소켓 메시지, TLV 속성 | 새 속성 타입 추가 (기존 속성 의미 유지) | 높음 — 속성 번호와 의미 고정, 점진 확장 용이 |
| sysfs/procfs | 가상 파일 read/write | 새 파일 추가 (기존 형식 유지) | 중간~높음 — 텍스트 형식이 사실상 ABI |
| vDSO | 공유 메모리 매핑, 사용자 공간 함수 호출 | 새 심볼 추가 (기존 심볼 유지) | 높음 — ELF 심볼과 데이터 레이아웃 고정 |
| BPF | bpf() syscall + 맵/프로그램 |
새 맵 타입, 프로그램 타입, 헬퍼 함수 추가 | 높음 — 맵 타입 번호, 헬퍼 번호 고정 |
make headers_install 파이프라인
커널 소스의 UAPI 헤더를 사용자 공간 프로그램이 직접 포함(#include)할 수 있는 형태로 가공하는 과정이 make headers_install입니다. 배포판이 linux-headers 패키지를 만들 때, 또는 크로스 컴파일 환경에서 사용자 공간 라이브러리를 빌드할 때 이 단계를 거칩니다.
# x86_64용 UAPI 헤더를 /tmp/kheaders에 설치합니다
make headers_install ARCH=x86_64 INSTALL_HDR_PATH=/tmp/kheaders
# 설치된 헤더 확인
ls /tmp/kheaders/include/linux/bpf.h
ls /tmp/kheaders/include/asm/unistd_64.h
# 사용자 프로그램에서 커널 UAPI 헤더를 직접 사용
gcc -I/tmp/kheaders/include my_program.c -o my_program
linux-headers 패키지와 커널 소스에서 직접 make headers_install한 결과가 미묘하게 다를 수 있습니다. 배포판은 자체 패치를 적용하거나 특정 커널 버전에 고정하므로, 최신 커널 기능의 UAPI 헤더가 필요하면 직접 빌드하는 편이 안전합니다.
UAPI 변경 리뷰 프로세스
UAPI 변경은 한번 메인라인에 들어가면 사실상 영구적이므로, 일반 커널 패치보다 훨씬 엄격한 리뷰를 거칩니다. 새 시스템 콜, 새 ioctl, 새 Netlink 속성, 새 sysfs 파일 등 사용자 공간에 노출되는 모든 인터페이스가 이 프로세스의 대상입니다.
| 단계 | 내용 | 관련 파일·도구 |
|---|---|---|
| 1. 설계 논의 | LKML(Linux Kernel Mailing List)에서 인터페이스 설계를 먼저 논의합니다. 구조체 레이아웃, 플래그 의미, 확장 전략에 대한 피드백을 받은 뒤 구현합니다. | LKML, 서브시스템 메일링 리스트 |
| 2. UAPI 헤더 변경 | include/uapi/ 아래에 새 헤더를 추가하거나 기존 헤더에 상수·구조체를 추가합니다. 고정 폭 타입, size 필드, reserved 필드, flags 필드 사용이 권장됩니다. |
include/uapi/linux/*.h |
| 3. man 페이지 | 새 인터페이스의 사용법을 문서화합니다. man-pages 프로젝트에 별도 패치를 보내거나, 커널 패치에 Documentation/ 문서를 포함합니다. |
man-pages 프로젝트, Documentation/ |
| 4. kselftest 작성 | 새 UAPI 인터페이스의 동작을 검증하는 셀프테스트를 반드시 포함합니다. 정상 경로, 에러 경로, 경계 조건을 테스트합니다. | tools/testing/selftests/ |
| 5. 서브시스템 리뷰 | 서브시스템 메인테이너가 ABI 영향과 구현 품질을 검토합니다. 특히 구조체 패딩, 정렬, 32비트 호환성을 확인합니다. | MAINTAINERS, 서브시스템 트리 |
| 6. ABI 검증 | 새 인터페이스가 Documentation/ABI/에 문서화되었는지, 안정성 분류(stable/testing)가 지정되었는지 확인합니다. |
Documentation/ABI/testing/, scripts/get_abi.pl |
| 7. 머지 윈도 | 서브시스템 트리를 거쳐 리누스의 메인라인에 머지됩니다. 머지 이후에는 인터페이스 의미를 바꿀 수 없으므로, rc 기간 동안 마지막 검토가 이루어집니다. | 메인라인 머지, rc 테스트 |
# 패치에 UAPI 변경이 포함되었는지 빠르게 확인합니다
git diff --stat HEAD~1 | grep uapi
# kselftest 실행으로 새 인터페이스를 검증합니다
make -C tools/testing/selftests TARGETS=서브시스템명 run_tests
# ABI 문서화 상태를 확인합니다
scripts/get_abi.pl validate
# UAPI 헤더의 컴파일 검증 (사용자 공간 컴파일러로)
make headers_install ARCH=x86_64
gcc -fsyntax-only -include /tmp/kheaders/include/linux/새헤더.h -x c /dev/null
__u32, __u64) 사용 여부,
(2) size 필드로 버전 협상이 가능한지,
(3) 모르는 flags 비트를 -EINVAL로 거절하는지,
(4) reserved 필드가 0이 아니면 거절하는지,
(5) 32비트 compat 경로에서도 같은 의미로 동작하는지,
(6) kselftest가 포함되었는지.
compat ABI와 아키텍처 차이
64비트 커널이 항상 64비트 사용자 프로그램만 실행하는 것은 아닙니다. 많은 배포판과 임베디드 장비는 64비트 커널 위에서 32비트 사용자 프로그램을 계속 실행합니다. 이 경우 커널은 별도의 compat ABI 경로를 통해 32비트 포인터 크기, 구조체 레이아웃, 시간 타입 차이를 보정합니다.
x86 계열에서는 64비트 native ABI, 32비트 i386 compat ABI, 그리고 64비트 모드이지만 32비트 포인터를 쓰는 x32 ABI까지 존재합니다. 여기에 Y2038 문제를 해결하기 위한 time64 전환까지 겹치면, 같은 기능이라도 사용자 공간이 어떤 ABI를 택했는지에 따라 커널 경로가 달라집니다.
/* compat ABI에서 문제가 되는 전형적인 선언 */
struct legacy_args {
unsigned int flags;
void *ptr;
unsigned long len;
};
/* compat 친화적인 UAPI 선언 */
struct compat_safe_args {
__u32 size;
__u32 flags;
__aligned_u64 ptr;
__u64 len;
__u64 reserved[2];
};
Y2038 문제와 time64 전환
ABI 호환성이 왜 까다로운지를 가장 극적으로 보여주는 사례가 Y2038 문제입니다. 전통적으로 리눅스(Linux)의 time_t는 32비트 부호 있는 정수(signed long)였으며, 1970년 1월 1일 자정(UTC)부터 초 단위로 경과 시간을 셉니다. 이 값은 2038년 1월 19일 03:14:07 UTC에 오버플로(Overflow)됩니다.
| 구분 | 옛 ABI (time32) | 새 ABI (time64) |
|---|---|---|
| time_t 크기 | 4바이트 (32비트) | 8바이트 (64비트) |
| struct timespec | 8바이트 (tv_sec 4 + tv_nsec 4) | 16바이트 (tv_sec 8 + tv_nsec 8) |
| 시스템 콜 예시 | clock_gettime (번호 263) |
clock_gettime64 (번호 403) |
| 표현 한계 | 2038-01-19 03:14:07 UTC | 약 2920억 년 |
| 영향 범위 | 32비트 사용자 공간만 | 32비트·64비트 모두 |
/* 32비트 환경에서 time64 시스템 콜을 직접 호출하는 예시 */
#include <stdint.h>
#include <unistd.h>
#include <sys/syscall.h>
struct __kernel_timespec {
int64_t tv_sec; /* 항상 64비트 — 2038 이후에도 안전 */
int64_t tv_nsec;
};
int get_time64(struct __kernel_timespec *ts)
{
return syscall(__NR_clock_gettime64, 0 /* CLOCK_REALTIME */, ts);
}
_TIME_BITS=64로 재빌드하거나, 아예 64비트 사용자 공간으로 전환해야 합니다.
커널은 이미 time64 경로를 제공하므로 사용자 공간의 전환만 남은 상태입니다.
커널의 compat 변환 구현
커널이 32비트 사용자 공간을 실제로 어떻게 처리하는지 구현 수준에서 살펴봅니다. 핵심은 compat 전용 시스템 콜 핸들러와 포인터/구조체 변환 헬퍼입니다.
compat 시스템 콜 디스패치
32비트 프로세스가 시스템 콜을 호출하면, 커널은 native 시스템 콜 테이블 대신 compat 시스템 콜 테이블을 참조합니다. x86_64에서 32비트 프로세스는 int 0x80이나 sysenter로 진입하며, 커널은 ia32_sys_call_table을 사용합니다.
compat 변환 헬퍼
커널은 compat 변환에 필요한 헬퍼 함수와 매크로를 제공합니다. 가장 자주 쓰이는 것들을 정리합니다.
| 헬퍼 | 역할 | 사용 위치 |
|---|---|---|
compat_ptr(u) |
32비트 정수를 사용자 공간 포인터로 변환합니다 | compat 핸들러에서 포인터 인자를 복원할 때 |
ptr_to_compat(p) |
64비트 포인터를 32비트 정수로 변환합니다 | 결과를 32비트 사용자 공간에 반환할 때 |
in_compat_syscall() |
현재 시스템 콜이 compat 경로인지 확인합니다 | 공통 핸들러에서 분기가 필요할 때 |
compat_alloc_user_space() |
사용자 스택에 임시 공간을 할당합니다 | 변환된 구조체를 사용자 공간에 임시 배치할 때 |
COMPAT_SYSCALL_DEFINEn() |
compat 시스템 콜 핸들러를 선언합니다 | compat 전용 시스템 콜 정의 |
/* 커널 compat 변환 패턴 예시: ioctl compat 핸들러 */
struct compat_my_ioctl_args {
compat_uint_t flags;
compat_uptr_t ptr; /* 32비트 사용자 공간 포인터 */
compat_size_t len;
};
static long my_compat_ioctl(struct file *file,
unsigned int cmd, unsigned long arg)
{
struct compat_my_ioctl_args cargs;
struct my_ioctl_args args;
if (copy_from_user(&cargs, (void __user *)arg, sizeof(cargs)))
return -EFAULT;
/* 32비트 → 64비트 변환 */
args.flags = cargs.flags;
args.ptr = compat_ptr(cargs.ptr); /* 핵심: 포인터 복원 */
args.len = cargs.len;
return my_ioctl_common(file, cmd, &args);
}
/* compat 시스템 콜 정의 매크로 예시 */
COMPAT_SYSCALL_DEFINE2(my_new_call, int, fd,
compat_uptr_t, user_args)
{
return do_my_new_call(fd, compat_ptr(user_args));
}
ioctl 구현 내에서 in_compat_syscall()로 현재 경로를 확인한 뒤, 구조체 크기를 달리 읽는 방식입니다.
그러나 가능하면 처음부터 고정 폭 타입으로 설계해서 compat 변환 자체가 필요 없게 만드는 편이 바람직합니다.
ABI가 깨지는 전형적인 사례
ABI 파손은 빌드 오류보다 더 위험합니다. 소스가 다시 컴파일되면 곧바로 문제가 드러나는 API 파손과 달리, ABI 파손은 이미 배포된 프로그램이 조용히 잘못된 데이터를 읽거나, 잘못된 시스템 콜을 호출하거나, 특정 아키텍처에서만 실패하는 방식으로 나타납니다.
| 실수 | 어떤 문제가 생기는가 | 권장 대응 |
|---|---|---|
| 구조체 필드 순서 변경 | 기존 바이너리가 다른 필드를 같은 오프셋에서 읽습니다 | 기존 필드는 유지하고 새 필드는 뒤에 추가합니다 |
| 기존 플래그 의미 변경 | 같은 비트 값이 새 의미로 해석되어 동작이 바뀝니다 | 새 비트를 추가하고 옛 의미는 유지합니다 |
| 시스템 콜 인자 의미 변경 | 예전 프로그램이 정상 호출을 했는데 커널이 다른 의미로 처리합니다 | 새 시스템 콜이나 새 구조체 기반 인터페이스를 추가합니다 |
| 문서화된 텍스트 ABI 변경 | 운영 도구와 자동화 스크립트가 파싱에 실패합니다 | 기존 형식을 유지하고 새 필드를 뒤에 추가합니다 |
| reserved 비트 무검증 | 미래 확장 시 옛 커널과 새 사용자 공간이 충돌합니다 | 모르는 비트가 들어오면 -EINVAL로 거절합니다 |
| 32비트 compat 미고려 | 64비트 환경에서는 되지만 32비트 사용자 공간에서 데이터가 잘립니다 | 고정 폭 타입과 compat 변환 경로를 설계 초기에 포함합니다 |
EINVAL이나 즉시 패닉으로 끝나지 않습니다.
사용자 공간이 잘못된 길이를 보내고 커널이 다른 구조체로 해석해도, 메모리 내용이 우연히 맞아 떨어지면 한동안 정상처럼 보일 수 있습니다. 그래서 ABI 검토는 새 기능 설계 단계에서 끝내야 합니다.
실제 ABI 파손 사례와 교훈
추상적인 규칙보다 실제 사건이 ABI 설계의 중요성을 더 잘 보여줍니다. 아래는 리눅스 커널 역사에서 ABI가 문제가 된 대표적인 사례입니다.
| 사례 | 문제 원인 | 해결 패턴 | 현재 상태 |
|---|---|---|---|
| stat → stat64 | 32비트 파일 크기 한계 | 새 시스템 콜 추가, 옛 콜 유지 | 64비트 환경에서는 기본 stat이 64비트 |
| open → openat2 | 플래그 공간 부족, 기능 확장 한계 | 구조체 기반 + size 필드 패턴 | copy_struct_from_user 모범 사례 |
| clone → clone3 | 레지스터 인자 개수 한계 | 구조체 포인터 + size 인자 | cgroup fd 등 새 기능 추가 용이 |
| select → pselect6 → epoll | fd 집합 크기 한계, 성능 문제 | 완전히 새로운 인터페이스 설계 | epoll이 사실상 표준, select도 유지 |
| ioctl 번호 충돌 | 매직 번호 중복 할당 | _IOC() 매크로로 체계적 번호 할당 |
Documentation/userspace-api/ioctl/ioctl-number.rst 관리 |
size 필드, flags 필드, reserved 필드는 이 교훈에서 비롯된 설계 관례입니다.
새 ABI를 설계할 때의 규칙
리눅스 커널 커뮤니티가 새 시스템 콜이나 새 UAPI 구조체를 설계할 때 반복해서 확인하는 규칙은 대체로 비슷합니다. 핵심은 "확장 가능하게 만들되, 기존 의미를 절대 재사용하지 않는다"입니다.
- 기존 인터페이스 의미를 바꾸지 않습니다. 기능 확장이 필요하면 새 시스템 콜이나 새 플래그, 새 Netlink 속성을 추가합니다.
- 구조체 기반 인터페이스에는 size 필드를 둡니다. 커널과 사용자 공간이 서로 어느 버전까지 알고 있는지 안전하게 판단할 수 있습니다.
- flags와 reserved 필드를 둡니다. 확장 비트를 위한 공간을 미리 확보하고, 모르는 비트는 거절합니다.
- 고정 폭 타입을 사용합니다. UAPI는
int,long, 포인터 크기에 기대지 않는 쪽이 좋습니다. - 복잡한 가변 속성에는 Netlink를 우선 검토합니다. 큰 구조체를 한 번에 굳히는 것보다 점진 확장성이 좋습니다.
- 디버깅 인터페이스와 안정 ABI를 분리합니다. debugfs는 운영 계약이 아니라 진단 보조 수단으로 남겨두는 편이 안전합니다.
/* 확장 가능한 UAPI 구조체 예시 */
#define ABI_OP_F_CLOEXEC (1U << 0)
#define ABI_OP_F_NONBLOCK (1U << 1)
struct abi_op_args {
__u32 size;
__u32 flags;
__u32 mode;
__u32 reserved0;
__aligned_u64 user_ptr;
__u64 user_len;
__u64 reserved[2];
};
/* 커널 측 검증 패턴 예시 */
static int validate_abi_op_args(const struct abi_op_args *u)
{
if (u->size < offsetof(struct abi_op_args, reserved))
return -EINVAL;
if (u->flags & ~( ABI_OP_F_CLOEXEC | ABI_OP_F_NONBLOCK ))
return -EINVAL;
if (u->reserved0 || u->reserved[0] || u->reserved[1])
return -EINVAL;
return 0;
}
vDSO와 ELF 보조 벡터
시스템 콜과 UAPI 구조체만 ABI를 이루는 것은 아닙니다. 커널은 프로세스를 시작할 때 ELF 보조 벡터(Auxiliary Vector)를 통해 런타임 정보를 전달하고, vDSO(virtual Dynamic Shared Object)를 통해 커널 모드 전환 없이 특정 시스템 콜을 실행할 수 있는 코드 페이지를 제공합니다. 이 두 메커니즘 모두 바이너리가 기대하는 ABI의 일부입니다.
| 보조 벡터 키 | 전달하는 정보 | ABI 의미 |
|---|---|---|
AT_SYSINFO_EHDR |
vDSO ELF 헤더 주소 | 이 주소에서 vDSO 심볼을 탐색할 수 있다는 약속 |
AT_PAGESZ |
시스템 페이지 크기 | mmap 정렬 단위로 사용할 수 있다는 약속 |
AT_HWCAP / AT_HWCAP2 |
CPU 하드웨어 기능 비트맵 | 각 비트가 특정 명령어 집합 지원을 의미한다는 약속 |
AT_RANDOM |
16바이트 난수 | 스택 카나리(Stack Canary), ASLR 시드 등에 사용할 수 있다는 약속 |
AT_EXECFN |
실행 파일 이름 | proc 접근 없이 실행 파일 경로를 얻을 수 있다는 약속 |
AT_CLKTCK |
clock ticks per second | times() 반환값 해석에 필요한 기준 값 |
/* 보조 벡터에서 vDSO와 HWCAP 정보를 읽는 예시 */
#include <stdio.h>
#include <sys/auxv.h>
int main(void)
{
unsigned long vdso = getauxval(AT_SYSINFO_EHDR);
unsigned long pgsz = getauxval(AT_PAGESZ);
unsigned long hwcap = getauxval(AT_HWCAP);
printf("vDSO base: %#lx\n", vdso);
printf("Page size: %lu\n", pgsz);
printf("HWCAP: %#lx\n", hwcap);
return 0;
}
# 보조 벡터를 직접 확인하는 방법
LD_SHOW_AUXV=1 /bin/true
# 출력 예시 (x86_64):
# AT_SYSINFO_EHDR: 0x7fff12345000
# AT_HWCAP: 0x178bfbff
# AT_PAGESZ: 4096
# AT_CLKTCK: 100
# AT_RANDOM: 0x7fff12340ab0
# vDSO에 어떤 심볼이 있는지 확인
objdump -T /usr/lib/debug/vdso64.so 2>/dev/null || \
readelf -Ws /proc/self/map_files/$(grep vdso /proc/self/maps | cut -d- -f1)-*
clock_gettime() 같은 고빈도 호출에서 커널 모드 전환 비용을 제거하는 것입니다.
커널은 공유 페이지에 시간 데이터를 주기적으로 갱신하고, vDSO 코드는 이 데이터를 사용자 모드에서 직접 읽습니다.
이 구현 세부사항은 변경될 수 있지만, vDSO 심볼의 이름과 호출 규약은 ABI로 유지됩니다.
copy_struct_from_user 확장 패턴
커널 5.4 이후 도입된 copy_struct_from_user()는 size 필드 기반 확장 패턴의 표준 구현입니다. 이 함수는 사용자 공간이 보내는 구조체가 커널보다 작을 수도, 클 수도 있는 상황을 안전하게 처리합니다.
/* copy_struct_from_user를 활용하는 시스템 콜 패턴 (openat2 기반) */
SYSCALL_DEFINE4(my_new_call, int, dirfd, const char __user *, path,
struct my_call_args __user *, uargs, size_t, usize)
{
struct my_call_args args;
int err;
/* 최소 크기 검증: 첫 버전 필드까지는 있어야 합니다 */
BUILD_BUG_ON(sizeof(args) < MY_CALL_ARGS_SIZE_VER0);
if (usize < MY_CALL_ARGS_SIZE_VER0)
return -EINVAL;
/* size 차이를 안전하게 처리합니다 */
err = copy_struct_from_user(&args, sizeof(args), uargs, usize);
if (err)
return err;
/* 모르는 flags 비트 거절 */
if (args.flags & ~MY_CALL_KNOWN_FLAGS)
return -EINVAL;
return do_my_call(dirfd, path, &args);
}
/* 사용자 공간 호출 예시 */
struct my_call_args args = {
.flags = MY_F_CLOEXEC,
.mode = 0644,
};
int fd = syscall(__NR_my_new_call, AT_FDCWD, "/tmp/test",
&args, sizeof(args));
copy_struct_from_user() 패턴이 동작하려면 두 가지 조건이 필요합니다.
(1) 새로 추가하는 필드의 기본값(=의미 없는 값)은 반드시 0이어야 합니다.
(2) 커널은 모르는 비트나 필드가 0이 아니면 -EINVAL로 거절해야 합니다.
이 두 규칙 덕분에 옛 사용자 공간은 새 커널에서 새 필드를 0으로 받고, 새 사용자 공간은 옛 커널에서 "모르는 필드가 0이 아니다" 에러를 통해 기능 부재를 감지할 수 있습니다.
실습: ABI 차이를 직접 확인하기
ABI를 눈으로 확인하는 가장 좋은 방법은 실제 코드를 작성해서 구조체 레이아웃, 시스템 콜 호출, 그리고 compat 차이를 직접 관찰하는 것입니다.
구조체 레이아웃 확인
/* abi_layout_check.c - 구조체 크기와 오프셋을 직접 출력합니다 */
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
/* 나쁜 예: 아키텍처별로 크기가 달라지는 구조체 */
struct bad_layout {
int flags;
void *ptr;
unsigned long len;
};
/* 좋은 예: 고정 폭 타입으로 크기가 일정한 구조체 */
struct good_layout {
uint32_t flags;
uint32_t reserved;
uint64_t ptr;
uint64_t len;
};
#define SHOW(type, field) \
printf(" %-12s offset=%2zu size=%zu\n", \
#field, offsetof(struct type, field), \
sizeof((struct type *)0)->field))
int main(void)
{
printf("sizeof(void*)=%zu sizeof(long)=%zu\n\n",
sizeof(void *), sizeof(long));
printf("bad_layout (size=%zu):\n", sizeof(struct bad_layout));
SHOW(bad_layout, flags);
SHOW(bad_layout, ptr);
SHOW(bad_layout, len);
printf("\ngood_layout (size=%zu):\n", sizeof(struct good_layout));
SHOW(good_layout, flags);
SHOW(good_layout, reserved);
SHOW(good_layout, ptr);
SHOW(good_layout, len);
return 0;
}
# 64비트 환경에서 컴파일/실행
gcc -o layout64 abi_layout_check.c && ./layout64
# 32비트 환경에서 컴파일/실행 (크기 차이를 직접 비교할 수 있습니다)
gcc -m32 -o layout32 abi_layout_check.c && ./layout32
# 64비트 출력 예시
sizeof(void*)=8 sizeof(long)=8
bad_layout (size=24):
flags offset= 0 size=4
ptr offset= 8 size=8 ← 4바이트 패딩 발생
len offset=16 size=8
good_layout (size=24):
flags offset= 0 size=4
reserved offset= 4 size=4 ← 명시적 패딩
ptr offset= 8 size=8
len offset=16 size=8
# 32비트 출력 예시
sizeof(void*)=4 sizeof(long)=4
bad_layout (size=12): ← 64비트의 절반!
flags offset= 0 size=4
ptr offset= 4 size=4
len offset= 8 size=4
good_layout (size=24): ← 32/64 동일
flags offset= 0 size=4
reserved offset= 4 size=4
ptr offset= 8 size=8
len offset=16 size=8
인라인 어셈블리로 raw 시스템 콜
/* raw_syscall.c - libc 래퍼 없이 시스템 콜을 직접 호출합니다 */
#include <unistd.h>
static long raw_write(int fd, const void *buf, unsigned long len)
{
long ret;
__asm__ volatile (
"syscall"
: "=a"(ret) /* rax = 반환값 */
: "a"((long)1), /* rax = __NR_write */
"D"((long)fd), /* rdi = arg1 */
"S"(buf), /* rsi = arg2 */
"d"(len) /* rdx = arg3 */
: "rcx", "r11", "memory" /* clobber 목록 */
);
return ret;
}
int main(void)
{
const char msg[] = "Hello from raw syscall!\n";
raw_write(1, msg, sizeof(msg) - 1);
return 0;
}
# 컴파일 및 strace로 확인
gcc -o raw_syscall raw_syscall.c
strace -e trace=write ./raw_syscall
# 출력: write(1, "Hello from raw syscall!\n", 23) = 23
# libc 래퍼를 거치지 않아도 동일한 시스템 콜 ABI를 사용합니다
strace로 ABI 경계 관찰
# 시스템 콜 인자와 반환값을 상세히 관찰합니다
strace -e trace=openat,read,write -v -x ./my_program
# ioctl 구조체 인자까지 디코딩합니다
strace -e trace=ioctl -v ./my_program
# 32비트 바이너리가 어떤 시스템 콜 ABI를 쓰는지 확인합니다
strace -e trace=%file file ./my_32bit_app
# pahole로 커널 구조체 레이아웃을 직접 확인합니다
pahole -C open_how /usr/lib/debug/boot/vmlinux-$(uname -r)
bad_layout은 32비트에서 12바이트, 64비트에서 24바이트로 크기가 달라집니다.
이 차이가 바로 compat ABI 변환이 필요한 이유이며, good_layout처럼 고정 폭 타입을 쓰면 두 환경에서 크기가 동일합니다.
직접 컴파일해서 비교하면 ABI가 왜 중요한지 체감할 수 있습니다.
ABI 문제를 디버깅하는 방법
ABI 문제를 의심할 때는 "소스가 맞는가"보다 "커널과 사용자 공간이 같은 바이트열을 같은 의미로 보고 있는가"를 확인해야 합니다. 다음 도구 조합이 가장 실용적입니다.
| 도구 | 무엇을 확인하는가 | 대표 상황 |
|---|---|---|
| strace | 실제 시스템 콜 번호, 인자, 반환값 | glibc 래퍼가 어떤 커널 ABI를 쓰는지 확인할 때 |
| readelf / objdump | ELF 헤더, 심볼, 동적 심볼, 보조 정보 | 바이너리 형식과 아키텍처를 확인할 때 |
| pahole | 구조체 오프셋과 패딩 | 레이아웃 차이와 정렬 문제를 볼 때 |
| file / getconf | 32비트/64비트 사용자 공간 여부 | compat 경로를 타는지 확인할 때 |
| bpftrace / ftrace | 실제 커널 진입 함수와 분기 경로 | native와 compat 핸들러가 어디로 가는지 볼 때 |
file ./app
getconf LONG_BIT
readelf -h ./app
readelf -Ws ./app | grep vdso
strace -e trace=%file,%process ./app
pahole -C some_uapi_struct vmlinux
예를 들어 사용자 프로그램이 64비트라고 생각했는데 file 결과가 32비트 ELF로 나온다면, 커널 쪽에서는 native 경로가 아니라 compat ABI 경로가 호출될 수 있습니다. 또는 pahole로 구조체 크기를 확인했을 때 예상보다 큰 패딩이 보이면, 사용자 공간과 커널이 같은 선언을 보고도 서로 다른 오프셋을 기대하고 있을 가능성을 의심해야 합니다.
참고자료
- 시스템 콜 - 시스템 콜 ABI와 compat 경로를 아키텍처별로 추적합니다.
- 어셈블리 종합 - 호출 규약과 레지스터 사용 규칙을 상세히 설명합니다.
- ELF - 바이너리 형식과 심볼, 동적 로더(Loader) 관점을 보강할 때 참고합니다.
- vDSO - 사용자 공간과 커널이 공유하는 특수한 ABI 경계를 설명합니다.
- ioctl 심화 - UAPI 구조체와 번호 설계가 중요한 전형적 사례입니다.
- Netlink - 속성 기반으로 확장 가능한 사용자 공간 인터페이스 예시입니다.
- Adding new system calls - 새 시스템 콜 설계 시 ABI 검토 포인트를 정리한 커널 문서입니다.
- System V AMD64 ABI - x86_64 호출 규약과 바이너리 규약의 기준 문서입니다.
- ARM64 AAPCS64 - ARM64 아키텍처의 프로시저 호출 표준입니다.
- RISC-V ELF psABI - RISC-V 아키텍처의 ELF 및 호출 규약 문서입니다.
- Linux Userspace API - 커널이 사용자 공간에 제공하는 인터페이스 전체 인덱스입니다.