The objective of this project is for you to create a simple shell. Yes, your own little bash or zsh. You will learn a lot about processes and file descriptors.
With Minishell, you’ll be able to travel through time and come back to problems people faced when Windows didn’t exist.
You must program a mini UNIX command interpreter.
이 인터프리터는 명령 프롬프트(예를 들면 $>
)를 띄워야하고, 사용자가 enter 키를 눌러 명령줄(command line)을 입력할 때 까지 기다려야 한다.
PATH 변수 및 상대/절대 경로에 기반한 실행 파일(The executable)을 올바르게 찾아 실행한다.
다음과 같은 내장 기능을 구현한다.
echo
(with option -n
)cd
(with only relative or absolute path)pwd
export
unset
env
(without any options and any arguments)exit
;
로 명령어를 분리할 수 있어야 한다.
다음 기능을 일반 bash와 동일하게 동작해야하도록 구현한다.
'
, "
<
, >
, >>
)|
) $
followed by characters)$?
ctrl-C
, ctrl-D
, ctrl-\
셸은 컴퓨터와 상호 작용할 수 있는 응용 프로그램이다. 셸에서 사용자는 프로그램을 실행할 수 있으며, 입력과 출력을 파일에서 가져오도록 리디렉션할 수도 있다. 셸은 또한 함수, 변수 등과 같은 프로그래밍 구조를 제공한다. 셸 스크립트라고 불리는 셸 프로그램은 편집, 기록, 파일 완성, 와일드카드, 환경 변수 확장 및 프로그래밍 구성과 같은 기능을 제공한다.
셸을 구현하는 작업은 다음 네 부분으로 나뉜다.
Lexer : 소스코드를 토큰 단위로 분석한다.
token : 쉘에서 입력을 처리하기 위해서는 적절한 단위로 명령문을 나눠야 한다. 이때, 명령문을 나누는 최소 단위를 토큰(token)
이라고 한다. 토큰은 국어의 형태소와 비슷한 개념이다. 형태소가 문장을 이루는 의미를 가진 가장 작은 요소인 것과 같이 토큰은 의미를 가지는 글자끼리 모아둔 소스코드를 이루는 가장 작은 요소이다.
token 예)
10*2+3
위 코드를 다음과 같이 쪼갤 수 있어야 한다.
NUMBER 10 STAR * NUMBER 2 PLUS + NUMBER 3
Parser : la -al
과 같은 명령을 읽은 뒤 Command Table(명령 테이블)이라는 데이터 구조에 삽입해 실행될 명령을 저장한다.
Executor : 명령 테이블의 모든 명령에 대해 새 프로세스를 생성한다. 필요한 경우 파이프(|
)를 생성하여 한 프로세스의 출력을 다음 프로세스의 입력으로 전달한다. 또한 표준 입력, 표준 출력 및 표준 오류를 리디렉션(>
, <
, >>
)한다.
Shell Subsystems
Environment Variables(환경변수) : ${VAR}
로 환경변수를 불러올 수 있다. 셸은 환경변수를 설정, 확인 및 출력할 수 있어야 한다.
Wildcards(와일드카드) : *
은 문자열 와일드 카드이다. 해당 디렉토리에서 내용이 일치하는 모든 파일을 불러온다.
Subshells : ( )
, $( )
, |
, &
를 이용해 한 명령의 출력값을 새 명령의 입력값으로 활용할 수 있다. 이렇게 명령을 실행시킬 때 생성되는 shell을 subshell 이라고 한다.
parent process 에서 설정한 변수나 함수는 export 해야지만 child process 에서 사용할 수 있다. 하지만 subshell 에서는 export 하지 않아도 사용할 수 있는 것이 특징이다. 출처
프로세스를 시작시키는 것이 셸의 주요 기능임을 알았으니, 프로세스의 시작 방식과 진행 상황을 정확하게 알고 있어야한다. Unix에서 프로세스를 시작하는 방법은 두 가지 뿐이다.
Init
fork()
: 대부분의 프로그램은 Init이 아니기 때문에 프로세스를 시작하는 실질적인 방법은 fork()
syscall 뿐이다. 이 기능이 호출되면 운영 체제가 부로 프로세스로부터 자식 프로세스를 복제하여 두 프로세스를 병렬로 실행한다. 즉, 본질적으로 프로세스를 시작하는 유일한 방법은 복제 뿐이다. exec()
을 사용하여 자신을 새 프로그램으로 바꾼다. wait()
를 사용하여 자식 프로세스를 계속 감시할 수도 있다.fork()는 현재 실행중인 process를 복사해서 다른 process를 생성한다. 복사해서 생성하기 때문에, 가지고 있던 메모리등의 시스템 자원을 모두 원래의 process와 공유하게 된다.
fork()
를 사용하여 생성한 프로세스는 부모 프로세스 Parent process, 새로 생긴 프로세스는 자식 프로세스 Child process 라고 부른다.
모든 프로세스는 (참고: 최상위 프로세스인 init
는 pid 1을 가진다) 생성될 때 프로세스 아이디를 부여받는다. fork()
함수는 부모에게는 자식 프로세스의 pid를 반환하고, 자식에게는 0을 반환한다. 이를 이용하여 자식 프로세스에게 특정 명령을 시킬 수 있다.
주의:
pid
는 변수명이지 실제 pid 를 의미하지 않는다. 따라서getpid()
를 사용해야 이 함수를 부른 프로세스의 id 를 얻을 수 있다.
fork 함수로 자식 프로세스를 생성하면 부모 프로세스와 자식 프로세스는 순서에 관계 없이 실행되고, 먼저 실행을 마친 프로세스는 종료한다. 이때 좀비 프로세스(zombie procss)같은 불안정 상태의 프로세스가 발생하는데 이를 방지하려면 프로세스 동기화 함수를 수행해서 부모 프로세스와 자식 프로세스를 동기화 시켜야한다.
프로세스 동기화 함수로 사용하는 것이 wait 계열 함수이다.
함수 원형 | 기능 |
---|---|
pid_t wait(int *stat_loc); | 임의의 자식 프로세스의 상태값 구하기 |
pid_t waitpid(pid_t pid, int *stat_loc, int options); | 특정 프로세스의 상태값 구하기 |
동기화 후에는 자식 프로세스는 if(pid == 0)
일 경우의 구문을 수행한 뒤 종료하며, 부모 프로세스의 경우엔 wait()를 통하여 자식 프로세스가 종료된 뒤 나머지 구문을 수행한 뒤 종료한다.
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char **argv)
{
pid_t pid;
pid = fork();
if (pid == 0)
{
printf("자식 프로세스\n");
// exit(0);
}
if (pid > 0)
{
printf("Wait\n");
//wait(NULL);
printf("Exit\n");
}
return 0;
}
우리가 명령어로 생각하는 'ls', 'echo'등은 실은 $PATH 경로 안에 있는 실행파일이다. 즉 프로그램을 실행해 명령어를 사용한다는 뜻이다.
int execve(const char *path, char const **argv, char const **envp)
path에 지정한 절대경로명의 파일을 실행하며 argv, envp를 인자로 전달한다. argv와 envp는 포인터 배열이다. 이 배열의 마지막에는 NULL 문자열을 저장해야 한다. 첫번째 인자는 새 프로세스 파일의 경로. 두번째 인자는 프로그램 명. 더블 포인터인 이유는 int main(int argc, char **argv)
에서 argv[0]이 프로그램 이름이었던 것과 같은 원리
시그널에 대한 더 자세한 내용은 다음 글에 따로 정리했습니다.
sig_t signal(int sig, sig_t func);
sig 는 시그널 번호, func 는 해당 시그널을 처리할 핸들러.
Signal이란 Software interrupt로, process에 무엇인가 발생했음을 알리는 간단한 메시지를 비동기적으로 보내는 것이다.
시그널을 받았을 때
시그널은 고유의 의미를 내포하고 있다. 이러한 시그널을 받은 실행객체인 프로세스는 그에 맞는 행동을 해야 한다. 시그널을 받은 프로세스는 다음중 한가지 행동을 취해야 한다.
시그널 종료는 다양하고 signal.h
에 정의 되어있다.
키보드 입력으로 발생시킬 수 있는 시그널은 Ctrl+C 외에도 아래의 몇가지가 있다.
Ctrl+C | SIGINT | 프로세스를 종료시킨다. |
---|---|---|
Ctrl+Z | SIGSTP | 프로세스를 중단시킨다. |
Ctrl+\ | SIGQUIT | core dump를 남기고 프로세스를 종료시킨다. |
ctrl+D | "end of file"을 의미한다. 터미널이 입력 상태이고, 라인의 맨 처음일 때에만 작동한다. (‘\0’를 STDIN에 입력하는 것) |
일반적으로 프로세스의 경우 SIGINT
(Ctrl+C) 시그널을 통하여 수행중인 프로세스(터미널)를 종료시킬 수 있지만 minishell의 경우엔 우리가 만든 minishell만 종료되고 터미널은 여전히 살아있도록 해야한다. 이런식으로 시그널을 받은 프로세스가 취할 행동을 바꿔주는게 handler 함수이다.
SIGINT
를 DEFAULT로 설정한다.SIGINT
시그널을 무시하도록 설정한다.환경변수(Environment variable)은 쉘에서 참조하는 변수이다. 쉘에서 참조하는 변수는 크게 쉘 변수
와 환경변수
로 나누어 지는데, 환경변수와 쉘 변수의 가장 큰 차이점은 child process을 생성할 때 환경변수는 상속이 되는 반면 쉘 변수는 그렇지 않다는 점이다.
한편 자식프로세스, 쉘 스크립트에서 생성한 환경변수는 부모프로세스에서 참조할 수 없다. Unix 시스템은 자식 프로세스가 부모 프로세스의 값을 바꿀 수 없기 때문이다. 반대로 부모 프로세스는 자신의 값을 바꾸고 자식 프로세스에게 전달할 수 있다. 아래 예제를 참고하면 이해에 도움이 된다.
$ cat a.sh
#/bin/bash
export VAR="abcd"
echo $VAR
$ ./a.sh
abcd
$ echo $VAR
$
각 명령어에 대한 자세한 내용은 다음 글에서 더 자세하게 설명해놓았습니다.
전체 환경변수 목록 확인
> export
특정 환경변수 값 확인
> echo $SHELL
환경변수 값 설정
> export 키=값
쉘 변수를 환경 변수로 변경
> NAME=value
> export NAME
export
가 bash의 빌트인명령 이라면, env
는 하나의 프로그램이다. 우리가 env
를 호출하면 다음과 같은 일이 순차적으로 진행된다.
env
가 새 프로세스로 실행된다.env
프로세스는 명령의 프로세스로 대체된다.Example:
env GREP_OPTIONS='-v' grep one test.txt
This command will launch two new processes: (i) env and (ii) grep (actually, the second process will replace the first one).
출처 : What's the difference between set, export and env and when should I use each?
다만 minishell 과제에서는 env
를 다른 추가 옵션이나 인자 없이 구현하기 때문에, 현재 지정되어 있는 환경변수 목록을 출력하기만 하면 된다.
변수 제거
> str="hello world"
> echo $str
> hello world
> unset str
> echo $str
>
char **envp
in mainint main(int argc, char **argv, char **envp)
키
와 값
으로 구성하고 =
로 구분한다.$BASH 사용하는 bash 쉘 경로
$COLUMNS 터미널 컬럼 수
$DISPLAY X 디스플레이 이름
$EDITOR 기본 편집기
$HISTFILE history 파일 경로
$HISTSIZE history에 저장되는 개수
$HOME 사용자 홈 디렉토리
$HOSTNAME 호스트 이름
$LANG 기본 언어
$LINES 터미널 라인 수
$LOGNAMES 로그인 이름
$MAIL 메일을 보관하는 경로
$MANPATH man 페이지 경로
$OSTYPE 운영체제 타입
$PATH 실행 파일 경로
$PS1 명령 프롬프트변수
$PWD 현재 작업 디렉토리
$SHELL 로긴 쉘
$TERM 터미널 타입
$UID 사용자 UID
$USER 사용자 이름
$VISUAL Visual 편집기
우리 팀의 미니쉘에서는 사용자에게 입력받은 command line를 저장하기위해 연결리스트를 사용했다.
42seoul의 첫 과제인 Libft의 t_list 연결리스트를 예로 들어서 설명하면 아래 코드와 같다.
typedef struct s_list
{
void *content;
struct s_list *next;
} t_list;
t_list *head = ft_lstnew(NULL);
t_list *cur_proc = head->next;
//content에 값(**cmdline)을 담는 과정은 생략
while(cur_proc != NULL)
{
printf("%s", cur_proc->content->cmdline[0]);
cur_proc = cur_proc->next;
}
그 외 Shell의 내장 함수, 시그널, 파이프, 리다이렉션 등의 minishell을 구현하면서 알아야 할 중요한 개념들은 구현을 시작하면서 다음 글들에서 따로 정리했다.
감사합니다