🖥️ 42 Subjects

minishell → bash Clone Coding

date
Mar 23, 2023
slug
minishell
author
status
Public
tags
42 Subjects
bsah
System Call
summary
bash를 클론코딩하며 OS의 구조를 대략 학습하며 시스템 콜을 활용합니다.
type
Post
thumbnail
category
🖥️ 42 Subjects
updatedAt
Mar 23, 2023 10:47 AM

쉘 만들기 (minishell)

쉘이란 운영제제와 사용자 사이에서 프로그램을 실행하거나 실행 결과를 문자로 출력해주는 "프로그램" 이다.
쉘은 sh, bash, zsh 등이 있으며 가장 기본 쉘은 sh이다. (sh가 가장 먼저 나왔으며 여기에 기능을 붙이고 한 것이 zsh, bash 등이다.)
iterm이나 기타 프로그램들도 쉘 (bash, zsh 등)을 실행해주는 것이며 쉘은 문자로만 통신하는 환경에서도 (예를 들면 시리얼 통신) 실행될 수 있다.

구현할 기능

  • 프롬프트
  • PATH 변수, 절대경로, 상대경로 등을 이용해 프로그램을 실행할 수 있어야 함.
  • 쉘 내장 기능 (echo -n, cd, pwd, export, unset, env, exit)을 실행할 수 있어야 함.
  • 명령문 내 세미콜론은 명령어를 구분할 수 있어야 함.
  • 외콤마 쌍콤마도 여러줄 명령어 들어갈 때 bash처럼 동작해야 함
  • 리다이렉션이 동작해야 함.
  • 파이프도 동작해야 함.
  • 환경변수도 동작해야 함.
  • ctrl + C / D / \ 도 동작해야 함.
일단 사용 가능한 함수들의 역할을 간단히 파악한 후 어떻게 작업을 수행할지 생각해보자.
C 표준 라이브러리 함수와 시스템 콜 함수가 있다. 아주 간단하게 분류만 해본다.

시스템 콜 함수 (man 2)

  • write : 1번 인수(파일 디스크립터)에 2번 내용을 3번 길이만큼 작성
  • read : 1번 인수(파일 디스크립터)에 있는 내용을 2번에 3번 길이만큼 작성
  • close : 인수(파일 디스크립터) 닫기
  • fork : 새 프로세스 생성
  • wait : 프로세스 종료 대기
  • wait3 : 프로세스 종료 대기
  • wait4 : 프로세스 종료 대기
  • waitpid : 프로세스 종료 대기
  • kill : 프로세스에 종료 "신호"를 보내는 함수
  • chdir : 현재 작업중인 디렉토리를 인수로 주어진 디렉토리로 변경
  • stat : 인수로 주어진 파일 상태를 가져옴
  • lstat : 인수로 주어진 파일 상태를 가져옴
  • fstat : 인수로 주어진 파일 상태를 가져옴
  • execve : 인수 파일 실행
  • dup : 파일 디스크립터 복제
  • dup2 : 파일 디스크립터 복제
  • pipe : 프로세서 사이 간 파일 디스크립터 쌍 생성
  • errno : 시스템 콜 함수 사용 에러시 에러가 이 변수에 기록됨

C 표준 함수 (man 3)

  • malloc : 메모리 할당
  • free : 메모리 할당 해제
  • signal : "신호" 를 받기 위한 시스템 콜 함수 sigaction의 단순화 버전
  • exit : 프로세스 종료
  • getcwd : 현재 작업중인 디렉토리 경로를 가져옴
  • opendir : 디렉토리 열기
  • readdir : 디렉토리 정보 가져오기
  • closedir : 디렉토리 닫기
  • strerror : 에러 넘버에 따른 에러 메시지를 리턴?

운영체제에 대한 아주아주아주 간단한 지식

쉘은 운영체제 커널과 실행하는 프로그램, 사용자 사이에서 인터페이스를 수행하는 프로그램이기 때문에, 또 시스템 콜 함수가 운영체제와 직/간접적으로 영향이 있기 때문에 운영체제에 대한 지식을 빠르게 훓고 간다. 이 과정이 없으면 내가 무엇을 만드는지 모를 것 같아 정리한다.
나는 모교 강의 자료를 바탕으로 지식을 다시 정리해 본다.

CPU란?

여러가지 관점으로 CPU를 바라볼 수 있다. ALU (산술 연산 장치)를 이용해 레지스터 내의 값들을 연산해 출력하는 것이 아주 간단한 CPU의 역할이다. CPU가 수행할 명령은 모두 메모리 내부에 들어있다.
CPU는 크게 4가지 모듈로 나눌 수 있다.
  • ALU (산술 연산 장치) : 더하기 빼기 곱하기 나누기 등과 같은 산술 연산을 수행한다.
  • 컨트롤 유닛 : CPU의 동작을 통제한다.
  • 레지스터 : CPU에서 사용하는 임시 변수, 명령어 주소 등 임시로 값을 저장하는 역할을 한다.
  • 버스 인터페이스 : CPU가 외부 모듈과 통신을 하는 데 사용한다.
