system

[시스템 프로그래밍] fork()와 execve()로 이해하는 bash의 명령어 실행 원리

숨usm 2025. 9. 8. 19:24

bash 쉘이 어떻게 명령어를 실행하면서도 자기 자신은 유지하는지에 대해 학습한 내용 정리

터미널에서 ls, cat, gcc 같은 명령어를 입력할 때 bash 작동 방식 - execve()와 fork() 시스템 콜을 통해 이해하기


1. execve() 시스템 콜의 이해

기본 문법

int execve(const char *pathname, char *const argv[], char *const envp[]);

매개변수 설명:

  • pathname: 실행할 프로그램의 경로
  • argv[]: 명령행 인자 배열 (NULL로 끝남)
  • envp[]: 환경변수 배열 (NULL로 끝남)

반환값:

  • 성공하면 반환하지 않음 (원래 프로그램이 완전히 교체되므로)
  • 실패하면 -1

execve() 예제 코드

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

int main() {
    char *const args[] = {"/bin/sh", NULL};
    char *const env[] = {NULL};
    
    if (execve("/bin/sh", args, env) == -1) {
        perror("execve 실패");
        return 1;
    }
    
    return 0;  // 이 줄은 절대 실행되지 않음!
}

execve()의 핵심 동작

  1. 프로세스 이미지 교체: 현재 프로세스의 메모리를 완전히 새로운 프로그램으로 교체
  2. PID 유지: 프로세스 ID는 그대로, 내용만 바뀜
  3. 메모리 초기화: 스택, 힙, 코드 영역 모두 새 프로그램으로 교체
  4. 성공 시 반환 없음: 원래 코드가 사라지므로 다음 줄로 넘어가지 않음

execve() 실행 과정

1. execve_test 프로세스 시작
2. execve("/bin/sh", ...) 호출
3. 프로세스 이미지가 sh로 완전 교체
4. 새로운 쉘이 시작됨
5. 원래 코드의 return 0은 절대 실행되지 않음

2. bash는 어떻게 명령어를 실행하면서 자신은 유지하나?

💡 핵심 아이디어: fork() + execve()

bash는 execve()를 직접 호출하지 않고, 먼저 자신을 복사한 후 복사본에서 execve()를 실행합니다.

// bash의 실제 동작 (단순화)
pid_t pid = fork();        // 자식 프로세스 생성
if (pid == 0) {
    execve("/bin/ls", ...); // 자식에서만 교체
} else {
    wait(&status);          // 부모는 자식 종료 대기
}

프로세스 트리 구조

bash (부모)
  ├─ ls (자식) → execve()로 변환됨
  ├─ cat (자식) → execve()로 변환됨  
  └─ gcc (자식) → execve()로 변환됨

단계별 실행 과정

bash (원본 - 집)
  ↓ fork() - 복사본 생성
bash (복사본)
  ↓ execve() - 변신
ls (복사본이 변신함)
  ↓ 실행 완료 후 죽음
bash (원본)이 다시 활동 시작
  ↓ 프롬프트 출력
user@user-utm:~$ ← 다음 명령어 기다림

3. fork() 시스템 콜 상세 분석

fork()가 하는 일

  1. 완전한 복사본 생성: 현재 프로세스의 코드, 메모리, 파일 디스크립터 등 모든 것을 복사
  2. 동시 존재: 부모와 자식 프로세스가 동시에 존재하게 됨
  3. 독립적 실행: 각각 독립적으로 실행됨

부모와 자식 구분 방법

pid_t pid = fork();
if (pid == 0) {
    // 자식 프로세스: execve()로 새 프로그램 실행
    execve("/usr/bin/ls", args, env);
} else if (pid > 0) {
    // 부모 프로세스: 자식 종료까지 대기
    wait(&status);
} else {
    // fork() 실패
    perror("fork 실패");
}

📝 fork() 반환값

  • 자식 프로세스에서: 0 반환
  • 부모 프로세스에서: 자식의 PID(양수) 반환
  • 실패시: -1 반환

fork() 예제 코드

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

int main() {
    printf("fork 전: PID = %d\n", getpid());
    
    pid_t pid = fork();
    
    if (pid == 0) {
        printf("자식: PID = %d\n", getpid());
    } else {
        printf("부모: 자식 PID = %d\n", pid);
    }
    return 0;
}

예제 코드 분석

getpid()

  • 현재 실행 중인 프로세스 ID(PID)를 반환
  • 운영체제는 각 프로세스를 구분하기 위해 고유한 번호(PID)를 부여

