System-Level I/O

youngwiki

상위 문서: 컴퓨터 시스템

개요

입출력(I/O)은 주기억장치(main memory)와 디스크 드라이브, 터미널, 네트워크와 같은 외부 장치(external devices) 사이에서 데이터를 복사하는 과정이다. 입력 연산(input operation)은 I/O 장치로부터 데이터를 주기억장치로 복사하고, 출력 연산(output operation)은 데이터를 주기억장치에서 장치로 복사한다.
해당 문서에서는 UNIX I/O와 표준 I/O의 일반적인 개념과, C 프로그램에서 이를 안정적으로 사용하는 방법을 설명한다.

UNIX I/O

리눅스 파일은 아래와 같은 m 바이트의 시퀸스로 구성된다.

B1, B2, ..., Bk, ..., Bm-1

모든 I/O 장치들(예: 네트워크, 디스크, 터미널, 커널! 등)은 아래와 같이 파일로 모델링되며, 모든 I/O는 해당 파일들을 읽고 쓰는 방식으로 수행된다.

/dev/sda2                       (/usrdiskpartition)
/dev/tty2                       (terminal)
/boot/vmlinuz-3.13.0-55-generic (kernel image)
/proc                           (kernel data structures)

I/O 장치들을 모두 파일로 매핑하는 방식 덕분에, 리눅스 커널은 UNIX I/O로 불리는 단순하고 저수준(low-level)의 인터페이스를 제공할 수 있다. 이를 통해 모든 I/O 작업들을 일관되고 통일된 방식으로 수행할 수 있다.

Files

각 리눅스 시스템 내에서의 역할을 나타내는 type을 가진다:

  • 일반 파일(regular file)은 임의의 데이터로 구성된다. 애플리케이션 들은 종종 일반 파일 들을 ASCII 혹은 유니코드 문자만을 포함하는 텍스트 파일(text file)과, 그 외의 모든 것을 포함하는 바이너리 파일(binary file) 을 구분하지만, 커널은 이를 구분하지 않는다. 따라서 UNIX I/O도 텍스트 파일과 바이너리 파일을 구분하지 않는다. 리눅스 텍스트 파일은 단순히 텍스트 라인들의 시퀸스(sequence of text lines)로 구성되며, 각 줄(text line)은 문자들의 시퀸스(sequence of characters)로 이루어지고 줄바꿈 문자('\n')[1]로 종료된다.
  • 디렉토리(directory)링크(link)들의 배열로 구성된 파일이며, 각 링크는 파일 이름을 파일(혹은 디렉토리)에 매핑한다. 각 디렉토리는 적어도 두 개의 항목을 가지고 있다. 먼저 .는 디렉토리 자신을 가리키는 링크이고, ..는 디렉토리 계층 구조에서 상위 디렉토리를 가리키는 링크이다. 디렉토리는 mkdir 명령어로 만들 수 있고, ls 명령어로 안의 내용을 볼 수 있으며, rmdir 명령어를 통해서 삭제할 수 있다.
  • 소켓(socket)은 네트워크를 통해 다른 프로세스와 통신하기 위해 사용되는 파일이다.

그외에도 named pipe, symbolic link, character and block deviced와 같은 여러 type들이 추가로 존재하나, 이에 대해서는 다루지 않느다.

Directory hierarchy

Figure 1. Portion of the Linux directory hierarchy
Figure 1. Portion of the Linux directory hierarchy

리눅스 커널은 모든 파일을 루트(root) 디렉터리 /로 고정된 단일 디렉터리 계층 구조 안에 조직한다. 시스템 내의 각 파일은 루트 디렉터리의 직계 또는 간접 후손(direct or indirect descendant)이다. Figure 1은 리눅스 시스템 내의 디렉토리 계층의 일부를 보여준다.
각 프로세스는 컨텍스트의 일부로 현재의 작업 디렉터리(current working directory)를 가지며, 이는 디렉토리 계층 내에서 현재 위치를 나타낸다. 이때, cd 명령어를 통해 셸(shell)의 현재 작업 디렉터리를 변경할 수 있다.

디렉더리 계층에서의 위치는 경로명(pathname) 으로 지정된다. 경로명은 /로 구분된 일련의 파일 이름들로 구성된 문자열이다.[2] 경로명은 두 가지 형태가 있다:

  • 절대 경로명(absolute pathname): /로 시작하며, 루트 디렉토리로부터의 경로를 나타낸다.
    • hello.c의 절대 경로명: /home/droh/hello.c
  • 상대 경로명(relative pathname): 파일 이름으로 시작하며, 현재 작업 디렉터리로부터의 경로를 나타낸다.
    • /home/droh가 현재 작업 디렉터리일 때 hello.c의 상대 경로명: ./hello.c
    • /home/bryant가 현재 작업 디렉터리일 때 hello.c의 상대 경로명: ../home/droh/hello.c

