Pipex - Mandatory
Pipex
Summary: This project is the discovery in detail and by programming of a UNIX mechanism that you already know.
요약: 이 프로젝트에서는 여러분이 이미 알고 계신 UNIX 동작 원리를 프로그래밍을 통해 상세히 파헤쳐볼 것입니다.
개요
오랜만에 과제 리뷰입니다. 과제 하나를 끝낼 때마다 블로그에 리뷰 글을 작성했었는데, 그래픽 과제까지 마치고 작성하게 되어 기간이 길었습니다.
pipex와 minitalk은 2 써클 과제로, 데이터를 통신하는 공통점이 있습니다. minitalk는 데이터를 주고받는 서버와의 통신에 중점을 두고, pipex는 라 피신에서 다루었던 UNIX 동작에 중점을 두고 있습니다.
과제 pdf의 첫 페이지 요약에서 알 수 있듯이, '이미 알고 계신 UNIX 동작 원리를 프로그래밍으로 상세히 파헤쳐 볼 것입니다.' 우린 라 피신에서 수많은 쉘 명령어를 접했고, 응용하며 문제를 풀어나갔습니다. 이번에는 이를 C언어 코드로 작성하여 동작시킬 것입니다.
파일 디스크립터 개념을 조금 더 파헤쳐 파일은 물론, 입출력 스트림과 파이프라인에 접근하고, 프로세스 개념 학습으로 pipe와 fork 함수를 중심으로 프로세스 간 데이터 통신을 구현할 것입니다.
push_swap 과제가 알고리즘 구현으로 어려웠다면, pipex는 새로운 개념과 데이터 통신을 가시적으로 볼 수 없어 어렵습니다. 또한 여러 가지 경우에서 발생하는 에러를 처리하기도 까다로운 이유 중 하나입니다. 흐름을 많이 그려보고 해결하시는 것을 추천해 드립니다.
목표
Your objective is to code the Pipex program.
여러분의 목표는 Pipex 프로그램을 작성하는 것입니다.
It should be executed in this way:
프로그램은 다음과 같이 실행될 것입니다:
$> ./pipex file1 cmd1 cmd2 file2
Just in case: file1 and file2 are file names, cmd1 and cmd2 are shell commands with their parameters.
설명해 드리자면: file1과 file2는 파일명이고, cmd1과 cmd2에는 쉘 명령어와 그에 대한 인자값이 들어갑니다.
The execution of the pipex program should do the same as the next shell command:
pipex 프로그램의 동작 결과는 다음 명령줄을 쉘에서 실행할 때의 결과와 동일하여야 합니다.
$> < file1 cmd1 | cmd2 > file2
$> ./pipex infile ``ls -l'' ``wc -l'' outfile
should be the same as “< infile ls -l | wc -l > outfile”
“< infile ls -l | wc -l > outfile” 와 같이 동작하여야 합니다.
$> ./pipex infile ``grep a1'' ``wc -w'' outfile
should be the same as “< infile grep a1 | wc -w > outfile”
“< infile grep a1 | wc -w > outfile” 와 같이 동작하여야 합니다.
과제의 요구 조건을 살펴봅시다.
1. 실행 파일명은 반드시 pipex 이어야 합니다.
2. 반드시 오류를 세심하게 처리해야 합니다. 어떠한 이유에서도 프로그램이 예상치 못하게 중지, 종료되어서는 안됩니다.
3. 자신의 오류 처리에 확신이 들지 않는다면, < file1 cmd1 | cmd2 > file2 의 결과와 동일하게 처리하면 됩니다.
4. 사용 가능한 외부함수 : access, open, unlink, close, read, write, malloc, waitpid, wait, free, pipe, dup, dup2, execve, fork perror, strerror, exit
구현 전 사전 지식
1. 외부 함수
2. 파일 디스크립터
3. 파이프와 프로세스
1. 외부 함수
- access : int access(const char *path, int mode)
파일의 권한을 확인. path는 경로, mode는 읽기, 쓰기, 실행 권한을 입력. 성공시 0, 실패시 -1 반환. errno에 설정. 경로, 권한에 따라 실패.
- open : int open(const char *path, int flag)
파일 오픈. path는 경로, flag는 읽기, 쓰기, 읽기&쓰기 옵션을 입력. 성공시 파일 디스크립터, 실패시 -1 반환. errno에 설정. 경로, 옵션에 따라 실패.
- unlink : int unlink(const char *path)
파일을 삭제. path 경로. 성공시 0, 실패시 -1 반환. errno에 설정. 경로에 따라 실패.
- close : int close(int fd)
파일 디스크립터를 닫음. 성공시 0, 실패시 -1 반환. errno에 설정. 잘못된 파일 디스크립터로 실패.
- read : ssize_t read(int fd, void *buff, size_t count)
파일 디스크립터를 읽음. fd는 파일 디스크립터, buff는 데이터를 저장할 버퍼 포인터, count는 읽을 크기. 성공시 읽은 크기, 실패시 -1 반환. errno에 설정. 블로킹함수로 파일 디스크립터에 따라 기다리게 됨. 권한에 따라 실패.
- write : ssize_t write(int fd, const void* buff, size_t count)
파일 디스크립터에 출력. fd는 파일 디스크립터, buff는 쓸 데이터가 저장된 버퍼의 포인터, count는 쓸 크기. 성공시 출력 크기, 실패시 -1 반환. errno에 설정. 권한에 따라 실패.
- malloc : void *malloc(size_t size)
힙 메모리에서 지정된 크기의 메모리 블록을 할당. size는 할당할 메모리 블록의 크기. 성공시 해당 메모리 블록의 시작 주소, 실패시 NULL 반환. errno설정. 할당 크기에 따라 실패.
- waitpid : pid_t waitpid(pid_t pid, int *status, int options)
자식 프로세스의 종료를 기다리는 데 사용됨. 성공시 자식 프로세스의 ID, 실패시 -1 반환. errno에 설정.
- pid: 대기할 자식 프로세스의 ID를 지정합니다.
- pid > 0 : 지정된 PID의 자식 프로세스를 대기합니다.
- pid == -1 : 어떤 자식 프로세스든 종료될 때까지 대기합니다.
- pid == 0 : 현재 프로세스 그룹 ID와 같은 그룹 ID를 가지는 모든 자식 프로세스를 대기합니다.
- pid < -1 : 그룹 ID가 pid의 절댓값과 같은 모든 자식 프로세스를 대기합니다.
- status: 자식 프로세스의 상태 정보를 저장할 int 형 포인터입니다. 자식 프로세스의 종료 상태 등의 정보를 얻을 수 있습니다. 이 인수를 NULL로 전달하면 상태 정보를 얻지 않고 대기만 수행합니다.
- options: waitpid() 함수의 동작을 제어하는 옵션입니다. 주로 WNOHANG, WUNTRACED, WCONTINUED 등의 옵션을 사용합니다.
- 0으로 블로킹 모드로 설정.
- wait : pid_t wait(int *status)
waitpid와 다른 점은 특정 pid를 설정할 수 없다는 점과 함수의 동작을 제어할 수 없다는 점. 따라서 블로킹 모드로만 실행. 최초로 종료된 자식 프로세스의 정보를 받음. 성공시 자식 프로세스의 ID, 실패시 -1 반환. errno에 설정.
- pipe : int pipe(int pipefd[2])
단방향 통신라인을 생성 이를 통해 부모 프로세스와 자식 프로세스 간에 데이터를 전달 가능.
pipefd는 두 개의 파일 디스크립터를 가리키는 정수 배열. pipefd[0]은 읽기용, pipefd[1]은 쓰기용. 성공시 정수 배열에 디스크립터가 배치되고, 0을 반환. 실패시 -1 반환. errno 설정.
- fork : pid_t fork(void)
부모 프로세스에서 해당 시점을 기준으로 프로세스를 복제. 성공시 부모 프로세스는 자식의 ID, 자식 프로세스는 0을 반환. 실패시 -1 반환. errno에 설정. 부모 프로세스로부터 자식 프로세스가 생성 자식 프로세스는 부모와 똑같은 프로그램 코드를 실행하지만 프로세스 특성상 별도의 메모리 공간을 가지며 독립적으로 동작.
- dup : int dup(int oldfd)
파일 디스크립터를 복제하는 함수. 기존 파일디스크립터를 복제하고 복제된 파일 디스크립터를 반환. 같은 파일을 가리키는 파일 디스크립터가 여러개가 됨. 성공시 새로운 파일디스크립터, 실패시 -1 반환. errno에 설정.
- dup2 : int dup2(int oldfd, int newfd)
파일 디스크립터를 임의의 파일 디스크립터로 복제. 성공시 새로운 파일디스크립터, 실패시 -1 반환. errno에 설정.
- execve : int execve(const char *path, char *const argv[], char *const envp[])
기존의 프로세스를 새로운 프로세스로 덮어쓰고, 새로운 프로세스의 프로그램을 실행합니다. 프로세스의 특성대로 별도의 메모리에 독립적으로 존재하나, 파일 디스크립터 테이블은 공유. 성공시 반환값 x, 실패시 -1 반환. errno에 설정.
- path: 실행할 프로그램의 경로를 나타내는 문자열입니다. 실행할 프로그램의 전체 경로 또는 PATH 환경 변수에서 검색 가능한 상대 경로를 제공해야 합니다.
- argv: 실행할 프로그램에 전달할 인자들을 담은 문자열 배열입니다. 배열의 첫 번째 요소는 프로그램의 이름이어야 합니다.
- envp: 실행할 프로그램에 전달할 환경 변수들을 담은 문자열 배열입니다. 일반적으로 environ 전역 변수를 사용하여 현재 프로세스의 환경 변수를 전달합니다.
- exit : void exit(int status)
exit() 함수를 호출하면 프로그램은 즉시 종료되며, 호출 이후에는 추가적인 코드가 실행되지 않습니다. 하나의 정수 값을 매개변수로 받습니다. 이 정수 값은 종료 상태 코드(exit status)로서, 프로그램의 종료 상태를 나타냅니다. 종료 상태 코드는 다른 프로그램이나 운영 체제에서 프로그램의 실행 결과를 확인하는 데 사용될 수 있습니다.
- perror : void perror(const char *s)
errno 변수에 설정된 오류 코드에 해당하는 오류 메시지를 출력하고, 추가로 지정한 문자열을 함께 출력합니다.
- strerror : char *strerror(int errnum)
strerror() 함수는 errnum 매개변수로 오류 코드를 받습니다. 이 오류 코드는 errno 변수에 설정된 값을 사용할 수 있습니다. strerror() 함수는 오류 코드에 해당하는 오류 메시지를 반환하는 포인터를 반환합니다. 반환된 포인터는 정적으로 할당된 문자열을 가리키며, 오류 메시지를 변경하려는 시도는 불가능합니다.
envp?
- C 언어에서 envp는 main 함수의 인자로 전달되는 환경 변수 배열입니다. envp는 프로그램이 실행될 때 운영 체제로부터 전달되는 환경 변수의 정보를 포함하고 있습니다.
- 환경 변수는 운영 체제 환경에서 프로그램에게 제공되는 설정 값입니다. 예를 들어, 시스템의 로케일, 현재 작업 디렉토리, 사용자 이름 등의 정보를 환경 변수로 사용할 수 있습니다. 환경 변수는 일반적으로 키-값 쌍으로 구성되며, C 프로그램은 이러한 환경 변수를 사용하여 프로그램의 동작을 조정하거나 설정 값을 가져올 수 있습니다.
- "PATH" 환경 변수는 실행 파일을 찾을 때 사용되는 경로를 나타냅니다. 일반적으로 운영 체제는 실행 파일의 위치를 지정한 디렉토리를 "PATH" 환경 변수에 포함시킵니다. 따라서 "PATH"에는 실행 파일이 위치할 수 있는 여러 디렉토리 경로가 존재합니다.
- 실행 파일을 실행하면 운영 체제는 "PATH" 환경 변수에 나열된 디렉토리를 순회하면서 해당 실행 파일을 찾습니다. 디렉토리는 콜론(:)이나 세미콜론(;)으로 구분되어 표시됩니다. 따라서 "PATH" 환경 변수의 값은 여러 디렉토리 경로를 포함한 문자열입니다.
errno?
- pipex가 어려운 이유 중 하나는 새롭게 접하는 함수가 너무 많다는 것입니다. 모든 함수를 반드시 전부 사용해야하는 것은 아닙니다. 숙지해야할 것은 error code입니다.
- 소개한 함수 대부분이 error code를 가지며 이를 errno에 기록하고 있습니다. 에러를 처리하는 함수들은 errno의 기록된 최근 사항을 확인하고 해당하는 출력문을 반환하거나 출력합니다. perror와 strerror 함수가 허용된 이상 면밀히 에러를 처리할 필요가 있습니다.
2. 파일 디스크립터
Get_next_line 과제를 할때만 하여도 파일 디스크립터란 0, 1, 2 라는 정해진 파일 디스크립터 넘버를 제외하고는 파일만을 가리키는 수라고 생각했습니다. 하지만 GNL 과제 한정으로 그렇게 이해해도 문제 없던 것이었고, 지금은 pipex와 다음 써클 minishell 과제를 위해서 파일 디스크립터에 대해 좀 더 알아야 하겠습니다.
파일 디스크립터는 C 언어에서 파일이나 입출력 장치와 상호 작용하기 위해 사용되는 정수 값입니다. 주로 다음과 같은 대상을 가리킬 수 있습니다.
- 파일: 파일 디스크립터는 파일을 식별하기 위한 핸들로 사용됩니다. 파일을 열거나 읽기, 쓰기, 닫기 등의 작업을 수행할 때 파일 디스크립터를 사용합니다.
- 표준 입출력: 파일 디스크립터 0, 1, 2는 각각 표준 입력(STDIN), 표준 출력(STDOUT), 표준 에러(STDERR)를 가리킵니다. 이들은 프로그램의 실행 환경에서 기본적으로 제공되는 입출력 장치에 대한 파일 디스크립터입니다.
- 파이프: pipe 함수를 사용하여 생성된 파이프는 파일 디스크립터를 통해 프로세스 간에 데이터를 주고받는 데 사용됩니다. 파이프는 일반적으로 부모 프로세스와 자식 프로세스 또는 다른 프로세스 간의 통신에 활용됩니다.
- 소켓: 네트워크 통신을 위해 소켓을 사용할 때도 파일 디스크립터를 이용합니다. 소켓은 파일 디스크립터를 통해 네트워크 연결의 입출력을 처리합니다.
- 기타: 파일 디스크립터는 다양한 입출력 장치와 파일 시스템 등을 가리킬 수 있습니다. 예를 들어, 시리얼 포트, 디바이스 파일(/dev/null, /dev/random 등), 메모리 매핑 파일 등을 파일 디스크립터로 참조할 수 있습니다.
이번 과제에서 1 ~ 3번 대상을 다루기 때문에, 간단한 예시를 작성해 보았습니다.
단, 파일과 파일 그리고 표준 입출력끼리의 데이터 통신은 GNL 등에서 할 수 있으므로 파이프를 이용하여 데이터 통신을 해보았습니다.
int main(void)
{
int file_fd[2]; // 0: infile_fd, 1: outfile_fd
int pipe_fd[2]; // 0: readonly_fd, writeonly_fd
char file_buff[100]; //file data(string)
char pipe_buff[100]; //pipe data(string)
pipe(pipe_fd);
file_fd[0] = open("infile", O_RDONLY);
file_fd[1] = open("outfile", O_WRONLY);
read(file_fd[0], file_buff, 7); // infile 데이터 읽고 버퍼에 저장
write(pipe_fd[1], file_buff, 7); // 파일버퍼 데이터(infile 데이터)를 pipe로
read(pipe_fd[0], pipe_buff, 7); // pipe 데이터를 읽고 버퍼에 저장
write(1, pipe_buff, 7); // 1. 파이프버퍼 데이터(infile 데이터)를 표준출력(화면)
write(file_fd[1], pipe_buff, 7); // 2. 파이프버퍼 데이터(infile 데이터)를 outfile로
}
infile에는 "abc\n123" 문자열이 적혀 있습니다. 코드를 실행하면 7개의 문자를 파일과 화면에 출력할 수 있습니다.
예시는 infile을 읽고 파이프를 거쳐 표준 출력과 outfile에 데이터를 기록하는 코드입니다. 현재는 프로세스 하나이기 때문에, 데이터 통신이라기보다는 데이터의 이동, 흐름이라고 표현하는 것이 적절하겠네요. 이 개념을 바탕으로 프로세스 간 통신을 설명하겠습니다.
3. 파이프와 프로세스
pipe?
- 쉘에서 "|" 문자는 앞의 명령어의 표준 출력 내용을 뒤의 명령어에게 표준 입력으로 전달해주는 문법 표기입니다. 이제부터 입력과 출력은 표준 입력과 표준 출력으로 이해하시면 됩니다.
- pipe 함수는 Unix 및 Unix 기반 시스템에서 사용되는 IPC(Inter-Process Communication, 프로세스 간 통신) 메커니즘 중 하나입니다. pipe 함수를 사용하면 부모 프로세스와 자식 프로세스 간에 단방향의 파이프(파이프라인)를 생성할 수 있습니다. 이를 통해 부모 프로세스와 자식 프로세스 간에 데이터를 전달할 수 있습니다.
process?
- 프로그램: 할 일 목록, 컴퓨터 메모리에 올라가 있지 않다. 정적 상태이다.
- 프로세스: 할 일을 하는 행위 자체, 컴퓨터 메모리에 올라간 동적 상태를 갖는다.
- 과거에는 프로그램을 실행하는 흐름은 오직 프로세스뿐이었으나, 소프트웨어가 발전하면서 하나의 프로그램이 복잡한 동시 작업을 요구하기 시작했다. 이를 위해 프로세스를 여러개 만들어 냈는데 프로세스의 특성상 하나의 프로그램이 동시 작업을 수월하게 할 수 없었다.
- 프로세스는 각각이 별도의 메모리에 올라가 있기 때문에 생성시에 필요한 정보를 모두 복사해야할 뿐더러 제거도 느리고 프로세스간 정보 교환 시 메모리의 중복, 프로세스의 수가 늘어나면 스위칭 부담도 커진다. 그래서 등장한 개념이 프로세스보다 더 작은 실행 단위인, 스레드
파이프를 이용한 프로세스 간의 데이터 통신을 예시로 코드를 작성해 보겠습니다.
int main(void)
{
int file_fd[2];
int pipe_fd[2];
char file_buff[100];
char pipe_buff[100];
pid_t child;
file_fd[0] = open("infile", O_RDONLY);
file_fd[1] = open("outfile", O_WRONLY, 0644);
pipe(pipe_fd);
child = fork();
if (child == 0)
{
read(file_fd[0], file_buff, 7);
write(pipe_fd[1], file_buff, 7);
}
else
{
read(pipe_fd[0], pipe_buff, 7);
write(file_fd[1], pipe_buff, 7);
}
}
부모, 자식프로세스는 파이프를 통해 데이터를 주고받습니다. 프로세스 식별자(pid_t)를 이용하여 child = 0인 경우, 자식은 infile의 내용을 읽고 파이프에 데이터를 집어넣으며 부모는 read 함수를 실행하여 파이프에 데이터가 들어올 때까지 대기상태를 유지합니다. 그리고 받아온 데이터를 표준 출력합니다.
주의해야 할 것은 파이프의 읽기, 쓰기 전용 파일 디스크립터를 구분해야 하고 읽기부를 쓰기부로 처리하려 하거나 그 반대의 상황을 실행하면 문제가 발생한다는 것입니다.
구현
code
void f_process1(t_cmd *info, char **av) //info는 파일에 대한 데이터 구조체(포인터)
{
pid_t child1;
int fd[2]; //pipe의 읽기, 쓰기를 표시할 파일 디스크립터 배열
if (pipe(fd) == -1)
print_error("pipe");
child1 = fork();
if (child1 == -1)
print_error("fork");
if (child1 == 0)
{
close(fd[0]);
close(info->file[1]);
dup2(info->file[0], 0);
dup2(fd[1], 1);
close(info->file[0]);
close(fd[1]);
execute_cmdline(info, av[2]);
}
f_process2(info, av, fd);
}
명령어 당 자식 프로세스 하나를 가져야 한다고 생각하시면 좋습니다. 왜냐하면 execve 함수를 정상적으로 실행했을 때, 프로세스가 대체되기 때문에 명령어 수 = 프로세스 수가 되기 때문입니다.
데이터 통신을 위한 파이프를 생성하고 첫 번째 명령어를 실행할 자식 프로세스를 생성합니다. 자식 프로세스에서는 infile을 읽어올 것이므로, 파이프의 쓰기 fd와 outfile의 fd를 닫습니다.
첫 번째 쉘 명령어는 infile을 입력으로 받고 다음 명령어에게 출력할 것이기 때문에 파이프로 데이터를 전달할 수 있도록 dup2함수로 파일 디스크립터를 복제합니다. 복제 당한 파일 디스크립터는 사용할 일이 없으므로 닫고 쉘 명령어를 실행합니다.
void f_process2(t_cmd *info, char **av, int *fd) //fd는 f_process1에서 생성한 파이프 fd 배열(을 가리키는 포인터)
{
pid_t child2;
child2 = fork();
if (child2 == -1)
print_error("fork");
if (child2 == 0)
{
close(fd[1]);
dup2(fd[0], 0);
dup2(info->file[1], 1);
close(fd[0]);
close(info->file[1]);
execute_cmdline(info, av[3]);
}
f_process3(info, fd);
}
두 번째 명령어를 실행할 자식 프로세스를 생성합니다. 두 번째 자식은 파이프의 쓰기 전용 디스크립터를 닫습니다. 파이프에서 표준 입력을 받아올 수 있도록 하고 표준 출력은 outfile을 가리키도록 복제합니다. 마찬가지로 복제 당한 파일 디스크립터는 닫고, 마지막 쉘 명령어를 실행합니다.
f_process3 함수는 부모 프로세스로 돌아가 자식 프로세스의 종료를 확인하고 리소스를 해제합니다.
void f_process3(t_cmd *info, int *fd)
{
int status;
pid_t temp;
int i;
i = 0;
close(fd[0]);
close(fd[1]);
open_close(info);
while (i < 2)
{
temp = wait(&status);
if (temp == -1)
print_error("wait");
i++;
}
exit(0);
}
부모는 파이프의 읽기, 쓰기를 모두 닫아 파이프를 망가뜨려 파이프 시그널을 보내게 되어 더 이상 입출력을 할 수 없게 만듭니다. 그리고 정상적으로 자식 프로세스들이 종료되었는지 확인하고 리소스를 회수하며 프로그램을 끝냅니다.
동료 평가
모든 프로세스가 정상적으로 종료되었는지 확인해야합니다.
동료 평가에서 가장 많이 털리는 부분이 무한대로 입출력을 수행하게 만드는 yes명령어와 리소스 회수 확인입니다. 이 두 가지를 어떻게 확인하고 해결할 수 있는지 이야기해 보겠습니다.
첫 번째는 yes명령어 입니다. 사실 /dev/random도 있습니다. 아무튼 이런 명령어들은 커맨드 창에 무한대로 문자를 출력합니다. 예시 커맨드 라인으로 > infile "yes" | "head -4" outile을 수행하면 파이프 파일 디스크립터를 제대로 닫지 않았을 경우 프로그램이 무한 루프에 빠집니다.
쉘 명령어에서는 정상적으로 oufile에 y\n이 4번 입력됩니다. 왜 우리 파이프에서만 이런 상황이 발생할까요? 쉘 명령어와 파이프가 어떻게 동적으로 진행되는지 알아볼 필요가 있습니다.
yes명령어는 입력을 받지 않고 y\n를 무한히 출력합니다. 이를 파이프로 전송하며 "head -4"는 파이프에서 정확히 4줄만 읽고 프로세스를 종료합니다. 그럼 yes를 실행하는 프로세스는 어떻게 될까요?
정확히는 "head -4" 명령어가 파이프의 읽기를 닫고 종료됐기 때문에 파이프는 제 기능을 할 수 없게 됐고(파이프 시그널), yes 명령어도 파이프의 쓰기가 닫혀 종료된 것이었습니다. 이를 마지막 프로세스가 해주어야 할 일입니다.
위에 설명하였듯이, 명령어의 개수는 자식 프로세스의 개수입니다. 마지막 명령어를 부모 프로세스에서 실행할 경우 리소스 회수나 자식 프로세스들의 종료, 기타 진행할 코드 등을 실행할 수 없습니다. 이를 주의해야 합니다.
마치며
과제의 구현 난이도는 어렵지 않습니다. 허용 외부 함수를 잘 숙지하시고, 프로세스 간 통신 흐름 파이프를 통해 이해하기 위해서 많이 그려보세요. 도움이 될겁니다.