CHAPTER 1. 운영체제 인터페이스


Chapter 1. 운영체제 인터페이스

운영체제의 역할은 하나의 컴퓨터를 여러 프로그램이 함께 쓰게 하고, 하드웨어만으로 제공되는 것보다 더 유용한 서비스 집합을 제공하는 것이다. 운영체제는 저수준 하드웨어를 관리하고 추상화한다. 예를 들어 워드 프로세서는 어떤 종류의 디스크 하드웨어가 사용되는지 신경 쓸 필요가 없다. 운영체제는 여러 프로그램이 동시에 실행되거나 동시에 실행되는 것처럼 보이도록 하드웨어를 공유한다. 마지막으로, 운영체제는 프로그램들이 데이터를 공유하거나 함께 작업할 수 있도록 통제된 상호작용 방법을 제공한다.

운영체제는 인터페이스를 통해 사용자 프로그램에 서비스를 제공한다. 좋은 인터페이스를 설계하는 일은 생각보다 어렵다. 한편으로는 구현을 올바르게 만들기 쉽도록 인터페이스가 단순하고 좁기를 바란다. 다른 한편으로는 응용 프로그램에 정교한 기능을 많이 제공하고 싶은 유혹도 있다. 이 긴장을 해결하는 핵심은, 서로 조합될 수 있는 몇 가지 메커니즘에 의존해서 높은 범용성을 제공하는 인터페이스를 설계하는 것이다.

이 책은 운영체제 개념을 설명하기 위한 구체적인 예로 하나의 운영체제를 사용한다. 그 운영체제인 xv6는 Ken Thompson과 Dennis Ritchie의 Unix 운영체제 [17]가 도입한 기본 인터페이스를 제공하며, Unix의 내부 설계도 모방한다. Unix는 잘 조합되는 메커니즘들로 이루어진 좁은 인터페이스를 제공하며, 놀랄 만큼 높은 범용성을 제공한다. 이 인터페이스는 매우 성공적이어서 BSD, Linux, macOS, Solaris, 그리고 더 제한적이지만 Microsoft Windows 같은 현대 운영체제들도 Unix와 비슷한 인터페이스를 갖게 되었다. xv6를 이해하는 것은 이러한 시스템과 그 밖의 많은 시스템을 이해하기 위한 좋은 출발점이다.

그림 1.1이 보여주듯이 xv6는 전통적인 형태의 커널을 가진다. 커널은 실행 중인 프로그램에 서비스를 제공하는 특별한 프로그램이다. 실행 중인 각 프로그램을 프로세스라고 부르며, 프로세스는 명령어, 데이터, 스택을 담은 메모리를 가진다. 명령어는 프로그램의 계산을 구현한다. 데이터는 계산이 작동하는 변수들이다. 스택은 프로그램의 프로시저 호출을 조직한다. 하나의 컴퓨터에는 보통 많은 프로세스가 있지만 커널은 하나뿐이다.

프로세스가 커널 서비스를 호출해야 할 때는 운영체제 인터페이스에 포함된 호출 중 하나인 시스템 콜을 호출한다. 시스템 콜은 커널로 진입한다. 커널은 서비스를 수행하고 되돌아온다. 따라서 프로세스는 사용자 공간에서 실행되는 상태와 커널 공간에서 실행되는 상태를 번갈아 오간다.

뒤의 장들에서 자세히 설명하겠지만, 커널은 CPU가 제공하는 하드웨어 보호 메커니즘을 사용하여 사용자 공간에서 실행되는 각 프로세스가 자기 자신의 메모리에만 접근할 수 있도록 보장한다. 이 책은 일반적으로 계산을 수행하는 하드웨어 요소를 CPU, 즉 central processing unit의 약어로 부른다. 다른 문서들, 예를 들어 RISC-V 명세는 CPU 대신 processor, core, hart라는 용어를 쓰기도 한다. 커널은 이러한 보호를 구현하는 데 필요한 하드웨어 권한을 가지고 실행되며, 사용자 프로그램은 그 권한 없이 실행된다. 사용자 프로그램이 시스템 콜을 호출하면 하드웨어가 권한 수준을 높이고 커널 안에 미리 정해 둔 함수를 실행하기 시작한다.

커널이 제공하는 시스템 콜들의 모음이 사용자 프로그램이 보게 되는 인터페이스다. xv6 커널은 Unix 커널들이 전통적으로 제공하던 서비스와 시스템 콜의 부분집합을 제공한다. 그림 1.2는 xv6의 모든 시스템 콜을 나열한다.

커널과 두 사용자 프로세스

그림 1.1: 커널과 두 사용자 프로세스

사용자 공간에는 shellcat 같은 사용자 프로세스가 있고, 이들은 시스템 콜을 통해 커널 공간의 Kernel로 진입한다.

