유저 모드 프로세스는 파일 읽기, 메모리 받기, 네트워크 전송, 프로세스 생성 등 자기 주소 공간 밖의 모든 일을 시스템 콜이라는 단 하나의 통제된 통로로만 커널에 요청할 수 있다. 이 경계가 리눅스 보호 모델 전체의 출발점이다.
우리가 파일 입출력, 메모리 확장, 네트워크 통신, 새 프로세스 생성 등 프로그램이 한다고 여기는 거의 모든 일은 프로그램이 커널에게 부탁한 일이다.
왜 프로그램이 하드웨어를 직접 못 만지게 막아두었을까 그리고 그 '부탁'은 어떻게 커널에 전달될까
이 두 질문의 답이 운영체제에서 가장 근본적인 경계인 유저 모드와 커널 모드의 분리, 그리고 두 모드를 잇는 단 하나의 통로인 system call(시스템 콜)이다.
이 경계는 리눅스의 거의 모든 것을 떠받친다. 프로세스 보호, 메모리 격리, 보안, 자원 관리, 컨테이너 같은 격리 기술까지 전부 이 경계 위에 세워진다.
핵심 개념과 용어 정의
- 커널(kernel)은 운영체제의 핵심으로, 하드웨어를 직접 만지는 단 하나의 소프트웨어다. CPU 시간 배분, 물리 메모리 할당, 디스크·네트워크 장치 제어가 전부 커널의 권한이다. 시스템 전체에 커널은 하나뿐이고, 모든 프로세스가 이 하나의 커널을 공유한다.
- 유저 공간(user space)과 커널 공간(kernel space)은 두 개의 분리된 실행 영역이다. 우리가 만드는 모든 애플리케이션은 유저 공간에서 돈다. 커널 코드와 디바이스 드라이버만이 커널 공간에서 돈다.
- 유저 모드(user mode)와 커널 모드(kernel mode)는 CPU가 명령을 실행하는 두 가지 권한 상태다.
- 유저 모드에서 CPU는 제한된 명령어 집합만 실행할 수 있다. 하드웨어 장치를 직접 제어하는 I/O 명령, 인터럽트를 끄는 명령, 페이지 테이블을 갈아끼우는 명령 같은 특권 명령(privileged instruction)이 금지된다.
- 커널 모드에서는 모든 명령이 허용된다. 이 권한 차이를 하드웨어(CPU)가 강제한다는 점이 중요하다. 소프트웨어의 약속이 아니라 물리적으로 CPU가 막는다.
- 시스템 콜(system call)은 유저 모드 프로세스가 커널 모드의 기능을 요청하는 공식 인터페이스다.
read,write,openat,mmap,clone,execve같은 것들이다. 리눅스에는 300개가 넘는 syscall이 있다(x86-64 기준 370여 개이며, 중간에 결번이 있어 번호 자체는 462까지 올라간다. 아키텍처·커널 버전에 따라 다르다). - 모드 전환(mode switch)은 CPU가 유저 모드에서 커널 모드로(또는 반대로) 권한 상태를 바꾸는 사건이다. syscall이 일어날 때마다 이 전환이 발생한다. 전환의 비용이 비싸기 때문에(레지스터 저장, 권한 검사 등) 성능에 민감한 곳에서는 syscall 횟수 자체를 줄이는 것이 최적화의 핵심이 된다.
- ABI(Application Binary Interface)는 "syscall을 어떤 번호로, 어떤 레지스터에 인자를 넣어 호출하는가"에 대한 약속이다. 아키텍처(x86-64, ARM64)마다 다르다. 이 약속이 있어서 컴파일된 바이너리가 커널과 대화할 수 있다.
- glibc / libc는 우리가 C로
write()를 부를 때 실제로는 syscall을 직접 치지 않고 거치는 표준 라이브러리다. libc 함수는 syscall을 감싼 얇은 래퍼(wrapper)인 경우가 많다. "프로그래머가 보는 함수"와 "커널이 보는 syscall" 사이에 한 겹이 더 있다는 뜻이다.
내부 구조 — 두 개의 모드, 하나의 주소 공간
1 CPU의 권한 링
x86-64 CPU에는 0부터 3까지 네 단계의 권한 링(protection ring)이 있다. 숫자가 작을수록 권한이 높다. 리눅스는 이 중 두 개만 쓴다.

