ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [C언어]Linux Shell 구현 방법 - 파이프와 리다이렉션
    C언어 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.