[minishell] 1. 과제소개 및 선행지식

미니쉘 과제를 시작하기 전 알고있으면 좋을 Shell의 구성요소, Shell에서 프로세스를 시작하는 방법, 허용함수 동작방식을 정리했습니다.

1. 과제 소개

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.

1.1. Instructions

  • 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와 동일하게 동작해야하도록 구현한다.

    • multiline commands를 제외한 ' , "

      • ' 또는 " 가 홀수개로 들어오면 사용자 입력을 기다리게 되는데 이건 구현 안해도 된다는 뜻.

    • file descriptor aggregation를 제외한 리다이렉션(<, >, >>)

    • 파이프(|)

    • 환경변수($ followed by characters)

    • $?

    • ctrl-C, ctrl-D, ctrl-\

1.2. Allowed Functions

  • malloc, free

  • read, write, open, close,

  • opendir, readdir, closedir

  • getcwd, chdir

  • stat, lstat, fstat

  • fork, execve

  • wait, waitpid, wait3, wait4

  • signal, kill

  • exit

  • strerror, errno

  • dup, dup2, pipe

2. 선행지식

1. shell 개요

1.1. Shell

셸은 컴퓨터와 상호 작용할 수 있는 응용 프로그램이다. 셸에서 사용자는 프로그램을 실행할 수 있으며, 입력과 출력을 파일에서 가져오도록 리디렉션할 수도 있다. 셸은 또한 함수, 변수 등과 같은 프로그래밍 구조를 제공한다. 셸 스크립트라고 불리는 셸 프로그램은 편집, 기록, 파일 완성, 와일드카드, 환경 변수 확장 및 프로그래밍 구성과 같은 기능을 제공한다.

1.2. Shell의 구성요소

셸을 구현하는 작업은 다음 네 부분으로 나뉜다.

  • 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 하지 않아도 사용할 수 있는 것이 특징이다. 출처

1.3. Shell 에서 프로세스를 생성하는 방법

프로세스를 시작시키는 것이 셸의 주요 기능임을 알았으니, 프로세스의 시작 방식과 진행 상황을 정확하게 알고 있어야한다. Unix에서 프로세스를 시작하는 방법은 두 가지 뿐이다.

  • Init

  • fork() : 대부분의 프로그램은 Init이 아니기 때문에 프로세스를 시작하는 실질적인 방법은 fork() syscall 뿐이다. 이 기능이 호출되면 운영 체제가 부로 프로세스로부터 자식 프로세스를 복제하여 두 프로세스를 병렬로 실행한다. 즉, 본질적으로 프로세스를 시작하는 유일한 방법은 복제 뿐이다.

    • 정리하자면,

  • 기존 프로세스는 두 개의 분리된 프로세스로 분기된다.

  • 그런 다음 자식 프로세스는 exec()을 사용하여 자신을 새 프로그램으로 바꾼다.

  • 부모 프로세스는 다른 작업을 계속 수행할 수 있으며 wait()를 사용하여 자식 프로세스를 계속 감시할 수도 있다.

2. 허용함수

2.1. fork()

fork()는 현재 실행중인 process를 복사해서 다른 process를 생성한다. 복사해서 생성하기 때문에, 가지고 있던 메모리등의 시스템 자원을 모두 원래의 process와 공유하게 된다.

fork()를 사용하여 생성한 프로세스는 부모 프로세스 Parent process, 새로 생긴 프로세스는 자식 프로세스 Child process 라고 부른다.

모든 프로세스는 (참고: 최상위 프로세스인 init는 pid 1을 가진다) 생성될 때 프로세스 아이디를 부여받는다. fork() 함수는 부모에게는 자식 프로세스의 pid를 반환하고, 자식에게는 0을 반환한다. 이를 이용하여 자식 프로세스에게 특정 명령을 시킬 수 있다.

주의: pid 는 변수명이지 실제 pid 를 의미하지 않는다. 따라서 getpid() 를 사용해야 이 함수를 부른 프로세스의 id 를 얻을 수 있다.

참고

2.2. wait, waitpid, wait3, wait4

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;
}

undefined

2.3. execve()

우리가 명령어로 생각하는 '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]이 프로그램 이름이었던 것과 같은 원리

참고

2.4. signal()

시그널에 대한 더 자세한 내용은 다음 글에 따로 정리했습니다.

sig_t signal(int sig, sig_t func);

