printf, scanf 같은 버퍼 I/O를 수행하는 표준 I/O 라이브러리<< (put to) 및 >> (get from) 연산자대부분 상위 수준 I/O 함수들로 충분한데, 왜 굳이 유닉스 I/O를 직접 배워야 할까요?
리눅스 파일은 바이트의 시퀀스입니다.
네트워크, 디스크, 터미널과 같은 모든 I/O 장치는 파일로 모델링되며, 모든 입력과 출력은 해당 파일에 대한 읽기(reading)와 쓰기(writing)를 통해 수행됩니다.
이러한 장치와 파일 간의 매핑을 통해, 리눅스 커널은 유닉스 I/O라는 단순하고 낮은 수준의 애플리케이션 인터페이스를 제공하며, 모든 입출력이 균일하고 일관된 방식으로 수행될 수 있도록 합니다.
STDIN_FILENO)STDOUT_FILENO)STDERR_FILENO)<unistd.h> 헤더 파일에 이 상수들이 정의되어 있습니다.)각 리눅스 파일은 시스템에서의 역할을 나타내는 타입(type)을 가집니다.
\\n (newline, ASCII LF, 0x0a) 문자로 끝납니다.. (자기 자신을 가리키는 링크)와 .. (부모 디렉터리를 가리키는 링크).mkdir로 생성하고, ls로 내용을 보며, rmdir로 삭제할 수 있습니다.
/ (슬래시)라는 이름의 루트 디렉터리를 기준으로 모든 파일을 단일 디렉터리 계층으로 구성합니다.cd 명령어로 변경 가능)디렉터리 계층 내 위치는 경로 이름(pathname)으로 지정됩니다. 경로 이름은 선택적인 슬래시(/)로 시작하고, 슬래시로 구분된 파일 이름들의 시퀀스로 구성됩니다.
/)로 시작하며, 루트 노드로부터의 경로를 나타냅니다./home/droh/hello.c/home/droh일 때) ./hello.c/home/bryant일 때) ../home/droh/hello.c텍스트 파일 작업의 번거로운 측면 중 하나는 시스템마다 줄의 끝(End of Line)을 표시하는 문자가 다르다는 것입니다.
\\n (0xa)을 사용합니다. (ASCII Line Feed, LF)\\r\\n (0xd 0xa) 시퀀스를 사용합니다. (ASCII Carriage Return, CR + Line Feed, LF)foo.txt)을 Linux 텍스트 편집기에서 열면, 각 줄 끝에 불필요한 ^M 문자가 보일 수 있습니다.^M은 Linux 도구가 \\r (CR) 문자를 표시하는 방식입니다.)perl 명령을 실행하여 foo.txt 파일에서 \\r 문자를 제자리에서(in place) 제거할 수 있습니다.linux> perl -pi -e "s/\\r\\n/\\n/g" foo.txt프로세스는 open 함수를 호출하여 기존 파일을 열거나 새 파일을 생성합니다.
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char *filename, int flags, mode_t mode);
open 함수는 filename을 파일 디스크립터(file descriptor)로 변환하고 그 디스크립터 번호를 반환합니다. 반환되는 디스크립터는 항상 해당 프로세스에서 현재 열려있지 않은 가장 작은 디스크립터입니다.
flags 인자flags 인자는 프로세스가 파일에 접근하려는 방식을 나타냅니다.
O_RDONLY: 읽기 전용O_WRONLY: 쓰기 전용O_RDWR: 읽기/쓰기(예: fd = Open("foo.txt", O_RDONLY, 0);)
flags 인자는 추가적인 쓰기 옵션을 제공하는 하나 이상의 비트 마스크와 OR(|) 연산될 수 있습니다.
O_CREAT: 파일이 존재하지 않으면, (비어있는) 새 파일을 생성합니다.O_TRUNC: 파일이 이미 존재하면, 파일을 잘라 비웁니다(truncate).O_APPEND: 매번 쓰기 작업(write operation) 전에, 파일 위치를 파일의 맨 끝으로 설정합니다.(예: fd = Open("foo.txt", O_WRONLY|O_APPEND, 0);)
mode 인자
mode 인자는 O_CREAT 플래그로 새 파일을 생성할 때의 접근 권한 비트를 지정합니다. (그림 10.2의 심볼릭 이름 참조)
umask 함수를 호출하여 설정하는 umask 값을 컨텍스트의 일부로 가집니다.open 함수로 새 파일을 생성할 때, 파일의 최종 접근 권한 비트는 mode & ~umask (mode AND NOT umask)로 설정됩니다.mode가 S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH (0666)이고, 기본 umask가 S_IWGRP|S_IWOTH (0022)라고 가정해 봅시다.#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);0666 & ~0022 = 0644 권한(rw-r--r--)을 가진 새 파일을 생성합니다.close 함수프로세스는 close 함수를 호출하여 열려있는 파일을 닫습니다.
#include <unistd.h>
int close(int fd);
이미 닫힌 디스크립터를 닫으려고 시도하면 오류가 발생합니다.
애플리케이션은 각각 read와 write 함수를 호출하여 입력과 출력을 수행합니다.
read 함수#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
read 함수는 파일 디스크립터 fd의 현재 파일 위치로부터 buf 메모리 위치로 최대 바이트를 복사합니다.
1 반환 값은 오류를 나타냅니다.0 반환 값은 EOF (파일 끝)를 나타냅니다.write 함수#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t n);
write 함수는 buf 메모리 위치로부터 파일 디스크립터 fd의 현재 파일 위치로 최대 바이트를 복사합니다.
(참고: 애플리케이션은 lseek 함수를 호출하여 현재 파일 위치를 명시적으로 수정할 수 있으나, 이 책의 범위를 벗어납니다.)
write() 함수를 호출하면, 데이터가 디스크에 바로 기록되는 것이 아니라, 커널(Kernel)에게 "이 데이터를 써달라"고 요청하는 시스템 콜(System Call)이 발생합니다. write() 함수의 내부는 크게 5단계로 나눌 수 있습니다.사용자가 C 코드에서 write(fd, buf, n)를 호출하면, 사실은 C 라이브러리(libc)에 있는 래퍼(wrapper) 함수를 호출한 것입니다.
이 래퍼 함수는 write에 해당하는 시스템 콜 번호(예: 1)를 RAX 레지스터에 저장하고, 인자(fd, buf, n)를 정해진 레지스터에 넣은 뒤, syscall 명령어를 실행합니다.
syscall 명령어는 소프트웨어 트랩(Trap)을 발생시켜, CPU의 모드를 '유저 모드'에서 '커널 모드'로 즉시 전환시키고, 커널의 시스템 콜 핸들러로 점프합니다.
이제 커널 모드입니다. 커널은 이 요청이 유효한지 확인해야 합니다.
커널은 fd (예: 3)를 인덱스로 사용하여, 현재 프로세스의 파일 디스크립터 테이블을 조회합니다.
이 테이블의 3번 항목은 시스템 전역의 "열린 파일 테이블(Open File Table)"에 있는 특정 항목을 가리킵니다.
이 "열린 파일" 항목에는 이 파일의 현재 위치(file position, k), 접근 권한(읽기/쓰기), 그리고 이 파일의 실제 정보가 담긴 VFS(가상 파일 시스템) 노드를 가리키는 포인터가 들어있습니다.
커널은 이 정보를 보고 write 요청이 fd의 권한(O_WRONLY 또는 O_RDWR로 열렸는지)과 일치하는지 확인합니다.
이것이 write의 핵심입니다. 커널은 데이터를 디스크로 직접 보내지 않습니다.
커널은 유저의 buf(유저 공간)에 있는 데이터 n 바이트를 커널 자신의 내부 버퍼(Kernel Buffer)로 복사합니다. (이 내부 버퍼가 바로 "페이지 캐시(Page Cache)"입니다.)
이 작업은 메모리(RAM) 메모리(RAM) 복사본이므로 매우 빠릅니다.
데이터 복사가 완료되면, 커널은 "열린 파일 테이블"에 있던 파일 위치 k를 k + n으로 업데이트합니다.
매우 중요한 지점입니다.
데이터가 커널 버퍼로 성공적으로 복사되는 즉시, write 시스템 콜은 "성공"으로 간주됩니다.
커널은 기록한 바이트 수(n)를 반환 값으로 RAX 레지스터에 설정합니다.
커널은 CPU를 다시 '유저 모드'로 전환시키고, 중단되었던 사용자 프로그램의 다음 명령어로 복귀(return)합니다.
사용자 프로그램은 write가 반환되었으므로 즉시 다음 코드를 실행합니다.
이 시점에서 데이터는 아직 물리 디스크(HDD/SSD)에 쓰이지 않았습니다!
커널은 "나중에" 시간적 여유가 있을 때(예: 디스크가 유휴 상태일 때, 또는 버퍼가 꽉 찼을 때), 커널 버퍼(페이지 캐시)에 모아둔 "더러운(dirty)" 데이터들을 비동기적(asynchronously)으로 디스크 컨트롤러에 보냅니다.
이것을 "지연된 쓰기 (Delayed Write)" 또는 "Write-Back Caching"이라고 부릅니다.
(만약 프로그래머가 write 직후 데이터가 반드시 디스크에 저장되어야 함을 보장받고 싶다면, fsync(fd)라는 별도의 시스템 콜을 호출하여 커널 버퍼를 강제로 디스크에 쓰도록(flush) 요청해야 합니다.)
read와 write가 애플리케이션이 요청한 바이트보다 적은 수의 바이트를 전송하는 경우가 있습니다.
이러한 short count는 오류(error)를 의미하지 않으며, 여러 가지 이유로 발생할 수 있습니다.
read는 20이라는 short count를 반환합니다.read는 0 (EOF)을 반환합니다.read 함수는 한 번에 하나의 텍스트 라인(text line)만 전송하며, 텍스트 라인의 크기만큼 short count를 반환합니다.read와 write가 short count를 반환할 수 있습니다.read와 write를 반복적으로 호출해야 합니다.이 섹션에서는 read/write 호출 시 발생하는 short count (부족한 전송)를 자동으로 처리해 주는 Rio (Robust I/O)라는 I/O 패키지를 개발합니다.
Rio 패키지는 short count가 발생하기 쉬운 네트워크 프로그램 같은 애플리케이션에서 편리하고, 견고하며, 효율적인 I/O를 제공합니다.
Rio는 두 가지 종류의 함수를 제공합니다.
printf 같은 표준 I/O 함수와 유사하게, 애플리케이션 수준 버퍼에 캐시된 파일의 내용을 효율적으로 읽을 수 있게 합니다.애플리케이션은 rio_readn과 rio_writen 함수를 호출하여 메모리와 파일 간에 데이터를 직접 전송할 수 있습니다. (즉, 애플리케이션 수준의 버퍼를 사용하지 않습니다.)
#include "csapp.h"
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
반환 값
rio_readn 함수는 디스크립터 fd의 현재 파일 위치에서 usrbuf 메모리로 최대 n 바이트를 전송합니다.
rio_readn이 n 바이트보다 적은 short count를 반환하는 경우는 EOF (파일 끝)을 만났을 때뿐입니다.