그림 1.2: xv6 시스템 콜

별도로 명시하지 않으면, 이 호출들은 오류가 없을 때 0을 반환하고 오류가 있으면 -1을 반환한다.

시스템 콜설명
int fork()프로세스를 생성하고 자식의 PID를 반환한다.
int exit(int status)현재 프로세스를 종료한다. statuswait()에 보고된다. 반환하지 않는다.
int wait(int *status)자식이 종료될 때까지 기다린다. 종료 상태를 *status에 저장하고 자식 PID를 반환한다.
int kill(int pid)PID에 해당하는 프로세스를 종료한다. 성공하면 0, 오류면 -1을 반환한다.
int getpid()현재 프로세스의 PID를 반환한다.
int pause(int n)n 클록 틱 동안 멈춘다.
int exec(char *file, char *argv[])파일을 적재하고 인자와 함께 실행한다. 오류일 때만 반환한다.
char *sbrk(int n)프로세스의 메모리를 0으로 채운 n바이트만큼 늘린다. 새 메모리의 시작 주소를 반환한다.
int open(char *file, int flags)파일을 연다. flags는 읽기/쓰기 방식을 나타내며 파일 디스크립터를 반환한다.
int write(int fd, char *buf, int n)buf에서 n바이트를 파일 디스크립터 fd에 쓴다. n을 반환한다.
int read(int fd, char *buf, int n)n바이트를 buf로 읽는다. 읽은 바이트 수를 반환하거나 파일 끝이면 0을 반환한다.
int close(int fd)열린 파일 디스크립터 fd를 해제한다.
int dup(int fd)같은 파일을 가리키는 새 파일 디스크립터를 반환한다.
int pipe(int p[])파이프를 만들고 읽기/쓰기 파일 디스크립터를 p[0]p[1]에 넣는다.
int chdir(char *dir)현재 디렉터리를 바꾼다.
int mkdir(char *dir)새 디렉터리를 만든다.
int mknod(char *file, int, int)장치 파일을 만든다.
int fstat(int fd, struct stat *st)열린 파일의 정보를 *st에 저장한다.
int link(char *file1, char *file2)file1 파일의 다른 이름인 file2를 만든다.
int unlink(char *file)파일을 제거한다.

이 장의 나머지는 xv6의 서비스, 즉 프로세스, 메모리, 파일 디스크립터, 파이프, 파일 시스템을 개괄한다. 또한 Unix의 명령줄 사용자 인터페이스인 셸이 이 서비스들을 어떻게 사용하는지 코드 조각과 논의로 설명한다. 셸의 시스템 콜 사용 방식은 이 시스템 콜들이 얼마나 신중하게 설계되었는지를 보여준다.

셸은 사용자에게서 명령을 읽고 실행하는 평범한 프로그램이다. 셸이 커널의 일부가 아니라 사용자 프로그램이라는 사실은 시스템 콜 인터페이스의 힘을 보여준다. 셸에는 특별한 것이 없다. 이는 셸을 쉽게 교체할 수 있다는 뜻이기도 하다. 그 결과 현대 Unix 시스템에는 각자 고유한 사용자 인터페이스와 스크립팅 기능을 가진 다양한 셸이 있다. xv6 셸은 Unix Bourne shell의 핵심을 단순하게 구현한 것이다.

xv6 셸의 구현은 (7850)에서 찾을 수 있다. 이 링크는 https://github.com/mit-pdos/xv6-riscv/의 관련 xv6 소스 코드로 연결되는 하이퍼링크이며, 특정 숫자는 xv6-src-booklet.pdf에서의 시트와 줄 번호를 가리킨다. 이는 Lions의 UNIX 6th Edition 주석서 [11]와 같은 방식이다. 좋은 습관은 먼저 자신이 선호하는 개발 환경, GitHub, 또는 PDF 뷰어에서 소스 코드를 직접 읽어 보고, 그다음 이 책으로 돌아오는 것이다. 이 책을 끝낼 때쯤에는 xv6 소스 코드의 모든 줄을 이 책 없이도 이해할 수 있어야 한다.

1.1 프로세스와 메모리

xv6 프로세스는 사용자 공간 메모리, 즉 명령어, 데이터, 스택과 커널 내부의 프로세스별 상태로 구성된다. xv6는 프로세스를 시분할한다. 실행을 기다리는 프로세스들의 집합 사이에서 사용 가능한 CPU를 투명하게 전환한다. 어떤 프로세스가 실행 중이 아닐 때 xv6는 그 프로세스의 CPU 레지스터를 저장하고, 다음에 그 프로세스를 실행할 때 복원한다. 커널은 각 프로세스에 프로세스 식별자, 즉 PID를 연결한다.

