본문 바로가기

[CSAPP] 10장 시스템 레벨 입출력

출처 Randal E.Byrant, David R. O'Hallaron, 컴퓨터 시스템 Computer Systems A Programmer's Perspective, 김형신 옮김 (퍼스트북), Pearson

 

입출력은 메인 메모리와 외부 디바이스 (디스크 드라이브, 터미널, 네트워크 등)간 데이터를 복사하는 과정이다. 입력 작업은 입출력 장치에서 메인 메모리로 데이터를 복사하고, 출력 작업은 메모리에서 디바이스로 데이터를 복사한다.

모든 언어의 런타임 시스템은 입출력 수행을 위해 고수준의 함수들을 제공한다. ANSI C는 표준 입출력 라이브러리를 제공하는데 여기에는 버퍼 입출력을 수행하는 printf, scanf 함수가 있다. C++ 언어는 오버로딩된 <<("put to")와 >>("get from") 연산자로 비슷한 기능을 제공한다. 리눅스 시스템의 경우 고수준 입출력 함수들은 커널이 제공하는 시스템 레벨의 Unix 입출력 함수를 이용하여 구현되어 있다. 고수준 입출력 함수들은 거의 항상 잘 작동해서 Unix 입출력을 직접적으로 사용할 필요가 없다. 그러면 왜 Unix 입출력을 배워야 할까?

 

  • Unix 입출력을 이해하면 다른 시스템 개념들을 배우는 데 도움이 된다. 입출력은 시스템 운영에 필수적이다. 우리는 입출력과 다른 시스템 아이디어 사이의 순환 참조를 종종 만나곤 한다. 예를 들어, 프로세스 생성과 실행에서 입출력은 핵심 역할을 한다. 반대로 프로세스 생성은 서로 다른 프로세스가 파일을 공유하는 데 있어 핵심 역할을 한다. 따라서 입출력을 정말 이해하려면 프로세스를 이해해야 하고 프로세스를 정말 이해하려면 입출력을 이해해야 한다.
  • 때때로 Unix 입출력 함수를 이용해야만 하는 경우가 있다. 고수준 입출력 함수를 이용하는 것이 불가능하거나 부적절한 경우가 있다. 예를 들어, 표준 입출력 라이브러리는 파일 크기나 파일 생성 시점과 같인 파일 메타 데이터에 접근할 수 있는 방법을 제공하지 않는다. 게다가 표준 입출력 라이브러리는 네트워크 프로그래밍에 사용하기에는 위험 부담이 있다는 문제가 있다.

1. Unix I/O

리눅스 파일은 m개의 바이트의 시퀀스이다. 네트워크, 디스크, 터미널 등의 모든 입출력 장치들은 파일이라고 모델링된다. 모든 입력과 출력은 적절한 파일을 읽고 쓰는 것으로서 수행된다. 이런 식으로 입출력 장치를 파일에 매핑시킴에 따라 리눅스 커널은 단순한 저수준의 애플리케이션 인터페이스인 Unix I/O를 제공할 수 있게 된다. Unix I/O는 모든 입력과 출력이 하나의 통일된 방법으로 수행되도록 만든다.

 

파일 열기

애플리케이션은 커널에 해당 파일을 open해달라고 요청함으로써 입출력 장치에 접근하려는 의도를 선언한다. 그러면 커널은 음수가 아닌 작은 정수인 descriptor를 반환한다. descriptor는 해당 파일에 대한 모든 작업에 대해 해당 파일을 식별해주는 역할을 한다. 커널이 열려 있는 파일에 대한 모든 정보를 추적하고 있다. 애플리케이션은 오직 descriptor만 추적하고 있으면 된다.

Linux shell에 의해 생성된 모든 프로세스는 세 개의 열려 있는 파일과 함께 생성된다. 표준 입력 (descriptor 0), 표준 출력 (descriptor 1) 그리고 표준 에러 (descriptor 2)이다. 헤더 파일 <unistd.h>가 STDIN_FILENO, STDOUT_FILENO 그리고 STDEFF_FILENO 상수를 정의하고 있어서 이 상수를 descriptor 값 대신 사용할 수 있다.

 

현재 파일의 위치 변경하기

커널은 모든 파일에 대해 파일 위치 k를 유지하고 있다. 파일 위치의 초깃값은 0이다. 파일 위치는 파일의 시작점에서부터의 바이트 오프셋이다. 애플리케이션은 seek 명령을 통해 현재 파일의 위치 k를 변경할 수 있다.

 

파일 읽기와 쓰기