이 함수는 read 시스템 콜이 short count나 EINTR 시그널로 중단되는 "불안정성"을 해결합니다.
nleft = n: 앞으로 읽어야 할 바이트 수를 추적합니다.while (nleft > 0): n 바이트를 모두 읽을 때까지 루프를 돕습니다.if ((nread = read(...)) < 0): read가 오류를 반환한 경우:if (errno == EINTR):nread = 0;으로 설정하고 루프를 다시 시도(restart) 합니다.else return -1;:fd)라면 -1을 반환합니다.else if (nread == 0):read가 0을 반환했다면, 이는 EOF (파일 끝)을 의미하므로 break로 루프를 탈출합니다.nleft -= nread;:read가 (short count로 인해) 일부만 읽어왔다면, 읽은 만큼(nread) nleft에서 빼고,bufp 포인터를 전진시켜 다음 read를 준비합니다.return (n - nleft);:n 바이트를 다 못 읽고 루프가 끝났을 수 있으므로,n - nleft)를 반환합니다.rio_writen 함수는 usrbuf 메모리에서 fd 디스크립터로 정확히 n 바이트를 전송합니다.
이 함수는 (성공 시) 절대 short count를 반환하지 않습니다.

이 함수는 write가 (예: 네트워크 버퍼가 꽉 차서) short count를 반환하더라도,
n 바이트를 모두 쓸 때까지 재시도합니다.
nleft = n: 앞으로 써야 할 바이트 수를 추적합니다.while (nleft > 0): n 바이트를 모두 쓸 때까지 루프를 돕습니다.if ((nwritten = write(...)) <= 0):write가 실패하거나 0바이트를 쓴 경우:if (errno == EINTR):nwritten = 0으로 설정하고 재시도합니다.else return -1;:nleft -= nwritten;:write가 short count (예: n의 일부인 nwritten)를 반환했더라도,nleft에서 그만큼을 뺍니다.bufp += nwritten;:bufp 포인터를 nwritten만큼 전진시켜,nleft 바이트를 다음 루프에서 쓸 준비를 합니다.return n;:while 루프가 끝났다는 것은 nleft가 0이 되었다는 뜻이므로, 항상 n을 반환합니다.텍스트 파일의 줄 수를 세는 프로그램을 read 함수로 1바이트씩 읽는 것은 각 바이트마다 커널 트랩이 발생하여 매우 비효율적입니다. 더 나은 방법은 내부 읽기 버퍼(read buffer)를 사용하는 래퍼 함수 (rio_readlineb)를 호출하여, 버퍼가 비었을 때만 read 시스템 콜을 호출하여 버퍼를 자동으로 리필하는 것입니다.
rio_t) 구조체RIO 버퍼 입력 함수들은 rio_t라는 구조체를 사용하여 애플리케이션 수준의 버퍼링을 구현합니다. (Fig 10.6)
#define RIO_BUFSIZE 8192
typedef struct {
int rio_fd; /* 이 내부 버퍼를 위한 디스크립터 */
int rio_cnt; /* 버퍼 안에 아직 읽지 않은 바이트 수 */
char *rio_bufptr; /* 버퍼 안에서 다음 읽을 바이트 위치 */
char rio_buf[RIO_BUFSIZE]; /* 내부 버퍼 */
} rio_t;
RIO는 텍스트 라인과 바이너리 데이터를 모두 효율적으로 읽기 위해 다음과 같은 버퍼 입력 함수들을 제공합니다.
rio_readnb: rio_readn의 버퍼 버전으로, 원시 바이트(raw bytes)를 전송합니다.rio_readlineb: 텍스트 라인을 읽는 데 특화된 함수입니다.#include "csapp.h"// 버퍼 초기화
void rio_readinitb(rio_t *rp, int fd);
// Returns: nothing
// 한 줄 읽기 (버퍼 사용)
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
// n 바이트 읽기 (버퍼 사용)
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
// Returns: number of bytes read if OK, 0 on EOF, -1 on error
rio_readinitb(rp, fd) (Fig 10.6):
void rio_readinitb(rio_t *rp, int fd)
{
rp->rio_fd = fd;
rp->rio_cnt = 0;
rp->rio_bufptr = rp->rio_buf;
}
fd) 마다 한 번 호출됩니다.fd를 rp 주소에 있는 rio_t 타입의 읽기 버퍼와 연결(associate)합니다.rio_cnt = 0, rio_bufptr 초기화).내부 rio_read(rp, usrbuf, n) 함수 (Fig 10.7):
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
int cnt;
while (rp->rio_cnt <= 0) { /* Refill if buf is empty */
rp->rio_cnt = read(rp->rio_fd, rp->rio_buf,
sizeof(rp->rio_buf));
if (rp->rio_cnt < 0) {
if (errno != EINTR) /* Interrupted by sig handler return */
return -1;
}
else if (rp->rio_cnt == 0) /* EOF */
return 0;
else
rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */
}
/* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */
cnt = n;
if (rp->rio_cnt < n)
cnt = rp->rio_cnt;
memcpy(usrbuf, rp->rio_bufptr, cnt);
rp->rio_bufptr += cnt;
rp->rio_cnt -= cnt;
return cnt;
}
read의 버퍼 버전 역할을 합니다.rp->rio_cnt 바이트가 남아있습니다.rp->rio_cnt <= 0): read(rp->rio_fd, ...) 시스템 콜을 호출하여 버퍼(rp->rio_buf)를 최대 RIO_BUFSIZE만큼 채웁니다. (read가 short count를 반환해도 오류 아님) 버퍼 포인터(rio_bufptr)는 리셋합니다.rp->rio_cnt 중 더 작은 값(cnt) 만큼 내부 버퍼(rio_bufptr)에서 사용자 버퍼(usrbuf)로 memcpy 합니다.rio_bufptr를 cnt만큼 전진시키고, rio_cnt를 cnt만큼 감소시킨 후, 복사된 바이트 수(cnt)를 반환합니다.read와 동일한 의미를 갖습니다 (오류 시 -1, EOF 시 0, 버퍼 잔량보다 많이 요청 시 short count 반환)rio_readlineb(rp, usrbuf, maxlen) (Fig 10.8):
ssize_t rio_readlineb(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_read(rp, &c, 1)를 한 바이트씩 최대 maxlen-1번 호출합니다.usrbuf에 복사하고, 개행 문자(\n)를 만나면 루프를 멈춥니다.usrbuf의 끝에 NULL 문자(\0)를 추가하여 문자열로 만듭니다.rio_readnb(rp, usrbuf, n) (Fig 10.8):
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; /* errno set by read() */
else if (nread == 0)
break; /* EOF */
nleft -= nread;
bufp += nread;
}
return (n - nleft); /* Return >= 0 */
}
rio_readn (버퍼 없는 버전)과 구조가 거의 동일합니다.read 시스템 콜 대신, 내부 rio_read 함수를 사용하여 버퍼에서 데이터를 가져옵니다.rio_read가 short count를 반환하더라도, n 바이트를 모두 읽거나 EOF를 만날 때까지 rio_read를 반복 호출합니다.rio_readlineb와 rio_readnb 호출은 동일한 rio_t 버퍼(rp)를 공유하며 임의로 혼용하여 사용할 수 있습니다.rio_readlineb, rio_readnb)의 호출과 버퍼 없는 함수(rio_readn)의 호출을 혼용해서는 안 됩니다.rio_readinitb, rio_readlineb, rio_writen을 사용하여 표준 입력에서 표준 출력으로 텍스트 파일을 한 줄씩 복사하는 간단한 예제입니다.#include "csapp.h"
int main(int argc, char **argv)
{
int n;
rio_t rio;
char buf[MAXLINE];
Rio_readinitb(&rio, STDIN_FILENO);
while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
Rio_writen(STDOUT_FILENO, buf, n);
}애플리케이션은 stat와 fstat 함수를 호출하여 파일에 대한 정보 (파일의 메타데이터)를 검색할 수 있습니다.
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 함수: 파일 이름(filename)을 입력으로 받아 struct stat 구조체(buf)의 멤버들을 채웁니다.fstat 함수: stat과 유사하지만, 파일 이름 대신 파일 디스크립터(fd)를 입력으로 받습니다.struct stat 구조체 (Figure 10.9)stat과 fstat 함수는 파일의 메타데이터를 struct stat 구조체에 채워 반환합니다.
struct stat {
dev_t st_dev; /* 장치 */
ino_t st_ino; /* inode */
mode_t st_mode; /* 보호(권한) 및 파일 타입 */
nlink_t st_nlink; /* 하드 링크 수 */
uid_t st_uid; /* 소유자 사용자 ID */
gid_t st_gid; /* 소유자 그룹 ID */
dev_t st_rdev; /* 장치 타입 (inode 장치일 경우) */
off_t st_size; /* 총 크기 (바이트 단위) */
blksize_t st_blksize; /* 파일 시스템 I/O 블록 크기 */
blkcnt_t st_blocks; /* 할당된 블록 수 */
time_t st_atime; /* 마지막 접근 시간 */
time_t st_mtime; /* 마지막 수정 시간 */
time_t st_ctime; /* 마지막 상태 변경 시간 */
};
st_mode와 st_size 멤버가 필요합니다.)st_size: 파일 크기를 바이트 단위로 포함합니다.st_mode: 파일 권한 비트(Figure 10.2)와 파일 타입(10.2절)을 모두 인코딩합니다.리눅스는 st_mode 멤버로부터 파일 타입을 결정하기 위한 매크로 술어(macro predicates)를 sys/stat.h에 정의하고 있습니다.
S_ISREG(m): 일반 파일(regular file)인가?S_ISDIR(m): 디렉터리(directory file)인가?S_ISSOCK(m): 네트워크 소켓(socket)인가?Figure 10.10은 이 매크로들과 stat 함수를 사용하여 파일의 st_mode 비트를 읽고 해석하는 방법을 보여줍니다.
#include "csapp.h"
int main (int argc, char **argv)
{
struct stat stat;
char *type, *readok;
Stat(argv[1], &stat); // stat 함수 호출
if (S_ISREG(stat.st_mode)) /* Determine file type */
type = "regular";
else if (S_ISDIR(stat.st_mode))
type = "directory";
else
type = "other";
if ((stat.st_mode & S_IRUSR)) /* Check read access */
readok = "yes";
else
readok = "no";
printf("type: %s, read: %s\n", type, readok);
exit(0);
}
S_IRUSR)을 가지고 있는지 출력합니다.애플리케이션은 readdir 계열 함수들을 사용하여 디렉터리의 내용을 읽을 수 있습니다.
opendir 함수디렉터리를 열고 스트림 포인터를 얻습니다.
#include <sys/types.h>
#include <dirent.h>
DIR *opendir(const char *name);
DIR *), 오류 시 NULLopendir 함수는 경로 이름(name)을 받아 디렉터리 스트림(directory stream)에 대한 포인터를 반환합니다. 스트림은 항목들의 정렬된 리스트에 대한 추상화이며, 이 경우 디렉터리 항목들의 리스트입니다.
readdir 함수스트림에서 다음 디렉터리 항목을 읽습니다.
#include <dirent.h>
struct dirent *readdir(DIR *dirp);
NULLreaddir를 호출할 때마다 스트림 dirp에서 다음 디렉터리 항목에 대한 포인터를 반환합니다. 더 이상 항목이 없으면 NULL을 반환합니다.
각 디렉터리 항목은 다음과 같은 struct dirent 구조체입니다.
struct dirent {
ino_t d_ino; /* 아이노드 번호 */
char d_name[256]; /* 파일 이름 */
};
d_ino는 파일 위치(아이노드), d_name은 파일 이름입니다.)오류 처리: readdir는 오류 발생 시에도 NULL을 반환하고 errno를 설정합니다. 스트림 끝(end-of-stream) 조건과 오류를 구별하는 유일한 방법은 readdir 호출 이후 errno가 변경되었는지 확인하는 것입니다.
closedir 함수열린 디렉터리 스트림을 닫습니다.
#include <dirent.h>
int closedir(DIR *dirp);
closedir 함수는 스트림을 닫고 관련 리소스를 해제합니다.

