ABI (Application Binary Interface)

응용 프로그램 바이너리 인터페이스(Application Binary Interface, ABI)는 컴파일된 프로그램이 커널, 라이브러리, 다른 바이너리와 만나는 실제 계약입니다. 호출 규약(Calling Convention), 시스템 콜(System Call) 번호, 구조체(Struct) 레이아웃, 정렬(Alignment), 패딩(Padding), ELF 심볼 규칙, compat ABI까지 한 흐름으로 묶어 이해해야 커널이 왜 사용자 공간(User Space)을 깨뜨리지 않으려 하는지 정확히 보입니다.

전제 조건: 시스템 콜, 어셈블리(Assembly) 종합, ELF 문서를 먼저 읽으세요. ABI는 함수 호출 규약만의 문제가 아니라 시스템 콜 진입, 바이너리 로딩, 구조체 레이아웃, 라이브러리 경계까지 함께 봐야 이해가 됩니다.
일상 비유: 이 개념은 컨테이너(Container) 운송 규격과 비슷합니다. 박스 모양, 팔레트 크기, 문 높이, 하역 순서가 모두 맞아야 창고와 트럭과 크레인이 같은 화물을 안전하게 처리할 수 있듯이, ABI도 레지스터(Register) 사용법과 데이터 배치 규칙이 맞아야 이미 배포된 프로그램이 계속 동작합니다.

핵심 요약

  • ABI - 컴파일이 끝난 뒤에도 남아 있는 바이너리 수준의 약속입니다.
  • API - 소스 코드 수준의 인터페이스이며, 커널 내부 API는 바뀔 수 있습니다.
  • UAPI - 커널이 사용자 공간에 공개하는 헤더와 상수, 구조체 집합입니다.
  • 시스템 콜 ABI - ABI 전체 중 커널 진입 규약을 담당하는 일부입니다.
  • compat ABI - 64비트 커널이 32비트 사용자 공간을 계속 실행하게 만드는 별도 호환 경로입니다.

단계별 이해

  1. 경계 확인
    사용자 프로그램이 어디서 libc를 거치고 어디서 커널 ABI에 직접 닿는지 먼저 구분합니다.
  2. 구성 요소 분해
    레지스터 규약, 구조체 크기, 정렬, 시스템 콜 번호를 ABI의 개별 조각으로 나눠 봅니다.
  3. 호환성 관점 추가
    이미 배포된 바이너리를 다시 빌드하지 않아도 계속 동작해야 한다는 조건을 붙입니다.
  4. compat 경로 확인
    32비트 사용자 공간, x32, time64 같은 예외 경로가 왜 별도 처리되는지 봅니다.
  5. 설계 규칙 연결
    새 인터페이스를 만들 때 왜 size, flags, reserved 필드가 필요한지 실무 규칙으로 연결합니다.
핵심 관찰: 커널에서 ABI는 단순히 "함수 인자를 어느 레지스터에 넣는가"만 뜻하지 않습니다. include/uapi/ 헤더, ioctl 구조체, Netlink 속성, vDSO 엔트리, ELF 보조 벡터, 32비트 compat 경로까지 모두 사용자 공간이 기대하는 ABI의 일부입니다.

ABI 개요

ABI는 소스 코드 수준의 인터페이스가 아니라 컴파일된 결과물이 기대하는 규약입니다. 같은 함수 이름이라도 인자를 전달하는 레지스터가 달라지거나 구조체 필드 오프셋(Offset)이 바뀌면, 소스는 그대로여도 이미 배포된 바이너리는 즉시 오동작할 수 있습니다.

리눅스 커널 관점에서 특히 중요한 ABI는 사용자 공간과 만나는 경계입니다. 시스템 콜 번호, ioctl 명령 번호, struct stat 같은 공개 구조체, Netlink 속성, sysfs 속성 파일 의미, vDSO 엔트리 주소 해석 규칙은 모두 사용자 공간이 장기간 의존하는 계약입니다.

