시스템 콜과 인터럽트
이중 모드, 트랩, 인터럽트 처리 과정을 따라가며 사용자 프로그램이 커널에 진입하는 구조를 정리합니다.
Contents
사용자 프로그램이 커널에 들어가는 합법적 경로는 트랩과 인터럽트 둘뿐이고, 두 경로 모두 모드 전환을 거칩니다.
이중 모드와 보호 경계
운영체제는 아무 코드나 하드웨어를 직접 만지게 두지 않습니다. 디스크에 쓰거나 메모리 매핑을 바꾸는 명령은 커널만 실행할 수 있어야 합니다. 이를 위해 중앙처리장치(CPU)는 이중 모드(Dual Mode)를 둡니다.
이중 모드는 모드 비트(Mode Bit) 하나로 권한을 구분합니다. 모드 비트가 1이면 유저 모드, 0이면 커널 모드입니다. 유저 모드에서는 특권 명령이 막히고, 위반하면 예외가 발생합니다.
여기서 핵심 제약이 하나 나옵니다. 유저 모드에서 커널 모드로 넘어가는 합법적 경로는 정해져 있습니다. 사용자 프로그램이 임의로 모드 비트를 0으로 바꿀 수는 없습니다.
그림처럼 사용자 프로그램은 트랩을 발생시켜야만 커널에 진입합니다. 트랩이 모드 비트를 0으로 바꾸고, 커널이 일을 마치면 다시 1로 되돌립니다. 진입 지점도 커널이 미리 등록한 핸들러로 고정됩니다.
유저 모드에서 커널 모드로 가는 세 가지 경우
전환을 일으키는 사건은 셋뿐입니다.
- 시스템 콜: 사용자 프로그램이 운영체제(OS) 서비스를 요청할 때. 트랩으로 진입합니다.
- 인터럽트: 타이머 만료나 입출력(I/O) 완료처럼 하드웨어 장치가 신호를 보낼 때.
- 예외(Exception): 0으로 나누기나 페이지 폴트처럼 실행 중 오류가 생길 때.
세 경우 모두 결과는 같습니다. CPU가 현재 상태를 저장하고, 커널의 정해진 핸들러로 점프합니다. 차이는 그 사건이 동기적인지 비동기적인지에 있습니다.
시스템 콜의 동작 과정
시스템 콜(System Call)은 사용자 프로그램이 커널 서비스를 요청하는 프로그래밍 인터페이스입니다. write나 read 같은 함수도 실제로는 시스템 콜 위에 올라가 있습니다.
리눅스 x86-64에서 write를 호출하면 번호와 인자를 약속된 레지스터에 실어 전달합니다. 보통은 C 표준 라이브러리의 래퍼 함수를 거칩니다.
#include <unistd.h>
int main(void) {
const char msg[] = "hello\n";
write(1, msg, 6); // glibc 래퍼가 syscall 명령으로 변환
return 0;
}GNU C 라이브러리(glibc)의 래퍼 함수가 이 호출을 받아 레지스터를 채웁니다. x86-64에서는 RAX에 시스템 콜 번호를, RDI/RSI/RDX에 인자를 넣습니다. 래퍼 없이 같은 동작을 어셈블리로 적으면 다음과 같습니다.
mov rax, 1 ; write의 시스템 콜 번호 (리눅스 x86-64)
mov rdi, 1 ; 첫 번째 인자: 파일 디스크립터 stdout
mov rsi, msg ; 두 번째 인자: 버퍼 주소
mov rdx, 6 ; 세 번째 인자: 바이트 수
syscall ; 트랩, 커널 진입레지스터를 채운 뒤 syscall 명령을 실행하면 트랩이 걸립니다. 커널은 RAX의 번호로 시스템 콜 디스패치 테이블(sys_call_table)을 찾아 해당 함수로 분기합니다. write라면 sys_write가 실행됩니다.
커널 함수가 끝나면 반환값을 RAX에 담고 유저 모드로 복귀합니다. 성공이면 0 이상의 값이, 실패면 음수가 RAX에 들어옵니다. glibc 래퍼는 이 음수를 -1과 errno로 변환해 돌려줍니다.
시스템 콜과 일반 함수 호출의 차이
일반 함수 호출은 유저 모드 안에서 스택 프레임만 바꿉니다. 같은 주소 공간, 같은 권한이라 비용이 작습니다.
시스템 콜은 다릅니다. 트랩으로 모드 비트를 바꾸고, CPU 상태(레지스터, 프로그램 카운터)를 커널 스택에 저장한 뒤 핸들러로 들어갑니다. 복귀할 때 이 상태를 모두 되돌립니다. 그래서 같은 일을 해도 함수 호출보다 오버헤드가 큽니다.
이 비용 때문에 초저지연 시스템은 시스템 콜 횟수를 줄이려 합니다. 커널 바이패스 기법은 네트워크 패킷을 커널을 거치지 않고 처리합니다. 이렇게 하면 시스템 콜, 사용자-커널 데이터 복사, 인터럽트 비용을 함께 줄일 수 있습니다.
인터럽트의 분류
인터럽트(Interrupt)는 CPU가 하던 일을 멈추고 급한 사건을 먼저 처리하게 만드는 신호입니다. 발생 주체에 따라 둘로 갈립니다.
하드웨어 인터럽트는 외부 장치가 보냅니다. 발생 시점을 예측할 수 없어 비동기적입니다. 다시 마스커블(Maskable)과 넌마스커블(Non-Maskable)로 나뉘는데, 전원 이상처럼 무시하면 안 되는 신호는 마스킹할 수 없습니다.
소프트웨어 인터럽트는 프로그램 명령어가 일으킵니다. 명령을 실행한 그 시점에 발생하므로 동기적입니다. 트랩과 예외가 여기에 속합니다.
| 구분 | 하드웨어 인터럽트 | 소프트웨어 인터럽트 |
|---|---|---|
| 발생 원인 | 외부 장치 (키보드, 디스크, 타이머) | 프로그램 내부 (명령어 실행) |
| 발생 시점 | 비동기적 | 동기적 |
| 예시 | 키보드 입력, 타이머 만료, 직접 메모리 접근(DMA) 완료 | syscall, 0으로 나누기 |
| 마스킹 | 마스커블/넌마스커블 구분 | 해당 없음 |
표에서 보듯 시스템 콜은 소프트웨어 인터럽트(트랩)에 들어갑니다. 즉 시스템 콜은 인터럽트 처리 구조를 그대로 빌려 쓰는 동기적 사건입니다.
트랩과 인터럽트는 헷갈리기 쉽습니다. 트랩은 프로그램이 의도적으로 일으키는 동기적 사건이고, 하드웨어 인터럽트는 언제 올지 모르는 비동기적 사건입니다. 예외도 동기적이지만, 의도하지 않은 오류라는 점이 트랩과 다릅니다.
인터럽트 처리 과정
인터럽트가 오면 CPU는 정해진 순서로 처리합니다. 핵심은 하던 일을 안전하게 보관했다가 정확히 그 자리로 돌아오는 것입니다.
CPU 상태 저장 단계에서는 프로그램 카운터(PC), Program Status Word(PSW), 레지스터를 스택에 보관합니다. 이 묶음을 컨텍스트라고 부릅니다. 인터럽트 핸들러가 레지스터를 덮어써도 복원 단계에서 원래 값을 되살릴 수 있습니다.
벡터 테이블 참조 단계가 어디로 점프할지 결정합니다. 인터럽트마다 번호가 있고, 이 번호로 테이블에서 인터럽트 서비스 루틴(Interrupt Service Routine, ISR)의 주소를 찾습니다. ISR은 해당 인터럽트를 처리하는 커널 함수입니다.
이 테이블은 CPU 동작 모드에 따라 형식이 다릅니다. x86 기준으로 비교하면 다음과 같습니다.
| 항목 | IVT (리얼 모드) | IDT (보호 모드 / x86-64) |
|---|---|---|
| 엔트리 크기 | 4 바이트 | 8 바이트(32비트) / 16 바이트(64비트) |
| 엔트리 수 | 256개 | 256개 |
| 저장 내용 | ISR 주소 | 게이트 디스크립터 (ISR 주소 + 권한) |
| 위치 | 물리 주소 0x0000 고정 | IDTR 레지스터가 가리키는 위치 |
인터럽트 벡터 테이블(Interrupt Vector Table, IVT)은 리얼 모드의 단순한 주소 표입니다. 보호 모드와 x86-64는 인터럽트 디스크립터 테이블(Interrupt Descriptor Table, IDT)을 씁니다. 여기에는 주소뿐 아니라 권한 정보까지 담은 게이트 디스크립터가 들어가, 유저 코드가 함부로 ISR을 호출하지 못하게 막습니다.
우선순위와 중첩 인터럽트
인터럽트는 동시에 여러 개가 들어올 수 있습니다. 그래서 우선순위가 필요합니다. 전원 이상과 기계 오류 같은 넌마스커블 인터럽트가 가장 위에 있습니다. 그 아래로 타이머, 디스크, 네트워크, 키보드가 이어지고, 소프트웨어 인터럽트(트랩)가 가장 아래입니다.
처리 중에 또 다른 인터럽트가 오면 두 가지 정책이 있습니다. 하나는 현재 ISR이 끝날 때까지 새 인터럽트를 막는 비활성화 방식입니다. 구현이 단순하지만 급한 신호가 밀릴 수 있습니다.
다른 하나는 중첩 인터럽트(Nested Interrupt)입니다. 새 인터럽트의 우선순위가 더 높으면 현재 ISR을 잠시 멈추고 새 ISR을 먼저 실행한 뒤 돌아옵니다. 대부분의 현대 운영체제는 이 우선순위 기반 중첩을 지원합니다.
정리
시스템 콜과 인터럽트는 유저 모드와 커널 모드의 경계를 넘는 같은 메커니즘을 공유합니다. 시스템 콜은 프로그램이 의도적으로 일으키는 동기적 트랩이고, 하드웨어 인터럽트는 외부에서 비동기로 들어오는 신호입니다. 두 경로 모두 CPU 상태를 저장하고, 정해진 핸들러로 점프했다가, 상태를 복원하며 원래 자리로 돌아옵니다. 이 구조를 이해하면 시스템 콜의 오버헤드가 왜 함수 호출보다 큰지, 커널이 어떻게 보호 경계를 지키는지 설명할 수 있습니다.