[Linux SystemProgramming] Process - Minishell

·

5 min read

Simple Minishell을 만들어보자.

먼저, Process가 무엇인지 간단하게 알아보자

Process

  • An entity that is registered to the kernel for execution

  • control flow passes from one process to another via Context Switching

<PCB>

모든 프로세스는 각각 PCB(Process Control Block)을 가지며, 이는 Process의 Metadata를 지니고 있는 자료구조이다. 각 PCB는 아래 그림의 내용을 지니고 있다.

프로세스에 대한 자세한 이야기는[OS - 프로세스 관리(Scheduling)] 편에서 자세히 알아보고, 여기서는 Process를 어떻게 만들고, Minishell을 만드는데 집중해보자.

Process 생성하기

먼저 2개의 Header File이 필요하다.

#include <stdio.h>
#incude <unistd.h>

<stdio.h>를 모르면 다른 진로를 알아보자.

<unistd.h>는 유닉스 계열에서 동작하는 C 컴파일러에 있는 헤더 파일이다. 윈도우 환경에는 존재하지 않으니, 리눅스 환경에서 돌려야 한다. 대부분의 시스템 함수들은 여기에 선언되어 있다.

추가로, <unistd.h>에 있는 기본적인 리눅스 파일 I/O 함수들이다. (File I/O 관련 자세한 내용은 추후 다른 게시글로 정리하겠다)

size_t read(int fd, void* buf, size_t nbytes);

fd : 파일 식별자, buf : 데이터 저장 위치, nbytes : 읽어올 바이트 수

return : 파일에서 읽은 바이트 수(성공) / -1(실패)

ssize_t write(int fd, void* buf, size_t nbytes);

fd : 파일 식별자, buf : 데이터 읽을 위치, nbytes : 쓸 바이트 수

return : 파일에 쓴 바이트 수(성공) / -1(실패)


이제 진짜 프로세스를 만들어보자.

프로세스를 만들기 전에 주의해야 할 사항은, 우리가 실행시키는 int main() 도 결국 하나의 프로세스라는 점이다!

프로세스의 생성은 fork() 를 통해 생성된다. 함수의 원형은 다음과 같다.

pid_t fork(void)

return: 0(for child process), pid(for child's pid)

fork()를 실행하면, 해당 지점부터 새로운 프로세스가 생성되며, 이를 자식 프로세스라고 통칭한다. 자식 프로세스는 생성된 시점부터 해야할 본인의 일을(?) 수행한다고 생각하면 된다. 또한, 생성된 자식 프로세스는 pid_t로 0을 return하며, 부모 프로세스(fork를 호출한 프로세스)는 자식 프로세스의 pid(프로레스 id)를 할당받는다. 예시를 통해 쉽게 이해해보자.

//헤더파일, main 생략
.
.
. 
// 부모 프로세스가 여기까지 실행하다가
pid_t pid = fork(); // 자식 프로세스 생성
// 부모는 계속 실행, 자식 프로세스는 여기서부터 실행
if(pid == 0) {
    printf("This is child Process! %d\n",pid);
}
else if(pid != 0) {
    printf("This is Parent Process! %d\n",pid);
}

<실행 결과>

// pid 번호는 실행시점마다 다를 것이다 (kernel이 그때그때 다르게 할당해줌)
This is Parent Process! 6096
This is child Process! 0

결국, fork()를 통해 생성한 자식 프로세스는 결과창 기준 윗줄을 출력하고, 부모 프로세스는 아랫줄을 출력한 것이다. 이때, 출력 순서와 getpid()의 값은 실행마다 달라질 수 있다.

흥미로운 점은, fork()는 1번 불렸지만, return 값이 2개(자식, 부모 프로세스)처럼 보인다는 것이다. 하지만, ❌이는 틀렸다❌. fork()를 실행하여 프로세스 생성에 실패했다면 -1을 즉시 리턴한다. 반면에, 프로세스 생성에 성공했다면, fork()함수 내부에서 자식 프로세스에게는 0을 부모 프로세스에게는 자식 프로세스의 pid를 리턴하는 것이다. fork() 도 결국에는 함수라는 점을 잊지말자.

또한, 프로세스를 다룰때에는 pid(Process ID)를 사용하여 다루는 경우가 많다. fork()를 통해 생성한 프로세스는 부모 프로세스 + 1의 pid를 가지게 된다. 예시를 통해 알아보자.

printf("Process pid:%d\n",getpid()); // 부모 pid
pid_t pid = fork(); 
if(pid == 0) {
    printf("This is child Process pid! %d\n",getpid()); // 자식 pid
    printf("This is child Process return! %d\n",pid);        
}
else if(pid != 0) {
    printf("This is parent Process pid! %d\n",getpid()); // 부모 pid
    printf("This is parent Process return! %d\n",pid);        
}

<실행 결과>

Process pid:9276
This is parent Process pid! 9276
This is parent Process return! 9277
This is child Process pid! 9277
This is child Process return! 0

void exit(int status)

return: 0(normally exit), !0 (Error)

exit() 함수는 간단하게, 프로세스를 종료한다고 보면 된다. status에는 보통 프로그램이 어떤 이유로 종료되었는지를 나타는 정수 값을 사용한다. 0은 일반적으로 프로그램이 성공적으로 종료되었음을 뜻하고, 1은 오류로 종료됬음을 의미한다. 추후에 알게되면 수정하겠지만, 지금은 웬만하면 0을 쓰도록 하자.

cf) atexit(call back function) 을 통해 exit()이 수행되었을때 실행할 함수를 설정할 수도 있다.