Ring 1과 Ring 2는 거의 쓰이지 않는다(역사적으로 드라이버용으로 구상됐으나 이식성 문제로 외면받았다). 리눅스의 세계는 사실상 Ring 0(커널)과 Ring 3(유저) 둘뿐이다. ARM64에서는 같은 개념을 "예외 수준(Exception Level)"이라 부르며, 유저는 EL0, 커널은 EL1에 해당한다.
핵심은, 유저 모드 코드가 스스로 Ring 0으로 올라갈 방법이 없다는 것이다. 마음대로 권한을 올릴 수 있다면 보호의 의미가 없다. 권한을 올리는 유일한 합법적 경로는 커널이 미리 정해둔 진입점(syscall 명령)으로 점프하는 것이다. 어떤 프로그램이든 시스템에 영향을 주는 일을 하려면 결국 이 좁은 문을 통과해야 한다.
2 가상 주소 공간의 분할
각 프로세스는 자기만의 가상 주소 공간(virtual address space)을 가진다.
가상 주소 공간이란, 프로세스가 보는 "0번지부터 시작하는 자기만의 메모리 지도"다. 실제 물리 메모리(RAM)의 어디에 있든, 커널과 CPU가 프로세스마다 이 지도를 따로 만들어 물리 주소로 번역해주기 때문에, 프로세스는 다른 프로세스의 메모리를 아예 볼 수도 가리킬 수도 없다.
x86-64에서는 이 공간은 둘로 갈린다.

커널 공간이 모든 프로세스의 주소 공간에 동일하게 매핑되어 있다. 프로세스마다 유저 공간(아래쪽)은 제각각이지만, 커널 공간(위쪽)은 모두 같은 하나의 커널을 가리킨다. 단지 유저 모드일 때는 그 영역에 접근하면 CPU가 즉시 보호 폴트(protection fault)를 일으켜 막을 뿐이다.
이 구조 덕분에 syscall이 효율적이다. 모드 전환만 일어나면(Ring 3에서 Ring 0으로 올라가기만 하면) 페이지 테이블을 통째로 갈아끼울 필요 없이 이미 그 자리에 있는 커널 코드를 바로 실행할 수 있다. (참고: Meltdown 취약점 대응으로 도입된 KPTI는 이 매핑을 분리해 보안을 높였고, 그만큼 syscall이 약간 더 비싸졌다.)
요약하면, 한 시스템의 모든 프로세스는 자기 주소 공간의 위쪽 절반에서 똑같은 하나의 커널을 공유한다. 유저 공간은 각자 격리되지만 커널은 공용이다. 이 비대칭이 syscall이라는 통로가 성립하는 물리적 바탕이다.
동작 원리 — write("hello") 한 줄이 커널에 닿기까지
가장 단순한 예로, 화면에 글자를 출력하는 과정을 끝까지 따라가 보자. C에서 printf("hello\n")을 부르면 내부적으로 결국 write(1, "hello\n", 6) syscall이 일어난다(1은 표준출력 fd).
1 레지스터에 인자 전달 (x86-64 규약)
x86-64 리눅스 ABI는 syscall 인자를 다음 레지스터에 싣기로 약속한다.
| 역할 | 레지스터 | 설명 |
| syscall 번호 | rax | 호출할 syscall의 번호 (write는 1) |
| 1번째 인자 | rdi | 파일 디스크립터 (fd) |
| 2번째 인자 | rsi | 쓸 데이터가 담긴 버퍼의 주소 |
| 3번째 인자 | rdx | 쓸 데이터의 길이 (바이트 수) |
| 4번째 인자 | r10 | syscall 종류에 따라 다름 - write는 미사용 |
| 5번째 인자 | r8 | syscall 종류에 따라 다름 - write는 미사용 |
| 6번째 인자 | r9 | syscall 종류에 따라 다름 - write는 미사용 |
| 반환값 | rax | 실제로 쓴 바이트 수 (또는 음수 에러코드) |
write의 syscall 번호는 x86-64에서 1이다. 따라서 rax=1, rdi=1(fd), rsi=버퍼주소, rdx=6(길이)을 세팅한 뒤 syscall 명령을 실행한다.
2 syscall 명령으로 모드 전환
syscall은 평범한 명령이 아니다. 이 명령을 만나면 CPU는 미리 약속된 동작을 수행한다.

