glibc에 래퍼가 없는 리눅스 시스템 콜 — syscall()로 futex_waitv() 호출하기

커널에 새 시스템 콜이 들어와도 glibc에 래퍼 함수가 없으면 C에서 일반 함수처럼 부를 수 없다. 이때는 syscall(SYS_xxx, ...)로 번호와 인자를 직접 넘긴다. 커널 5.16에 들어온 futex_waitv()도 아직 래퍼가 없다. LPC 2026에서 glibc 메인테이너 카를로스 오도넬(Carlos O’Donell)과 플로리안 바이머(Florian Weimer)가 이 문제의 진행 상황을 발표했다. 이 글에서는 래퍼가 없는 시스템 콜을 직접 세고, syscall() 호출 예제와 glibc 팀의 계획을 정리한다.

래퍼가 없는 시스템 콜 세기

발표 기준 x86-64 시스템 콜 386개 중 77개에 래퍼가 없다. 커널 헤더의 __NR_* 목록과 libc.so.6 동적 심볼을 비교하면 직접 셀 수 있다.

H=/usr/include/x86_64-linux-gnu/asm/unistd_64.h
grep -oP '^#define __NR_\K\w+' $H | sort -u > syscalls.txt
nm -D --defined-only /lib/x86_64-linux-gnu/libc.so.6 \
  | awk '{print $3}' | sed 's/@.*//' | sort -u > libc_syms.txt
comm -12 syscalls.txt libc_syms.txt > wrapped.txt
comm -23 syscalls.txt libc_syms.txt > nowrap.txt
wc -l syscalls.txt wrapped.txt nowrap.txt
 373 syscalls.txt
 288 wrapped.txt
  85 nowrap.txt

Ubuntu 24.04(glibc 2.39, 커널 헤더 6.8) 기준 nowrap.txt다. 이름만 비교하므로 rt_sigaction처럼 다른 이름으로 감싼 것도 들어간다.

_sysctl          futex_requeue      io_uring_register        lsm_set_self_attr  rseq                     set_robust_list
add_key          futex_wait         io_uring_setup           map_shadow_stack   rt_sigaction             set_thread_area
afs_syscall      futex_waitv        ioprio_get               mbind              rt_sigpending            set_tid_address
bpf              futex_wake         ioprio_set               membarrier         rt_sigprocmask           signalfd4
cachestat        get_mempolicy      kcmp                     memfd_secret       rt_sigqueueinfo          statmount
clone3           get_robust_list    kexec_file_load          migrate_pages      rt_sigreturn             sysfs
epoll_ctl_old    get_thread_area    kexec_load               move_pages         rt_sigsuspend            tkill
epoll_wait_old   getdents           keyctl                   mq_getsetattr      rt_sigtimedwait          tuxcall
eventfd2         io_cancel          landlock_add_rule        newfstatat         rt_tgsigqueueinfo        userfaultfd
exit_group       io_destroy         landlock_create_ruleset  openat2            sched_getattr            vserver
faccessat2       io_getevents       landlock_restrict_self   perf_event_open    sched_setattr
fadvise64        io_pgetevents      listmount                pselect6           seccomp
fchmodat2        io_setup           lookup_dcookie           quotactl_fd        security
finit_module     io_submit          lsm_get_self_attr        request_key        set_mempolicy
futex            io_uring_enter     lsm_list_modules         restart_syscall    set_mempolicy_home_node

래퍼가 없는 함수를 그대로 부르면 컴파일이 실패한다.

#define _GNU_SOURCE
#include <unistd.h>
int main(void) { return futex_waitv(0, 0, 0, 0, 0); }
$ gcc -Wall -Werror nowrap.c -o nowrap
nowrap.c: In function ‘main’:
nowrap.c:3:25: error: implicit declaration of function ‘futex_waitv’ [-Werror=implicit-function-declaration]

syscall()로 futex_waitv() 호출하기

futex_waitv()는 여러 futex를 한 번에 기다린다. Wine/Proton이 Windows WaitForMultipleObjects()를 에뮬레이션할 때 쓴다.

#define _GNU_SOURCE
#include <errno.h>
#include <linux/futex.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>

static uint32_t f[3];   /* 기다릴 futex 3개 */

/* glibc에 래퍼가 없으므로 직접 만든다 */
static long my_futex_waitv(struct futex_waitv *w, unsigned int n,
                           const struct timespec *abs, clockid_t clk)
{
    return syscall(SYS_futex_waitv, w, n, 0, abs, clk);
}

