suminworld

concept

운영체제 프로세스 개념 정리

숨usm 2025. 10. 17. 00:28

1. fork() 함수

기본 개념

fork()는 새로운 프로세스(child)를 만드는 시스템 콜입니다.

#include <unistd.h>
pid_t pid = fork();

실행하면 운영체제가 현재 프로세스(parent)를 복제해서 자식 프로세스(child)를 하나 만듭니다. 즉, 실행 후에는 두 개의 프로세스가 동시에 실행됩니다.

fork()의 반환값

프로세스 fork() 반환값 설명

부모 프로세스 > 0 (자식의 pid 값) 자식이 누구인지 알 수 있음
자식 프로세스 0 부모로부터 만들어졌음을 뜻함
에러 발생 시 -1 프로세스 생성 실패

예제 1: 기본 fork()

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("A\n");
    fork();
    printf("B\n");
    return 0;
}

실행 과정:

  1. 프로그램 시작 → "A" 출력
  2. fork() 실행 → 복제 발생 → 부모, 자식 둘 다 아래 줄부터 실행
  3. 각자 "B" 출력

출력 결과:

A
B
B

예제 2: 반환값 구분

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();

    if (pid > 0) {
        printf("Parent: pid = %d, child pid = %d\n", getpid(), pid);
    } else if (pid == 0) {
        printf("Child : pid = %d, parent pid = %d\n", getpid(), getppid());
    }
}

출력 예시:

Parent: pid = 1000, child pid = 1001
Child : pid = 1001, parent pid = 1000

fork() 실행 후 프로세스 개수

  • fork() 1번 실행 → 2개 프로세스
  • fork() 2번 실행 → 최대 4개
  • fork() 3번 실행 → 최대 8개

일반적으로 fork() 호출 횟수가 n이면 최대 생성 프로세스 개수는 2ⁿ개입니다.


2. 부모(Parent) vs 자식(Child) 프로세스

차이점

항목 부모 프로세스 자식 프로세스

PID 다름 새 PID 할당
코드 같음 복사됨
변수 값 복사본 가짐 복사됨 (서로 영향 X)
파일 디스크립터 복사됨 둘 다 같은 파일에 접근 가능
메모리 영역 독립적 주소공간 복제 (Copy-on-write)

핵심: 독립된 메모리 공간

int x = 5;
pid_t pid = fork();

if (pid == 0) {
    x++;
    printf("child x = %d\n", x);
} else {
    printf("parent x = %d\n", x);
}

출력:

parent x = 5
child x = 6

자식이 x++를 해도 부모 x에는 영향이 없습니다. fork()는 복사본을 만들기 때문입니다.


3. PID 관련 함수

getpid() 함수

현재 실행 중인 프로세스의 PID(Process ID)를 반환하는 시스템 콜입니다.

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);

getppid() 함수

자신을 생성한 부모 프로세스의 PID를 반환하는 시스템 콜입니다.

#include <sys/types.h>
#include <unistd.h>

pid_t getppid(void);

예제: 부모와 자식 PID 출력

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("Child PID: %d, Parent PID: %d\n", getpid(), getppid());
    } else {
        printf("Parent PID: %d, Child PID: %d\n", getpid(), pid);
    }
    return 0;
}

출력 예시:

Parent PID: 1200, Child PID: 1201
Child PID: 1201, Parent PID: 1200

함수 정리

함수 역할 반환값

fork() 자식 프로세스 생성 부모에서는 자식 PID, 자식에서는 0
getpid() 자기 PID 조회 자기 고유 PID
getppid() 부모 PID 조회 부모의 PID

4. exit() 함수

기본 개념

exit()는 현재 프로세스를 종료시키는 시스템 콜입니다.

#include <stdlib.h>
void exit(int status);

인자로 종료 상태 코드(보통 0은 정상 종료)를 줍니다. main() 함수의 return과 거의 같지만, exit()는 즉시 프로세스 정리(clean-up)하고 커널로 돌아갑니다.

프로세스 종료 후 동작

  1. OS는 프로세스를 "terminated" 상태로 변경
  2. 해당 PID를 부모 프로세스에게 전달
  3. 부모가 wait()를 호출해서 종료 상태를 확인해야 함
  4. 그때까지는 프로세스가 "좀비(zombie)" 상태로 남아있음

예제

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    fork();
    printf("Hello\n");
    exit(0);
}

출력:

Hello
Hello

