[JUNGLE] TIL_17. CSAPP 3.9

모깅·2025년 9월 21일

JUNGLE

목록 보기
18/56
post-thumbnail

3.9 이기종 데이터 구조

C언어는 서로 다른 타입의 데이터들을 묶어 새로운 자료형을 만드는 두 가지 메커니즘을 제공합니다.

1. 구조체 (Structures, struct)

여러 개의 서로 다른 데이터를 하나의 단위로 '모아놓는(aggregate)' 방식입니다. 구조체의 각 멤버는 독립된 메모리 공간을 차지하며, 모든 멤버가 동시에 값을 가질 수 있습니다.

2. 공용체 (Unions, union)

하나의 메모리 공간을 여러 종류의 데이터 타입으로 '다르게 해석'할 수 있게 해주는 방식입니다. 모든 멤버가 동일한 메모리 공간을 공유하므로, 한 번에 오직 하나의 멤버만 유효한 값을 가질 수 있습니다.

3.9.1 구조체 (Structures)

C언어의 struct 선언은 서로 다른 타입의 데이터들을 하나의 객체로 그룹화하는 자료형을 만듭니다.

1. 구조체의 메모리 할당

구조체의 구현은 배열과 유사합니다.

  • 연속된 메모리: 구조체의 모든 멤버(필드)들은 연속된 메모리 영역에 저장됩니다.
  • 포인터: 구조체를 가리키는 포인터는 구조체의 첫 번째 바이트의 주소를 가집니다.
  • 오프셋(Offset): 컴파일러는 각 멤버가 구조체의 시작점으로부터 얼마나 떨어져 있는지에 대한 바이트 오프셋 정보를 유지합니다.

예시:

struct rec {
    int i;      // 오프셋 0
    int j;      // 오프셋 4
    int a[2];   // 오프셋 8
    int *p;     // 오프셋 16 (int a[2]가 8바이트 차지)
}; // 총 크기: 24 바이트

2. 구조체 멤버 접근

컴파일러는 멤버에 접근하는 코드를, 구조체의 시작 주소에 멤버의 오프셋을 더하는 기계어 명령어로 번역합니다.

  • C 코드: r->j = r->i;
    (rstruct rec * 타입이고, 그 주소는 %rdi 레지스터에 저장되어 있음)
  • 어셈블리어:
movl    (%rdi), %eax    // r->i (오프셋 0)의 값을 %eax로 가져온다.
movl    %eax, 4(%rdi)   // %eax의 값을 r->j (오프셋 4) 위치에 저장한다.

3. 구조체 멤버의 주소 생성

구조체 내의 특정 요소에 대한 포인터를 생성하는 것은, 구조체의 시작 주소에 해당 요소의 오프셋을 더하는 것과 같습니다. 이 작업에는 leaq 명령어가 매우 효율적으로 사용됩니다.

  • C 코드:
r->p = &r->a[r->i + r->j];

(구조체 포인터 r은 %rdi에 저장되어 있음)

  • 어셈블리어:
movl    4(%rdi), %eax      // %eax = r->j
addl    (%rdi), %eax       // %eax = r->i + r->j
cltq                       // %eax를 64비트 %rax로 부호 확장
leaq    8(%rdi,%rax,4), %rax // %rax = &(r->a[r->i + r->j])
movq    %rax, 16(%rdi)     // 계산된 주소를 r->p에 저장
  • 주소 계산 분석 (leaq):
    • 8(%rdi, %rax, 4) 는 주소 &r + 8 + (r->i + r->j) * 4를 계산합니다.
    • &r: %rdi (구조체 시작 주소)
    • + 8: a 배열의 시작 오프셋
    • + (r->i + r->j) * 4: a 배열 내에서의 최종 인덱스 오프셋

결론적으로, 구조체 멤버의 선택은 전적으로 컴파일 시점에 이루어집니다. 생성된 기계어 코드에는 멤버의 이름이나 선언에 대한 정보가 전혀 남아있지 않고, 오직 미리 계산된 오프셋(offset)을 이용한 주소 계산만 존재합니다.

3.9.2 공용체 (Unions)

