[C언어] 파일 입출력 - 텍스트와 이진 파일 열기, 읽기 ,쓰기, 닫기
C언어 파일 입출력
C언어에서 scanf와 printf 함수를 통해서 키보드로 입력을 받고 모니터로 출력해주는 그런 프로그램들을 많이 보았을 겁니다. 이런 키보드나 모니터같은 입출력 장비를 콘솔이라고 합니다. 그래서 콘솔 입출력을 해왔던 것이죠.
여기서는 파일 입출력에 대해서 설명합니다. 사실 콘솔 입출력과는 별로 다를바가 없습니다. 단지 그 대상이 모니터나 키보드가 아닌 파일이기 때문이죠. 본격적으로 파일 입출력을 설명하기 전에 우리는 스트림에 대한 개념을 먼저 알아야합니다.
> 그전에 왜 C 표준입출력을 사용하나요?
리눅스를 배우셨던 분들은 open, read, write, close를 이용해서 파일을 다뤄보셨을 겁니다. 그때는 open에 필요에 따라 여러 플래그들을 줄 수가 있는데요. 예를 들어 O_RDONLY, O_CREAT 등 말이죠. 이거 구차하게 일일히 헤더 추가한 다음에 파일 디스크립터를 가져와서 write, read하는 것을 C 표준입출력 라이브러리에서는 stdio.h만 포함해서 사용할 수 있습니다. 아주 개꿀이라는 얘기죠. 그리고 변태같은 플래그들을 포함하지 않아도 사용하기에 적합한 플래그들을 미리 조합해놨기 때문에 상큼하게 그걸 사용하면 됩니다. 또한 내부적으로 버퍼를 사용하기 때문에 read, write 함수들을 최적으로 사용하게 됩니다.
스트림(Stream)
영어를 그대로 직영하게 되면 흐름이라는 건데요. 비슷하게 생각하시면 됩니다. 프로그램에서 파일이 열리면 C표준입출력은 스트림(stream)이라는 파일과 프로그램 사이의 추상적인 흐름이 일어나는 파이프를 생성합니다. 그래서 파일이 열리게 되면 개념적으로 스트림을 통해서 파일에 기록하거나 읽을 수 있습니다. 만약 파일을 읽기만 하겠다하면 읽기 전용의 스트림을 여는 것이고, 파일을 쓰기만 할 것이라면 쓰기 전용의 스트림을 열어서 거기에 기록을 하면 됩니다.
그래서 아래와 같이 어떤 프로그램에서 File이라는 이름의 파일을 읽고 쓰기 위해서 스트림을 열면 아래와 같은 상황이 발생하게 됩니다. 그래서 바이트 단위던, 줄 단위던 입력이 흐름이 가능한 상태가 됩니다.
기본적으로 보통 프로그램에서는 3개의 스트림이 열려있습니다. 바로 표준 입력 스트림(stdin), 표준 출력 스트림(stdout), 표준 에러 스트림(stderr)입니다. 이 3개는 콘솔에 대해서 열려있는 스트림들입니다.
stdin, stdout, stderr
키보드로 입력받고, 모니터로 출력하는 것도 C표준 입출력에서는 스트림으로 간주하게 됩니다. 그래서 우리가 stdin을 통해서 입력을 받는다면 키보드를 통해서 입력을 받는 것이고, 표준 출력 스트림으로 출력한다면 모니터 화면에다가 출력이 되는 겁니다. 그래서 파일 대신 모니터와 키보드가 스트림 끝에 놓여있는 것을 보세요.
파일의 종류 ( 텍스트 파일 , 이진 파일)
파일을 사람이 쓰고 읽냐, 컴퓨터가 쓰고 읽냐에 따라서 텍스트 파일(text-file), 이진 파일(binary-file)로 나누게 됩니다. 이와 같은 구분은 문자열로 입출력을 하느냐, 아니면 바이너리로 입출력을 하느냐를 위해서 구분합니다. 맨 처음 파일에 대해서 스트림을 생성할 때 결정이 됩니다.
1. 파일 열기 fopen
파일 함수는 표준입출력(stdio.h) 헤더파일에 존재합니다. 파일에 어떤 데이터를 읽고, 쓰고, 추가하려면 일단 파일을 열어야겠지요. 함수를 한번 보시죠.
FILE *fopen(const char *filename, const char *mode);
filename : 파일명을 말합니다. 절대 경로나 상대 경로로 줄 수 있습니다. 상대 경로는 그 프로젝트 위치를 기준으로 합니다.
mode : 파일을 어떤 방식으로 열건지 정합니다. 스트림 방식을 정하는 겁니다. 입력 스트림인지, 출력 스트림인지.
-동작 모드
모드 설명 flag 조합 r(read) 1. 파일을 읽기 전용으로 엽니다.
2. 파일이 있어야합니다.O_RDONLY w(write) 1. 파일을 쓰기 전용으로 엽니다.
2. 주의해야합니다. 파일이 존재한다면 기존의 내용을 지우고 쓰기 때문이죠.
3. 파일이 없으면 새로 생성합니다.O_WRONLY | O_CREAT | O_TRUNC a(append) 1. 파일이 있으면 파일의 끝에 내용을 추가합니다.
2. 파일이 없으면 생성해서 내용을 추가합니다.O_WRONLY | O_CREAT | O_APPEND r+ 1. 파일을 읽고 쓰기 위해 엽니다.
2. 파일이 반드시 있어야 합니다.O_RDWR w+ 1. 파일을 읽고 쓰려고 엽니다.
2. r+와 다르게 파일이 있는 경우 내용을 덮어쓰고 없으면 생성해서 데이터를 씁니다.O_RDWR | O_CREAT | O_TRUNC a+ 1. 파일을 읽고 갱신하기 위해 엽니다.
2. 파일이 없으면 생성해서 데이터를 추가합니다.O_RDWR | O_CREAT | O_APPEND
- 이진 또는 텍스트 모드(t, b)
텍스트모드가 기본(default)입니다. 이진 모드로 파일을 열려면 b를 추가합니다.
ex) 이진모드로 읽기 위해 파일을 open -> rb
파일을 여는 데 성공했다면 그 파일에 대한 포인터를 return합니다.
하지만 파일을 여는 데 실패했으면 NULL을 반환하죠.
2. 파일 닫기 fclose
무엇이든 열었으면 닫는 것이 원칙이죠. 파일 스트림을 닫으려면 fclose를 사용하시면 됩니다.
int fclose(FILE *stream);
그냥 열었던 파일 포인터를 집어넣으면 됩니다. 성공하면 0을 반환하고 실패하면 EOF(-1)를 반환합니다.
3. 텍스트 파일 읽기 함수
파일은 두 종류의 파일이 있다고 했죠? 사람이 읽을 수 있는 텍스트 형식의 파일과 컴퓨터가 읽고 처리하는 바이너리 파일, 즉 이진 파일이 있습니다. 우선 텍스트 파일을 읽는 함수는 쓰임새에 따라 여러가지가 있습니다.
3.1 한문자 읽기 : fgetc, getc, getchar
#include <stdio.h>
int fgetc(FILE *stream);
int getc(FILE *stream);
int getchar(void);
fgetc와 getc는 같은 기능을 하는 함수입니다. getchar() 함수는 키보드용 한문자 입력을 받는 함수와 같아서 getc(stdin)과 같습니다.
getc = getc , getc(stdin) = getchar()
stream에서 한 글자를 읽어오는 함수이며, 일반적으로 반환형은 한 글자의 ASCII값인 정수형 값입니다. 파일의 끝에 도달할 시에 EOF를 return합니다. EOF는 End-Of-File로 -1입니다. 이렇게 반환형이 (signed) int인 이유는 이 EOF를 반환받기 위해서입니다.
3.2 한 줄 읽기 : fgets
#include <stdio.h>
char *fgets(char *s, int size, FILE *stream);
stream에서 문자 한줄을 읽어올때 사용하는 함수이며 size 이하의 문자 한줄을 s로 읽어옵니다. 이때 개행문자까지 읽어옵니다. 그래서 개행문자('\n') 다음 문자의 끝을 나타내는 문자인 NULL('\0')이 붙습니다. 간단히 사용법을 확인해볼까요? 다음은 stdin으로 콘솔(키보드)로부터 입력을 받는 단순한 예제입니다.
//fgets_test.c
#include <stdio.h>
#include <string.h>
#define BUF_SIZE 32
int main(){
char buf[BUF_SIZE] = {0,};
printf("입력:");
fgets(buf, BUF_SIZE, stdin);
printf("출력:");
printf("%s(%ld)", buf, strlen(buf));
}
# ./a.out
입력:hello world
출력:hello world
(12)#
여기서 보이는 "hello world"의 문자열 길이는 공백을 포함해서 11글자이지만, 개행문자를 포함했기 때문에 12글자가 되고, 문자 길이도 한 줄 밑에 출력이 되었네요.
3.3 서식화된 파일 입력 : fscanf
#include <stdio.h>
int fscanf(FILE *stream, const char *format, ...);
키보드 입력에 대해서 입력 포맷팅 함수는 scanf였죠? 파일에 대해서 포맷팅 함수는 fscanf입니다.
4. 텍스트 파일 쓰기 함수
텍스트 파일에 쓰는 함수는 아래와 같습니다. 위의 텍스트 파일 읽기 함수의 네이밍을 따라갑니다. 함수에 대한 소개만하고 넘어가도록 합시다.
4.1 한문자 쓰기 : fputc, putc, putchar
#include <stdio.h>
int fputc(int c, FILE *stream);
int putc(int c, FILE *stream);
int putchar(int c);
putchar(c)는 putc(stdout, c)와 같습니다.
4.2 한 줄 쓰기 : fputs, puts
#include <stdio.h>
int fputs(const char *s, FILE *stream);
int puts(const char *s);
4.3 서식화된 파일 출력 : fprintf
#include <stdio.h>
int fprintf(FILE *stream, const char *format, ...);
예제 - 텍스트 데이터 저장, 읽어오기
//writer.c
#include <stdio.h>
#include <string.h>
#define NUM 3
typedef struct _student{
char name[16]; //이름
unsigned int age; //나이
unsigned int id; //학번
} student;
int main(){
int i;
student s[NUM] = {
{"park", 18, 1234},
{"jung", 18, 1235},
{"kim", 19, 1111}
};
FILE *fp;
//텍스트 파일, 없으면 새로운 파일 생성, 있으면 내용 덮어쓰기(w+)
fp = fopen("info.txt", "w+");
if(fp == NULL) {
printf("fopen error\n");
return 1;
}
for(i = 0;i < NUM; i++){
fprintf(fp, "%s %d %d\n",
s[i].name, s[i].age, s[i].id);
}
fclose(fp);
}
//reader.c
#include <stdio.h>
#define NUM 3
typedef struct _student{
char name[16]; //이름
unsigned int age; //나이
unsigned int id; //학번
} student;
int main(){
int i;
FILE *fp;
student s[NUM];
//테스트 파일 읽기 전용
fp = fopen("info.txt", "r");
if(fp == NULL){
printf("fopen error\n");
return 1;
}
for(i = 0; i < NUM; i++){
fscanf(fp,"%s %d %d",
s[i].name, &(s[i].age), &(s[i].id));
printf("[%d]\n", i);
printf("name : %s, age : %u, id : %u\n",
s[i].name, s[i].age, s[i].id);
}
fclose(fp);
}
# gcc reader.c -o reader
# ./writer
# cat info.txt
park 18 1234
jung 18 1235
kim 19 1111
writer만 실행해보면 info.txt가 생겨났고 그 내용은 이렇게 적혀있습니다. 사람이 알아볼 수 있죠?
# ./reader
[0]
name : park, age : 18, id : 1234
[1]
name : jung, age : 18, id : 1235
[2]
name : kim, age : 19, id : 1111
reader라는 프로그램으로도 아주 잘 읽을 수 있습니다.
5. 이진 파일 읽기 fread
파일을 읽는 함수는 fread입니다. 프리드라고 읽지마세요 제발. 앞에 f는 모두 file의 f입니다. 앞에 f가 붙은 함수는 거의 다 파일에 대한 함수라는 것을 기억하세요.
#include <stdio.h>
size_t fread(void *buffer, size_t size, size_t count, FILE *stream);
stream으로부터 자료형인 size를 count만큼 읽어서 buffer에 저장합니다. buffer가 void*인 이유는 어떤 자료형이건 받아와야하기 때문입니다. 파일을 읽은 길이(count)만큼 반환합니다.
만약에 단순 바이너리를 읽는다면, 그러니까 바이트 단위를 읽는다면 size는 1입니다. 그러면 만약 크기 16바이트인 구조체를 3개를 읽는다면 아래와 같이 호출이 됩니다.
fread(buffer, 16, 3, fp);
6. 이진 파일 쓰기 fwrite
파일에 쓰는 함수입니다. fread와는 반대 기능이죠.
#include <stdio.h>
size_t fwrite(const void *buffer, size_t size, size_t count, FILE *stream);
buffer에 담긴 내용을 기록하는데 size만큼의 count 만큼 버퍼로부터 stream쪽으로 씁니다. 성공하면 count를 return하고 실패한다면 count가 아닐 수 있습니다.
예제 - 이진데이터 구조체 저장, 읽어오기
이진 파일을 사용할 수 있는 가장 큰 장점은 모든 자료를 이진데이터로 쓸 수 있다는 점입니다. 객체(구조체)도 그냉 냅다 쓸 수 있습니다. 모든 것을 이진 데이터로 쓰기 때문이지요. 다음은 구조체를 파일에 쓰고, 그 파일로부터 읽어오는 예제를 보여줍니다.
//info_writer.c
#include <stdio.h>
#define NUM 3
typedef struct _student{
char name[16]; //이름
unsigned int age; //나이
unsigned int id; //학번
} student;
int main(){
FILE *fp;
student s[NUM] = {
{"kim", 16, 1234},
{"lee", 16, 1235},
{"lim", 17, 1111}
};
//이진(b)으로 쓰기용, 없으면 만들고 있으면 덮어쓴다(w+)
fp = fopen("info.bin", "wb+");
if(fp == NULL){
printf("fopen error\n");
return 1;
}
if(fwrite(s, sizeof(student), NUM, fp) != NUM){
printf("fwrite erorr\n");
fclose(fp);
return 1;
}
printf("Student Information Saved OK \n");
fclose(fp);
}
//info_reader.c
#include <stdio.h>
#define NUM 3
typedef struct _student{
char name[16]; //이름
unsigned int age; //나이
unsigned int id; //학번
} student;
int main(){
int i;
FILE *fp;
student s[NUM];
fp = fopen("info.bin", "rb"); //읽기 전용
if(fp == NULL){
printf("fopen error\n");
return 1;
}
if(fread(s, sizeof(student), NUM, fp) != NUM){
printf("fread erorr\n");
fclose(fp);
return 1;
}
for(i = 0; i < NUM; i++){
printf("[%d]\n", i);
printf("name : %s, age : %u, id : %u\n",
s[i].name, s[i].age, s[i].id);
}
fclose(fp);
}
# gcc info_writer.c -o writer
# gcc info_reader.c -o reader
# ./writer
Student Information Saved OK
# ./reader
[0]
name : kim, age : 16, id : 1234
[1]
name : lee, age : 16, id : 1235
[2]
name : lim, age : 17, id : 1111
7. 버퍼
버퍼는 C표준입출력에서 입력과 출력을 효율적으로 처리하기 위한 일종의 저장공간입니다. 내부적으로 write, read를 적시에 한번만 호출하기 위한 것이 목적입니다. 그런데 이러한 버퍼의 처리 방식을 잘 모르면 낭패를 볼 수 있는데요. 아래의 코드를 봅시다.
//buffer.c
#include <stdio.h>
int main(){
char c;
printf("아무 글자나 하나 입력:");
scanf("%c", &c);
printf("입력받은 글자 : %c\n", c);
printf("다시 입력 : ");
scanf("%c", &c);
printf("입력받은 글자 : %c\n", c);
}
실행하게 되면 두 번재 scanf에 입력을 주기도 전에 프로그램이 끝나게 됩니다. 분명 scanf를 통해서 한글자 입력을 받는 코드를 작성했으에도 말이죠.
# ./a.out
아무 글자나 하나 입력:H
입력받은 글자 : H
다시 입력 : 입력받은 글자 :
#
이 프로그램은 내부적으로 이렇게 동작하게 됩니다. 'H'라는 문자를 입력하면 내부적으로 엔터에 해당하는 개행 문자 '\n'도 입력이 됩니다.
결국 버퍼에는 H와 '\n'이 입력이 되게 되며 변수 c에는 'H'가 담기게 되겠죠. 버퍼에 남아있는 건 개행문자 '\n'입니다. 그래서 다음 scanf는 이 개행문자를 입력받아 입력이 끝나게 되는 겁니다.
위는 줄 단위 버퍼링의 사례로 결국에는 남아있는 버퍼를 비워줘야합니다. 버퍼를 비워주는 방법에는 여러 가지 방법이 있는데요.
7.1 버퍼를 비우는 방법들
- 간단한 방법은 단순히 문자 하나 입력받는 거죠. 아래와 같이말이죠.
printf("아무 글자나 하나 입력:");
scanf("%c", &c);
getchar();
printf("입력받은 글자 : %c\n", c);
printf("다시 입력 : ");
scanf("%c", &c);
getchar();
printf("입력받은 글자 : %c\n", c);
- fflush 함수 사용
#include <stdio.h>
int fflush(FILE *stream);
fflush 함수를 사용할 수가 있는데, 이 방법은 표준이 아니므로 권장되지 않습니다. 실제 제 ubuntu시스템에서는 동작하지 않습니다.
- scanf에서 공백 사용
scanf("%c", &c) -> scanf(" %c", &c)
앞에 공백 문자 하나를 넣어주세요.
- 개행문자가 나올때까지 제거
while(getchar() != '\n');
어떤 시스템에는 \r\n으로 개행합니다. 그게 윈도우즈인데, 이럴 때는 getchar()만 사용하게 되면 \r만 제거 됩니다. 그래서 아예 \n까지 제거 할 수 있도록 while문을 도는 방식을 사용할 수 있습니다. 약간 고급진 말로 '\r'은 커서를 맨 앞으로 돌리는 CR(Carriage return)이라 하며 '\n'은 커서는 그자리이며 라인만 바꾸는 LF(Line Feed)라고 합니다.