Linux 시스템 콜 — 커널 경계, 동작 원리, 분류, strace·ltrace, errno, vDSO, seccomp
이 글의 핵심
시스템 콜은 애플리케이션이 권한과 자원(파일·프로세스·소켓·메모리)에 접근하는 유일한 정식 경로입니다. 커널로의 진입·래퍼·errno·관측(strace)과, 최소 권한(seccomp)까지 한 흐름으로 이해하면 “왜 이 호출이 실패했는지”를 정확히 설명할 수 있습니다.
들어가며 — Linux 시리즈 #06, 근데 이번엔 반말
이번 글은 Linux 시리즈 여섯 번째고, 프로세스·스케줄러랑 메모리 쪽을 이미 봤다면 여기서는 “앱이 커널한테 부탁하는 한 줄, 한 줄”인 시스템 콜 축을 잡는 거다. Linux 완전 가이드에서 봤던 VFS·인터페이스 느낌이랑 이어지면 훨씬 덜 헤맨다.
아래 흐름은 이렇게 갈게. 먼저 strace로 밤을 새웠던(혹은 새울) 이야기부터 찍고, 그다음에야 “시스템 콜이 뭔지”를 정리한다. 그리고 나서 내 주장: 시스템 콜 번호 직접 쑤셔 넣는 코드, 일반 응용에서는 쓰지 마. 측은 하고 싶다고 다 되는 거 아님. 그 뒤에 동작, errno, vDSO, seccomp, 예제, 디버깅 쪽으로 이어진다.
1. strace로 밤새워 디버깅했던 이야기
한 번은 프로덕션에선 가끔 터지는데, 로컬에선 재현이 1%도 안 되는 그런 장애가 있었어. 로그엔 “timeout” 뭐 이런 식만 맴돌고, 스택은 애매하게 올라가 있고. 그때 쓴 게 strace다. strace -f -T 붙이고, epoll_wait가 얼마나 눌러 앉는지, read가 어디서 잠기는지, openat이 ENOENT를 몇 번 찍는지, 한 줄씩 쫓다 보면 “아 이거 경로” 혹은 “아 이거 락” 같은 게 보이기 시작해. 말 그대로 strace로 밤새워 디버깅해본 적 있을 정도로, 나한텐 “앱이 아니라 커널한테 뭐라고 했는지”를 보는 도구다.
strace는 대충 말해 ptrace 쪽 느낌으로 프로세스가 실제로 어떤 시스템 콜에 어떤 인자로 들어갔는지, 에러/지연/리턴이 뭔지를 시간순으로 뽑아 준다. “코드는 멀쩡한데” 할 때, 권한·경로·락·쿼터 때문에 막힌 케이스는 openat·futex·epoll 패턴이 결정적 힌트를 준다.
자주 쓰는 건 대충 이런 것들. 그냥 strace ./앱 기본, 누적은 strace -c로 “어떤 syscall이 횟수/시간을 씹는지” 보기. 노이즈 줄이고 싶으면 strace -e trace=openat,read,write 식으로 좁혀. fork·exec·멀티스레드 쓰면 -f 없으면 자식/스레드 쪽이 빠져서 “부모만 이상하게 조용”해지는 사고가 나니까, 그런 앱이면 -f부터 의심해. 운영에선 strace -f -T -p PID로 붙이기도 하는데, 비밀(토큰, SQL, 쿠키)이 그대로 로그에 터질 수 있고 부하도 크니까 정책/승인 체크하고, 프로덕션에선 샘플링·시간 제한이랑 같이 써.
해석은 경험인데, ENOENT·EACCES는 코드보다 경로/권한/SELinux/마운트 쪽을 먼저 보라는 신호다. futex+짧은 epoll_wait 난사는 뮤텍스 경합·이벤트 루프 쪽(단, 오독 주의). EINTR 뜨면 시그널/타이머랑 애플 재시도 설계를 같이 봐야 하고, 한 번의 read에 지연이 크면 네트워크/디스크/프록시 쪽 의심해 볼 수 있어. 그리고 strace·과도한 ptrace는 개발/장애에선 좋은데, 초고부하·초지연 환경에선 부하로 보일 수 있으니, 그럴 땐 perf나 eBPF 쪽 샘플링 루트를 생각하라. 이건 “관찰 도구가 다시 스케줄/콜에 간섭한다”는 딜레마다.
ltrace는 라이브러리 경계(malloc, fwrite 같은)를 더 가깝게 본다. I/O가 fwrite로 묶이면 strace의 write 횟수랑 1:1이 아닐 수 있고, malloc 스팸은 힙/할당기 쪽 힌트. 보안/운영에선 LD_PRELOAD 류랑 결이 비슷해서 권한·hardening은 같이 봐.
2. 그래서 시스템 콜(syscall)이 뭐냐
시스템 콜은 유닉스/리눅스에서 일반 응용이 “디스크, 소켓, 다른 프로세스 메모리” 같은 걸 정식으로 손대게 해 주는 커널이 약속한 요청이다. C 라이브러리 함수랑은 다르다. read(2)라고 말해도, 실제론 glibc 래퍼가 ABI 맞추고 errno 정리하고, strace엔 커널이 받는 이름·인자가 찍힌다. open이 strace엔 openat로 보이는 것도 흔하고, “함수 1번 = syscall 1번”은 항상 아니다.
앱은(대충) 사용자 모드에서 돌다가, 파일을 열거나 소켓을 쓰려면 커널 모드 쪽 권한·검사·VFS/네트워크/메모리 서브시스템을 거쳐야 한다. 그 문이 시스템 콜이고, 전환엔 비용이 붙는다(나노초~μs+). 그래서 핫 루프에서 gettimeofday를 미친 듯이 부르는 짓은, 오히려 지터만 키울 수 있다. 반대로 정상적인 I/O는 반드시 이 경계를 건너야 하고, “빨리” 한다고 경계를 우회하려다 보면 보안·이식·관측 쪽에서 장기로 터진다.
과거 32비트 x86은 int 0x80 같은 소프트웨어 인터럽트 비유로 많이 가르쳤고, x86-64 리눅스는 보통 syscall 명령이 중심, AArch64는 svc 계열이 그 역할을 한다(세부는 ABI/문서). 요지는 사용자 코드가 번호랑 인자를 ABI대로 넣고, CPU가 특권 전환을 일으키면 커널이 시스템 콜 테이블로 핸들러에 붙는다는 것.
시스템 콜 번호는 아키텍처마다 다른 숫자 공간이다. “리눅스 전체 공통 상수”라고 믿지 마. C에선 syscall(SYS_read, …)로 직접 부르거나(libc가 줌), __NR_… 상수를 쓰는 식이고, x86-64랑 arm64는 같은 이름이어도 번호가 다를 수 있다. 그래서 일반 앱은 SYS_… / __NR_… / libc를 쓰고, 숫자를 손으로 박아넣는 raw syscall은 eBPF, 최소 루트, 특수 런타임 같은 제한된 맥락에서나 합리적인 경우가 많다. 시스템 콜 직접 쓰지 마 — 라고 말해도 될 정도다. (교육/실험 syscall() 예제는 아래에 남겨둔다. 그건 학습용.)
한 번의 read가 소켓에서 블로킹이면, 사용자 공간 코어는 잠시 멈추고 커널 쪽 태스크 상태·스케줄 이슈(#05)와 겹쳐 보인다. 하드웨어 인터럽트(디스크, NIC)랑, 사용자가 커널에 요청하는 시스템 콜이랑은 축이 다르다. preempt가 sys_read가 길다고 해서 곧장 “다른 프로세스가 그 read를 가로챈다”로 단순화되진 않는다. 락·끊김 가능 지점·병행성이 같이 와야 한다.
flowchart LR A[userspace: libc 래퍼] -->|ABI 레지스터 배치| B[특권 전환 + syscall 진입] B --> C[번호로 테이블 조회] C --> D[VFS/네트워크/메모리/스케줄...] D -->|성공/실패| E[userspace: errno/반환]
3. 주요 콜들 — 파일, 프로세스, 메모리, 네트워크, 시그널 (대충 맵)
파일 I/O는 fd가 심장이다. open/openat는 VFS·경로·권한·O_NOFOLLOW·O_CLOEXEC 해석, read/write는 부분 읽기/쓰기가 당연하다고 봐야 하고(파이프·터미널·시그널·논블로킹), close는 fd 재사용·dup2·exec랑 잘 엮인다. “작은 write를 초당 수만 번”이면 콜 비용 + 문맥 전환이 쌓이니까 버퍼링·배치·Nagle 쪽이랑 같이 봐.
프로세스는 fork·COW(복사-온-라이트) 이야기는 메모리 글이랑 연결, execve는 주소공간을 갈아엎는 것, waitpid 쪽이 좀비 방지, exit(3) vs _exit(2)는 C 정리(atexit) vs 커널 쪽. 자세한 건 #05.
메모리는 brk/sbrk 힙 끝, mmap/munmap 파일/익명 매핑, mprotect는 JIT·샌드박스. #06.
소켓은 socket → bind → listen → accept → connect·send·recv·epoll·io_uring 쪽 이벤트 루프랑 이어짐. 대규모 I/O는 I/O 다중화도 같이.
시그널은 kill이 그냥 “죽이기”만은 아니고, 새 코드는 sigaction 쪽, EINTR·SA_RESTART 한계는 man이랑 같이 봐. 시그널 9(D 상태 등) 느린 건 “커널 어디에서 어떤 대기”에 잠겼냐 문제다.
poll·epoll·eventfd·inotify도 전부 “여러 fd를 한 번에 기다리는” 축이고, 효율은 콜 횟수·빈 루프·큐랑 같이 정해진다. pipe·dup2는 셸 파이프라인, ioctl은 터미널·디바이스·소켓·드라이버 쪽 “만능 스위치”라 strace에 ioctl이 폭주하면 누가 어떤 cmd로 뭔지 좁혀 봐.
4. glibc 래퍼랑 아키텍처 차이
glibc 래퍼는 인자/레지스터 배치(ABI) 준비하고 raw syscall 진입하고, -1/errno로 정리해 준다. 그래서 같은 read도 라이브러리 버전/빌드에 “주변” 동작이 조금 달라질 수는 있다.
x86-64 쪽 느낌만: 번호는 rax, 인자는 rdi, rsi, rdx, r10, r8, r9 — 4번째가 r10인 이유가 ABI/커널 쪽 히스토리다. syscall 경로에선 rcx, r11이 자주 clobber라서 인라인 asm·JIT은 조심. AArch64는 번호 x8, 인자 x0–x5 류로 기억하면 된다(세부는 헤더/문서).
표로 암기하려고 하지 말고, “왜 r10이 있지, rcx는 왜 위험하지”가 디버그·eBPF tracepoint 잡을 때 감이 된다. 최적화한다고 직접 syscall을 섞는 건 성능 1% vs 휴먼 에러 99%로 자주 끝난다 — 내 취향은 기본은 libc, 특수 케이스만 따로.
syscall(2)로 직접 포워딩하는 최소 예 (실서비스에 복붙 X)
unistd.h + sys/syscall.h에서:
#include <sys/syscall.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
int main(void) {
int fd = syscall(SYS_openat, AT_FDCWD, "/tmp/syscall-smoke.txt",
O_WRONLY|O_CREAT|O_TRUNC, 0644);
if (fd < 0) {
return 1; /* errno 설정됨 */
}
const char msg[] = "from syscall() path\n";
(void) syscall(SYS_write, fd, msg, (long)(sizeof msg - 1));
(void) syscall(SYS_close, fd);
return 0;
}
요즘은 open보다 openat 쪽 흐름이 흔하다(컨테이너·O_TMPFILE 등). 응용 코드에선 open/read 래퍼 쓰는 게 정신 건강에 좋다. 위는 “커널이 뭘 받는지”를 한 번 눈에 넣는 용도.
5. errno — 스레드 로컬 “마지막 실패 힌트”
errno는 대충 스레드 로컬이고(구현/플랫폼), 성공 직후엔 0이라 안심했다가 재사용에 걸릴 수 있으니 read/open 직후 바로 검사/저장하는 습관이 중요하다. read/write는 -1 / 0(EOF) / 양수(부분) 패턴이고, 완성 합은 루프로 쌓는다:
#include <errno.h>
#include <unistd.h>
/* 완성할 때까지 반복(인터럽트·부분쓰기·일부 EAGAIN은 상황에 맞게) */
static ssize_t write_all(int fd, const void *buf, size_t len) {
const char *p = (const char *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, p + off, len - off);
if (n < 0) {
if (errno == EINTR) continue; /* SA_RESTART·환경에 따라 다름 */
return -1;
}
if (n == 0) return (ssize_t)off;
off += (size_t)n;
}
return (ssize_t)off;
}
perror/strerror는 개발·로그엔 좋고, 운영 파이프라인에선 에러 정규화가 따로 필요할 때가 많다.
6. 성능, vDSO, io_uring — “콜 수만 줄이면 끝”은 아님
문맥 전환+커널 경로+캐시 미스, 잦은 read/write/getsockopt가 초고빈도일 때 병목이 될 수 있다. 그래서 배치(readv/writev·sendmmsg·io_uring·앱 버퍼), epoll/uring으로 대기·깨움 설계, page cache 친화 같은 게 나온다. 근데 콜 수만 줄인다고 처리량이 항상 따라오진 않는다. TCP 윈도우·앱 락·GC·원격 DB·디스크가 지배하면 syscall 튜닝은 이득이 제한적이다. 측정(strace -c, perf, SLO)이 먼저.
vDSO는 커널이 사용자 공간에 매핑해 주는 작은 공유 객체(linux-vdso*.so.1로 보이기도)고, clock_gettime·gettimeofday 류가 vDSO 경로로 빨리 끝나는 환경이 있으면 microbench가 엄청 민감해진다. 핵심은, vDSO가 모든 I/O를 없앤다는 게 아님 — 파일/소켓 본질은 그대로 커널이다. KPTI·retpoline·취약점 완화 쪽이 바뀌면 측정 절대값도 흔들린다.
io_uring은(5.1+ 쪽) 제출/완료 큐로 빈번한 개별 read/write를 묶는 경로다. “시스템 콜이 0”에 가깝다는 민담이 돌곤 하는데, 실질은 초기화·권한·배치 루프·부분 완료·에러가 그대로다. strace/perf로 “진짜 병목이 syscall인지, IO/하드/프로토콜인지” 먼저 갈라.
7. seccomp — “이 프로세스는 이 콜만” (근데 틀리면 SIGSYS)
seccomp는 프로세스가 쓸 수 있는 시스템 콜 집합을 필터(BPF)로 제한하는 거다(컨테이너·크롬·K8s securityContext·OCI 런타임). 허용/거부/킬/로그·user_notif 같은 고급 경로, 필수 콜 누락은 SIGSYS로 딱 끊길 수 있고, “조용한 실패”가 아닌 경우가 많다. exec 뒤 상속/재설정·no_new_privs랑 겹치면 “갑자기 openat가 막혔다” 류 사고로 간다. 최소 권한은 보안뿐 아니라 실수 반경 줄이는 설계이기도 하다. prctl(PR_SET_NO_NEW_PRIVS, …)는 LSM·seccomp 조합이랑 엮이니까, 도커/K8s 옵션만 보면 “같은 앱, 다른 SIGSYS”가 날 수 있어. 정상 워크로드에서 strace로 필수 syscall 집합을 모아 최소 허용으로 수렴시키는 반복이 엔지니어링적으로는 현실적이다(롤백·합의·내부 정책은 별도).
8. 실전 C — SYS_openat로 때려보기 (또: 이걸로 서비스 짜지 마)
#include <linux/unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
int main(void) {
int fd;
const char *path = "/tmp/seccomp-example.txt";
const char *text = "hello via SYS_openat/write/close + errno path\n";
fd = syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
(void) fprintf(stderr, "openat: %s\n", strerror(errno));
return 1;
}
ssize_t n = syscall(SYS_write, fd, text, (long) strlen(text));
if (n < 0) {
(void) fprintf(stderr, "write: %s\n", strerror(errno));
(void) syscall(SYS_close, fd);
return 1;
}
(void) syscall(SYS_close, fd);
return 0;
}
빌드: cc -O2 -Wall -Wextra -o t t.c && ./t && strace -c ./t 해보면, 네가 안 짠 mprotect·set_tid_address 같은 런타임 syscall이 딸려 온다. “내 코드 = syscall만”은 절대 아님.
9. 시스템 콜 번호 — 표 말고 방법만 (숫자 하드코딩 금지)
절대 숫자는 커널/아키/버전마다 변한다. 믿을 만한 건 타깃의 asm/unistd_64.h / ausyscall(8) / man 2 syscalls / strace 출력이다.
예로만 말하자(환경 마다 다름): x86-64에선 read가 문헌상 0, openat이 257 쪽에 자주 잡힌다. arm64에선 read가 63, openat이 56 근처로 자주 보인다는 식. 똑같은 C라도 크로스 컴파일하면 __NR_… 상수가 바뀐다. CI에 아키 매트릭스 넣는 이유다.
검증 예:
ausyscall x86_64 read
ausyscall x86_64 openat
read, open, close, mmap, socket, execve, exit, epoll_create1 같은 이름은 “개념 지도”로 외우고, 숫자는 도구로 끌어와.
10. 문제 생겼을 때 — 표 대신 “증상 → 의심 → 할 일” 스토리
EACCES/EISDIR: 권한·SELinux·마운트·잘못된 fd.ls -lZ,namei, 유닛User=.EMFILE/ENFILE: RLIMIT, 전역 fd, 누수.ulimit -n,lsof,/proc/.../fd, systemdLimitNOFILE.EINTR반복: 시그널/타이머.read/write루프 재시도,SA_RESTART한계는 man.- 논블로킹
EAGAIN: 준비 안 됨. 이벤트 루프,poll스핀은 오히려 독. - 블록 I/O
EIO: 스토리지/FS/가상화.dmesg같이. ETIMEDOUT: TCP/앱/프록시. 경로, MTU, SLO.- strace에
write/read가 수만 줄: 작은 I/O, 버퍼/배치 부족.writev·io_uring·프로토콜. futex난무 + 지연: 뮤텍스·cond·락 순서, 샤딩.SIGSYS+ seccomp: 금지 syscall. OCI·프로필,NO_NEW_PRIVS.- vDSO/시간 이상: 가상화·KPTI.
clock_getres, 하이퍼바이저. ENOMEM(mmap): cgroup·ulimit. 메모리 글·dmesg.EPIPE/SIGPIPE: 반대편close. 스트리밍·HTTP/2.- strace에
restart_syscall반복:nanosleep·SA_RESTART·커널/버전.
11. strace / ltrace 커맨드 (복붙 전 비밀·부하)
strace -f -o /tmp/s.txt ./app arg1
strace -c -f -o /dev/null ./app
strace -e trace=openat,read,write,close,stat,fsync -f ./app
strace -f -T -p 12345 2> /tmp/s-p.txt
ltrace는 예를 들어 ltrace -f -S -e malloc+free+realloc -o /tmp/l.txt ./app — -S는 버전에 syscall까지 붙이기도 하는데, strace 동시는 무거우니 개발에서만.
12. ptrace·GDB·PTRACE_SYSCALL·/proc/self/syscall
strace·GDB는 ptrace 가족을 쓴다. PTRACE_SYSCALL는 입/출에서 정지를 걸 수 있어 strace 류랑 비슷한 내부를 공유한다. CAP_SYS_PTRACE·Yama ptrace_scope·SELinux 안 맞으면 attach Permission denied. 초지연에 전면 strace는 “재현”엔 좋고 “운영 핫스팟”엔 악화시킬 수 있어, 샘플·시간 제한.
cat /proc/<tid>/syscall은 그 순간 어떤 syscall 안에 있는지 스냅샷에 가깝다. stack·wchan·sched는 #05 D 상태랑 같이.
13. perf·eBPF·tracepoint
perf·eBPF·raw_syscalls / sys_enter_* 는 strace보다 집계·상대 비교에 강한 편. 운영에선 권한·감사 정책 먼저. read·futex 누적만으로 CPU/락 원인이 안 끊기면 perf+flamegraph·BPF 샘플 병행.
14. 부록: 번호가 왜 밀리냐
openat2·faccessat2·pidfd_open·io_uring_* 같은 새 syscall이 추가되고, 아키마다 상수 공간·하위 호환이 딜레마다. glibc / musl / Bionic / 교차 빌드는 SYS_ / __NR_·툴체인 일치를 권장한다. CI·이미지·ausyscall·문서 동시 갱신. 콜 번호 표 하나만 믿지 마.
15. SRE/백엔드 — 내가 실제로 하는 순서
- 느리다 싶으면
strace -c로read/write/futex/epoll_wait누적 상위부터 본다. - 막혔다면
EACCES/EAGAIN분기 + SELinux·cgroup·seccomp를 동시에 훑는다(한 축만 보면 오판하기 쉽다). - fd면
ulimit·lsof·/proc/.../fd·LimitNOFILE. EINTR이면SA_RESTART한계 + 앱 루프 계약을 코드·문서에 맞춘다.- 관측이 부담이면 strace 시간·이벤트 한정, 전면 attach 정책 검토.
16. 이 글에서 안 파는 것
eBPF 프로그램 문법, glibc vDSO 어셈 세부, Windows·WSL2·gVisor 쪽 가상화 차이. 권장: man 2 intro, man 2 syscalls, Kerrisk TLPI, 대상 커널 Documentation/.
배포 전 npm run build로 수집·내부 링크 확인하고, git add / commit / push 뒤 npm run deploy(프로젝트 규칙).
맺음말
시스템 콜은 “리눅스 쓰는 입장”에서 손이 제일 잘 닿는 커널 경계다. strace로 사실을, errno로 이유를, vDSO·io_uring·배치로 이득/한계를, seccomp로 의도를 맞추면 “왜 느린지/왜 막혔는지”를 동료·고객한테 덜 헛소리로 설명할 수 있다. 그리고 한 번 더: 응용 코드에선 시스템 콜 직접 쓰지 마. 정말 필요할 때만, 그때는 이유를 글로 남기고, 런타임이 끼어드는 syscall도 같이 보라. 프로세스·메모리·I/O는 #05·#06 memory·I/O 다중화로 이어진다.
키워드: Linux, 시스템 콜, syscall, glibc, strace, ltrace, errno, EINTR, vDSO, seccomp, x86-64, AArch64, 시스템 프로그래밍으로 검색해도 이 글이 도움이 될 수 있다.