프로세스는 fork 시스템 콜을 사용하여 새 프로세스를 만들 수 있다. fork는 호출한 프로세스의 메모리를 정확히 복사해서 새 프로세스에 준다. 즉 호출 프로세스의 명령어, 데이터, 스택을 새 프로세스의 메모리로 복사한다. fork는 원래 프로세스와 새 프로세스 양쪽에서 반환한다. 원래 프로세스에서는 새 프로세스의 PID를 반환한다. 새 프로세스에서는 0을 반환한다. 원래 프로세스와 새 프로세스는 보통 부모와 자식이라고 부른다.

예를 들어 C 프로그래밍 언어 [7]로 작성한 다음 프로그램 조각을 보자.

int pid = fork();
if(pid > 0){
  printf("parent: child=%d\n", pid);
  pid = wait((int *) 0);
  printf("child %d is done\n", pid);
} else if(pid == 0){
  printf("child: exiting\n");
  exit(0);
} else {
  printf("fork error\n");
}

exit 시스템 콜은 호출한 프로세스가 실행을 멈추고 메모리와 열린 파일 같은 자원을 해제하게 한다. exit는 정수 상태 인자를 받는데, 관례적으로 0은 성공을, 1은 실패를 나타낸다. wait 시스템 콜은 현재 프로세스의 자식 중 종료되었거나 kill된 자식의 PID를 반환하고, 그 자식의 종료 상태를 wait에 전달된 주소로 복사한다. 호출자의 자식 중 아직 종료된 것이 없다면 wait는 어떤 자식이 종료될 때까지 기다린다. 호출자에게 자식이 없다면 wait는 즉시 -1을 반환한다. 부모가 자식의 종료 상태에 관심이 없다면 wait에 주소 0을 전달할 수 있다.

위 예에서 다음 출력 줄들은

parent: child=1234
child: exiting

부모와 자식 중 어느 쪽이 먼저 printf에 도달하느냐에 따라 어느 순서로든, 심지어 서로 섞여서 나올 수도 있다. 자식이 종료된 뒤 부모의 wait가 반환되어 부모는 다음을 출력한다.

parent: child 1234 is done

자식은 부모 메모리의 복사본으로 시작하지만, 부모와 자식은 서로 분리된 메모리와 레지스터로 실행된다. 한쪽에서 변수를 바꿔도 다른 쪽에는 영향을 주지 않는다. 예를 들어 부모 프로세스에서 wait의 반환값을 pid에 저장해도 자식의 변수 pid는 바뀌지 않는다. 자식에서 pid 값은 여전히 0이다.

exec 시스템 콜은 호출한 프로세스의 메모리를 파일 시스템에 저장된 파일에서 읽어 온 새 메모리 이미지로 대체한다. 그 파일은 명령어가 어느 부분에 있는지, 데이터가 어느 부분에 있는지, 어느 명령어에서 시작할지 등을 지정하는 특정 형식을 가져야 한다. xv6는 ELF 형식을 사용하며, 3장에서 더 자세히 다룬다. 보통 이 파일은 프로그램 소스 코드를 컴파일한 결과다. exec가 성공하면 호출 프로그램으로 되돌아오지 않는다. 대신 파일에서 적재한 명령어들이 ELF 헤더에 선언된 진입점부터 실행되기 시작한다. exec는 두 인자를 받는다. 실행 파일의 이름과 문자열 인자 배열이다. 예를 들어 다음과 같다.

char *argv[3];

argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");

이 조각은 호출 프로그램을 echo hello라는 인자 목록으로 실행되는 /bin/echo 프로그램의 인스턴스로 대체한다. 대부분의 프로그램은 인자 배열의 첫 번째 원소를 무시하는데, 관례적으로 이 원소는 프로그램 이름이다.

xv6 셸은 사용자를 대신하여 프로그램을 실행하기 위해 위 호출들을 사용한다. 셸의 주요 구조는 단순하다. main (8001)을 보라. 메인 루프는 getcmd로 사용자에게서 입력 한 줄을 읽는다. 그런 다음 fork를 호출하여 셸 프로세스의 복사본을 만든다. 부모는 wait를 호출하고, 자식은 명령을 실행한다. 예를 들어 사용자가 셸에 echo hello를 입력했다면 runcmdecho hello를 인자로 호출되었을 것이다. runcmd (7903)는 실제 명령을 실행한다. echo hello의 경우 exec (7927)를 호출한다. exec가 성공하면 자식은 runcmd 대신 echo의 명령어를 실행한다. 어느 시점에 echoexit를 호출하고, 그러면 부모는 main (8001)의 wait에서 반환된다.