핵심을 풀어 쓰면 이렇다.
- CPU는 현재 명령의 다음 주소(복귀 지점)를
rcx에, 플래그 레지스터를r11에 저장한다. - 부팅 시 커널이
LSTAR이라는 특수 레지스터(MSR)에 등록해둔 커널 진입점 주소를 가져온다. 리눅스에서 이 진입점은entry_SYSCALL_64라는 어셈블리 루틴이다. - CPU 권한을 Ring 0으로 올리고 그 진입점으로 점프한다. 이제부터는 커널 모드다.
- 커널은
rax에 담긴 번호(1)로 시스템 콜 테이블(sys_call_table이라는 함수 포인터 배열)을 인덱싱해sys_write함수를 찾아 호출한다. sys_write는rdi로 받은 fd가 유효한지,rsi가 가리키는 버퍼가 정말 이 프로세스가 접근 가능한 메모리인지 등을 검증한 뒤 실제 작업을 수행한다. 이 검증이 있기에 유저가 거짓 인자를 넘겨 커널을 속일 수 없다.- 작업이 끝나면
sysret명령으로 다시 Ring 3로 내려오고, 반환값(쓴 바이트 수)이rax에 담긴다.
진입점이 단 하나로 고정되어 있다는 점이 보안의 핵심이다. 유저 코드는 커널의 아무 곳으로나 점프할 수 없다. 오직 커널이 부팅 때 정해둔 그 입구로만 들어올 수 있고, 입구에 들어선 순간부터는 커널이 모든 통제권을 쥔다.
3 glibc라는 중간 계층과 vDSO
대부분의 프로그램은 위 어셈블리를 직접 쓰지 않는다. glibc의 write() 함수가 레지스터 세팅과 syscall 명령을 대신 해준다. 그래서 우리는 평범한 함수처럼 부른다.
한편 일부 syscall은 너무 자주 불려서 모드 전환 비용조차 아까운 경우가 있다. 대표적으로 "현재 시각 읽기"(gettimeofday, clock_gettime)다. 이를 위해 커널은 vDSO(virtual Dynamic Shared Object)라는 작은 코드 조각을 모든 프로세스의 주소 공간에 매핑해둔다. 시각 정보처럼 읽기만 하면 되는 데이터는 커널이 공유 페이지에 갱신해두고, 프로세스는 모드 전환 없이 유저 공간에서 그 페이지를 읽는다. 즉 "syscall처럼 보이지만 실제로는 커널에 안 들어가는" 최적화다. 성능 분석 때 "분명 syscall인데 strace에 안 잡힌다" 싶으면 vDSO를 의심하면 된다.
syscall의 진짜 모습 — 반환, 실패, 트랩
여기까지가 "성공하는 syscall"의 이상적인 경로다. 그러나 OS를 제대로 이해한다는 것은 실패하고, 끊기고, 다른 방식으로 진입하는 경우까지 아는 것이다. 이 절은 그 디테일을 다룬다.
1 반환값과 errno — syscall은 어떻게 실패를 알리나
커널 ABI 수준에서 syscall은 rax에 단 하나의 정수를 담아 돌아온다. 성공이면 결과값(쓴 바이트 수, 새 fd 번호 등)이고, 실패면 음수로 된 에러 번호다. 예를 들어 파일이 없으면 -2(ENOENT), 권한이 없으면 -13(EACCES)이 rax에 담긴다.
그런데 우리가 C에서 보는 건 "-1 반환 + errno 변수 세팅"이다. 이 차이는 glibc 래퍼가 메운다. 래퍼는 커널이 돌려준 값이 -4095 ~ -1 범위(에러로 약속된 구간)이면, 그 값을 양수로 뒤집어 스레드 지역 변수 errno에 넣고, 호출자에게는 -1을 돌려준다. 즉 "-1과 errno"는 커널의 음수 반환을 libc가 번역한 결과다.
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void) {
int fd = open("/no/such/file", O_RDONLY);
if (fd == -1)
printf("open 실패: errno=%d (%s)\n", errno, strerror(errno));
return 0;
}
open 실패: errno=2 (No such file or directory)
errno=2가 바로 ENOENT(그런 파일/디렉토리 없음)다. 같은 호출을 strace로 보면 커널이 돌려준 값이 그대로 드러난다.
openat(AT_FDCWD, "/no/such/file", O_RDONLY) = -1 ENOENT (No such file or directory)
strace는 libc 아래, 커널 경계에서 가로채기 때문에 -1 ENOENT라는 "번역 전/후가 합쳐진" 모습을 보여준다. 이 구조를 알면 "왜 어떤 함수는 -1을 주고 어떤 건 NULL을 주나" 같은 혼란이 정리된다 — libc 래퍼마다 규약이 다를 뿐, 그 아래 커널은 일관되게 음수 errno를 돌려준다.
2 시그널에 끊기는 syscall — EINTR과 재시작
syscall이 항상 즉시 끝나는 건 아니다. read로 파이프나 소켓에서 데이터를 기다릴 때처럼 블록되는(blocking) syscall은 한참 잠들어 있을 수 있다. 그런데 그 사이에 프로세스로 시그널(예: SIGCHLD, SIGALRM)이 도착하면 어떻게 될까?
커널은 잠든 syscall을 깨워 시그널 핸들러를 먼저 실행시킨다. 그리고 핸들러가 끝난 뒤, 블록되어 있던 syscall은 흔히 -1을 반환하며 errno를 EINTR("Interrupted system call")로 설정한다. 즉 "일을 끝내지 못하고 시그널 때문에 중간에 돌아왔다"는 신호다. 이 때문에 견고한 프로그램은 블로킹 syscall을 다음 관용구로 감싼다.
ssize_t n;
do {
n = read(fd, buf, len);
} while (n == -1 && errno == EINTR); // 시그널에 끊기면 다시 시도
시그널 핸들러를 SA_RESTART 플래그로 등록하면, 커널이 일부 syscall을 자동으로 재시작해줘 EINTR을 안 보게 할 수도 있다.
다만 모든 syscall이 재시작 대상은 아니다(poll·select 등 일부는 SA_RESTART와 무관하게 EINTR을 돌려줄 수 있다). "블로킹 syscall은 시그널에 끊길 수 있고, 그때 EINTR을 처리해야 한다"는 사실은 OS 위에서 안정적인 코드를 짜는 데 빠지지 않는 기본기다.
3 syscall은 트랩이다 — 인터럽트·예외와 어떻게 다른가
유저 모드에서 커널 모드로 넘어가는 사건은 syscall만이 아니다. 모드 전환을 일으키는 사건은 크게 세 가지이며, syscall은 그중 트랩(trap)에 해당한다.
| 종류 | 발생 계기 | 예 | 성격 |
| 시스템 콜 (트랩) | 프로그램이 의도적으로 커널 기능 요청 | write, openat, mmap | 동기 · 자발적 |
| 예외 / 폴트 (exception/fault) | 명령 실행 중 문제 발생 | page fault, 0으로 나누기, 잘못된 명령 | 동기 · 비자발적 |
| 인터럽트 (interrupt) | 외부 장치가 CPU에 알림 | 타이머 틱, NIC 패킷 수신, 키 입력 | 비동기 |
셋 다 결과적으로 Ring 0으로 진입해 커널 코드를 실행시킨다는 점은 같다. 하지만 계기가 다르다. 인터럽트는 프로그램과 무관하게 아무 때나(비동기로) 들이닥치고, 예외는 프로그램이 의도치 않게 일으킨 것이며, syscall(트랩)만이 프로그램이 일부러 커널에 일을 시키려고 발생시킨 것이다.
이 구분이 중요한 이유가 있다. 예를 들어 mmap으로 받은 메모리에 처음 접근하는 순간 일어나는 page fault는 "에러"가 아니라 커널이 그 시점에 물리 페이지를 붙여주는 정상 동작이다(demand paging). 즉 page fault는 예외 경로로 커널에 들어가지만, 우리가 의도한 syscall이 아닌데도 커널 모드에서 처리된다. "커널 모드 진입 = syscall"이라고만 알면 이런 동작이 설명되지 않는다. 모드 전환은 syscall보다 넓은 개념이다.
4 int 0x80에서 syscall 명령으로 — 진입 방식의 변천
지금은 x86-64에서 syscall이라는 전용 명령으로 커널에 진입하지만, 늘 그랬던 건 아니다. 32비트 x86 시절에는 소프트웨어 인터럽트 int 0x80을 써서 진입했다. 프로그램이 int 0x80을 실행하면 CPU가 인터럽트 디스크립터 테이블(IDT)의 0x80번 항목을 찾아 커널 핸들러로 점프하는 방식이다. 잘 동작했지만, IDT를 거치는 경로가 상대적으로 느렸다.
그래서 CPU 제조사들이 빠른 진입 명령을 추가했다 — 인텔의 sysenter/sysexit, AMD의 syscall/sysret. 이 명령들은 IDT 조회를 건너뛰고, 진입점 주소를 특수 레지스터 LSTAR에서 바로 읽어 점프하므로 더 빠르다. x86-64 리눅스는 AMD가 정의한 syscall을 표준으로 쓴다.
직접 관찰하기 — syscall을 눈으로 보기
이론을 눈으로 확인하자. 가장 강력한 도구는 strace다. 프로세스가 치는 모든 syscall을 가로채 보여준다.
1 strace로 syscall 흐름 보기
strace -f echo hello
출력의 일부(요약):
execve("/usr/bin/echo", ["echo", "hello"], 0x7ffd...) = 0
brk(NULL) = 0x55c2a1b3a000
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 20480, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f3c...
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
...
write(1, "hello\n", 6) = 6
close(1) = 0
exit_group(0) = ?
이 짧은 출력이 많은 것을 말해준다. echo hello라는 사소한 명령조차도:
execve로 현재 프로세스의 메모리를 비우고 그 자리에echo실행 파일을 적재해 실행을 시작하고,openat·mmap으로 동적 라이브러리(libc)를 메모리에 올리고,- 우리가 기대한
write(1, "hello\n", 6)로 실제 출력을 하고 (반환값6은 6바이트를 썼다는 뜻), exit_group으로 종료한다.
"화면에 hello를 찍는다"는 한 줄의 의도가 수십 개의 syscall로 번역된다. "프로그램이 혼자 하는 일은 거의 없고 거의 전부를 커널에 부탁한다"는 앞서의 주장이 눈앞에 그대로 증명된 것이다.
2 어떤 syscall이 몇 번 불렸는지 집계
strace -c -f curl -s https://example.com -o /dev/null
-c는 syscall별 호출 횟수와 소요 시간을 표로 요약한다.
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
31.20 0.001540 18 84 recvfrom
18.05 0.000891 13 66 mmap
12.30 0.000607 15 40 5 openat
...
100.00 0.004936 612 12 total
네트워크 요청 하나에 612번의 syscall이 일어났다(errors 열의 12는 실패한 호출 수다 — 커널이 음수 errno를 돌려준 호출들이다). 애플리케이션이 느릴 때 "혹시 불필요한 syscall이 폭증하고 있지 않은가"를 이 한 줄로 진단할 수 있다. recvfrom(네트워크 수신)이 가장 많은 것도 자연스럽다.
3 /proc로 지금 이 순간의 syscall 들여다보기
strace는 추적이 무겁다. 부담 없이 프로세스가 지금 어떤 syscall에 멈춰 있는지만 보고 싶다면 /proc를 쓴다. 예를 들어 어떤 프로세스(PID 4242)가 응답이 없다고 하자.
cat /proc/4242/syscall
0 0x9 0x7f3c1a2b4000 0x1000 0x0 0x0 0x0 0x7ffd... 0x7f3c...
맨 앞 숫자 0이 현재 실행 중인 syscall 번호다. x86-64에서 0은 read다. 즉 이 프로세스는 지금 read에서 무언가를 기다리며 블록되어 있다. 뒤따르는 값들은 인자들이다. 디버거를 붙이지 않고도 "이 프로세스는 입력을 기다리느라 멈춰 있다"를 알아낸 것이다.
syscall 번호와 이름의 대응표는 아키텍처별 헤더에 들어 있다. 번호는 아키텍처마다 다르다 — x86-64는 read=0, write=1이지만, arm64·riscv 등은 asm-generic/unistd.h의 별도 체계(read=63, write=64)를 따른다. x86-64에서 확인하려면 그 아키텍처 전용 헤더를 본다.
ausyscall --dumptable 2>/dev/null | head # 감사(audit) 패키지 제공
# x86-64 전용 번호 헤더 (asm-generic/unistd.h가 아님에 주의)
grep -E '__NR_(read|write|openat|clone) ' /usr/include/asm/unistd_64.h
4 모드 전환 비용을 체감하기
syscall이 공짜가 아님을 보이는 간단한 실험. 같은 양의 데이터를 쓰되, 한 번은 1바이트씩(=write syscall 폭증) 한 번은 큰 버퍼로(=syscall 최소화) 써보면 후자가 압도적으로 빠르다.
# 1바이트씩 100만 번 — syscall 100만 회
dd if=/dev/zero of=/dev/null bs=1 count=1000000 2>&1 | tail -1
# 1MB씩 한 번 — syscall 거의 없음
dd if=/dev/zero of=/dev/null bs=1M count=1 2>&1 | tail -1
전자는 수백 ms, 후자는 1ms 미만이 흔하다. 같은 1MB를 옮기는데 차이는 오롯이 모드 전환 횟수에서 온다. 한 번의 syscall로 더 많은 일을 처리할수록 빨라진다는 뜻이다. 고성능 I/O에서 여러 요청을 한 번에 커널에 제출하는 io_uring이나, 데이터를 모아 한 번에 보내는 배치 처리가 각광받는 이유가 여기에 있다.
응용: 이 경계가 컨테이너에서 하는 일
지금까지 본 것은 순수한 OS 메커니즘이다. 컨테이너 이야기는 한 줄도 없었다. 이제 이 메커니즘이 현실에서 어떻게 쓰이는지, 컨테이너를 예로 짧게 짚어보자. 컨테이너 격리의 핵심이 사실은 이 syscall 경계 위에 서 있기 때문이다.
7.1 컨테이너는 호스트 커널에 직접 syscall을 친다
가상 머신(VM)과 컨테이너의 결정적 차이는 syscall이 어디로 가느냐다.