fork() 후 부모 + 자식 두 개 프로세스가 존재하고, 둘 다 printf("Hello") 실행 후 각각 자기 exit(0)으로 종료됩니다.

종료 순서

순서 보장은 없습니다. 운영체제가 스케줄링하면서 CPU를 어느 프로세스에 먼저 주느냐에 따라 달라집니다.

  • 부모가 먼저 exit()하면 → 자식은 "고아 프로세스(orphan)"가 되고, 커널이 자동으로 부모를 init 프로세스(PID 1)로 변경
  • 자식이 먼저 exit()하면 → 부모는 wait()을 통해 그 종료를 감지하고, 자식은 잠시 "좀비(zombie)" 상태로 남음

5. wait() 함수

기본 개념

부모 프로세스가 자식 프로세스의 종료를 기다리는 시스템 콜입니다.

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

역할

  1. 자식이 종료될 때까지 부모를 잠깐 멈춤(block)
  2. 자식의 종료 상태(status code)를 받아옴
  3. 종료된 자식 프로세스를 "좀비 상태"에서 제거 (시스템 자원 회수)

예제

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("Child process\n");
    } 
    else {
        wait(NULL);
        printf("Parent process\n");
    }
    return 0;
}

실행 결과:

Child process
Parent process

자식이 먼저 실행되고 종료한 후, 부모는 wait() 덕분에 자식이 끝난 뒤에야 "Parent process"를 출력합니다.

wait() 없는 경우

pid_t pid = fork();

if (pid == 0) {
    printf("Child\n");
    exit(0);
} else {
    printf("Parent\n");
}

출력 (순서 불확정):

Parent
Child

또는

Child
Parent

순서가 랜덤인 이유는 스케줄링(운영체제 결정) 때문입니다.


6. 좀비(Zombie)와 고아(Orphan) 프로세스

좀비(Zombie) 프로세스

정의: 자식이 이미 종료(exit() 완료)했는데, 부모가 아직 그 사실을 확인(wait)하지 않아서 커널이 정보를 남겨둔 상태

발생 이유: 부모가 wait()를 호출해야 "자식이 어떻게 죽었는지(exit code)"를 확인할 수 있기 때문

구조:

Parent (PID 1000)
  ├── wait() 아직 안 부름
  └── [계속 실행 중...]

Child (PID 1001)
  ├── exit(0)
  └── 상태: Zombie

좀비는 이미 메모리도 해제됐지만, 커널의 프로세스 테이블(process table)에 이름표만 남아있습니다. 부모가 wait()를 부르면 OS가 그 정보도 삭제("reap")합니다.

고아(Orphan) 프로세스

정의: 부모가 먼저 죽고, 자식이 아직 살아있는 경우

구조:

Parent (PID 1000)
  ├── fork() → Child (PID 1001)
  │              └── (아직 실행 중)
  └── exit(0)  ← 부모가 먼저 종료

이때 OS는 자식이 혼자 남지 않게 자동으로 "init 프로세스(PID 1)"를 새 부모로 붙여줍니다.

init (PID 1)
 └── Child (PID 1001)

비교표

구분 좀비(Zombie) 고아(Orphan)

발생 시점 자식이 먼저 종료했는데 부모가 아직 wait() 안 했을 때 부모가 먼저 종료했을 때
상태 자식은 죽었지만 커널에 정보 남음 자식은 살아 있고, 부모가 없음
해결 방법 부모가 wait() 호출 시 소멸 커널이 자동으로 init에 붙임
위험성 wait() 안 하면 좀비가 계속 쌓여 자원 낭비 자동 회수되므로 문제 거의 없음

7. 프로세스 테이블과 커널

exit() 이후 동작

  1. exit() 호출 → 자식 프로세스 종료 요청
  2. OS(커널)가 처리
    • 자식의 메모리 공간(memory space) 전부 회수 (code, stack, heap 다 해제)
    • 대신, 프로세스의 최소한의 정보만 커널 내부에 남김
[Process Table entry for PID 1010]
├─ pid = 1010
├─ ppid = 1000
├─ exit_code = 0
├─ status = ZOMBIE
  1. 부모가 wait() 호출
    • 커널이 저 테이블 항목을 보고
    • 부모에게 exit code를 전달
    • 테이블 엔트리 삭제 (완전 clean up)

프로세스 테이블(Process Table)

운영체제가 모든 프로세스의 상태를 관리하는 커널 내부의 데이터 구조입니다.