forkexec가 하나의 호출로 합쳐져 있지 않은지 궁금할 수 있다. 뒤에서 보겠지만 셸은 I/O 리다이렉션을 구현할 때 이 분리를 활용한다. 프로세스를 복제한 뒤 곧바로 exec로 대체하는 낭비를 피하기 위해 운영체제 커널은 copy-on-write 같은 가상 메모리 기법을 사용해 이 사용 사례에 맞게 fork 구현을 최적화한다. 이는 5장에서 다룬다.

xv6는 대부분의 사용자 공간 메모리를 암묵적으로 할당한다. fork는 부모 메모리를 복사해 자식에게 필요한 메모리를 할당하고, exec는 실행 파일을 담기에 충분한 메모리를 할당한다. 실행 시간에 더 많은 메모리가 필요한 프로세스, 예를 들어 malloc을 위해 메모리가 필요한 프로세스는 sbrk(n)을 호출해 데이터 메모리를 0으로 채운 n바이트만큼 늘릴 수 있다. sbrk는 새 메모리의 위치를 반환한다.

1.2 I/O와 파일 디스크립터

파일 디스크립터는 프로세스가 읽거나 쓸 수 있는 커널 관리 객체를 나타내는 작은 정수다. 프로세스는 파일, 디렉터리, 장치를 열거나, 파이프를 만들거나, 기존 디스크립터를 복제해서 파일 디스크립터를 얻을 수 있다. 단순화를 위해 파일 디스크립터가 가리키는 객체를 종종 "파일"이라고 부르겠다. 파일 디스크립터 인터페이스는 파일, 파이프, 장치 사이의 차이를 추상화해서 모두 바이트 스트림처럼 보이게 한다. 입력과 출력을 I/O라고 부르겠다.

내부적으로 xv6 커널은 파일 디스크립터를 프로세스별 테이블의 인덱스로 사용한다. 따라서 모든 프로세스는 0부터 시작하는 자신만의 파일 디스크립터 공간을 가진다. 관례적으로 프로세스는 파일 디스크립터 0에서 읽고, 파일 디스크립터 1에 출력을 쓰며, 파일 디스크립터 2에 오류 메시지를 쓴다. 이들은 각각 표준 입력, 표준 출력, 표준 오류다. 뒤에서 보겠지만 셸은 I/O 리다이렉션과 파이프라인을 구현하기 위해 이 관례를 활용한다. 셸은 항상 세 개의 파일 디스크립터가 열려 있도록 보장하며 (8007), 기본적으로 이 디스크립터들은 콘솔을 가리킨다.

readwrite 시스템 콜은 파일 디스크립터로 명명된 열린 파일에서 바이트를 읽거나 쓴다. read(fd, buf, n) 호출은 파일 디스크립터 fd에서 최대 n바이트를 읽어 buf로 복사하고, 읽은 바이트 수를 반환한다. 파일을 가리키는 각 파일 디스크립터에는 연관된 오프셋이 있다. read는 현재 파일 오프셋에서 데이터를 읽은 뒤, 읽은 바이트 수만큼 그 오프셋을 앞으로 이동시킨다. 따라서 뒤따르는 read는 첫 번째 read가 반환한 바이트 다음의 바이트를 반환한다. 더 읽을 바이트가 없으면 read는 파일의 끝을 나타내기 위해 0을 반환한다.

write(fd, buf, n) 호출은 buf에서 n바이트를 파일 디스크립터 fd에 쓰고, 쓴 바이트 수를 반환한다. 오류가 발생했을 때만 n보다 적은 바이트가 쓰인다. read와 마찬가지로 write도 현재 파일 오프셋에 데이터를 쓰고, 쓴 바이트 수만큼 그 오프셋을 앞으로 이동시킨다. 각 write는 이전 write가 끝난 지점에서 이어 쓴다.

다음 프로그램 조각은 cat 프로그램의 핵심을 이룬다. 표준 입력에서 표준 출력으로 데이터를 복사하고, 오류가 발생하면 표준 오류에 메시지를 쓴다.

char buf[512];
int n;

for(;;){
  n = read(0, buf, sizeof buf);
  if(n == 0)
    break;
  if(n < 0){
    fprintf(2, "read error\n");
    exit(1);
  }
  if(write(1, buf, n) != n){
    fprintf(2, "write error\n");
    exit(1);
  }
}

이 코드 조각에서 중요한 점은 cat이 자신이 파일에서 읽는지, 콘솔에서 읽는지, 파이프에서 읽는지 모른다는 것이다. 마찬가지로 cat은 자신이 콘솔에 출력하는지, 파일에 출력하는지, 아니면 다른 무엇에 출력하는지도 모른다. 파일 디스크립터를 사용하고 파일 디스크립터 0은 입력, 1은 출력이라는 관례를 따르기 때문에 cat을 단순하게 구현할 수 있다.

