Unix Shell 구현하기

하스코딩·2025년 4월 4일

환경설정

1. 프로그램 설치

  1. MicroSoft store에서 ubuntu 24.04.1 LTS 설치
  2. cmd 관리자권한 실행 후 wsl --install, y로 설치 계속
  3. username과 password를 입력하면 (화면에 보이지 않음)
  4. 내 user name으로 로그인되어 이제 sudo 권한을 가질 수 있게 된다.
  • 이제 아래처럼 mkdir과 cd를 통해 해당 경로로 이동해준다.
  1. 아래 경로에서 아래 두줄을 순서대로 입력해 gcc 설치를 완료해준다.
    (필요한 경우 password 입력)
myhas@LG16Z95P:/mnt/c/Windows/System32/os/ch02$ 
sudo apt update
sudo apt install gcc
  1. 정상적으로 설치되었으면 버전 정보가 출력되어야 한다.
gcc --version

2. C코드 작성법(2가지 방법)

1. cmd에서 작성하기

  1. 현재 상태에서 nano osh.c 로 파일을 생성하고 코드를 붙여넣고 저장해준다
  • 복사: (외부에서 ctrl+c로 복사 후 내부에서 마우스 우클릭)
  • 저장 및 종료: (Ctrl + O, Enter, Ctrl + X)
  1. 이후 cmd에 아래 두줄을 입력해 컴파일 및 실행을 해준다.
gcc -o osh osh.c
./osh

=> 만약 다시 cmd를 실행해 osh.c를 컴파일 하고 싶을 때

  1. wsl -d Ubuntu
  2. cd os, cd ch02로 경로 이동
  3. gcc -o osh osh.c
  4. ./osh
  5. 실행성공!

2. VScode + WSL 연동해서 작성하기(VScode는 이미 설치됨)

  • 만약 VScode로 C개발을 계속하려면 WSL 확장 기능을 사용하면 osh.c파일을 편하게 편집하고 컴파일할 수 있다.
  1. VScode를 실행해 왼쪽 사이드바에서 확장(Extensions) 탭에서 WSL 설치
  2. WSL에서 VScode를 실행하기 위해 WSL 터미널에서 아래 명령을 입력한다.
code .
  1. 이제 VScode 창이 열리면, 왼쪽 폴더에서 osh.c 파일을 열어 C코드를 작성하면 된다. 이제 cmd는 종료해도 된다.
  2. C 코드를 작성 후 Termianl에서 아래 두줄을 입력해 컴파일 및 실행을 해준다.
gcc -o osh osh.c
./osh

* 이후 다시 VScode를 실행해 작업하던 osh.c를 열고 싶을 때

  • 아래 순서로 입력하면 된다.

VScode에서 코드 구현

개요

  • Shell 인터페이스는 다음 명령을 입력할 수 있도록 사용자에게 프롬프트를 출력한다. 우리는 osh>이라는 프롬프트가 출력된 후 사용자의 명령을 입력받을 것이다.

  • Shell 인터페이스를 구현하는 기법 중 하나

  1. 부모 프로세스가 명령창에서 사용자가 입력한 명령 한 줄을 읽음
  2. fork() 시스템 호출로 별도의 자식 프로세스를 만들어서 exec() 패밀리 중 하나로 사용자 명령을 실행
    => 일반적인 경우 부모 프로세스는 자식 프로세스의 종료를 기다렸다가, 자식 프로세스가 종료한 후 부모 프로세스가 다시 실행된다.
  3. 하지만 Unix Shell에서는 명령어 끝에 &를 추가해 백그라운드 작업을 생성할 수도 있다. 이 경우 부모와 자식 프로세스는 병행적으로 실행된다.
  • 다음 코드는 명령줄을 처리하는 Shell 프로그램의 개요를 C언어로 작성한 것이다. main() 함수에서 osh>을 출력하고, 입력된 사용자 명령을 처리하는 개략적 과정이 나와 있다.

  • 스켈레톤 코드의 자세한 해석