구조체 예시:

struct task_struct {
    pid_t pid;           // 프로세스 ID
    pid_t ppid;          // 부모 PID
    int state;           // 상태 (running, sleeping, zombie 등)
    int exit_code;       // 종료 코드
    void *kernel_stack;  // 커널 스택 주소
    struct mm_struct *mm;// 메모리 매핑 정보
    ...
};

메모리 영역 구분

영역 설명 접근 주체

사용자 메모리(User space) 코드, 스택, 힙 등 해당 프로세스
커널 공간(Kernel space) 프로세스 테이블 등 시스템 관리 데이터 운영체제(OS)

핵심:

  • 메모리는 이미 다 해제됨 (코드, 데이터, 스택 없음)
  • 하지만 커널이 갖고 있는 "프로세스 테이블"에 이름표만 남아있음
  • 그 이름표가 부모가 wait()로 확인할 때까지 존재

8. init 프로세스 (PID 1)

개념

init 프로세스는 운영체제가 부팅될 때 제일 처음 만드는 프로세스이자, 모든 프로세스의 궁극적인 부모입니다.

부팅 과정

커널 시작
   ↓
init 프로세스 (PID=1) 생성
   ↓
bash, systemd, 데몬, 사용자 프로그램 등 모두 여기서 뻗어나감

프로세스 계층 구조

init (PID=1)
 ├── bash (PID=120)
 │     ├── a.out (PID=240)
 │     └── ...
 └── sshd (PID=300)

고아 프로세스와의 관계

부모가 먼저 죽으면 OS는 자동으로 그 자식을 init 프로세스(PID 1)에 붙여줍니다.

Parent (PID=1000)
 └── Child (PID=1001)

→ 부모가 exit() 하면

init (PID=1)
 └── Child (PID=1001)

따라서 고아 프로세스는 절대 방치되지 않고, 커널이 init에게 새 부모를 지정해줍니다.


9. 함수 종합 정리

전체 관계

함수 실행 주체 동작 결과

fork() 부모 자식 생성 두 프로세스 실행
exit() 호출한 프로세스 자신 종료 부모에 종료 상태 전달
wait() 부모 자식이 끝날 때까지 대기 자식 상태 회수
getpid() 모든 프로세스 자신의 PID 숫자 반환
getppid() 모든 프로세스 부모의 PID 숫자 반환

프로세스 생애주기 전체 흐름

            fork()
        ┌───────────────┐
        │ Parent (1000) │
        └─────┬─────────┘
              │
              ▼
         Child (1001)
              │
              ├─ exit(0)
              ▼
      ┌───────────────────┐
      │ Process Table      │  ← 커널이 정보 잠시 보관
      │ PID=1001, PPID=1000│
      │ STATE=ZOMBIE       │
      └───────────────────┘
              │
              ▼
         Parent wait()
              │
              └──> 커널이 엔트리 삭제 (clean up)

10. 실전 예제

예제 1: 프로세스 개수 확인

int x = 5;
fork();
fork();
  • 첫 번째 fork: 부모+자식 2개
  • 두 번째 fork: 각각 또 복제 → 총 4개

예제 2: wait() 활용

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("Child start (pid=%d)\n", getpid());
        sleep(1);
        printf("Child exit\n");
        exit(0);
    } else {
        printf("Parent start (pid=%d)\n", getpid());
        sleep(5);
        wait(NULL);
        printf("Parent end\n");
    }
}

실행 순서:

시간 동작

0초 부모와 자식 둘 다 실행 시작
1초 자식 exit(0) 호출 → 좀비 상태
1~5초 부모는 sleep(5) 중 → 자식은 좀비로 존재
5초 부모 wait() 호출 → 좀비 제거 + 부모 계속 실행

출력:

Parent start (pid=3000)
Child start (pid=3001)
Child exit
Parent end

요약

핵심 개념

  1. fork() - 현재 프로세스를 복제하여 자식 프로세스 생성
  2. getpid() / getppid() - 자신의 PID, 부모의 PID 조회
  3. exit() - 현재 프로세스 종료 (메모리 해제, 커널에 정보 남김)
  4. wait() - 부모가 자식의 종료를 기다리고 완전히 제거
  5. 좀비 프로세스 - 종료됐지만 부모가 회수 안 한 상태
  6. 고아 프로세스 - 부모가 먼저 죽어 init(PID 1)에 붙음
  7. 프로세스 테이블 - 커널이 관리하는 모든 프로세스 정보
  8. init 프로세스 - PID 1, 모든 프로세스의 최상위 부모