close 시스템 콜은 파일 디스크립터를 해제하여 앞으로의 open, pipe, dup 시스템 콜이 재사용할 수 있게 한다. 새로 할당되는 파일 디스크립터는 항상 현재 프로세스에서 사용되지 않는 가장 작은 번호의 디스크립터다.

파일 디스크립터와 fork는 함께 작동하여 I/O 리다이렉션을 쉽게 구현하게 한다. fork는 메모리와 함께 부모의 파일 디스크립터 테이블도 복사하므로, 자식은 부모와 정확히 같은 열린 파일들로 시작한다. exec 시스템 콜은 호출 프로세스의 메모리는 대체하지만 파일 테이블은 보존한다. 이 동작 덕분에 셸은 자식을 fork한 뒤, 자식에서 선택한 파일 디스크립터를 닫고 다시 열고, 그다음 exec를 호출해 새 프로그램을 실행하는 방식으로 I/O 리다이렉션을 구현할 수 있다. 다음은 cat < input.txt 명령을 위해 셸이 실행하는 코드의 단순화된 버전이다.

char *argv[2];

argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
  close(0);
  open("input.txt", O_RDONLY);
  exec("cat", argv);
}

자식이 파일 디스크립터 0을 닫은 뒤에는 open이 새로 연 input.txt에 그 파일 디스크립터를 사용한다는 것이 보장된다. 0이 가장 작은 사용 가능한 파일 디스크립터이기 때문이다. 그러면 cat은 파일 디스크립터 0, 즉 표준 입력이 input.txt를 가리키는 상태로 실행된다. 이 과정은 자식의 디스크립터만 수정하므로 부모 프로세스의 파일 디스크립터는 바뀌지 않는다.

xv6 셸의 I/O 리다이렉션 코드도 정확히 이런 방식으로 동작한다 (7931). 이 시점의 코드에서는 셸이 이미 자식 셸을 fork했고, runcmd가 새 프로그램을 적재하기 위해 exec를 호출할 것임을 떠올리자.

open의 두 번째 인자는 open의 동작을 제어하는 비트 플래그들의 집합이다. 가능한 값은 file control, 즉 fcntl 헤더 (4000-4004)에 정의되어 있다. O_RDONLY, O_WRONLY, O_RDWR, O_CREATE, O_TRUNC가 있으며, 각각 읽기용으로 열기, 쓰기용으로 열기, 읽기와 쓰기 모두 가능하게 열기, 파일이 없으면 만들기, 파일 길이를 0으로 자르기를 지시한다.

이제 forkexec가 분리되어 있는 것이 왜 유용한지 분명해진다. 두 호출 사이에서 셸은 주 셸의 I/O 설정을 건드리지 않고 자식의 I/O를 리다이렉션할 기회를 얻는다. 가상의 결합 호출인 forkexec 시스템 콜을 상상할 수도 있지만, 그런 호출로 I/O 리다이렉션을 처리하는 선택지는 어색해 보인다. 셸이 forkexec를 호출하기 전에 자기 자신의 I/O 설정을 바꾸고 나중에 되돌릴 수도 있다. 또는 forkexec가 I/O 리다이렉션 지시를 인자로 받을 수도 있다. 또는 가장 매력적이지 않은 방식으로, cat 같은 모든 프로그램에 자기 자신의 I/O 리다이렉션을 수행하도록 가르칠 수도 있다.

fork가 파일 디스크립터 테이블을 복사하기는 하지만, 각각의 기반 파일 오프셋은 부모와 자식 사이에서 공유된다. 다음 예를 보자.

if(fork() == 0) {
  write(1, "hello ", 6);
  exit(0);
} else {
  wait(0);
  write(1, "world\n", 6);
}

이 조각이 끝나면 파일 디스크립터 1에 연결된 파일은 hello world라는 데이터를 담게 된다. 부모의 writewait 덕분에 자식이 끝난 뒤에만 실행되고, 자식의 write가 끝난 지점에서 이어 쓴다. 이 동작은 (echo hello; echo world) >output.txt 같은 셸 명령 시퀀스가 순차적인 출력을 만들도록 돕는다.

dup 시스템 콜은 기존 파일 디스크립터를 복제하여 같은 기반 I/O 객체를 가리키는 새 디스크립터를 반환한다. 두 파일 디스크립터는 fork로 복제된 파일 디스크립터와 마찬가지로 오프셋을 공유한다. 다음은 파일에 hello world를 쓰는 또 다른 방법이다.

fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);