읽기 작업은 현재 파일 위치 k에서부터 n개의 바이트를 파일에서 메모리로 복사한다. m 바이트 크기의 파일이 있다고 했을 때 m 바이트보다 큰 k 바이트를 읽는 작업을 수행할 경우 이는 end-of-file (EOF)라는 상태를 유발한다. EOF는 애플리케이션이 감지할 수 있는 상태로 "EOF 문자"라는 게 실제로 파일의 끝에 있는 것은 아니다.

비슷하게, 쓰기 작업은 현재 파일 위치 k에 n개의 바이트를 메모리에서 파일로 복사한다. 

 

파일 닫기

애플리케이션이 파일 접근을 끝내면 애플리케이션은 커널에게 파일을 close해달라고 요청한다. 그러면 커널은 파일이 열렸을 때 생성된 자료 구조를 해제하고 descriptor를 사용 가능한 descriptor pool로 되돌린다. 프로세스가 어떤 이유에서든지 종료될 때 커널은 모든 열려 있는 파일을 닫고 그 파일의 메모리 리소스를 해제한다.

2. 파일

모든 리눅스 파일은 시스템에서의 역할을 나타내는 type을 가지고 있다.

 

regular file

regular file은 임의의 데이터를 가지고 있다. 애플리케이션 프로그램은 텍스트 파일과 바이너리 파일을 구분하곤 하지만 커널에게는 두 파일에 차이가 없다. 텍스트 파일은 ASCⅡ나 유니코드 문자만 가지고 있는 regular file이고, 바이너리 파일은 그 외 모든 파일이다. 

Linux 텍스트 파일은 텍스트 줄들의 연속이다. 모든 줄은 '\n'으로 끝나는 문자 시퀀스이다. 개행 문자는 ASCⅡ의 LF 문자와 같고 숫자 값으로는 0x0a 값을 가진다.

 

directory

directory는 link의 배열로 이루어진 파일이다. 링크는 파일 이름을 파일에 매핑한다. 이때 파일은 또다른 directory일 수도 있다. 모든 directory는 적어도 두 개의 항목을 가진다. 자기 자신 directory에 대한 link인 .과 디렉토리 계층에서 부모 디렉토리를 가리키는 link인 ..이다. mkdir 명령어로 directory를 생성할 수 있고 ls로 directory의 내부 콘텐츠를 볼 수 있으며, rmdir로 directory를 삭제할 수 있다.

 

socket

socket은 네트워크를 거쳐서 다른 프로세스와 통신하고 싶을 때 사용되는 파일이다.

 

다른 파일 type으로는 named pipes, symbolic links, character, block devices가 있는데 이는 우리의 범위를 넘어선다.

Linux 커널은 모든 파일들은 하나의 디렉토리 계층으로 조직한다. 디렉토리들은 / 라는 루트 디렉토리에서부터 뻗어나온다. 모든 프로세스는 디렉토리 계층에서 자신의 현재 위치를 나타내는 현재 작업 디렉토리를 가진다. shell의 현재 작업 디렉토리는 cd 명령어로 변경할 수 있다.

 

디렉토리 계층에서의 위치는 경로로 표시된다. 경로는 슬래시로 구분되는 파일이름의 연속으로 이루어진 문자열이다.

 

절대 경로

절대 경로는 루트 노드를 나타내는 슬래시로부터 시작한다. <예시> /home/droh/hello.c

 

상대 경로

상대 경로는 현재 작업 디렉토리를 나타내는 파일 이름부터 시작한다. /home/droh가 현재 작업 디렉토리라면 상대 경로는 ./hello.c이다. 

3. 파일 열고 닫기

프로세스는 open 함수를 호출하여 파일을 열거나 새 파일을 생성한다.

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

/* Returns: new file descriptor if OK, -1 on error */
int open (char *filename, int flags, mode_t mode);

 

open 함수는 파일 이름을 파일 디스크립터로 변환한 뒤 파일 디스크립터 넘버를 리턴한다. 디스크립터가 리턴될 때는 프로세스에서 열려 있지 않은 디스크립터 중 가장 작은 디스크립터가 리턴된다. flags 인자는 프로세스가 파일에 어떻게 접근하려고 하는지를 나타낸다.

 

O_RDONLY 읽기 전용

O_WRONLY 쓰기 전용

O_RDWR 읽기 쓰기 모두 가능

 

flags 인자는 하나 이상의 비트 마스크와 OR을 통해 쓰기 작업에 대한 추가 지시 사항을 전달할 수도 있다. 

 

