Devin.KR

C 구조체와 메모리 배치 - offsetof 패딩 정렬과 필드 순서

개발자KR 조회 26

이 장에서 배우는 것

9장의 IntList처럼 포인터·길이·용량을 한 덩어리로 묶으면 함수에 넘기기도, 이해하기도 쉽다. 이 덩어리가 구조체다. 그런데 필드 크기를 더한 값과 sizeof가 다른 경우가 많다. CPU가 값을 빠르게 읽을 수 있도록 컴파일러가 필드 사이에 빈 바이트, 곧 패딩을 넣기 때문이다. 센서 레코드 수만 개를 메모리에 올리거나 구조체를 그대로 통신·파일에 쓰는 코드에서는 이 빈 바이트가 용량과 호환성 문제로 바로 이어진다.

  • 구조체 선언·지정 초기화·대입(복사)·포인터를 통한 필드 접근(->)을 익힌다.
  • offsetof, sizeof, _Alignof로 필드 배치와 패딩을 측정한다.
  • 필드 순서를 바꿔 구조체 크기를 줄인다.
  • -Wpadded로 컴파일러가 넣은 패딩을 확인한다.

문제 상황

센서 한 번 측정값을 uint8_t ID, double 온도, uint16_t 플래그로 담는 구조체를 만들었다. 필드 크기를 더하면 1 + 8 + 2 = 11바이트인데, 측정값 100개 배열이 2,400바이트, 즉 하나에 24바이트를 차지한다. 메모리가 64KB뿐인 보드에서 이 차이는 작지 않다. 또 이 구조체를 그대로 파일에 쓰고 다른 장비에서 읽었더니 값이 어긋난다.

메모리 그림: 정렬과 패딩

정렬(alignment): 4바이트 int 는 4의 배수 주소에, 8바이트 double 은 8의 배수 주소에 놓는다
               (이 기계 기준. 대부분의 64비트 환경이 같다)

struct Loose { char kind; int32_t value; char unit; };        sizeof = 12
 오프셋 0    1    2    3    4    5    6    7    8    9    10   11
       ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
       │kind│ ░░ │ ░░ │ ░░ │      value        │unit│ ░░ │ ░░ │ ░░ │
       └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
             └ value 를 4의 배수에 맞추려는 패딩 ┘    └ 배열로 이어 놓을 때
                                                      다음 원소를 맞추려는 꼬리 패딩 ┘

struct Ordered { int32_t value; char kind; char unit; };      sizeof = 8
       ┌────┬────┬────┬────┬────┬────┬────┬────┐
       │      value        │kind│unit│ ░░ │ ░░ │
       └────┴────┴────┴────┴────┴────┴────┴────┘

struct Reading { uint8_t sensor_id; double celsius; uint16_t flags; };   sizeof = 24
 0    1 ─────── 7   8 ──────────── 15  16  17  18 ────── 23
 [id] [ 패딩 7바이트 ] [    celsius    ] [flags ] [ 패딩 6바이트 ]
 실제 데이터 11바이트, 빈 바이트 13바이트

규칙은 두 가지다. ① 각 필드는 자기 정렬 단위의 배수 오프셋에 놓인다. ② 구조체 전체 크기는 가장 큰 정렬 단위의 배수가 된다(배열로 늘어놓아도 각 원소가 정렬을 지키도록). 필드 순서는 선언한 순서 그대로이며, 컴파일러가 C 구조체의 필드 순서를 바꾸는 일은 없다. 그래서 순서를 바꾸는 것은 프로그래머의 몫이다.

char, int32_t, char 순서는 패딩 6바이트가 끼어 12바이트지만, int32_t 를 앞으로 옮기면 8바이트가 된다.

char, int32_t, char 순서는 패딩 6바이트가 끼어 12바이트지만, int32_t 를 앞으로 옮기면 8바이트가 된다.

uint8_t, double, uint16_t 순서의 Reading 은 데이터 11바이트에 패딩 13바이트로 24바이트를 쓰고, double 을 앞에 두면 16바이트로 줄어든다.

uint8_t, double, uint16_t 순서의 Reading 은 데이터 11바이트에 패딩 13바이트로 24바이트를 쓰고, double 을 앞에 두면 16바이트로 줄어든다.

완성 코드

배치 측정

#include <stdio.h>
#include <stddef.h>
#include <stdint.h>

struct Loose {
    char kind;
    int32_t value;
    char unit;
};

struct Ordered {
    int32_t value;
    char kind;
    char unit;
};

struct Reading {
    uint8_t sensor_id;
    double celsius;
    uint16_t flags;
};

#define SHOW(type, member) \
    printf("  %-10s offset %2zu, size %zu\n", #member, \
           offsetof(type, member), sizeof(((type *)0)->member))