VM 안의 프로세스는 자기 게스트 커널에게 syscall을 친다. 게스트 커널을 통째로 들고 다니므로 무겁지만, 그 게스트 커널이 1차 방어선이 되어준다. 반면 컨테이너는 자기 커널이 없다 — 컨테이너 안 프로세스의 syscall은 곧바로 호스트 커널에 닿는다. 한 시스템의 모든 프로세스가 같은 커널을 공유한다는 사실이 컨테이너에도 그대로 적용되는 것이다. 그래서 컨테이너는 가볍고 부팅이 빠르지만, 호스트 커널에 권한 상승 취약점이 있으면 컨테이너 탈출(container escape)로 이어질 수 있다. "컨테이너는 VM만큼 강한 격리가 아니다"라는 흔한 경고의 기술적 실체다.
2 namespace는 결과를 거르고, seccomp는 통로를 좁힌다
그렇다면 같은 커널을 공유하면서 어떻게 격리가 일어날까? 두 가지 커널 기능이 이 syscall 경계 위에서 동작한다.
- namespace: 커널이 syscall을 처리할 때, 호출한 프로세스가 속한 네임스페이스를 참조해 결과를 걸러서 돌려준다. 그래서 컨테이너 안에서 프로세스 목록을 조회하는 같은 syscall이라도 자기 네임스페이스 안의 것만 보이고, 자기 PID가 1로 보인다. syscall 통로는 호스트와 완전히 동일하고, 커널이 보여주는 view만 다른 것이다.
- seccomp: syscall 진입점에 필터를 걸어, 컨테이너가 칠 수 있는 syscall 자체를 제한한다(필터는 작은 BPF 프로그램으로 작성된다). 허용 목록에 없는 syscall은 진입 단계에서 거부된다. syscall 진입점이 단 하나로 고정되어 있기에, 그 문 하나에 검문소를 세우는 것만으로 통제가 가능하다.
정리하면, 컨테이너 격리의 본질은 "프로그램이 다른 통로를 쓴다"가 아니라 "같은 syscall 통로를 쓰되, 커널이 결과를 거르고(namespace) 통로를 좁힌다(seccomp)"는 것이다. 이 한 문장이 이 글에서 다룬 OS 경계와 컨테이너를 잇는 다리다. (namespace와 seccomp가 커널 안에서 구체적으로 어떻게 구현되는지는 그 자체로 큰 주제다.)
8. 정리
이번 글에서 세운 토대를 한 문장으로 압축하면 이렇다. 유저 모드 프로세스가 자기 주소 공간 밖의 일을 하려면 반드시 시스템 콜이라는 단 하나의 통제된 문을 통과해야 하며, 이 경계가 리눅스의 보호·격리·자원 관리 전체의 출발점이다.
기억할 핵심:
- CPU는 유저 모드(Ring 3)와 커널 모드(Ring 0)를 하드웨어로 강제 구분한다. 유저 코드는 스스로 권한을 올릴 수 없다.
- syscall은 레지스터에 번호·인자를 싣고
syscall명령으로 커널이 정한 단일 진입점에 들어가는 것이다. 진입점이 하나로 고정돼 있어 보안이 성립한다. - 커널은 결과를
rax에 음수 errno로 실패를 알리며, 블로킹 syscall은 시그널에 끊겨 EINTR을 돌려줄 수 있다. 모드 전환은 syscall(트랩)뿐 아니라 예외·인터럽트로도 일어난다. - 모드 전환은 비싸다 — syscall 횟수가 성능을 좌우한다.
- 이 경계 위에서 namespace는 syscall 결과를 거르고 seccomp는 syscall 통로를 좁힌다. 컨테이너가 "가벼운 격리"인 근본 이유가 여기 있다.