상세 컨텐츠

본문 제목

C로 비트스트림 파싱해서 7가지 타입(char ~ double)으로 출력하기 (시스템 프로그래밍 과제)

학습·과제

by 코드스키 2026. 7. 23. 20:07

본문

 C 비트스트림 파싱과 7가지 타입 출력 대표 이미지
C 비트스트림 파싱과 7가지 타입 출력 대표 이미지

시스템 프로그래밍 수업 과제로 받은 문제인데, 요약하면 input 파일에 들어 있는 0/1 비트스트림을 처음부터 끝까지 읽어서 앞에서부터 순서대로 signed charASCIIunsigned charsigned intunsigned intfloatdouble 이렇게 7가지 타입으로 해석해서 printf로 출력하라는 거였다. 조건은 출력에 printf만 쓸 것(puts 쓰면 형식이 안 맞아서 감점), 파일 크기를 미리 모르니 동적 할당을 쓸 것, Makefile 포함해서 학번 붙인 파일명으로 제출할 것.

이 과제가 진짜 물어보는 건 두 가지다. 메모리에 있는 같은 비트열을 어떻게 다른 타입으로 해석하느냐(type punning), 그리고 C 표준 타입의 크기와 표현 방식을 정확히 아는지.

첫 번째 함정 — 입력은 비트가 아니라 문자다

처음에 헷갈렸던 게, 파일에 들어 있는 게 진짜 비트 단위 데이터가 아니라 '0''1' 문자가 ASCII로 그대로 적혀 있는 텍스트라는 점이었다. 즉 한 비트가 실제로는 1바이트를 차지한다. 파일에 "01000001"이라고 8글자가 적혀 있으면, 그게 의미하는 건 0 1 0 0 0 0 0 1 8비트, 곧 1바이트다.

그래서 제일 먼저 할 일이 이 텍스트를 읽어서 진짜 바이트 배열로 압축하는 거였다.

char ch;
unsigned char byte = 0;
int bit_count = 0;

while (fread(&ch, 1, 1, fp) == 1) {
    if (ch != '0' && ch != '1') continue;  // 개행 등 스킵
    byte = (byte << 1) | (ch - '0');
    bit_count++;
    if (bit_count == 8) {
        buffer[idx++] = byte;
        byte = 0;
        bit_count = 0;
    }
}

0/1 문자 8개를 모아서 1바이트로 만드는 거고, 비트 시프트로 한 자리씩 밀면서 OR로 새 비트를 끼워넣는 게 정석이다.

동적 할당 — 파일 크기를 모를 때

파일 크기를 미리 모르니까 malloc(1024)처럼 임의로 잡아둘 수가 없다. 두 가지 방법을 놓고 고민했다.

fseek으로 끝까지 갔다가 ftell로 위치를 확인하는 방법은 깔끔하긴 한데, 텍스트 파일에 \r\n 같은 게 섞여 있으면 실제 의미 있는 바이트 수랑 어긋날 수 있어서 좀 불안했다.

fseek(fp, 0, SEEK_END);
long size = ftell(fp);
fseek(fp, 0, SEEK_SET);
char *buf = malloc(size);
fread(buf, 1, size, fp);

결국 더 안전한 쪽인 realloc으로 동적 확장하는 방법을 썼다. 용량이 차면 두 배씩 늘리는 식이라 입력 형태가 좀 변형돼도 유연하게 대응된다.

size_t cap = 16, len = 0;
unsigned char *buf = malloc(cap);
while (...읽기...) {
    if (len == cap) {
        cap *= 2;
        buf = realloc(buf, cap);
    }
    buf[len++] = byte;
}

진짜 핵심 — 같은 메모리를 7가지 타입으로 읽기

이 과제의 본체는 같은 바이트 영역을 그대로 두고 타입만 바꿔서 읽어내는 거다. 처음엔 캐스팅이 더 간단해 보였다.

// 이렇게 하면 안 된다
float f = *(float*)p;

이게 컴파일도 되고 보통은 잘 동작하는데, 문제는 C 표준의 strict aliasing 규칙을 위반한다는 점이다. 컴파일러는 서로 다른 타입의 포인터가 같은 메모리를 가리키지 않는다고 가정하고 최적화를 하기 때문에, 위 코드는 -O2 이상에서 예측 못 한 결과를 낼 수 있다. C11 표준 6.5절 7항을 찾아보니 객체에 저장된 값은 호환되는 타입의 lvalue로만 접근할 수 있고, 예외는 character 타입(char, unsigned char)뿐이라고 명시돼 있었다.

그래서 결국 memcpy를 썼다. 내부적으로 unsigned char *로 바이트를 복사하기 때문에 strict aliasing 문제를 피할 수 있고, 현대 컴파일러는 작은 memcpy 호출을 레지스터 이동으로 최적화해줘서 성능 손해도 거의 없다.

각 타입의 크기는 signed charunsigned char가 1바이트, signed intunsigned intfloat이 4바이트, double이 8바이트로 총 23바이트가 필요했다.

unsigned char *p = buffer;