#include <stdio.h>         // 표준 입출력 함수 (e.g. printf, fgets 등)
#include <unistd.h>        // fork(), execvp(), getpid() 등 시스템 호출

#define MAX_LINE 80     // 사용자가 입력할 수 있는 최대 명령어 길이
                        // 명령어는 최대 80자까지 입력 가능
                        // -> 명령어 + 공백 + NULL 포함
                        
int main(void)
{
	// 사용자가 입력한 명령어를 공백 기준으로 나눈 토큰들 저장할 배열
    // (execvp()는 문자열 배열 형태로 인자를 받기 때문에 이렇게 선언)
    // 명령어는 보통 2개 단어 정도가 많아서 최대 40개 + NULL 포인터 공간 확보
    char *args[MAXLINE / 2 + 1]; 

    // 쉘이 계속 실행될지 결정하는 변수(무한 루프 제어)
    // 사용자가 "exit" 명령을 입력하면 0으로 바꿔서 루프 종료
    int should_run = 1;

	//사용자가 종료 명령을 입력할 때까지 반복
    while (should_run) {  
        printf("osh> ");  // 프롬프트 출력 (사용자 입력 대기)
        fflush(stdout);
        // 출력 버퍼를 강제로 비워서 사용자에게 즉시 프롬프트가 바로 보이도록 함

        /**
         * 여기에 들어갈 핵심 로직
         *
         * 1. 사용자로부터 입력을 읽는다 (예: fgets 또는 getline 사용)
         * 2. 입력받은 문자열을 공백 단위로 잘라 args[]에 저장 (토큰화)
         * 3. fork()를 호출해 자식 프로세스를 생성한다
         * 4. 자식 프로세스는 execvp()를 호출하여 명령어 실행
         * 5. 부모 프로세스는 자식이 끝날 때까지 wait()로 대기
         *    단, 명령어 끝에 `&`가 있으면 백그라운드 실행으로 wait 생략
         */
    }

    return 0;  // 프로그램 종료
}

1단계. 자식 프로세스에서 명령 실행하기

  • 첫 번째 작업은 주어진 스켈레톤 코드를 수정해 자식 프로세스가 fork되고 사용자 명령을 수행하도록 하는 것이다.
  1. 이 작업을 위해 사용자가 입력한 명령을 토큰으로 분할하고, 분할된 토큰들을 args 스트링 배열에 저장해야 한다.
    EX) ps –ael 입력 시 args 배열에는 아래와 같이 저장됨
    args[0] = “ps”
    args[1] = “-ael”
    args[2] = NULL
  2. 이 args 배열은 execvp() 함수의 인수로 전달된다. execvp() 함수의 모양은 다음과 같다.
    ```
    execvp(char *command, char *params[])
    ```
    command: 실행할 명령
    params: 명령의 파라미터
    => 여기서는 execvp(args[0], args)로 호출
  3. 사용자가 명령줄 마지막에 &를 사용했는지 확인

소스코드

#include <stdio.h>         // 표준 입출력 함수 (e.g. printf, fgets 등)
#include <unistd.h>        // fork(), execvp(), getpid() 등 시스템 호출
#include <string.h>        // 문자열 처리 함수 (e.g. strtok, strcmp 등)
#include <sys/types.h>     // pid_t 타입 정의
#include <sys/wait.h>      // wait() 함수 사용을 위한 헤더

#define MAXLINE 80         // 사용자가 입력할 수 있는 최대 명령어 길이