중요 포인트

  • fork() 후 부모와 자식은 독립된 메모리 공간을 가짐
  • exit()는 호출한 프로세스만 종료
  • wait() 없이 자식이 종료되면 좀비가 됨
  • 부모 없는 자식은 자동으로 init에 붙음
  • 프로세스 테이블은 커널 영역에 존재

11. 시스템 콜(System Call)

개념

시스템 콜은 사용자 프로그램이 운영체제 커널의 기능을 요청하는 공식적인 통로입니다.

필요성

사용자 프로세스는 User Mode에서 실행되기 때문에 직접 하드웨어나 커널 데이터(파일, 메모리, 프로세스 테이블 등)에 접근할 수 없습니다. 따라서 시스템 자원을 사용하려면 반드시 OS에 요청해야 합니다.

User program
     ↓ syscall
Operating System (Kernel)

시스템 콜의 구조적 흐름

User mode
┌────────────────────────┐
│  printf(), fork(), ... │   ← (라이브러리 함수 호출)
└────────────┬───────────┘
             │
             ▼
       [syscall 인터페이스]
             │
             ▼
Kernel mode
┌────────────────────────┐
│  sys_fork(), sys_exit()│   ← (커널 내부 실제 함수)
│  sys_wait4(), sys_getpid()|
└────────────────────────┘

우리가 C에서 호출하는 fork()는 사실 커널 내부의 sys_fork()를 호출하는 "포장 함수(wrapper)"입니다. syscall 명령어로 CPU를 커널 모드로 전환시켜서 실행됩니다.


12. syscall() 함수 직접 사용

기본 형태

리눅스에서는 <unistd.h> 헤더에 정의된 syscall() 함수를 사용하면, 커널의 시스템 콜을 직접 호출할 수 있습니다.

#include <unistd.h>
#include <sys/syscall.h>

long syscall(long number, ...);
  • number: 시스템 콜 번호 (리눅스에서 각 콜마다 번호 있음)
  • ...: 해당 콜의 인자들

예제 1: getpid()를 syscall로 직접 호출

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

int main() {
    pid_t pid1 = getpid();                    // 일반 호출
    pid_t pid2 = syscall(SYS_getpid);         // 직접 syscall 호출

    printf("getpid() = %d\n", pid1);
    printf("syscall(SYS_getpid) = %d\n", pid2);

    return 0;
}

출력:

getpid() = 3120
syscall(SYS_getpid) = 3120

두 결과가 같습니다. getpid()는 결국 내부적으로 syscall(SYS_getpid)를 호출하는 것입니다.

예제 2: fork(), wait(), exit() 시스템 콜 연결

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/syscall.h>
#include <stdlib.h>

int main() {
    pid_t pid = syscall(SYS_fork);  // fork() 대신 syscall 직접 호출

    if (pid == 0) {
        printf("Child process (pid=%ld)\n", syscall(SYS_getpid));
        syscall(SYS_exit, 0);       // exit(0) 직접 호출
    } 
    else {
        printf("Parent waiting for child...\n");
        syscall(SYS_wait4, pid, NULL, 0, NULL); // wait(NULL)
        printf("Parent process end.\n");
    }

    return 0;
}

출력 예시:

Parent waiting for child...
Child process (pid=3150)
Parent process end.

예제 3: sleep()도 syscall

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

int main() {
    printf("Sleeping 2 seconds...\n");
    syscall(SYS_nanosleep, &(struct timespec){2, 0}, NULL);
    printf("Wake up!\n");
}

sleep(2) 내부에서는 실제로 SYS_nanosleep 시스템 콜을 호출합니다.


13. 시스템 콜의 동작 원리

CPU 단계별 처리

리눅스 커널은 syscall 명령어 하나로 유저 모드에서 커널 모드로 전환합니다.

1. User code: fork() 호출
2. glibc: syscall(SYS_fork)
3. CPU instruction: syscall
4. 커널 모드 진입
5. 커널 내부 함수 sys_fork() 실행
6. 새로운 프로세스 생성
7. 결과를 레지스터에 담아 사용자 모드로 복귀

이때 프로세스가 "User mode ↔ Kernel mode"로 전환됩니다.

시스템 콜 테이블 (Linux x86_64 기준)