Figure 10.11은 opendir, readdir, closedir를 사용하여 디렉터리의 내용(파일 이름)을 읽어 출력하는 방법을 보여줍니다. while 루프를 사용하여 readdir가 NULL을 반환할 때까지 반복하며, 루프 종료 후 errno를 확인하여 오류 발생 여부를 판단합니다.
리눅스 파일은 여러 가지 방식으로 공유될 수 있습니다. 커널이 열린 파일을 어떻게 표현하는지 명확히 이해하지 못하면 파일 공유 개념이 혼란스러울 수 있습니다.
커널은 열린 파일을 표현하기 위해 세 가지 연관된 자료 구조를 사용합니다:
stat 구조체의 대부분의 정보 (예: st_mode, st_size 멤버)를 포함합니다.디스크립터 테이블 (Process-specific)
fork() 호출 시: 자식 프로세스는 부모 프로세스의 디스크립터 테이블을 그대로 복제하여 초기화됩니다.execve() 호출 시: 디스크립터 테이블은 유지됩니다. (단, FD_CLOEXEC 플래그가 설정된 디스크립터는 닫힙니다.)파일 테이블 (System-wide)
v-node 테이블 (System-wide)
- 시점: 시스템 부팅 시입니다.
- 설명: 파일 테이블과 마찬가지로 커널 전역에서 공유되는 테이블(정확히는 캐시)입니다. 커널이 부팅될 때 이 v-node 캐시(또는 inode 캐시) 자료 구조가 초기화됩니다. 이 역시 처음에는 비어 있으며, 파일에 접근할 때 동적으로 채워집니다.
데이터 추가는 주로 파일을 열거나(open) 새로운 I/O 채널을 만들 때 발생합니다.
디스크립터 테이블
open(), pipe(), socket(), accept(), dup() 등의 함수를 호출하면, 커널은 다음을 수행합니다.파일 테이블
open() 시스템 콜이 호출될 때입니다.open()을 호출하면, 커널은 새로운 파일 테이블 항목을 생성합니다.fork()는 파일 테이블에 새 항목을 추가하지 않습니다. 대신 기존 항목의 참조 카운트(ref count)를 증가시킵니다. dup()도 마찬가지입니다.v-node 테이블
- 시점: 파일 시스템의 파일에 처음 접근할 때 (캐시 미스 발생 시) 입니다.
- 설명: 이 테이블은 캐시(cache)처럼 동작합니다.
1. 프로세스가 open() 등으로 특정 파일(예: /path/to/file.txt)에 접근을 시도합니다.
2. 커널은 이 파일의 메타데이터(inode)가 v-node 테이블(캐시)에 이미 있는지 확인합니다.
3. 만약 없다면 (Cache Miss): 커널은 디스크에서 해당 파일의 inode를 읽어와, 새로운 v-node 테이블 항목을 생성하여 그 정보를 채워 넣습니다.
4. 만약 있다면 (Cache Hit): 디스크를 읽지 않고 캐시된 v-node 항목을 즉시 재사용합니다.
사용자가 open("file.txt", ...)을 호출하면 다음과 같은 일이 발생합니다 (가장 일반적인 시나리오):
open() 함수는 3번 슬롯의 인덱스인 3을 반환합니다.핵심 역할: 프로세스 수준의 추상화 (Process-level Abstraction)
이 테이블의 존재 이유는 "프로세스가 커널의 복잡한 내부 구조를 몰라도 되게 하자"입니다.
프로세스가 파일을 다룰 때 사용하는 간단한 정수(파일 디스크립터, 예: 0, 1, 3...)를 제공합니다.
프로세스 내부에서 사용하는 이 FD 정수(인덱스)를, 커널의 전역적인 '파일 테이블'의 특정 항목과 1:1로 연결(mapping)하는 역할을 합니다.
이 테이블은 프로세스마다 독립적으로 존재하기 때문에, A 프로세스의 3번 FD와 B 프로세스의 3번 FD는 서로 다른 파일을 가리킬 수 있습니다.
요약: 프로세스에게 간단한 정수 핸들(FD)을 제공하고, 이 핸들을 실제 '열린 파일'로 변환해주는 프로세스 전용 안내 데스크입니다.
핵심 역할: 파일 열기 '세션' 또는 '인스턴스' 관리 (Session/Instance Management)
이 테이블은 "파일이 어떻게 열렸고, 어디까지 읽었는가"를 관리합니다.
가장 중요한 역할은 '파일 위치(file position/offset)'를 저장하는 것입니다. 즉, 파일의 몇 바이트 지점을 읽거나 쓸 차례인지를 기록합니다.
open()이 호출될 때마다 생성되는, 파일 열기 자체의 상태(state)를 나타냅니다. (예: O_APPEND, O_RDWR 같은 열기 모드, 상태 플래그 등)
이 테이블 항목이 공유되면(fork()나 dup() 등으로), 파일 위치(offset)도 공유됩니다.
참조 카운트(reference count)를 통해, 자신을 가리키는 디스크립터가 몇 개인지 추적하여 언제 자신을 메모리에서 해제할지 결정합니다.
요약: 특정 파일의 '열린 상태'를 관리하는 책갈피입니다. 파일의 어느 페이지를 읽고 있었는지(offset)를 기억합니다. open()을 할 때마다 새 책갈피가 생긴다고 볼 수 있습니다.
핵심 역할: 파일의 '메타데이터'와 '실체' 관리 (Metadata & File System Abstraction)
이 테이블은 "파일 그 자체가 무엇인가"를 정의합니다.
파일 시스템에 존재하는 파일 그 자체(디스크 상의 inode 정보)를 커널 메모리 상에 표현합니다.
파일 유형, 권한, 크기, 소유자, 타임스탬프 등 stat 구조체에 담기는 모든 메타데이터의 원본을 저장합니다.
파일 시스템의 종류(ext4, XFS, NTFS 등)에 관계없이 커널이 파일을 일관되게 다룰 수 있도록 하는 파일 시스템 추상화 계층의 핵심입니다.
모든 프로세스가 공유하며, 디스크 I/O를 줄이기 위한 캐시(cache)로 동작합니다. (한번 읽어온 inode 정보는 메모리에 유지)
요약: 도서관의 원장(ledger) 또는 색인 카드입니다. 파일(책)의 고유 정보(제목, 저자, 크기, 위치)를 담고 있으며, 이 정보가 필요한 모든 사람(프로세스)이 공유합니다.
이 세 테이블은 다음과 같은 계층 구조로 동작합니다.
[프로세스] ➔ FD (디스크립터 테이블) ➔ 파일 위치 (파일 테이블) ➔ 파일 메타데이터 (v-node 테이블) ➔ [디스크]