실행 흐름

  1. printf("fork 전...") → 부모 프로세스가 출력
  2. pid_t pid = fork(); → 여기서 프로그램이 둘로 분할
    • 부모 프로세스: pid 변수에 "자식 PID" 저장
    • 자식 프로세스: pid 변수에 0 저장
  3. if (pid == 0) → 자식 프로세스 실행
    • "자식: PID = ..." 출력
  4. else → 부모 프로세스 실행
    • "부모: 자식 PID = ..." 출력

실행 결과 예시

fork 전: PID = 1234
부모: 자식 PID = 1235
자식: PID = 1235

또는

fork 전: PID = 1234
자식: PID = 1235
부모: 자식 PID = 1235

(부모/자식 출력 순서는 OS 스케줄링에 따라 달라질 수 있음)


4. bash의 명령어 실행 메커니즘

bash의 실제 동작 흐름

bash → fork() → bash(부모) + bash(자식)
                   ↓              ↓
               wait()         execve(ls)
                   ↓              ↓  
              프롬프트 출력      ls 실행 후 종료

쉘에서 명령 실행 구조 (단계별)

1단계: 사용자가 ls 입력

  • bash(부모 프로세스)가 입력을 읽음

2단계: fork()

  • bash는 자기 자신을 복사해서 자식 프로세스 생성
  • 이제 부모 bash + 자식 bash 두 개가 존재

3단계: 부모/자식 역할 분리

부모 bash:

  • fork() 반환값 = 자식 PID
  • 바로 wait() 호출해서 자식이 끝날 때까지 기다림
  • 그래서 부모는 프롬프트를 바로 못 띄우고 "대기" 상태

자식 bash:

  • fork() 반환값 = 0
  • 곧바로 execve("ls", ...) 실행
  • 자식 프로세스의 메모리 전체가 ls 프로그램 코드로 교체됨
  • 이제 더 이상 "자식 bash"가 아니라, ls 프로세스가 된 것

4단계: 실행 결과

  • ls 프로세스: 현재 디렉토리 내용을 출력 후 exit() → 종료
  • 부모 bash: wait()이 끝나고 다시 프롬프트($) 출력

구조

사용자: ls 입력
        │
        ▼
   bash (부모)
        │
   fork() 호출
   ┌───────────┐
   │           │
   ▼           ▼
bash(부모)   bash(자식)
 wait()       execve("ls")
   │              │
   │          ls 실행 후 종료
   ▼
프롬프트 다시 출력

5. 핵심 정리

각 시스템 콜의 역할

  • fork(): 쉘을 복제해서 자식 프로세스 만듦
  • execve(): 자식 프로세스를 ls 프로그램으로 교체
  • wait(): 부모가 자식이 끝날 때까지 기다림
  • exit(): 자식 종료 후 부모가 다시 프롬프트 표시

왜 이렇게 복잡하게 할까?

  1. 프로세스 격리: 각 명령어는 독립된 프로세스에서 실행되어 서로 영향을 주지 않음
  2. 쉘 보존: 명령어가 실행되어도 쉘 자체는 살아있어 다음 명령어를 받을 수 있음
  3. 안전성: 명령어가 크래시되어도 쉘은 영향받지 않음

메모리 관점에서의 이해

[fork() 전]
bash 프로세스 (PID: 1000)
├─ 코드 영역: bash 실행코드
├─ 데이터 영역: bash 변수들
└─ 스택 영역: bash 함수 호출스택

[fork() 후]
bash 부모 (PID: 1000)          bash 자식 (PID: 1001)
├─ 코드: bash 실행코드         ├─ 코드: bash 실행코드 (복사)
├─ 데이터: bash 변수들         ├─ 데이터: bash 변수들 (복사)
└─ 스택: bash 함수스택         └─ 스택: bash 함수스택 (복사)

[execve() 후]
bash 부모 (PID: 1000)          ls 프로세스 (PID: 1001)
├─ 코드: bash 실행코드         ├─ 코드: ls 실행코드 (교체됨!)
├─ 데이터: bash 변수들         ├─ 데이터: ls 변수들 (교체됨!)
└─ 스택: bash 함수스택         └─ 스택: ls 함수스택 (교체됨!)

마무리

fork()와 execve()를 조합하여 유닉스/리눅스 시스템의 프로세스 생성과 프로그램 실행이 이루어짐.

bash는 자기를 복사해서 자식을 만들고, 그 자식이 다른 프로그램으로 변신하는 방식으로 명령어를 실행하면서도 자신은 유지.

다음에는 파이프(|)나 리디렉션(>, <) 등이 어떻게 구현되는지도 다뤄볼 것.