O_CREAT 파일이 존재하지 않으면 truncated (empty) 파일을 생성

O_TRUNC 파일이 이미 있으면 truncate하기

O_APPEND 쓰기 작업을 하기 전에 파일 위치를 파일의 끝으로 설정하기

 

mode 인자는 새 파일에 대한 접근 권한 비트를 명시한다. 다음은 sys/stat.h에 정의되어 있는 접근 권한 비트들이다.

모든 프로세스는 umask 함수를 호출함으로써 설정된 umask를 가진다. 프로세스가 몇몇 mode 인자를 가지고 open 함수로 새 파일을 생성했을 때 파일의 접근 권한 비트는 mode & ~umask로 설정된다. mode는 기본 권한 설정이고 umask는 제거하고 싶은 권한 설정이다.

예를 들면, 다음과 같다.

#define DEF_MODE	S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH
#define DEF_UMASK	S_IWGRP|S_IWOTH

umask (DEF_UMASK);
fd = open ("foo.txt", O_CREAT|O_TRUNC|O_WRONLY, DEF_MODE);

 

close 함수는 다음과 같다.

#include <unistd.h>

/* Returns: 0 if OK, -1 on error */
int close (int fd);

 

이미 close된 파일 디스크립터를 닫으면 에러가 발생한다.

4. 파일 읽고 쓰기

애플리케이션은 read와 write 함수를 호출하여 입출력을 수행한다.

#include <unistd.h>

/* Returns: number of bytes read if OK, 0 on EOF, -1 on error */
ssize_t read (int fd, void *buf, size_t n);

/* Returns: number of bytes written if OK, -1 on error */
ssize_t write (int fd, const void *buf, size_t n);

*ssize_t는 signed size라는 의미로 long 타입이다. 반환값은 -1이 될 수 있으므로 부호형을 사용한다.

*size_t는 unsigned long이다.

 

read 함수는 최대 n 바이트를 디스크립터 fd의 현재 파일 위치에서 메모리 위치 buf로 복사한다.

write 함수는 최대 n 바이트를 메모리 위치 buf에서 파일 디스크립터 fd의 현재 파일 위치로 복수한다.

 

애플리케이션은 lseek 함수를 호출하여 현재 파일 위치를 변경할 수 있다.

어떤 경우에 read와 write는 애플리케이션이 요청한 것보다 적은 바이트를 복사할 수도 있다. 이는 에러가 아니다. 이러한 일은 다음과 같은 이유로 발생한다.

 

읽기를 할 때 EOF를 마주한 경우

현재 파일 위치에서 20 바이트가 있는데 50 바이트를 읽으려고 한다고 해보자. 그러면 read는 20 바이트만 리턴할 것이고 다음 read는 EOF의 신호를 받아 0을 리턴할 것이다.

 

터미널로부터 텍스트 라인을 읽는 경우

열려 있는 파일이 터미널 (키보드나 화면)에 관련되어 있다면, read 함수는 매번 하나의 텍스트 라인을 읽고 텍스트 라인 사이즈만큼의 바이트를 리턴할 것이다.

 

네트워크 소켓을 읽고 쓰는 경우

열려 있는 파일이 네트워크 소켓이라면, 내부 버퍼 제약과 긴 네트워크 지연은 read와 write가 적은 바이트 수를 리턴하게 할 수 있다. Linux pipe에서 read와 write를 호출할 때도 적은 바이트 수를 리턴할 수 있다.

 

실제에서는 EOF를 제외하고는 디스크로부터 읽기 작업을 할 때 short count를 마주할 일은 없을 것이고, 디스크 작업에 쓰기를 할 때도 그렇다. 하지만 웹 서버와 같은 robust (신뢰성 있는) 네트워크 애플리케이션을 만들고 싶다면 모든 요청 바이트가 전송되기까지 read와 write를 반복적으로 호출함으로써 short count 문제를 처리해야 할 것이다.

5. RIO 패키지를 이용한 Robust 읽기와 쓰기

short count를 자동으로 처리해주는 I/O 패키지인 RIO (Robust I/O)를 개발해보겠다. RIO 패키지는 네트워크 프로그램과 같이 short count가 발생할 수 있는 애플리케이션에서  편리하고, 튼튼하고, 효율적인 입출력을 제공한다.

 

RIO 패키지는 다음 두 가지 유형의 함수를 제공한다.

 

버퍼되지 않는 입출력 함수

이 함수들은 애플리케이션 레벨의 버퍼 없이 메모리와 파일 사이에 데이터를 바로 전송한다. 이 함수들은 네트워크로부터 바이너리 데이터를 읽고 쓸 때 특히 유용하다.

 