open 함수를 동일한 파일 이름으로 두 번 호출하면 이와 같은 상황이 발생할 수 있습니다.
fork 호출 전, 부모 프로세스가 (그림 10.12)와 같이 파일을 열고 있었다고 가정합니다.fork 호출 후, 자식은 부모의 디스크립터 테이블의 복사본을 얻습니다.리눅스 쉘은 사용자가 표준 입력(standard input)과 표준 출력(standard output)을 디스크 파일과 연결할 수 있도록 입출력 리디렉션(I/O redirection) 연산자를 제공합니다.
예를 들어, linux> ls > foo.txt를 입력하면, 쉘은 ls 프로그램을 로드하고 실행하며, 이때 표준 출력이 디스크 파일 foo.txt로 리디렉션됩니다. (11.5절에서 보겠지만) 웹 서버가 클라이언트를 대신해 CGI 프로그램을 실행할 때도 이와 유사한 리디렉션을 수행합니다.
그렇다면 입출력 리디렉션은 어떻게 작동할까요? 한 가지 방법은 dup2 함수를 사용하는 것입니다.
#include <unistd.h>
int dup2(int oldfd, int newfd);
dup2 함수는 디스크립터 테이블 항목 oldfd를 디스크립터 테이블 항목 newfd로 복사합니다. 이 과정에서 newfd의 이전 내용은 덮어씌워집니다. 만약 newfd가 이미 열려 있었다면, dup2는 oldfd를 복사하기 전에 newfd를 먼저 닫습니다.
dup2(4, 1)을 호출하는 상황을 가정해 보겠습니다.

