수업/System Programming

[System Programming] Shell 공부

hw-ani 2022. 11. 5. 13:00

Shell이 무엇인지 알아보고, Shell의 작동 원리를 카피해 비슷한 기능을 하도록 간단하게 구현하는 방법을 알아본다.

그 전에 기본이 되는 개념인 program과 process의 차이에 대해서도 간단하게 알아본다.


Program : disk에 file로 저장된 machine instructions

Process : memory에 올라와있는 machine instructions, CPU가 각 줄을 실행한다.

(PCB라는 구조체가 현재 프로세스 정보를 담고 관리한다)

 

file system이 disk의 file 정보들을 포함/관리 하듯이,

User Space라는 것이 memory의 processes와 그 data 정보를 포함한다.

 

메모리는 사실 아래 그림과 같이 kernel space와 user space 두 부분으로 나뉜다.

일반 process들은 user space를 활용하는 것이다.

메모리는 page라는 단위로 분할된다.

Kernel이 process를 생성하는 역할을 해준다.

free pages를 찾아서 machine codes와 data를 거기 올리고, 그 할당 정보를 담는 data structures를 세팅한다.

이후 실행시키는 것.

 

> `ps` 명령어
user space를 뒤져서 현재 실행 중인 프로세스에 관한 정보를 보여준다.
<option>
`-a` : 다른 유저나 다른 터미널에서 실행중인 모든 processes에 대해 출력한다. shell은 배제하고 출력.
`-l` : processes 정보를 long format으로 출력한다. process의 각종 정보를 상세하게 표시한다.
`-fa` : `-a` 옵션에 비해 사람이 읽기 편한 형태로 변환하여 출력한다. ex)UID를  문자로 출력함

 


Shell 이란?

OS' service에 접근하기위한 user interface를 제공하는 program이다.

즉, Shell도 다른 process를 실행시키고 관리하지만, 얘도 사실은 하나의 process(program)인 것이다.

그래서 얘는 OS에 딸려나오는게 아니라 사실 만들기 나름인 하나의 프로그램인지라 sh, bash, tcsh, csh 등 다양한 종류가 있다.(OS의 interface인 system calls까지가 OS라고 보는게 맞을듯)

 

보통 Shell의 main 기능은 다음과 같다.

1. Program running : 다른 program을 memory에 올려서 실행

2. Input/Output managing : redirection/pipe 등을 통해 process의 input/output을 다른 process/file의 input/output과 연결

3. Directly programmable UI : 간단한 coding 기능 제공 → shell script

 

이 글에선 위 기능 중 "다른 program을 실행하는 기능"에 대해 자세히 알아본다.

 

우리가 Shell에 `ls`나 `ps` 명령어를 줬을때 일어나는 일은 다음과 같다.

1. 명령어를 입력받는다.

2. 새로운 process를 만든다.

3. 새로운 process에서 입력받은 프로그램을 실행시킨다.

4. 기존 process는 새로운 process가 종료될때까지 기다린다.

5. 다시 1번으로 올라가 반복한다.

 

 

이제 어떤 일을 하는지 알았으니 위 기능들을 하는 System Call들을 하나씩 알아보자.

이후 각 System call을 함께 이용하여 Shell의 1번 기능(다른 프로그램을 실행)을 하도록 만든다.

 

 

 

1. execvp()

"exec 계열"의 함수들은 다른 program을 실행시킨다.

execvp의 경우 file 인자로 들어온 이름을 가지는 실행파일에 argv로 받은 각종 인자들을 넘겨줘서 실행시킨다.

구체적으로

1) 특정 program에서 execvp()을 호출

2) kernel이 해당 program을 disk에서 process로 load

3) kernel이 arglist를 해당 process로 copy

4) kernel이 해당 process의 main() 함수 호출

로 진행된다.

 

한가지 주의할 것이 있는데, exec 계열의 함수들은 다른 process를 현재 실행중인 process 위에 덮어써서 실행시킨다.

