컴퓨터 시스템 10장(시스템 수준 입출력)

Devkty·2025년 5월 3일
1

유닉스는 모든 것은 파일이다.

모든 언어의 런타임 시스템은 입출력을 수행하기 위한 고급 기능들을 제공한다. C는 버퍼를 통해 I/O를 수행하는 printf, scanf 같은 함수들을 표준 I/O 라이브러리에서 제공한다. 리눅스에서는 이러한 고급 I/O 함수들은 커널이 제공하는 시스템 수준 Unix I/O 함수들을 사용해서 구현되어 있다. 결국 이러한 I/O도 파일을 통해 구현이 되어 있다.

10.1 Unix I/O

리눅스에서 파일은 연속된 m개의 바이트이다.

네트워크, 디스크, 터미널 같은 모든 I/O 디바이스들은 파일로 모델링되며, 모든 입력과 출력은 해당 파일을 읽거나 쓰는 형식으로 수행된다.

파일 열기
응용은 I/O 디바이스에 접근하겠다는 의도를 해당 파일을 열겠다고 커널에 요청하는 방법으로 알린다.
커널은 식별자라고 식별자라고 하는 작은 비음수를 리턴하여, 이후의 파일에 관한 모든 연산에서 파일을 나타낸다. 이후에 열린 파일에 관한 모든 정보를 추적한다.

현재 파일 위치 변경
커널은 파일을 열 때 마다 파일 위치 k를 관리하며, 이것은 처음에는 0이다. 파일 위치는 파일의 시작 부분부터 바이트 오프셋이다.

파일 읽기 / 쓰기
읽기 연산은 현재 파일 위치 k에서 시작해서 n > 0 바이트를 파일에서 메모리로 복사하고, k를 n 증가한다. m바이트 파일에서 k가 m 바이트를 오버해서 읽으면 end-of-file EOF가 발생한다.

파일 닫기
응용이 파일 접근을 마치면, 커널에 파일을 닫아줄 것을 요청한다.프로세스가 종료시, 커널이 모든 열려있는 파일을 닫고, 메모리 자원을 반환한다.

10.2 파일

각각의 리눅스 파일은 시스템에서의 역할을 나타내는 타입을 가진다.

  • 일반 파일은 임의의 데이터를 포함한다.
    응용은 ASCII문자나 유니코드만을 포함하는 텍스트 파일과 그외의 모든 파일을 포함하는 이진 파일들을 구분한다. (커널은 차이 없다.)
  • 디렉토리는 링크들의 배열로 구성되며, 각각의 링크는 파일 이름(또 다른 디렉토리일 수 있음)을 파일로 대응시킨다. 각각의 디렉토리는 자신의 디렉토리 링크, 부모의 디렉토리 링크를 포함한다.
  • 소켓은 네트워크 상의 다른 프로세스와 통신하기 위해 사용되는 파일이다. (11.4 참고)

다른 파일 유형들에는 이름 있는 파이프, 심볼형 링크, 문자 및 블록 장치 등이 있지만, 다루지 않는다.

디렉토리 계층 구조에서의 위치는 경로 이름으로 명시한다. 경로 이름은 사선으로 구분된 일련의 파일 이름들이 사선 다음에 따라 올 수 있는 스트링이다. 경로 이름은 두가지 형태가 있다.

절대 경로
사선으로 시작하며 루트로부터 경로를 나타낸다. (/home/droh/hello.c)

상대 경로

파일이름으로 시작하며, 현재 작업 디렉토리로부터의 경로를 표시한다. (/home/bryant 기준 ../home/droh/hello.c)

10.3 파일 열기와 닫기

프로세스는 다음과 같은 open 함수를 호출해서 기존의 파일을 열거나 새 파일을 생성한다.

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

int open(char *filename, int flags, mode_t mode);

open 함수는 filwname을 파일 식별자로 변환하고 식별자 번호를 리턴한다. 리턴된 식별자는 항상 프로세스 내에서 현재 열려 있지 않은 가장 작은 식별자이다. flags 인자는 어떻게 프로세스가 파일에 접근하는지를 나타낸다.

flags 인자

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

다음 과 같은 코드는 기존 파일을 읽기 위해 어떻게 오픈하는지 보여준다.

fd = Open("foo.txt", O_RDONLY, 0);