int main(void)
{

    char *args[MAXLINE / 2 + 1]; 
    // 사용자가 입력한 명령어를 공백 기준으로 나눈 토큰들 저장 배열
    // 명령어는 보통 2개 단어 정도가 많아서 최대 40개 + NULL 포인터 공간 확보

    int should_run = 1;// 프로그램을 계속 실행할지 여부 (무한 루프 제어)

    char last_command[MAXLINE];//마지막 명령 저장공간
    int has_history = 0;//히스토리 존재 여부

    while (should_run) {
        printf("osh> ");
        fflush(stdout);
        // 출력 버퍼를 강제로 비워서 사용자에게 즉시 프롬프트가 바로 보이도록 함

        char input[MAXLINE];
        //fgets() 띄어쓰기 포함 문자열 사용자 입력을 받고
        if (fgets(input, MAXLINE, stdin) == NULL) {
            perror("fgets failed");
            continue;//사용자 입력 없으면 에러 출력 후 다시 반복
        }

        //줄 끝의 \n 개행 제거(문자열 끝 처리)
        input[strcspn(input, "\n")] = '\0';

        // Tokenize input
        int arg_index = 0;//배열 인덱스

        //공백기준 토큰분리해 포인터저장
        char *token = strtok(input, " ");
        int background = 0;//명령어 끝에 &여부 확인

        //토큰 분석
        while (token != NULL && arg_index < MAXLINE / 2) {
            if (strcmp(token, "&") == 0) {//&면 백그라운드 on, args에 넣지 않음
                background = 1;
            } else {
                args[arg_index++] = token;//토큰을 args배열에 인덱스별로 삽입해줌
            }
            token = strtok(NULL, " ");
        }
        args[arg_index] = NULL;//args는 NULL로 끝나야 execvp()가 작동

        //빈 입력이면 continue
        if (arg_index == 0) {
            continue;
        }


        //fork()로 명령어를 실행할 자식 프로세스 생성
        pid_t pid = fork();

        // 백그라운드가 아니면, 부모 프로세스는 자식 wait했다가 종료코드 받으면 실행됨
        if (pid > 0) {
            if (!background) {
                wait(NULL);
            }
        } 
        //자식 프로세스 실행
        else if (pid == 0) {
            // 자식 프로세스
            if (execvp(args[0], args) == -1) {
                perror("execvp failed");
            }
            return 1;//실행 실패 시 오류 출력하고 1반환
        } 
        //fork() 실패, 에러 출력 후 다시 루프
        else {
            perror("Fork failed");
            continue;
            
        }
    }

    return 0;
}

실행결과

2단계. 명령어 히스토리 기능 추가하기

  • Shell 프로그램을 수정해 사용자가 !!을 입력 시 가장 최근 입력한 명령을 다시 실행할 수 있도록 하는 히스토리 기능을 추가한다.
  • 이렇게 실행된 명령도 히스토리에 추가되어야 하며, 최근 실행한 명령이 없을 경우 “No command in history”라는 오류 메시지를 출력하여야 한다.
  • “! 숫자“ 입력시 해당 숫자에 해당하는 명령어 실행되면 가산점

소스코드

#include <stdio.h>         // 표준 입출력 함수 (e.g. printf, fgets 등)
#include <unistd.h>        // fork(), execvp(), getpid() 등 시스템 호출
#include <string.h>        // 문자열 처리 함수 (e.g. strtok, strcmp 등)
#include <sys/types.h>     // pid_t 타입 정의
#include <sys/wait.h>      // wait() 함수 사용을 위한 헤더
#include <stdlib.h>        // atoi, exit 등 포함

#define MAXLINE 80         // 사용자가 입력할 수 있는 최대 명령어 길이
#define HISTORY_SIZE 10    // 저장할 수 있는 최대 히스토리 개수

