C · 심화
포인터·메모리 안전·시스템 프로그래밍
비트 연산과 바이트 순서 - 파일 형식을 직접 읽고 쓰기
플래그 비트 마스크, 시프트의 부호 함정, 고정 폭 정수 stdint.h, 엔디언 확인과 변환, 구조체를 바로 fwrite 하면 안 되는 이유와 직렬화 함수
개발자KR · 원고 갱신
이 장에서 배우는 것
메모리에 있는 데이터를 파일로 저장하거나 네트워크로 전송하려면, 프로그램이 사용하는 내부 데이터를 바이트(byte) 단위의 흐름으로 변환해야 한다. 앞 장에서 모듈을 분리하고 불투명 타입으로 인터페이스를 설계하는 방법을 다루었다면, 이번 장에서는 프로그램과 외부 세계가 소통하는 가장 낮은 단위인 비트(bit)와 바이트를 직접 다루는 방법을 알아본다. 메모리에 데이터가 어떻게 배치되는지 이해하고, 환경이 달라져도 깨지지 않는 안전한 파일 형식을 설계하는 것이 목표다.
- 크기가 고정된 정수 타입(
stdint.h)을 사용하여 플랫폼 독립적인 데이터를 표현한다. - 비트 마스크(bit mask)와 시프트(shift) 연산자를 활용하여 여러 상태를 하나의 변수에 압축하고 추출한다.
- 부호 있는 정수의 시프트 연산이 일으키는 부호 확장(sign extension) 문제를 이해하고 피한다.
- 빅 엔디언(Big-endian)과 리틀 엔디언(Little-endian)의 차이를 이해한다.
- 구조체를
fwrite로 직접 기록할 때 발생하는 패딩(padding)과 이식성 문제를 해결하는 직렬화(serialization) 기법을 구현한다.
문제 상황
작은 도서관 관리 프로그램에서 도서 정보를 파일로 저장하는 기능을 구현해야 한다. 한 개발자가 도서 번호, 출판 연도, 도서 상태 플래그를 담은 구조체를 정의하고, 이를 통째로 fwrite 함수를 이용해 이진 파일(binary file)로 저장했다.
#include <stdio.h>
#include <stdint.h>
struct BadBookRecord {
uint32_t id;
uint8_t flags;
uint32_t published_year;
};
/* 개발자가 작성한 저장 함수 (문제가 발생한다) */
int save_bad_book(FILE *file, const struct BadBookRecord *book) {
if (fwrite(book, sizeof(struct BadBookRecord), 1, file) != 1) {
return -1;
}
return 0;
}
이 코드는 개발자의 컴퓨터에서는 정상적으로 작동하고, 읽어올 때도 문제가 없었다. 하지만 이 파일을 다른 운영체제나 다른 프로세서(아키텍처)를 사용하는 서버로 옮겨서 읽어 들였더니, 출판 연도에 알 수 없는 큰 숫자가 나타나고 도서 상태 플래그가 엉뚱한 값으로 인식되는 현상이 발생했다.
이러한 현상이 나타나는 원인은 두 가지다. 첫째, 컴파일러가 메모리 접근 효율을 높이기 위해 구조체 멤버 사이에 빈 공간(패딩)을 삽입하기 때문이다. 패딩의 크기와 위치는 컴파일러와 아키텍처마다 다르다. 둘째, 다중 바이트 정수(예: 32비트 정수)를 메모리에 저장할 때 바이트를 배열하는 순서(엔디언)가 컴퓨터마다 다르기 때문이다. 이 장에서는 이런 함정을 피하고 어떤 컴퓨터에서도 안전하게 읽고 쓸 수 있는 파일 형식을 만드는 방법을 살펴본다.
고정 폭 정수와 비트 연산
파일 형식이나 네트워크 프로토콜을 정의할 때는 데이터의 크기가 변하지 않아야 한다. C 표준의 int나 long은 시스템에 따라 4바이트일 수도, 8바이트일 수도 있다. 따라서 이진 데이터를 다룰 때는 <stdint.h> 헤더에서 제공하는 고정 폭 정수(fixed-width integer) 타입을 사용한다.
가장 많이 쓰이는 타입은 부호 없는 8비트 정수 uint8_t(1바이트), 16비트 정수 uint16_t(2바이트), 32비트 정수 uint32_t(4바이트)다. 부호 없는(unsigned) 정수를 사용하는 이유는 비트 연산을 할 때 예기치 않은 동작을 방지하기 위해서다.
여러 개의 참/거짓 상태를 저장할 때는 변수 하나에 각각의 상태를 비트 단위로 나누어 담을 수 있다. 이를 비트 플래그(bit flag)라고 한다. 각 상태는 2의 거듭제곱 값을 가지며, 비트 OR 연산자(|)로 상태를 결합하고, 비트 AND 연산자(&)로 특정 상태가 켜져 있는지 확인한다.
#define FLAG_BORROWED 0x01 /* 이진수 0000 0001 */
#define FLAG_RESERVED 0x02 /* 이진수 0000 0010 */
#define FLAG_LOST 0x04 /* 이진수 0000 0100 */
uint8_t status = 0;
status = status | FLAG_BORROWED | FLAG_RESERVED; /* 대출 중이면서 예약됨 상태로 설정 */
if (status & FLAG_BORROWED) {
/* 대출 중일 때 실행되는 코드 */
}
비트를 끄고 싶을 때는 비트 NOT 연산자(~)와 AND 연산자를 조합하여 마스킹(masking)을 수행한다. status & ~FLAG_RESERVED 연산은 다른 비트는 그대로 둔 채 예약 상태 비트만 0으로 만든다.
시프트 연산과 부호 확장 함정
시프트 연산자는 정수의 비트를 왼쪽(<<)이나 오른쪽(>>)으로 밀어낸다. 왼쪽 시프트는 지정한 비트 수만큼 왼쪽으로 이동시키고 빈자리를 0으로 채운다. 문제는 오른쪽 시프트 연산을 수행할 때 발생한다.
C 표준에 따르면, 부호 없는 정수(unsigned integer)를 오른쪽으로 시프트하면 빈자리는 항상 0으로 채워진다(논리 시프트). 하지만 부호 있는 정수(signed integer)가 음수일 때 오른쪽으로 시프트하면 빈자리를 부호 비트인 1로 채울지, 0으로 채울지는 구현 정의(implementation-defined)로 남겨져 있다. 대부분의 컴파일러는 부호를 유지하기 위해 1로 채우며 이를 산술 시프트라고 한다.
이 현상을 부호 확장(sign extension)이라고 부르며, 바이트 단위로 데이터를 쪼개거나 합칠 때 의도치 않은 값을 만들어낸다. int8_t 변수에 0x80(음수 -128)이 들어있을 때 이를 더 큰 부호 있는 타입으로 암묵적 변환을 거친 뒤 비트 연산을 수행하면 상위 비트가 모두 1로 채워진 0xFFFFFF80이 되어 데이터가 망가진다. 따라서 데이터를 비트 단위로 쪼개고 합치는 직렬화 과정에서는 반드시 모든 변수와 피연산자를 uint8_t, uint32_t 같은 부호 없는 타입으로 선언하고 캐스팅해야 한다.
바이트 순서(엔디언)와 안전한 직렬화
32비트 정수 0x12345678은 4바이트 크기다. 이 4개의 바이트(12, 34, 56, 78)를 메모리의 낮은 주소부터 어떻게 배열할 것인지는 컴퓨터의 중앙 처리 장치(CPU) 설계에 따라 다르다.
빅 엔디언(Big-endian)은 사람이 숫자를 읽는 것처럼 가장 큰 자릿수(최상위 바이트, 0x12)를 가장 낮은 메모리 주소에 먼저 저장한다. 반면, 리틀 엔디언(Little-endian)은 가장 작은 자릿수(최하위 바이트, 0x78)를 가장 낮은 메모리 주소에 먼저 저장한다. 오늘날 데스크톱과 서버에서 주로 쓰이는 x86 및 ARM 프로세서는 대부분 리틀 엔디언을 사용하지만, 인터넷 프로토콜(TCP/IP)과 많은 표준 파일 형식은 빅 엔디언을 표준 바이트 순서(네트워크 바이트 순서라고도 부름)로 규정하고 있다.
이러한 차이 때문에 구조체 포인터를 파일에 바로 fwrite 하는 코드는 시스템 간 이식성이 없다. 패딩 문제와 엔디언 문제를 동시에 해결하는 가장 확실하고 안전한 방법은 바이트 배열을 하나 준비하고, 비트 시프트 연산을 이용해 바이트를 하나씩 명시적인 순서로 채워 넣는 것이다. 이를 직렬화(serialization)라고 한다.
예를 들어 32비트 정수를 빅 엔디언 순서로 직렬화하려면 다음과 같이 시프트와 0xFF 마스크를 사용한다.
위 그림처럼 데이터를 직접 시프트하여 바이트를 추출하면 컴파일러가 패딩을 얼마나 넣었는지, 현재 CPU가 리틀 엔디언인지 빅 엔디언인지 전혀 신경 쓰지 않아도 된다. 시프트 연산 자체는 엔디언에 무관하게 항상 논리적인 수학적 값을 기준으로 동작하기 때문이다.
완성 코드
앞서 설명한 개념을 종합하여 도서관 대출 관리 프로그램의 도서 정보를 안전하게 파일로 저장하고 불러오는 모듈을 작성한다.
book_format.h
#ifndef BOOK_FORMAT_H
#define BOOK_FORMAT_H
#include <stdint.h>
#include <stdio.h>
/* 도서 상태 비트 플래그 */
#define BOOK_FLAG_BORROWED 0x01
#define BOOK_FLAG_RESERVED 0x02
#define BOOK_FLAG_LOST 0x04
#define BOOK_FLAG_DAMAGED 0x08
/* 도서 정보 구조체 */
struct BookRecord {
uint32_t id; /* 도서 고유 번호 */
uint32_t published_year; /* 출판 연도 */
uint8_t flags; /* 상태 플래그 */
};
/* 함수 원형 선언 */
int save_book(FILE *file, const struct BookRecord *book);
int load_book(FILE *file, struct BookRecord *book);
#endif /* BOOK_FORMAT_H */
book_format.c
#include "book_format.h"
/* 도서 정보를 빅 엔디언 바이너리 형식으로 파일에 저장한다 */
int save_book(FILE *file, const struct BookRecord *book) {
uint8_t buf[9];
/* 1. id 직렬화 (4바이트, 빅 엔디언) */
buf[0] = (uint8_t)((book->id >> 24) & 0xFF);
buf[1] = (uint8_t)((book->id >> 16) & 0xFF);
buf[2] = (uint8_t)((book->id >> 8) & 0xFF);
buf[3] = (uint8_t)(book->id & 0xFF);
/* 2. published_year 직렬화 (4바이트, 빅 엔디언) */
buf[4] = (uint8_t)((book->published_year >> 24) & 0xFF);
buf[5] = (uint8_t)((book->published_year >> 16) & 0xFF);
buf[6] = (uint8_t)((book->published_year >> 8) & 0xFF);
buf[7] = (uint8_t)(book->published_year & 0xFF);
/* 3. flags 직렬화 (1바이트) */
buf[8] = book->flags;
/* 9바이트를 파일에 한 번에 기록한다 */
if (fwrite(buf, 1, 9, file) != 9) {
return -1;
}
return 0;
}
/* 파일에서 빅 엔디언 바이너리 데이터를 읽어 도서 정보 구조체를 채운다 */
int load_book(FILE *file, struct BookRecord *book) {
uint8_t buf[9];
/* 9바이트를 파일에서 읽는다 */
if (fread(buf, 1, 9, file) != 9) {
return -1;
}
/* 1. id 역직렬화 */
book->id = ((uint32_t)buf[0] << 24) |
((uint32_t)buf[1] << 16) |
((uint32_t)buf[2] << 8) |
((uint32_t)buf[3]);
/* 2. published_year 역직렬화 */
book->published_year = ((uint32_t)buf[4] << 24) |
((uint32_t)buf[5] << 16) |
((uint32_t)buf[6] << 8) |
((uint32_t)buf[7]);
/* 3. flags 역직렬화 */
book->flags = buf[8];
return 0;
}
main.c
#include <stdio.h>
#include "book_format.h"
int main(void) {
const char *filename = "library_data.bin";
/* 저장할 원본 도서 정보 구성 */
struct BookRecord original_book;
original_book.id = 12345678;
original_book.published_year = 2024;
original_book.flags = BOOK_FLAG_BORROWED | BOOK_FLAG_RESERVED;
/* 바이너리 쓰기 모드로 파일 열기 */
FILE *out = fopen(filename, "wb");
if (out == NULL) {
fprintf(stderr, "파일을 열 수 없습니다.\n");
return 1;
}
if (save_book(out, &original_book) != 0) {
fprintf(stderr, "저장에 실패했습니다.\n");
fclose(out);
return 1;
}
fclose(out);
/* 파일에서 읽어올 구조체 */
struct BookRecord loaded_book;
/* 바이너리 읽기 모드로 파일 열기 */
FILE *in = fopen(filename, "rb");
if (in == NULL) {
fprintf(stderr, "파일을 열 수 없습니다.\n");
return 1;
}
if (load_book(in, &loaded_book) != 0) {
fprintf(stderr, "불러오기에 실패했습니다.\n");
fclose(in);
return 1;
}
fclose(in);
/* 결과 출력 */
printf("도서 ID: %u\n", loaded_book.id);
printf("출판 연도: %u\n", loaded_book.published_year);
if (loaded_book.flags & BOOK_FLAG_BORROWED) {
printf("대출 상태: 대출 중\n");
} else {
printf("대출 상태: 비치 중\n");
}
if (loaded_book.flags & BOOK_FLAG_RESERVED) {
printf("예약 상태: 예약됨\n");
} else {
printf("예약 상태: 예약 없음\n");
}
return 0;
}
줄별 해설
book_format.h 파일에서 #define BOOK_FLAG_BORROWED 0x01과 같이 상태 플래그를 정의했다. 16진수 표기법을 사용하면 각 비트의 위치를 시각적으로 파악하기 쉽다. 0x01, 0x02, 0x04, 0x08은 이진수로 변환할 때 1인 비트가 겹치지 않는 값들이다.
book_format.c의 save_book 함수를 살펴보자. 파일에 쓸 데이터를 담을 9바이트 크기의 buf 배열을 선언했다. 도서 번호(4바이트), 출판 연도(4바이트), 플래그(1바이트)를 합친 크기다. buf[0] = (uint8_t)((book->id >> 24) & 0xFF); 코드는 도서 번호 변수에서 가장 윗부분 8비트를 추출하여 배열의 첫 번째 칸에 넣는다. & 0xFF를 수행하여 혹시 모를 상위 비트 오염을 방지하고 정확히 하위 8비트만 걸러낸다. 이 과정을 차례대로 거치면 데이터는 빅 엔디언 형식으로 buf에 차곡차곡 쌓이게 되며, fwrite 한 번으로 안전하게 파일에 기록된다.
load_book 함수에서는 역직렬화를 수행한다. 파일에서 9바이트를 먼저 읽어온 후, 각 바이트를 제자리로 돌려놓기 위해 왼쪽 시프트 연산을 사용한다. ((uint32_t)buf[0] << 24)처럼 배열 요소 하나를 uint32_t로 형변환한 뒤 시프트하는 것이 매우 중요하다. 형변환을 하지 않고 시프트하면 C 언어의 정수 승격 규칙에 따라 부호 있는 int로 취급되어 부호 확장 문제가 발생할 수 있다. 네 부분의 바이트를 시프트한 뒤 비트 OR(|) 연산자로 하나로 합친다.
main.c에서는 파일을 생성하고(wb 모드) 다시 읽어(rb 모드) 올바르게 데이터가 복원되었는지 검증한다. 조건문 if (loaded_book.flags & BOOK_FLAG_BORROWED)는 플래그 변수와 대출 마스크를 AND 연산하여, 해당 위치의 비트가 1로 설정되어 있는지 검사하는 정석적인 패턴이다.
실행 결과
프로그램을 컴파일하고 실행한 결과는 다음과 같다. 바이너리 모드로 저장하고 읽어오는 과정이 성공적으로 수행되었으며, 플래그를 통해 설정한 상태가 정확히 복원되었다.
$ cc -std=c17 -Wall -Wextra main.c book_format.c -o library_app
$ ./library_app
도서 ID: 12345678
출판 연도: 2024
대출 상태: 대출 중
예약 상태: 예약됨
실무에서 자주 틀리는 것
부호 있는 정수의 시프트와 비트 연산
비트 연산을 다룰 때 부호 있는 타입(int, int8_t 등)을 사용하는 실수가 잦다.
/* 틀린 코드 */
char byte = 0x80; /* 대부분의 환경에서 -128로 취급됨 */
uint32_t val = (byte << 24); /* 부호 확장이 발생하여 상위 비트가 모두 1로 채워질 수 있음 */
/* 고친 코드 */
uint8_t byte = 0x80;
uint32_t val = ((uint32_t)byte << 24); /* 명시적으로 부호 없는 타입으로 캐스팅하여 안전하게 시프트 */
데이터 패킷을 다루는 모든 변수와 구조체 멤버는 unsigned 계열 타입을 사용하는 것이 안전하다.
포인터 변수를 포함한 구조체의 직접 파일 기록
구조체를 fwrite로 그대로 저장할 때, 구조체 안에 메모리 주소를 가리키는 포인터가 있으면 심각한 오류가 발생한다.
struct BookName {
uint32_t id;
char *title; /* 포인터 변수 */
};
/* 틀린 코드 */
fwrite(&book_name, sizeof(struct BookName), 1, file);
파일에 기록되는 것은 문자열 내용이 아니라 포인터가 가리키는 휘발성 메모리 주소값이다. 프로그램을 다시 실행하거나 다른 컴퓨터에서 읽을 때 이 주소는 유효하지 않다. 포인터가 가리키는 실제 데이터(문자열)의 길이를 구하고 데이터 자체를 직렬화하여 기록해야 한다.
한눈에 보기
| 연산자 | 이름 | 설명 | 사용 예 (상태 플래그) |
|---|---|---|---|
| |
비트 OR | 두 비트 중 하나라도 1이면 1 | flags | FLAG_A (상태 켜기) |
& |
비트 AND | 두 비트가 모두 1이어야 1 | flags & FLAG_A (상태 확인) |
~ |
비트 NOT | 0은 1로, 1은 0으로 반전 | flags & ~FLAG_A (상태 끄기) |
^ |
비트 XOR | 두 비트가 다르면 1, 같으면 0 | flags ^ FLAG_A (상태 토글) |
| 타입 | 크기(바이트) | 부호 여부 | 주요 용도 |
|---|---|---|---|
uint8_t |
1 | 없음 | 바이트 배열, 직렬화 버퍼, 플래그 모음 |
uint16_t |
2 | 없음 | 포트 번호, 짧은 길이 필드 |
uint32_t |
4 | 없음 | 고유 ID, 날짜/시간 타임스탬프 |
int32_t |
4 | 있음 | 음수가 발생할 수 있는 좌표값이나 오프셋 |
연습 문제
- 도서 상태 플래그에서
BOOK_FLAG_LOST(분실)와BOOK_FLAG_DAMAGED(파손) 상태를 동시에 설정하는 C 코드를 작성하라. 변수 이름은uint8_t status로 가정한다. - 16비트 부호 없는 정수
uint16_t port = 0x3039;가 주어졌다. 이를 빅 엔디언 순서로uint8_t buf[2]배열에 직렬화하는 코드를 작성하라. - 구조체를
fwrite로 직접 파일에 썼을 때 생길 수 있는 문제 두 가지를 간략히 설명하라.
정답과 해설
status = status | BOOK_FLAG_LOST | BOOK_FLAG_DAMAGED;혹은 복합 대입 연산자를 사용하여status |= (BOOK_FLAG_LOST | BOOK_FLAG_DAMAGED);로 작성한다. 비트 OR 연산은 여러 플래그를 한 번에 결합할 때 사용한다.-
상위 8비트를 추출하기 위해 오른쪽으로 8번 시프트하고, 하위 8비트는 시프트 없이 마스킹만 수행하여 배열에 순서대로 담는다.buf[0] = (uint8_t)((port >> 8) & 0xFF); buf[1] = (uint8_t)(port & 0xFF); - 첫째, 컴파일러가 구조체 멤버 사이에 삽입하는 패딩(padding)의 크기와 위치가 환경마다 달라 파일 형식이 깨질 수 있다. 둘째, 다중 바이트 데이터를 기록할 때 CPU의 엔디언(바이트 순서) 방식에 따라 바이트 배열 순서가 달라져 이식성이 없다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.