pid_t wait(int* status)

return: pid_t(종료된 자식 프로세스의 pid)

  • status는 exit과 같이 종료 상태 정보를 저장하기 위한 변수이다.

pid_t waitpid(pid_t pid, int* status, int options)

wait의 변형으로 특정 pid를 지정해서 해당 프로세스가 끝나기를 기다린다.

wait 함수는 다음과 같이 쓰인다.

  1. 부모 프로세스는 wait 함수를 호출하여 자식 프로세스의 종료를 기다립니다.

  2. 만약 자식 프로세스가 이미 종료되었다면, wait 함수는 즉시 반환하고 자식 프로세스의 PID를 반환합니다.

  3. 자식 프로세스가 종료되지 않았을 경우, 부모 프로세스는 자식 프로세스가 종료될 때까지 대기 상태에 있습니다.

  4. 자식 프로세스가 종료되면, wait 함수는 자식 프로세스의 PID를 반환하고, 자식 프로세스의 종료 상태 정보를 status 매개변수에 저장합니다. 이 종료 상태 정보는 자식 프로세스가 어떻게 종료되었는지를 나타내며, 부모 프로세스가 이 정보를 검사하여 적절한 조치를 취한다.


Minishell을 만들기 위해서는 다른 프로그램을 실행시켜주는 execv를 사용해야 한다.(execl, execlp... 등 많다)

int execv(char* path, char* argv[]);

path : 실행 파일의 Directory 포함 전체 파일 명

argv[] : 인수 (사용자 입력)

return: -1(실패)

보통 /bin/ 디렉토리 안에 있는 리눅스 명령어들을 사용한다. execv를 예시를 통해 알아보자.

char *argv[] ={ "/bin/ls", "-al", "/tmp", NULL};
execv( "/bin/ls", argv);

보통 shell command를 입력할때, ls -al /tmp 와 같이 입력을 준다. 이를 Token화 하여 argv 문자열 배열에 넣어두고, execv의 인자로 주는 것이다. exec에는 여러가지가 존재하는데, argv[0]에 절대경로를 주어야 하는 함수와, 상대경로를 주는 함수가 있으므로, 주의해야 한다.


Rediecting(>, <), pipelining(|)와 같은 것들을 제외한 simple minishell을 만들어보자.

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h> // getline
#include <string.h> // strtok, strcmp
#include <sys/wait.h> // wait
#include <sys/types.h> //size_t, ssize_t

#define MAX_LINE 64

int main(int argc, char* argv[]) {            
    pid_t pid;
    size_t size;

    char* cmd = NULL; // NULL -> getline automatically allign malloc.    
    char* path[MAX_LINE]; // argv[0]에 들어갈 절대경로
    char* input[MAX_LINE]; // Tokenized argv

    while(1) {
        getline(&cmd, &size, stdin); // User input
        cmd[strlen(cmd) -1] = '\0';  // EOS      
        // Tokenize
        int i = 0;
        char* ptr = strtok(cmd, " ");
        while(ptr != NULL) {
            input[i++] = ptr;
            ptr = strtok(NULL, " ");
        }
        input[i] = NULL;
        // Path
        sprintf(path, "/bin/%s", input[0]);
        // Exit by user
        if(strcmp(input[0], "quit") == 0){
            break;
        }
        pid = fork();
        if(pid == 0) {
            // Wrong input
            if(execv(path, input) == -1) { 
                perror("Command Error\n");
                exit(0);
            }
        }        
    }    
    if(pid > 0) {
        wait(NULL); // wait until all child process ends
        exit(0);
    }
}

시스템프로그래밍실습 수업은 어렵지만 신기하다는 느낌이 든다. 내가 shell command를 실행시키는 프로그램을 만들 수 있다니!!~

edited by 김지호