커널에 새 시스템 콜이 들어와도 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()의 반환값을 음수 에러 코드로 읽지 말 것. glibcsyscall()은 실패하면 -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 저장소에 요청하면 된다.