예를들어 A라는 프로세스가 exec 계열 함수로 B라는 프로그램을 메모리에 올린다면, B는 A가 사용중인 프로세스 공간에 그대로 덮어씌워져서 실행된다. 그래서 A의 exec 이후 코드들은 실행되지 않는다.

A process의 pid와 B process의 pid를 getpid() 함수로 찍어보면 같은 값이 나온다.

 

즉, execvp() 함수만 가지고선 Shell의 기능을 구현할 수 없다. Shell은 한 program을 실행하고 다시 기다렸다가 다른 program도 실행할 수 있어야하는데, 이것만으론 다른 프로그램 하나 실행하고나면 바로 종료돼버릴 것이기 때문이다.

해결방법은, 다른 process를 만들어서 그 process에서 execvp()를 이용하는 것이다.

 

execvp 함수 두번째 인자도 주의해야한다. 기존 command line argument 형식처럼 넘겨주는건 맞는데,
=> (1)두번째 인자 [0]번지에도 실행 파일의 이름이 들어가야되고, (2)각 배열 elements는 `\0`로 끝나야한다.
그리고 (3)마지막 배열 인자는 NULL(널 포인터)이 들어가야한다.

 

 

 

2. fork()

fork() 함수는 새로운 process를 만들고 본인 복사해서 넣는다. 자세하게 아래 순서로 진행된다.

1) Allocate : 새로운 memory와 kernel data structures를 할당

2) Copy : 기존 process(fork를 호출한 process)의 code와 data를 새로 할당받은 process에 복사

3) Add : 새로운 process를 running processes 집합에 추가

4) Return : 각 processes(fork 실행시킨 process와 새로 생긴 process)로 control 이동시킴 (새로운 process 만든다고 아마 kernel로 control이 이동했었을거임)

 

여기서는 Copy가 눈여겨 볼 요소이다. 새로운 process가 memory에 만들어지긴 하는데, fork를 호출한 program code/data가 그대~로 복사돼서 들어간다. PC 값, file descriptor 등등...

즉, fork() 이후 코드들은 두개가 동시에 실행된다. 새로 생긴 프로세스도 fork() 이후 부분부터 실행해나가기 시작한다.

. . .
ret_from_fork = fork(); //fork 이후로는 프로세스 두개에서 동시 실행된다.
printf("Hi!");
. . .

출력 : Hi!Hi!

 

그래서 fork()의 반환 값을 유용하게 사용한다.

parent process에서의 fork()의 반환값은 child process의 pid이지만,

child process에서의 fork()의 반환값은 0이다.

(parent process란 fork()를 호출한 process, child process란 fork()의 호출로 새로생긴 process)

 

보통 아래와 같이 반환값을 이용해 각각 행동을 나눠서 하도록 해주는 경우가 대부분이다.

. . .
fork_rv = fork();
if (fork_rv == -1)
	perror("fork");
else if (fork_rv == 0)
	//child process일때의 행동
else
	//parent process일때의 행동

(switch문으로 나눠도 좋을 듯)

 

 

 

3. wait()

wait() 함수는 자식 process가 종료할때까지 기다린다. (exit/return 등으로 종료될 경우)

(자식 process가 zombie process가 되는 것을 방지하기위해 사용하는 이유도 있다.)

(fork()를 여러번해서 자식 프로세스가 여러 개 생기는 경우도 있다.)

 

child process의 반환값도 parameter를 이용해 받아온다.

아래 그림처럼 넘겨준 parameter에 exit value, core dump flag, signal number 세 정보를 받아온다.

bit 잘 골라내서 읽으면 된다.

 

Tip.
shell에서 `&`를 명령어 뒤에 붙이면 해당 명령어는 background에서 실행된다.
ex) `./waitdemo2 &`

다시 foreground로 가져오려면 `fg` 명령어를 치면 된다. 백그라운드 작업이 여러 개인 경우 `fg %job_id` 이용한다.
job_id는 `jobs` 명령어를 통해 확인할 수 있다.

 

 

 

구현

이제 배운 내용들을 모두 합치기만 하면 된다.

위 흐름을 그대로 구현한다.