두 파일 디스크립터는 같은 원래 파일 디스크립터에서 forkdup 호출들의 연쇄를 통해 파생되었을 때 오프셋을 공유한다. 그렇지 않으면 같은 파일에 대해 open을 호출해 얻은 디스크립터들이더라도 오프셋을 공유하지 않는다. dup은 셸이 ls existing-file non-existing-file > tmp1 2>&1 같은 명령을 구현하게 해 준다. 2>&1은 셸에게 해당 명령의 파일 디스크립터 2를 디스크립터 1의 복제본으로 만들라고 지시한다. 존재하는 파일의 이름과 존재하지 않는 파일에 대한 오류 메시지가 모두 tmp1 파일에 나타난다. xv6 셸은 오류 파일 디스크립터에 대한 I/O 리다이렉션을 지원하지 않지만, 이제 어떻게 구현할 수 있는지는 알게 되었다.

파일 디스크립터는 강력한 추상화다. 무엇에 연결되어 있는지의 세부 사항을 숨기기 때문이다. 파일 디스크립터 1에 쓰는 프로세스는 파일에 쓰고 있을 수도 있고, 콘솔 같은 장치에 쓰고 있을 수도 있고, 파이프에 쓰고 있을 수도 있다.

1.3 파이프

파이프는 읽기용 하나와 쓰기용 하나, 한 쌍의 파일 디스크립터로 프로세스에 노출되는 작은 커널 버퍼다. 파이프의 한쪽 끝에 데이터를 쓰면 그 데이터는 파이프의 다른 끝에서 읽을 수 있게 된다. 파이프는 프로세스들이 통신하는 방법을 제공한다.

다음 예제 코드는 표준 입력이 파이프의 읽기 끝에 연결된 상태로 wc 프로그램을 실행한다.

int p[2];
char *argv[2];

argv[0] = "wc";
argv[1] = 0;

pipe(p);
if(fork() == 0) {
  close(0);
  dup(p[0]);
  close(p[0]);
  close(p[1]);
  exec("/bin/wc", argv);
} else {
  close(p[0]);
  write(p[1], "hello world\n", 12);
  close(p[1]);
}

프로그램은 pipe를 호출한다. pipe는 새 파이프를 만들고 읽기와 쓰기 파일 디스크립터를 배열 p에 기록한다. fork 뒤에는 부모와 자식 모두 파이프를 가리키는 파일 디스크립터를 가진다. 자식은 closedup을 호출하여 파일 디스크립터 0이 파이프의 읽기 끝을 가리키도록 만들고, p에 들어 있는 파일 디스크립터들을 닫은 뒤, exec를 호출해 wc를 실행한다. wc가 표준 입력에서 읽으면 파이프에서 읽게 된다. 부모는 파이프의 읽기 쪽을 닫고, 파이프에 쓰고, 그다음 쓰기 쪽을 닫는다.

사용 가능한 데이터가 없으면 파이프에 대한 read는 데이터가 쓰이거나, 쓰기 끝을 가리키는 모든 파일 디스크립터가 닫힐 때까지 기다린다. 후자의 경우 read는 데이터 파일의 끝에 도달했을 때처럼 0을 반환한다. 새 데이터가 도착할 가능성이 없어질 때까지 read가 블록된다는 사실은, 위 예에서 자식이 wc를 실행하기 전에 파이프의 쓰기 끝을 닫는 것이 왜 중요한지 설명한다. 만약 wc의 파일 디스크립터 중 하나가 파이프의 쓰기 끝을 가리킨다면 wc는 결코 파일 끝을 보지 못할 것이다.

xv6 셸은 grep fork sh.c | wc -l 같은 파이프라인을 위 코드와 비슷한 방식으로 구현한다 (7950). 자식 프로세스는 파이프라인의 왼쪽 끝과 오른쪽 끝을 연결할 파이프를 만든다. 그런 다음 파이프라인의 왼쪽 끝에 대해 forkruncmd를 호출하고, 오른쪽 끝에 대해서도 forkruncmd를 호출한 뒤, 둘이 끝날 때까지 기다린다. 파이프라인의 오른쪽 끝은 그 자체로 파이프를 포함하는 명령일 수도 있다. 예를 들어 a | b | c에서 오른쪽 끝은 다시 두 자식 프로세스, 즉 bc를 위한 프로세스를 fork한다. 따라서 셸은 프로세스 트리를 만들 수 있다. 이 트리의 잎은 명령이고, 내부 노드는 왼쪽과 오른쪽 자식이 완료될 때까지 기다리는 프로세스다.

파이프는 임시 파일보다 강력하지 않아 보일 수도 있다. 파이프라인

echo hello world | wc

은 파이프 없이도 다음처럼 구현할 수 있기 때문이다.

echo hello world >/tmp/xyz; wc </tmp/xyz