static void *waker(void *arg)
{
    int idx = (int)(intptr_t)arg;
    usleep(100 * 1000);
    __atomic_store_n(&f[idx], 1, __ATOMIC_RELEASE);
    syscall(SYS_futex, &f[idx], FUTEX_WAKE_PRIVATE, 1, NULL, NULL, 0);
    return NULL;
}

int main(void)
{
    struct futex_waitv w[3];
    for (int i = 0; i < 3; i++) {
        memset(&w[i], 0, sizeof(w[i]));
        w[i].uaddr = (uintptr_t)&f[i];
        w[i].val = 0;                          /* 값이 0일 때만 잠든다 */
        w[i].flags = FUTEX2_SIZE_U32 | FUTEX2_PRIVATE;
    }

    pthread_t t;
    pthread_create(&t, NULL, waker, (void *)(intptr_t)2);

    struct timespec abs;
    clock_gettime(CLOCK_MONOTONIC, &abs);
    abs.tv_sec += 1;                           /* 절대 시각 타임아웃 */

    long ret = my_futex_waitv(w, 3, &abs, CLOCK_MONOTONIC);
    if (ret < 0)
        printf("futex_waitv: %s\n", strerror(errno));
    else
        printf("깨어난 futex 인덱스: %ld\n", ret);

    pthread_join(t, NULL);

    /* 아무도 깨우지 않으면 타임아웃 */
    f[2] = 0;
    clock_gettime(CLOCK_MONOTONIC, &abs);
    abs.tv_nsec += 200 * 1000 * 1000;
    if (abs.tv_nsec >= 1000000000) { abs.tv_sec++; abs.tv_nsec -= 1000000000; }
    ret = my_futex_waitv(w, 3, &abs, CLOCK_MONOTONIC);
    printf("두 번째 호출: ret=%ld errno=%s\n", ret, strerrorname_np(errno));
    return 0;
}
$ gcc -Wall -O2 waitv.c -o waitv -pthread && ./waitv
깨어난 futex 인덱스: 2
두 번째 호출: ret=-1 errno=ETIMEDOUT

타임아웃은 상대 시간이 아니라 clk 기준 절대 시각이다. 래퍼가 없으면 컴파일러가 이런 인자 의미를 검사하지 못한다.

ENOSYS가 두 가지 뜻을 갖는 문제

커널에 없는 시스템 콜은 ENOSYS를 반환한다. membarrier()는 지원 명령을 조회하는 QUERY 명령을 따로 제공한다.

#define _GNU_SOURCE
#include <errno.h>
#include <linux/membarrier.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(void)
{
    /* 1) 커널에 없는 번호는 ENOSYS */
    long r = syscall(999);
    printf("syscall(999)      = %ld, errno=%s\n", r, strerrorname_np(errno));

    /* 2) membarrier: 지원 명령을 비트마스크로 돌려준다 */
    r = syscall(SYS_membarrier, MEMBARRIER_CMD_QUERY, 0, 0);
    printf("membarrier QUERY  = 0x%lx\n", r);
    printf("  PRIVATE_EXPEDITED 지원: %s\n",
           (r & MEMBARRIER_CMD_PRIVATE_EXPEDITED) ? "yes" : "no");

    /* 3) 등록 전에 PRIVATE_EXPEDITED를 쓰면 EPERM */
    r = syscall(SYS_membarrier, MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0);
    printf("등록 전 호출      = %ld, errno=%s\n", r, strerrorname_np(errno));
    syscall(SYS_membarrier, MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0);
    r = syscall(SYS_membarrier, MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0);
    printf("등록 후 호출      = %ld\n", r);
    return 0;
}
syscall(999)      = -1, errno=ENOSYS
membarrier QUERY  = 0x3ff
  PRIVATE_EXPEDITED 지원: yes
등록 전 호출      = -1, errno=EPERM
등록 후 호출      = 0

문제는 seccomp 필터도 ENOSYS를 반환할 수 있다는 점이다. 아래 필터는 membarrier()만 막는다.

