Devin.KR

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' 으로, 바이너리 파일에는 메모리의 바이트가 엔디언 그대로 들어간다.

텍스트 파일에는 숫자 90 이 문자 '9' '0' 으로, 바이너리 파일에는 메모리의 바이트가 엔디언 그대로 들어간다.

char line[8] 로 긴 줄을 읽으면 fgets 는 7바이트와 널 문자만 채우고 나머지는 다음 호출로 넘긴다. 줄바꿈이 없으면 잘린 줄이다.

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처럼 패딩이 있는 구조체를 그대로 쓰면 빈 바이트(쓰레기 값일 수 있음)까지 파일에 들어간다.

한눈에 보기

fopen 모드
모드파일이 없으면기존 내용용도
"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장의 정리 구간 패턴을 똑같이 적용한다.

연습 문제

  1. binary.c의 온도 출력은 -0.250도(milli_celsius = -250)를 잘못 찍는다. 무엇이 찍히며, 어떻게 고치는가?
  2. char line[8];"sensor-longname,42\n" 한 줄을 fgets로 읽으면 첫 번째 호출에서 무엇이 들어오는가? 이런 긴 줄을 감지하는 방법은?
  3. uint32_t v = 0x0a0b0c0d;를 이 기계에서 fwrite(&v, 4, 1, fp)로 쓰면 파일에 어떤 순서의 바이트가 들어가는가? 어느 장비에서든 빅 엔디언 순서로 쓰는 코드를 작성하라.

정답

  1. 정수 나눗셈 -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에서도 넘치지 않게 한다.
  2. 최대 7바이트인 "sensor-"와 널 문자가 들어온다. 나머지는 다음 fgets 호출에서 이어서 읽힌다. 읽은 문자열에 '\n'이 없고 파일 끝도 아니면 줄이 잘린 것이다. 그 줄은 줄바꿈이 나올 때까지 버리고 "줄이 너무 김"으로 보고하거나, 버퍼를 늘린다.
  3. 리틀 엔디언이므로 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

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.