ABI와 API와 UAPI와 시스템 콜의 관계 ABI는 API보다 넓은 범위의 바이너리 수준 실행 계약입니다 소스 코드 계층 함수 시그니처 매크로와 타입 이름 API 컴파일 ABI 범위 — 컴파일된 바이너리가 기대하는 계약 UAPI 헤더 상수, 플래그, 구조체 ioctl 번호 시스템 콜 ABI 시스템 콜 번호 레지스터 인자 배치 바이너리 규칙 ELF 심볼, 오프셋, 정렬 vDSO, 보조 벡터 API는 다시 컴파일하면 맞춰질 수 있습니다 ABI는 이미 배포된 바이너리를 기준으로 판단합니다 커널은 사용자 공간 ABI를 오래 유지해야 합니다 ABI는 "소스를 어떻게 부르나"보다 "바이너리가 실제로 무엇을 기대하나"에 가깝습니다
구분 주로 보는 대상 대표 예시 깨지면 나타나는 문제
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 항목
실무 경고: UAPI 구조체에서 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에서 함수 호출 규약과 시스템 콜 규약의 차이 x86_64: 함수 호출 vs 시스템 콜에서 4번째 인자 레지스터가 다릅니다 함수 호출 (System V AMD64) rdi: arg1 rsi: arg2 rdx: arg3 rcx: arg4 r8: arg5 r9: arg6 rax: 반환값 시스템 콜 (syscall 명령어) rdi: arg1 rsi: arg2 rdx: arg3 r10: arg4 r8: arg5 r9: arg6 rax: 번호 → 반환값 rcx → r10 대체 syscall 명령어가 rcx에 RIP를, r11에 RFLAGS를 덮어쓰기 때문입니다 glibc의 syscall() 래퍼가 mov r10, rcx 를 자동 수행하므로, C 코드에서는 이 차이를 의식할 필요가 없습니다
; 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에 반환값 (쓴 바이트 수 또는 음수 에러)
핵심 차이: x86_64에서 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 낮음 메인라인 내부 정리와 리팩터링이 계속 일어납니다
읽는 법: 커널 패치(Patch)를 볼 때 "이 변경이 내부 구현만 건드리는가, 아니면 사용자 공간이 직접 보는 바이트 배열과 숫자 의미까지 건드리는가"를 먼저 물으면 ABI 영향 범위를 빠르게 가를 수 있습니다.

ABI 안정성 분류 체계

리눅스 커널은 모든 사용자 공간 인터페이스를 동일한 강도로 보호하지 않습니다. 커널 소스의 Documentation/ABI/ 디렉터리는 인터페이스를 안정성 수준에 따라 명시적으로 분류합니다. 이 분류를 이해해야 어떤 인터페이스에 의존해도 되고 어떤 인터페이스가 바뀔 수 있는지 판단할 수 있습니다.