버퍼되는 입력 함수

이 함수들은 printf와 같은 표준 입출력 함수를 위해 제공되는 것과 비슷한 애플리케이션 레벨의 버퍼를 사용하여 파일로부터 텍스트 라인과 바이너리 데이터를 읽어들이는 작업을 효율적이게 만들어준다. 버퍼된 RIO 입력 함수는 thread-safe하고 같은 디스크립트에 대해 임의로 교차하여 사용될 수 있다. 예를 들어, 디스크립터로부터 텍스트 라인을 읽고 바이너리 데이터를 읽고 그다음 텍스트 라인을 읽을 수 있다.

 

(1) RIO 버퍼되지 않는 입출력 함수

rio_readn과 rio_writen 함수를 호출하면 애플리케이션은 메모리와 파일 사이에서 데이터를 직접 전송할 수 있다.

#include "csapp.h"

ssize_t rio_readn (int fd, void *usrbuf, size_t n) {
  size_t nleft = n;
  ssize_t nread;
  char *bufp = usrbuf;

  while (nleft > 0) {
    if ((nread = read (fd, bufp, nleft)) < 0) {
      if (errno == EINTR) /* Interrupted by sig handler return */
        nread = 0;        /* and call read () again */
      else
        return -1;        /* errno set by read () */
    }
    else if (nread == 0)
      break;              /* EOF */
    nleft -= nread;
    bufp += nread;
  }
  return (n - nleft);     /* Return >= 0 */
}

ssize_t rio_writen (int fd, void *usrbuf, size_t n) {
  size_t nleft = n;
  ssize_t nwritten;
  char *bufp = usrbuf;

  while (nleft > 0) {
    if ((nwritten = write (fd, bufp, nleft)) < 0) {
      if (errno == EINTR)   /* Interrupted by sig handler return */
        nwritten = 0;       /* and call write () again */
      else
        return -1;          /* errno set by write () */
    }
    nleft -= nwritten;
    bufp += nwritten;
  }
  return n;
}

 

rio_readn 함수는 파일 디스크립터 fd의 현재 파일 위치에서 메모리 위치 usrbuf로 최대 n 바이트를 전송한다. 비슷하게, rio_writen 함수는 메모리 위치 usrbuf에서 fd로 n 바이트를 전송한다.

rio_readn 함수는 EOF를 만났을 때만 short count를 리턴한다. rio_writen은 short count를 절대 리턴하지 않는다. rio_readn에 대한 호출과 rio_writen에 대한 호출은 같은 디스크립터에 대해 임의로 교차될 수 있다.

read와 write 함수가 애플리케이션 시그널 핸들러의 리턴에 의해 인터럽트되면 read와 write는 수동으로 재시작한다. 

 

(2) RIO 버퍼되는 입력 함수

텍스트 파일에 있는 라인의 수를 세는 프로그램을 작성하고 싶다고 해보자. 하나의 접근은 한 번에 1 바이트를 전송하는 read 함수를 이용하여 각각의 바이트가 개행 문자인지 확인하는 방법이 있다. 이 방법은 비효율적이고 파일에 있는 각 바이트를 읽는 동안 커널이 트랩되어야 하는 단점이 있다. 더 나은 접근은 rio_readlinb라는 wrapper 함수를 이용하는 것이다. 이 함수는 내부 read buffer를 이용한다. 내부 read buffer가 비면 자동으로 read 함수 호출을 해서 텍스트 라인을 read buffer로 복사한다. 바이트 단위로 같은 동작을 하는 rio_readnb도 있다.

#include "csapp.h"

ssize_t rio_readlinb (rio_t *rp, void *usrbuf, size_t maxlen) {
  int n, rc;
  char c, *bufp = usrbuf;

  for (n = 1; n < maxlen; n++) {
    if ((rc = rio_read (rp, &c, 1)) == 1) {
      *bufp++ = c;
      if (c == '\n') {
        n++;
        break;
      }
    } else if (rc == 0) {
      if (n == 1)
        return 0; /* EOF, no data read */
      else
        break;    /* EOF, some data was read*/
    } else
      return -1;  /* Error */  
  }
  *bufp = 0;
  return n- 1;
}

 

rio_readinitb 함수는 열려있는 descriptor에 대해 한 번 호출된다. 디스크립터 fd를 주소 rp에 있는 rio_t 타입의 read buffer에 연결한다.

#include "csapp.h"

