Minishell (2) — 실행 구조: fork · pipe · heredoc · signal
Published:
Minishell 시리즈
- 파싱과 tree 구조 회고
- 실행 구조: fork · pipe · heredoc · signal ← 현재
Part 1 에서 내가 맡은 파서 얘기를 했다. 이번 편은 페어가 짠 실행부. 파서가 뱉은 t_mini[] 를 실제 프로세스로 띄우고, 파이프로 엮고, 리다이렉션을 붙이고, 끝나기를 기다리는 쪽. 내가 직접 짠 건 아니지만 파서와 맞물리는 접점에서 코드를 오래 들여다봤다.
전체 흐름 한 장
main → readline 루프
└─ process_input
├─ parsing (Part 1 영역)
├─ check_struct (문법 + env 확장)
└─ exec_cmd
├─ only_builtin ── 단일 빌트인 → 부모에서 실행
└─ 파이프 루프 (cnt 회):
├─ set_re_and_pipe ── 리다이렉션 + pipe()
├─ fork
│ ├─ child: execve or do_builtin
│ └─ parent: fd 정리, prev_pipe 전파
├─ origin_dup ── stdin/stdout 복원
└─ ft_wait ── 자식 전원 수거, $? 세팅
이 체인이 반복되면서 REPL 이 돈다. 각 블록을 짧게 훑어본다.
fork + execve — 새 프로그램 띄우기
fork() → 현재 프로세스 복제본 생성
execve() → 복제본을 새 프로그램으로 덮어씀
왜 두 단계인가. fork 직후 execve 직전 사이에 자식 환경을 조정할 수 있기 때문이다. 이 “사이” 가 shell 구현의 전부라고 봐도 된다.
// src/process.c
void exec_fork(t_params *p, int *cur_pipe)
{
pid_t id = fork();
if (id == 0) {
signal(SIGQUIT, SIG_DFL);
signal(SIGINT, SIG_DFL); // 자식: 기본 동작 복원
handle_pipe_close(p, cur_pipe); // fd 재배치
child_execve(p->data, p->env, p->i);
}
else if (id > 0)
parent_set(p->data, cur_pipe, &p->prev_pipe, p->i);
else
error_fork();
}
자식은 먼저 시그널 디폴트로 원복 (안 그러면 부모가 SIGINT 무시하는 걸 물려받아서 Ctrl+C 로 cat 을 못 죽인다). 그 다음 fd 재배치 후 execve.
Pipe — EOF 가 흐르려면 close 를 빼먹으면 안 됨
pipe(fd) 는 FIFO 하나 + 양끝 fd 두 개 (fd[0] 읽기, fd[1] 쓰기). 문제는 fork 후 부모/자식이 반대쪽을 반드시 close 해야 한다는 점.
$ cat | ls # ls 는 즉시 끝나야 하는데 안 끝난다
왜. 부모가 cur_pipe[1] 을 안 닫으면 cat 의 stdin 에 연결된 write end refcount 가 0 이 안 되고, reader 는 EOF 를 못 받는다. 커널은 write end 가 전부 close 될 때만 EOF 를 준다.
파이프 체인의 리듬:
iter 0 (ls):
pipe(cur_pipe)
fork → child: cur_pipe[1] 이 stdout
parent: close(cur_pipe[1]), prev_pipe ← cur_pipe[0]
iter 1 (grep):
pipe(cur_pipe) ← 새 파이프
fork → child: prev_pipe 가 stdin, cur_pipe[1] 이 stdout
parent: close(prev_pipe), close(cur_pipe[1]), prev_pipe ← cur_pipe[0]
iter 2 (wc) — 마지막:
pipe(cur_pipe)
fork → child: prev_pipe 가 stdin, stdout 은 터미널 유지
parent: close(prev_pipe), close(양쪽)
매 iteration 마다 새 파이프를 뚫고, 앞 파이프의 읽기쪽만 prev_pipe 로 들고 오고, 나머지는 전부 close. 이 리듬을 한 번만 깨도 파이프라인이 멈춘다.
리다이렉션 — 결국 dup2 두 줄
cmd > file 은 결국 이 세 줄로 수렴한다:
int fd = open("file", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); // stdout 이 이제 file
close(fd); // 원본 fd 정리
<, >, >>, << 네 분기가 각각 open 플래그만 다르게 해서 같은 패턴을 돈다. 자세한 건 src/redirection_set.c 에 분기별로 갈라져 있다.
파서가 리다이렉션 토큰을 set_cmd_null 로 NULL 덮어쓴 자리 (Part 1 참고) 를 실행기가 cmd_realoc 로 걸러서 execve argv 를 재조립한다. AST 였다면 redir 과 argv 가 처음부터 별도 필드라 이런 trick 이 필요 없다.
Heredoc — 추가 fork 가 필요한 이유
<< 는 다른 리다이렉션과 근본적으로 다르다. 사용자 입력을 런타임에 받는다.
cat << EOF
hello
world
EOF
cat 실행 전에 EOF 까지 터미널에서 한 줄씩 읽어서 파이프에 쏟아붓고, 파이프 읽기쪽을 cat 의 stdin 으로 붙인다.
parent shell
│
├─ pipe(fd)
├─ fork() ──→ heredoc child
│ └─ readline("> ") 루프
│ → EOF 만나면 close + exit
│
└─ dup2(fd[0], 0) ← cat 의 stdin
readline 루프를 왜 별도 프로세스 에서 돌리나. Ctrl+C 격리 때문이다.
heredoc 입력 중 Ctrl+C 는:
- heredoc 만 중단, 쉘은 살아남아야 함
- 본 명령(
cat) 실행도 건너뛰고 프롬프트로 복귀
같은 프로세스에서 longjmp 로 빠져나올 수도 있지만, 별도 프로세스 를 뜨면 자식이 SIG_DFL 로 깔끔하게 죽고 부모 쉘은 멀쩡하다. 구조적으로 훨씬 안전하다.
그리고 이 heredoc 자식 안에 두 번째 $ 확장기 가 있다. 일반 토큰 expander 는 파싱 시점에 돌지만, heredoc 은 사용자가 타자 치는 시점에 확장해야 하니까. 로직은 거의 같은데 구현이 parsing_env.c / heredoc.c 로 갈라져있다. Part 1 에서 말한 expander 두 벌 문제의 실물이 여기다.
빌트인 — 부모에서 돌려야 하는 것들
cd, export, unset, exit 은 부모 쉘의 상태를 바꿔야 한다. fork 안에서 돌리면 자식의 cwd/env 만 바뀌고 부모는 그대로다. bash -c '(cd /tmp)' 가 원래 쉘의 cwd 를 안 바꾸는 것과 같은 이유.
그래서 “파이프 없음 + 단독 빌트인” 이면 fork 를 건너뛰고 부모에서 직접:
// src/builtin_ready.c
int only_builtin(t_mini *data, t_env *env)
{
builtin_counter(data);
if (data->builtin_cnt == 1 && data->cnt == 1) {
redirection_set(data, env); // 부모에서 리다이렉션 적용
do_builtin(data, env); // 부모에서 빌트인 실행
return 1;
}
return 0;
}
파이프 안의 빌트인 (ls | export FOO=bar) 은 자식에서 실행되고 환경 변경은 버려진다. bash 도 똑같다.
여기서 파서가 만든 origin_in/out 백업이 의미를 가진다. cd > /tmp/x 같이 단독 빌트인에 리다이렉션이 붙으면 부모의 fd 1 을 파일로 돌려버리는데, 명령이 끝난 뒤 원본으로 복원하지 않으면 다음 프롬프트가 파일에 찍힌다.
// src/parsing.c
(*mini)[i].origin_in = dup(0);
(*mini)[i].origin_out = dup(1);
// src/process_utils.c (매 iter 끝)
close(0); close(1);
dup2(data[i].origin_in, 0);
dup2(data[i].origin_out, 1);
Signal — readline 과의 싸움
쉘이 아이들 (프롬프트 대기) 상태에서:
| 키 | 시그널 | Bash 동작 |
|---|---|---|
| Ctrl+C | SIGINT | 입력 라인 취소, 새 프롬프트 |
| Ctrl+\ | SIGQUIT | 무시 |
| Ctrl+D | EOF | 쉘 종료 |
void signal_main(void) {
signal(SIGQUIT, SIG_IGN);
signal(SIGINT, sigint_handler);
}
핸들러에서 write("\n") 만 하면 readline 내부 상태가 이전 입력을 기억하고 있어서 커서가 엉킨다. 그래서 세 함수를 꼭 불러야 한다:
void sigint_handler(int signal)
{
(void)signal;
g_signal = 130; // $? = 128 + 2
write(1, "\n", 1);
rl_on_new_line();
rl_replace_line("", 0); // readline 버퍼 비움
rl_redisplay(); // 프롬프트 다시 그림
}
rl_replace_line 은 macOS 기본 libedit 엔 없어서 Homebrew readline 을 링크해야 한다. 과제 세팅에서 자주 밟는 지뢰.
자식 실행 중에는 부모가 SIGINT 를 무시한다 (SIG_IGN). 아니면 Ctrl+C 한 방에 쉘까지 같이 죽는다. 자식 수거가 끝나면 다시 signal_main 으로 복원.
$? 는 단일 바이트에 두 역할
unsigned char g_signal; // 42 Norm: 전역 1개 제약
0~125: 직전 명령 exit code126: 찾았으나 실행 불가127: command not found128 + sig: 시그널로 종료 (SIGINT → 130, SIGQUIT → 131)
한 바이트에 $? 와 signal flag 를 같이 우겨넣는다. 전역을 하나만 쓰라는 과제 제약 때문. bash 는 더 긴 int + 별도 변수지만, 범위가 겹치지 않으니 실용적으론 문제 없다.
프로젝트 총 회고
잘한 것
- 팀 분업이 깔끔했다. 나는 파서, 페어는 실행기. 인터페이스는
t_mini[]하나로 고정. 초반에 구조체 필드를 어떻게 나눌지 앉아서 합의한 게 컸다. - 환경을
node/export두 벌로 분리. bash 의declare -x를 자연스럽게 흉내. - Heredoc 을 별도 fork 로. Ctrl+C 처리를
longjmp같은 잔재주 없이 프로세스 격리로 푼 건 깔끔했다. - 단독 빌트인 부모 분기.
cd,export,exit이 의미있게 동작하는 최소 요건.
못한 것 (구조)
파서를 flat 으로 짠 것. Part 1 에서 길게 썼다. 핵심 증상:
- expander 두 벌 (
parsing_env.cvsheredoc.c) - 리다이렉션을 토큰 배열에 NULL 로 덮어쓰는 trick
- 문법 검사가 인덱스 놀이
&&,||확장이 구조적으로 불가능
돌아보면 이게 뒤따른 모든 trick 의 진앙지다.
못한 것 (구현 디테일)
이건 구조 문제가 아니라 그냥 실수 / 검토 부족:
ft_wait이 마지막 자식 PID 가 아니라 아무거나 수거 —$?가 엄밀히 맞지 않을 여지. 과제는 통과하지만 bash 동작과 다름.- Heredoc 내
$?확장 시ft_itoa반환값 free 누락 — 호출 때마다 누수. - env 확장에서
ft_strlen(NULL)가능성 — 정의되지 않은 변수 확장 시 segfault 위험. - 파이프 자식의
origin_in/out누수 — 각t_mini마다 dup 을 떠놓고 정리는 첫 칸만. 긴 파이프라인 반복 실행 시 fd 누적. error_cmd2의 uninitialized str — 특정 경로에서 str 이 초기화되지 않은 채 write.-Werror에서 빌드 안 됨.
이런 건 구조와 무관하게, 조용히 앉아서 코드 리뷰 한 번 제대로 했으면 잡혔을 것들.
얻은 것
- fork-exec 모델을 몸으로 이해. execve 매뉴얼 페이지를 읽는 것과 파이프를 세 번 체이닝해본 것은 완전히 다른 경험.
- fd 관리 감각. close 를 빼먹으면 어떻게 멈추는지, dup2 가 어떻게 동작하는지, pipe refcount 가 왜 중요한지. 서버 프로그래밍 어디서 튀어나와도 이제 겁 안 난다.
- Signal handler 의 함정. readline 같은 라이브러리와 시그널이 어떻게 충돌하는지, async-signal-safe 가 왜 한정되는지.
- AST 의 필요성을 체감. 이론으로 “파서는 트리” 를 배운 것과 “flat 으로 짰다가 리다이렉션에서 꼬여서 씨름했다” 는 다르다. 다음에 DSL / 파서 얘기가 나오면 바로 AST 를 꺼낸다.
다음에 쉘을 또 만든다면
- 파서부터 AST —
CMD,PIPE,REDIR_*,HEREDOC다섯 노드. - Expander 한 모듈 —
expand(Word, env)인터페이스 하나, 타이밍만 caller 가 정함. - 자식 PID 리스트 저장 —
$?에 마지막 자식 status 만 반영. - fd 소유권을 구조적으로 관리 — 실행기 최상단에서 한 번만 뜨고 마지막에 정리.
- 회귀 테스트 스크립트 하나 —
ft_itoa누수 같은 건 간단한 스크립트로도 잡힌다.
한 줄 요약
Shell 은 파서와 fork, 두 얘기다. 파서를 flat 으로 짜면 fork 얘기까지 피곤해진다.
두 편을 관통하는 주장이 이거다. bash 소스를 다시 뜯어보면 왜 parse.y 의 AST 에서 시작해 execute_cmd.c 로 내려오는지, 그 흐름이 왜 그렇게 생겼는지, 이제는 훨씬 선명하게 읽힌다. 그게 이번 프로젝트의 가장 큰 소득.
