Minishell (2) — 실행 구조: fork · pipe · heredoc · signal

Published:

Minishell 시리즈

  1. 파싱과 tree 구조 회고
  2. 실행 구조: 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+CSIGINT입력 라인 취소, 새 프롬프트
Ctrl+\SIGQUIT무시
Ctrl+DEOF쉘 종료
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 code
  • 126: 찾았으나 실행 불가
  • 127: command not found
  • 128 + sig: 시그널로 종료 (SIGINT → 130, SIGQUIT → 131)

한 바이트에 $? 와 signal flag 를 같이 우겨넣는다. 전역을 하나만 쓰라는 과제 제약 때문. bash 는 더 긴 int + 별도 변수지만, 범위가 겹치지 않으니 실용적으론 문제 없다.


프로젝트 총 회고

잘한 것

  1. 팀 분업이 깔끔했다. 나는 파서, 페어는 실행기. 인터페이스는 t_mini[] 하나로 고정. 초반에 구조체 필드를 어떻게 나눌지 앉아서 합의한 게 컸다.
  2. 환경을 node / export 두 벌로 분리. bash 의 declare -x 를 자연스럽게 흉내.
  3. Heredoc 을 별도 fork 로. Ctrl+C 처리를 longjmp 같은 잔재주 없이 프로세스 격리로 푼 건 깔끔했다.
  4. 단독 빌트인 부모 분기. cd, export, exit 이 의미있게 동작하는 최소 요건.

못한 것 (구조)

파서를 flat 으로 짠 것. Part 1 에서 길게 썼다. 핵심 증상:

  • expander 두 벌 (parsing_env.c vs heredoc.c)
  • 리다이렉션을 토큰 배열에 NULL 로 덮어쓰는 trick
  • 문법 검사가 인덱스 놀이
  • &&, || 확장이 구조적으로 불가능

돌아보면 이게 뒤따른 모든 trick 의 진앙지다.

못한 것 (구현 디테일)

이건 구조 문제가 아니라 그냥 실수 / 검토 부족:

  1. ft_wait 이 마지막 자식 PID 가 아니라 아무거나 수거$? 가 엄밀히 맞지 않을 여지. 과제는 통과하지만 bash 동작과 다름.
  2. Heredoc 내 $? 확장 시 ft_itoa 반환값 free 누락 — 호출 때마다 누수.
  3. env 확장에서 ft_strlen(NULL) 가능성 — 정의되지 않은 변수 확장 시 segfault 위험.
  4. 파이프 자식의 origin_in/out 누수 — 각 t_mini 마다 dup 을 떠놓고 정리는 첫 칸만. 긴 파이프라인 반복 실행 시 fd 누적.
  5. 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 를 꺼낸다.

다음에 쉘을 또 만든다면

  1. 파서부터 ASTCMD, PIPE, REDIR_*, HEREDOC 다섯 노드.
  2. Expander 한 모듈expand(Word, env) 인터페이스 하나, 타이밍만 caller 가 정함.
  3. 자식 PID 리스트 저장$? 에 마지막 자식 status 만 반영.
  4. fd 소유권을 구조적으로 관리 — 실행기 최상단에서 한 번만 뜨고 마지막에 정리.
  5. 회귀 테스트 스크립트 하나ft_itoa 누수 같은 건 간단한 스크립트로도 잡힌다.

한 줄 요약

Shell 은 파서와 fork, 두 얘기다. 파서를 flat 으로 짜면 fork 얘기까지 피곤해진다.

두 편을 관통하는 주장이 이거다. bash 소스를 다시 뜯어보면 왜 parse.y 의 AST 에서 시작해 execute_cmd.c 로 내려오는지, 그 흐름이 왜 그렇게 생겼는지, 이제는 훨씬 선명하게 읽힌다. 그게 이번 프로젝트의 가장 큰 소득.