dup2(4, 1) 호출newfd (즉, 디스크립터 1)을 먼저 닫습니다. 이로 인해 파일 A의 파일 테이블 참조 카운트가 0이 되어, 파일 A의 파일 테이블 항목과 v-node 테이블 항목이 삭제됩니다.oldfd (즉, 디스크립터 4)의 내용을 newfd (디스크립터 1)로 복사합니다.
C 언어는 표준 입출력 라이브러리(Standard I/O library)라고 불리는 고수준 입출력 함수 집합을 정의하며, 이는 프로그래머에게 Unix I/O의 고수준 대안을 제공합니다.
이 라이브러리(libc)는 다음과 같은 함수들을 제공합니다.
fopen, fclosefread, fwritefgets, fputsscanf, printf표준 I/O 라이브러리는 열린 파일을 스트림(stream)으로 모델링합니다. 프로그래머에게 스트림은 FILE 타입의 구조체 포인터입니다.
모든 ANSI C 프로그램은 3개의 열린 스트림으로 시작하며, 이는 각각 표준 입력, 표준 출력, 표준 에러에 해당합니다:
#include <stdio.h>
extern FILE *stdin; /* 표준 입력 (디스크립터 0) */
extern FILE *stdout; /* 표준 출력 (디스크립터 1) */
extern FILE *stderr; /* 표준 에러 (디스크립터 2) */
FILE 타입의 스트림은 파일 디스크립터(file descriptor)와 스트림 버퍼(stream buffer)에 대한 추상화입니다.
스트림 버퍼의 목적은 비용이 많이 드는 리눅스 I/O 시스템 콜의 횟수를 최소화하는 것입니다.
getc를 반복 호출하는 프로그램이 있다고 가정해 봅시다.getc가 처음 호출되면, 표준 라이브러리는 read 시스템 콜을 한 번 호출하여 스트림 버퍼를 가득 채웁니다. 그리고 버퍼의 첫 번째 바이트를 애플리케이션에 반환합니다.getc 호출은 시스템 콜을 다시 호출하지 않고, 메모리에 있는 스트림 버퍼에서 직접 데이터를 가져와 처리됩니다.이 장에서 논의한 다양한 I/O 패키지들이 [그림 10.16]에 요약되어 있습니다.
open, close, lseek, read, write, stat 같은 함수를 통해 애플리케이션에 제공됩니다.read/write의 견고한(robust) 래퍼(wrapper)입니다. 'Short count'(부분 읽기/쓰기)를 자동으로 처리하며, 텍스트 라인을 읽기 위한 효율적인 버퍼링 접근법을 제공합니다.printf, scanf와 같은 서식화된 I/O 루틴을 포함합니다.그렇다면 프로그램에서 어떤 함수를 사용해야 할까요? 기본적인 가이드라인은 다음과 같습니다.
stat 정도를 제외하고는) 저수준 Unix I/O 함수를 신경 쓰지 않고 표준 I/O만 독점적으로 사용합니다.scanf나 rio_readlineb를 사용하지 말라.scanf나 rio_readlineb 같은 함수들은 특별히 텍스트 파일을 읽도록 설계되었습니다.0xa (개행) 바이트가 마구 흩어져 있을 수 있습니다.)fflush, fseek, fsetpos, rewind 중 하나를 중간에 호출하지 않고는 입력 함수를 호출할 수 없습니다.fseek, fsetpos, rewind 중 하나를 호출하지 않고는 출력 함수를 호출할 수 없습니다.fseek)은 Unix I/O의 lseek 함수를 사용합니다. 하지만 소켓에 lseek를 사용하는 것은 불법(illegal)입니다.fflush를 호출하는 습관으로 해결할 수 있습니다.)fdopen)가 있습니다.FILE *fpin, *fpout;
fpin = fdopen(sockfd, "r");
fpout = fdopen(sockfd, "w");fclose(fpin)와 fclose(fpout)를 둘 다 호출해야 한다는 문제가 있습니다. 각 fclose는 동일한 소켓 디스크립터를 닫으려고 시도하므로, 두 번째 close 연산은 실패할 것입니다. 이것은 스레드 프로그램에서 재앙을 초래할 수 있습니다.sprintf를 사용해 메모리에 문자열을 포맷팅한 다음, rio_writen으로 소켓에 전송하십시오.rio_readlineb로 텍스트 한 줄을 통째로 읽어들인 다음, sscanf를 사용해 그 줄에서 여러 필드를 추출하십시오.
리눅스는 Unix I/O 모델을 기반으로 하는 소수의 시스템 수준 함수를 제공하며, 이는 애플리케이션이 파일을 열고, 닫고, 읽고, 쓰며, 파일 메타데이터를 가져오고, I/O 리디렉션을 수행할 수 있게 합니다.
read 및 write 연산은 'short count'(부분 읽기/쓰기)가 발생할 수 있으며, 애플리케이션은 이를 예상하고 올바르게 처리해야 합니다.read/write 연산을 반복 수행함으로써 'short count'를 자동으로 처리해 줍니다.리눅스 커널은 열린 파일을 표현하기 위해 세 가지 연관된 자료 구조(디스크립터 테이블, 파일 테이블, v-node 테이블)를 사용합니다.