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()의 핵심 동작
- 프로세스 이미지 교체: 현재 프로세스의 메모리를 완전히 새로운 프로그램으로 교체
- PID 유지: 프로세스 ID는 그대로, 내용만 바뀜
- 메모리 초기화: 스택, 힙, 코드 영역 모두 새 프로그램으로 교체
- 성공 시 반환 없음: 원래 코드가 사라지므로 다음 줄로 넘어가지 않음
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()가 하는 일
- 완전한 복사본 생성: 현재 프로세스의 코드, 메모리, 파일 디스크립터 등 모든 것을 복사
- 동시 존재: 부모와 자식 프로세스가 동시에 존재하게 됨
- 독립적 실행: 각각 독립적으로 실행됨
부모와 자식 구분 방법
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)를 부여
실행 흐름
- printf("fork 전...") → 부모 프로세스가 출력
- pid_t pid = fork(); → 여기서 프로그램이 둘로 분할
- 부모 프로세스: pid 변수에 "자식 PID" 저장
- 자식 프로세스: pid 변수에 0 저장
- if (pid == 0) → 자식 프로세스 실행
- "자식: PID = ..." 출력
- 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(): 자식 종료 후 부모가 다시 프롬프트 표시
왜 이렇게 복잡하게 할까?
- 프로세스 격리: 각 명령어는 독립된 프로세스에서 실행되어 서로 영향을 주지 않음
- 쉘 보존: 명령어가 실행되어도 쉘 자체는 살아있어 다음 명령어를 받을 수 있음
- 안전성: 명령어가 크래시되어도 쉘은 영향받지 않음
메모리 관점에서의 이해
[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는 자기를 복사해서 자식을 만들고, 그 자식이 다른 프로그램으로 변신하는 방식으로 명령어를 실행하면서도 자신은 유지.
다음에는 파이프(|)나 리디렉션(>, <) 등이 어떻게 구현되는지도 다뤄볼 것.
'system' 카테고리의 다른 글
[시스템 프로그래밍] Tiny Shell 프로젝트: 잡 컨트롤, 시그널, 레이스 컨디션 다루기 (0) | 2025.09.09 |
---|---|
[시스템 프로그래밍] execve() 시스템 콜 C vs 어셈블리 비교 분석 (0) | 2025.09.08 |
[시스템 프로그래밍] execve() 시스템 콜로 이해하는 프로세스 교체 원리 (0) | 2025.09.08 |
[시스템 프로그래밍] fork() 시스템 콜로 이해하는 프로세스 복제 원리 (0) | 2025.09.08 |
[시스템 보안] 버퍼 오버플로우 (0) | 2025.09.08 |