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 에러 메시지 출력하기

아래는 기본 디스크립터를 이용하여 터미널에서 1byte씩 읽고 출력하는 예제 프로그램이다.

#include "csapp.h"
int main(void) {
    char c;
    while (Read(STDIN_FILENO, &c, 1) != 0)
        Write(STDOUT_FILENO, &c, 1);
    exit(0);
}

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의 현재 파일 위치(current file position) 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에 해당하는 파일의 현재 파일 위치 k에 적고 k를 갱신한다. 예를 들어, 아래 코드는 "hello"라는 5바이트짜리 문자열을 파일 fd에 쓴다:

char msg[] = "hello";
write(fd, msg, 5);

Changing the current file position

애플리케이션은 lseek 함수를 호출하여 현재의 파일 위치를 바꾼다:

#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence); //Returns: current file position if OK, -1 on error

lseek 함수는 fd에 해당하는 파일의 현재 파일 위치를 offset byte만큼 옮긴다. 이때 옮긴 위치에 기준을 잡기 위해서 아래 표에 정리되어 있는 whence 옵션이 사용된다:

옵션 설명
SEEK_SET Move from the start of the file
SEEK_CUR Move from the current position of the file
SEEK_END Move from the end of the file

아래는 lseek 함수가 사용된 코드의 예시이다.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
    int fd = open("example.txt", O_RDWR); //파일 열기
    if (fd == -1) { //적절한 파일 디스크립터인지 확인
        perror("open");
        return 1;
    }
    off_t pos = lseek(fd, 0, SEEK_CUR); //pos에 현재 파일 위치 저장
    printf("Current position: %ld\n", pos);

    lseek(fd, 5, SEEK_SET); // 파일 시작에서 5바이트 앞으로 이동
    write(fd, "HELLO", 5);  // 파일 처음에서 5번째 바이트에 HELLO 덮어쓰기

    off_t end = lseek(fd, 0, SEEK_END); //현재 파일 위치를 파일의 끝으로 이동
    printf("File size: %ld bytes\n", end);

    close(fd); //파일 닫기
    return 0;
}

Short counts

어떤 상황에서는 read(), write() 함수가 요청받은 바이트(n)보다 적은 양을 전송하기도 한다. 이를 short counts라고 하며, 이는 오류를 의미하지 않는다. 이는 다음과 같은 상황일때 발생한다:

  • 읽기 도중 EOF를 만난 경우: 예를 들어, 현재 파일 위치로부터 남은 바이트가 20바이트뿐인 파일을 50바이트씩 읽고 있다고 하자. 이 경우 다음 read 호출은 20이라는 짧은 수치를 반환하고, 그 다음 호출은 0을 반환하여 EOF를 나타낸다.
  • 터미널에서 텍스트 줄을 읽는 경우: 열린 파일이 터미널(즉, 키보드와 디스플레이)과 연결되어 있다면, 각 read() 함수 호출은 한 줄의 텍스트만 전송하고, 이 줄의 크기만큼의 short counts를 반환한다.
  • 네트워크 소켓을 읽고 쓰는 경우: 열린 파일이 네트워크 소켓에 해당한다면, 내부 버퍼 제약과 긴 네트워크 지연으로 인해 read(), write() 함수는 짧은 수치를 반환할 수 있다. 또한, 리눅스 파이프(pipe)를 read와 write로 호출할 때도 발생할 수 있다.

각주

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