flags 인자는 쓰기 작업을 위한 추가적인 명령을 제공하도록 한 개 이상의 비트 마스크들을 OR 형태 작성 가능.

  • O_CREAT: 파일이 존재하지 않으면, 비어 있는 파일 생성
  • O_TRUNC: 파일 이미 존재시 내용 비워줌
  • O_APPEND: 매 쓰기 연산 전에 파일 위치를 파일의 마지막으로 설정

위 명령을 사용해 일부 데이터를 추가하려고 기존의 파일을 오픈하는 코드는 다음과 같다.

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

mode인자는 새 파일들의 접근 권한 비트들을 명시한다. 비트들의 심볼 이름은 다음과 같다.

자신의 컨텍스트의 일부로, 각 프로세스는 umask를 가지고, 이건 umask함수를 호출해서 설정한다. 프로세스가 어떤 mode인자를 사용해서 open함수를 호출하고, 이를 통해서 새 파일을 생성하면, 파일의 접근 권한 비트들은 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>

int close(int fd);

이미 닫은 파일 식별자 닫으면 에러다.

10.4 파일 읽기와 쓰기

응욘은 read와 write 함수를 호출해서 읽기와 쓰기를 수행한다.

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);

ssize_t write(int fd, const void *buf, size_t n);

read함수
식별자 fd의 현재 파일 위치에서 최대 n 바이트를 메모리 위치 buf로 복사한다. 리턴 값 -1은 에러, 0은 EOF를 나타낸다. 그렇지 않으면, 리턴 값은 실제로 전송한 바이트 수를 나타낸다.

write함수
메모리 위치 buf에서 식별자 fd의 현재 파일 위치로 최대 n 바이트를 복사한다.

++ 응용은 lseek 함수를 사용해서 현재 파일 위치를 명시적으로 수정할 수 있다.

크게 3가지의 유형이 있다.

EOF를 읽기 중에 만났을 때
20 바이트만 가지고 있는 파일의 현재 파일위치에서 읽을 준비가 되었으며, 이 파일에서 50바이트를 한 번에 읽으려 한다고 하면, 다음 번의 read는 짧은 카운트 20을 리턴하고, 이후 read는 짧은 카운트 0을 리턴해서 EOF를 알려줄 것이다.

터미널에서 텍스트 줄을 읽을 때

만일 열린 파일이 터미널과 연결되어 있으면, 각 read 함수는 한 번에 한 개의 텍스트 줄을 전송할 것이다. 텍스트줄의 길이와 동일한 짧은 카운트를 리턴한다.

네트워크 소켓을 읽거나 쓸 때

만일 열린 파일이 네트워크 소켓에 대응된다면, 내부 버퍼링 제약과 긴 네트워크 지연시간 때문에 read와 write는 짧은 카운트를 리턴할 수 있다.

10.5 RIO패키지를 이용한 안정적인 읽기와 쓰기

RIO 패키지는 CSAPP에서 소개하는 패키지이다. 공식적인 리눅스에서 지원하는 패키지가 아님을 유념하자.

해당 패키지는 다음과 같은 함수를 제공한다.

버퍼 없는 입력 및 출력 함수

이 함수들은 메모리와 파일 사이에 응용 수준의 버퍼링 없이 직접 데이터를 전송한다. 이들은 특히 네트워크에서 바이너리 데이터를 읽고 쓸 때 유용하다.

버퍼를 사용하는 입력 함수

텍스트 라인들과 내용이 응용 수준 버퍼에 캐시되어 있는 파일의 바이너리 데이터를 효율적으로 읽도록 해주며, 이것은 printf와 같이 표준 I/O 함수를 위해 제공되는 것과 유사하다.

10.5 RIO 패키지는 앞에 서술한 것처럼 CSAPP가 만든 것으로 스킵하겠다.

10.6 파일 메타데이터 읽기

응용은 파일에 관한 정보를 stat와 fstat 함수를 호출해서 가져올 수 있다.

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

int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);

stat 함수는 파일 이름을 입력으로 받아서 stat 구조체의 멤버들을 채워준다. fstat 함수도 비슷하게 파일 이름 대신 파일 식별자를 사용한다.

10.7 디렉토리 내용 읽기

응용 프로그램은 readdir 계열의 함수를 이용해서 디렉토리의 내용을 읽을 수 있다.

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

DIR *opendir(const char *name);