함수 System Call 이름 번호 커널 내부 함수

fork() SYS_fork 57 sys_fork()
getpid() SYS_getpid 39 sys_getpid()
getppid() SYS_getppid 110 sys_getppid()
wait4() SYS_wait4 61 sys_wait4()
exit() SYS_exit 60 sys_exit()
nanosleep() SYS_nanosleep 35 sys_nanosleep()

이 번호들은 /usr/include/asm/unistd_64.h에 정의되어 있습니다.


14. 시스템 콜 전체 구조도

┌──────────────────────────┐
│     User Space (App)     │
│  e.g., printf(), fork()  │
└────────────┬─────────────┘
             │
             ▼
        glibc wrapper
      (C 라이브러리 함수)
             │
             ▼
┌──────────────────────────┐
│ syscall() instruction    │ ← 커널로 진입
└────────────┬─────────────┘
             ▼
┌──────────────────────────┐
│  Kernel Space            │
│  e.g., sys_fork(),       │
│        sys_exit(), ...   │
└──────────────────────────┘

시스템 콜 요약표

항목 설명

System Call 유저 모드에서 커널 기능 요청하는 공식 인터페이스
fork() sys_fork() 호출로 자식 프로세스 생성
getpid(), getppid() sys_getpid(), sys_getppid()
wait() sys_wait4() 호출로 자식 종료 기다림
exit() sys_exit() 호출로 프로세스 종료
sleep() sys_nanosleep() 호출로 일정 시간 block
syscall() 번호 기반으로 직접 커널 호출

15. 함수별 시스템 콜 정리

함수 역할 내부 시스템 콜

getpid() 내 PID 반환 SYS_getpid
getppid() 부모 PID 반환 SYS_getppid
sleep() 실행 일시 정지 SYS_nanosleep
exit() 현재 프로세스 종료 SYS_exit
wait() 자식 종료 기다림 SYS_wait4
fork() 자식 프로세스 생성 SYS_fork

핵심 정리

fork(), exit(), wait(), sleep() 같은 함수는 사실 모두 syscall() 인터페이스를 통해 커널 내부의 sys_XXXX() 함수를 호출하는 시스템 콜입니다.


16. System Call Wrapper의 본질

한줄 요약

fork(), getpid() 같은 함수들은 사실 "system call의 포장(wrapper)"입니다. 내부적으로 syscall() 인터페이스를 통해 커널의 진짜 함수(sys_fork, sys_getpid)를 호출합니다.

구조적 흐름

사용자 프로그램 (user space)
│
│  fork();             ← 우리가 C에서 부르는 함수
│   ↓
│  └─ glibc (C 표준 라이브러리)
│         ↓
│     syscall(SYS_fork) ← 운영체제 진입 인터페이스
│         ↓
커널 (kernel space)
│
└─ sys_fork()          ← 진짜 fork 동작 (프로세스 생성)

우리가 쓰는 fork()는 진짜 시스템 콜이 아니라, 시스템 콜을 대신 불러주는 C 라이브러리 함수("wrapper")입니다.


17. 코드 레벨 비교

일반적인 fork() 호출

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork(); // glibc 내부에서 syscall(SYS_fork)를 호출함
    if (pid == 0)
        printf("Child pid=%d\n", getpid());
    else
        printf("Parent pid=%d, child=%d\n", getpid(), pid);
}

내부적으로 glibc는 이렇게 동작합니다:

pid_t fork(void) {
    return (pid_t) syscall(SYS_fork);
}

즉, fork() = syscall(SYS_fork) 동작은 완전히 같고, 우리가 쓰기 쉽게 감싸놓은 것입니다.

직접 syscall() 호출 버전

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

int main() {
    pid_t pid = syscall(SYS_fork); // 커널에 직접 요청

    if (pid == 0)
        printf("Child pid=%ld\n", syscall(SYS_getpid));
    else
        printf("Parent pid=%ld\n", syscall(SYS_getpid));
}

출력은 위의 코드와 완전히 같습니다. 다만 이번엔 glibc wrapper를 거치지 않고 직접 커널을 호출했을 뿐입니다.


18. Wrapper를 사용하는 이유

glibc wrapper의 추가 기능

fork()를 syscall()로 직접 호출하지 않는 이유는 glibc wrapper가 단순히 커널 호출만 하는 게 아니기 때문입니다.

