1-3. Struct Padding & Alignment: 메모리 정렬의 미학]

hyunahn·2025년 12월 1일
post-thumbnail

Data Alignment & Padding: CPU를 위한 정리정돈

1. The Core Concept (본질 정의)

"CPU는 편식쟁이입니다. 아무 주소에서나 데이터를 먹지 않습니다."

  • Alignment(정렬): 데이터가 메모리에 저장될 때 자신의 크기(또는 프로세서의 Word Size)의 배수에 해당하는 주소에 위치하려는 성질입니다.
  • Padding(패딩): 이 정렬 규칙을 지키기 위해 컴파일러가 데이터 사이에 몰래 끼워 넣는 '의미 없는 빈 공간(Dummy Bytes)'입니다.

이것은 낭비처럼 보이지만, CPU의 메모리 접근 횟수를 줄여 속도를 극대화하기 위한 필수 전략입니다.


2. Under the Hood (하드웨어 투시)

다음과 같은 구조체가 있다고 가정해 봅시다.

struct Weird {
    char a; // 1 byte
    int  b; // 4 bytes
    char c; // 1 byte
};

상식적으로는 1 + 4 + 1 = 6바이트여야 합니다. 하지만 실제 sizeof(struct Weird)를 찍어보면 12바이트가 나옵니다. (32bit/64bit 시스템 기준)

왜 6바이트나 낭비되었을까요? 메모리 내부를 투시해 봅시다.

메모리 맵 (4바이트 단위 정렬 가정)

AddressValue비고
0x0000[a] (1 byte)
0x0001[Padding]3 Bytes Wasted!
0x0002[Padding](CPU: "int는 4의 배수 주소에 줘!")
0x0003[Padding]
0x0004[b] (4 bytes)0x04는 4의 배수이므로 OK!
0x0005[b]
0x0006[b]
0x0007[b]
0x0008[c] (1 byte)
0x0009[Padding]3 Bytes Wasted!
0x000A[Padding](구조체 배열을 위해 전체 크기를 4의 배수로 맞춤)
0x000B[Padding]
  • Rule 1: int는 4바이트이므로, 시작 주소가 4의 배수여야 합니다. 그래서 char a 뒤에 3바이트를 비워둡니다.
  • Rule 2: 구조체 전체의 크기도 가장 큰 멤버(int)의 배수로 끝나야 합니다. 그래서 char c 뒤에도 3바이트를 채웁니다.

3. The "Why" & Real-world Analogy (이유와 비유)

Q: 왜 아깝게 빈 공간을 두나요? 그냥 꽉 채우면 안 되나요?
A: "CPU가 두 번 일하게 하지 않기 위해서입니다."

🚌 버스 탑승 비유

여러분이 4인승 버스(32bit Data Bus)를 운전한다고 상상해 봅시다. 이 버스는 한 번에 4명(4바이트)씩만 태울 수 있고, 좌석은 항상 1번부터 4번까지 채워야 출발합니다.

상황 1: 패딩 없이 꽉 채워 저장했을 때 (Unaligned)

만약 int b가 주소 0x0001부터 0x0004에 걸쳐 있다고 칩시다.

  1. 첫 번째 버스 출발: 0x0000~0x0003을 읽습니다. 여기서 b앞부분 3바이트만 가져옵니다.
  2. 두 번째 버스 출발: 0x0004~0x0007을 읽습니다. 여기서 b나머지 1바이트를 가져옵니다.
  3. 조립(Shift & Merge): CPU는 가져온 두 조각을 회로 내에서 풀칠하고 조립해야 합니다.
    • 결과: 2번 읽어야 함 (속도 1/2로 저하) + 조립 비용 발생

상황 2: 패딩을 넣어 정렬했을 때 (Aligned)

int b0x0004에 딱 맞췄습니다.

  1. 첫 번째 버스: 그냥 지나갑니다.
  2. 두 번째 버스: 0x0004~0x0007을 읽습니다. b한 번에 쏙 들어옵니다.
    • 결과: 1번만 읽으면 됨 (최고 속도)

경고: 일부 엄격한 하드웨어(예: 구형 ARM, SPARC)는 정렬되지 않은 주소에 접근하면 아예 "Bus Error"를 내며 프로그램을 강제 종료시킵니다. 인텔 CPU는 종료시키진 않지만 성능이 급격히 떨어집니다.


4. Code & Best Practice

하드웨어 엔지니어처럼 코드를 짜는 법을 알려드리겠습니다.

1) 테트리스 하듯이 순서 바꾸기 (Reordering)

멤버 변수의 선언 순서만 바꿔도 메모리 낭비를 줄일 수 있습니다.
원칙: 크기가 큰 멤버부터 작은 순서대로 선언하십시오.

/* 나쁜 예 (12 Bytes) */
struct Bad {
    char a; // 1
    // 3 bytes padding
    int b;  // 4
    char c; // 1
    // 3 bytes padding
};

/* 좋은 예 (8 Bytes) - 4바이트 절약! */
struct Good {
    int b;  // 4 (0x00 시작)
    char a; // 1 (0x04 위치)
    char c; // 1 (0x05 위치)
    // 2 bytes padding (전체 크기 4의 배수 맞춤)
};

2) 패킹 강제하기 (__attribute__((packed)))

네트워크 패킷을 보내거나, 파일 헤더를 읽을 때는 "빈 공간"이 있으면 안 됩니다. 프로토콜은 패딩 따위를 모릅니다. 이때는 컴파일러에게 "속도가 느려져도 좋으니 빈 공간 없이 꽉 채워!"라고 명령해야 합니다.

// GCC, Clang 스타일
struct __attribute__((packed)) Packet {
    char cmd;
    int payload;
};