int main(void)
{
    char *args[MAXLINE / 2 + 1]; 
    // 사용자가 입력한 명령어를 공백 기준으로 나눈 토큰들 저장 배열
    // 명령어는 보통 2개 단어 정도가 많아서 최대 40개 + NULL 포인터 공간 확보

    int should_run = 1; // 프로그램을 계속 실행할지 여부 (무한 루프 제어)

    char history[HISTORY_SIZE][MAXLINE]; // 명령어 히스토리 저장 배열
    int history_count = 0; // 현재 저장된 히스토리 수

    //사용자가 종료 명령을 입력할 때까지 반복
    while (should_run) {
        printf("osh> ");
        fflush(stdout);
        // 출력 버퍼를 강제로 비워서 사용자에게 즉시 프롬프트가 바로 보이도록 함

        char input[MAXLINE]; //입력 명령어를 저장할 배열
        //fgets() 띄어쓰기 포함 문자열 사용자 입력을 받고
        if (fgets(input, MAXLINE, stdin) == NULL) {
            perror("fgets failed");
            continue;//사용자 입력 없으면 에러 출력 후 다시 반복
        }

        //줄 끝의 \n 개행 제거(문자열 끝 처리)
        input[strcspn(input, "\n")] = '\0';

        // !! 처리
        if (strcmp(input, "!!") == 0) {
            if (history_count == 0) {//히스토리 없으면 메시지 출력
                printf("No command in history\n");
                continue;
            }
            //히스토리가 있으면 가장 최근 명령어를 input에 복원해 출력
            strcpy(input, history[history_count - 1]);
            printf("osh> %s\n", input);
        }
        // !번호 처리
        else if (input[0] == '!' && input[1] != '\0') {
            int index = atoi(&input[1]) - 1;
            if (index < 0 || index >= history_count) {
                printf("No such command in history\n");
                continue;
            }
            // 해당 번호의 명령어를 복원
            strcpy(input, history[index]);
            printf("osh> %s\n", input);
        }

        // 히스토리에 명령어 저장
        if (history_count < HISTORY_SIZE) {
            strcpy(history[history_count++], input);
        } else {
            // 가장 오래된 명령어를 제거하고 새로운 명령어 추가
            for (int i = 1; i < HISTORY_SIZE; i++) {
                strcpy(history[i - 1], history[i]);
            }
            strcpy(history[HISTORY_SIZE - 1], input);
        }

        // Tokenize input
        int arg_index = 0;//배열 인덱스

        //공백기준 토큰분리해 포인터저장
        char *token = strtok(input, " ");
        int background = 0;//명령어 끝에 &여부 확인

        //토큰 분석
        while (token != NULL && arg_index < MAXLINE / 2) {
            if (strcmp(token, "&") == 0) {//&면 백그라운드 on, args에 넣지 않음
                background = 1;
            } else {
                args[arg_index++] = token;//토큰을 args배열에 인덱스별로 삽입해줌
            }
            token = strtok(NULL, " ");
        }
        args[arg_index] = NULL;//args는 NULL로 끝나야 execvp()가 작동

        //빈 입력이면 continue
        if (arg_index == 0) {
            continue;
        }

        //fork()로 명령어를 실행할 자식 프로세스 생성
        pid_t pid = fork();

        // 백그라운드가 아니면, 부모 프로세스는 자식 wait했다가 종료코드 받으면 실행됨
        if (pid > 0) {
            if (!background) {
                wait(NULL);
            }
        } 
        //자식 프로세스 실행
        else if (pid == 0) {
            // 자식 프로세스
            if (execvp(args[0], args) == -1) {
                perror("execvp failed");
            }
            return 1;//실행 실패 시 오류 출력하고 1반환
        } 
        //fork() 실패, 에러 출력 후 다시 루프
        else {
            perror("Fork failed");
            continue;
        }
    }

    return 0;
}

실행 결과