ATmega 같은 8비트 CPU 임베디드 시스템은 프로그램을 메모리에 넣고 동작시키면 CPU가 프로그램을 명령어 단위로 하나하나 실행하며 사용자가 원하는 동작을 하게 한다. 하지만 정확히 의도한 동작을 하게 하려면 CPU의 입/출력 포트 등 구조를 자세히 알고 있어야 한다.
예를 들어 ATmega에서 FND와 온도 센서를 이용하여 현재 온도를 출력하는 프로그램을 작성한다 생각해보자. 이런 경우엔 CPU가 어떤 버스 인터페이스를 이용해 통신하며 이 인터페이스를 제어하려면 어떤 레지스터를 제어해야 하는지 알고 있어야 하며 FND와 온도 센서가 어떤 프로토콜로 통신하는지 알고 있어야 한다. 이런 과정을 알고 통신하기 위한 프로토콜을 구현해야 한다.

운영체제란?

하지만 최근에 사용되는 여러 복잡한 컴퓨터 시스템은 프로그램, CPU가 복잡하며 해야 할 일도 많다. 예를 들어서 우리가 사용하는 컴퓨터에 들어가는 프로그램을 완전히 밑바닥부터 코딩한다 생각해 보자. 단순한 문장을 출력하기 위해 당장 화면 픽셀에 점을 어디 찍을지 고민해야 하며 "화면에 점을 출력하는 행동"은 하드웨어를 제어하는 행동이기 때문에 당연히 현재 하드웨어에 대한 지식이 있어야 한다.
또 동일한 상황에서 동시에 여러 가지 프로그램을 운용한다 생각해 보자. 하드웨어를 직접 제어하며 화면에 뭔가를 출력하며 또 음악까지 재생한다 생각해 보자. 이런 경우 이 프로그램을 실행했다 저 프로그램을 실행했다 해야 하는데 이것을 C로만 구현할 수 있을까?
이런 상황에서 사용자가 보다 편하게, 하드웨어에 대한 관리나 여러 프로그램을 실행했을 때 적절히 실행되도록 해 주는 프로그램을 운영체제라고 한다.
운영체제의 목적과 정의는 여러 가지가 있겠지만 가장 중요한 기능은 사용자가 하드웨어에 대한 깊은 이해와 구현에 대한 신경을 덜 쓰고 하드웨어를 다룰 수 있도록 해주는 것과 여러 가지 프로그램을 동시에 실행해도 적절히 실행되는 기능을(이를 프로세스 관리라고 한다.) 해 주는 것이 운영체제의 가장 강하고 중요한 기능이라고 본다. (그리고 미니쉘 과제를 수행하는 데 가장 중요한 포인트라 생각한다.)
우리는 실제로 CPU가 그래픽카드와 어떻게 통신하며 화면에 글자를 출력하는지 잘 모른채 printf를 이용해 쉘 혹은 어딘가에 문자를 출력한다. 이런 행동은 운영체제가 해 주는 것이다.

시스템 콜이란?

운영체제가 제공하는 기능을 사용하게 해 주는 인터페이스이다. 위에서 말한 화면에 글자를 출력하거나 하는 하드웨어를 제어하는 행위나 운영체제가 관리하는 프로세스를 제어할 때 등에 시스템 콜을 사용한다.

시스템 콜의 종류

fork() / execve() - 프로세스 생성 (man 2 fork / execve)

위 시스템 콜의 그 fork() 및 exec()가 맞다.
유닉스 계열에서는 fork() 와 exec() 시스템 콜을 이용해서 프로세스를 생성한다.
먼저 fork() 시스템 콜은 현재 실행중은 프로세스를 PID 제외하고 그대로 복사해서 실행되도록 해준다. 이를 이용해서 한 프로세스 내에서 작업을 분할하게 할 수도 있다.
exec() 시스템 콜은 인수로 주어진 프로세스를 실행하라는 의미이지만 새로운 프로세스를 생성하는 것이 아니라 현재 프로세스의 내용 (PCB)을 새로 생성되는 프로세스로 교체한다.
따라서 어떤 프로세스에서 다른 프로세스를 생성하려면 fork로 프로세스를 복제한 다음 exec로 프로세스를 교체해야 한다. (왜 이렇게 번거롭게 작업하게 하는지는 모르겠다. unix 계열만 이런 식으로 구동되는지도 모르겠다.)
그래서 unix 계열의 프로세스는 최상위 프로세스에서 자식 프로세스가 fork()와 exec() 가 생성되는 식으로 여러가지 프로세스가 생성되는 방식이다.

exit() - 프로세스 종료 (man 3 exit)

위 시스템 콜의 exit()와 같다. exit 시스템콜이 호출되면 현재 프로세스를 종료하며 부모 프로세스에게 상태 값을 반환한다.
실제로 c 표준함수에서 exit 함수는 시스템 콜 _exit() 을 호출하지만 궂이 c 표준함수를 사용하는 이유는 exit() 함수가 그 외에 작업을 추가적으로 더 수행해준다.

wait() - 프로세스 종료 대기

인수로 주어진 프로세스가 종료될 때까지 기다린다.

프로세스

프로세스는 하드나 SSD에 있는 프로그램 (그러니까 바탕화면에 롤같은 프로그램이나 USB 안에 프로그램 등)이 메모리에 적재되 현재 실행 중인 프로그램을 의미한다.
거의 비슷한 용어는 작업(task) 이 있다.
프로세스의 가장 큰 특징은
  1. 프로세스가 메모리에 적재되어 있는 상태에서
  1. 운영체제(커널)에 의해서 스케줄링이 되는 것이다.
프로세스 (프로그램이 메모리에 적재될 때)는 여러가지 요소를 가지고 있다. 그 중 대표적인 것 네가지가 스택과 힙, 코드, 데이터이다.
스택은 주로
  • 함수의 매개변수와
  • 함수 호출 후 복귀용 주소
  • 함수의 지역변수