커널 ABI 안정성 4단계 분류 체계 Documentation/ABI/ 디렉터리의 4단계 분류 — 안정성이 높을수록 변경 비용이 큽니다 stable 사실상 영구 보장 최소 2년 사전 고지 없이 변경 불가 예: /sys/class/net/*/address 예: 시스템 콜 번호 testing 안정화 진행 중 변경 시 최소 1개 릴리스 사전 고지 필요 새 sysfs 속성이 처음 추가될 때 피드백 반영 후 stable 승격 obsolete 대체 인터페이스 존재 사용 중단 권고 당장 제거하지는 않음 예: sysfs의 일부 레거시 속성 예: 구형 ioctl 명령 번호 removed 이미 제거됨 사용 불가 기록용으로만 유지 제거 사유와 대체 경로를 문서에 기록합니다 분류 밖: debugfs debugfs는 원칙적으로 안정 ABI가 아닙니다. 그러나 사용자 공간 도구가 실제로 파싱하기 시작하면 사실상(de facto) ABI가 됩니다. perf나 systemtap이 debugfs에 의존하는 경우가 대표적입니다. 의존해도 안전한 대상 stable 분류된 sysfs/procfs 속성 시스템 콜 번호와 UAPI 구조체 문서화된 Netlink 속성 의존에 주의가 필요한 대상 testing 단계 인터페이스 (변경 가능) debugfs 경로와 형식 문서화되지 않은 procfs 세부 형식
분류 변경 가능성 사전 고지 사용자 공간 프로그램 권장
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)
사실상(de facto) ABI: 공식 분류가 없어도, 사용자 공간 프로그램이 실제로 의존하기 시작한 인터페이스는 사실상 ABI가 됩니다. 리누스 토르발스(Linus Torvalds)는 "사용자 공간을 깨뜨리지 않는다"는 원칙을 공식 분류보다 우선합니다. 즉, debugfs에 공식 안정 보장이 없더라도 널리 쓰이는 경로를 갑자기 바꾸면 되돌림(revert) 대상이 될 수 있습니다.

UAPI, libc, 시스템 콜의 연결

사용자 프로그램은 보통 libc 래퍼를 먼저 통합니다. open(), clock_gettime(), pthread_create() 같은 호출은 곧바로 커널에 닿지 않고, glibc나 musl이 아키텍처별 ABI 규약에 맞춰 레지스터를 준비하고 에러를 errno로 변환합니다. 이때 커널이 직접 약속하는 영역은 시스템 콜 ABI와 UAPI 헤더입니다.

사용자 공간에서 커널 ABI까지의 흐름 사용자 공간 프로그램은 보통 libc를 거쳐 커널 ABI에 도달합니다 사용자 바이너리 ELF 실행 파일 컴파일된 호출 지점 libc 래퍼 인자 정리, 레지스터 배치 errno 변환 시스템 콜 ABI 번호와 레지스터 규약 entry 경로 커널 구현 검증과 copy_from_user ksys / vfs / driver UAPI 헤더 (include/uapi/) 상수, 구조체, ioctl 번호, Netlink 속성 — 사용자 공간과 커널이 같은 바이트 의미를 공유합니다 참조 정의 구현
/* 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로 깔끔하게 추출할 수 있게 되었고, 개발자가 패치를 작성할 때 "이 변경이 사용자 공간에 닿는가"를 파일 경로만으로 즉시 판단할 수 있게 되었습니다.

include/uapi/ 디렉터리 계층 구조와 아키텍처별 헤더 관계 커널 소스 include/ 디렉터리: 내부 헤더와 UAPI 공개 헤더의 분리 커널 내부 헤더 (비공개) include/linux/ — 커널 내부 자료구조, 함수 선언 include/asm-generic/ — 아키텍처 공통 내부 헤더 arch/*/include/asm/ — 아키텍처별 내부 헤더 이 영역의 구조체·상수는 커널 버전마다 바뀔 수 있습니다 사용자 프로그램이 직접 참조하면 안 됩니다 v3.5 이전 상태 include/linux/에 내부 정의와 공개 정의가 혼재 #ifdef __KERNEL__로만 구분 → 경계가 모호 v3.7 UAPI 분리 공개 정의를 include/uapi/로 물리적 이동 UAPI 공개 헤더 (사용자 공간 계약) include/uapi/linux/ syscall 번호, ioctl 명령, 구조체, 상수, 플래그 include/uapi/asm-generic/ 아키텍처 공통 UAPI 정의 (타입, 시그널 번호 등) arch/*/include/uapi/asm/ 아키텍처별 UAPI (레지스터, 시그널 프레임 등) include/uapi/drm/ · net/ · sound/ 등 서브시스템별 UAPI (DRM, 소켓 옵션, ALSA 등) 아키텍처가 재정의 가능 make headers_install UAPI 헤더를 사용자 공간 배포용으로 가공 unifdef로 __KERNEL__ 블록 제거 후 추출 분리 이 영역의 변경은 ABI 변경이므로 각별한 주의가 필요합니다
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
분리의 실익: UAPI 분리 덕분에 (1) git diff --stat include/uapi/만으로 ABI 변경 패치를 빠르게 걸러낼 수 있고, (2) make headers_install#ifdef __KERNEL__ 없이도 깔끔하게 동작하며, (3) 새 기여자가 "이 헤더를 고치면 사용자 공간이 깨질 수 있는가"를 파일 경로만으로 판단할 수 있습니다.

UAPI 인터페이스 유형별 분류

UAPI는 단일 메커니즘이 아닙니다. 커널이 사용자 공간에 기능을 노출하는 경로는 여러 가지이며, 각 경로마다 설계 제약과 확장 방식이 다릅니다. 새 기능을 추가할 때 어떤 경로를 택할지가 곧 ABI의 장기 유지보수 비용을 좌우합니다.