C언어의 union 선언은 하나의 메모리 공간을 여러 개의 서로 다른 데이터 타입으로 다르게 참조(해석)할 수 있게 해주는 자료형을 만듭니다.

1. 구조체(struct)와의 차이점

  • 구조체: 각 멤버가 별도의 독립된 메모리 공간을 가집니다. 구조체의 전체 크기는 모든 멤버의 크기를 합한 것과 거의 같습니다.
  • 공용체: 모든 멤버가 동일한 메모리 공간을 공유합니다. 공용체의 전체 크기는 멤버 중 가장 큰 멤버의 크기와 같습니다.

예시:
union U3의 멤버 c, i, v는 모두 메모리의 동일한 시작 주소를 가리킵니다.


2. 공용체의 활용

1. 메모리 공간 절약

하나의 자료구조에서 두 개 이상의 필드가 동시에 사용되지 않는다는 것을 알 때, 이 필드들을 공용체로 선언하면 전체 공간을 줄일 수 있습니다.

  • 예시: 이진 트리 노드
    • 내부 노드: 두 자식 노드를 가리키는 포인터 2개만 필요.
    • 리프 노드: 데이터 2개만 필요.
      이 두 경우를 union으로 묶으면, 포인터 공간과 데이터 공간을 공유하여 각 노드의 크기를 절반으로 줄일 수 있습니다. 하지만 이 경우, 현재 노드가 내부 노드인지 리프 노드인지 구별할 방법이 없다는 문제가 생깁니다.

이를 해결하기 위해 보통 열거형(enum)으로 태그(tag) 필드를 만들어, 현재 어떤 멤버가 유효한지 표시하는 구조체를 함께 사용합니다.

2. 데이터의 비트 패턴 접근

공용체는 한 데이터 타입의 비트 패턴을 그대로 유지한 채, 다른 데이터 타입으로 해석하는 데 유용하게 사용됩니다.

  • 예시: double 값을 unsigned long으로 변환할 때
    • 일반 형 변환: unsigned long u = (unsigned long) d;

      d의 숫자 값을 정수로 변환하여 u에 저장합니다. 비트 패턴은 완전히 달라집니다.

    • 공용체를 이용한 변환:

      union { double d; int u; } temp;
      temp.d = d; // double 형태로 비트를 저장
      return temp.u; // 저장된 비트를 unsigned long으로 해석하여 반환

      이 코드는 d비트 패턴을 그대로 u로 가져옵니다. u의 숫자 값은 d와 아무 관련이 없지만, 부호, 지수, 가수 비트를 직접 분석하고 싶을 때 유용한 기법입니다.


      실제 예시 (floatint)

      32비트 float 변수 d2.5라는 값이 들어있다고 가정해 봅시다.

    • 메모리 속 비트 패턴: d는 IEEE 754 규칙에 따라 메모리에 16진수 0x40200000으로 저장됩니다.

      1. 일반 형 변환: int i = (int)d;

      이 코드는 d숫자 값을 정수로 바꾸려고 시도합니다.

    1. d의 값 2.5를 가져옵니다.

    2. 소수점 이하를 버려 정수 2로 만듭니다.

    3. i에 정수 2를 저장합니다.

    4. 정수 2의 비트 패턴은 0x00000002입니다.

      결과: 값은 2.52로 비슷하게 유지됐지만, 비트 패턴은 0x402000000x00000002로 완전히 바뀌었습니다.

      2. 공용체를 이용한 변환

      union { float d; int i; } temp;
      temp.d = 2.5;
      int i = temp.i;

      이 코드는 d비트 패턴을 그대로 유지한 채 정수로 읽으려고 시도합니다.

    5. temp라는 공용체 공간에 float 타입으로 0x40200000 비트 패턴을 저장합니다.

    6. 그 똑같은 공간을 이번에는 int 타입으로 읽어서 i에 저장합니다.

    7. i에는 0x40200000 비트 패턴이 그대로 들어갑니다.

    8. 0x40200000을 10진수 정수로 해석하면 1,075,838,976입니다.

      결과: 비트 패턴 0x40200000은 그대로 유지됐지만, 값은 2.51,075,838,976이라는 전혀 다른 수가 되었습니다.

      이처럼 공용체는 데이터의 실제 비트 구성을 직접 들여다보고 조작해야 하는 저수준(low-level) 프로그래밍에서 유용하게 사용됩니다.

