파일 시스템과 I/O 개념
File Table (OS)
운영체제는 각 프로세스의 열려있는 파일들을 관리하기 위한 파일 테이블을 유지한다. 파일의 인덱스는 실제 파일명이 아니라 File Descriptor (fd)로 관리된다.
각 파일 테이블의 엔트리는 다음과 같이 구성된다.
- inode
- file offset (파일 위치)
- Access modes
이 포스트에서는 Unix IO의 파일 입출력 관련 System calls를 소개한 후, standard IO 라이브러리와 함수들을 소개 후, Unix와 standard IO를 비교하면서 장단점을 정리합니다.
File Descriptor (fds)
File Descriptor(fds)는 0부터 시작하는 정수 값이며, 실행 중인 프로세스가 파일을 열 때 부여된다. 하나의 프로세스 내에서만 유니크하며, C에서는 int 타입으로 다룬다.
예약된 File Descriptor:
- 0 : 표준 입력 (stdin, 키보드 입력)
- 1 : 표준 출력 (stdout, 화면 출력)
- 2 : 표준 에러 (stderr, 에러 메시지 출력)
Unix File IO 시스템 콜
파일 열기 (Opening Files)
int open (const char *name, int flags)
int open (const char *name, int flags, mode_t mode)
Flags (필수 & 선택 옵션)
- 필수 플래그 (하나 선택)
- O_RDONLY : 읽기 전용
- O_WRONLY : 쓰기 전용
- O_RDWR : 읽기/쓰기 가능
- 선택적 플래그 (비트 OR 조합 가능)
- O_APPEND : 파일 끝에 데이터 추가
- O_CREAT : 파일이 없으면 새로 생성
- O_TRUNC : 기존 파일 내용을 비움
파일 생성 시 권한 (mode)
- S_IRWXU : 사용자 읽기, 쓰기, 실행 (7)
- S_IRUSR : 사용자 읽기 (4)
- S_IWUSR : 사용자 쓰기 (2)
- S_IXUSR : 사용자 실행 (1)
creat() 함수
int creat(const char *name, mode_t mode)
open(file, O_WRONLY|O_CREAT|O_TRUNC, mode)
파일 생성 시 creat()를 사용할 수도 있으며, open()과 동일한 역할을 한다.
파일 읽기 (Reading Files)
ssize_t read(int fd, void *buf, size_t len);
- 성공 시 반환값: 읽은 바이트 수 (0이면 EOF)
- 실패 시 반환값: -1 (errno로 오류 확인 가능)
전체 파일 읽기 예제
ssize_t ret;
while(len != 0 && (ret = read(fd, buf, len)) != 0) {
if (ret == -1) {
perror("read");
break;
}
len -= ret;
buf += ret;
}
파일 쓰기 (Writing Files)
ssize_t write (int fd, const void *buf, size_t count)
- 성공 시 반환값: 실제 기록된 바이트 수
- 실패 시 반환값: -1
파일 오프셋 (File Offset)
- write() 호출 후, 기록된 바이트 수만큼 파일 오프셋이 증가한다.
- 같은 파일 디스크립터를 사용하는 다른 함수(read, lseek)에도 영향을 미친다.
지연된 쓰기 (Delayed Write) - 중요!
Linux 커널은 쓰기 작업의 성능을 향상시키기 위해 데이터를 직접 디스크에 기록하지 않고, 먼저 Page Cache에 저장한 후 비동기적으로 기록하는 방식을 사용한다.
- 데이터 복사
- write() 호출 시, 데이터는 페이지 캐시(Page Cache)에 먼저 저장된다.
- 이 시점에서 write() 호출은 성공한 것으로 간주되며, 프로세스는 즉시 반환된다.
- 지연된 기록
- 커널은 데이터를 바로 디스크에 쓰지 않고, Dirty Buffer 상태로 유지한다.
- Dirty Buffer는 디스크에 동기화되지 않은 데이터를 포함하는 버퍼이다.
- 백그라운드 작업
- 커널은 Dirty Buffer를 일정 시간 수집한 후, 최적화된 순서로 배치 및 병합하여 디스크에 기록한다.
- 이를 통해 I/O 작업 수를 줄이고, 디스크 접근 속도를 최적화한다
파일 강제 동기화 (Sync I/O)
int fsync(int fd);
int fdatasync(int fd);
파일을 디스크에 즉시 기록하도록 강제한다.
파일 닫기 (Closing Files)
int close(int fd);
- 파일을 닫고, 프로세스와 파일의 연결을 해제한다.
- 파일이 닫혔다고 해서 데이터가 즉시 디스크에 기록되는 것은 아니다.
Unix I/O 파일 읽고 쓰기 예제
#define BUF_SIZE 1024
int main(){
char str[BUF_SIZE], read_buf[BUF_SIZE];
size_t len;
ssize_t ret;
int fd;
char *bufp = read_buf;
printf("input: ");
if (fgets(str, BUF_SIZE, stdin) == NULL){
perror("fgets");
exit(1);
}
len = strlen(str);
if (fork() == 0){
fd = open("file1.txt", O_CREAT|O_WRONLY|O_TRUNC, S_IRUSR|S_IWUSR);
if (fd < 0){
perror("open");
exit(1);
}
if (write(fd, str, len) < 0){
perror("write");
close(fd);
exit(1);
}
} else {
wait(NULL);
fd = open("file1.txt", O_RDONLY);
if (fd < 0){
perror("open");
exit(1);
}
while(len){
ret = read(fd, bufp, len);
if (ret < 0){
perror("read");
close(fd);
exit(1);
}
len -= ret;
bufp += ret;
}
*bufp = '\0';
printf("output: %s\n", read_buf);
}
close(fd);
return 0;
}
파일 디스크립터 복제 (file sharing)
dup(int oldfd) 함수는 기존 파일 디스크립터(oldfd)를 복제하여 새로운 파일 디스크립터를 생성한다. 새로 생성된 파일 디스크립터는 사용 가능한 가장 작은 번호를 반환하며, 오류가 발생하면 -1을 반환한다.
만약 특정 예약된 번호(예: 0, 1, 2)를 사용하려면 먼저 해당 fd를 close(fd)를 통해 닫아야 한다.
int dup(int oldfd);
dup2(int oldfd, int newfd) 함수는 dup()과 유사하지만, 복제된 fd를 newfd 값에 맞춰 생성한다. 만약 newfd가 이미 열려 있다면 자동으로 close(newfd)를 수행한 후 oldfd를 복제한다.
int dup2(int oldfd, int newfd);
이러한 dup 함수들은 기존 파일 디스크립터와 같은 Open(Global) File Table을 가리키므로 ref++가 증가하고, 파일 오프셋이 공유된다.
dup() 함수 예제
아래 코드를 실행하면 dup()을 통해 복제된 파일 디스크립터가 같은 Global File Table을 가리킨다는 것을 확인할 수 있다.
int main(){
int fd1, fd2;
char c;
fd1 = open("file1.txt", O_RDONLY);
if(fd1 == -1){
perror("open");
exit(1);
}
read(fd1, &c, 1);
fd2 = dup(fd1);
read(fd2, &c, 1);
printf("c: %c\n", c);
return 0;
}
예제 실행 결과 (file1.txt 내용: "konkuk")
c: o
첫 번째 read(fd1, &c, 1); 실행 후 offset이 1 증가하여 fd2에서 읽을 때 "o"가 출력되었다. 즉, fd1과 fd2가 같은 Global File Table을 공유하기 때문에 offset이 함께 이동한다.
Shared File Descriptor (공유된 파일 디스크립터)
fork()를 호출하면 부모 프로세스의 파일 디스크립터 테이블이 그대로 복사되며, 같은 Global File Table을 공유하게 된다. 이때, 참조 카운트(ref++)가 증가한다.
File Sharing (파일 공유)
파일이 열리는 방식에 따라 같은 파일이라도 서로 다른 Global File Table을 가리킬 수 있다.
open()을 두 번 호출할 경우
dup()이 아니라 open()을 두 번 호출하면, 같은 파일을 열더라도 서로 다른 Global File Table을 가리킨다. 이 경우 같은 파일을 가리키더라도 파일 오프셋이 공유되지 않는다. (결국 같은 v-node를 가리킴, 같은 파일의 entry)
코드 예제
int main(){
int fd1, fd2;
char c;
fd1 = open("file1.txt", O_RDONLY);
fd2 = open("file1.txt", O_RDONLY);
if(fd1 == -1 || fd2 == -1){
perror("open");
exit(1);
}
read(fd1, &c, 1);
read(fd2, &c, 1);
printf("c: %c\n", c);
return 0;
}
첫 번째 read(fd1, &c, 1);이 실행된 후에도 fd2의 offset이 그대로 유지되어 k가 출력되었다. 즉, fd1과 fd2가 서로 다른 Global File Table을 가리키므로 offset을 공유하지 않는다.
서로 다른 두 파일에서 fork()이 불릴경우
fork()를 호출하면 부모 프로세스의 Open File Table이 복사되며, 같은 Global File Table을 공유한다. 따라서 파일 오프셋과 ref count가 함께 공유된다.
fork() 호출 전
부모 프로세스가 두 개의 파일 디스크립터를 보유
fork() 호출 후
부모와 자식이 같은 Global File Table을 공유
Standard I/O 라이브러리
Unix IO를 기반으로 구현된 Standard IO (stdio) 라이브러리는 사용자 레벨에서 효율적인 입출력을 제공한다. 커널과의 불필요한 context-switching을 줄여 성능을 최적화하며, 버퍼링(buffering) 기법을 활용하여 시스템 호출(system calls)의 빈도를 최소화한다.
C 표준 라이브러리(libc.so)는 높은 수준의 표준 I/O 함수들을 제공한다.
- fopen / fclose
- fread / fwrite
- fgets / fputs
- fprintf / fscanf
fflush() 호출 시 즉시 플러시
printf("Hello, ");
fflush(stdout);
Standard IO의 성능 최적화
1. 데이터 정렬(Alignment)
- I/O 요청이 블록 크기(block size)에 맞게 정렬될 경우 성능이 향상된다. (예: 4KB 페이지 단위)
- 정렬된 요청은 하드웨어가 데이터를 더 빠르게 접근하고 처리할 수 있도록 돕는다.
2. System Call 최소화
- 시스템 호출(system calls)은 컨텍스트 스위칭을 발생시켜 성능 저하를 초래한다.
- 1,024바이트를 한 번에 읽는 것이 1바이트를 1,024번 읽는 것보다 훨씬 효율적이다.
3. User-Buffered IO
- 사용자 영역(User Space)에서 데이터를 버퍼링하여, 커널과의 불필요한 데이터 전송을 최소화한다.
- stdio 라이브러리는 플랫폼 독립적인 유저 버퍼 솔루션을 제공한다.
File Pointers
Standard IO에서는 파일 디스크립터(File Descriptor, fd) 대신 *파일 포인터(FILE)**를 사용한다.
FILE *stdin;
FILE *stdout;
FILE *stderr;
파일 및 스트림 관리
파일 열기(Open)
FILE * fopen (const char *path, const char *mode);
FILE * fdopen (int fd, const char *mode);
mode 설명
r | 읽기 전용 |
w | 쓰기 전용(기존 파일 내용 삭제) |
r+ | 읽기/쓰기 |
w+ | 읽기/쓰기(기존 파일 내용 삭제) |
a | 추가(쓰기) |
a+ | 추가(읽기/쓰기) |
파일 읽기(Read)
int fgetc(FILE *stream); // 한 글자 읽기
char * fgets(char *str, int size, FILE *stream); // 문자열 읽기
size_t fread(void *buf, size_t size, size_t nr, FILE *stream); // 바이너리 데이터 읽기
- fread()는 size 크기의 요소를 nr개 읽고, 읽은 개수를 반환한다.
파일 쓰기(Write)
int fputc(int c, FILE *stream); // 한 글자 쓰기
int fputs(const char *str, FILE *stream); // 문자열 쓰기
size_t fwrite(void *buf, size_t size, size_t nr, FILE *stream); // 바이너리 데이터 쓰기
- fwrite()는 size 크기의 요소를 nr개 기록하며, 성공적으로 기록한 요소 개수를 반환한다.
버퍼 플러싱(Flushing)
int fflush(FILE *stream); // 버퍼를 강제로 비우고 데이터를 즉시 출력
버퍼링된 데이터를 즉시 커널 버퍼로 이동시키며, 시스템 호출(write())을 수행한다.
스트림 탐색(Seeking)
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
whence 값
SEEK_SET | 파일 시작 위치 기준 offset 설정 |
SEEK_CUR | 현재 위치에서 offset 만큼 이동 |
SEEK_END | 파일 끝에서 offset 만큼 이동 |
파일 닫기(Close)
int fclose(FILE *stream);
int fcloseall(void); // 모든 스트림 닫기
Standard IO 예제 코드
#include <stdio.h>
#include <stdlib.h>
int main(){
FILE *in, *out;
struct univ{
char name[100];
unsigned int year;
} u, my_univ = {"Konkuk Univ", 1931};
// 파일 쓰기
out = fopen("data", "w");
if(!out){ perror("fopen"); exit(1); }
if(!fwrite(&my_univ, sizeof(struct univ), 1, out)){ perror("fwrite"); exit(1); }
fclose(out);
// 파일 읽기
in = fopen("data", "r");
if(!in){ perror("fopen"); exit(1); }
if(!fread(&u, sizeof(struct univ), 1, in)){ perror("fread"); exit(1); }
fclose(in);
printf("University name: %s\n", u.name);
printf("University year: %d\n", u.year);
return 0;
}
이 코드는 구조체 데이터를 파일에 저장하고 다시 읽어오는 예제로, fwrite()와 fread()를 활용한다.
Unix I/O vs Standard I/O
- 저수준(Unix I/O) : Page Cache를 직접 사용 (빠르지만 비효율적일 수 있음)
- 고수준(Standard I/O) : 버퍼를 활용하여 성능 향상
- Standard IO는 Unix IO보다 사용자 친화적인 인터페이스와 버퍼링 기능을 제공하여 성능을 최적화한다.
- fopen(), fread(), fwrite(), fflush() 등의 함수를 활용하면 파일을 쉽게 조작할 수 있다.
standard는 버퍼링을 활용해서 최적화를 하지만,
내용을 저장할때 stdio버퍼(user buffer) -> OS 버퍼 -> DISK 쓰기 과정을 거치고
내용을 읽어들일때 DISK -> OS버퍼 -> stdio버퍼(user buffer) 로 복사의 과정을 거치며 page 단위로 복사가 되므로 4KB씩 복사하는 오버헤드가 있다.
위 처럼 tradeoff가 존재하기 때문에 상황에 따라서 standard가 아닌 system call(Unix IO)를 쓰는 경우도 있다
'CS 지식 > 시스템 프로그래밍' 카테고리의 다른 글
[C언어] 메모리 매핑 입출력(Memory Mapped I/O) 관련 시스템 콜(System call) (0) | 2025.03.02 |
---|---|
[C언어] 다중화된 입출력(Multiplexed IO) 관련 시스템 콜(System call) (0) | 2025.03.02 |
[C언어] 리눅스 파일 시스템과 file table의 구조(inode, offset등) (0) | 2025.03.02 |
[C언어] pthread 라이브러리를 이용해서 스레드에게 signal 보내기 (0) | 2025.01.28 |
[C언어] Deadlock(교착상태) 원인과 해결법 (0) | 2025.01.28 |
댓글