추가 기능:

  • 오류 처리 (errno 설정)
  • 매개변수 유효성 검사
  • 반환값 변환 (long → int 등)
  • 일부 플랫폼별 호환성 처리

즉, 우리가 편하게 쓰는 함수(fork, getpid, exit 등)는 이런 편의 기능을 덧씌운 "system call wrapper"입니다.

전체 구조도

┌────────────────────────────────────┐
│        User Space (사용자 모드)      │
│                                    │
│   [C 코드]                         │
│   fork();  → glibc wrapper         │
│             → syscall(SYS_fork)    │
└────────────┬───────────────────────┘
             │  (CPU: syscall 명령)
             ▼
┌────────────────────────────────────┐
│        Kernel Space (커널 모드)     │
│                                    │
│   sys_fork()                       │
│   sys_getpid()                     │
│   sys_exit()                       │
│   ...                              │
└────────────────────────────────────┘

19. 모드 전환의 핵심

비교표

구분 fork(), getpid() (일반 함수) syscall() (직접 호출) 커널 내부 함수

실행 위치 user mode user mode (→ kernel로 진입) kernel mode
역할 커널 요청 포장(wrapper) 커널 호출 인터페이스 실제 시스템 동작 수행
예시 fork(), getpid() syscall(SYS_fork) sys_fork()
접근 가능 파일, 표준입출력 등 제한적 동일 전체 커널 자원 접근 가능

CPU 관점의 실행 흐름

  1. User 모드에서 fork() 호출
  2. glibc가 내부적으로 syscall 명령 실행
  3. CPU가 커널 모드(Kernel mode)로 전환
  4. 커널이 sys_fork() 실행 → 자식 프로세스 생성
  5. 결과를 반환하고 User 모드로 복귀

이것을 "user → kernel → user"의 control transfer (exceptional control flow)라고 합니다.


20. 레벨별 정리

계층 구조표

이름 레벨 기능 호출 예

fork() user space C 라이브러리 wrapper pid_t pid = fork();
syscall() user→kernel 인터페이스 커널 함수 직접 호출 syscall(SYS_fork);
sys_fork() kernel space 실제 자식 프로세스 생성 커널 내부 함수

최종 요약

fork() = 사용하기 쉽게 만들어진 syscall()의 포장 버전(wrapper)

결국 fork(), getpid() 모두 내부적으로 syscall(SYS_XXX) 호출을 실행하여:

  1. CPU는 user mode → kernel mode로 변경
  2. 커널 함수(sys_fork, sys_getpid, ...)가 진짜 일을 수행
  3. 결과 반환 후 user mode로 복귀

이 과정이 운영체제의 "Exceptional Control Flow"의 핵심입니다.


21. syscall() 직접 호출의 장점

기본 개념 재정리

구분 설명

fork(), getpid() 등 glibc가 제공하는 wrapper 함수 (일반적으로 우리가 쓰는 함수)
syscall() glibc를 거치지 않고 커널 시스템 콜을 직접 호출하는 함수
sys_fork() 커널 내부 함수 (사용자는 접근 불가)

syscall()은 glibc (유저레벨 라이브러리)를 건너뛰고, 커널 시스템콜 인터페이스에 바로 접근하는 방법입니다.

흐름도 비교

일반 fork():
User Code → glibc wrapper → syscall 명령 → Kernel (sys_fork)

syscall():
User Code → syscall 명령 → Kernel (sys_fork)

syscall()은 중간에 있는 glibc 포장(wrapper) 단계를 건너뜁니다.


22. syscall() 직접 호출의 장점

(1) glibc 없이도 커널 기능 직접 호출 가능

glibc 라이브러리가 없는 초저수준 환경에서도 커널 기능을 바로 사용할 수 있습니다.

사용 예시:

  • CTF(pwnable) 문제에서 shellcode 작성
  • 리눅스 init 프로세스 제작
  • bare-metal 환경에서 system call 디버깅
syscall(SYS_write, 1, "Hello", 5);

이는 write(1, "Hello", 5)와 동일하지만 glibc 없이 동작합니다.

(2) glibc wrapper의 부가 로직 제거

glibc 함수들은 오류 처리(errno 설정), locale 지원, 쓰레드 안전성 보장 등 부가 동작을 많이 수행합니다. low-level 테스트 시에는 이런 것이 방해될 수 있습니다.

syscall()은 커널 호출만 수행하므로 결과가 100% 커널 그대로입니다.