주의점: 바이트 순서 (Endianness)

크기가 다른 데이터 타입을 공용체로 묶을 때, 시스템의 바이트 순서(엔디언)에 따라 결과가 달라질 수 있습니다. 예를 들어, 4바이트 unsigned 두 개를 합쳐 8바이트 double을 만드는 공용체는 리틀 엔디언과 빅 엔디언 머신에서 두 unsigned 값의 상위/하위 바이트 순서가 뒤바뀌게 됩니다.

3.9.3 데이터 정렬 (Data Alignment)

많은 컴퓨터 시스템은 기본 데이터 타입이 저장될 수 있는 주소에 제약을 둡니다. 즉, 특정 크기(K)의 데이터는 반드시 K의 배수가 되는 주소에 위치해야 한다는 규칙입니다. 이러한 정렬(alignment) 규칙은 프로세서와 메모리 시스템 사이의 하드웨어 설계를 단순화하고 성능을 향상시킵니다.

예를 들어, CPU가 항상 8의 배수 주소에서 8바이트씩 데이터를 가져온다고 가정해 봅시다. 만약 8바이트 double 데이터가 항상 8의 배수 주소에 정렬되어 있다면, CPU는 단 한 번의 메모리 접근으로 값을 읽고 쓸 수 있습니다. 하지만 정렬되어 있지 않다면, 데이터가 두 개의 8바이트 메모리 블록에 걸쳐있게 되어 두 번의 메모리 접근이 필요할 수 있습니다.

x86-64 하드웨어는 데이터가 정렬되지 않아도 올바르게 동작하지만, 인텔은 메모리 시스템의 성능 향상을 위해 데이터를 정렬할 것을 권장합니다.

원칙: K바이트 크기의 데이터는 반드시 K의 배수인 주소에 위치해야 한다.

K (크기)타입
1char
2short
4int, float
8long, double, 포인터

구조체에서의 정렬 (패딩)

→ 시작 주소가 내 크기의 배수인가??

컴파일러는 구조체의 모든 멤버가 각각의 정렬 규칙을 만족하도록 패딩(padding)이라는 빈 공간을 멤버 사이에 삽입할 수 있습니다.

  • 1. 멤버 사이의 패딩:
struct S1 {
    int i;   // 4바이트
    char c;  // 1바이트
    int j;   // 4바이트
};

i는 오프셋 0 (4의 배수)에 위치합니다. c는 오프셋 4에 위치합니다. 그 다음 j는 오프셋 5에 위치해야 하지만, int는 4의 배수 주소에 있어야 합니다. 따라서 컴파일러는 cj 사이에 3바이트의 패딩을 삽입하여 j의 오프셋을 8로 만듭니다. 그 결과 구조체의 전체 크기는 9바이트가 아닌 12바이트가 됩니다.

  • 2. 구조체 끝의 패딩:
    구조체 배열에서 각 요소가 모두 정렬 규칙을 만족하도록, 컴파일러는 구조체의 전체 크기 자체를 가장 큰 멤버의 정렬 값의 배수로 맞추기 위해 끝에 패딩을 추가할 수 있습니다.
struct S2 {
    int i;   // 4바이트
    int j;   // 4바이트
    char c;  // 1바이트
};

이 구조체의 멤버 크기 합은 9바이트입니다. 하지만 이 구조체의 배열 S2 d[4];를 선언하면, 각 요소의 주소는 xd, xd+9, xd+18... 이 되어 d[1], d[2]의 멤버들이 4의 배수 주소에 위치할 수 없게 됩니다.
이를 해결하기 위해, 컴파일러는 S2의 전체 크기를 가장 큰 멤버(int)의 정렬 값인 4의 배수로 맞추기 위해 끝에 3바이트 패딩을 추가합니다. 그 결과 구조체의 전체 크기는 12바이트가 되고, 배열의 모든 요소들이 정렬 규칙을 만족하게 됩니다.

profile
멈추지 않기

0개의 댓글