이 글에서 등장하는 모든 STDIN, STDOUT, STDERR은 파일디스크럽터 0, 1, 2를 조금 더 직관적으로 이해하기 쉽게 사용하려고 제 미니쉘 코드에 define 해 놓은 매크로 입니다. 보통은 STDIN_FILENO 혹은 0으로 사용합니다.
# include <unistd.h>
int dup2( int fd, int fd2 );
파일 식별자를 복제해 fd2를 fd1으로 바꾼다.
예를 들어 int dup2(fd, stdout);
와 같이 사용하면, 모든 출력이 fd로 향하게 된다. 즉, dup2 함수를 이용하면 부모프로세스가 자식프로세스에게 표준입력으로 문자열을 주는 프로그램을 만들 수 있게된다.
파이프의 기본 원리이다.
파이프를 이해하는데 이 글이 매우매우 도움이 되었습니다. 꼭 참고해보세요!
유닉스(Unix)는 단순하지만 매우 가치있는 디자인 철학을 갖고 있는데, 유닉스 파이프(Pipe)의 창시자인 Doug McIlroy
는 다음과 같이 말했다.
“한 가지 일만 아주 잘하는 프로그램들을 작성하라. 프로그램들이 다른 프로그램들과 함께 일할 수 있도록 작성하라. 프로그램들이 텍스트 스트림을 처리할 수 있도록 작성하라. 왜냐하면 그것은 보편적인 인터페이스이기 때문이다.”
pipe(fd[2])
시스템 호출이다.파이프라인(pipeline)
이라고 부른다. 파이프
에 의해 연결되어 있는 것이다.읽기
위한 것이고, 다른 하나는 데이터에 쓰기
위한 것이다.pipe(fd[2])
호출은 fd 배열 {3, 4}를 채우게되고, 따라서 fd 4
에 쓰여진(written) 데이터가 fd 3
으로부터 읽히도록(read) 만든다.읽기(read)
, 쓰기(write)
액션은 파이프를 사용하는 양쪽의 2개의 프로세스
들의 관점에서 정의된다.A | B | C | D
위와 같은 파이프라인이 있다고 생각했을 때, 처음에 내가 파이프를 이해 느낌은 "A의 표준출력이 파이프를 쭉 타고가서 D가 표준입력으로 받아서 터미널에 출력한다." 였다.
그런데 아니었다.
왜냐면 저러면 B, C 명령어는 실행되지 못하니까...
그냥 파이프는 무조건 앞|뒤
에서만 끝난다고 이해하는게 맞는 것 같다. "A | B 에서 끝나고, B의 명령어를 실행해서 표준출력이 생겼는데 만약 뒤에 또 파이프가 있다면 C의 표준입력으로 넘겨준다." 이게 파이프에 대한 더 정확한 표현이다.
아래 그림은 6.3 의 예제를 간략화한 버전이다. pipe()를 호출하고 fork()와 dup2()를 통해 아래 그림의 명령을 실행해보자. 파이프를 사용해 부모 프로세스로부터 자식 프로세스로 데이터가 전달되는 경우를 배울 수 있다.
다시 상기하고 넘어가기.
fd[1] 에 쓰고 fd[0] 으로 읽는다.
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
int fds[2]; // 2개의 fd를 담을 배열을 정의한다.
pipe(fds); // pipe를 호출해 두 개의 fd로 배열을 채워준다.
pid_t pid = fork(); // 부모 프로세스의 fd와 메모리를 복제한 자식 프로세스 생성한다.
if (pid == 0) { // if pid == 0, 자식 프로세스
dup2(fds[0], STDIN_FILENO); // fds[0]으로 표준입력을 넘겨준다.
close(fds[0]); // fds[0]은 자식 프로세스에서 더이상 필요하지 않기 떄문에 닫아준다. 복사본이기 때문에(?)
close(fds[1]); // 원래부터 필요없었던 fd라 닫아준다.
char *cmd[] = {(char *)"sort", NULL}; // sort 명령어 인자를 만들어준다.
if (execvp(cmd[0], cmd) < 0)
exit(0); // sort 명령어 실행하고 문제있으면 exit
}
// 부모 프로세스 코드 시작
close(fds[0]); // 쓰기만 하면되는 부모 프로세스에서는 필요 없는 fd라 닫아준다.
const char *words[] = {"pear", "peach", "apple"}; // 자식 프로세스에서 읽을 write input
for (int i = 0; i < 3; i++) {
dprintf(fds[1], "%s\n", words[i]); // fds[1]에 출력을 쓴다.
}
close(fds[1]);
int status;
pid_t wpid = waitpid(pid, &status, 0); // 자식 프로세스가 종료될때까지 기다린다.
return (wpid == pid && WIFEXITED(status) ? WEXITSTATUS(status) : -1);
}
fds[0, 1] 배열의 용도를 정의하는게 핵심이라고 6.3에서 정리했다. fds[0]는 파이프가 자식 프로세스에서 입력을 읽는데 사용하고, fds[1]은 부모 프로세스로부터 출력을 쓰는데 사용된다.
dup2()
함수를 호출하여 자신의 stdin
을 자신의 read( 자식프로세스 왼쪽 끝)와 연결시킨다.stdin
은 더 이상 키보드가 아니라 fds[0]에서 데이터를 입력받게 된다.stdin
은 데이터를 읽어들일 준비가 되었으며, pipe()
호출에 의해 생성된 fd는 이제 더 이상 필요 없으므로 fd를 닫는다.execvp()
함수를 호출하여 sort
명령을 실행하고, 부모 프로세스의 모든 데이터가 파이프의 read(파이프의 왼쪽 끝)로 쓰여질 때가지 대기한다.fds[1]
을 닫는다.sort
명령을 수행한다.이 코드를 보고 힌트를 얻었다. 프로그램 명령어(bin/ls 등)와 파이프 둘다 fork를 사용해 자식프로세스를 만들어야 하는 함수니까 합쳐도 되지 않을까?
그런데 만들고 보니 함수가 길어지고, 함수는 기능별로 짜는게 좋다는 얘기를 하도 들어서 이 방법이 좋다고 추천할 수는 없을 것 같다. 다만 새로운 시도를 하면서, 남들이 안하는 방식으로 직접 코드를 구현해보면서 정말 많이 배운 것 같다. kycho님이 많이 도와주셨다. 그리고 아예 파이프에 대한 개념, 예제 코드 공부를 저 microshell 코드를 보면서 하는게 좋은 건 확실할 듯!
다만 우리 코드로는 앞 노드의 flag에 접근할 수 없어서, 파이프 플래그가 있으면 fd를 현재 노드에서 열어주는게 아니라 pipe(next_cmd->fds)
와 같은 식으로 다음 노드에서 파이프를 열고, 입력과 출력을 뒷 노드를 기준으로 연결하는 방식으로 구현했다.
bash-3.2$ ls | exit
bash-3.2$
위 경우 bash 쉘이 꺼지면 안된다. 미니쉘도 마찬가지.
자식 프로세스는 부모 프로세스의 값에 영향을 미칠 수 없기 때문이다. 파이프가 나오면 앞 프로세스의 파이프 플래그를 설정해줬기 때문에 exit도 파이프 플래그 값을 가지던지, 혹은 동작하지 않도록 수정해야 했다. 후자가 더 편할 것 같아서 preflag라는 구조체 멤버를 추가했고 ft_exit 에서 preflag가 파이프면 return ; 을 해서 exit가 동작하지 않도록 했다. export와 unset도 마찬가지.