CPU가 효율적으로 접근할 수 있도록 구조체 멤버 사이에 컴파일러가 빈 공간을 끼워 넣는 것(padding).
왜 생기는가 — 정렬 요구사항
CPU는 4바이트 값을 4의 배수 주소에서 읽을 때 가장 빠르다
ARM Cortex-M은 정렬되지 않은 접근이
- Cortex-M0/M0+ : 불가능. HardFault 발생
- Cortex-M3/M4/M7 : 가능하지만 느리다(여러 번 버스 접근)
이 제약을 지키려고 컴파일러가 빈 공간을 넣는다.
예 — 순서만 바꿔도 크기가 달라진다
struct Bad { // 12바이트
uint8_t a; // 0 1바이트
// 1~3 패딩 3바이트 ← b를 4의 배수에 두려고
uint32_t b; // 4~7
uint8_t c; // 8
// 9~11 패딩 3바이트 ← 구조체 전체 크기를 4의 배수로
};
struct Good { // 8바이트
uint32_t b; // 0~3
uint8_t a; // 4
uint8_t c; // 5
// 6~7 패딩 2바이트
};
멤버를 큰 것부터 배열하면 패딩이 줄어든다. RAM이 수십 KB뿐인 MCU에서 이 구조체가 100개짜리 배열이면 400바이트를 절약하는 셈이다.
끝에도 패딩이 붙는 이유
struct Good arr[2];
배열의 두 번째 원소도 4의 배수 주소에서 시작해야 한다. 그래서 구조체 전체 크기가 가장 큰 멤버의 정렬 단위의 배수가 되도록 꼬리에 패딩을 붙인다.
확인 방법
printf("%zu\n", sizeof(struct Bad)); // 12
printf("%zu\n", offsetof(struct Bad, b)); // 4 ← 패딩 확인
_Static_assert(sizeof(struct Packet) == 8, "크기 불일치"); // 컴파일 시 검증
_Static_assert로 크기를 못 박아 두는 것이 실무에서 유용하다.
누가 멤버를 추가해 크기가 바뀌면 컴파일이 실패한다.
attribute((packed)) — 위험한 해결책
struct __attribute__((packed)) Packet {
uint8_t cmd;
uint32_t value; // 오프셋 1 — 정렬되지 않았다!
};
패딩을 없애 통신 프로토콜 구조를 그대로 표현할 수 있다. 하지만
struct Packet *p = ...;
uint32_t v = p->value; // Cortex-M0 에서 HardFault
// Cortex-M4 에서는 느린 코드로 컴파일
uint32_t *ptr = &p->value; // ✗ 정렬 안 된 포인터. 매우 위험
멤버 주소를 다른 함수에 넘기면 그 함수는 정렬되어 있다고 가정하고 접근하다 폴트를 낸다.
안전한 대안 — 직렬화
// packed 구조체를 캐스팅하지 않고, 바이트 단위로 조립한다
void encode(uint8_t *buf, const Packet *p) {
buf[0] = p->cmd;
buf[1] = (uint8_t)(p->value );
buf[2] = (uint8_t)(p->value >> 8);
buf[3] = (uint8_t)(p->value >> 16);
buf[4] = (uint8_t)(p->value >> 24);
}
정렬 문제도 없고 엔디안도 명시적으로 고정된다. 통신 프로토콜에서는 이 방식이 정석이다.
통신·저장에서 구조체를 그대로 쓰면 안 되는 이유
- ① 패딩이 컴파일러·아키텍처마다 다르다
- ② 엔디안이 다르면 값이 뒤집힌다
- ③ 타입 크기가 다를 수 있다 (int 가 16비트인 MCU도 있다)
send(&packet, sizeof(packet)); // ✗ 상대 기기와 레이아웃이 다를 수 있다
같은 코드를 양쪽에서 컴파일하는 경우에만 겨우 통한다. 이기종 통신에서는 반드시 직렬화한다.
비트 필드도 이식성이 없다
struct { uint8_t a : 3; uint8_t b : 5; } flags;
비트 배치 순서(LSB부터인지 MSB부터인지)가 구현 정의다. 하드웨어 레지스터 매핑에 쓰면 컴파일러를 바꿨을 때 깨진다. 비트 마스킹을 쓰는 편이 안전하다.