UAPI 인터페이스 유형별 분류: 사용자 공간이 커널에 접근하는 6가지 경로 사용자 공간이 커널 기능에 접근하는 6가지 UAPI 경로 사용자 프로그램 libc 래퍼 또는 직접 syscall() 시스템 콜 (syscall) 가장 기본적인 커널 진입 경로 번호 고정, 확장 시 새 syscall 추가 예: openat2, clone3, pidfd_open 헤더: unistd.h, syscall 번호 테이블 ioctl 디바이스·서브시스템별 명령 확장 fd + 명령 번호 + 구조체 인자 예: DRM_IOCTL_*, VIDIOC_*, SIOCGIFFLAGS 헤더: drm.h, videodev2.h, sockios.h Netlink 소켓 기반 비동기 메시지 인터페이스 TLV 속성으로 점진적 확장 용이 예: NETLINK_ROUTE, NETLINK_GENERIC 헤더: netlink.h, rtnetlink.h, genetlink.h sysfs / procfs 가상 파일시스템 기반 텍스트 인터페이스 read/write로 상태 조회·설정 예: /proc/pid/status, /sys/class/net/*/mtu Documentation/ABI/에 형식 문서화 vDSO / vsyscall 커널 모드 전환 없는 빠른 경로 공유 메모리 매핑을 통해 데이터 제공 예: clock_gettime, gettimeofday, getcpu AT_SYSINFO_EHDR로 주소 전달 BPF (확장 인터페이스) bpf() syscall + 맵/프로그램 타입 커널 내부 동작을 사용자가 프로그래밍 예: BPF_MAP_TYPE_HASH, BPF_PROG_TYPE_XDP 헤더: bpf.h, btf.h 경로 선택 기준 단순 요청/응답 → syscall · ioctl | 비동기·점진 확장 → Netlink | 상태 노출 → sysfs/procfs 지연 시간 민감 → vDSO | 프로그래밍 가능 확장 → BPF | 디바이스 고유 명령 → ioctl
경로 통신 방식 확장 전략 ABI 안정성 부담
시스템 콜 동기 트랩, 레지스터 인자 새 번호 추가 (기존 번호 재사용 금지) 매우 높음 — 번호와 의미가 영구 고정
ioctl ioctl(fd, cmd, arg) 새 명령 번호 추가, 구조체에 size/flags 필드 높음 — 명령 번호와 구조체 크기 고정
Netlink 소켓 메시지, TLV 속성 새 속성 타입 추가 (기존 속성 의미 유지) 높음 — 속성 번호와 의미 고정, 점진 확장 용이
sysfs/procfs 가상 파일 read/write 새 파일 추가 (기존 형식 유지) 중간~높음 — 텍스트 형식이 사실상 ABI
vDSO 공유 메모리 매핑, 사용자 공간 함수 호출 새 심볼 추가 (기존 심볼 유지) 높음 — ELF 심볼과 데이터 레이아웃 고정
BPF bpf() syscall + 맵/프로그램 새 맵 타입, 프로그램 타입, 헬퍼 함수 추가 높음 — 맵 타입 번호, 헬퍼 번호 고정
설계 판단: 새 커널 기능을 추가할 때 ioctl과 Netlink 중 고민이 된다면, 속성이 자주 늘어나고 다중 객체를 다루는 경우 Netlink가 장기 확장성에서 유리합니다. 반면, 단일 디바이스에 대한 간단한 명령이라면 ioctl이 구현 복잡도를 낮춰줍니다.

make headers_install 파이프라인

커널 소스의 UAPI 헤더를 사용자 공간 프로그램이 직접 포함(#include)할 수 있는 형태로 가공하는 과정이 make headers_install입니다. 배포판이 linux-headers 패키지를 만들 때, 또는 크로스 컴파일 환경에서 사용자 공간 라이브러리를 빌드할 때 이 단계를 거칩니다.

make headers_install 파이프라인: 커널 UAPI 헤더에서 사용자 공간 배포용 헤더까지 make headers_install: UAPI 헤더를 사용자 공간 배포용으로 가공하는 파이프라인 커널 소스 include/uapi/linux/*.h include/uapi/asm-generic/*.h arch/*/include/uapi/asm/*.h ARCH 선택 ARCH=x86이면 arch/x86/include/uapi/asm/ 에서 asm/ 헤더 수집 unifdef 전처리 #ifdef __KERNEL__ 블록 제거 (커널 전용 코드 삭제) 사용자 공간 정의만 남김 설치 디렉터리 INSTALL_HDR_PATH/include/ linux/*.h asm/*.h, asm-generic/*.h 실행 명령과 주요 옵션 make headers_install ARCH=x86_64 INSTALL_HDR_PATH=/usr/include ARCH — 대상 아키텍처 (x86, arm64, riscv 등), 해당 arch/의 uapi/asm/ 헤더를 선택합니다 INSTALL_HDR_PATH — 출력 경로, 생략 시 usr/ (커널 소스 루트 아래) scripts/headers_install.sh — 실제 복사와 unifdef를 수행하는 스크립트 unifdef -U__KERNEL__ -D__EXPORTED_HEADERS__ — 커널 전용 블록 제거, 공개 선언만 남김 결과: glibc, musl, 사용자 프로그램이 #include <linux/bpf.h>처럼 사용할 수 있는 깨끗한 헤더
# 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
리뷰어 체크리스트: UAPI 패치를 리뷰할 때 다음 항목을 확인합니다: (1) 고정 폭 타입(__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를 택했는지에 따라 커널 경로가 달라집니다.

32비트와 64비트 UAPI 구조체 레이아웃 차이 같은 소스 선언이라도 ABI가 다르면 바이트 배치가 달라집니다 32비트 사용자 공간 offset 0: flags — 4바이트 offset 4: ptr — 4바이트 offset 8: len — 4바이트 총 12바이트 — 패딩 없음 포인터와 long이 4바이트이므로 정렬 패딩이 없습니다 64비트 사용자 공간 offset 0: flags — 4바이트 offset 4: 암묵적 패딩 — 4바이트 offset 8: ptr — 8바이트 offset 16: len — 8바이트 총 24바이트 — 패딩 4바이트 포함 8바이트 정렬을 위해 flags 뒤에 패딩이 삽입됩니다 같은 struct 선언 커널은 compat 변환 코드로 32비트 사용자 공간 구조체를 별도로 해석해야 합니다 8바이트 필드(ptr, len)의 높이가 4바이트 필드보다 큰 것에 주목하세요
/* 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];
};
compat 설계 원칙: 32비트 사용자 공간을 지원해야 하는 인터페이스라면 포인터를 그대로 구조체 필드로 노출하지 말고, 크기와 정렬이 고정된 정수 타입으로 운반한 뒤 커널에서 검증하는 편이 훨씬 안전합니다.

Y2038 문제와 time64 전환

ABI 호환성이 왜 까다로운지를 가장 극적으로 보여주는 사례가 Y2038 문제입니다. 전통적으로 리눅스(Linux)의 time_t는 32비트 부호 있는 정수(signed long)였으며, 1970년 1월 1일 자정(UTC)부터 초 단위로 경과 시간을 셉니다. 이 값은 2038년 1월 19일 03:14:07 UTC에 오버플로(Overflow)됩니다.

Y2038 문제: 32비트 time_t 오버플로와 time64 전환 32비트 time_t는 2038년에 오버플로됩니다 — 커널은 time64 ABI로 전환했습니다 1970 Epoch time_t = 0 2000 Y2K 2020 커널 5.6 time64 완료 2038-01-19 0x7FFFFFFF 32비트 오버플로 미래 64비트 time_t ~2920억 년 32비트 time_t 표현 범위 커널의 3단계 전환 전략 1단계: 새 시스템 콜 추가 clock_gettime64, futex_time64, ppoll_time64 등 time64 변형 추가 시스템 콜 번호는 새로 할당 구조체도 64비트 시간 필드 사용 2단계: libc 전환 glibc/musl이 time64 시스템 콜을 기본으로 사용하도록 전환 _TIME_BITS=64 컴파일 플래그 소스 재빌드 필요 (ABI 변경점) 3단계: 옛 경로 유지 32비트 time_t를 쓰는 옛 바이너리도 compat 경로를 통해 계속 실행 2038 이전까지는 정상 동작 기존 ABI를 깨뜨리지 않음 핵심: 새 시스템 콜을 추가하되 옛 시스템 콜도 유지함으로써 ABI 호환성을 보존합니다 이것이 "기존 의미를 바꾸지 않고 새 인터페이스를 추가한다"는 원칙의 실제 적용입니다
구분 옛 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);
}
임베디드 장비 주의: 수명이 긴 임베디드 장비(자동차, 산업 제어, 인프라)는 2038년 이후에도 동작해야 합니다. 이런 장비의 32비트 사용자 공간은 _TIME_BITS=64로 재빌드하거나, 아예 64비트 사용자 공간으로 전환해야 합니다. 커널은 이미 time64 경로를 제공하므로 사용자 공간의 전환만 남은 상태입니다.

커널의 compat 변환 구현

커널이 32비트 사용자 공간을 실제로 어떻게 처리하는지 구현 수준에서 살펴봅니다. 핵심은 compat 전용 시스템 콜 핸들러포인터/구조체 변환 헬퍼입니다.

compat 시스템 콜 디스패치

32비트 프로세스가 시스템 콜을 호출하면, 커널은 native 시스템 콜 테이블 대신 compat 시스템 콜 테이블을 참조합니다. x86_64에서 32비트 프로세스는 int 0x80이나 sysenter로 진입하며, 커널은 ia32_sys_call_table을 사용합니다.

native와 compat 시스템 콜 디스패치 경로 64비트 커널이 프로세스 종류에 따라 다른 시스템 콜 테이블을 선택합니다 64비트 프로세스 syscall x32 프로세스 syscall (bit 30) 32비트 프로세스 int 0x80 / sysenter sys_call_table sys_read, sys_write, ... x32_sys_call_table 64비트 코드 + 32비트 포인터 ia32_sys_call_table compat_sys_read, ... native 핸들러 64비트 구조체 직접 사용 native (x32) 포인터만 32비트 처리 compat 핸들러 32→64 변환 후 처리 VFS / driver 공통 구현 세 경로 모두 변환 후 같은 내부 구현(VFS, 드라이버)을 호출합니다 진입 명령어와 시스템 콜 테이블이 다를 뿐, 최종 처리 로직은 공유됩니다 x32 ABI는 64비트 레지스터를 사용하되 포인터만 32비트로 제한하는 절충 모델입니다

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));
}
in_compat_syscall() 패턴: 공통 핸들러 안에서 분기가 필요한 경우도 있습니다. 예를 들어 같은 ioctl 구현 내에서 in_compat_syscall()로 현재 경로를 확인한 뒤, 구조체 크기를 달리 읽는 방식입니다. 그러나 가능하면 처음부터 고정 폭 타입으로 설계해서 compat 변환 자체가 필요 없게 만드는 편이 바람직합니다.

ABI가 깨지는 전형적인 사례

ABI 파손은 빌드 오류보다 더 위험합니다. 소스가 다시 컴파일되면 곧바로 문제가 드러나는 API 파손과 달리, ABI 파손은 이미 배포된 프로그램이 조용히 잘못된 데이터를 읽거나, 잘못된 시스템 콜을 호출하거나, 특정 아키텍처에서만 실패하는 방식으로 나타납니다.

실수 어떤 문제가 생기는가 권장 대응
구조체 필드 순서 변경 기존 바이너리가 다른 필드를 같은 오프셋에서 읽습니다 기존 필드는 유지하고 새 필드는 뒤에 추가합니다
기존 플래그 의미 변경 같은 비트 값이 새 의미로 해석되어 동작이 바뀝니다 새 비트를 추가하고 옛 의미는 유지합니다
시스템 콜 인자 의미 변경 예전 프로그램이 정상 호출을 했는데 커널이 다른 의미로 처리합니다 새 시스템 콜이나 새 구조체 기반 인터페이스를 추가합니다
문서화된 텍스트 ABI 변경 운영 도구와 자동화 스크립트가 파싱에 실패합니다 기존 형식을 유지하고 새 필드를 뒤에 추가합니다
reserved 비트 무검증 미래 확장 시 옛 커널과 새 사용자 공간이 충돌합니다 모르는 비트가 들어오면 -EINVAL로 거절합니다
32비트 compat 미고려 64비트 환경에서는 되지만 32비트 사용자 공간에서 데이터가 잘립니다 고정 폭 타입과 compat 변환 경로를 설계 초기에 포함합니다
왜 조용히 위험한가: ABI 파손은 흔히 EINVAL이나 즉시 패닉으로 끝나지 않습니다. 사용자 공간이 잘못된 길이를 보내고 커널이 다른 구조체로 해석해도, 메모리 내용이 우연히 맞아 떨어지면 한동안 정상처럼 보일 수 있습니다. 그래서 ABI 검토는 새 기능 설계 단계에서 끝내야 합니다.

실제 ABI 파손 사례와 교훈

추상적인 규칙보다 실제 사건이 ABI 설계의 중요성을 더 잘 보여줍니다. 아래는 리눅스 커널 역사에서 ABI가 문제가 된 대표적인 사례입니다.

리눅스 커널 역사에서의 대표적 ABI 사건과 교훈 ABI를 둘러싼 대표적인 사건 — 모두 "사용자 공간을 깨뜨리지 않는다"는 원칙과 연결됩니다 사례 1: stat64 구조체 패딩 문제 초기 32비트 리눅스에서 struct stat의 파일 크기 필드가 32비트로 제한되어 2GB 이상 파일을 표현할 수 없었습니다. 교훈: 기존 stat을 수정하지 않고 stat64 시스템 콜을 새로 추가했습니다. 옛 바이너리는 기존 stat을 계속 사용할 수 있었습니다. 사례 2: open → openat → openat2 진화 open()에 기능을 추가할 때마다 플래그 공간이 부족해졌고, 디렉터리 fd 기준 경로가 필요해지면서 openat이 등장했습니다. 교훈: openat2는 구조체 기반 + size 필드 패턴으로 설계하여 copy_struct_from_user 확장 패턴의 모범 사례가 되었습니다. 사례 3: clone → clone3 전환 clone() 시스템 콜은 플래그가 계속 추가되면서 레지스터 인자만으로는 확장이 불가능해졌습니다. 교훈: clone3은 구조체 포인터 + size 인자로 전환하여 새 기능(cgroup fd 등)을 안전하게 추가할 수 있게 되었습니다. 사례 4: sysfs 출력 형식 변경 되돌림 sysfs 속성 파일의 출력 형식을 개선하려 한 패치가 사용자 공간 파싱 스크립트를 깨뜨려 즉시 되돌려졌습니다. 교훈: 텍스트 출력도 ABI입니다. 형식이 정해지면 바꿀 수 없으며, 새 정보는 새 속성 파일로 제공해야 합니다. 반복되는 공통 패턴 1. 기존 인터페이스의 인자·구조체 공간이 부족해집니다 2. 기존 인터페이스를 수정하면 배포된 바이너리가 깨집니다 3. 해법: 새 시스템 콜이나 새 속성을 추가하고, 옛 경로는 그대로 유지합니다 처음부터 size/flags/reserved 패턴으로 설계하면 이 순환을 줄일 수 있습니다
사례 문제 원인 해결 패턴 현재 상태
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 구조체를 설계할 때 반복해서 확인하는 규칙은 대체로 비슷합니다. 핵심은 "확장 가능하게 만들되, 기존 의미를 절대 재사용하지 않는다"입니다.

  1. 기존 인터페이스 의미를 바꾸지 않습니다. 기능 확장이 필요하면 새 시스템 콜이나 새 플래그, 새 Netlink 속성을 추가합니다.
  2. 구조체 기반 인터페이스에는 size 필드를 둡니다. 커널과 사용자 공간이 서로 어느 버전까지 알고 있는지 안전하게 판단할 수 있습니다.
  3. flags와 reserved 필드를 둡니다. 확장 비트를 위한 공간을 미리 확보하고, 모르는 비트는 거절합니다.
  4. 고정 폭 타입을 사용합니다. UAPI는 int, long, 포인터 크기에 기대지 않는 쪽이 좋습니다.
  5. 복잡한 가변 속성에는 Netlink를 우선 검토합니다. 큰 구조체를 한 번에 굳히는 것보다 점진 확장성이 좋습니다.
  6. 디버깅 인터페이스와 안정 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의 일부입니다.

ELF 보조 벡터와 vDSO의 역할: 프로세스 시작 시 커널이 전달하는 ABI 정보 커널은 프로세스 시작 시 보조 벡터와 vDSO를 통해 런타임 ABI 정보를 전달합니다 커널 (execve) ELF 로드 스택 초기화 vDSO 매핑 배치 프로세스 초기 스택 argc, argv[], envp[] 보조 벡터 (auxv) AT_SYSINFO_EHDR → vDSO 주소 AT_PAGESZ → 페이지 크기 AT_HWCAP → CPU 기능 비트맵 AT_RANDOM → 16바이트 난수 AT_NULL (종료 표시) ↑ 높은 주소 (스택 바닥) ↓ 낮은 주소 (스택 성장 방향) 참조 vDSO (가상 공유 객체) 커널이 사용자 주소 공간에 매핑하는 읽기 전용 ELF 공유 라이브러리 clock_gettime() — 커널 진입 없이 시간 조회 gettimeofday() — 레거시 호환 시간 조회 getcpu() — 현재 CPU 번호 조회 libc (glibc / musl) vDSO를 자동 감지하여 빠른 경로 사용 vDSO와 보조 벡터가 ABI인 이유 보조 벡터의 키(AT_xxx) 값과 의미는 커널이 보장하는 ABI입니다. 새 키를 추가할 수는 있지만 기존 키를 제거하거나 의미를 바꿀 수 없습니다. vDSO의 심볼 이름(__vdso_clock_gettime 등)과 호출 규약도 ABI입니다. libc가 이 심볼에 의존하기 때문입니다. AT_HWCAP 비트맵은 CPU 기능 탐지에 사용되며, 비트 할당도 아키텍처별로 고정된 ABI입니다. ARM64에서는 MTE, SVE, SME 등 새 기능이 추가될 때마다 AT_HWCAP/AT_HWCAP2에 비트가 할당됩니다.
보조 벡터 키 전달하는 정보 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)-*
성능과 ABI: vDSO의 핵심 가치는 clock_gettime() 같은 고빈도 호출에서 커널 모드 전환 비용을 제거하는 것입니다. 커널은 공유 페이지에 시간 데이터를 주기적으로 갱신하고, vDSO 코드는 이 데이터를 사용자 모드에서 직접 읽습니다. 이 구현 세부사항은 변경될 수 있지만, vDSO 심볼의 이름과 호출 규약은 ABI로 유지됩니다.

copy_struct_from_user 확장 패턴

커널 5.4 이후 도입된 copy_struct_from_user()는 size 필드 기반 확장 패턴의 표준 구현입니다. 이 함수는 사용자 공간이 보내는 구조체가 커널보다 작을 수도, 클 수도 있는 상황을 안전하게 처리합니다.

copy_struct_from_user의 forward/backward compatibility 처리 사용자 공간과 커널의 구조체 버전이 달라도 안전하게 동작합니다 사례 1: 옛 사용자 + 새 커널 flags (사용자가 보냄) fd (사용자가 보냄) mode → 커널이 0으로 채움 reserved → 커널이 0으로 채움 새 필드는 기본값(0)으로 동작 사례 2: 같은 버전 flags fd mode reserved 전체 복사 — 정상 경로 사례 3: 새 사용자 + 옛 커널 flags fd mode (커널이 아는 범위) priority → 커널이 모름 초과분이 0이면 성공, 아니면 EINVAL 확장 확장 copy_struct_from_user() 동작 규칙 usize < ksize → 사용자 데이터 복사, 나머지 0 채움 (backward compatibility) usize == ksize → 전체 복사 (정상 경로) usize > ksize → 커널이 아는 만큼 복사, 초과분 0 검증 (forward compatibility) 핵심: 새 필드의 기본값이 0이어야 이 패턴이 동작합니다 각 규칙의 색상은 위 3개 사례와 대응됩니다
/* 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로 구조체 크기를 확인했을 때 예상보다 큰 패딩이 보이면, 사용자 공간과 커널이 같은 선언을 보고도 서로 다른 오프셋을 기대하고 있을 가능성을 의심해야 합니다.

실전 순서: 1) 바이너리 비트수 확인, 2) 실제 시스템 콜과 인자 확인, 3) UAPI 구조체 크기와 오프셋 확인, 4) compat 경로 존재 여부 확인 순으로 좁혀 가면 대부분의 ABI 문제를 빠르게 분리할 수 있습니다.

참고자료