예를 들어 read()로 -1 리턴이 오면 glibc는 errno 세팅을 하지만, syscall(SYS_read, ...)은 그대로 -1만 돌려줍니다. 즉, "진짜 커널 리턴값"을 볼 수 있습니다.

(3) 시스템 콜 레벨에서 테스트/트레이싱 가능

보안/시스템 연구자들은 실제 어떤 syscall이 호출되는지 확인해야 합니다. 직접 syscall()을 사용해서 커널 호출 레벨을 실험할 수 있습니다.

long ret = syscall(SYS_open, "test.txt", O_RDONLY);
printf("Return from SYS_open = %ld\n", ret);

glibc open()을 사용하면 숨겨지는 내부 호출을 직접 관찰할 수 있습니다.

(4) 커널 버전에 따라 새 시스템 콜을 미리 실험 가능

새로운 리눅스 커널이 나올 때, glibc는 그 시스템 콜을 아직 지원하지 않을 수도 있습니다. 하지만 번호(SYS_...)만 알면, glibc 업데이트를 기다리지 않고 syscall()로 직접 사용할 수 있습니다.

syscall(548, arg1, arg2, arg3);

커널 6.x의 새로운 시스템콜 번호가 548이라면, glibc가 몰라도 호출 가능합니다.

(5) 보안/익스플로잇 연구, 샌드박스 환경에서 직접 제어 가능

해킹 실습(예: pwnable.kr, dreamhack)에서 "직접 syscall"을 사용해서 쉘코드(int 0x80, syscall 명령) 작성이 가능합니다.

시스템 콜 번호를 직접 지정할 수 있으므로 glibc에 의존하지 않고 스택에 문자열만 있어도 호출 가능합니다.

x86-64 쉘코드 내부 구조 예시:

mov rax, 59        ; SYS_execve
mov rdi, rbx       ; "/bin/sh" 주소
xor rsi, rsi
xor rdx, rdx
syscall

이는 glibc 없이 커널을 직접 명령하는 수준의 호출입니다.


23. syscall() 사용의 제한

대부분의 일반 프로그램은 syscall() 불필요

이유 설명

glibc가 이미 안전하고 편리하게 감싸줌 오류처리, 호환성, 스레드안전성 등
system call 번호는 커널 버전마다 달라질 수 있음 직접 번호 사용 시 이식성 문제
wrapper 함수가 코드 가독성 향상 fork() vs syscall(SYS_fork)

대부분의 코드에서는 그냥 fork(), getpid(), write()를 사용합니다. syscall()은 디버깅용, 저수준 실험용, 보안연구용으로만 사용하는 것이 일반적입니다.

전체 구조도

┌────────────────────────────────────┐
│          User Program              │
│ ┌──────────────────────────────┐   │
│ │ fork(), getpid() (glibc)     │   │ ← 보통 우리가 쓰는 레벨
│ │ └─ 내부에서 syscall() 호출    │   │
│ └────────────┬─────────────────┘   │
│              ▼                     │
│       syscall(SYS_XXX)             │ ← 저수준 직접 호출 (같은 결과)
│              ▼                     │
│         커널 모드 진입             │
│              ▼                     │
│         sys_fork(), sys_getpid()   │ ← 커널 내부 실제 동작
└────────────────────────────────────┘

사용 시나리오별 정리

언제 사용 방법 이유

일반 프로그래밍 fork(), getpid(), exit() 등 wrapper 사용 편하고 안전함
시스템 프로그래밍/보안 연구 syscall() 직접 사용 커널 동작을 실험, 트레이스, 디버깅
커널 레벨 제어 필요 syscall() 직접 사용 glibc 없는 환경 (부트로더, shellcode, init 등)

결론

syscall()을 직접 사용하면 glibc를 통하지 않고 커널 시스템콜을 직접 호출할 수 있습니다. 일반 프로그램에는 필요 없지만, 시스템 수준 제어, 보안 연구, 커널 테스트, 저수준 환경에서는 필수적입니다.


24. glibc(GNU C Library) 이해하기

glibc란?

glibc (GNU C Library)는 C 언어의 표준 함수 라이브러리이자, 운영체제 커널을 호출하기 위한 "인터페이스 계층(library layer)"입니다.

쉽게 말하면: 우리가 C 코드에서 사용하는 printf(), fork(), getpid(), malloc(), sleep() 등의 함수들 대부분은 실제로는 glibc가 제공하는 함수입니다.