signed char sc;
memcpy(&sc, p, sizeof(sc));
printf("%d\n", sc);
p += sizeof(sc);

char asc;
memcpy(&asc, p, sizeof(asc));
printf("%c\n", asc);
p += sizeof(asc);

unsigned char uc;
memcpy(&uc, p, sizeof(uc));
printf("%u\n", uc);
p += sizeof(uc);

signed int si;
memcpy(&si, p, sizeof(si));
printf("%d\n", si);
p += sizeof(si);

unsigned int ui;
memcpy(&ui, p, sizeof(ui));
printf("%u\n", ui);
p += sizeof(ui);

float f;
memcpy(&f, p, sizeof(f));
printf("%.6f\n", f);
p += sizeof(f);

double d;
memcpy(&d, p, sizeof(d));
printf("%.6lf\n", d);
p += sizeof(d);

union으로도 같은 걸 할 수 있긴 하다. C에서는 합법인데(C++에서는 아니다), trap representation 문제가 있어서 memcpy가 더 안전하다는 쪽으로 정리했다. -fno-strict-aliasing으로 GCC의 strict aliasing 검사를 아예 꺼버리는 방법도 있고 실제로 Linux 커널이 이렇게 하는데, 학교 과제 수준에서는 굳이 쓸 이유가 없어 보였다.

형식 지정자와 헷갈리는 것들

printf만 쓰라고 한 이유가 형식 지정자가 출력의 정밀도를 결정하기 때문이었다. signed charsigned int%d, unsigned charunsigned int%u, ASCII 문자는 %c, float%f, double%lf 또는 %f를 쓴다. 채점이 소수점 자릿수까지 본다면 %.4f처럼 정밀도를 고정해두는 게 안전하다.

엔디안 문제도 신경 써야 했다. signed int, unsigned int, float, double처럼 여러 바이트를 차지하는 타입은 어떤 바이트가 가장 의미 있는 비트인지가 엔디안으로 결정된다. x86과 ARM 대부분은 little endian이라 가장 낮은 주소에 LSB가 오는데, [0x41, 0x42, 0x43, 0x44] 4바이트를 int로 읽으면 0x44434241이 된다. 내 x86 Linux 환경에서 memcpy로 읽으면 자연스럽게 little endian으로 해석되니까, 입력 데이터가 같은 엔디안 기준으로 만들어졌어야 결과가 맞는다.

C 표준이 int를 정확히 32비트라고 못박지 않는다는 것도 새삼 알게 됐다. int >= 16, long >= 32만 보장한다. 실제로는 거의 모든 현대 시스템에서 int가 4바이트지만, 이식성을 생각하면 <stdint.h>int32_t, uint32_t 같은 고정 크기 타입을 쓰는 게 안전하다는 얘기도 여기저기서 봤다.

floatdouble은 IEEE 754 방식이라, 임의의 4바이트를 float으로 해석하면 경우에 따라 NaN이나 Infinity가 나올 수도 있다. 과제 입력이 의도된 값이라 그럴 일은 없었지만, 디버그 출력을 걸어두면 확인하기 편하다.

Makefile은 채점 점수가 따로 있어서 아주 간단하게 만들었다.

CC = gcc
CFLAGS = -Wall -O2
TARGET = 20233051

$(TARGET): $(TARGET).c
    $(CC) $(CFLAGS) -o $(TARGET) $(TARGET).c

clean:
    rm -f $(TARGET)

탭으로 들여써야 하는 걸 깜빡하고 스페이스로 넣었다가 make가 "missing separator" 에러를 뱉어서 30분을 날렸다. 사소한 거지만 은근히 자주 걸리는 함정 같다.

참고

  • C11 표준 6.5절 7항 — strict aliasing 규칙
  • cppreference — memcpy, <stdint.h> 고정 크기 타입
  • IEEE 754 — float/double 표현 방식

타입 크기와 출력 형식은 따로 검증하기

비트스트림을 7가지 타입으로 바꾸는 과제에서 가장 위험한 부분은 “내 컴퓨터에서는 이 크기였다”를 표준처럼 쓰는 것이다. C의 산술 타입 크기는 구현에 따라 달라질 수 있고, printf 형식 지정자 불일치는 정의되지 않은 동작으로 이어질 수 있다. sizeof, 고정폭 정수형, 엔디언, 부동소수점 표현을 따로 확인했다.

cppreference C 산술 타입 문서
cppreference C 산술 타입 문서
POSIX printf 함수 명세
POSIX printf 함수 명세
IEEE 754 부동소수점 표준 페이지
IEEE 754 부동소수점 표준 페이지
GCC 기타 내장 함수 공식 문서
GCC 기타 내장 함수 공식 문서
#include <stdint.h>
#include <inttypes.h>

uint32_t value = 0;
printf("%" PRIu32 "
", value);

고정폭 타입은 파일 포맷처럼 비트 수가 계약인 경우에 특히 유용하다. 부동소수점은 바이트 배열을 포인터 캐스팅으로 억지 해석하기보다 memcpy로 옮긴 뒤 대상 형식과 엔디언을 명시하는 편이 안전하다.

공식 자료

관련글 더보기