#define RIO_BUFSIZE 8192
typedef struct {
    int rio_fd;                /* Descriptor for this internal buf */
    int rio_cnt;               /* Unread bytes in internal buf */
    char *rio_bufptr;          /* Next unread byte in internal buf */
    char rio_buf[RIO_BUFSIZE]; /* Internal buffer */
} rio_t;

void rio_readinitb (rio_t *rp, int fd) {
  rp->rio_fd = fd;
  rp->rio_cnt = 0;
  rp->rio_bufptr = rp->rio_buf;
}

 

rio_readlineb 함수는 파일 rp로부터 다음 텍스트 라인을 읽고, 읽은 것을 메모리 위치 usrbuf로 복사하고, 텍스트 라인을 NULL (0) 문자로 종료시킨다. rio_readlineb 함수는 종료 NULL 문자를 위한 공간을 남겨두고 최대 maxlen - 1 바이트를 읽는다. maxlen - 1 바이트를 초과하는 텍스트 라인은 잘린 후에 NULL 문자로 종료된다.

 

rio_readnb 함수는 최대 n 바이트를 읽는다. rio_readlinb와 rio_readnb 함수 호출은 같은 디스크립터에 대해 교차될 수 있다. 하지만 버퍼된 함수들은 버퍼되지 않은 함수 rio_readn 함수 호출돠는 교차될 수 없다.

#include "csapp.h"

ssize_t rio_readnb (rio_t *rp, void *usrbuf, size_t n) {
  size_t nleft = n;
  ssize_t nread;
  char *bufp = usrbuf;

  while (nleft > 0) {
    if ((nread = rio_read (rp, bufp, nleft)) < 0)
      return -1;
    else if (nread == 0)
      break;
    nleft -= nread;
    bufp += nread;
  }
  return (n - nleft);   
}

 

rio_read 함수는 Linux read 함수의 버퍼 버전이다. n개의 바이트를 읽는 rio_read가 호출되면 read buffer에 rp->rio_cnt 바이트만큼의 안 읽은 바이트가 있는 것이다. 버퍼가 비었다면 read 함수를 호출한다. read 함수로부터 short count를 리턴받는 것은 errorr가 아니다. read buffer를 부분적으로 채워주면 된다. 버퍼가 비어 있지 않다면 rio_read는 n과 rp->rio_cnt 중 더 작은 것만큼의 바이트를 버퍼에서 유저 버퍼로 복사하고 복사한 바이트 수를 리턴한다.

애플리케이션 프로그램에게 rio_read 함수는 Linux의 read 함수와 문법이 같다. 에러 시 -1을 리턴하고 errno를 설정한다. EOF의 경우 0을 반환한다. read buffer에 있는 읽지 않은 바이트 수보다 더 큰 바이트를 요청받으면 short count를 리턴한다. 두 함수가 유사하기 때문에 read 대신 rio_read를 사용함으로써 여러 종류의 버퍼 읽기 함수들을 쉽게 만들 수 있다. 예를 들어, rio_readnb 함수는 read를rio_read로 대체함으로써 rio_readn과 유사한 구조를 가지고 있다. 비슷하게 rio_readlineb 루틴은 rio_read를 최대 maxlen-1 번 호출하고 있다. 모든 호출은 read buffer로부터 1바이트를 리턴하며 각 호출은 각 바이트가 개행 문자인지 확인한다.

6. 파일 메타데이터 읽기

애플리케이션은 stat과 fstat 함수를 호출하여 파일에 대한 정보를 얻을 수 있다.

#include <unistd.h>
#include <sys/stat.h>

/* returns 0 if OK, -1 on error */
int stat (const char *filename, struct stat *buf);
int fstat (int fd, struct stat *buf);

 

stat 함수는 filename을 입력받아서 stat 구조체를 채워넣는다.

stat 구조체는 다음과 같다.

/* Metadata returned by the stat and fstat functions */
struct stat {
    dev_t			st_dev;		/* Device */
    int_t			st_ino;		/* inode */
    mode_t			st_mode;	/* Protection and file type */
    nlink_t			st_nlink;	/* Number of hard links */
    uid_t			st_uid;		/* User ID of owner */
    gid_t			st_gid;		/* Group ID of owner */
    dev_t			st_rdev;	/* Device type (if inode device) */
    off_t			st_size;	/* Total size, in bytes */
    unsigned long 		st_blksize;	/* Bulk size for filesystem I/O */ 
    unsigned long		st_blocks;	/* Number of blocks allocated */
    time_t			st_atime;	/* Time of last access */
    time_t			st_mtime;	/* Time of last modification */
    time_t			st_ctime;	/* Time of last change */
}

 

