전산학/운영체제

쉘의 명령에서 내부적으로 일어나는 일

lindeblad 2024. 10. 30. 02:02

쉘에서 ls 명령을 입력하면 해당 위치의 파일 목록이 주루륵 출력되고, 다음 명령을 기다리는 상태로 다시 돌아간다. 다음은 현재 경로에 존재하는 모든 *.so 파일들을 출력하는 ls -l *.so 명령을 입력했을 때의 모습이다.

 

내부적으로 쉘은 어떻게 동작하는 것일까?

파싱 (Parsing)

쉘에서 받은 문자열 입력을 토큰 단위로 쪼갠다. 예를 들어, "ls -l *.so" 명령은 "ls", "-l", "*.so"로 쪼개져서 executor로 전달된다.

 

예시에 든 것처럼 공백을 단위로 쪼개는 것이 파서의 전부는 아니다. 쉘은 프로그래밍 언어와 같은 다양한 문법을 가지기 때문이다. 예를 들면 다음과 같다:

  • 여러 명령의 순차적 실행: {명령 1}; {명령 2}
  • 명령을 백그라운드에서 실행: {명령} &
  • 명령의 결과를 다음 명령으로 전달: {명령 1} | {명령 2}

실행 (Execution)

"ls -l *.so"는 "ls"라는 커맨드라는 것을 파서가 인식했다면, 실행기는 해당 커맨드를 찾아서 실행하는 프로세스를 소환한다. 기본적으로 슬래시(/)나 점+백슬래시(./)으로 시작하는 커맨드는 그 경로에 있는 실행 파일을 실행하고, 그렇지 않은 경우 쉘에 등록된 PATH라는 환경변수의 경로에 존재하는 실행 파일일 때만 실행한다.

 

"ls" 커맨드의 예시를 보면 쉽다. PATH라는 환경변수의 경로에 실제로 그 이름의 실행 파일이 존재한다.

환경변수 PATH에는 어떤 값이 들어있을까?

이렇게 콜론(:)으로 여러 경로가 구분되어 있는데, 그 중에 /usr/bin에 ls라는 실행 파일이 위치한다. 이는 which라는 명령을 통해서도 확인 가능하고,

 

실제로 그 위치에서 디렉토리 리스팅을 해도 된다.

 

이렇게 /usr/bin 디렉토리에 'ls'라는 파일이 존재한다.

 

여기까지 내용을 정리하자면, 쉘은 입력된 명령을 파싱에서 ls라는 커맨드를 찾았고, /usr/bin/ls라는 실행 파일을 찾아내여 해당 파일을 실행하는 프로세스를 소환한다. 프로세스라는 용어가 헷갈릴 수도 있는데, 그냥 실행중인 프로그램을 의미한다고 보면 된다.

Q. 쉘도 프로세스일까?

 

정답은 '그렇다'이다. 쉘도 파일시스템에 존재하는 실행 파일로, /bin/sh (bash 쉘을 쓰는 예제의 경우 /bin/bash)와 같은 프로그램들이 커맨드 라인 명령 창에서 실행되고 있다.

 

하지만 쉘도 실행중인 프로세스인데, 이제 ls라는 프로세스가 실행되었다가, 다시 쉘이 실행되어야 하는 작업이 이루어져야 하는 상황이다. 이 과정은 어떻게 일어날까? 이 질문에 답하려면 운영체제시스템 콜에 대한 이해가 필요하다.

 

운영체제(Operating System)는, 여러 프로세스가 언제, 어떻게 실행될지를 관리하는 주체로 이해하면 된다. 우리가 컴퓨터를 활용할 때는 동시에 여러 개의 프로그램을 실행하고 있지만, 해당 컴퓨터가 활용하는 CPU나 메모리 등의 자원은 한정적이기 때문에 어떤 프로그램이 우선적으로 실행되고, 그 프로그램의 실행이 종료되면 어떤 프로그램을 실행하고 할 지를 관리해주는 주체가 필요한데, 그게 운영체제이다. 프로세스들을 관리하는 '중앙 관리자'나 연주자(프로세스)들을 조율하는 '지휘자'(운영체제)정도로 이해하면 된다.

 

