유닉스/리눅스 시스템에서 "모든 것은 파일"이라는 철학에 따라, 파일뿐만 아니라 디바이스, 네트워크 소켓 등 모든 I/O 작업이 파일 인터페이스를 통해 이루어진다.
다음은 Low Level과 High Level 파일 I/O의 차이점과 특징을 정리한 내용이다.
Low Level File I/O
기본 개념
Low Level File I/O는 운영체제가 직접 제공하는 시스템 콜을 사용한 파일 입출력 방식이다. File은 입출력이 되는 모든 것을 의미하며, 일반적인 파일뿐만 아니라 디바이스, 파이프, 소켓 등도 포함한다.
동작 흐름
open() → fd 할당 → 파일 사용 → close()일반적으로 fd = open() 처럼 한 줄에 작성하여 파일 디스크립터를 할당받는다.
파일 디스크립터(File Descriptor)
파일 디스크립터는 열린 파일을 식별하는 정수 값이다. 운영체제는 각 프로세스마다 파일 디스크립터 테이블을 관리한다.
📌 기본 파일 디스크립터
- 0: 표준 입력 (stdin)
- 1: 표준 출력 (stdout)
- 2: 표준 오류 출력 (stderr)
- 3부터: 사용자가 열린 파일들
새로운 파일을 열면 사용 가능한 가장 작은 번호가 할당된다.
open() 함수와 플래그
기본 문법:
int open(const char *pathname, int flags, mode_t mode);📌 주요 플래그
| 플래그 | 설명 |
|---|---|
| O_RDONLY | 파일을 읽기 전용으로 연다 |
| O_WRONLY | 파일을 쓰기 전용으로 연다 |
| O_RDWR | 파일을 읽기/쓰기용으로 연다 |
| O_CREAT | 파일이 없으면 생성한다. 파일을 생성할 권한을 담당한 옵션이 있어야 한다. 파일이 이미 있다면 아무 의미 없다 |
| O_EXCL | O_CREAT 옵션과 함께 사용할 경우 기존 파일이면 파일을 생성하고, 이미 있으면 파일을 생성하지 않고 오류 메시지를 출력한다 |
| O_APPEND | 이 옵션을 지정하면 파일의 맨 끝에 내용을 추가한다 |
| O_TRUNC | 파일을 생성할 때 이미 있는 파일이고 쓰기 옵션으로 열었으면 내용을 모두 지우고 파일 길이를 0으로 변경한다 |
O_TRUNC 플래그
- 파일이 존재하는 경우 → 파일 내용 모두 삭제(빈 파일이 됨)
- 파일이 존재하지 않는 경우 → 아무런 효과 없음
- 그래서 보통 O_CREAT와 함께 사용함
📌 플래그 조합
// 파일 생성 및 쓰기 전용
fd = open("test.txt", O_CREAT | O_WRONLY, 0644);
// 배타적 생성 (파일이 이미 있으면 실패)
fd = open("test.txt", O_CREAT | O_EXCL, 0644);
// 읽기/쓰기 모드로 열기
fd = open("test.txt", O_RDWR);Low Level I/O 함수들
read() 함수
ssize_t read(int fd, void *buf, size_t count);특징:
- 파일에 저장된 데이터 상관없이 바이트 단위로 읽는다
- 리턴 값이 0이면 파일 끝에 도달했다는 의미다
- 파일을 열면 읽을 위치를 나타내는 오프셋이 파일의 시작을 가리키고, read() 함수를 실행할 때마다 읽은 크기만큼 오프셋이 이동한다
예시:
int fd, n;
char buf[10];
fd = open("linux.txt", O_RDONLY);
n = read(fd, buf, 5); // 5바이트 읽기
buf[n] = '\0';
printf("n = %d, buf = %s\n", n, buf); // n = 5, buf = "Linux"write() 함수
ssize_t write(int fd, const void *buf, size_t count);파일에 버퍼에 담긴 데이터를 N 바이트만큼 출력하는 함수다. 하나의 파일에서 파일 오프셋은 하나이므로, 파일을 읽기/쓰기 모드로 열었을 때 파일 읽기 오프셋과 쓰기 오프셋은 공유된다.
파일 복사 예시:
int rfd, wfd, n;
char buf[10];
rfd = open("linux.txt", O_RDONLY);
wfd = open("linux.bak", O_CREAT | O_WRONLY | O_TRUNC, 0644);
while ((n = read(rfd, buf, 6)) > 0) {
if (write(wfd, buf, n) != n) {
perror("Write");
exit(1);
}
}lseek() 함수
off_t lseek(int fd, off_t offset, int whence);파일 디스크립터 오프셋을 제어하는 함수다. fd 파일에서 whence 기준에서 offset 크기만큼 이동한다.
📌 whence 옵션
| 값 | 설명 |
|---|---|
| SEEK_SET | 파일의 시작을 기준으로 계산 |
| SEEK_CUR | 현재 위치를 기준으로 계산 |
| SEEK_END | 파일의 끝을 기준으로 계산 |
예시:
off_t start, cur;
start = lseek(fd, 0, SEEK_CUR); // 현재 위치 확인
start = lseek(fd, 6, SEEK_SET); // 파일 시작에서 6바이트 이동파일 디스크립터 조작
dup() 함수
int dup(int oldfd);파일 디스크립터를 복사하는 함수다. 사용 가능한 가장 작은 파일 디스크립터 번호를 할당한다.
표준 출력 리다이렉션 예시:
int fd, fd1;
fd = open("tmp.aaa", O_CREAT | O_WRONLY | O_TRUNC, 0644);
close(1); // 표준 출력 닫기
fd1 = dup(fd); // fd1 = 1 (표준 출력 자리를 차지)
printf("Standard Output Redirection\n"); // 파일로 출력됨dup2() 함수
int dup2(int oldfd, int newfd);dup()과 달리 새로운 파일 디스크립터를 지정할 수 있다.
int fd = open("tmp.bbb", O_CREAT | O_WRONLY | O_TRUNC, 0644);
dup2(fd, 1); // fd를 표준 출력(1)으로 복사
printf("DUP2: Standard Output Redirection\n");fcntl() 함수
int fcntl(int fd, int cmd, ...);File Descriptor Control의 약자로, 파일 디스크립터의 속성을 확인하고 제어하는 함수다.
주요 명령어:
F_GETFL: 플래그 정보를 읽어온다F_SETFL: 플래그 정보를 설정한다
예시 - O_APPEND 플래그 추가:
int flags;
flags = fcntl(fd, F_GETFL); // 현재 플래그 읽기
flags |= O_APPEND; // O_APPEND 플래그 추가
fcntl(fd, F_SETFL, flags); // 플래그 설정High Level File I/O
기본 개념
High Level File I/O는 C 표준 라이브러리에서 제공하는 파일 입출력 방식이다. Low Level I/O를 감싸서 더 편리하고 효율적인 인터페이스를 제공한다.
동작 흐름
fopen() → 파일 사용 → fclose()File Pointer vs File Descriptor
- 파일 디스크립터: 정수형, 운영체제가 직접 관리
- 파일 포인터:
FILE *형, 디스크에서 메모리에 로드된 파일(FILE 구조체)의 위치 주소 정보를 담은 포인터
FILE 구조체 특징:
- 파일 디스크립터를 포함함
- 포함된 파일 디스크립터를 이용해 파일 포인터와 파일 디스크립터 사이의 변환이 가능
- 플랫폼 독립적인 구조 = 어느 플랫폼에서든 동일하게 동작
fopen() 함수
FILE *fopen(const char *pathname, const char *mode);파일 열기 모드:
| 모드 | 의미 |
|---|---|
| r | 읽기 전용으로 텍스트 파일을 연다 |
| w | 새로 쓰기용으로 텍스트 파일을 연다. 기존 내용은 삭제된다 |
| a | 기존 내용의 끝에 추가해서 쓰기용으로 텍스트 파일을 연다 |
| rb | 읽기 전용으로 바이너리 파일을 연다 |
| wb | 새로 쓰기용으로 바이너리 파일을 연다. 기존 내용은 삭제된다 |
| ab | 추가해서 쓰기용으로 바이너리 파일을 연다 |
| r+ | 읽기와 쓰기용으로 텍스트 파일을 연다 |
| w+ | 쓰기와 읽기용으로 텍스트 파일을 연다 |
| a+ | 추가 쓰기와 읽기용으로 텍스트 파일을 연다 |
문자 기반 입출력 함수
입력 함수들
// 파일에서 문자 하나 읽기
int fgetc(FILE *fp);
int getc(FILE *fp); // fgetc와 동일
int getchar(void); // 표준 입력에서 문자 읽기
// 워드 단위로 읽기 (int 크기)
int getw(FILE *fp);출력 함수들
// 파일에 문자 하나 쓰기
int fputc(int c, FILE *fp);
int putc(int c, FILE *fp); // fputc와 동일
int putchar(int c); // 표준 출력에 문자 쓰기
// 워드 단위로 쓰기 (int 크기)
int putw(int w, FILE *fp);파일 복사 예시:
FILE *rfp, *wfp;
int c;
rfp = fopen("linux.txt", "r");
wfp = fopen("linux.out", "w");
while((c = fgetc(rfp)) != EOF) {
fputc(c, wfp);
}문자열 기반 입출력 함수
gets() vs fgets()
// 위험한 함수 - 사용 금지
char *gets(char *s);
// 안전한 함수 - 권장
char *fgets(char *s, int size, FILE *fp);gets()의 문제점:
- 버퍼 크기를 알 수 없어 버퍼 오버플로우 위험
- 보안상 위험하므로 사용하지 않음
fgets() 특징:
- 최대 size-1개의 문자를 읽음
- 개행 문자('\n')도 포함하여 저장
- 버퍼의 마지막에 널 문자('\0') 추가
puts() vs fputs()
// 표준 출력에 문자열 출력 (개행 문자 자동 추가)
int puts(const char *s);
// 파일에 문자열 출력 (개행 문자 추가하지 않음)
int fputs(const char *s, FILE *fp);버퍼 기반 입출력 함수
fread() 함수
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *fp);매개변수:
ptr: 데이터를 저장할 버퍼 주소size: 각 항목의 크기nmemb: 읽을 항목 수 (number of members)fp: 파일 포인터
정리하면: fp 파일에서 size * nmemb 만큼 ptr 버퍼에 저장
예시:
char buf[BUFSIZ];
int n;
// 2바이트 * 4 = 8바이트씩 읽기
n = fread(buf, sizeof(char) * 2, 4, fp);fwrite() 함수
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *fp);size * nmemb 만큼 ptr 버퍼에서 읽어와 fp 파일에 출력한다.
형식 기반 입출력 함수
scanf 계열
// 표준 입력에서 형식화된 입력
int scanf(const char *format, ...);
// 파일에서 형식화된 입력
int fscanf(FILE *fp, const char *format, ...);학생 성적 처리 예시:
FILE *fp = fopen("linux.dat", "r");
int id, s1, s2, s3, s4;
while(fscanf(fp, "%d %d %d %d %d", &id, &s1, &s2, &s3, &s4) != EOF) {
printf("%d\t%d\n", id, (s1 + s2 + s3 + s4) / 4);
}printf 계열
// 표준 출력에 형식화된 출력
int printf(const char *format, ...);
// 파일에 형식화된 출력
int fprintf(FILE *fp, const char *format, ...);파일 오프셋 제어
fseek() 함수
int fseek(FILE *fp, long offset, int whence);Low Level의 lseek()와 유사하지만:
- lseek(): 성공하면 변경된 오프셋을 리턴
- fseek(): 성공하면 0, 실패하면 -1(EOF)를 리턴
ftell() 함수
long ftell(FILE *fp);현재 오프셋을 리턴한다. fseek()와 달리 현재 위치를 구할 때 사용한다.
long cur = ftell(fp); // 현재 위치 확인rewind() 함수
void rewind(FILE *fp);파일의 오프셋 위치를 파일의 시작으로 즉시 이동시킨다.
파일 디스크립터 ↔ 파일 포인터 변환
Low Level과 High Level I/O 사이의 상호 변환이 가능하다.
// 파일 디스크립터 → 파일 포인터
FILE *fdopen(int fd, const char *mode);
// 파일 포인터 → 파일 디스크립터
int fileno(FILE *fp);예시:
FILE *fp = fopen("linux.txt", "r");
int fd = fileno(fp); // 파일 포인터 → 파일 디스크립터
int n = read(fd, str, BUFSIZ); // Low Level 함수 사용시스템 콜 오버헤드와 버퍼링
시스템 콜의 오버헤드
시스템 콜 호출 시 다음과 같은 비용이 발생한다.
-
보호 도메인 변경: User mode ↔ Supervisor mode 전환 비용
- 레지스터, 플래그, 스택 상태 저장 및 복원
- 인터럽트 발생 시 처리 비용
-
포인터 유효성 검사: 커널이 사용자가 제공한 주소의 유효성을 확인
- 버퍼 주소가 유효한 범위인지 검사
- 메모리 접근 권한 확인
-
메모리 맵 조정: User Stack ↔ Kernel Stack 전환 비용
해결책: 버퍼링
stdio.h에서 제공하는 표준 입출력 함수들은 버퍼링을 사용하여 시스템 콜 호출 횟수를 줄인다.
버퍼링의 동작 원리
📌 예시 1 - fread() 함수
fread(&len, sizeof(len), 1, fp);내부적으로
- read() 시스템 콜을 호출하여 큰 블록(4KB)을 한번에 읽어 FILE 구조체 내부 버퍼에 저장
- 실제 읽어온 버퍼의 전체 블록 중에서 사용자가 요청한 바이트 수만큼만 반환
- 다음 호출 시 버퍼에 남은 데이터를 먼저 사용
📌 예시 2 - printf() 함수
printf("Hello\n");내부적으로:
- 즉시 write() 시스템 콜을 호출하지 않음
- 내부 버퍼에 데이터를 축적
- 버퍼가 가득 차거나, 개행 문자('\n')를 만나거나, fflush() 호출 시 실제로 출력
성능 비교
UNIX I/O (Low Level):
while (read(fd, &c, 1) == 1) {
total += c;
}
// 매번 시스템 콜 호출 → 느림Standard I/O (High Level):
while ((ferror(fp) && fread(&c, 1, 1, fp)) == 1) {
total += c;
}
// 버퍼링으로 시스템 콜 최소화 → 빠름마치며
마지막으로 Low Level과 High Level I/O의 특징을 정리하면
📌 Low Level File I/O
- 장점: 직접적인 제어, 정확한 에러 처리
- 단점: 복잡한 코드, 시스템 콜 오버헤드
- 사용 예: 시스템 프로그래밍, 디바이스 드라이버, 네트워크 프로그래밍
📌 High Level File I/O
- 장점: 간편한 사용, 자동 버퍼링, 이식성
- 단점: 제한적인 제어, 추가 메모리 사용
- 사용 예: 일반적인 애플리케이션 개발, 텍스트 처리