3단계. 입출력 재지정(I/O Redirection)

  • Shell을 수정하여 <와 > 연산자를 통하여 입출력 재지정 기능을 구현한다.
  1. ">" 연산자: 명령 실행의 출력을 파일로 저장

  2. "<" 연산자: 명령 실행 입력을 파일로부터 읽어옴
    EX) osh> ls > out.txt 입력 시: ls 명령의 출력이 out.txt라는 파일로 저장
    osh> sort < in.txt 입력 시: sort의 입력을 in.txt 파일로부터 읽어옴
    ( "sort < 파일명" : 텍스트 파일에서 입력을 읽어 정렬해줌 )

    단 최대 하나의 입력 혹은 출력 재지정만 존재한다고 가정한다. 입출력을 동시에 재지정하지는 않는다고 가정한다.

  3. 입출력 재지정을 위해서는 dup2() 함수를 사용한다.
    => dup2() 함수: 이미 존재하는 파일 디스크립터를 다른 파일 디스크립터로 복사한다

    EX) fd가 out.txt 파일에 대한 디스크립터일 경우 아래와 같이 fd 디스크립터를 표준 출력에 복사한다.

    dup2(fd, STDOUT_FILENO);
    

    즉, 터미널에 출력하는 모든 것이 out.txt 파일에 저장됨을 의미한다.

최종코드

#include <stdio.h>         // 표준 입출력 함수 (e.g. printf, fgets 등)
#include <unistd.h>        // fork(), execvp(), getpid() 등 시스템 호출
#include <string.h>        // 문자열 처리 함수 (e.g. strtok, strcmp 등)
#include <sys/types.h>     // pid_t 타입 정의
#include <sys/wait.h>      // wait() 함수 사용을 위한 헤더
#include <fcntl.h>         // open(), O_RDONLY 등 사용
#include <stdlib.h>        // exit()

#define MAXLINE 80          // 사용자가 입력할 수 있는 최대 명령어 길이
#define HISTORY_SIZE 10     // 저장할 히스토리 최대 개수