Opening and Closing Files

Opening files

애플리케이션은 close() 함수를 통해 커널에게 특정 파일을 열도록 요철하며, 해당 I/O 장치에 접근한다.

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char *filename, int flags, mode_t mode); //Returns: new file descriptor if OK, −1 on error

커널은 해당 파일 이름을 파일 디스크립터(file descriptor)[3]로 변환하고 그 디스크립터 번호를 반환한다. 반환되는 디스크립터는 항상 프로세스에서 현재 열려 있지 않은 가장 작은 디스크립터 번호이다. 이 디스크립터는 이후 파일에 대한 모든 연산에서 사용된다.[4]close() 함수에서 flag 인자는 프로세스가 파일에 어떻게 접근하려는지를 나타내며, 이는 아래와 같다:

  • O_RDONLY: 읽기 전용
  • O_WRONLY: 쓰기 전용
  • O_RDWR: 읽기 및 쓰기

또한, flags 인자는 추가적인 옵션을 제공하는 하나 이상의 비트 마스크(bit mask)와 OR 연산으로 결합될 수 있다.

  • O_CREAT: 파일이 존재하지 않으면 빈 파일을 새로 만든다.
  • O_TRUNC: 파일이 이미 존재하면 해당 파일을 빈 파일로 만든다.
  • O_APPEND: 쓰기 작업(write())을 할 때마다, 커널이 자동으로 파일의 맨 끝으로 이동해서 쓰게 한다.[5]

예를 들어, 이어 쓰고자 할 때, 파일을 쓰기 전용으로 여는 방법은 다음과 같다.

fd = Open("foo.txt", O_WRONLY|O_APPEND, 0);

또한 모든 리눅스 프로세스는 생성되거나 시작하는 즉시 기본으로 열려있는 세개의 파일(디스크립터)를 가지고 있는데, 이는 다음 표와 같다:

이름 디스크립터 상수 역할
standard input 0 STDIN_FILENO 키보드 등에서 입력 받기
standard output 1 STDOUT_FILENO 화면(터미널)으로 출력하기
standard error 2 STDERR_FILENO 에러 메시지 출력하기

Colsing files

애플리케이션이 파일에 대한 접근을 마치면, close() 함수를 호출하여 해당 파일을 닫아달라고 커널에 요청한다:

#include <unistd.h>
int close(int fd); //Returns: 0 if OK, −1 on error

커널은 위 함수의 호출 결과로 파일을 열 때 생성한 데이터 구조들을 해제하고, 디스크립터를 사용 가능한 디스크립터 풀로 되돌린다. 이때 이미 닫혀있는 디스크립터를 닫는 것은 오류이다. 또한 프로세스가 어떤 이유로든 종료되면, 커널은 열려있는 모든 파일들을 닫고 그 메모리 자원들을 해제한다.

Reading and Writing Files

Reading files

애플리케이션은 read() 함수를 호출하여 입력 연산을 수행한다:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n); //Returns: number of bytes read if OK, 0 on EOF, −1 on error

read() 함수는 디스크립터 fd의 현재 파일 위치 k로부터 최대 n 바이트를 buf에 복사하고, 그 후에 k를 n만큼 증가시킨다. 이때 파일 크기가 m 바이트일 때, k≥m인 상태에서 읽기 연산을 수행하면 EOF(end of file) 상태가 발생한다.[6] 예를 들어 아래의 코드는 100바이트씩 계속 읽다가 더 이상 읽을 게 없으면, read() 함수는 EOF 상태이므로 0을 반환한다.

int fd = open("myfile.txt", O_RDONLY);
char buf[100];
int n;
while ((n = read(fd, buf, 100)) > 0) {
    // 여기서 buf 안의 데이터를 사용함
}
if (n == 0) {
    //n == 0 이면 EOF 도달, n == -1이면 에러 종료
}

Writing files

애플리케이션은 write() 함수를 호출하여 쓰기 연산을 수행한다:

#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t n); //Returns: number of bytes written if OK, −1 on error

write() 함수는 buf 안에 있는 데이터에서 최대 n 바이트를 복사하여 fd에 해당하는 파일의 현재 파일 위치에 적는다. 예를 들어, 아래 코드는 "hello"라는 5바이트짜리 문자열을 파일 fd에 쓴다.

각주

  1. 줄 바꿈 문자는 0x0a에 해당하며, 이는 ASCII 값과 동일하다.
  2. 이때 경로명은 /으로 시작할 수도, 안할 수도 있다.
  3. 0 이상의 정수이다
  4. 커널은 열려있는 파일에 대한 모든 정보를 추적하고 관리하나, 애플리케이션은 디스크립터만을 추적한다.
  5. 기존 내용을 덮어 쓰지 않고 이어 쓰고자 할 때 이용한다.
  6. 파일 끝에는 명시적인 EOF 문자는 존재하지 않는다.