st_size 변수는 파일 크기를 바이트로 표현한다. st_mode 변수는 파일 권한 비트와 파일 유형을 인코딩한다. Linux는 sys/stat.h에서 st_mode로부터 파일 타입을 확인하는 매크로를 정의하고 있다.

S_ISREG(m). Is this a regular file?
S_ISDIR(m). Is this a directory file?
S_ISSOCK(m). Is this a network socket?

 

다음은 파일의 유형과 읽기 권한을 확인하는 프로그램이다. (*csapp.h 대신 시스템 표준 헤더들을 직접 include해줬다.)

#include <unistd.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>

int main (int argc, char **argv) {
  struct stat status;
  char *type, *readok;

  stat (argv[1], &status);
  if (S_ISREG(status.st_mode))
    type = "regular";
  else if (S_ISDIR(status.st_mode))
    type = "directory";
  else
    type = "other";
  if ((status.st_mode & S_IRUSR))
    readok = "yes";
  else
    readok = "no";
  printf("type: %s, read: %s\n", type, readok);
  exit (0);
}

7.  디렉토리의 콘텐츠 읽기

애플리케이션은 readdir 계열의 함수를 통해 디렉토리의 콘텐츠를 읽을 수 있다.

#include <sys/types.h>
#include <dirent.h>

/* Returns: pointer to handle if OK, NULL on error */
DIR *opendir (const char *name);

 

opendir 함수는 경로를 입력받아서 directory stream을 가리키는 포인터를 반환한다. 스트림은 디렉토리 항목의 목록에 대한 추상화이다.

#include <dirent.h>

/* Returns: pointer to next directory entry if OK, NULL if no more entries or error */
struct dirent *readdir (DIR *dirp);

 

readdir 호출은 스트림 dirp에서 다음 디렉토리에 대한 포인터를 반환한다. 아무 항목이 없으면 NULL을 반환한다. 모든 디렉토리 항목은 dirent 형식으로 되어있다.

struct dirent {
    ino_t	d_ino;	        /* inode number */
    char	d_name[256];	/* Filename */
};

 

Linux의 어떤 버전은 다른 멤버 변수를 포함하고 있기도 하지만 모든 시스템에서 표준으로 사용되는 것은 위의 두 멤버 변수이다. d_name은 디렉토리 이름이고 d_ino는 파일의 위치이다.

에러 시, readdir은 NULL을 반환하고 errono를 설정한다. 안타깝게도 end-of-stream 상태와 에러를 구별하는 방법은 errno가 readdir에 대한 호출 이후에 변경되었는지를 확인하는 방법뿐이다.

 

closedir 함수는 스트림을 close하고 그 스트림의 리소스를 해제한다.

#include <dirent.h>

/* Returns: 0 on success, -1 on error */
int closedir (DIR *dirp);

 

다음은 readdir 함수를 사용한 프로그램 예시이다. (csapp.h 대신 시스템 헤더 파일을 직접 include해줬다. 디렉토리 이름이 아니라 파일 이름을 인자로 전달하면 segmentation fault가 난다. 이런 상황을 디버깅하려면 예외 처리를 잘하거나 예외 처리가 잘 되어 있는 wrapper 함수를 사용하면 된다.)

8. 파일 공유하기

리눅스 파일은 다양한 방법으로 공유될 수 있다. 커널이 어떻게 열려 있는 파일을 나타내는지 분명하게 알지 못한다면 파일 공유의 아이디어는 혼란스러울 수 있다.

 

커널은 3개의 관련 자료 구조를 이용하여 열려 있는 파일을 나타낸다.

 

디스크립터 테이블

각 프로세스는 자신만의 디스크립터 테이블을 가지고 있다. 디스크립터 테이블 항목의 인덱스는 프로세스의 열려 있는 파일 디스크립터이다. 열려 있는 디스크립터들 항목들은 파일 테이블에 있는 항목을 가리키고 있다.

 

파일 테이블

파일 테이블에 의해 표현되는 열려 있는 파일들의 집합은 모든 프로세스에 의해 공유된다. 파일 테이블 항목들은 현재 위치, reference count (이 파일을 가리키고 있는 디스크립터 항목의 수), v-node 테이블에 있는 항목을 가리키는 포인터를 포함하고 있다. 디스크립터를 close하면 파일 테이블에서 해당되는 파일 항목의 reference count가 감소하게 된다. 커널은 reference count가 0이 되기 전까지 파일 테이블 항목을 지우지 않는다.

 

