Devin.KR

C 구조체와 메모리 배치 - offsetof 패딩 정렬과 필드 순서 (C 기초 10장)

개발자 조회 1

이 장에서 배우는 것

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

그림과 같다. LooseOrdered는 필드가 같은데 순서만 바꿔 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.nameq.name이 같은 문자열 버퍼를 가리킨다. 독립된 복사본이 필요하면 9장의 copy_string처럼 새 블록을 할당해 내용을 복사하는 "깊은 복사" 함수를 만든다.

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

댓글 0

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

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