sig 는 시그널 번호, func 는 해당 시그널을 처리할 핸들러.

  • Signal이란 Software interrupt로, process에 무엇인가 발생했음을 알리는 간단한 메시지를 비동기적으로 보내는 것이다.

  • 시그널을 받았을 때

    시그널은 고유의 의미를 내포하고 있다. 이러한 시그널을 받은 실행객체인 프로세스는 그에 맞는 행동을 해야 한다. 시그널을 받은 프로세스는 다음중 한가지 행동을 취해야 한다.

    1. 그 시그널을 처리할 등록된 함수(handler)를 호출한다.

    2. 시그널을 무시한다.

    3. 시그널을 무시하지 않지만, 그렇다고 해서 특별히 함수를 호출하지도 않는다.

  • 시그널 종료는 다양하고 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 함수이다.

  • 하지만 fork()를 통하여 일반 명령을 수행하는 자식 프로세스의 경우엔 작업 도중에 수행을 중단시킬 수 있어야 하므로 자식 프로세스의 경우에 한해서만 SIGINT를 DEFAULT로 설정한다.

  • 자식 프로세스가 백그라운드로 수행중일 때는 쉘의 뒤편에서 암묵적으로 수행하는 프로세스이므로 SIGINT 시그널을 무시하도록 설정한다.

참고

3. 환경변수

환경변수(Environment variable)은 쉘에서 참조하는 변수이다. 쉘에서 참조하는 변수는 크게 쉘 변수환경변수로 나누어 지는데, 환경변수와 쉘 변수의 가장 큰 차이점은 child process을 생성할 때 환경변수는 상속이 되는 반면 쉘 변수는 그렇지 않다는 점이다.

한편 자식프로세스, 쉘 스크립트에서 생성한 환경변수는 부모프로세스에서 참조할 수 없다. Unix 시스템은 자식 프로세스가 부모 프로세스의 값을 바꿀 수 없기 때문이다. 반대로 부모 프로세스는 자신의 값을 바꾸고 자식 프로세스에게 전달할 수 있다. 아래 예제를 참고하면 이해에 도움이 된다.

 $ cat a.sh
   #/bin/bash
   export VAR="abcd"
   echo $VAR
 $ ./a.sh
 abcd
 $ echo $VAR

 $

3.1. 환경변수 관련 명령어

각 명령어에 대한 자세한 내용은 다음 글에서 더 자세하게 설명해놓았습니다.

3.1.1. export

  • 전체 환경변수 목록 확인

    > export
  • 특정 환경변수 값 확인

    > echo $SHELL
  • 환경변수 값 설정

    > export 키=값
  • 쉘 변수를 환경 변수로 변경

    > NAME=value
    > export NAME

3.1.2. env

export 가 bash의 빌트인명령 이라면, env는 하나의 프로그램이다. 우리가 env를 호출하면 다음과 같은 일이 순차적으로 진행된다.

  1. env가 새 프로세스로 실행된다.

  2. 인자로 들어온 명령을 호출한다. 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 를 다른 추가 옵션이나 인자 없이 구현하기 때문에, 현재 지정되어 있는 환경변수 목록을 출력하기만 하면 된다.

3.1.3. unset

  • 변수 제거

    > str="hello world"
    > echo $str
    > hello world
    > unset str
    > echo $str
    
    >

3.2. char **envp in main

int main(int argc, char **argv, char **envp)
  • argc - 명령행 인자 개수

  • argv - 명령행 인자 벡터

  • envp - 환경변수 목록

    • 환경 변수 목록의 각 항목은 으로 구성하고 =로 구분한다.

    • 맨 마지막 항목 뒤는 NULL

3.3. 대표적인 환경변수 목록

$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 편집기

4. 연결리스트

우리 팀의 미니쉘에서는 사용자에게 입력받은 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;
}
  • cur_proc이 노드2을 가리키도록 초기화한다.

  • 헤드포인터가 첫번째 노드의 시작주소를 계속 갖고 있도록 보존하기 위해 cur_proc을 따로 쓴 것이고, 노드1인 head의 content는 NULL로 남겨둔다.

  • cur_proc이 담는 주소값을 다음 노드로 변경함으로서 cur_proc의 현재 노드를 갱신한다.

그 외 Shell의 내장 함수, 시그널, 파이프, 리다이렉션 등의 minishell을 구현하면서 알아야 할 중요한 개념들은 구현을 시작하면서 다음 글들에서 따로 정리했다.

Last updated