opendir함수는 경로 이름을 받아서 디렉토리 스크림을 가리키는 포인터를 리턴한다. 스트림은 항목들의 정렬된 리스트에 대한 추상화, 이 경우에는 디렉토리 항목들의 리스트가 된다.

#include <dirent.h>

struct dirent *readdir(DIR *dirp);

readdir를 호출하면 dirp 스트림에서 다음 디렉토리 항목으로의 포인터를 리턴하거나 만일 더 이상의 항목이 없는 경우에는 NULL을 리턴한다. 각 디렉토리 항목은 다음과 같다.

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

일부 리눅스 버전에서는 다른 구조체 멤버를 포함할 수 있지만, 이들은 모든 시스템에서 표준인 단 두 개의 멤버를 가진다.

에러 발생시 readdir은 NULL을 리턴하고 errno를 설정한다.

#include <dirent.h>

int closedir(DIR *dirp);

closedir 함수는 스트림을 닫고 자신의 자원들을 반환한다.

10.8 파일 공유

리눅스 파일은 여러 가지 방법으로 공유 가능하다. 커널은 세 개의 관련 자료구조를 사용해서 오픈한 파일들을 표현한다.

식별자 테이블
각 프로세스는 자신만의 별도의 식별자 테이블을 가지고 있고, 이들의 엔트리는 프로세스의 오픈된 파일 식별자로 인덱스 된다. 각 오픈 식별자 엔트리는 파일 테이블 내의 한 개의 엔트리를 가리킨다.

파일 테이블

오픈 파일들은 모든 프로세스들이 공유하는 모든 프로세스들이 공유하는 한 개의 파일 테이블로 표시된다. 각 파일 테이블 엔트리는 현재 파일 위치, 현재 가리키는 식별자 엔트리들의 참조 횟수, v-노드 테이블의 엔트리로의 포인터로 구성된다.

구조역할누가 소유?
파일 디스크립터 테이블프로세스가 열린 파일을 가리키는 포인터 목록프로세스마다 따로 존재
오픈 파일 테이블실제 파일의 상태, 위치, 접근 모드 등 관리커널 내부에서 공유

v-노드 테이블

파일 테이블처럼 v-노드 테이블은 모든 프로세스들이 공유한다. 각 엔틀히는 st_mode, st_size 멤버를 포함해서 stat 구조 내의 대부분의 정보를 가지고 있다.

파일 디스크립터(File Descriptor, FD)

숫자로 된 핸들(파일 번호)입니다. (예: 0 = stdin, 1 = stdout, 2 = stderr)
open() 함수로 파일을 열면 FD가 리턴됨

  1. 기본 상황
  • 프로세스가 두 개의 파일 디스크립터 1, 4를 가지고 있음.
  • 각각 다른 파일을 열었고, 서로 다른 오픈 파일 테이블 항목을 가리킴.

요점: 파일마다 고유한 파일 디스크립터와 오픈 파일 테이블이 따로 있음.

  1. 같은 파일을 여러 번 열었을 경우
  • open("a.txt", ...)두 번 호출하면 FD가 2개 생김 (예: fd1, fd2)
  • 이 두 FD는 같은 파일을 가리키지만, 서로 다른 오픈 파일 테이블을 참조함.
  • 즉, 파일 위치도 독립적 → 각 FD는 파일의 다른 위치에서 읽고 쓸 수 있음.

요점: 같은 파일을 여러 번 열면 각각 독립적으로 다룰 수 있음!

  1. fork() 호출 후
  • fork()를 호출하면 자식 프로세스가 생성됨
  • 자식은 부모의 파일 디스크립터 테이블을 복사받음 → 디스크립터 번호는 같음
  • 하지만 이 디스크립터들이 가리키는 오픈 파일 테이블은 부모와 공유

→ 중요 결과

  • 부모와 자식은 같은 파일 위치를 공유함
  • 자식이 파일을 읽으면, 부모도 그 다음 위치에서 읽게 됨 (같은 포인터 공유)
  • 파일을 완전히 닫으려면 부모와 자식 모두 close() 해야 오픈 파일 테이블 항목이 삭제됨
상황디스크립터오픈 파일 테이블파일 위치 공유 여부
파일을 두 번 open서로 다름서로 다름❌ 독립적 위치
dup() 사용서로 다름같음✅ 위치 공유
fork() 후 자식같음같음✅ 위치 공유