// 결과: sizeof(Packet) == 5 (1 + 4)
// 주의: 이 멤버에 접근할 때 CPU는 느린 연산을 수행합니다.

5. Deep Dive: 임베디드 실무자를 위한 추가 학습

기본 개념을 잡았다면, 이제 면접과 실무에서 차이를 만드는 "한 끗"을 채울 시간입니다.

1️⃣ 왜 구조체 끝에도 패딩이 붙을까? (Struct Array)

"구조체 내부 패딩은 이해가는데, 왜 마지막에도 패딩을 넣나요?"
정답: "배열(Array) 때문입니다."

struct Example {
    int  a;   // 4 bytes (offset 0)
    char b;   // 1 byte  (offset 4)
    // 3 bytes padding (offset 5~7)
};  // sizeof = 8

만약 끝 패딩이 없어서 sizeof가 5라면?

struct Example arr[2];
// arr[0].a : 0x0000 (OK)
// arr[1].a : 0x0005 (X) -> 4의 배수가 아님! 정렬 위반 발생!

배열의 두 번째 원소도 정확히 정렬된 위치에서 시작하기 위해, 구조체 전체 크기를 가장 큰 멤버(int)의 배수로 맞춰야 합니다.

2️⃣ 플랫폼별 Unaligned Access 동작 (면접 단골)

"Bus Error"는 언제 발생할까요? 플랫폼마다 다릅니다.

플랫폼Unaligned Access 시 동작비고
x86/x64허용 (성능 저하)인텔은 매우 관대함
ARMv7 (Cortex-M3/M4)대부분 허용단, LDM/STM 등 일부 다중 로드 명령어는 예외 발생
ARMv6 이하 (구형)Bus Error (SIGBUS)구형 모바일/임베디드는 매우 엄격
SPARC, MIPSBus Error하드웨어적으로 정렬 필수
RISC-V구현체에 따라 다름스펙상 선택적 기능

자동차 SW (ASIL) Tip:
ECU용 Cortex-R/M 시리즈는 Unaligned Access를 허용하더라도, 안전성(Safety)과 예측 가능성을 위해 컴파일러 옵션(-mno-unaligned-access)으로 정렬을 강제하는 경우가 많습니다. "허용된다고 막 쓰면 안 됩니다."

3️⃣ 반대 개념: 정렬 강제하기 (alignas, aligned)

packed가 빈 공간을 없애는 것이라면, aligned더 큰 정렬을 강제하는 것입니다.

// C11 표준: alignas
#include <stdalign.h>

struct alignas(16) SIMDFriendly {
    float data[4];  // 16바이트, SIMD 벡터 연산에 최적화
};

// GCC/Clang 확장: Cache Line 정렬
struct __attribute__((aligned(64))) CacheLineFriendly {
    long value; 
    // 자동으로 64바이트 경계에 배치됨 (False Sharing 방지)
};
  • 언제 쓰는가?: SIMD 연산(SSE/NEON), DMA 전송 버퍼, 캐시 라인 최적화 시.

4️⃣ 패딩 계산 실습 (손으로 해보세요!)

실제로 계산해봐야 늡니다. 다음 구조체의 크기는? (64bit 시스템 기준)

struct Challenge {
    char   a;    // 1 byte
    double b;    // 8 bytes
    short  c;    // 2 bytes
    int    d;    // 4 bytes
};

정답 해설:
1. a (1 byte) → 오프셋 0
2. b (8 bytes) → 오프셋 1 (X) → 패딩 7바이트 → 오프셋 8 (OK)
3. c (2 bytes) → 오프셋 16
4. d (4 bytes) → 오프셋 18 (X) → 패딩 2바이트 → 오프셋 20 (OK)
5. 끝 패딩 → 현재 24바이트. 가장 큰 멤버 double(8)의 배수인가? → 24는 8의 배수 (OK).
6. 총 크기: 24 bytes


6. Advanced Topics (심화: 캐시와 동시성)

패딩과 정렬은 단순한 메모리 낭비 문제를 넘어, 멀티코어 프로그래밍의 성능과도 직결됩니다.

💣 False Sharing (거짓 공유) 문제

멀티코어 환경에서 서로 다른 쓰레드가 인접한 변수를 각각 자주 수정할 때 발생하는 심각한 성능 저하 현상입니다.

상황:

  • Thread A는 변수 X를 수정함.
  • Thread B는 변수 Y를 수정함.
  • XY가 우연히 메모리 상에서 바로 옆에 붙어 있어서, 같은 캐시 라인(Cache Line, 보통 64바이트)에 들어감.

결과:
CPU 코어들은 캐시 일관성(Cache Coherency)을 유지하기 위해 서로의 캐시 라인을 계속 무효화(Invalidate)하고 다시 로딩해야 합니다. 변수는 다르지만 캐시 라인이 같아서 성능이 곤두박질칩니다.

해결책: 강제 패딩 (Padding for Cache Line Alignment)

struct ThreadSafeCounter {
    long value;
    // 64바이트(캐시 라인 크기)를 채우기 위한 패딩
    char padding[64 - sizeof(long)]; 
};

이렇게 하면 value는 자신만의 전용 캐시 라인을 가지게 되어, 다른 코어의 간섭 없이 빠르게 처리됩니다.

생각해볼만한 것:
"구조체를 설계할 때는 단순히 논리적인 연관성뿐만 아니라, 변수들의 물리적 크기와 배치를 고려하는 습관을 들이세요. 또한 멀티쓰레드 환경에서는 데이터가 캐시 라인 경계에 어떻게 놓이는지까지 생각하는 것이 진짜 시스템 프로그래머의 센스(Sense)입니다."

0개의 댓글