fork()에서 우리는 보통 부모 프로세스와 자식 프로세스의 행동을 조건문으로 나눠서 진행한다고 했는데, 자식 프로세스에서 execvp()를 실행하도록 하고, 부모는 wait()으로 기다리도록 하면 된다. 아래 예시 참고.

//코드 일부분
. . .
rv_fork = fork()
switch(rv_fork) {
    case -1:
    	perror("fork");
        exit(1);
        break;
    case 0:
    	execvp(프로그램이름, 인자배열);
        perror("execvp failed");
        exit(1);
        break;
    default:
    	wait(NULL);
        break;
}
. . .

 

 

전체 코드

(shell의 "다른 프로그램 실행 기능" 수행)

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define maxargs 20
#define arglen 100

int execute(char* arglist[]);
char* makestring(char* buf);

int main()
{
    char* arglist[maxargs+1];
    int numargs = 0;
    char argbuf[arglen];
    while(numargs<maxargs)
    {
        printf("arg[%d] ? \n",numargs);
        if (fgets(argbuf, arglen, stdin) && *argbuf !='\n') //'\n'도 인식하기위해 fgets사용
            arglist[numargs++] = makestring(argbuf);
        else
        {
            if(numargs>0)
            {
                arglist[numargs] = NULL;
                execute(arglist);
                numargs = 0;
            }
        }
    }
}

int execute(char* arglist[])
{
    int pid, exitstatus;

    pid = fork();
    switch (pid)
    {
    case -1:
        perror("fork failed");
        exit(1);
    case 0:
        execvp(arglist[0], arglist);
        perror("execvp failed");
        exit(1);
    default:
     /*fork를 여러번해서 자식 process가 여러개일 수도 있다. 그럴때 특정 자식 process가
     종료됐을때 다시 실행되도록 아래와 같이 할 있다. 여기선 fork 하나뿐이라 아래처럼
     안해도 상관없긴함.*/
        while(wait(&exitstatus) != pid)
            ;
        printf("child exited with stauts %d, %d \n", exitstatus>>8, exitstatus&0377);
    }
}

char* makestring(char* buf)
{
    char* cp;
    //fgets 쓰면 다른 애들도 다 엔터가 뒤따라 들어오니까 strlen-1번째를 '\0'으로 만듦
    buf[strlen(buf) - 1] = '\0';
    cp = malloc(strlen(buf) + 1 );
    if ( cp == NULL)
    {
        fprintf(stderr,"no memory\n");
        exit(1);
    }
    strcpy(cp, buf);
    return cp;
}

 

위 code에서 특정 pid의 child가 종료될때까지 wait()하는 구문이 있다.
위에서도 말했듯이 wait()은 해당 process의 child process의 종료를 기다리는건 맞는데,
그 child process가 여러개라면 그 중 한놈이 끝날때까지만 wait한다.(test해봄)
위 code는 child가 하나 뿐이어서 어찌하든 큰 상관은 없지만, child가 여러개라면 저런 기법이 유용할 수 있다.
원하는 child process가 끝날때까지 기다리도록 할 수도 있고, 아니면 모~든 child가 끝나고 실행시키고 싶다면 가장 마지막으로 종료되는 child의 pid를 이용해서 그놈이 종료될때까지 기다리게 할 수 있다.
ㅡㅡㅡ
위 코드를 잘 보면 execvp에 arglist를 넘겨줄때 [0]에는 해당 프로그램 이름이 같이 들어간다.
마지막은 NULL로 끝나고, 각 문자열 마지막엔 `\0`을 추가한다.
(그렇게 안하니까 제대로 작동 안함 ㅠ)

 

 

※참고※

keyboard signal은 해당 terminal과 붙어있는 모든 processes에 전송됨.

 

 

 

(참고) 다음은 xv6이라는 MIT에서 만든 교육용 OS의 shell program source code이다.

https://github.com/mit-pdos/xv6-public/blob/master/sh.c

(말고 운체 두번째 과제에서 한 것도 코드 찾아보면 있음. runcmd 함수가 위 코드랑 과제랑 비슷한데 좀 다른듯)