C · 기본
메모리 그림으로 배우는 C
C 파일 입출력 - fopen fgets sscanf 오류 처리와 바이너리 저장 (C 기초 11장)
텍스트 CSV를 한 줄씩 안전하게 읽어 잘못된 줄을 건너뛰고, 구조체를 바이너리로 저장한 뒤 od로 바이트를 확인하며 엔디언·패딩 이식성 문제를 짚는다.
개발자 · 원고 갱신
이 장에서 배우는 것
지금까지의 데이터는 프로그램이 끝나면 사라졌다. 이 장에서는 값을 파일에 남기고 다시 읽는다. C의 파일 입출력은 FILE *라는 포인터 하나로 다루며, 거의 모든 함수가 실패할 수 있다. 파일이 없거나, 권한이 없거나, 디스크가 가득 찼거나, 한 줄이 버퍼보다 길 수 있다. 실패를 검사하는 코드가 파일 입출력 코드의 절반이다.
fopen/fclose와 오류 보고(perror,errno)를 익힌다.- 텍스트 파일을
fprintf로 쓰고fgets+sscanf로 한 줄씩 안전하게 읽는다. - 잘못된 줄을 만나도 멈추지 않고 건너뛰며 보고한다.
fwrite/fread로 바이트를 그대로 저장하고, 파일 속 바이트를od로 확인하며 이식성 문제를 이해한다.
문제 상황
측정 장비가 매일 이름,점수 형식의 CSV를 남긴다. 평균을 내는 프로그램을 만들었는데, 사람이 손으로 고친 줄에 숫자가 아닌 값이 섞이자 프로그램이 이상한 평균을 냈다. 다른 날에는 파일 이름을 잘못 줘서 아무 메시지 없이 죽었다. 또 속도를 높이려고 측정 구조체를 바이너리 파일로 통째로 저장했더니, 다른 보드에서 읽을 때 값이 어긋났다.
메모리 그림: 파일은 바이트의 줄
텍스트로 쓴 scores.csv (사람이 읽는 글자)
k i m , 9 0 \n l e e , 7 5 \n ...
숫자 90 은 문자 '9' '0' 두 바이트 (0x39 0x30)
fgets(line, 64, fp) — 줄바꿈까지 또는 63바이트까지 읽고 끝에 \0 을 붙인다
line: ┌───┬───┬───┬───┬───┬───┬───┬───┬─────
│ k │ i │ m │ , │ 9 │ 0 │\n │\0 │ ...
└───┴───┴───┴───┴───┴───┴───┴───┴─────
strcspn 으로 \n 자리를 찾아 \0 으로 바꾼다
바이너리로 쓴 records.bin (메모리 바이트를 그대로)
Record = { uint16_t sensor_id; uint16_t flags; int32_t milli_celsius; } 8바이트
┌──────┬──────┬────────────┐┌──────┬──────┬────────────┐
│01 00 │01 00 │94 8e 00 00 ││02 00 │00 00 │1e fb ff ff │
└──────┴──────┴────────────┘└──────┴──────┴────────────┘
id=1 flag 36500(리틀 엔디언) id=2 -1250(2의 보수)
텍스트 파일에는 숫자 90 이 문자 '9' '0' 으로, 바이너리 파일에는 메모리의 바이트가 엔디언 그대로 들어간다.
char line[8] 로 긴 줄을 읽으면 fgets 는 7바이트와 널 문자만 채우고 나머지는 다음 호출로 넘긴다. 줄바꿈이 없으면 잘린 줄이다.
완성 코드
텍스트 CSV 쓰기와 읽기
#include <stdio.h>
#include <string.h>
static int write_scores(const char *path) {
FILE *fp = fopen(path, "w");
if (fp == NULL) {
perror(path);
return -1;
}
fprintf(fp, "kim,90\n");
fprintf(fp, "lee,75\n");
fprintf(fp, "park,abc\n");
fprintf(fp, "choi,88\n");
if (fclose(fp) != 0) {
perror("fclose");
return -1;
}
return 0;
}
static int read_scores(const char *path) {
FILE *fp = fopen(path, "r");
if (fp == NULL) {
perror(path);
return -1;
}
char line[64];
int line_no = 0;
int total = 0;
int ok = 0;
while (fgets(line, sizeof line, fp) != NULL) {
line_no++;
line[strcspn(line, "\n")] = '\0';
char name[16];
int score;
if (sscanf(line, "%15[^,],%d", name, &score) == 2) {
total += score;
ok++;
} else {
fprintf(stderr, "%d번째 줄 건너뜀: %s\n", line_no, line);
}
}
if (ferror(fp)) {
perror("읽기 오류");
}
fclose(fp);
if (ok > 0) {
printf("읽은 점수 %d개, 평균 %.1f\n", ok, (double)total / ok);
}
return 0;
}
int main(int argc, char *argv[]) {
const char *path = argc > 1 ? argv[1] : "scores.csv";
if (argc <= 1 && write_scores(path) != 0) {
return 1;
}
return read_scores(path) == 0 ? 0 : 1;
}
바이너리 레코드 쓰기와 읽기
#include <stdio.h>
#include <stdint.h>
typedef struct {
uint16_t sensor_id;
uint16_t flags;
int32_t milli_celsius;
} Record;
int main(void) {
Record out[2] = {
{.sensor_id = 1, .flags = 0x0001, .milli_celsius = 36500},
{.sensor_id = 2, .flags = 0x0000, .milli_celsius = -1250},
};
FILE *fp = fopen("records.bin", "wb");
if (fp == NULL) {
perror("records.bin");
return 1;
}
size_t written = fwrite(out, sizeof out[0], 2, fp);
fclose(fp);
printf("기록한 레코드 %zu개, 레코드 크기 %zu바이트\n", written, sizeof(Record));
Record in[2];
fp = fopen("records.bin", "rb");
if (fp == NULL) {
perror("records.bin");
return 1;
}
size_t read = fread(in, sizeof in[0], 2, fp);
fclose(fp);
for (size_t i = 0; i < read; i++) {
printf("센서 %u: %d.%03d도\n", (unsigned)in[i].sensor_id,
(int)(in[i].milli_celsius / 1000),
(int)(in[i].milli_celsius < 0 ? -in[i].milli_celsius : in[i].milli_celsius) % 1000);
}
return 0;
}
줄별 해설
FILE *fp = fopen(path, "w");— 모드"w"는 쓰기(있으면 내용을 지움),"r"은 읽기,"a"는 끝에 덧붙이기다. 실패하면 NULL을 돌려준다.perror(path);— 방금 실패한 원인(errno)을 사람이 읽을 문장으로 붙여 표준 오류에 찍는다. 어떤 파일에서 실패했는지 알 수 있게 경로를 앞에 준다.if (fclose(fp) != 0)— 쓰기 모드에서는 버퍼에 모아 둔 내용이fclose때 실제로 기록된다. 디스크가 가득 차는 오류가 여기서야 드러나기도 하므로 반환값을 확인한다.while (fgets(line, sizeof line, fp) != NULL)— 한 줄씩 읽는다. 버퍼 크기를 넘지 않고 항상 널 문자로 끝내므로 안전하다. 파일 끝이나 오류에서 NULL을 돌려준다.line[strcspn(line, "\n")] = '\0';— 줄 끝의 줄바꿈을 지운다. 줄바꿈이 없는 마지막 줄에서도 문자열 끝 자리에 0을 다시 쓰는 것이라 안전하다.sscanf(line, "%15[^,],%d", name, &score) == 2— 쉼표가 아닌 글자를 최대 15개 읽어name에, 쉼표 뒤를 정수로score에 넣는다. 반환값은 성공한 항목 수이므로 2가 아니면 잘못된 줄이다. 폭 15를 적지 않으면 긴 이름이name[16]을 넘친다.fprintf(stderr, ...)— 건너뛴 줄은 표준 오류로 보고한다. 정상 결과(표준 출력)와 섞이지 않아 파이프로 결과만 받아 쓸 수 있다.if (ferror(fp))—fgets가 NULL을 돌려준 이유가 "파일 끝"인지 "읽기 오류"인지 구분한다.fopen("records.bin", "wb")—b는 바이너리 모드다. 리눅스·macOS에서는 차이가 없지만 Windows에서는 텍스트 모드가 줄바꿈을 바꾸므로 바이너리 파일에는 반드시 붙인다.fwrite(out, sizeof out[0], 2, fp)— 원소 크기와 개수를 받아 메모리 바이트를 그대로 쓴다. 반환값은 실제로 쓴 원소 수다.fread(in, sizeof in[0], 2, fp)— 읽은 원소 수를 돌려준다. 파일이 짧으면 2보다 작으므로 반복문은read까지만 돈다.milli_celsius— 온도를 1000배한 정수로 저장한다. 부동소수의 바이트 표현까지 맞출 필요가 없어 형식이 단순해진다.
실제 실행 결과
이 실행은 터미널처럼 표준 출력과 표준 오류가 한 화면에 섞이는 환경에서 받았다.
$ ./scores
3번째 줄 건너뜀: park,abc
읽은 점수 3개, 평균 84.3
$ cat scores.csv
kim,90
lee,75
park,abc
choi,88
셋째 줄 park,abc는 보고하고 건너뛰었다. 평균은 (90 + 75 + 88) / 3 = 84.3이다. 없는 파일을 주면 원인이 한 줄로 나오고 종료 코드 1로 끝난다.
$ ./scores nofile.csv; echo "종료 코드: $?"
nofile.csv: No such file or directory
종료 코드: 1
$ ./binary
기록한 레코드 2개, 레코드 크기 8바이트
센서 1: 36.500도
센서 2: -1.250도
파일에 실제로 들어간 바이트를 od로 본다(-An은 주소 열 생략, -tx1은 1바이트씩 16진수). macOS의 od는 열 간격이 넓게 나오며, 리눅스 GNU od는 같은 바이트를 더 좁은 간격으로 보여 준다.
$ od -An -tx1 -v records.bin; wc -c < records.bin
01 00 01 00 94 8e 00 00 02 00 00 00 1e fb ff ff
16
메모리 그림과 정확히 같은 16바이트다. 36500(0x8e94)이 94 8e 00 00으로, 낮은 자리 바이트부터 들어갔다. 이 파일을 빅 엔디언 장비에서 같은 코드로 읽으면 0x948e0000으로 해석해 전혀 다른 온도가 나온다. 이 Record는 필드 크기가 2, 2, 4바이트라 패딩이 없지만, 10장의 Reading처럼 패딩이 있는 구조체를 그대로 쓰면 빈 바이트(쓰레기 값일 수 있음)까지 파일에 들어간다.
한눈에 보기
| 모드 | 파일이 없으면 | 기존 내용 | 용도 |
|---|---|---|---|
"r" | 실패(NULL) | 그대로 | 읽기 |
"w" | 새로 만듦 | 지움 | 새로 쓰기 |
"a" | 새로 만듦 | 뒤에 덧붙임 | 로그 |
"rb" "wb" | 위와 같음 | 위와 같음 | 바이너리(Windows 에서 필수) |
| 함수 | 버퍼 넘침 위험 | 오류 구분 | 권장 |
|---|---|---|---|
gets | 있음 | 불가 | C11 에서 제거됨 |
fscanf("%s") | 폭이 없으면 있음 | 반환값 | 폭 지정 필수 |
fgets | 없음 | NULL + ferror | 줄 단위 기본 |
atoi | 해당 없음 | 불가("abc" → 0) | strtol·sscanf 로 대체 |
실무에서 자주 틀리는 것
while (!feof(fp))로 반복한다.feof는 "읽기를 시도했다가 끝에 닿은 뒤"에야 참이 된다. 마지막 줄을 두 번 처리하는 버그가 흔하다. 읽기 함수의 반환값으로 반복을 끝낸다.fscanf(fp, "%s", buf)에 폭을 주지 않는다.gets와 같은 버퍼 오버플로가 된다.%15s처럼 버퍼 크기 - 1을 적거나,fgets로 줄을 읽은 뒤 파싱한다.atoi로 숫자를 변환한다."abc"도 0을 돌려줘 오류와 진짜 0을 구분할 수 없다.sscanf의 반환값이나strtol의 끝 포인터와errno로 검사한다.- 구조체를 통째로 파일·네트워크에 쓴다. 엔디언·패딩·자료형 크기가 바뀌면 깨진다. 장비 사이에서 주고받는 형식이라면 필드별로 바이트 순서를 명시해 한 바이트씩 조립하거나(예:
buf[0] = v & 0xff; buf[1] = (v >> 8) & 0xff;), 텍스트·JSON 같은 형식을 쓴다. - 열었던 파일을 오류 경로에서 닫지 않는다. 프로세스가 열 수 있는 파일 수에는 한도가 있어, 오래 도는 서버는 결국 "Too many open files"로 멈춘다. 9장의 정리 구간 패턴을 똑같이 적용한다.
연습 문제
binary.c의 온도 출력은 -0.250도(milli_celsius = -250)를 잘못 찍는다. 무엇이 찍히며, 어떻게 고치는가?char line[8];로"sensor-longname,42\n"한 줄을fgets로 읽으면 첫 번째 호출에서 무엇이 들어오는가? 이런 긴 줄을 감지하는 방법은?uint32_t v = 0x0a0b0c0d;를 이 기계에서fwrite(&v, 4, 1, fp)로 쓰면 파일에 어떤 순서의 바이트가 들어가는가? 어느 장비에서든 빅 엔디언 순서로 쓰는 코드를 작성하라.
정답
- 정수 나눗셈 -250 / 1000은 0 쪽으로 버려 0이 되므로
0.250도가 찍혀 부호가 사라진다(3장의 규칙). 부호를 따로 처리한다.int32_t m = in[i].milli_celsius; const char *sign = m < 0 ? "-" : ""; long a = m < 0 ? -(long)m : m; printf("%s%ld.%03ld도\n", sign, a / 1000, a % 1000);—long으로 넓힌 뒤 부호를 뒤집어INT32_MIN에서도 넘치지 않게 한다. - 최대 7바이트인
"sensor-"와 널 문자가 들어온다. 나머지는 다음fgets호출에서 이어서 읽힌다. 읽은 문자열에'\n'이 없고 파일 끝도 아니면 줄이 잘린 것이다. 그 줄은 줄바꿈이 나올 때까지 버리고 "줄이 너무 김"으로 보고하거나, 버퍼를 늘린다. - 리틀 엔디언이므로
0d 0c 0b 0a순서다. 빅 엔디언으로 쓰려면unsigned char b[4] = { (v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff }; fwrite(b, 1, 4, fp);— 시프트 연산은 값에 대해 계산하므로 장비의 엔디언과 상관없이 같은 결과를 낸다.
마지막 장에서는 이 책 내내 쓴 컴파일러 경고, UBSan, 정적 분석기에 디버거를 더해, 버그 하나를 처음부터 끝까지 추적하는 흐름으로 정리한다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.