C 언어 시리즈 #07 — 구조체·공용체·비트필드·패딩과 ABI
이 글의 핵심
왜 컴파일러가 패딩을 넣는지, offsetof가 왜 필요한지, union으로 타입을 바꿔 읽기가 C와 C++에서 어떻게 다르게 취급되는지, 비트필드가 endian·정렬에 어떻게 묶이는지 정리합니다.
시리즈 안내
struct를 선언하면 필드가 선언한 순서대로 메모리에 놓이는 것은 맞지만, 빈틈 없이 붙어 있지는 않습니다. 필드 사이에는 정렬을 맞추기 위한 패딩이 끼고, 끝에도 꼬리 패딩이 붙습니다. 이 사실을 모른 채 struct를 소켓이나 파일에 그대로 쓰면, 컴파일러·아키텍처·옵션이 바뀌는 순간 필드가 몇 바이트씩 밀려 읽히는 버그가 생깁니다. 이 글은 교과서 순서 대신 실제로 문제가 터지는 지점(와이어 형식·ABI·성능)부터 짚고, 그다음에 기초 문법으로 돌아옵니다.
먼저 터지는 쪽: “구조체만 믿고 바깥(와이어)에 싣기”
운영에서 가장 먼저 터지는 가정이 하나 있습니다. “내부에서 struct로 쓰던 레이아웃이 파일·네트워크·다른 머신에도 그대로 통한다”는 가정입니다. 대개 틀립니다. 정렬·패딩뿐 아니라 엔디안, long/size_t/enum 폭, 컴파일 옵션, 심지어 #pragma pack 한 줄이 레이아웃을 갈라 놓기도 하기 때문입니다.
따라서 프로덕션 코드의 상투적인 결론은 이것입니다. 내부 객체는 struct로 두고, 밖으로 나가는 것은 uint8_t 스트림에 명시적으로 조립/파싱합니다(엔디안 정책까지 문서에 못 박습니다). 아래처럼 “LE로 간다”를 코드로 못 박는 팀이 많습니다.
#include <stdint.h>
/* LE로 보낸다 — 바이트 인덱스에 의미 둬야 함 */
static void write_u16_le(unsigned char *p, uint16_t v) {
p[0] = (unsigned char)(v); /* LSB 먼저 */
p[1] = (unsigned char)(v >> 8);
}
static void write_u32_le(unsigned char *p, uint32_t v) {
p[0] = (unsigned char)(v);
p[1] = (unsigned char)(v >> 8);
p[2] = (unsigned char)(v >> 16);
p[3] = (unsigned char)(v >> 24);
}
static uint32_t read_u32_le(const unsigned char *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
/* enum을 와이어에 "그대로" 쓰지 말고 u8로 고정 */
struct wire_header_le {
uint8_t kind; /* 1=hello, 2=ping … (문서에 표로 따로) */
uint8_t reserved;
uint16_t u16; /* 정책: LE */
uint32_t u32; /* 정책: LE */
};
/* 실전에선 send(이 struct 직투) 말고 버퍼에 pack 한 다음 보낸다 */
enum의 기저 정수 크기는 구현·옵션에 따라 흔들리기 때문에, 원격/파일 쪽 kind는 uint8_t로 폭을 못 박는 편이 안전합니다(의미는 #define이나 문서에 상수로 따로 둡니다). 문법적으로 enum을 “그냥” 쓰는 것과 와이어에 넣는 정수를 단계별로 나누는 것이 팀 규칙으로 정착하는 경우가 많습니다.
또 하나, struct를 통째로 read()/소켓에 투사하면 엔디안·패딩·정렬 전부가 “내가 생각한 그림”과 다를 수 있습니다. 이때 offsetof+_Static_assert로 “지금 이 TU·이 옵션”에서의 상수는 잡을 수 있지만(바로 아래 절), “상대 머신”까지 같은 이미지를 보장하려면 결국 직렬화가 따로 필요합니다.
offsetof·sizeof·_Static_assert — “지금 툴체인이 짠 레이아웃”을 잡는 도구
ABI가 고정되어 있거나(디스크/네트워크) 파일 헤더 크기를 딱 맞춰야 할 때, 필드를 손대면 offsetof로 위치를 다시 확인해야 합니다. “상수에 숫자를 하드코딩”하기보다 한 번 측정·고정하는 쪽이 회귀에 강합니다.
#include <stdint.h>
#include <stddef.h>
struct on_disk_header {
uint8_t magic[4];
uint16_t version; /* “디스크는 항상 LE” 정책이 있다고 가정 */
uint8_t flags;
uint8_t reserved0;
uint32_t body_len;
};
_Static_assert(sizeof(struct on_disk_header) == 12, "unexpected header size");
_Static_assert(offsetof(struct on_disk_header, body_len) == 8, "unexpected offset");
offsetof는 “이 컴파일/옵션/플랫폼”의 현재 레이아웃이며, 크로스 환경에서 “완전 동일”을 원한다면 빌드가 내놓는 상수나 테스트로 회귀를 막는 쪽이 현실적입니다. 엔디안이 다르면 멀티바이트 값은 읽은 뒤 le32toh·htole32 같은 변환 함수(glibc·BSD 확장)나 앞의 read_u32_le처럼 바이트 단위 조립으로 맞추는 한 겹이 더 붙습니다(내부 struct와 “와이어 바이트”는 계속 분리합니다).
정렬과 패딩
하드웨어와 생성 코드는 타입 T에 대해 주소가 alignof(T)에 맞을 때 로드/스토어가 빠르거나 (혹은 그때만 합법이라) 그렇게 쓰입니다. 따라서 컴파일러는 struct 안에 중간 패딩을 집어넣고, 배열로 이어 붙일 때를 대비해 꼬리 패딩도 넣습니다. sizeof(S)는 “필드 합 + α”이고, α는 정렬·배열 규칙에서 나옵니다.
packed가 얽힌 버그는 찾기 어려운 편입니다. __attribute__((packed)) 구조체의 멤버를 h.len처럼 이름으로 읽으면 컴파일러가 정렬되지 않은 접근임을 알고 안전한 코드를 만들어 줍니다. 문제는 &h.len으로 포인터를 꺼내 uint32_t *로 넘기는 순간입니다. 그 포인터를 받은 함수는 정렬된 주소라고 가정하고 일반 로드 명령을 쓰므로, 비정렬 접근을 허용하는 x86-64에서는 조용히 동작하고 이를 허용하지 않는 일부 ARM·RISC-V 코어나 SIMD 명령이 섞인 최적화 빌드에서는 예외로 죽습니다. GCC 9 이상은 이런 코드에 -Waddress-of-packed-member 경고를 냅니다. packed는 구조체를 작게 만드는 대신 비정렬 접근 비용과 이식성 문제를 함께 가져옵니다.
struct naive {
char c1; /* 1 */
int x; /* alignof(int)가 4인 환경이면 앞에 3바이트 패딩 */
char c2; /* 뒤에 꼬리 패딩 3바이트 → 흔히 sizeof == 12 */
};
#include <stdio.h>
#include <stddef.h>
int main(void) {
printf("sizeof(naive) = %zu\n", sizeof(struct naive));
printf("offsetof c1 = %zu\n", offsetof(struct naive, c1));
printf("offsetof x = %zu\n", offsetof(struct naive, x));
printf("offsetof c2 = %zu\n", offsetof(struct naive, c2));
return 0;
}
“개념도”는 머릿속에 이렇게 잡아 둡니다(실제 sizeof는 반드시 측정하세요).
flowchart LR
subgraph M["struct naive (개념적 바이트 슬롯)"]
B0["c1"]
P1["pad"]
P2["pad"]
P3["pad"]
I0["x (4 bytes)"]
B1["c2"]
TAIL["pad ..."]
end
필드 순서를 int, char, char처럼 큰 것을 앞에 두는 식의 재배치는, ABI/가독성을 해치지 않는 범위에서 공간/캐시에 도움이 될 수 있습니다(아래 성능 절).
C11 stdalign로 “더 강한 정렬”이 필요한 경우(캐시 라인, SIMD, MMIO)에는 _Alignas를 씁니다.
#include <stdalign.h>
struct hot {
_Alignas(64) double v[8];
int tag;
};
_Static_assert(alignof(struct hot) % 64 == 0, "alignment assumption failed");
정렬이 안 맞는 주소의 멀티바이트 값을 일반 포인터로 역참조하는 것은 C에서 정의되지 않은 동작이고, 플랫폼에 따라 느리거나 예외를 냅니다. 바이트 버퍼 안의 값을 꺼낼 때는 memcpy로 정렬된 임시 변수에 복사해 읽는 것이 표준적인 해법이며, 최신 컴파일러는 이 memcpy를 단일 로드 명령으로 최적화합니다.
이제 기초로 내려가기: struct 선언·초기화·. / ->
struct는 사용자 정의 집계 타입이고, 멤버마다 (대부분) 오프셋이 정해집니다. 태그 S는 struct S에서 따로 쓰입니다. typedef로 반복 키워드를 줄이는 것은 팀 스타일입니다.
#include <stdio.h>
struct point {
int x;
int y;
};
/* typedef로 매번 struct 키워드를 쓰지 않게 함 */
typedef struct {
double re;
double im;
} cpx_t;
int main(void) {
struct point p1;
p1.x = 3;
p1.y = 4;
printf("%d %d\n", p1.x, p1.y);
cpx_t z = {1.0, 0.0};
(void)z;
return 0;
}
struct가 불완전 타입이면(선언만 있고 정의가 없음) 그 시점에는 값으로 둔 객체를 만들 수 없습니다. 전방 선언 struct node; 후 포인터만 쓰는 opaque 핸들이 여기서 출발합니다(아래 Opaque pointer 절).
초기화: 정적/스레드 저장 기간 객체라면 쓰지 않은 멤버는 (규칙에 따라) 0이 됩니다. 자동 지역 struct는 건드리지 않으면 쓰레기 값일 수 있어서, 실무에서는 {0}을 기본으로 씁니다.
#include <stddef.h>
struct item {
int id;
const char *name;
size_t refcount;
};
void demo_init(void) {
struct item a = {0}; /* 모든 멤버가 0/NULL. 패딩 바이트가 0이 된다는 보장은 없음 */
struct item b = {1, "foo", 0};
(void)a;
(void)b;
}
memset으로 0을 채우는 것은 “비트 0” 관점에서는 쓸 만하지만, 부동소수/포인터/원자 타입의 의미까지 항상 ‘논리적 0’을 보장하려고 쓰기에는 경우가 갈립니다. 직렬화/역직렬화는 memcpy로 “바이트 이미지”를 다루는 층을 따로 두는 경우가 많습니다.
접근: 값이면 s.member, 포인터면 p->member를 씁니다((*p).member와 같습니다).
struct buf {
size_t len;
unsigned char *data;
};
void bump_len(struct buf *b) {
if (b == NULL) {
return;
}
b->len += 1U; /* b가 가리키는 객체의 len을 올림 */
}
자주 쓰는 패턴: 복사, designated init, 배열, 중첩, FAM
struct는 대입하면 값이 복사됩니다(얕은 복사: 포인터는 주소만 복사됩니다).
#include <string.h>
struct chunk {
size_t n;
char *p;
};
void shallow(struct chunk a) {
/* a는 복사본인데, p는 같은 곳 가리킬 수 있음 — 깊은 복사는 따로 */
(void)a;
}
struct chunk dup_chunk(const struct chunk *src) {
struct chunk out = *src;
return out;
}
C99 designated initializer로 필드 이름에 맞춰 값을 넣습니다(옵션이 늘어날 때 강합니다).
struct opts {
int port;
int backlog;
int reuseaddr;
};
struct opts o = {
.port = 8080,
.backlog = 128,
.reuseaddr = 1,
};
struct 배열은 원소마다 sizeof(T) 간격(패딩 포함)이므로, “바이트를 한 줄로” 쓰려면 구조/직렬화를 별도로 설계해야 합니다.
struct particle {
float x, y, z;
};
void move(struct particle *ps, size_t n, float dt) {
for (size_t i = 0; i < n; i++) {
ps[i].x += dt; /* ps[i]는 sizeof(struct particle) 간격으로 계산됨 */
}
}
중첩은 그대로 struct 멤버로 품습니다.
struct mat3 {
float m[3][3];
};
struct transform {
struct mat3 r;
float tx, ty;
};
int main(void) {
struct transform t = {
.r = {{{1,0,0},{0,1,0},{0,0,1}}},
.tx = 0.0f,
.ty = 0.0f,
};
(void)t;
return 0;
}
FAM(C99, 마지막 멤버만 T arr[])은 malloc(sizeof(struct packet) + n * sizeof(T))로 한 덩어리로 할당하는 패턴이 흔합니다.
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
struct packet {
uint32_t len;
unsigned char data[]; /* FAM: sizeof에 포함되지 않음 */
};
struct packet *make_packet(const void *src, size_t n) {
if (n > 0xFFFFFFFFu) {
return NULL;
}
struct packet *p = malloc(sizeof *p + n); /* 꼬리 n바이트까지 같이 */
if (!p) {
return NULL;
}
p->len = (uint32_t)n;
if (n && src) {
memcpy(p->data, src, n);
}
return p;
}
FAM이 있으면 sizeof는 꼬리를 세지 않습니다 → 복제/직렬화는 항상 len 같은 명시 필드로 길이를 추적합니다. 구조체를 *dst = *src로 대입하면 FAM 부분은 복사되지 않으므로, 복제할 때는 sizeof *p + len 바이트 전체를 memcpy해야 합니다. 헤더만 복사된 객체는 겉보기에 멀쩡해서 이 버그는 오래 숨어 있기 쉽습니다.
union — “같은 메모리, 다른 해석”과 태그
union의 멤버는 (대개) 같은 시작 주소를 쓰며, sizeof는 가장 큰 멤버에 맞춰집니다. 해석과 엔디안은 여전히 조심해야 합니다.
#include <stdio.h>
#include <stdint.h>
union u32b {
uint32_t u;
uint8_t b[4];
};
int main(void) {
union u32b x;
x.u = 0x01020304u;
printf("bytes: %u %u %u %u\n", x.b[0], x.b[1], x.b[2], x.b[3]);
return 0; /* 리틀 엔디안이면 4 3 2 1, 빅 엔디안이면 1 2 3 4 */
}
C에서는 union의 한 멤버에 쓰고 다른 멤버로 읽는 것이 허용됩니다(C99 TC3 이후 각주로 명시). 읽은 값은 저장된 바이트를 새 타입으로 재해석한 결과이며, 그 바이트가 새 타입에서 유효하지 않은 표현이면 문제가 됩니다. 반면 C++에서는 마지막으로 쓴 활성 멤버가 아닌 멤버를 읽으면 정의되지 않은 동작입니다. C에서도 &x.u와 &x.b처럼 멤버 포인터를 따로 꺼내 다른 함수에서 역참조하면 union을 거친 접근이 아니게 되어 엄격 별칭 규칙에 걸릴 수 있습니다. C와 C++ 양쪽에서 쓰이는 헤더이거나 이식성이 중요하다면 memcpy로 바이트를 옮기거나, tagged union으로 “지금 유효한 멤버”를 하나로 고정하는 편이 안전합니다(엄격 별칭은 #05 포인터 참고).
#include <string.h>
#include <stdint.h>
float u32_to_float_pun(uint32_t bits) {
uint32_t tmp = bits;
float f;
memcpy(&f, &tmp, sizeof f); /* C와 C++ 모두에서 정의된 방법 */
return f;
}
enum value_kind { VAL_INT, VAL_FLOAT, VAL_STR };
struct value {
enum value_kind k; /* 지금 유효한 union 멤버를 기록하는 태그 */
union {
int i;
float f;
const char *s;
} u;
};
void use_value(const struct value *v) {
if (!v) {
return;
}
switch (v->k) {
case VAL_INT: (void)v->u.i; break;
case VAL_FLOAT: (void)v->u.f; break;
case VAL_STR: (void)v->u.s; break;
}
}
메시지를 하나의 struct msg에 모을 때도 type을 먼저 보고 union을 읽는 파서 습관이 생명선입니다(분기 전에 u를 건드리지 않습니다). 프로덕션에서는 I/O 버퍼 크기를 union의 최악 크기+패딩까지 고려해 잡습니다.
#include <stdint.h>
#include <stddef.h>
struct msg_hello { uint8_t v; uint16_t cap; };
struct msg_ping { uint32_t ts; };
struct msg_data { uint32_t n; };
enum msg_type { MSG_HELLO, MSG_PING, MSG_DATA };
struct msg {
uint8_t type;
union {
struct msg_hello hello;
struct msg_ping ping;
struct msg_data data;
} u;
};
비트필드 — 드라이버에서 빛나고, 이식에선…
struct flags {
unsigned a : 3;
unsigned b : 1;
unsigned : 0; /* 다음 비트필드를 새 저장 단위에서 시작 (단위 크기는 구현 정의) */
unsigned c : 4;
};
부호·비트 수·“한 unsigned에 몇 개의 필드가 붙는지”는 구현 정의라 흔들리고, 주소 연산자 &도 쓸 수 없습니다. 엔디안과 컴파일러에 묶여 있어 다른 머신에서 “레지스터 비트맵 = 소스 1:1”을 재현하기 어렵습니다.
임베디드/드라이버에서 32비트 레지스터를 다루는 전형적인 방식은 다음과 같습니다. 비트필드 대신 shift/mask로 못 박는 팀이 많습니다.
#include <stdint.h>
#if defined(__GNUC__)
typedef struct {
volatile uint32_t v;
} reg32_t;
#define IO_GPIO_MODE (*(reg32_t *)0x40021000U)
#define GPIO_MODE_OUT_SHIFT 0u
#define GPIO_MODE_MASK 0x3u
/* 읽기: (IO_GPIO_MODE.v >> GPIO_MODE_OUT_SHIFT) & GPIO_MODE_MASK */
#endif
이식성이 우선이면 uint32_t+((x>>s)&m)을 쓰고, 한 SoC+한 툴체인에 고정된 레지스터라면(그리고 CI에서 크로스 빌드를 검증한다면) 비트필드가 팀 규칙일 수는 있습니다. 다만 “네트워크/원격/파일”과 섞이지 않게 해야 합니다. 도메인 밖으로 나가면 깨질 확률이 큽니다.
ABI·packed·OS API
struct stat 같은 OS API는 플랫폼마다 오프셋이 고정된 경우가 있지만(헤더가 근거), 앱이 struct를 “디스크/네트워크에 그대로” 쓰면 엔디안·패딩·정렬·버전이 조금만 달라도 호환이 끊깁니다. 따라서 바깥으로 나가는 데이터는 uint8_t/uint32_t/uint64_t+엔디안+스키마 버전+필요 시 Protobuf/TLV 같은 명시적 형식을 쓰는 것이 일반적입니다.
GCC/Clang __attribute__((packed)) 예시(확장 기능)입니다.
#include <stdint.h>
struct __attribute__((packed)) net_hdr {
uint8_t ver;
uint16_t u16; /* 오프셋 1: 비정렬. &hdr.u16 포인터를 넘기면 위험 */
uint32_t u32;
};
bool·long·size_t·enum·포인터 폭을 와이어에서 기대하는 것은 위험하며, #pragma pack/비트필드/FAM/_Alignas는 옵션에 민감합니다. 결론은 글 첫머리와 같습니다. 내부는 struct, 와이어는 serialize/deserialize입니다.
성능: 필드 순서, 캐시 라인, false sharing
큰 것 먼저, 작은/비슷한 것끼리 묶으면 꼬리 패딩을 줄여 객체·배열 전체의 메모리를 아낄 수 있습니다. 자주 같이 쓰는 필드를 같은/인접 캐시 라인에 두면(측정 전제) 캐시 미스를 줄이는 효과를 기대할 수 있습니다.
false sharing은 이런 경우입니다. 코어0은 필드A, 코어1은 필드B를 “서로 독립”이라 여기고 쓰는데 둘이 같은 64B 라인에 앉아 있으면, 잠금이 없어도 캐시 무효화가 서로를 때립니다. 코드만 봐서는 공유 자원이 없어 보이므로 찾기 어렵습니다. Linux에서는 perf c2c로 여러 코어가 경합하는 캐시 라인을 찾은 뒤, alignas(64)(C11에서는 <stdalign.h>)로 핫 카운터를 서로 다른 라인으로 떼어 놓는 식으로 해결합니다. 캐시 라인 크기는 대부분의 x86-64에서 64바이트지만 아키텍처마다 다를 수 있습니다.
flowchart TB
subgraph C["캐시 라인(예: 64B)"]
A["필드A (코어0가 자주)"]
B["필드B (코어1가 자주)"]
end
C2["같은 라인 → 둘 다 invalidation (false sharing)"]
A --> C2
B --> C2
Opaque pointer + 파일 포맷 예
Opaque: 헤더에는 struct engine;만 두고, .c에 정의합니다.
/* api.h */
struct engine;
struct engine *engine_new(void);
void engine_free(struct engine *e);
void engine_tick(struct engine *e, double dt);
/* engine.c */
#include "api.h"
#include <stdlib.h>
struct engine {
int state;
/* 헤더 사용자는 크기·멤버를 모르므로 레이아웃에 의존할 수 없음 */
};
struct engine *engine_new(void) { return calloc(1, sizeof(struct engine)); }
void engine_free(struct engine *e) { free(e); }
void engine_tick(struct engine *e, double dt) { (void)dt; if (e) e->state++; }
파일 v1 헤더는 크기·오프셋을 빌드 단계에서 고정합니다.
#include <stdint.h>
#include <stddef.h>
struct file_header_v1 {
uint8_t magic[4];
uint16_t version;
uint16_t flags;
uint32_t table_off;
uint32_t table_len;
};
_Static_assert(sizeof(struct file_header_v1) == 16, "v1 size fixed");
/* 엔디안은 read 후 정책에 맞게 */
C struct vs C++ class/struct — 핵심만 흐름으로
C와 C++는 비슷한 문법이 있어도 ODR, 접근 제어, 템플릿, 수명, RAII, 맹글링이 다릅니다. C++에서 C 헤더를 포함할 때는 extern "C" 블록이 짝으로 붙는 경우가 있습니다.
C의 struct에는 “멤버 private 기본” 같은 개념이 없습니다. C++ class는 기본 private, struct는 기본 public이라 여기서 오해가 생기기 쉽습니다. C++은 멤버 함수·생성자, 템플릿, 오버로드로 타입이 커지고, 바이너리 수준에서 “C struct와 똑같이” 맞출 생각이라면(상속/가상 함수까지 가면) 그 기대는 깨집니다. 바이너리/원격 데이터는 공용 바이트 스키마+코덱 쪽이 안전합니다.
다음: #08 전처리 단계
같이 보면 좋은 글
- C 언어 시리즈 #04 — 함수·스택 프레임·호출 규약(ABI)과 가변 인자
- C 언어 시리즈 #05 — 포인터 연산·엄격 별칭(strict aliasing)·유효성
- C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