시스템 콜 — 커널 모드와 유저 모드, 인터럽트
printf("hello")를 호출하면 화면에 글자가 찍힙니다. 그런데 유저 프로그램이 하드웨어(모니터)에 직접 접근할 수 있을까요?
안 됩니다. 유저 프로그램은 하드웨어에 직접 손댈 수 없고, 커널에 부탁해야 합니다. 이 부탁하는 행위가 시스템 콜이고, 이 질문을 따라가면 커널 모드, 인터럽트까지 자연스럽게 이어집니다.
유저 모드 vs 커널 모드
CPU 보호 링 (Protection Ring)
CPU는 Ring 0 ~ Ring 3 이라는 권한 레벨을 가지고 있어요. 실질적으로 쓰이는 건 두 개뿐입니다.
| 레벨 | 모드 | 설명 |
|---|---|---|
| Ring 0 | 커널 모드 (Kernel Mode) | 모든 명령어 실행 가능. 하드웨어 직접 접근, 메모리 전체 접근 |
| Ring 3 | ** 유저 모드** (User Mode) | 제한된 명령어만 실행 가능. 하드웨어 접근 불가 |
Ring 1, 2는 이론적으로 존재하지만 대부분의 운영체제가 안 씁니다. 가상화 환경에서 하이퍼바이저가 Ring -1(VMX root mode)을 쓰는 경우는 있어요.
왜 분리하는가
분리 안 하면 어떻게 될까요? 유저 프로그램이 디스크를 직접 제어할 수 있다면, 악의적인 프로그램이 다른 프로세스의 파일을 날려버릴 수 있습니다. 메모리를 마음대로 읽을 수 있다면 비밀번호를 빼가는 것도 일도 아니에요.
그래서 OS는 이런 구조를 잡았습니다.
- ** 유저 모드 **: 애플리케이션이 돌아가는 공간. 하드웨어에 직접 손대지 못합니다
- ** 커널 모드 **: OS 커널만 동작하는 공간. I/O, 메모리 관리, 프로세스 제어 등 특권 명령(privileged instruction) 실행 가능
유저 프로그램이 하드웨어 자원이 필요하면? 직접 접근하는 게 아니라 ** 커널에 부탁 **해야 합니다. 이 부탁하는 행위가 바로 시스템 콜이에요.
시스템 콜 (System Call)
시스템 콜은 유저 프로그램이 커널의 서비스를 요청하는 ** 프로그래밍 인터페이스 **입니다. 파일을 열고, 프로세스를 만들고, 네트워크 소켓을 생성하는 것 — 이런 건 전부 시스템 콜을 통해야만 해요.
C 언어에서 open(), read(), write(), fork() 같은 함수가 시스템 콜의 래퍼입니다. 이걸 호출하면 내부에서 CPU에게 "나 지금 커널 모드로 전환해야 해"라는 신호를 보내요.
시스템 콜 동작 과정
이 과정이 핵심입니다. 단계별로 뜯어볼게요.
유저 프로그램
│
│ ① write() 호출
▼
C 라이브러리 (glibc)
│
│ ② 시스템 콜 번호를 레지스터에 세팅 (x86-64: rax에 1 = write)
│ ③ syscall 명령어 실행 (트랩 발생)
▼
─── 모드 전환 (User → Kernel) ───
│
│ ④ CPU가 커널 모드로 전환
│ ⑤ 인터럽트 벡터 테이블에서 시스템 콜 핸들러 찾음
│ ⑥ sys_write() 커널 함수 실행
│ ⑦ 결과를 레지스터에 저장
▼
─── 모드 전환 (Kernel → User) ───
│
│ ⑧ 유저 모드로 복귀, 결과 반환
▼
유저 프로그램 계속 실행
여기서 핵심은 ③ 트랩(Trap) 입니다. syscall 명령어(x86-64 기준)를 실행하면 CPU가 소프트웨어 인터럽트를 발생시키고, 자동으로 커널 모드로 전환해요. 옛날 x86에서는 int 0x80을 썼는데, syscall이 더 빠릅니다.
시스템 콜 번호는 OS마다 정해져 있어요. 리눅스 x86-64 기준으로 몇 개만 보면:
| 번호 | 시스템 콜 | 설명 |
|---|---|---|
| 0 | read | 파일 디스크립터에서 읽기 |
| 1 | write | 파일 디스크립터에 쓰기 |
| 2 | open | 파일 열기 |
| 57 | fork | 프로세스 생성 |
| 59 | execve | 프로그램 실행 |
주요 시스템 콜 분류
시스템 콜을 카테고리별로 정리하면 전체 구조가 잡힙니다.
프로세스 관련
| 시스템 콜 | 설명 |
|---|---|
fork() | 현재 프로세스를 복제해서 자식 프로세스 생성. 부모는 자식 PID 반환, 자식은 0 반환 |
exec() | 현재 프로세스의 메모리를 새 프로그램으로 교체. fork 후에 exec 하는 패턴이 일반적 |
wait() | 자식 프로세스가 종료될 때까지 대기 |
exit() | 프로세스 종료. 종료 코드를 부모에게 전달 |
getpid() | 현재 프로세스의 PID 반환 |
쉘에서 명령어를 실행하는 과정이 정확히 fork() → exec() → wait() 흐름이에요. 쉘이 자기 자신을 복제하고, 복제된 자식에서 입력받은 프로그램을 exec으로 덮어씌우고, 부모 쉘은 wait으로 기다립니다.
파일 관련
| 시스템 콜 | 설명 |
|---|---|
open() | 파일을 열고 파일 디스크립터(fd) 반환 |
read() | fd에서 데이터를 버퍼로 읽기 |
write() | 버퍼의 데이터를 fd에 쓰기 |
close() | fd 닫기 |
lseek() | fd의 읽기/쓰기 위치 변경 |
리눅스에서는 "Everything is a file" 이라는 철학이 있어서, 소켓이든 파이프든 디바이스든 전부 fd로 다룹니다. 그래서 read()와 write()가 네트워크 통신에도 쓰여요.
메모리 관련
| 시스템 콜 | 설명 |
|---|---|
mmap() | 파일이나 디바이스를 메모리에 매핑. 또는 익명 매핑으로 메모리 할당 |
munmap() | mmap으로 매핑한 영역 해제 |
brk() / sbrk() | 힙 영역의 끝(break)을 조정해서 메모리 확장/축소 |
malloc()은 시스템 콜이 아닙니다. C 라이브러리 함수인데, 내부적으로 작은 할당은 brk()를, 큰 할당(보통 128KB 이상)은 mmap()을 호출해요. 이 차이를 아는지가 깊이 있는 이해의 갈림길입니다.
인터럽트 (Interrupt)
시스템 콜을 이해했으면 인터럽트도 같이 잡아야 합니다. 시스템 콜 자체가 소프트웨어 인터럽트(트랩)로 구현되니까요.
하드웨어 인터럽트 vs 소프트웨어 인터럽트
| 구분 | 하드웨어 인터럽트 | 소프트웨어 인터럽트 (트랩) |
|---|---|---|
| 발생 원인 | 외부 장치 (키보드, 디스크, 네트워크 카드) | 프로그램이 의도적으로 발생 (syscall, int 명령) |
| ** 발생 시점** | 비동기 — 언제든 발생 가능 | 동기 — 특정 명령어 실행 시 발생 |
| ** 예시** | 키보드 입력, 타이머, 디스크 I/O 완료 | 시스템 콜, 0으로 나누기(예외) |
| ** 목적** | CPU에게 외부 이벤트 알림 | 커널 서비스 요청 또는 예외 처리 |
트랩 안에서도 의도적인 것(시스템 콜)과 의도치 않은 것(page fault, division by zero 같은 예외)을 구분하기도 합니다. 핵심만 정리하면 "인터럽트와 트랩의 차이"는 ** 비동기 vs 동기 **로 구분돼요.
인터럽트 처리 과정
- ** 인터럽트 발생 **: 하드웨어 장치가 CPU의 인터럽트 라인에 신호를 보내거나, 소프트웨어가 트랩 명령어를 실행
- ** 현재 상태 저장 **: CPU가 현재 실행 중인 명령어의 PC(Program Counter), 레지스터, 상태 플래그를 스택에 저장
- ** 인터럽트 벡터 테이블 참조 **: 인터럽트 번호를 인덱스로 사용해서 해당 ISR의 주소를 찾음
- **ISR 실행 **: 커널 모드에서 해당 인터럽트 서비스 루틴 실행
- ** 상태 복원 **: 저장했던 레지스터, PC를 복원하고 원래 프로그램 실행 재개
인터럽트 벡터 테이블 (IVT)
┌──────┬────────────────────┐
│ 0 │ Division Error │
│ 1 │ Debug │
│ ... │
│ 14 │ Page Fault │
│ ... │
│ 32 │ Timer Interrupt │
│ 33 │ Keyboard Interrupt │
│ ... │
│ 128 │ System Call (0x80) │
└──────┴────────────────────┘
인터럽트 벡터 테이블 (IVT)
인터럽트 번호와 ISR(Interrupt Service Routine) 주소를 매핑하는 테이블입니다. 부팅할 때 OS가 이 테이블을 세팅해요. CPU는 인터럽트가 들어오면 이 테이블에서 해당 번호의 주소로 점프합니다.
x86에서는 IDT(Interrupt Descriptor Table)라고 부르고, 각 엔트리에는 ISR 주소 외에 권한 레벨(DPL), 세그먼트 셀렉터 같은 정보도 들어갑니다.
ISR 자체는 가능한 짧게 짜야 해요. ISR이 실행되는 동안에는 같은 인터럽트가 비활성화되거나, 다른 인터럽트도 지연될 수 있기 때문입니다. 리눅스에서는 이 때문에 top half / bottom half 구조를 사용합니다. 급한 처리(하드웨어 ACK 등)만 top half에서 하고, 나머지는 bottom half(softirq, tasklet, workqueue)로 미뤄요.
DMA (Direct Memory Access)
인터럽트 얘기가 나왔으니 DMA도 짚고 넘어갈게요. 디스크에서 데이터를 읽을 때 CPU가 바이트 하나하나를 직접 옮기면 비효율적입니다. 그래서 DMA 컨트롤러 라는 별도의 하드웨어가 CPU 대신 데이터를 전송해요.
동작 흐름
① CPU가 DMA 컨트롤러에 전송 명령
(소스 주소, 목적지 주소, 크기)
│
▼
② DMA 컨트롤러가 메모리 ↔ 디바이스 간 데이터 직접 전송
(이 동안 CPU는 다른 일을 할 수 있다)
│
▼
③ 전송 완료 시 DMA가 CPU에 인터럽트 발생
│
▼
④ CPU가 인터럽트 받고 후처리
DMA가 없던 시절에는 CPU가 I/O 포트에서 한 바이트씩 읽어서 메모리에 써야 했습니다(Programmed I/O). 디스크에서 1MB를 읽는 동안 CPU가 꼼짝 못 하고 바이트 복사만 하고 있었다는 뜻이에요. DMA 덕분에 CPU는 전송 시작만 지시하고 다른 작업을 계속 할 수 있게 되었습니다.
디스크 I/O가 발생하면 내부적으로 DMA가 동작한다는 점까지 이해하면 전체 흐름이 연결돼요.
주의할 점
printf와 write의 혼동
printf()는 시스템 콜이 아닙니다. C 라이브러리 함수입니다. 내부에서 버퍼링 후 write() 시스템 콜을 호출합니다. 이 차이를 모르면 "printf가 느린 이유"나 "로그가 누락되는 이유"를 설명할 수 없습니다.
vDSO를 모르면 성능 분석에서 빗나감
gettimeofday() 같은 간단한 시스템 콜은 실제로 커널 모드 전환 없이 유저 모드에서 처리됩니다(vDSO). strace로 추적해도 안 잡히는 경우가 있어서, 성능 분석 시 혼란을 줄 수 있습니다.
ISR에서 오래 걸리는 작업 수행
ISR은 가능한 짧게 짜야 합니다. ISR 실행 중에는 같은 인터럽트가 비활성화되거나, 다른 인터럽트도 지연됩니다. 리눅스는 이 때문에 top half/bottom half 구조를 사용합니다.
printf vs write — 라이브러리 함수와 시스템 콜
printf()는 C 표준 라이브러리 함수입니다. write()는 시스템 콜이에요. 이 둘은 뭐가 다를까요?
// 라이브러리 함수 — 유저 모드에서 버퍼링 후 시스템 콜 호출
printf("hello\n");
// 시스템 콜 — 직접 커널에 요청
write(1, "hello\n", 6);
| 구분 | printf (라이브러리 함수) | write (시스템 콜) |
|---|---|---|
| 실행 모드 | 유저 모드에서 시작 → 내부에서 write 호출 | 바로 커널 모드 전환 |
| ** 버퍼링** | stdout 버퍼에 모았다가 한 번에 write | 버퍼링 없이 즉시 커널에 전달 |
| ** 성능** | 버퍼링 덕에 시스템 콜 횟수가 줄어 더 빠를 수 있음 | 매 호출마다 모드 전환 → 오버헤드 |
| ** 이식성** | C 표준이라 모든 플랫폼에서 동일 | OS마다 번호나 규약이 다를 수 있음 |
정리하면, printf는 유저 공간에서 포매팅과 버퍼링을 한 다음, 버퍼가 차거나 \n이 오면 내부에서 write() 시스템 콜을 호출합니다. 라이브러리 함수는 시스템 콜을 감싸는 고수준 래퍼인 셈이에요.
모드 전환의 비용
시스템 콜 한 번에 모드 전환이 2번 일어납니다(User → Kernel → User). 이 비용이 구체적으로 뭘까요?
- ** 레지스터 저장/복원 **: 유저 모드의 레지스터 상태를 저장하고 커널 스택으로 전환
- **TLB/캐시 영향 **: 커널 코드가 실행되면서 유저 코드의 캐시 라인이 밀려날 수 있습니다
- ** 파이프라인 플러시 **: CPU 파이프라인이 비워지는 비용
- 시스템 콜이 실행되면 유저 모드에서 커널 모드로 전환됩니다
- 이때 레지스터 저장, 커널 스택 전환이 발생해요
- 커널 코드가 실행되면서 유저 코드의 캐시 라인이 밀려날 수 있습니다
- 결과를 반환하고 다시 유저 모드로 돌아옵니다
이 비용은 ** 컨텍스트 스위칭보다는 가볍습니다 **. 컨텍스트 스위칭은 프로세스 전체의 상태(페이지 테이블 포함)를 교체하지만, 시스템 콜은 같은 프로세스 안에서 모드만 바뀌기 때문이에요. 그래도 초당 수만 번 호출되면 무시 못 하는 오버헤드가 됩니다. 그래서 리눅스는 vDSO(virtual Dynamic Shared Object) 같은 기법으로 간단한 시스템 콜(gettimeofday 등)을 모드 전환 없이 유저 모드에서 처리하기도 해요.
strace로 시스템 콜 추적하기
strace는 프로세스의 시스템 콜을 모두 추적해주는 리눅스 디버깅 도구입니다.
# 프로그램의 모든 시스템 콜 출력
strace ./myapp
# 파일 관련 시스템 콜만 필터링
strace -e trace=file ./myapp
# 시스템 콜별 소요 시간 요약
strace -c ./myapp
# 실행 중인 프로세스에 붙이기
strace -p 1234
서버가 느린데 원인을 모르겠을 때 strace -c를 걸면 어떤 시스템 콜에서 시간을 잡아먹는지 한눈에 보입니다. 파일 오픈 실패, 소켓 타임아웃 같은 문제도 strace로 잡을 수 있어요.
정리
| 항목 | 설명 |
|---|---|
| 시스템 콜 | 유저 프로그램이 커널의 서비스를 요청하는 인터페이스 |
| 유저 모드 → 커널 모드 | syscall 명령어(트랩)로 전환, 완료 후 복귀 |
| printf vs write | printf는 라이브러리 함수(버퍼링), write는 시스템 콜(즉시 커널) |
| 하드웨어 인터럽트 | 비동기 — 외부 장치가 CPU에 신호 |
| 소프트웨어 인터럽트(트랩) | 동기 — 프로그램이 의도적으로 발생 |
| DMA | CPU 대신 데이터를 전송하는 별도 하드웨어 |
| 모드 전환 비용 | 레지스터 저장/복원 + 캐시 오염. 컨텍스트 스위칭보다는 가벼움 |
| vDSO | 간단한 시스템 콜을 모드 전환 없이 유저 모드에서 처리 |