ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Linux Shell 구현 방법 - 파이프와 리다이렉션
    운영체제 2022. 4. 15. 17:30

     

    쉘이란?

    쉘이란 커널과 유저사이에서 통역자 역할을 하는 소프트웨어이다.
    유저가 커널에 직접 필요할 때 마다 명령을 내리는 건 복잡하다.
    그래서 그 중간자 역할자인 쉘이 탄생했다.
    쉘은 유저의 명령을 커널이 이해할 수 있는 명령어로 번역해서 커널에 전달해준다.

     


    모든 코드는 깃허브에 정리가 되어있습니다! 참고하시며 보면 좋을 것 같습니다.

     

     

    1. 리눅스 커맨드 실행 방법

    1-1. execve() 함수

    execve() 함수 설명

    #include <unistd.h>
    
    int execve(const char *path, char *const argv[], char *const envp[]);

     

    execve() 함수란?

    execve() 함수는 매개변수로 들어온 경로에 위치한 파일을 실행해주는 시스템 콜 함수이다.
    파일은 반드시 바이너리 실행파일이거나 스크립트 파일이어야 한다.
    특정 쉘 명령어(command)를 실행하고 싶으면 이 execve() 함수에 명령어가 위치한 경로를 넣어주면 된다.

    첫 번째 매개변수는 파일이 현재 위치한 경로입니다.
    두 번째 매개변수 *const argv[]는 실행할 프로세스에 넘길 인자 배열이다.
    예를 들어 execve("/usr/bin/ls", ""ls", "-l"", NULL);
    이렇게 보낸다면 ls 명령어를 실행하고 그 프로세스에 "ls", "-l"를 넘긴 것이다.
    출력은 ls -l과 동일하다.
    세 번째 매개변수는 환경변수 문자열 배열리스트이다. 환경변수를 이용한 커맨드를 실행하고 싶다면 이 매개변수에 환경변수 배열을 넣어줘야한다.

     

    반환값

    실패하면 -1을 반환한다.

     

    execve() 함수 예제

    #include <unistd.h>
    #include <stdlib.h>
    #include <string.h>
    
    int main(int arc, char *argv[], char *envp[])
    {
    	char	*path;
    	char	**argument;
    
    	argument = (char **)malloc(sizeof(char *) * 2);
    	argument[0] = strdup("ls");
    	argument[1] = strdup("-l");
    	path = strdup("/usr/bin/ls");
    	execve(path, argument, envp);
        
        	/*execve()가 실패하지 않는 한 이 라인은 이제 실행되지 않습니다*/
        	perror("Function call failed");
    	exit(EXIT_FAILURE);
    }

     

    execve() 함수 주의사항

    exec 계열 함수는 프로세스를 생성하는 시스템 콜 함수이다.
    프로세스를 생성하는 방법은 fork와 exec로 나뉘는데 간단하게 설명하면

     

    fork

    fork() 함수 호출자가 자식 프로세스를 생성(호출자는 부모 프로세스)

    exec

    exec() 함수 호출자가 새로운 프로세스를 생성하고 그 프로세스로 대체됨

    위 같은 exec 계열 함수의 특성상 execve() 함수를 호출하면 그 순간
    호출한 프로세스는 존재하지 않는다.
    때문에 execve() 호출 코드 그 아래에 있는 코드는 실행되지 않는다.

    예외 경우는 execve() 함수 호출에 실패하여 함수가 실행되지 못한 경우다.

    execve(path, argument, envp);
    perror("Function call failed");//execve가 실패하면 이 코드 실행
    exit(EXIT_FAILURE);


    자세한 설명은 아래 사이트에서 참고하면 좋을 것 같습니다.

    fork와 exec 차이점 참고 사이트
    https://woochan-autobiography.tistory.com/207

     

     




    2. 리다이렉션 구현

    2-1. 리다이렉션 설명

    표준 입력을 키보드로 받고 표준 출력을 모니터로 주는 것이 아니라 사용자가 임의로 표준 입출력 방향을 바꿔서 파일로부터 입력을 받거나 혹은 파일에 출력을 가능하게 해주는 명령어

    표준 출력(Standard out) : >, >>
    "cat > testfile.c" 라는 명령을 예로 들면
    cat 명령어의 출력을 모니터가 아닌 testfile.c 파일에 한다는 의미이다.
    만약 파일이 없으면 새로 만들어지며, 파일이 존재하면 덮어씌어진다.
    기존 파일 내용을 보관하고 추가로 덧붙이고 싶으면 >> 를 사용한다.
    표준 입력(Standard input) : <
    "cat < testfile.c" 라는 명령을 예로 들면
    cat 명령어가 받을 입력을 키보드가 아닌 testfile.c 로 받는다는 의미이다.
    만약 파일이 없으면 에러 메시지가 출력된다.
    표준 에러(Standard error) : 2>, 2>>
    표준에러의 출력을 모니터가 아닌 다른 곳으로 바꿔준다.
    ~]$ cat testfile.c 2> errortext.txt
    ~]$ cat errotext.text
    testfile.c: No such file or directory



    2-2. dup2() 함수

    dup2() 함수 설명

    #include <unistd.h>
    
    int dup2(int oldfd, int newfd);

    dup2() 함수로 리다이렉션을 구현할 수 있다.

    dup2() 함수는 파일 디스크립터(FD)를 복제하는 함수이다.

    dup() 함수와 차이점은 dup() 함수는 FD를 복제하면 사용하지 않는 FD값이 자동으로 할당된다.
    하지만 dup2() 함수는 사용자가 원하는 FD 번호를 할당할 수 있다.
    만약 그 번호가 이미 사용 중 이라면 자동으로 그 파일을 닫은 다음 할당한다.

     

    첫 번째 매개변수에 복제하고 싶은 FD를 넣어준다.
    두 번째 매개변수에 복제하는 FD에 번호를 지정해준다.

     

    dup2() 함수 예제

    #include <unistd.h>
    #include <stdlib.h>
    #include <string.h>
    #include <fcntl.h>
    
    /*
    open() 함수로 받아온 FD를 dup2() 함수로 표준 입력으로 바꿔주는 함수
    */
    void	change_standard_input(void)
    {
    	int fd = open("test_txt.txt", O_RDONLY);
    	dup2(fd, STDIN_FILENO);
    	close(fd);	
    }
    
    int main(int arc, char *argv[], char *envp[])
    {
    	char	*path;
    	char	**argument;
    
    	argument = (char **)malloc(sizeof(char *) * 1);
    	argument[0] = strdup("cat");
    	path = strdup("/usr/bin/cat");
    	change_standard_input();
        	/*표준 입력 변경*/
    	execve(path, argument, envp);
        	/*cat 명령어 실행*/
    	perror("Function call failed");
    	exit(EXIT_FAILURE);
    }

     

    실행결과

    hello
    this file is test file

    execve() 함수로 cat 커맨드를 실행하지만
    그 전에 dup2() 함수로 표준 입력을 키보드에서 test_txt 파일로 변경했다.

    떄문에 execve() 함수가 생성한 프로세스는 표준입력이 test_txt으로 향한 상태에서 cat 바이너리 파일을 실행하게 된다.



    2-3. '>' 와 '>>' 차이점

    static int	output_redirection(char *outfile, int fd[])
    {
    	fd[WRITE] = open(outfile, O_CREAT | O_RDWR | O_TRUNC, 0644);
    	if (fd[WRITE] == -1)
    		return (ERROR);
    	return (NORMAL);
    }
    
    /*">>" 일 때는 open() 함수 옵션에 O_APPEND*/
    static int	d_output_redirection(char *outfile, int fd[])
    {
    	fd[WRITE] = open(outfile, O_CREAT | O_RDWR | O_APPEND, 0644);
    	if (fd[WRITE] == -1)
    		return (ERROR);
    	return (NORMAL);
    }

    '>>' 같은 경우에는 open() 함수 flag에 O_APPEND 옵션을 추가해준다.



    2-4. 리다이렉션 구현 방법

    dup2() 함수를 사용하여 다양한 방법으로 리다이렉션을 구현할 수 있다.
    미숙한 방법이지만 제가 구현한 리다이렉션의 실행 프로세스를 설명하겠다.

    매끄러운 설명을 위해 예외처리 코드 부분은 따로 삭제하고
    몇 부분은 의사코드로 대체했습니다!

    리다이렉션 구현 순서

    1. 환경변수인 PATH를 적절하게 파싱해서 2차원 배열에 넣는다.
    execve() 함수는 매개변수에 커맨드 경로를 주어야한다.
    명령어가 위치한 경로를 찾기 위해 PATH 환경변수를 참조한다.

    void	set_environment_path(t_info *info)
    {
    	char	*path_value;
    	char	**env_path;
    
    	1. path_value 변수에 PATH 환경변수 문자열을 대입한다.
    	2. env_path = split(path_value, ':');
        	/*PATH를 ':'를 기준으로 split*/
    	3. add_slash_at_end_of_path(info, env_path);
        	/*경로 끝에 /를 붙여준다.*/
    }

     

    2. PATH 환경변수를 참조해서 커맨드 경로를 리턴하는 루틴 구현
    2차원 배열이 널을 만날 때 까지 while() 문으로 PATH환경변수에 있는 목록을 하나 하나 커맨드와 결합하고 그 파일이 존재하는지 확인
    만약 있으면 그 경로를 리턴한다.

    char	*get_cmd_path(char **env_path, t_info *info)
    {
    	int		idx;
    	char		*cmd;
    	char		*path_of_cmd;
    	struct stat	file_stat;
    
    	if (!info->cmd_str[0])
        		return (NULL);
    	idx = 0;
    	path_of_cmd = NULL;
    	cmd = info->cmd_str[0];
    	if (ft_strchr(cmd, '/'))
    	{
    		is_directory(cmd);
            	/*디렉토리면 오류메시지 출력 후 종료*/
    		return (cmd);
    	}
    	while (env_path && env_path[idx])
    	{
    		path_of_cmd = strjoin(env_path[idx], cmd);
            	/*stat() 함수로 파일이 존재하는 확인*/
    		if (stat(path_of_cmd, &file_stat) == 0)
    			return (path_of_cmd);
    		free(path_of_cmd);
    		path_of_cmd = NULL;
    		idx++;
    	}
    	return (path_of_cmd);
    }

     

    3. 사용자가 입력한 리다이렉션이 무엇인지 strncmp() 함수와 비교 연산자로 판별해서 그에 맞는 함수를 호출해서 fd 값을 가져온다.
    open() 함수로 생성한 fd를 int fd[] 배열에 따로 저장한다.

    /*
    ** if (reval == -1)//here_doc 상태에서 ctrl+c 받거나 open() 함수 에러났을 때 if문 들어감
    */
    int	redirection(t_info *info, int fd[])
    {
    	t_lst	*cur;
    	int	reval;
    	char	**redi;
    
    	cur = info->cmd_lst[info->cmd_sequence].redi;
    	while (cur != NULL)
    	{
    		redi = split(cur->str, ' ');
            	/*
            	redi[0] == "<"
        		redi[1] == "test_txt.txt"
        		*/
    		if (!strncmp(redi[0], "<<", 2))
    			reval = get_here_doc_fd(info, fd);
    		else if (!strncmp(redi[0], ">>", 2))
    			reval = d_output_redirection(redi[1], fd);
    		else if (redi[0][0] == '<')
    			reval = input_redirection(redi[1], fd);
    		else if (redi[0][0] == '>')
    			reval = output_redirection(redi[1], fd);
    		if (reval <= -1)
    		{
    			if (reval == -1)
    				error_msg(redi[1], NULL, strerror(errno));
    			return (ERROR);
    		}
    		cur = cur->next;
    	}
    	return (NORMAL);
    }

     

    static int	get_here_doc_fd(t_info *info, int fd[])
    {
    	fd[READ] = info->pipex.here_fd[info->here_sequence];
    	info->here_sequence++;
    	return (fd[READ]);
    }
    
    static int	input_redirection(char *infile, int fd[])
    {
    	fd[READ] = open(infile, O_RDWR);
    	if (fd[READ] == -1)
    		return (ERROR);
    	return (NORMAL);
    }
    
    static int	output_redirection(char *outfile, int fd[])
    {
    	fd[WRITE] = open(outfile, O_CREAT | O_RDWR | O_TRUNC, 0644);
    	if (fd[WRITE] == -1)
    		return (ERROR);
    	return (NORMAL);
    }
    
    /*">>" 일 때는 open() 함수 옵션에 O_APPEND*/
    static int	d_output_redirection(char *outfile, int fd[])
    {
    	fd[WRITE] = open(outfile, O_CREAT | O_RDWR | O_APPEND, 0644);
    	if (fd[WRITE] == -1)
    		return (ERROR);
    	return (NORMAL);
    }

     

     

     

     

    4. int fd[]에 배열에 저정한 fd를 최종적으로 dup2() 함수로
    표준입출력을 변경해준다.

    if (fd[READ] != STDIN_FILENO)
    	ret = dup2(fd[READ], STDIN_FILENO);
    if (fd[WRITE] != STDOUT_FILENO)
    	ret = dup2(fd[WRITE], STDOUT_FILENO);
    if (ret == -1)
    {
    	printf("%s\n", strerror(errno));
    	exit(EXIT_FAILURE);
    }

     

     




    3. 파이프 라인

    3-1. pipe() 함수

    pipe() 함수 설명

    #include <unistd.h>
    
    int pipe(int pipefd[2]);

     

    파이프의 개념

    • 두 프로세스 간 통신을 지원하는 특수 파일
    • 기본적으로 단방향이다.
    • 양방향 통신을 위해서는 파이프를 2개 생성하면 된다.

     

    pipe() 함수는 프로세스들이 데이터를 공유할 수 있게 해주는 함수이다.

    하나의 파이프가 생성이 되고 프로세스들이 이 파이프를 공유한다. 공유가 가능한 이유는 파이프는 특정 프로세스 안에서 생성 되는 게 아니라 커널 내부에 생성이 되기 때문이다.

    프로세스들은 파이프를 이용할 수 있는 파일디스크립터(FD)를 제공받는다. 단점은 FD를 사용할려면 부모 프로세스와 자식 프로세스 관계여야하기 때문에 파이프는 부모 프로세스와 자식 프로세스의 통신만 지원한다.

    pipe() 함수가 성공적으로 호출되었다면 0, 실패했을 경우 -1을 반환한다.


    매개변수에는 인지가 2개있는 int형 배열을 넣어줘야한다.

    int fd[2];
    pipe(fd); // 크기가 2인 int형 배열 요구

    fd[0]: 함수 호출 후 fd[0]에 데이터를 입력 받을 수 있는 파일 디스크립터가 담김(파이프 출구)
    fd[1]: 함수 호출 후 데이터를 출력할 수 있는 파일 디스크립터다 담긴다(파이프 입구)

     

    pipe() 함수 예제

    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <string.h>
    #include <errno.h>
    #include <sys/wait.h>
    
    void	report_error_msg(char	*error_str)
    {
    	printf("%s\n", error_str);
    	exit(EXIT_FAILURE);
    }
    
    int main(void)
    {
    	int	fd[2];
    	int	pipe_val;
    	pid_t	pid;
    	char	buff[100];
    
    	pipe_val = pipe(fd);
    	if (pipe_val == -1)
    		report_error_msg(strerror(errno));
    	pid = fork();
    	if (pid == -1)
    		report_error_msg(strerror(errno));
    
    	if (pid > 0)
    	{
    		/*부모 프로세스*/
    		wait(0);
    		read(fd[0], buff, 100);
    		printf("%s\n", buff);
    	}
    	else if (pid == 0)
    	{
    		/*자식 프로세스*/
    		write(fd[1], "hello, parent", strlen("hello, parent"));
    	}
    }



    3-2. 파이프라인 구현

    단일 파이프라인

    • command 1 | command 2
      command 1의 출력이 파이프에 저장이 되고
      command 2의 입력이 파이프로 향하고 있어야한다.
    • command 1은 dup2() 함수를 통해 표준 출력을 파이프(fd[1])로 변경
      command 2는 dup2() 함수를 통해 표준 입력을 파이프(fd[0])로 변경
    • 표준입출력이 변경된 상태에서 execve() 함수로 커맨드를 실행하면 명령어가 실행되면서 자동으로 파이프로 읽고 써진다.

     

    간단한 예제를 코드로 작성

    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <string.h>
    #include <errno.h>
    #include <sys/wait.h>
    
    void	report_error_msg(char	*error_str)
    {
    	printf("%s\n", error_str);
    	exit(EXIT_FAILURE);
    }
    
    int main(void)
    {
    	int	fd[2];
    	int	pipe_val;
    	pid_t	pid;
    	char	buff[100];
    	char	**command_1;
    	char	**command_2;
    
    	command_1 = (char **)malloc(sizeof(char *) * 2);
    	command_1[0] = strdup("/usr/bin/ls");
    	command_1[1] = strdup("-l");
    
    	command_2 = (char **)malloc(sizeof(char *) * 2);
    	command_2[0] = strdup("/usr/bin/wc");
    	command_2[1] = strdup("-l");
    
    	pipe_val = pipe(fd);
    	if (pipe_val == -1)
    		report_error_msg(strerror(errno));
    	pid = fork();
    	if (pid == -1)
    		report_error_msg(strerror(errno));
    
    	if (pid > 0)
    	{
    		/*부모 프로세스*/
    		wait(0);
    		dup2(fd[0], 0);
    		close(fd[0]);
    		close(fd[1]);
    		execve(command_2[0], command_2, NULL);
    	}
    	else if (pid == 0)
    	{
    		/*자식 프로세스*/
    		dup2(fd[1], 1);
    		close(fd[1]);
    		close(fd[0]);
    		execve(command_1[0], command_1, NULL);
    	}
    }

    위 코드는 그리 좋은 예제가 아니다.
    왜냐하면 부모 프로세스에서 execve() 함수를 실행했기 때문이다.

    exec 계열 함수 특성상 execve() 함수가 호출되면 그 즉시 호출자 프로세스는 사라진다.

    때문에 저런 식으로 코드를 짠다면 그 프로그램은 명령어를 실행하는 순간 그 즉시 종료가 될 것이다.

     

    파이프라인이 여러개인 경우

    재귀를 통한 구현

    멀티 파이프라인 같은 경우에는 재귀를 통해 구현을 했다.

    전체적인 코드 흐름은 아래와 같다.

    void	execute_command(t_info *info, int depth)
    {
    	if (depth > info->n_cmd - 1)
    		return ;
    	info->cmd_sequence = depth; //depth로 커맨드 순서 인식, depth는 처음에 무조건 0
        
    	make_pipeline(info, depth); //파이프 생성
        	
    	fork_process(info, depth); //fork() 함수 호출, 이 때 이후로 부모, 자식 프로세스로 나눠짐
        
            //부모 프로세스일 때 해당 if문 진입
    	if (info->pipex.pid[depth] > 0)
    	{
        		//만약 마지막 명령어라면
    		if (depth == info->n_cmd - 1)
            	{
                		close_all_pipeline(info); //모든 파이프 닫기
    			waiting_child_process(info, depth); //자식 프로세스 끝날 때 까지 대기
                	}
            	execute_command(info, depth + 1); //재귀 호출
    
    	}
    	else if (info->pipex.pid[depth] == 0) //자식에서 명령어 실행 함수 호출
    		execute_execve(info);
    }

     

    execute_command() 함수는 커맨드 개수만큼 재귀적으로 호출된다.

    커맨드 개수가 1개이면 1번만 수행된다. (정확히는 2번이지만 if 탈출조건으로 바로 빠져나온다.)

     

    또한 execute_command() 함수는 내부적으로 fork() 함수를 호출하여 자식 프로세스를 생성한다.

    커맨드 개수만큼 자식 프로세스를  하는 구조이다. 커맨드가 1개여도 무조건 자식 프로세스를 생성한다.

    만약 커맨드가 2번이면 execute_command()가 2번 호출되어 fork() 함수도 2번 호출된다.

    때문에 자식 프로세스는 총 2개가 생성되게된다.

     

    부모 프로세스에서 재귀함수를 호출하고 자식 프로세스에서 execute_execve(); 함수를 호출해여 커맨드를 실행한다.



    Pipe 닫아주기

    생성한 pipe는 사용이 끝나면 모두 닫아줘야한다.

    close_all_pipeline(info);


    위 예제에 나온 close_all_pipeline() 함수로 부모 프로세스에서 생성한 모든 파이프를 닫아준다.

    중요한 점은 마지막 커맨드를 실행하는 타이밍에서 닫아줘야한다. 뒤에 실행할 커맨드가 아직 남아있는데 파이프를 닫아버리면 파이프를 쓸 수가 없어진다.

Designed by Tistory.