v-node 테이블

파일 테이블처럼 v-node 테이블은 모든 프로세스에 의해 공유된다. v-node 테이블의 항목은 st_mode와 st_size를 포함하여 stat 구조체에 있는 거의 모든 정보를 가지고 있다. 

 

다음 그림은 디스크립터 1과 4가 각각 다른 파일을 참조하고 있는 상황이다.

 

여러 개의 디스크립터가 같은 파일을 참조하고 있는 경우이다. 다른 파일 테이블 엔트리를 통해서이다.

이러한 상황은 예를 들어, 당신이 같은 파일 이름에 대해 open 함수를 두 번 호출했을 때 발생할 수 있다. 이렇게 하면 각각의 디스크립터는 파일의 다른 위치에서부터 데이터를 가져올 수 있다.

 

부모와 자식 프로세스가 어떻게 파일을 공유하는지도 이해할 수 있다. fork 함수를 호출하기 전에 부모 프로세스가 열려 있는 파일들을 가지고 있다고 해보자.

자식 프로세스는 부모 프로세스의 디스크립터 테이블의 복사본을 가지게 된다. 부모와 자식 프로세스는 같은 파일 테이블을 공유하고 따라서 같은 파일 위치를 공유한다. 부모와 자식이 모두 디스크립터를 close하고 나서 커널이 파일 테이블 항목을 삭제하도록 해야 한다.

9. I/O 리다이렉션

Linux shell은 사용자가 표준 입출력을 디스크 파일에 연결할 수 있는 I/O 리다이렉션 작업을 제공한다.

 

다음은 표준 입출력을 디스크 파일에 리다이렉션한 예이다.

ls의 출력 결과를 터미널이 아닌 list.txt에 저장했다.

 

I/O 리다이렉션을 하는 방법 중 하나는 dup2 함수를 이용하는 것이다.

#include <unistd.h>

/* Returns: nonnegative descriptor if OK, -1 on error */
int dup2 (int oldfd, int newfd);

 

dup2 함수는 디스크립터 테이블 항목 oldfd를 디스크립터 테이블 항목 newfd로 복사한다. 이때 newfd 항목에 있던 기존 콘텐츠는 모두 덮어 쓰여진다. newfd가 이미 열려 있다면 dup2는 oldfd를 복사하기 전에 newfd를 먼저 close한다.

dup2(4, 1) 함수를 호출한 결과는 다음과 같다.

 

10. 표준 입출력

C 언어는 표준 입출력 라이브러리라고 하는 고수준 입출력 함수의 집합을 정의해놓고 있다. libc 라이브러리는 fopen, fclose, fread, fwrite(바이트), fgets, fputs(문자열), scanf, printf를 제공한다.

표준 입출력 라이브러리는 열려 있는 파일을 stream으로 모델링한다. 프로그래머에게 스트림이란 FILE 타입 구조체에 대한 포인터이다. 모든 ANSI C 프로그램은 세 가지 열려 있는 스트림으로 시작한다. stdin, stdout 그리고 stderr이다. 각각은 표준 입력, 표준 출력, 표준 에러에 대응한다.

#include <stdio.h>

extern FILE *stdin;		/* Standard input (descriptor 0) */
extern FILE *stdout;	/* Standard output (descriptor 1) */
extern FILE *stderr;	/* Standard error (descriptor 2) */

 

FILE 타입의 스트림은 파일 디스크립터와 스트림 버퍼의 추상화이다. 스트림 버퍼의 목적은 RIO read buffer와 동일하다. 비용이 비싼 Linux I/O 시스템 콜의 호출 횟수를 최소화하는 것이다. 예를 들어, 표준 입출력함수 getc를 반복적으로 호출하는 프로그램이 있다고 해보자. getc 함수는 파일의 다음 문자를 반환한다. getc가 처음으로 호출됐을 때 라이브러리는 스트림 버퍼를 read 함수 호출 한 번으로 채워넣는다. 그다음 애플리케이션에게 버퍼의 첫 번째 바이트를 리턴한다. 버퍼에 읽지 않은 바이트가 있다면 stream buffer는 getc에 대한 후속 호출 시 수행해야 할 작업을 대신해줄 수 있다.

11. 어떤 입출력 함수를 사용해야 할까?