하지만 이 상황에서 파이프는 임시 파일보다 적어도 세 가지 장점이 있다. 첫째, 파이프는 자동으로 자신을 정리한다. 파일 리다이렉션을 사용하면 셸은 작업이 끝났을 때 /tmp/xyz를 조심해서 제거해야 한다. 둘째, 파이프는 임의로 긴 데이터 스트림을 전달할 수 있지만, 파일 리다이렉션은 모든 데이터를 저장할 만큼의 빈 디스크 공간을 필요로 한다. 셋째, 파이프는 파이프라인 단계들의 병렬 실행을 허용하지만, 파일 방식은 두 번째 프로그램이 시작되기 전에 첫 번째 프로그램이 끝나야 한다.

1.4 파일 시스템

xv6 파일 시스템은 해석되지 않은 바이트 배열을 담는 데이터 파일과, 데이터 파일 및 다른 디렉터리에 대한 이름 붙은 참조를 담는 디렉터리를 제공한다. 디렉터리들은 루트라고 불리는 특별한 디렉터리에서 시작하는 트리를 이룬다. /a/b/c 같은 경로는 루트 디렉터리 / 안의 a라는 디렉터리 안의 b라는 디렉터리 안에 있는 c라는 파일 또는 디렉터리를 가리킨다. /로 시작하지 않는 경로는 호출 프로세스의 현재 디렉터리를 기준으로 평가되며, 현재 디렉터리는 chdir 시스템 콜로 바꿀 수 있다. 다음 두 코드 조각은 관련 디렉터리들이 모두 존재한다고 가정하면 같은 파일을 연다.

chdir("/a");
chdir("b");
open("c", O_RDONLY);

open("/a/b/c", O_RDONLY);

첫 번째 조각은 프로세스의 현재 디렉터리를 /a/b로 바꾼다. 두 번째 조각은 프로세스의 현재 디렉터리를 참조하지도 바꾸지도 않는다.

새 파일과 디렉터리를 만드는 시스템 콜들이 있다. mkdir는 새 디렉터리를 만들고, O_CREATE 플래그를 사용한 open은 새 데이터 파일을 만들며, mknod는 새 장치 파일을 만든다. 다음 예는 세 가지를 모두 보여준다.

mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);

mknod는 장치를 가리키는 특수 파일을 만든다. 장치 파일에는 major와 minor 장치 번호, 즉 mknod의 두 인자가 연결된다. 이 번호들은 커널 장치를 고유하게 식별한다. 나중에 어떤 프로세스가 장치 파일을 열면, 커널은 readwrite 시스템 콜을 파일 시스템으로 넘기는 대신 커널 장치 구현으로 우회시킨다.

파일의 이름은 파일 자체와 다르다. inode라고 불리는 같은 기반 파일은 link라고 불리는 여러 이름을 가질 수 있다. 각 link는 디렉터리 안의 항목으로 구성된다. 이 항목은 파일 이름과 inode에 대한 참조를 담는다. inode는 파일에 대한 메타데이터를 담는다. 여기에는 파일의 타입, 즉 파일/디렉터리/장치 여부, 길이, 디스크에서 파일 내용의 위치, 파일에 대한 link 수가 포함된다.

fstat 시스템 콜은 파일 디스크립터가 가리키는 inode에서 정보를 가져온다. fstatstat.h (4050)에 정의된 struct stat을 채운다.

#define T_DIR     1   // Directory
#define T_FILE    2   // File
#define T_DEVICE  3   // Device

struct stat {
  int dev;      // File system's disk device
  uint ino;     // Inode number
  short type;   // Type of file
  short nlink;  // Number of links to file
  uint64 size;  // Size of file in bytes
};

link 시스템 콜은 기존 파일과 같은 inode를 가리키는 또 다른 파일 시스템 이름을 만든다. 다음 조각은 ab라는 두 이름을 가진 새 파일을 만든다.

open("a", O_CREATE|O_WRONLY);
link("a", "b");

a에서 읽거나 a에 쓰는 것은 b에서 읽거나 b에 쓰는 것과 같다. 각 inode는 고유한 inode 번호로 식별된다. 위 코드 시퀀스 이후 fstat 결과를 조사하면 ab가 같은 기반 내용을 가리키는지 확인할 수 있다. 둘은 같은 inode 번호, 즉 ino를 반환하고, nlink 카운트는 2로 설정된다.

unlink 시스템 콜은 파일 시스템에서 이름을 제거한다. 파일의 inode와 그 내용을 담은 디스크 공간은 파일의 link 수가 0이고 그 파일을 가리키는 파일 디스크립터가 하나도 없을 때만 해제된다. 따라서 앞의 코드 시퀀스에 다음을 추가해도

unlink("a");

inode와 파일 내용은 b라는 이름으로 계속 접근할 수 있다. 더 나아가 다음은

fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");

이름이 없는 임시 inode를 만드는 관용적 방법이다. 이 inode는 프로세스가 fd를 닫거나 종료될 때 정리된다.