int main(void) {
    printf("struct Loose   sizeof=%zu alignof=%zu\n", sizeof(struct Loose), _Alignof(struct Loose));
    SHOW(struct Loose, kind);
    SHOW(struct Loose, value);
    SHOW(struct Loose, unit);

    printf("struct Ordered sizeof=%zu alignof=%zu\n", sizeof(struct Ordered), _Alignof(struct Ordered));
    SHOW(struct Ordered, value);
    SHOW(struct Ordered, kind);
    SHOW(struct Ordered, unit);

    printf("struct Reading sizeof=%zu alignof=%zu\n", sizeof(struct Reading), _Alignof(struct Reading));
    SHOW(struct Reading, sensor_id);
    SHOW(struct Reading, celsius);
    SHOW(struct Reading, flags);

    struct Reading log[100];
    printf("Reading 100개 배열 = %zu바이트\n", sizeof log);
    return 0;
}

구조체 사용: 복사와 포인터

#include <stdio.h>
#include <string.h>

typedef struct {
    char title[32];
    int price;
    int stock;
} Book;

static void discount(Book *b, int percent) {
    b->price = b->price * (100 - percent) / 100;
}

static int inventory_value(const Book *books, size_t count) {
    int total = 0;
    for (size_t i = 0; i < count; i++) {
        total += books[i].price * books[i].stock;
    }
    return total;
}

int main(void) {
    Book shelf[] = {
        {.title = "C 입문", .price = 20000, .stock = 3},
        {.title = "임베디드 기초", .price = 30000, .stock = 1},
    };
    size_t count = sizeof shelf / sizeof shelf[0];

    Book copy = shelf[0];
    strcpy(copy.title, "C 입문 (복사본)");
    discount(&shelf[0], 10);

    printf("shelf[0]: %s %d원\n", shelf[0].title, shelf[0].price);
    printf("copy    : %s %d원\n", copy.title, copy.price);
    printf("재고 금액 합계: %d원\n", inventory_value(shelf, count));
    printf("sizeof(Book) = %zu\n", sizeof(Book));
    return 0;
}

줄별 해설

  • offsetof(type, member) — <stddef.h>의 매크로로, 구조체 시작에서 필드까지의 바이트 수를 컴파일 시점에 알려 준다.
  • sizeof(((type *)0)->member) — 필드 하나의 크기다. sizeof는 식을 실제로 계산하지 않으므로 NULL 포인터를 따라가는 것처럼 보여도 안전하다.
  • #define SHOW(type, member) ... 의 #member — 전처리기의 문자열화 연산자다. 필드 이름을 그대로 문자열로 찍는다.
  • _Alignof(struct Reading) — C11부터 있는 키워드로 정렬 단위를 알려 준다. <stdalign.h>를 넣으면 alignof로 쓸 수 있다.
  • typedef struct { ... } Book; — 매번 struct를 쓰지 않도록 별명을 붙인다.
  • {.title = "C 입문", .price = 20000, .stock = 3} — 지정 초기화(C99). 필드 이름을 적으므로 순서를 바꾸거나 필드를 추가해도 안전하고, 적지 않은 필드는 0이 된다.
  • Book copy = shelf[0]; — 구조체 대입은 모든 바이트를 복사한다. 안에 든 char title[32] 배열까지 통째로 복사되므로 copy.title을 바꿔도 원본은 그대로다. 단, 필드가 포인터라면 주소만 복사되어 두 구조체가 같은 힙 블록을 가리키게 된다(얕은 복사).
  • static void discount(Book *b, int percent) — 큰 구조체는 포인터로 넘긴다. 값으로 넘기면 40바이트를 매번 복사하고, 함수 안에서 바꾼 값이 원본에 반영되지 않는다.
  • b->price — (*b).price의 줄임 표기다.
  • const Book *books — 읽기만 하는 함수라는 표시다. 8장의 const 규칙 그대로다.

실제 실행 결과

$ ./layout
struct Loose   sizeof=12 alignof=4
  kind       offset  0, size 1
  value      offset  4, size 4
  unit       offset  8, size 1
struct Ordered sizeof=8 alignof=4
  value      offset  0, size 4
  kind       offset  4, size 1
  unit       offset  5, size 1
struct Reading sizeof=24 alignof=8
  sensor_id  offset  0, size 1
  celsius    offset  8, size 8
  flags      offset 16, size 2
Reading 100개 배열 = 2400바이트
layout.c 실행 결과: 필드 오프셋과 크기
구조체필드오프셋크기
Loose (12바이트)kind01
value44
unit81
Ordered (8바이트)value04
kind41
unit51
Reading (24바이트)sensor_id01
celsius88
flags162

그림과 같다. Loose와 Ordered는 필드가 같은데 순서만 바꿔 12바이트가 8바이트가 됐다. Reading은 11바이트의 데이터에 24바이트를 쓴다. 이 수치는 이 기계(arm64 macOS) 기준이고, x86-64 리눅스에서도 같게 나오지만 32비트 x86 리눅스에서는 double의 구조체 내 정렬이 4바이트라 Reading이 16바이트가 된다. 8비트 AVR 같은 마이크로컨트롤러는 패딩이 거의 없다. 그래서 구조체 크기를 파일·통신 형식의 약속으로 삼으면 안 된다.

컴파일러에게 패딩을 보고받기