Unix의 I/O 모델은 운영체제 커널에 구현되어 있다. 애플리케이션은 open, close, lseek, read, write, stat과 같은 시스템 콜을 통해 이 Unix I/O 함수를 사용할 수 있다. 고수준 함수인 RIO 또는 표준 입출력 함수는 Unix의 I/O 함수를 이용하여 그 위에 구현되어 있다. RIO 함수들은 read와 write에 대한 튼튼한 wrapper 함수로 이 교재를 위해 개발된 것이다. 이 함수들은 short count를 자동으로 처리하며 텍스트 라인을 읽어들일 때 효율적인 버퍼 방식을 사용한다. 표준 입출력 함수는 printf나 scanf와 같은 포맷팅 I/O 루틴을 포함하여 더욱 완전한 입출력 함수를 제공한다. 

 

당신은 프로그램에 어떤 입출력 함수를 써야 할까? 가이드라인은 다음과 같다.

 

1. 가능하다면 표준 입출력 함수를 사용하라.

표준 입출력 함수는 디스크와 터미널 장치에 대한 입출력에서 사용할 수 있는 방법이다. 대부분의 C 개발자는 커리어 동안 표준 입출력 라이브러리에 없는 stat을 제외하고는 저수준의 Unix I/O 라이브러리를 신경쓰지 않고 표준 입출력 함수만을 사용한다.

 

2. 바이너리 파일을 읽는 경우 scanf나 rio_readlineb를 사용하지 마라.

scanf나 rio_readlineb는 텍스트 파일을 읽을 때 사용될 수 있도록 설계된 것이다. 바이너리 파일에 있는 0xa 바이트가 NULL 문자로 인식되면 파일이 이상하게 망쳐질 수 있다.

 

3. 네트워크 소켓에 대한 입출력에서 RIO 함수를 이용하라.

안타깝게도 표준 입출력은 네트워크 입출력에 사용하기에는 해로운 문제들을 야기한다. 네트워크에 대한 Linux의 추상화는 소켓이라고 불리는 파일이고 소켓은 다른 파일처럼 파일 디스크립터에 의해 참조된다. 애플리케이션 프로세스는 소켓 디스크립터를 읽고 씀으로써 다른 컴퓨터에서 돌아가고 있는 프로세스와 통신해야 한다.

 

표준 입출력 스트림은 프로그램이 같은 스트림에 입력도 할 수 있고 출력도 할 수 있다는 점에서 full duplex이다. 하지만 소켓의 제약으로 인해 스트림에 제약 사항이 몇 가지 있다.

 

(1) 입력 함수는 출력 함수 다음에 올 수 없다.

입력 함수는 fflush, fseek, fsetpos, rewind 호출 없이 출력 함수 다음에 올 수 없다.

fflush 함수는 스트림에 연결된 버퍼를 비운다. 나머지 함수들은 Unix 입출력 함수를 사용하여 현재 파일 위치를 재설정한다.

 

(2) 출력 함수는 입력 함수 다음에 올 수 없다.

출력 함수는 입력 함수가 end-of-file을 만나지 않은 이상 fseek, fsetpos, rewind 호출 없이 입력 함수 다음에 올 수 없다. 

이 제약은 네트워크 애플리케이션에서 문제가 된다. 왜냐하면 소켓에서 lseek 함수를 이용하는 것은 금지되어 있기 때문이다. 스트림 I/O에 대한 첫 번째 제약 (입력 함수는 출력 함수 다음에 올 수 없다.)는 입력 작업을 하기 전에 버퍼를 비움으로써 해결될 수 있다. 그런데 이 두 번째 제약을 피할 수 있는 방법은 같은 소켓 디스크립터에 대해 두 개의 스트림을 open하는 것밖에 없다. 하나는 읽기용 하나는 쓰기용으로 여는 것이다.

FILE *fpin, *fpout;

fpin = fdopen (sockfd, "r");
fpout = fdopen (sockfd, "w");

 

하지만 이 방법도 문제가 있다. 왜냐하면 애플리케이션은 스트림에 연결된 메모리 자원을 해제하고 메모리 누수를 피하기 위해서 두 개의 스트림에 모두 fclose를 호출해야 하기 때문이다. 

fclose (fpin);
fclose (fpout);

 

각각의 작업은 같은 소켓 디스크립터를 close하려고 시도하고, 그래서 두 번째 close 작업은 실패한다. 이미 닫혀 있는 descriptor를 close하는 것은 멀티스레드 환경에서 재앙이다.

 

따라서 네트워크 소켓에는 표준 I/O 함수를 이용하지 말고 robust RIO 함수들을 이용할 것을 추천한다. 만약 포맷팅이 필요하다면 sprintf나 sscanf를 같이 이용할 수 있다.