가 들어가며, 힙은
  • 동적으로 메모리 할당할 때 데이터
가 들어간다.
코드는 말 그대로 프로그램의 코드를 의미하며 데이터는 전역변수/Static 변수가 있다.

프로세스 스케줄링

프로세스는 크게 5가지 상태를 가진다.
  • new : 프로세스 생성 중
  • ready : 프로세스 할당 기다림
  • running : 프로세스 실행 중
  • waiting : 프로세스가 신호 혹은 입출력 완료 이벤트를 기다림
  • terminated : 프로세스 종료됨
운영체제는 생성되는 각각의 프로세스를 PCB(Process Control Block) 라는 자료구조로 관리한다. PCB 내에는 프로세스 상태, 프로세스의 PC (레지스터의 그 PC와 동일한 의미를 가진다), 프로세스가 사용하는 메모리의 시작과 끝, 스택, 힙 정보 등 프로세스를 관리하고 실행시키기 위한 정보를 가지고 있다.
운영체제는 PCB를 링크드 리스트 자료구조로 제어하며 적당한 알고리즘을 사용해 적당히 작업을 분배시키며 프로세스를 구동시킨다.

프로세스 연산

이 부분이 특히 중요한데 위에서 프로세스를 직접 다루는 시스템 콜이 등장하기 때문에 잘 알고 있어야 한다.
프로세스는 부모 프로세스와 자식 프로세스가 있으며 부모-자식 관계 때문에 자연스럽게 프로세스 트리라는게 생긴다.
부모 - 자식 프로세스 간 자원 공유를 전부 하거나, 일부만 공유하거나 전혀 하지 않을 수 있다.
또 부모 - 자식 프로세스는 병렬로 실행되어 실행 주기에 영향을 미치지 않거나 부모가 자식 프로세스가 끝날 때 까지 대기할 수도 있다.
부모 - 자식 프로세스 관계 때문에 우리가 프로세스를 사용할 때 항상 부모 프로세스라는게 존재한다. (완전히 독립적으로 생성되는 것이 아니라 어느 프로세스가 실행될 때 그 프로세스에서 새로 프로세스가 생성된다.)
예를 들면 우리가 바탕화면에서 프로그램을 더블클릭해서 실행하는 행위는 GUI 프로세스에서 자식 프로세스를 생성하는 행위와 같다.

IPC (Inter Process Communication)

IPC는 영어 말 뜻 그대로 프로세스 간 통신을 의미한다. 프로세스 간 메모리를 공유하는 방식으로 통신하거나 메시지를 직접 전달하는 방식으로 통신할 수 있다.

스레드

스레드는 프로세스 내에서 구동되는 흐름의 단위를 말한다. 초보자 용으로 쉽게 표현하면 마치 메인 함수를 여러개 두고 쓰는것과 같으며 이 스레드들은 프로그램 내의 자원을 공유한다. (자세한 설명은 다음 서클 철학자 과제에서 진행하므로 생략한다.)

쉘 만들어 보기

쉘은 쉘 입력칸에 무엇인가를 입력하고 엔터를 치면 그 무엇인가가 실행되고 종료할 때까지 계속 입력하는 창이 뜨게 된다.
그러면 쉘의 동작은 크게 3가지로 나눌 수 있다.
  1. 입력 받기
  1. 입력된 스트링 파싱하기
  1. 파싱된 결과대로 무엇인가를 실행하기
이 세가지 행동의 loop 이므로 일단 입력을 받는 것부터 만들어보자.

입력 받기

기존에 만들어 봤던 get_next_line 코드를 포팅해 사용한다.
이제부터는 프로세스를 직접 호출하고 프로세스의 상태 값을 직접 받아 다루기 때문에 에러 상황에 대해 적절한 에러 코드를 리턴하고 종료하게 해야 한다.

fork() / exec() 테스트

fork()

이 코드를 실행시켜보자.
pid_t pid; printf("1. before fork\n"); pid = fork(); printf("2. after fork (pid : %d)\n", pid);
실행하면 결과가 다음과 같이 나온다.
$> ./minishell 1. before fork 2. after fork (pid : 58688) 2. after fork (pid : 0)
fork 함수를 실행하고 나서 프로세스는 복제되어 이후로 마저 코드를 실행한다. 위에서 언급했다시피 차이점은 PID가 있는데 fork는 복제에 성공하면 부모 프로세스와 자식 프로세스 각각 리턴하는 값이 다른데 자식 프로세스에는 pid 0을 리턴한다. 그리고 부모 프로세스에는 복제된 자식 프로세스의 pid를 리턴한다.
이를 자세히 보려면 다음과 같이 sleep 함수를 걸어 종료되지 못하게 정지시켜놓고 ps로 프로세스를 확인해보자.
pid_t pid; printf("1. before fork\n"); pid = fork(); printf("2. after fork (pid : %d)\n", pid); sleep(10);
실행하면 결과가 다음과 같이 나온다. (and는 프로세스를 백그라운드에서 실행하겠다는 의미이다. 그러면 프로그램 PID가 나온다.)
$> ./minishell & [1] 58827 1. before fork 2. after fork (pid : 58828) 2. after fork (pid : 0) $> ps -l UID PID PPID F CPU PRI NI SZ RSS WCHAN S ADDR TTY TIME CMD 501 51219 51218 4006 0 31 0 4346816 1984 - Ss 0 ttys001 0:00.20 /bin/zsh -l 501 58827 51219 4006 0 31 5 4268324 720 - SN 0 ttys001 0:00.00 ./minishell 501 58828 58827 6 0 26 5 4277540 432 - SN 0 ttys001 0:00.00 ./minishell 501 55837 55836 4006 0 31 0 4354728 1836 - S+ 0 ttys003 0:00.15 -zsh
위에서 PID가 프로세스 ID고 PPID가 부모 프로세스 ID이다.
처음에 실행된 PID 58827 minishell은 zsh 위에서 실행되었으므로 PPID가 51219 (zsh) 이다.
그 밑에 PID 58828 minishell은 PPID가 58827 (minishell) 이다.
fork는 fork 작업에 실패하면 -1을 리턴하고 errno를 적당히 set 하므로 오류가 발생할 경우 적절히 대처해야 한다.