$ clang -std=c17 -Wall -Wextra -Wpadded -c layout.c -o /dev/null
layout.c:19:12: warning: padding struct 'struct Reading' with 7 bytes to align 'celsius' [-Wpadded]
   19 |     double celsius;
      |            ^
layout.c:17:8: warning: padding size of 'struct Reading' with 6 bytes to alignment boundary [-Wpadded]
   17 | struct Reading {
      |        ^
layout.c:7:13: warning: padding struct 'struct Loose' with 3 bytes to align 'value' [-Wpadded]
    7 |     int32_t value;
      |             ^
layout.c:5:8: warning: padding size of 'struct Loose' with 3 bytes to alignment boundary [-Wpadded]
    5 | struct Loose {
      |        ^
layout.c:11:8: warning: padding size of 'struct Ordered' with 2 bytes to alignment boundary [-Wpadded]
   11 | struct Ordered {
      |        ^
5 warnings generated.

-Wpadded는 패딩이 들어간 곳마다 알려 준다. 모든 구조체에 켜 두면 잡음이 많으니, 메모리 배치가 중요한 헤더를 점검할 때 잠깐 켜는 용도로 쓴다.

구조체 복사와 포인터 전달

$ ./book
shelf[0]: C 입문 18000원
copy    : C 입문 (복사본) 20000원
재고 금액 합계: 84000원
sizeof(Book) = 40

discount는 포인터로 원본을 바꿨고, 미리 복사해 둔 copy는 할인 전 가격 20000원을 유지한다. 재고 금액 합계는 18000 × 3 + 30000 × 1 = 84000원이다.

한눈에 보기

구조체를 함수에 넘기는 방법
방법복사하는 양원본 변경어울리는 곳
값 Book b구조체 전체불가작은 구조체, 복사본이 필요할 때
포인터 Book *b주소 8바이트가능원본을 고치는 함수
const 포인터 const Book *b주소 8바이트불가(컴파일러가 막음)읽기만 하는 큰 구조체

실무에서 자주 틀리는 것

  • fwrite(&rec, sizeof rec, 1, fp)로 구조체를 통째로 저장·전송한다. 패딩 바이트(쓰레기 값일 수 있음), 엔디언, 자료형 크기가 모두 플랫폼 의존이라 다른 장비·컴파일러에서 읽으면 어긋난다. 11장에서 이 문제를 직접 보고, 필드별로 바이트 순서를 정해 쓰는 방법을 다룬다.
  • 패딩을 없애려고 무조건 __attribute__((packed))를 붙인다. 표준이 아닌 컴파일러 확장이고, 정렬이 어긋난 필드를 읽으면 일부 CPU에서는 느려지거나 예외가 난다. 먼저 필드를 큰 것부터 작은 것 순으로 정렬해 본다.
  • 구조체를 ==로 비교하거나 memcmp로 비교한다. ==는 구조체에 쓸 수 없고, memcmp는 패딩의 쓰레기 값까지 비교해 같은 값도 다르다고 할 수 있다. 필드별로 비교한다.
  • 포인터 필드가 있는 구조체를 대입으로 복사하고 둘 다 free한다. 얕은 복사라 같은 블록을 두 번 해제하게 된다(9장의 이중 해제).

연습 문제

  1. struct Reading의 필드 순서를 바꿔 크기를 줄여라. 최소 몇 바이트가 되는가?
  2. struct P { char a; double b; char c; int d; };의 이 기계에서의 sizeof를 오프셋 그림으로 구하라.
  3. typedef struct { char *name; int age; } Person;에서 Person q = p; 뒤 q.name[0] = 'X';를 하면 p.name은 어떻게 되는가?

정답

  1. double celsius; uint16_t flags; uint8_t sensor_id; 순서로 바꾸면 오프셋 0, 8, 10에 놓이고 데이터 끝은 11이다. 전체는 8의 배수여야 하므로 16바이트다. 100개 배열이면 2,400바이트가 1,600바이트로 줄어든다. double이 있는 한 8의 배수 아래로는 줄일 수 없다.
  2. a는 0, b는 8의 배수인 8(패딩 7), c는 16, d는 4의 배수인 20(패딩 3)에 놓여 데이터 끝이 24다. 24는 8의 배수이므로 24바이트다. 순서를 double b; int d; char a; char c;로 바꾸면 0, 8, 12, 13으로 끝이 14이고, 8의 배수로 올려 16바이트가 된다.
  3. p.name도 "X..."로 바뀐다. 대입은 포인터 값(주소)만 복사하므로 p.name과 q.name이 같은 문자열 버퍼를 가리킨다. 독립된 복사본이 필요하면 9장의 copy_string처럼 새 블록을 할당해 내용을 복사하는 "깊은 복사" 함수를 만든다.

다음 장에서는 메모리 밖, 파일로 데이터를 옮긴다. 텍스트로 쓸 때와 바이트 그대로 쓸 때의 차이를 이 장의 패딩·엔디언 지식으로 해석한다.

댓글 0

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

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