Unix는 셸에서 호출할 수 있는 파일 유틸리티를 사용자 수준 프로그램으로 제공한다. 예를 들어 mkdir, ln, rm이 있다. 이 설계는 누구나 새 사용자 수준 프로그램을 추가해서 명령줄 인터페이스를 확장할 수 있게 한다. 뒤돌아보면 이 계획은 당연해 보이지만, Unix가 설계되던 시기의 다른 시스템들은 종종 이런 명령들을 셸에 내장했고, 셸을 커널에 내장하기도 했다.

한 가지 예외는 cd로, 이는 셸에 내장되어 있다 (8021). cd는 셸 자체의 현재 작업 디렉터리를 바꿔야 한다. 만약 cd가 일반 명령으로 실행된다면 셸은 자식 프로세스를 fork하고, 자식 프로세스가 cd를 실행하고, cd는 자식의 작업 디렉터리만 바꿀 것이다. 부모, 즉 셸의 작업 디렉터리는 바뀌지 않는다.

1.5 현실 세계

Unix가 "표준" 파일 디스크립터, 파이프, 그리고 이들에 대한 편리한 셸 문법을 결합한 것은 범용 재사용 프로그램을 작성하는 데 큰 진전이었다. 이 아이디어는 "소프트웨어 도구" 문화를 촉발했고, 이는 Unix의 힘과 인기의 상당 부분을 책임졌다. 셸은 이른바 최초의 "스크립팅 언어"였다. Unix 시스템 콜 인터페이스는 오늘날 BSD, Linux, macOS 같은 시스템에도 남아 있다.

Unix 시스템 콜 인터페이스는 POSIX, 즉 Portable Operating System Interface 표준을 통해 표준화되었다. xv6는 POSIX를 준수하지 않는다. xv6에는 lseek 같은 기본적인 호출을 포함해 많은 시스템 콜이 없고, xv6가 제공하는 많은 시스템 콜도 표준과 다르다. xv6의 주요 목표는 단순하고 명료하면서도 단순한 Unix 계열 시스템 콜 인터페이스를 제공하는 것이다. 몇몇 사람들은 기본 Unix 프로그램을 실행하기 위해 xv6에 시스템 콜 몇 개와 단순한 C 라이브러리를 추가해 확장했다. 그러나 현대 커널은 xv6보다 훨씬 더 많은 시스템 콜과 훨씬 더 많은 종류의 커널 서비스를 제공한다. 예를 들어 네트워킹, 윈도 시스템, 사용자 수준 스레드, 많은 장치의 드라이버 등을 지원한다. 현대 커널은 계속 빠르게 진화하며, POSIX를 넘어서는 많은 기능을 제공한다.

Unix는 여러 종류의 자원, 즉 파일, 디렉터리, 장치에 대한 접근을 단일한 파일 이름 및 파일 디스크립터 인터페이스로 통합했다. 이 아이디어는 더 많은 종류의 자원으로 확장될 수 있다. 좋은 예가 Plan 9 [16]로, "자원은 파일이다"라는 개념을 네트워크, 그래픽 등에도 적용했다. 그러나 대부분의 Unix 파생 운영체제는 이 길을 따르지 않았다.

파일 시스템과 파일 디스크립터는 강력한 추상화였다. 그렇지만 운영체제 인터페이스에는 다른 모델들도 있다. Unix의 전신인 Multics는 파일 저장소를 메모리처럼 보이게 추상화하여 매우 다른 느낌의 인터페이스를 만들었다. Multics 설계의 복잡성은 Unix 설계자들에게 직접적인 영향을 주었고, 그들은 더 단순한 것을 만드는 것을 목표로 삼았다.

xv6는 사용자라는 개념이나 한 사용자를 다른 사용자로부터 보호하는 기능을 제공하지 않는다. Unix 용어로 말하면 xv6의 모든 프로세스는 root로 실행된다.

이 책은 xv6가 Unix와 비슷한 인터페이스를 어떻게 구현하는지 살펴본다. 그러나 그 아이디어와 개념은 Unix에만 적용되는 것이 아니다. 어떤 운영체제든 기반 하드웨어 위에서 프로세스를 multiplex해야 하고, 프로세스를 서로 격리해야 하며, 통제된 프로세스 간 통신 메커니즘을 제공해야 한다. xv6를 공부한 뒤에는 더 복잡한 다른 운영체제를 보더라도 그 안에 깔린 xv6의 개념들을 알아볼 수 있어야 한다.

1.6 연습문제

  1. UNIX 시스템 콜을 사용해 두 프로세스 사이에서 한 바이트를 "ping-pong"하는 프로그램을 작성하라. 각 방향마다 하나씩, 두 개의 파이프를 사용하라. 프로그램의 성능을 초당 교환 횟수로 측정하라.