-
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.texttestfile.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() 함수로 부모 프로세스에서 생성한 모든 파이프를 닫아준다.중요한 점은 마지막 커맨드를 실행하는 타이밍에서 닫아줘야한다. 뒤에 실행할 커맨드가 아직 남아있는데 파이프를 닫아버리면 파이프를 쓸 수가 없어진다.
'운영체제' 카테고리의 다른 글
[리눅스] KVM VM 설치 및 네트워크 설정 - Bridge 네트워크 (0) 2022.04.15 [리눅스] 온프레미스 서버에 RedHat 7.5 설치해보자(VM x) (0) 2022.04.15