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에게 "나 지금 커널 모드로 전환해야 해"라는 신호를 보내요.

시스템 콜 동작 과정

이 과정이 핵심입니다. 단계별로 뜯어볼게요.

PLAINTEXT
유저 프로그램

    │ ① 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 기준으로 몇 개만 보면:

번호시스템 콜설명
0read파일 디스크립터에서 읽기
1write파일 디스크립터에 쓰기
2open파일 열기
57fork프로세스 생성
59execve프로그램 실행

주요 시스템 콜 분류

시스템 콜을 카테고리별로 정리하면 전체 구조가 잡힙니다.

프로세스 관련

시스템 콜설명
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 동기 **로 구분돼요.

인터럽트 처리 과정

  1. ** 인터럽트 발생 **: 하드웨어 장치가 CPU의 인터럽트 라인에 신호를 보내거나, 소프트웨어가 트랩 명령어를 실행
  2. ** 현재 상태 저장 **: CPU가 현재 실행 중인 명령어의 PC(Program Counter), 레지스터, 상태 플래그를 스택에 저장
  3. ** 인터럽트 벡터 테이블 참조 **: 인터럽트 번호를 인덱스로 사용해서 해당 ISR의 주소를 찾음
  4. **ISR 실행 **: 커널 모드에서 해당 인터럽트 서비스 루틴 실행
  5. ** 상태 복원 **: 저장했던 레지스터, PC를 복원하고 원래 프로그램 실행 재개
PLAINTEXT
인터럽트 벡터 테이블 (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 대신 데이터를 전송해요.

동작 흐름

PLAINTEXT
① 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()는 시스템 콜이에요. 이 둘은 뭐가 다를까요?

C
// 라이브러리 함수 — 유저 모드에서 버퍼링 후 시스템 콜 호출
printf("hello\n");

// 시스템 콜 — 직접 커널에 요청
write(1, "hello\n", 6);
구분printf (라이브러리 함수)write (시스템 콜)
실행 모드유저 모드에서 시작 → 내부에서 write 호출바로 커널 모드 전환
** 버퍼링**stdout 버퍼에 모았다가 한 번에 write버퍼링 없이 즉시 커널에 전달
** 성능**버퍼링 덕에 시스템 콜 횟수가 줄어 더 빠를 수 있음매 호출마다 모드 전환 → 오버헤드
** 이식성**C 표준이라 모든 플랫폼에서 동일OS마다 번호나 규약이 다를 수 있음

정리하면, printf는 유저 공간에서 포매팅과 버퍼링을 한 다음, 버퍼가 차거나 \n이 오면 내부에서 write() 시스템 콜을 호출합니다. 라이브러리 함수는 시스템 콜을 감싸는 고수준 래퍼인 셈이에요.


모드 전환의 비용

시스템 콜 한 번에 모드 전환이 2번 일어납니다(User → Kernel → User). 이 비용이 구체적으로 뭘까요?

  • ** 레지스터 저장/복원 **: 유저 모드의 레지스터 상태를 저장하고 커널 스택으로 전환
  • **TLB/캐시 영향 **: 커널 코드가 실행되면서 유저 코드의 캐시 라인이 밀려날 수 있습니다
  • ** 파이프라인 플러시 **: CPU 파이프라인이 비워지는 비용
  1. 시스템 콜이 실행되면 유저 모드에서 커널 모드로 전환됩니다
  2. 이때 레지스터 저장, 커널 스택 전환이 발생해요
  3. 커널 코드가 실행되면서 유저 코드의 캐시 라인이 밀려날 수 있습니다
  4. 결과를 반환하고 다시 유저 모드로 돌아옵니다

이 비용은 ** 컨텍스트 스위칭보다는 가볍습니다 **. 컨텍스트 스위칭은 프로세스 전체의 상태(페이지 테이블 포함)를 교체하지만, 시스템 콜은 같은 프로세스 안에서 모드만 바뀌기 때문이에요. 그래도 초당 수만 번 호출되면 무시 못 하는 오버헤드가 됩니다. 그래서 리눅스는 vDSO(virtual Dynamic Shared Object) 같은 기법으로 간단한 시스템 콜(gettimeofday 등)을 모드 전환 없이 유저 모드에서 처리하기도 해요.


strace로 시스템 콜 추적하기

strace는 프로세스의 시스템 콜을 모두 추적해주는 리눅스 디버깅 도구입니다.

BASH
# 프로그램의 모든 시스템 콜 출력
strace ./myapp

# 파일 관련 시스템 콜만 필터링
strace -e trace=file ./myapp

# 시스템 콜별 소요 시간 요약
strace -c ./myapp

# 실행 중인 프로세스에 붙이기
strace -p 1234

서버가 느린데 원인을 모르겠을 때 strace -c를 걸면 어떤 시스템 콜에서 시간을 잡아먹는지 한눈에 보입니다. 파일 오픈 실패, 소켓 타임아웃 같은 문제도 strace로 잡을 수 있어요.



정리

항목설명
시스템 콜유저 프로그램이 커널의 서비스를 요청하는 인터페이스
유저 모드 → 커널 모드syscall 명령어(트랩)로 전환, 완료 후 복귀
printf vs writeprintf는 라이브러리 함수(버퍼링), write는 시스템 콜(즉시 커널)
하드웨어 인터럽트비동기 — 외부 장치가 CPU에 신호
소프트웨어 인터럽트(트랩)동기 — 프로그램이 의도적으로 발생
DMACPU 대신 데이터를 전송하는 별도 하드웨어
모드 전환 비용레지스터 저장/복원 + 캐시 오염. 컨텍스트 스위칭보다는 가벼움
vDSO간단한 시스템 콜을 모드 전환 없이 유저 모드에서 처리
댓글 로딩 중...