execve와 환경변수

execve의 프로토타입은 다음과 같다.
int execve(const char *path, char *const argv[], char *const envp[]);
첫번째 인수는 실행돨 프로그램의 경로이고 두번째는 실행될 프로그램에 입력될 인수이고 세번째는 실행될 프로그램에 입력될 환경 변수이다.
환경변수는 우리가 main 함수를 확장하면 다음과 같이 늘릴 수 있다.
(관련 내용 : https://www.gnu.org/software/libc/manual/html_node/Program-Arguments.html , envp를 통해 환경변수를 받는 것이 유닉스 시스템에서만 유효한 것으로 보인다.)
int main(int argc, char *argv[], char *envp[]);
이런 경우 우리가 쉘에서 env를 치면 나오는 환경변수가 저 프로그램 인수의 envp로 들어가게 되며 2차원 배열의 마지막 주소는 NULL로 된다.
추가로 쉘에서 환경변수를 새로 설정한다고 하면 그 환경변수는 그 쉘에서만 유효하다. 아마도 자식 프로세스에 환경변수를 넘기기 위해 envp라는 인수를 받는 듯 하다.
만약 실행에 실패할 경우 -1을 리턴하고 errno를 적당히 set 하므로 오류가 발생할 경우 적절히 대처해야 한다.
다음에는 이 코드를 실행해보자.
char *arg1 = "/bin/pwd"; char *arg[2]; arg[0] = arg1; arg[1] = NULL; printf("1. before exec\n"); execve("/bin/pwd", arg, envp); printf("2. after exec\n");
설명을 하면 argument에 기본값 (argument에 아무것도 넣지 않을 때 값은 실행 프로그램 경로가 들어간다.)을 넣고 인수는 main 함수에 입력된 것을 그대로 집어 넣는다.
실행하면 결과가 다음과 같이 나온다.
$> ./minishell 1. before exec [현재 minishell 실행중인 경로가 출력됨]
일단 minishell 실행중인 경로가 출력된다. pwd 명령어는 환경변수의 PWD를 출력하기 때문이다.
그리고 위에서 언급했다시피 exec 시스템 콜 호출 후 호출된 프로그램만 실행되고 종료되므로 2번째 printf는 출력되지 않는다.
분리시켜서 한 프로세스 내에 두개의 프로세스를 생성하게 할 수 있을까?
이 코드를 실행해보자.
pid_t pid; char *arg1 = "~"; char *arg[2]; arg[0] = arg1; arg[1] = NULL; printf("1. before exec/fork\n"); pid = fork(); if (pid > 0) execve("/bin/pwd", arg, envp); printf("2. after exec\n");
실행하면 결과가 다음과 같이 나온다.
$> ./minishell 1. before exec/fork 2. after exec [현재 minishell 실행중인 경로가 출력됨]
이 상황은 minishell은 minishell대로 실행하고 새로 생성된 pwd 프로세스는 그거대로 실행되는 상황이다.
이 코드도 실행해보자.
pid_t pid; char *arg1 = "/bin/sleep"; char *arg2 = "10"; char *arg[3]; arg[0] = arg1; arg[1] = arg2; arg[2] = NULL; printf("1. before exec/fork\n"); pid = fork(); if (pid == 0) execve("/bin/sleep", arg, envp); sleep(10); printf("2. after exec\n");
실행하면 결과가 다음과 같이 나온다.
$> ./minishell & [1] 59774 1. before exec/fork $> ps -l UID PID PPID F CPU PRI NI SZ RSS WCHAN S ADDR TTY TIME CMD 501 51219 51218 4006 0 31 0 4363200 2036 - Ss 0 ttys001 0:00.22 /bin/zsh -l 501 59774 51219 4006 0 31 5 4268340 568 - SN 0 ttys001 0:00.00 ./minishell 501 59775 59774 6 0 26 5 4277540 428 - SN 0 ttys001 0:00.00 /bin/sleep 10 501 55837 55836 4006 0 31 0 4354728 1820 - S+ 0 ttys003 0:00.16 -zsh 2. after exec [1] + done ./minishell
보면 minishell에서 생성된 sleep 프로세스는 실행 명령마저 변경되었다. 별개의 프로세스가 된것이다.

시그널

참조
https://www.joinc.co.kr/w/Site/system_programing/Book_LSP/ch06_Signal
시그널은 이전 cub3d에서 이벤트와 비슷하다. 하지만 시그널은 키 입력을 받는 것이 아니라 프로세스로 수신되는 신호를 받는 것에 대한 문제이며 이 신호들은 프로세스 외부에서 프로세스를 종료하는 것과 연관이 있다.
또 시그널은 신호가 큐잉되지 않으며 (쌓이지 않으며) 시그널을 보내는 순간 프로세스가 그 시그널을 못받는 상황이 온다면 무시된다. 시그널을 한 5개 날린다고 프로세스가 시그널을 5개 다 받는건 아니다. 프로세스는 본인이 시그널을 수신하면 받는대로 무엇인가를 수행한다.

man 3 signal / man 2 kill

프로세스에서 시그널을 어떻게 받을까? 관련 시스템 콜을 이용해서 받는다. C 라이브러리에 있는 signal 함수는 시스템 콜을 쉽게 받을 수 있도록 해 주는 함수이다.
그러면 프로세스가 다른 프로세스로 신호를 보내는 방법은 없을까? kill이라는 시스템 콜을 사용하면 원하는 프로세스에 원하는 신호를 보낼 수 있다.

키보드로 쉘에서 시그널 보내기

키보드로 현재 실행되고 있는 프로그램에 대해 시그널을 보낼 수 있다.
  • control + c : SIGINT (인터럽트 신호 - 보통 프로세스를 종료시킴)
  • control + \ : SIGQUIT (종료 신호)

control + d

멘데토리 항목에 있는 control + d는 시그널을 발생시키는 단축키가 아니다. 0x1a (EOF) 라는 아스키 문자를 입력해 주는 단축키이다.

bash에서 위 명령을 내리면?

쉘에서 bash를 실행시킨 다음 위 시그널을 보내면 프로세스가 종료되지 않는다. (SIGINT는 줄바꿈이 되고 SIGQUIT는 아무 일도 일어나지 않는다.)
일단 저 두 신호를 핸들링 해보자.

신호 컨트롤

void ft_signal(void) { signal(SIGINT, ft_sigint); signal(SIGQUIT, ft_sigkill); } void ft_sigint(int code) { printf("[SIGNAL] %d at %s\n", code, __func__); } void ft_sigkill(int code) { printf("[SIGNAL] %d at %s\n", code, __func__); }
위와 같이 설정해두면 신호 수신시에 printf가 출력된다. 하지만 ctrl + c / \로 종료가 되지 않으므로 쉘을 하나 더 열어서 kill 명령어로 죽여야 한다.

키보드 입력문자 제거

키보드 단축키로 시그널을 입력하면 입력한 문자가 터미널에 그대로 뜬다. (^c 혹은 ^\ ) 그래서 수동으로 제거해줘야 한다.
제거는 '\b' 문자를 이용해서 한다.

EOF

EOF (ctrl + d) 도 엄연히 bash와 동일하게 처리해야 한다. (신호가 아니라고 무시해서는 안된다.)
먼저 EOF가 무엇인지 알아보자.

EOF란?

우리가 쉘에서 데이터를 입력 혹은 출력할 때에 대해서 생각해 보자. 표준 입/출력이나 파일 모두 파일 포인터를 이용해서 파일 혹은 표준 입/출력에 접근했다. (해당 내용은 get_next_line을 해보면 알게 된다.)
근데 입출력과 파일을 같은 방식으로 다루는 것에 대해서 당시에는 좀 의아하게 생각했다. 리눅스 혹은 유닉스에서는 "스트림" 이라는 것을 이용해서 파일이나 표준 입출력을 제어한다. 스트림이라 하면 데이터의 그 자체를 의미하는 추상적인 개념이다. 이 스트림 중 "표준 스트림" 이 "표준 입출력" 이다.
스트림에 대한 자세한 내용은 다른 곳에서 찾아보는게 좋을 것이다. 아무튼 스트림을 통해 데이터 송/수신을 하다보면 (특히 파일을 읽다 보면) 전체 데이터 크기를 알아내서 데이터에 접근하는 것이 아닌 파일에 끝에 도달하다 보면 읽는 행위를 멈추게 된다. 이 "파일의 끝" 을 나타내는 데이터 혹은 아스키 코드가 EOF이다.
read 함수는 eof가 등장할때까지 파일을 계속 읽어나간다. (man 2 read 참조) 그래서 표준 스트림을 통해 입력을 받다가 eof가 등장하면 get_next_line은 0을 리턴하게 된다.

bash

bash에서 EOF (ctrl + d)를 입력해 보자.
$> bash The default interactive shell is now zsh. To update your account to use zsh, please run `chsh -s /bin/zsh`. For more details, please visit https://support.apple.com/kb/HT208050. bash-3.2$ exit $>
exit라는 문자열은 내가 입력한 것이 아니라 ctrl + d를 누르면 저 문자열이 출력되며 종료된다.
다른 상황을 만들어보자.
$> bash The default interactive shell is now zsh. To update your account to use zsh, please run `chsh -s /bin/zsh`. For more details, please visit https://support.apple.com/kb/HT208050. bash-3.2$ ls
아무 문자나 쉘에 입력해 놓고 ctrl + d를 누르면 (연타해도) 아무 반응이 없고 저 상황에서 엔터를 누르면 입력했던 명령어가 실행된다.
이렇게 동작되도록 한번 만들어 보자.

1. 아무 입력도 안한 상황에서 EOF 입력

이런 경우엔 입력된 문자열의 길이도 0이고 gnl로 읽은 결과도 0이다. 이런 경우엔 표준입력에 exit를 출력하고 종료한다.

2. 어떤 문자가 입력된 상황에서 EOF 입력

이 경우에는 입력되는 eof 문자를 아예 무시해야 한다. 사실 무시하지 못하고 문자열을 그만 받게 되므로 이런 상황에서는 이전 문자열과 이어 붙여야 한다.
위 상황을 잘 조합해서 문자열을 입력받을 수 있도록 코딩해 본다.

명령 파싱

일단 bash에서는 \을 이용해 개행도 쉘에 입력 가능하다. 하지만 지금 구현하고 있는 쉘은 gnl 기반으로 개행을 통해 명령어를 구분하기 때문에 개행에 대한 처리를 어떻게 할지 고민했다. (개행에 관해서는 별도로 언급된 사항이 없었다.)
평가 항목을 찾아보니 별도로 개행에 대해 처리하는 항목은 없는 것 같다. 그냥 한 줄로 입력되는 명령들을 잘 파싱하면 될 것 같다.
작동되어야 하는 항목을 참조하면 쉘에서 동작되는 스크립트 까지는 구현할 필요 없으며
  • ';' 으로 구분회는 명령
  • 파이프 사용
  • 리다이렉션 사용
  • 외따옴표, 쌍따옴표가 동일하게 작동
4가지만 구현하면 되는 것으로 보인다.

쉘 명령

쉘에서 실행되는 명령은 기본적으로 다음과 같다.
$> [명령] [인수1] [인수2] ...
파이프, 리다이렉션, 세미콜론을 제외한 구간에서는 항상 위와 같이 구성된다.
외따옴표나 쌍따옴표는 (Quotes 라고 한다.) 다음과 같은 용도로만 사용된다.
  • 공백으로 분리되는 문자열을 하나로 합칠 때
  • 공백이 둘 이상일 때 공백을 유지하기 위해 (따옴표가 없으면 하나 이상의 공백은 하나로 처리됨)
그래서 쉘에서는 아래 명령들이 똑같은 의미를 가진다.
$> ls -al $> "ls" -al $> ls "-al" $> 'ls' "-al"

명령 실행

쉘의 명령어는 보통 실행파일이다. 이런 실행파일은 사용하는 환경마다 다르지만 보통 /bin 폴더 내부에 있다.
이런 실행파일 (스크립트가 될수도 있다.)들은 쉘의 환경 변수 내의 PATH 라는 변수 폴더 내에 존재하면 별다른 경로를 입력하지 않고 실행 파일명만 입력하여도 바로 실행된다.
만약에 PATH에 등록되지 않은 실행파일을 실행하려면 절대경로 혹은 상대경로를 붙여야 실행된다.
근데 모든 명령어가 실행파일인 것은 아니다. 이런 것들 중 하나인 빌트인 명령이 있다. 이런 명령들은 쉘이 자체적으로 내장한 명령어이기 때문에 별도로 프로세스를 생성하지 않고 내부적으로 실행된다.
쉘이 지원하는 내장 명령어가 무엇이 있는지 확인하려면 man 1 builtin을 입력하면 된다.
인수를 입력하지 않고 간단하게 명령어만 실행되도록 해보자.

환경변수

위에서 언급한 main의 세번째 인수인 이차원 문자열 배열 envp를 파싱해야 한다.
기존 bash에서 env (현재 쉘의 환경변수를 출력하는 명령어)를 입력하면 PATH가 어떤 형식으로 작성되어 있는지 볼 수 있다.
PATH=경로1:경로2:경로3:... 와 같이 작성되어 있으므로 PATH=로 시작하는 문자열을 찾은 다음 split으로 쪼개면 PATH 경로들을 얻을 수 있다.

실행 가능 여부

환경 변수에 존재하는 경로를 파싱해서 쉘에 입력된 명령을 조합해 실행파일의 절대 경로를 얻을 수 있다. 하지만 해당 경로에 파일이 존재하지 않으면 파일을 열 수 없다.
그래서 파일을 열기 전에 파일에 대해 먼저 접근해서 파일을 실행할 수 있는지 알아내야 한다.
이를테면 절대경로의 파일이 존재하는지 확인하는 함수를 다음과 같이 만들 수 있다.
int ft_isexecutable(char *file) { struct stat test; if (stat(file, &test) != -1) return (1); else return (0); }

wait

위 기능을 조합하면 쉘에서 명령어를 입력하면 실행하게 할 수 있다. 하지만 지금 구현한 대로라면 부모 프로세스와 자식 프로세스가 따로따로 돈다. 쉘을 실행해보면 쉘에서 특정 명령을 실행하면 그 명령이 끝날 때 까지 쉘은 동작을 잠시 멈추게 된다. (& 같은 명령을 입력해 명령이 백그라운드로 실행되는 경우는 제외되며 이는 minishell 멘데토리에서 구현할 사항이 아니므로 넘어간다..)

기본형

pid_t wait(int *stat_loc);
fork 이후에 부모 프로세스에서 wait를 콜하면 부모 프로세스는 자식 프로세스가 종료될 때 까지 작동을 중단시킨다.
리턴값은 자식 프로세스의 PID이고 내부 인수의 stat_loc는 종료되는 자식 프로세스가 종료될 때의 상태값을 저장한다.
wait 시스템콜의 여러 바리에이션들이 있는데 자식 프로세스를 직접 지정하거나 자식 프로세스를 기다릴 지 여부를 선택할 수 있다.
wait을 제외한 다른 바리에이션은 option 인수를 받게 되어 있는데 이 인수를 통해 wait 함수 내에서 블로킹을 할지 말지 정할 수 있다. (wait()과 동일하게 자식프로세스 종료 전까지 대기하려면 0을 대입한다.)
exec, fork, wait 시스템 콜과 위에서 수행했던 작업들을 조합하면 내가 만든 쉘에서 명령어는 수행할 수 있다.

명령어에 인수 집어넣기

명령어와 인수 파싱

명령어에 인수를 집어넣으려면 일단 입력된 명령어를 공백 단위로 잘라야 한다.
단순히 자르는 것은 split 함수를 이용하면 되지만 우리는 외따옴표, 쌍따옴표에 대한 처리도 수행해야 한다.
그러면 어떤 식으로 할 지 코드를 작성하기 전에 미리 전략을 세워본다.
나는 다음과 같은 전략을 세웠다.
  1. 문자열에서 따옴표 모두 제거
    1. 제거를 하며 따옴표 내부에 존재하는 공백 문자는 모두 다른 문자로 대치
  1. 문자열을 공백으로 split
  1. 다른 문자로 대치된 공백 문자를 공백 문자로 치환

argv[0]

argv[0]에는 현재 실행중인 프로세스의 이름이 들어간다고 한다. 정확히 말하면 (쉘에서 실행 할 때 기준으로) 쉘에서 실행되는 명령어 이름이 들어간다. 예를 들어
$> ps $> /bin/ps
둘 다 ps라는 프로그램을 실행하는 것이지만 실행된 프로세스를 ps 혹은 top 등으로 실행중인 프로세스 이름을 확인해 본다면 두가지 경우 이름이 다르게 나온다.
https://unix.stackexchange.com/questions/187666/why-do-we-have-to-pass-the-file-name-twice-in-exec-functions/187673#187673 와 https://unix.stackexchange.com/questions/315812/why-does-argv-include-the-program-name 을 참조하면 프로세스를 exec를 이용해 실행할 때 argv[0]의 인수는 쉘에서 입력한 명령어가 그대로 들어가는 것을 확인할 수 있다.

상대 경로에 있는 프로그램 실행 및 디렉토리 이동

절대 경로와 상대 경로의 차이는 말에도 나와있듯이 어떤 것에 대한 절대적인 경로를 의미하는것이다. 절대 경로는 반드시 루트 디렉토리 (/) 로 시작한다. 상대 경로는 현재 위치 (현재 실행되고 있는 프로세스 기준)에 대한 상대적인 위치를 의미하는 것이다.
위에서는 PATH 경로에 있는 프로그램만 실행되도록 하였다. 하지만 상대 경로에 있는 프로그램을 실행하게 할 수도 있어야 한다.

현재 프로세스 실행경로의 위치

현재 프로세스의 실행경로의 위치를 알아야 exec로 프로세스를 생성할 수 있다. (exec)
이걸 워킹 디렉토리라고 하며 자세한 내용은 https://en.wikipedia.org/wiki/Working_directory

디렉토리 관련 사용 가능한 함수들

디렉토리 관련 사용 가능한 함수들은 다음과 같다.
  • getcwd : 현재 작업중인 디렉토리 경로를 가져옴
  • chdir : 디렉토리 변경
  • opendir : 디렉토리 열기
  • readdir : 디렉토리 정보 가져오기
  • closedir : 디렉토리 닫기
getcwd, chdir 먼저 테스트해본다. (나머지 3개의 함수는 지금 다룰 필요가 없다.)

getcwd

char *getcwd(char *buf, size_t size);
getcwd는 현재 동작되는 프로세스의 워킹 디렉토리를 알아와준다.
buf라는 위치에 워킹 디렉토리 문자열을 삽입하며 size에는 삽입한 문자열을 집어넣는다.
리턴값은 워킹 디렉토리를 저장한 포인터를 반환한다.
만약 buf가 NULL일 경우 size는 무시된다.

chdir

int chdir(const char *path);
프로세스의 워킹 디렉토리를 바꾼다. path로 워킹 디렉토리를 바꾸는데 정상적으로 바뀌면 0을 리턴하고 정상적으로 바뀌지 않으면 -1을 리턴하며 errno를 적절한 값으로 바꾼다.

명령어 파싱

일단 지금은 무조건 명령어를 PATH에서 검색하는 식으로 하였다. 하지만 경로가 명령어에 붙으면 PATH에서 찾으면 안되므로 경로가 붙은지를 판별하여 경로가 붙지 않은 경우 PATH에서 찾도록 하여야 한다.
만약 경로가 붙을 경우 이 경로를 파싱하여야 한다. (근데 안해도 될수도 있다.)

테스트

절대경로와 상대경로를 합하면 어떨까? 절대경로와 상대경로를 합해도 시스템 콜에서 인식 가능하다면? 위에서 말한 파싱을 궂이 안해도 될 수도 있다.
만약 디렉토리가 다음과 같이 구성되어 있다 해보자.
/
/a/
/b/
/b/a.out
그러면 a란 폴더 내부에서 b로 폴더를 이동한다 해보자.
chdir("/a/../b") 와 같은 명령을 수행할 때 정상적으로 동작되는지 확인한다.
execuv("/a/../b/a.out") 로 바이너리가 실행되는지를 확인해본다.
실행이 되는것으로 보아 절대경로 혹은 상대경로에 존재하는 명령어를 파싱하는 데 완전히 경로 단위로 파싱해서 재조합 할 필요가 없어진 것 같다.

리다이렉션

쉘에서 입력 및 출력은 stdin/stdout/stderr 세가지로 나누어진다. stdin은 쉘에 키보드로 무엇인가를 입력하는 것이고 stdout/stderr는 쉘에 무엇인가가 출력되는 것이다.
리다이렉션 기호는 저 입력 및 출력을 파일로부터 읽거나 쓸 수 있도록 해 주는 것이다.
기본적인 사용은 다음과 같다.
$> [명령어] [인수] < [명령어의 표준입력을 해당파일로 받음] $> [명령어] [인수] > [명령어의 표준출력을 해당파일로 받음] $> [명령어] [인수] >> [명령어의 표준출력을 해당파일로 받음] ; 기존 파일내용에 이어붙임 $> [명령어] [인수] > [명령어의 표준출력을 해당파일로 받음] < [명령어의 표준입력을 해당파일로 받음]
원래 bash에서는 리다이렉션 기호에 숫자로 파일 디스크립터를 정하지만 minishell에서는 표준입력/표준출력으로만 받는다.
리다이렉션은 여러개가 올 수 있다. 이런 경우 마지막에 온 리다이렉션에 대해서만 동작한다.
$> echo "1" > 1.txt $> echo "2" > 2.txt $> echo "3" > 3.txt $> cat < 1.txt < 2.txt < 3.txt 1 2 3 $> cat < 1.txt < 2.txt < 3.txt > 123.txt $> cat 123.txt 3
또 리다이렉션 기호는 어느 위치에 와도 동일하게 동작된다.
리다이렉션은 명령어와 인수 사이 어디든 올 수 있다.
$> echo "1" > 1.txt $> echo "2" > 2.txt $> > 3.txt echo "3" $> < 1.txt < 2.txt cat < 3.txt 1 2 3 $> < 1.txt < 2.txt < 3.txt > 123.txt cat $> cat 123.txt 3
이렇게 작성해도 잘 동작된다.

파일 디스크립터

파일 디스크립터는 유닉스 계열에서 파일을 다룰 때 사용하는 개념으로 유닉스에서는 거의 모든 요소를 파일로 다룬다. (실제 존재하는 디바이스도 파일로 다루며 여러 가지 요소들을 파일로 다룬다.) 그래서 프로세스가 파일에 접근할 때 접근하고자 하는 일종의 지시자가 필요한데 이것을 파일 디스크립터라고 한다.
유닉스 내부적으로 파일 디스크립터에 대응되는 특정한 파일이 있으면 해당 파일에 접근해 내용을 읽고 쓰고 변경할 수 있다.
가장 기본적으로 사용되는 것은 표준 입출력이다. (표준 입출력도 파일로 존재한다. 예를 들면 표준 입력도 /dev/stdin이라는 위치에 파일로 존재한다.) 근데 이런 표준 입출력은 이미 매칭이 되어 있어서 표준 입출력 번호 (0, 1, 2)에 내용을 읽고 쓰면 쉘에 그 내용이 바로 출력되게 된다.
프로세스에서 쉘에 입력 혹은 출력되는 내용들은 표준 입/출력을 사용하는데, 이를 임의로 변경하려면 파일 디스크립터끼리 연결하거나 기존에 가리키고 있는 디스크립터를 해제하고 다른 디스크립터에 대응되도록 우회하여야 한다.

dup()

int dup(int fildes);
dup() 시스템 콜은 인자로 들어온 파일 디스크립터를 복제하여 다른 파일 디스크립터로 생성해 준다.

dup2()

int dup2(int fildes, int fildes2);
dup2() 시스템 콜은 fildes2 파일 디스크립터의 기능을 fildes에 할당해준다. 리턴값은 보통 첫번째 인자를 반환한다.

pipe()

int pipe(int fildes[2]);
pipe() 시스템 콜은 입력된 크기가 2인 배열 변수에 파일 디스크립터끼리 연결해 주는 파이프를 할당해 준다. 데이터의 방향은 fildes[1] -> fildes[0] 이다. 여기서 생성된 파일 디스크립터로 부모/자식 프로세스 통신에 사용할 수도 있다.

쉘 로그인 메시지

쉘에 로그인하면 프롬프트 위에 뜨는 메시지가 있다. 없는 경우도 있지만 있는 경우도 있다.
예를 들면 bash는 다음과 같은 메시지가 뜬다.
$> bash The default interactive shell is now zsh. To update your account to use zsh, please run `chsh -s /bin/zsh`. For more details, please visit https://support.apple.com/kb/HT208050. bash-3.2$
위에서 공백 1줄, 문장 3줄이 bash를 실행시키면 출력되는 메시지이다. 필수 구현사항은 아니지만 한번 구현해 보자.
쉘에서 문자를 가지고 그림처럼 문자를 만드는 것을 한 번 쯤은 본적이 있을 것이다. 이를 만들어 주는 프로그램이 있는데 이를 figlet이라고 한다. 유닉스 계열에서는 90년대부터 사용한 프로그램이라 하는데 그런 문자를 만들기 위해서 무엇인가를 설치하는 것은 귀찮으므로 구글에 online figlet generator 와 같은 키워드로 찾아보면 온라인으로 만들어 주는 사이트가 있다.
나는 https://www.askapache.com/online-tools/figlet-ascii/ 에서 만들었다. 그럼 minishell이라는 글자를 다음과 같이 다음과 같이 꾸밀 수 있다.
## ## #### ## ## #### ###### ## ## ######## ## ## ### ### ## ### ## ## ## ## ## ## ## ## ## #### #### ## #### ## ## ## ## ## ## ## ## ## ### ## ## ## ## ## ## ###### ######### ###### ## ## ## ## ## ## #### ## ## ## ## ## ## ## ## ## ## ## ### ## ## ## ## ## ## ## ## ## ## #### ## ## #### ###### ## ## ######## ######## ########
여러 서체가 있으므로 취향에 맞는 서체를 이용해 쉘이 처음 실행될 때 출력되도록 하면 좋을 것이다.