glibc의 역할 구조

사용자 코드 (user space)
│
│   printf("hi");
│   fork();
│   malloc(100);
│   sleep(1);
│
└──→ glibc 라이브러리
        │
        ├─ 내부적으로 syscall(SYS_write), SYS_fork 등 호출
        │
        └──→ 커널 (Linux kernel)

우리가 printf()라고 호출하면 glibc의 함수 printf()가 실행되고, 내부에서 커널 system call (write)을 호출해서 화면에 출력합니다.


25. glibc가 없다면?

glibc 없이 프로그래밍

glibc가 없다면:

  • printf()도, malloc()도, fork()도 없음
  • 커널을 직접 syscall()로 호출해야 함 (번호까지 직접 지정)

glibc 없이 "Hello" 출력:

#include <unistd.h>
#include <sys/syscall.h>

int main() {
    syscall(SYS_write, 1, "Hello\n", 6); // 1 = stdout
    syscall(SYS_exit, 0);
}

glibc가 있으면:

#include <stdio.h>
int main() {
    printf("Hello\n");
}

glibc는 이런 저수준 syscall을 감싸서 "편하고 안전하게 사용할 수 있게 만든 라이브러리"입니다.


26. glibc의 시스템 구조 위치

계층 구조

┌──────────────────────────────┐
│         User Space           │
│ ┌──────────────────────────┐ │
│ │  Application (C code)    │ │
│ │  ├── printf(), fork()    │ │
│ │  └── malloc(), sleep()   │ │
│ └──────────┬───────────────┘ │
│            ▼                 │
│        glibc (C Library)     │ ← system call wrapper
│            ▼                 │
└────────────┬─────────────────┘
             ▼
┌──────────────────────────────┐
│        Kernel Space          │
│   sys_fork(), sys_exit(), ...│
└──────────────────────────────┘

glibc는 커널이 아니며, 커널에 요청을 전달하는 "유저 공간의 C 함수 집합"입니다.


27. glibc가 제공하는 3가지 핵심 기능

역할 설명

시스템콜 wrapper fork(), read(), getpid() 등은 내부에서 syscall() 호출
표준 C 함수 제공 printf(), malloc(), strlen() 등 ANSI C 표준 함수
런타임 초기화 프로그램 시작 시 main 이전 초기화 (_start, atexit 등)

fork() 내부 구조 예시

pid_t fork(void) {
    return (pid_t) syscall(SYS_fork);
}

우리가 fork()를 호출하면:

  1. glibc의 fork() 함수가 실행
  2. 내부에서 syscall(SYS_fork) 호출
  3. 커널의 sys_fork() 함수 실행
  4. 프로세스 생성

결국 아래 둘은 같은 일을 수행합니다:

fork();
syscall(SYS_fork);

28. glibc의 제작 및 위치

제작 주체

  • GNU 프로젝트(GNU's Not Unix)에서 제작한 C 표준 라이브러리
  • 리눅스, 대부분의 유닉스 계열 OS에서 사용
  • BSD 계열(macOS 등)은 libSystem, Windows는 msvcrt.dll 등 자체 구현 사용

파일 시스템 위치

리눅스에서는 glibc가 다음 경로에 존재합니다:

/lib/x86_64-linux-gnu/libc.so.6

확인 명령:

ldd /bin/ls

결과 예시:

linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0c4b8e0000)

ls 같은 프로그램도 결국 glibc(libc.so.6)를 통해 syscall을 호출합니다.


29. glibc와 커널의 관계 최종 정리

비교표

구분 glibc 커널

위치 유저 공간 (User Space) 커널 공간 (Kernel Space)
역할 시스템콜을 포장(wrapping)해서 제공 시스템콜 실제 구현
예시 함수 fork(), printf(), malloc() sys_fork(), sys_write()
수정 주체 GNU / 배포판 Linux kernel 개발자
접근 권한 일반 프로그램 root, OS 내부

최종 요약

glibc는 사용자 프로그램과 커널 사이의 중간 계층으로, 시스템 콜을 편리하고 안전하게 사용할 수 있도록 포장(wrapper)해주는 표준 C 라이브러리입니다. 대부분의 C 함수들은 내부적으로 syscall()을 통해 커널의 실제 기능을 호출하며, 이 과정에서 오류 처리, 호환성 보장, 스레드 안전성 등의 부가 기능을 제공합니다.