비유로 설명하기

  • 디스크립터 테이블 = 독서대에서 책을 어디에 꽂았는지 기억하는 메모
  • 오픈 파일 테이블 = 실제 책 (내용, 페이지 위치 포함)
  • open() 두 번 → 책 2권 따로 빌림 (독립적)
  • fork() → 독서대를 통째로 복사 (책은 같이 공유, 페이지도 같이 넘김)

10.9 I/O 재지정

리눅스 쉘 표준 입력 및 출력을 디스크 파일과 연결할 수 있도록 해주는 I/O 재지정 연산자를 제공한다.

ls > foo.txt

해당 명령어는 다음과 같은 의미를 가진다. ls 프로그램의 표준 출력(stdout) 이 화면(터미널) 이 아니라 파일 foo.txt로 리디렉션(전환) 되게 한다는 것이다.

실제 시스템 동작 과정

일반적인 상황에서 stdout은 기본적으로 디스크립터 1번이다. 이 디스크립터는 보통 터미널을 가리킨다.

그런데, ls > foo.txt를 실행하면, 셀은 dup2() 같은 함수를 사용해서 표준 출력을 foo.txt로 바꾸는 것이다.

int dup2(int oldfd, int newfd);
  • oldfd → 이미 열린 파일 디스크립터 (예: foo.txt)
  • newfd → 바꾸고 싶은 대상 (예: 1번 = stdout)

의미: stdout(1번)이 가리키는 대상(터미널)을 닫고, oldfd(예: foo.txt)와 같은 것을 가리키도록 설정

dup2 호출 전

  • 1번 디스크립터(stdout)파일 A (터미널)
  • 4번 디스크립터파일 B (foo.txt)

dup2(4, 1) 호출 후

  • 1번 디스크립터(stdout)파일 B (foo.txt)
  • 즉, printf()write(1, ...) 하면 화면이 아니라 파일로 출력됨
  • 원래 stdout이 가리키던 터미널은 닫힘
  • file B의 참조 수(ref count)는 2 → 3으로 증가 (공유 중이니까)
개념설명
dup2(oldfd, newfd)newfd를 oldfd와 같은 파일을 가리키게 만든다
기존 newfd가 열려 있으면먼저 자동으로 닫은 후 복사
사용 목적표준 입력/출력/에러를 다른 파일로 리디렉션 할 때
예시dup2(fd_foo, 1)stdoutfoo.txt로 리디렉션

실생활 비유

  • stdout(1번) = 수도꼭지
  • 원래는 물이 싱크대(터미널) 로 흐르고 있었는데,
  • dup2()로 연결을 바꾸면 호스를 다른 통(foo.txt) 으로 바꿔 꽂는 것
  • 이후부터 물이 싱크대가 아니라 통으로 나감 = 리디렉션

10.10 표준 I/O

표준 입출력 라이브러리란?

C 언어는 Unix 시스템 호출(read, write 등) 보다 조금 더 편한 방식으로 파일 입출력을 할 수 있도록

표준 I/O 함수들을 제공합니다.

대표적인 함수들:

  • fopen() / fclose() → 파일 열기/닫기
  • fread() / fwrite() → 바이트 단위 읽기/쓰기
  • fgets() / fputs() → 문자열 읽기/쓰기
  • scanf() / printf() → 포맷 지정 입출력

"파일 스트림(FILE *)" 이란?

C에서는 파일을 FILE *이라는 스트림(stream)으로 다룹니다.

FILE *fp = fopen("hello.txt", "r");

이렇게 하면 fp는 실제 파일을 추상화한 포인터로,

파일 디스크립터 + 버퍼를 합쳐 놓은 구조체를 가리킵니다.


기본으로 열려 있는 스트림 3개

ANSI C 프로그램을 실행하면, 자동으로 아래 세 가지 스트림이 열려 있습니다:

이름설명번호 (디스크립터)
stdin표준 입력0 (keyboard)
stdout표준 출력1 (screen)
stderr표준 에러 출력2 (screen)

이것들 역시 FILE * 타입으로, 우리가 사용하는 printf, scanf 등이 내부적으로 사용합니다.


스트림 버퍼란?

문제점

만약 getc() 같은 함수를 호출할 때마다 read()를 직접 호출한다면,

→ 매번 비싼 시스템 호출이 발생하게 됩니다. (속도 느림)

해결책

"스트림 버퍼" 라는 메모리 공간을 만들어

한 번 read()를 호출할 때 여러 바이트를 한꺼번에 읽어와 버퍼에 저장합니다.