#define _GNU_SOURCE
#include <errno.h>
#include <linux/filter.h>
#include <linux/membarrier.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(void)
{
    /* membarrier만 ENOSYS로 막는 필터 (아키텍처 검사는 생략한 예제) */
    struct sock_filter f[] = {
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_membarrier, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | ENOSYS),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    };
    struct sock_fprog prog = { .len = 4, .filter = f };

    printf("필터 전: %ld\n", syscall(SYS_membarrier, MEMBARRIER_CMD_QUERY, 0, 0));
    prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
    prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
    long r = syscall(SYS_membarrier, MEMBARRIER_CMD_QUERY, 0, 0);
    printf("필터 후: %ld, errno=%s\n", r, strerrorname_np(errno));
    return 0;
}
필터 전: 1023
필터 후: -1, errno=ENOSYS

호출한 쪽은 커널에 기능이 없는지, seccomp가 막았는지 구분하지 못한다. 바이머는 prctl()로 섀도 스택은 켜졌는데 map_shadow_stack()만 막힌 사례를 들었다.

glibc 팀의 우선순위

오도넬, 바이머, 아데메르발 자넬라(Adhemerval Zanella)는 래퍼가 없는 시스템 콜을 네 그룹으로 나눴다.

분류예방침
핵심 커널 기능futex_waitv(), membarrier(), rseq_slice_yield(), map_shadow_stack()최우선. 앙드레 알메이다(André Almeida)의 futex_waitv() RFC 진행 중
도메인 전용 기능io_uring 계열liburing 같은 전용 라이브러리가 있으므로 래퍼를 만들지 않는다
C 라이브러리 구현용get_robust_list(), set_robust_list()libc 내부에서 직접 부른다. 공개 래퍼는 만들지 않는다
사용하지 않는 것_sysctl, tuxcall, vserver구현하지 않는다

알메이다는 다음 단계로 pthread_wait_on_multiple() 같은 고수준 API를 원한다. 커널 쪽 작업은 끝났고 glibc 인터페이스만 남았다.

새 범용 래퍼와 그 밖의 제안

바이머는 우선순위 작업 뒤에는 개별 래퍼 대신 범용 syscall()을 세 가지로 나눠 제공할 계획이다. 레지스터 타입도 long 대신 아키텍처에 맞는 타입으로 바꾼다.

래퍼용도
_syscall()일반 시스템 콜
_syscallr()vfork()처럼 두 번 반환하는 호출. GCC/Clang이 잘못 최적화하지 않도록 속성을 붙인다
_syscallc()스레드 취소(cancellation)가 가능한 호출. 취소 핸들러를 실행하고 스레드를 종료한다

토론에서 나온 다른 제안은 다음과 같다.

제안자내용
피터 앤빈(H. Peter Anvin)커널이 시스템 콜 목록을 알려 주면 IFUNC로 구현을 고를 수 있다. 커널이 단순한 시스템 콜용 라이브러리를 직접 제공하자
오도넬BSD처럼 커널이 시스템 콜 DSO를 제공하고 libc DSO의 필터로 쓰는 방식을 검토하자
키스 쿡(Kees Cook)seccomp가 내부에 가진 “항상 허용/항상 거부/필터 있음” 비트맵을 libc에 노출할 수 있다
스티븐 로스테드(Steven Rostedt)1990년대 호환용 레거시 시스템 콜을 커널 설정으로 뺄 수 있게 하자

주의사항

  • syscall()의 반환값을 음수 에러 코드로 읽지 말 것. glibc syscall()은 실패하면 -1을 반환하고 errno를 설정한다.
  • ENOSYS만 보고 커널 미지원으로 단정하지 말 것. 컨테이너 안에서는 seccomp 필터일 수 있다.
  • 타임아웃 인자의 의미를 man page나 커널 소스로 확인할 것. futex_waitv()는 절대 시각을, FUTEX_WAIT는 상대 시간을 받는다.
  • 32비트 아키텍처에서 futex()를 직접 부르지 말 것. 아키텍처에 따라 futex_time64()가 필요하고, 32비트 시간용 futex()는 제거될 예정이다.
  • glibc 헤더와 커널 UAPI 헤더의 심볼이 충돌하면 래퍼 코드를 별도 파일로 분리할 것. 두 헤더가 같은 이름을 정의하는 경우가 있다.

마무리

래퍼가 없는 시스템 콜도 syscall()로 쓸 수 있지만 타입 검사가 없다. glibc 팀은 futex_waitv()부터 래퍼를 추가한다. 필요한 시스템 콜은 UAPI 그룹의 kernel-features 저장소에 요청하면 된다.

참고

답글 남기기