프로세스 하나가 실행될 때 컴퓨터에 존재하는 자원에 접근해야 할 일이 발생할 수 있다. 특정 영역의 메모리를 사용하고 싶을 수도 있고, 디스크에 있는 어떤 파일을 읽고 싶을 수도 있다. 하지만, 이런 자원들의 관리 주체는 운영체제이기 때문에 , 운영체제에 의해 관리되는 프로세스들은 자원에 대한 접근을 운영체제에 요청하여 허락을 받는 과정이 필요하다. 이게 가능하도록 운영체제에서는 프로세스가 운영체제에 할 수 있는 요청들의 목록을 만들어놨는데, 그게 바로 시스템 콜(System Call)이다. 다시 운영체제를 지휘자에 비유한다면, 연주자(프로세스)가 악기 연주 도중 화장실에 가고 싶다면, 연주자가 지휘자(운영체제)에게 요청(시스템 콜)을 할 것이다.

 

이 두 개념이 필요한 이유는, 쉘 프로세스에서 "ls"를 실행하는 프로세스를 만들고, 실행했다가 다시 쉘 프로세스로 돌아가는 데에 시스템 콜이 필요하기 때문이다. 다음은 활용되는 시스템 콜의 순서이다. 이 부분이 핵심이다.

  1. fork: 새로운 프로세스를 만든다.
  2. execve: 프로그램을 실행한다.
  3. wait: 자식 프로세스가 종료될 때까지 기다린다.

구체적으로 다음과 같은 코드로 설명할 수 있다:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();  // Fork a new process

    if (pid == 0) {  // Child process
        execvp("/usr/bin/ls", {"ls", "-l", "*.so", NULL});  // Replace child process with ls -l *.so

        // If execvp fails
        perror("execvp");
        exit(EXIT_FAILURE);
    } else {  // Parent process
        wait(NULL);  // Wait for child process to finish
        printf("Child process completed.\n");
    }

    return 0;
}

 

이걸 더 이해하기 위해서는 fork 명령에 대해서 자세히 알아야 한다. fork는 현재 프로세스(부모 프로세스라고 부른다)에서 자식 프로세스를 생성하는 명령으로, pid라는 값을 반환하는데, 그 값이

  • 부모 프로세스에서는 자식 프로세스의 프로세스 확인값(Process Identifier, PID)
  • 자식 프로세스에서는 0

으로 설정된다.

 

따라서, if 블럭에서 pid가 0이면 자식 프로세스이고, 아니면 부모 프로세스로 결정된다. fork 콜이 일어난 순간 동일한 프로세스가 두 개가 되는데, 어떤 쪽이 부모이고 어떤 쪽이 자식인지를 구분하기 위해 이렇게 fork 시스템 콜은 설계되었다.

 

우선, 부모 프로세스에서는 자식 프로세스가 종료될 때까지 기다린다. 그게 wait(NULL) 부분이다.

 

자식 프로세스에서는 execvp 시스템 콜로 ls 프로그램을 실행하는데, 위 코드에서는 execvp("/usr/bin/ls", {"ls", "-l", "*.so", NULL})이 실행된 후, 종료된다. "/usr/bin/ls"는 실행 파일의 경로, {"ls", "-l", "*.so", NULL}는 해당 프로그램을 실행할 때 어떤 입력을 전달할 지를 의미한다.

 

정리하자면, 쉘(부모 프로세스)에서 fork를 통해 자식 프로세스를 만든 후, 부모 프로세스는 wait 시스템 콜로 자식 프로세스가 종료될 때까지 기다리고, 자식 프로세스에서는 execvp 시스템 콜로 /usr/bin/ls 파일을 "ls", "-l", "*.so" 명령 인자를 통해 실행하고, 종료한다.

 

이 과정을 종료하면 부모 프로세스인 쉘 프로세스만 살아남게 되고, 실제 쉘에서는 다음 쉘 명령어를 기다리는 창이 출력될 것이다. 이렇게 말이다!