그 다음부터는…

  • getc()fgets()를 여러 번 호출하더라도
  • 버퍼 안에서 꺼내 쓰면 되므로 빠름
  • 버퍼가 다 차면 → 다시 시스템 호출로 읽기

흐름을 도식으로 표현

프로그램 ─→ getc() ─→ [스트림 버퍼] ─→ read() ─→ 파일
  • 처음에는 read()가 파일에서 데이터를 버퍼에 채움
  • 이후는 getc()가 버퍼에서 하나씩 꺼내감
  • 버퍼가 다 닳으면 read()로 다시 채움

요약표

개념설명
FILE *파일을 나타내는 스트림 포인터
스트림파일 디스크립터 + 버퍼로 구성된 고수준 I/O
stdin, stdout, stderr기본 제공되는 3가지 스트림
스트림 버퍼시스템 호출 수를 줄여 I/O 성능을 높이는 메모리 공간
getc(), fgets()버퍼에 있는 데이터를 효율적으로 읽는 고수준 함수

10.11 어떤 I/O 함수를 써야하는가?

세 가지 입출력 방식의 계층 구조

우리가 사용하는 입출력 함수는 3가지 층으로 나눌 수 있습니다.

  • Unix I/O: open, read, write, close 등 (저수준)
  • Rio I/O: rio_readn, rio_writen, rio_readlineb (버퍼 있음, 견고함)
  • Standard I/O: fopen, fread, fwrite, printf, scanf 등 (가장 편함)

어떤 함수를 언제 써야 하나?

이 부분은 가이드라인 3가지(G1, G2, G3)로 설명되어 있다.

G1: 가능하면 Standard I/O 를 사용하기

  • 대부분의 경우 fopen, fclose, fread, fgets, printf 등이 가장 쉽고 편리합니다.
  • 많은 C 프로그래머들이 이 방식만 사용해도 평생 문제 없습니다.
  • 예외적으로 stat()은 유닉스 전용이므로 표준 I/O에 없습니다.

요약: 텍스트 파일이나 터미널 입출력에는 표준 I/O 함수를 쓰는 게 최고입니다.

G2: scanf / rio_readlineb는 바이너리 파일에 쓰지 마세요

  • scanfrio_readlineb텍스트 파일 전용입니다.
  • 바이너리 파일에는 특수문자(예: 0x0a 등)가 많아, 이상한 동작이 나올 수 있습니다.

요약: 바이너리 파일 → read, fread, rio_readn 같은 바이트 기반 함수 써야 안전합니다.

G3: 네트워크 소켓에서는 Rio 함수를 사용하세요

  • 소켓(socket)은 파일 디스크립터처럼 다루지만, lseek 같은 건 사용 불가능합니다.
  • 표준 I/O 함수는 내부에 버퍼가 있고, 버퍼 입출력 순서 제약 때문에 소켓과 궁합이 안 맞습니다.

표준 I/O의 문제점 (소켓에서)

제약 1: 출력 후 입력 → fflush 또는 fseek 필수이다.

→ 하지만 fseek은 소켓에서 불가능하므로 fflush만 가능

제약 2: 입력 후 출력 → fseek 등 없으면 안 됨

→ 소켓에서 해결 불가능.

→ 그래서 fdopen()을 두 번 써서 읽기/쓰기를 따로 처리하려고 해도 fclose() 두 번 호출하게 되면 동일 디스크립터를 두 번 닫아 위험하다.

이런 이유로 멀티스레드 환경에서는 특히 치명적일 수 있습니다.


해결책은?

  • 읽기: rio_readlineb()
  • 쓰기: rio_writen()
  • 포맷 출력: sprintf()로 문자열 만든 다음 rio_writen()으로 전송
  • 포맷 입력: rio_readlineb()로 문자열 읽고 sscanf()로 분석

요약표

상황추천 함수
일반 텍스트 파일 입출력fopen, fgets, fprintf 등 (표준 I/O)
바이너리 파일 읽기/쓰기read, write, fread, fwrite
네트워크 소켓 입출력rio_readlineb, rio_writen, sprintf, sscanf
파일 정보 조회 (크기 등)stat() (Unix I/O 전용)
profile
모든걸 기록하며 성장하고 싶은 개발자입니다. 현재 크래프톤 정글 8기를 수료하고 구직활동 중입니다.

0개의 댓글