First written: 23-08-11
Uploaded: 25-03-03
Last modified: 24-09-14
드디어 파일 입출력까지 왔다. 많은 사람들이 프로그램의 초창기에 알고리즘을 접하다보니 초기부터 많이 사용하게 되는 함수들이 file I/O와 관련되어 있다. 그러나 대부분의 책이나 강의에는 이 내용이 마지막에 있다는 것이 좀 아쉽다.
입출력을 하려면 열고 닫을 줄 알아야 하겠다. 이전에 힙heap 공간을 할당받을 때 특정 함수를 통해 OS에 우리의 의사를 전달하고, 할당받아 쓰던 공간을 해제하려고 할 때에도 OS에게 우리의 뜻을 전달했다. 파일 열기도 똑같다. OS의 허락을 받아야 한다. 따라서 파일을 여는 함수와 사용 후에 닫는 함수가 필요하다. 이들이 우리를 대신해 OS에게 말을 걸어준다.
FILE* fopen( char* filename, char* mode );
fopen은 지정된 모드에 따라 파일을 연다. 그 과정에서 에러가 생기면 NULL이 반환된다. 문제 없이 파일을 열 수 있는 경우 FILE type의 구조체 변수를 가리키는 주소를 반환한다. 아래는 해당 구조체의 정의 예시이다. compiler마다 다를 수 있다.
struct _iobuf
{
char* _ptr;
int _cnt;
char* _base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char* _tmpfname;
}
filename
을 지정할 때에는 절대경로와 상대경로 모두 사용이 가능하다. 윈도우에서는 대소문자가 구별되지 않지만 유닉스 계열 OS에서는 대소문자를 구별한다. 또한 filename
에는 최대 길이 제한이 걸려있는데, 표준 C에서는 260으로 정의되어 있다.
파일을 여는 모드는 총 여섯 가지가 있다.
mode | file exists | file not exists |
---|---|---|
r | read | ERROR |
w | write(덮어씀) | NEW FILE |
a | write(이어씀) | NEW FILE |
r+ | read + write(덮어씀) | ERROR |
w+ | read + write(덮어씀) | NEW FILE |
a+ | read + write(이어씀) | NEW FILE |
수정 모드(+
가 붙은 모드)로 읽기 쓰기를 동시에 하는 것보다 읽기 모드와 쓰기 모드를 따로 열어서 작업하는 것이 권장된다.
파일을 닫을 때에는 fclose()
가 사용된다.
int fclose( FILE* fp );
만일 파일 닫기에 성공하면 0을, 실패하면 EOF
를 반환한다. EOF
는 End Of File의 약자지만 몇몇 함수(특히 파일 입출력 함수)에서는 에러의 발생을 의미한다.
int feof( FILE* fp ); // 반환값이 0이 아니면 파일의 끝에 도달했음을 나타낸다
int ferror( FILE* fp ); // 반환값이 0이 아니면 에러가 발생한 것이다
void clearerr( FILE* fp ); // 에러 발생 여부를 초기화한다
에러가 발생했을 때에는 위와 같은 함수들이 도움을 줄 수 있다.
if ( feof( fp ) )
printf( "End Of File\n" );
else if( ferror( fp ) )
{
printf( "Error Occurred\n" );
clearerr( fp );
}
한 번 에러가 발생하면 clearerr()
로 초기화를 해줘야 한다. 그렇지 않으면 에러 상태가 계속 남아 ferror()
의 반환값이 0이 아니게 된다.
당연히 파일을 열어둔 채로 닫지 않으면 메모리 누수memory leakage의 원인이 된다. OS는 동시에 열 수 있는 파일 개수를 제한해두는 게 일반적이다. 또한 파일을 닫지 않으면 작업중이던 내용이 날아갈 수 있다. fclose()
가 호출되면 버퍼buffer에 남아있는 내용이 없는지 확인하고 있다면 해당 내용을 파일에 출력하고 프로그램을 닫는다. 하지만 fclose()
를 호출하지 않으면 해당 작업을 해줄 함수도 없다. 버퍼에 남아있던 내용은 전부 날아가게 된다.
파일의 종류는 크게 두 가지로 나뉠 수 있다. 바로 텍스트 파일과 이진 파일이다. 텍스트 파일은 전부 문자 코드(정수로 된)로 이루어져 있고, 이진 파일은 문자와 숫자로 이루어져 있다. 텍스트 파일은 저장된 내용을 해석할 때 저장된 각각의 내용을 문자 코드와 일치하는 문자로 바꿔주기만 하면 된다. 반면 이진 파일은 해당 파일이 작성된 프로그램의 해석 방식을 알지 못하면 파일을 해석할 수가 없다(우리는 텍스트 뷰어/에디터로 다른 확장자 파일을 열었을 때 이런 경험을 한 적이 있다). 이진 파일을 읽기 위해서는 해당 프로그램을 깔거나 공개된 파일 형식을 기반으로 코드를 짜야 한다.
일반적인 프로그래밍에서 좀 더 자주 쓰이는 텍스트 파일의 읽기와 쓰기부터 보자.
int fgetc( FILE* fp );
int fputc( int c, FILE* fp );
fgetc()
와 fputc()
는 문자 하나 단위로 읽고 출력한다. fputc()
에는 int
type의 변수가 들어오는데, 이는 문자 상수를 의미한다. char
type의 변수가 들어와도 자동 형변환이 진행되기 때문에 문제가 없다. fputc()
는 들어온 문자를 매개변수에 있는 포인터가 가리키는 파일에 한 문자씩 저장한다. fgetc()
는 파일을 한 문자씩 읽어오고 마지막에 도달하면 -1을 반환한다(앞에서 이미 EOF를 다뤘다). 아래의 코드는 두 함수를 활용해 파일의 내용을 복사한다.
FILE* in_file = fopen( "original.txt", "r" );
FILE* out_file = fopen( "copy.txt", "w" );
int ch = 0;
while ( (ch = fgetc( in_file )) != EOF )
fputc( ch, out_file );
문자열 단위로 입출력을 진행하는 함수로는 fgets()
와 fputs()
가 있다.
char* fgets( char* buf, int n, FILE* fp );
int fputs( char* buf, FILE* fp );
입출력에 버퍼가 사용되는 것을 알 수 있다. fgets()
는 버퍼가 꽉차거나 개행문자인 '\n'을 만날 때까지 파일에서 문자열을 읽어온다. 최대로 읽어올 수 있는 문자의 갯수는 버퍼의 크기에서 1을 제외해야 한다. 마지막에 널 문자를 넣어주어야 하기 때문이다. 널 문자가 들어가는 이유는 파일의 끝을 알려주기 위해서다. fputs()
가 파일의 마지막 부분을 읽어올 때 널 문자가 없으면 어느 부분이 끝인지를 알기가 어렵기 때문이다.
버퍼에 계속해서 덮어쓰는 방식으로 내용을 읽어오므로 마지막 부분이나 짧은 부분을 읽어올 때에는 널 문자 이후에 그 전에 읽어왔던 데이터가 남아있다.
fgets()
를 사용할 때에는 그러므로 버퍼의 크기가 중요하다. 따라서 두 번째 인자는 sizeof( buf )
로 설정하는 경우가 흔하다. 버퍼의 크기보다 크면 안 되지만 작을 이유도 없으므로, 버퍼 사이즈와 크기가 같은 것이 좋다. 혹은 버퍼의 크기를 늘려주는 것도 좋은 방법이다. 버퍼의 길이가 한 행의 최대 길이보다 2가 크면 이론적으로 한 행의 데이터를 한 버퍼에 다 담을 수 있다. 2가 커야 하는 이유는 개행문자와 널 문자가 들어갈 자리가 필요하기 때문이다.
fgets()
는 gets()
와 다르게 개행문자를 제거하지 않고 버퍼에 저장한다. 그러므로 fgets()
를 사용할 때 다른 문자열과 비교를 해야 한다면 개행문자가 포함될 가능성을 인지하고 그에 대응할만한 코드를 넣어주는 게 좋다.
fputs()
는 널 문자를 만날 때까지 출력한다. 음수가 아닌 값이 반환되면 정상적으로 처리가 되었다는 뜻이고, EOF를 반환하면 에러가 발생했다는 뜻이다.
예전에 보았던 gets()
와 puts()
, 이번에 본 fgets()
와 fputs()
를 비교해보자. gets()
는 입력 버퍼에서 읽어온 내용 마지막의 개행문자를 널 문자로 대체한다. 대신 puts()
는 자동으로 개행문자를 붙여준다(이것이 puts("")
가 개행문자를 출력하는 이유다). 반면 fgets()
는 개행문자를 그대로 두는데, 그에 상응해 fputs()
가 해당 개행문자를 출력하지 않는다. 양쪽 다 서로가 서로를 보완하여 작동하고 있다.
FILE* in_file = fopen( "original.txt", "r" );
FILE* out_file = fopen( "copy.txt", "w" );
while ( fgets( buf, sizeof( buf ), in_file ) != NULL )
fputs( buf, out_file );
int fscanf( FILE* fp, char* format, ... );
int fprintf( FILE* fp, char* format, ... );
fscanf()
와 fprintf()
는 형식화된 입출력을 할 수 있다. scanf()
와 printf()
와 유사하지만, fscanf()
와 fprintf()
는 화면 입출력 외에 파일 입출력도 할 수 있고 공백이나 탭 문자도 받아서 처리할 수 있다는 게 차이점이다. 그 외에는 비슷하다고 보면 된다.
FILE* in_file = fopen( "data.txt", "r" ); // 1234 Alejandro 22
FILE* out_file = fopen( "copy.txt", "w" );
int id, age;
char name[10];
fscanf( in_file, "%d %s %d", &id, name, &age ); // id = 1234, name = "Alejandro", age = 22
fprintf( out_file, "id: %d, name: %s, age: %d", id, name, age ); // id: 1234, name: Alejandro, age: 22
여는 것부터 이진 파일 모드binary mode로 열어야 한다. 파일 모드에 'b'를 추가하면 되는데 'b'의 위치는 'r', 'w', 'a'의 뒤에 와야 한다(원래 텍스트 모드도 't'를 추가해주어야 하지만 기본값이 't'라서 생략이 가능하다).
FILE* frb = fopen( filename, "rb+" );
FILE* frb = fopen( filename, "r+b" ); // same as above
텍스트 모드에서는 데이터를 문자로 바꾼 뒤 출력했다. 입력의 경우도 문자를 데이터로 변환했다. 이진 모드에서는 어떤 변환도 이루어지지 않는다. 그러다보니 변환이 필요없어 훨씬 효율적이고, 사람이 바로 알아보기에도 쉽지 않아 보안 측면에서도 장점이 있다. 따라서 환경설정 파일이나 에러 로그 등을 제외한 대부분의 파일은 이진 모드로 입출력을 진행한다. 빅엔디안이냐 리틀엔디안이냐에 따라서 해석이 완전히 달라지므로 바이트 순서byte order에 주의를 기울여야 한다.
size_t fread( void* buf, size_t sz, size_t n, FILE* fp );
size_t fwrite( void* buf, size_t sz, size_t n, FILE* fp );
fread()
는 파일(fp
)에서 sz byte의 데이터를 n개 읽어 버퍼(buf
)에 저장한 뒤 읽어온 데이터의 개수를 반환한다. 에러가 발생했거나 파일의 끝이면 반환값이 n보다 작을 수 있다. fwrite()
는 버퍼에 저장되어 있는 sz byte 데이터 n개를 파일에 출력한다. 반환값은 출력에 성공한 데이터의 개수다. 역시 에러가 발생했다면 반환값은 n보다 작을 수 있다. 둘 다 입출력의 type을 void*
로 두고 있다는 것이 텍스트 모드와의 차이다.
FILE *fp;
char input[10] = "hello!\n";
char output[10];
fp = fopen( "output.txt", "wb" );
fwrite( input, strlen( input ), 1, fp );
fclose( fp );
fp = fopen( "data.txt", "rb" );
fread( output, sizeof( output ), 1, fp );
printf( "output: %s", output );
fclose( fp );
이 과정에서 사용되는 버퍼의 크기는 시스템마다 다르다. x86이나 x64에서는 버퍼의 크기가 보통 4KB(4096 byte)다. 이는 CPU가 한 번에 다루는 메모리 최소 단위와도 같다. 그래서 코드에서 사용하는 버퍼를 4096(char
type)으로 해서 사용하기도 한다.
fwrite( buf, sizeof( MyStructure ), SIZE, out_file );
fflush( out_file );
fread( ... )
// code
종종 위와 같은 코드(fflush()
가 포함된)를 발견할 수 있을 것이다. 버퍼의 크기가 꽉차지 않으면 파일에 출력되지 않기 때문에 그 다음에 버퍼를 사용하기 전에 버퍼에 남아있는 내용을 파일(out_file
)에 출력할 수 있도록 도와주는 코드다.
표준 입출력standard I/O에서 표준 입력이란 키보드로 들어오는 입력을 의미하고 표준 출력이란 화면을 통해 데이터가 출력되는 것을 의미한다. C는 표준 입출력을 돕기 위한 구조체 FILE type의 변수를 제공한다. stdin
, stdout
, stderr
이 그 변수를 가리키는 포인터들이다. 이들을 이용해서 파일 입출력과 같은 방식으로 표준 입출력을 진행할 수 있다.
extern FILE _iob[];
#define stdin (&_iob[0])
#define stdout (&_iob[1])
#define stderr (&_iob[2])
stdin
은 키보드 입력의 수정 용이성을 위해 버퍼를 사용하지만 나머지 둘은 버퍼를 사용하지 않는다. 위에서 보이는 것처럼 FILE type의 구조체로 이루어진 배열이 존재한다. 0번째가 stdin
, 1번째가 stdout
, 2번째가 stderr
가 존재한다. 여기서 fopen()
을 이용해 파일 하나를 열면 _iob[3]
의 자리는 해당 파일의 FILE type 구조체가 가져가게 될 것이다. 해당 구조체의 멤버member에 직접 접근해 관리하고 체크하는 것도 가능하다.
stdin
에는 입력의 끝을 알릴 방법이 따로 정해져 있지는 않다. 그래서 OS별로 다른 방식으로 정의가 되어있는데, 윈도우에서는 개행문자를 입력한 뒤 ctrl+z
키를 입력하여 특수문자인 0x1A(10진수 26)를 입력하도록 되어 있다. 유닉스 계열에서는 ctrl+d
를 입력하면 되고 이 문자 코드는 0x4인데 EOT(End Of Transmission)를 의미한다.
freopen()
함수를 쓰면 stdin
, stdout
, stderr
의 대상을 변경할 수 있다. 주로 stderr
의 출력 대상을 파일로 변경할 때 자주 쓰인다. 아래의 코드를 실행하면 더 이상 화면에 stderr
에 관한 내용이 뜨지 않고 매개변수로 들어온 경로의 파일에 에러 내용이 적히게 된다.
FILE* fp = freopen( "./error.log", "w", stderr );
또한 프로그램을 실행할 때 아래와 같이 실행파일 이후에 > 파일
을 적어주면 stdout
에 출력되어야 할 내용들이 전부 해당 파일에 출력되게 된다. 역시 화면에도 출력되지 않는다. stderr
은 2> 파일
형식으로 하면 같은 효과를 볼 수 있다. stdin
입력 대상을 변경하고플 때는 < 파일
형식으로 하면 된다.
11_example.exe > output.txt
11_example.exe 2> error.log
11_example.exe > output.txt < input.txt
파일 위치 지시자file position indicator는 파일 입출력의 기준점을 어디로 삼을지 알려주는 변수다. 입출력 함수를 호출하면 마치 GUI의 커서cursor처럼 읽거나 쓴 만큼 자동적으로 이동(값 증가)한다. 대부분의 순차적인 파일 입출력의 경우, 우리가 이 지시자를 건드릴 필요는 없다.
하지만 미디어 파일을 특정 위치에서부터 재생하거나 구간반복을 하는 등의 행위를 하려면 해당 지시자를 옮겨주어야 한다. 이 지시자의 값을 변경하는 데에는 fseek()
함수가 사용된다.
#define SEEK_SET 0
#define SEEK_CUR 1
#define SEEK_END 2
int fseek( FILE* fp, long offset, int origin );
fseek( fp, 5L, SEEK_CUR ); // 현재 위치에서 5 byte 이동
fseek( fp, sizeof( MyStructure ), SEEK_SET ); // 처음 위치에서 MyStructure 구조체 크기만큼 이동
다음은 사용 예시다.
FILE* fp = fopen( "empty.txt", "w" );
int filesize = 1024 * 1024 * 1024; // 100MB
fseek( fp, filesize - 1, SEEK_SET );
fputc( '1', fp );
100MB만큼의 크기를 이동한 뒤에 마지막에 '1'을 적어준다. 그 사이의 공간은 모두 널 문자로 채워지게 될 것이다.
참고 서적:
남궁성, 2016, 도우출판, 『C언어의 정석』
참고 사이트:
오리는 오늘도 꽥꽥: C언어 fprintf, fscanf에 대해 알아보기
천천히, 빠르게. 개발자의 Repository: c/c++ fwrite, fread 사용법
Copyright © 2025. moyqi. All rights reserved.