int main(void)
{
    // 사용자가 입력한 명령어를 공백 기준으로 나눈 토큰들 저장할 배열
    // (execvp()는 문자열 배열 형태로 인자를 받기 때문에 이렇게 선언)
    char *args[MAXLINE / 2 + 1]; 

    // 쉘이 계속 실행될지 결정하는 변수(무한 루프 제어)
    // exit 명령 시 0이 되어 루프 종료
    int should_run = 1;

    char history[HISTORY_SIZE][MAXLINE]; // 최근 명령어들을 저장하는 히스토리 배열
    int history_count = 0;               // 저장된 히스토리 개수

    // 사용자가 종료 명령을 입력할 때까지 반복
    while (should_run) {
        printf("osh> ");
        fflush(stdout);  
        //출력 버퍼를 강제로 비워 사용자에게 즉시 프롬프트가 보이도록 함

        char input[MAXLINE];//입력 명령어를 저장할 배열

        //fgets() 띄어쓰기 포함 문자열 사용자 입력을 받고,
        //사용자 입력 없으면 에러 출력 후 다시 반복
        if (fgets(input, MAXLINE, stdin) == NULL) {
            perror("fgets failed");
            continue;
        }

        //입력 문자열 끝의 개행 문자 제거(문자열 끝 처리)
        input[strcspn(input, "\n")] = '\0';  

        //1. !번호 처리 (예: !3 은 3번째 명령 실행)
        if (input[0] == '!' && input[1] != '!') {
            int index = atoi(&input[1]) - 1; // 입력된 숫자 -1 (0부터 시작)
            if (index < 0 || index >= history_count) {
                printf("No such command in history\n");
                continue;
            }
            strcpy(input, history[index]);
            printf("osh> %s\n", input);
        }
        //2. !! 처리 (가장 최근 명령 실행)
        else if (strcmp(input, "!!") == 0) {
            //히스토리가 없으면 메시지 출력
            if (history_count == 0) {
                printf("No command in history\n");
                continue;
            }
            //히스토리 있으면 이전 명령어를 input에 복사해 출력
            strcpy(input, history[history_count - 1]);
            printf("osh> %s\n", input);
        } 

        // 입력된 명령어를 히스토리에 저장한다
        if (history_count < HISTORY_SIZE) {
            strcpy(history[history_count++], input);
        } else {
            //갱신 방식: 가장 오래된 명령어 제거 후 새 명령 추가 (FIFO 방식)
            //명령어를 한칸씩 당겨 저장해 history[0] 명령어 제거 
            for (int i = 1; i < HISTORY_SIZE; i++) {
                strcpy(history[i - 1], history[i]);
            }
            strcpy(history[HISTORY_SIZE - 1], input);
        }

        // Tokenize input
        int arg_index = 0;//args배열 인덱스

        //공백기준 토큰 분리해 포인터 저장
        char *token = strtok(input, " ");
        int background = 0;//명령어 끝에 &여부 확인 플래그

        char *infile = NULL;//< 입력 재지정
        char *outfile = NULL;//> 출력 재지정

        //입력된 명령어를 공백 기준 토큰단위로 분석
        while (token != NULL && arg_index < MAXLINE / 2) {
           
            // strcmp결과 두문자가 정확히 같으면 0반환해 조건문 실행 
            //경우1. 명령어 끝에 & 붙어 백그라운드 실행 
            if (strcmp(token, "&") == 0) {
                background = 1; 
            } 
            //경우2. < 연산자 다음의 파일명을 infile에 저장한다.
            else if (strcmp(token, "<") == 0) {
                token = strtok(NULL, " ");
                if (token != NULL) infile = token;  // 입력 리디렉션 파일 지정
            } 
            //경우3. > 연산자 다음의 파일명을 outfile에 저장한다.
            else if (strcmp(token, ">") == 0) {
                token = strtok(NULL, " ");
                if (token != NULL) outfile = token;  // 출력 리디렉션 파일 지정
            } 
            //경우4. 앞의 문자들에 해당 안되는 일반 명령이면 그냥 args 배열에 저장
            else {
                args[arg_index++] = token;
            }
            //token 변수에 다음 토큰으로 갱신해 저장
            token = strtok(NULL, " ");
        }
        //args는 NULL로 끝나야 execvp()가 작동되므로 마지막에 대입
        args[arg_index] = NULL; 

        //만약 빈 입력이면 osh>에 다시 입력받음
        if (arg_index == 0) continue;

        //fork()로 명령어를 실행할 자식 프로세스 생성
        pid_t pid = fork();

        //1. 부모 프로세스 실행
        if (pid > 0) {
            // 백그라운드가 아니면 자식 종료까지 대기
            if (!background) {
                wait(NULL);
            }
        } 
        //2. 자식 프로세스 실행
        else if (pid == 0) {
            //2.1 infile에 내용이 있으면 < 입력 리디렉션 처리
            if (infile != NULL) {
                //file을 R모드로 열어 파일 디스트립터(fd)를 반환
                int fd = open(infile, O_RDONLY);
                if (fd < 0) {//파일 열기 실패시 에러메시지 출력 후 자식 종료
                    perror("open (input) failed");
                    exit(1);
                }
                //표준 입력(0)을 fd로 복제한다. 
                //즉 프로그램이 입력을 받으면 파일에서 읽도록 함
                dup2(fd, STDIN_FILENO);
                close(fd);
            }
            //2.2 > 출력 리디렉션 처리
            if (outfile != NULL) {
                //outfile을 쓰기모드로 열되, 파일이 없으면 생성(create), 있으면 내용을 비운다(trunc).
                int fd = open(outfile, O_WRONLY | O_CREAT | O_TRUNC, 0644);
                if (fd < 0) {//파일 열기 실패 시 에러메세지 출력 후 자식 종료
                    perror("open (output) failed");
                    exit(1);
                }
                //프로그램의 표준 출력을 이제 파일에서 출력
                dup2(fd, STDOUT_FILENO);
                close(fd);//dup2로 복사했으므로 fd는 close
            }
            //3. 명령어 실행 execvp로 현재 프로세스를 새 프로그램으로 교체
            if (execvp(args[0], args) == -1) {
                perror("execvp failed");
            }
            return 1; //종료코드 반환해 자식 프로세스 종료
        } 
        //fork() 실패 시, 에러 출력 후 다시 루프
        else {
            perror("Fork failed");
            continue;
        }
    }

    return 0;
}

실행 결과

0개의 댓글