객체와 정렬

객체와 정렬 (Objects and alignment)

C 프로그램은 사실상 **객체(Object)**를 만들고, 없애고, 접근하고, 조작하는 일밖에 안 해요. 그런데 여기서 객체란 정확히 뭘까요? 그리고 모든 객체에 따라다니는 "정렬 요구사항"은 왜 신경 써야 할까요? 이 페이지에서는 C의 객체가 메모리에서 어떻게 정의되는지, 그리고 객체 표현·유효 타입·정렬이 어떤 의미를 갖는지 정리해 볼게요.

출처: cppreference Objects and alignment

본문

C 프로그램은 객체를 만들고, 없애고, 접근하고, 조작해요.

C에서 객체는 실행 환경 안의 **데이터 저장 영역(region of data storage)**이에요. 그 내용물은 값을 나타낼 수 있어요. (값은 객체의 내용물을 특정 타입으로 해석했을 때의 의미예요.)

모든 객체는 다음을 가져요.

  • 크기 (sizeof로 구할 수 있어요)
  • 정렬 요구사항 (_Alignof(C23까지) / alignof(C23부터)로 구할 수 있어요)(C11부터)
  • 저장 수명(storage duration) (자동, 정적, 할당, 스레드 로컬)
  • 수명(lifetime) (저장 수명과 같거나 임시임)
  • 유효 타입(effective type) (아래 참고)
  • (미확정(indeterminate)일 수 있어요)
  • 선택적으로, 이 객체를 가리키는 식별자(identifier)

객체는 선언, 할당 함수, 문자열 리터럴, 복합 리터럴(compound literal), 그리고 배열 멤버를 가진 구조체·공용체를 반환하는 비-lvalue 표현식에 의해 만들어져요.

객체 표현 (Object representation)

비트 필드를 제외하면, 객체는 하나 이상의 연속된 바이트 시퀀스로 구성돼요. 각 바이트는 CHAR_BIT개의 비트로 이루어져 있고, memcpy로 타입 unsigned char[n]의 객체(n은 객체의 크기)에 복사할 수 있어요. 결과 배열의 내용을 **객체 표현(Object representation)**이라고 불러요.

두 객체가 같은 객체 표현을 가지면 그들은 같다고 비교돼요 (부동소수점 NaN 제외). 그 역은 성립하지 않아요. 같은 값으로 비교되는 두 객체가 서로 다른 객체 표현을 가질 수 있는데, 객체 표현의 모든 비트가 값에 참여할 필요는 없기 때문이에요. 그런 비트는 정렬 요구사항을 맞추기 위한 패딩(padding), 패리티 검사, 트랩 표현 표시 등에 쓰일 수 있어요.

객체 표현이 객체 타입의 어떤 값도 나타내지 않으면 이를 **트랩 표현(trap representation)**이라고 불러요. 문자 타입의 lvalue 표현식을 통한 읽기 외의 어떤 방식으로 트랩 표현에 접근하는 것은 **정의되지 않은 동작(undefined behavior)**이에요. 구조체나 공용체의 값은, 특정 멤버가 트랩 표현이더라도 결코 트랩 표현이 아니에요.

char, signed char, unsigned char 타입의 객체는 객체 표현의 모든 비트가 값 표현에 참여해야 하고, 가능한 모든 비트 패턴이 서로 다른 값을 나타내요 (패딩, 트랩 비트, 다중 표현이 허용되지 않아요).

정수 타입(short, int, long, long long)의 객체가 여러 바이트를 차지할 때 그 바이트들의 사용 방식은 구현 정의예요. 하지만 지배적인 두 구현은 빅엔디안(big-endian)(POWER, Sparc, Itanium)과 리틀엔디안(little-endian)(x86, x86_64)이에요. 빅엔디안 플랫폼은 정수가 차지하는 저장 영역의 가장 낮은 주소에 가장 중요한 바이트를 저장하고, 리틀엔디안 플랫폼은 가장 낮은 주소에 가장 덜 중요한 바이트를 저장해요. 자세한 내용은 엔디언(Endianness) 문서를 참고하세요. 아래 예제도 함께 보세요.

대부분의 구현은 정수 타입에 트랩 표현, 패딩 비트, 다중 표현을 허용하지 않지만 예외가 있어요. 예를 들어 Itanium에서 정수 타입의 어떤 값은 트랩 표현일 수 있어요.

유효 타입 (Effective type)

모든 객체는 **유효 타입(Effective type)**을 가져요. 유효 타입은 어떤 lvalue 접근이 유효한지, 어떤 것이 엄격한 앨리어싱 규칙을 위반하는지를 결정해요.

객체가 선언에 의해 만들어졌다면, 그 객체의 선언된 타입이 그 객체의 유효 타입이에요.

객체가 할당 함수(realloc 포함)에 의해 만들어졌다면, 선언된 타입은 없어요. 그런 객체는 다음과 같이 유효 타입을 얻게 돼요.

  • 문자 타입이 아닌 타입의 lvalue를 통해 그 객체에 첫 번째 쓰기가 일어날 때, 그 lvalue의 타입이 그 쓰기와 그 이후의 모든 읽기에 대해 그 객체의 유효 타입이 돼요.
  • memcpymemmove가 다른 객체를 그 객체로 복사하거나, 문자 타입의 배열로서 다른 객체를 그 객체로 복사할 때, 소스 객체의 유효 타입(있었다면)이 그 쓰기와 이후의 모든 읽기에 대해 이 객체의 유효 타입이 돼요.
  • 선언된 타입이 없는 객체에 대한 그 밖의 모든 접근에서, 접근에 사용된 lvalue의 타입이 유효 타입이 돼요.

엄격한 앨리어싱 (Strict aliasing)

유효 타입 T1을 가진 객체가 있을 때, 다른 타입 T2의 lvalue 표현식(보통 포인터 역참조)으로 접근하는 것은 다음 중 하나가 아닌 한 정의되지 않은 동작이에요.

  • T2T1이 **호환 타입(compatible types)**인 경우
  • T2T1과 호환되는 타입의 cvr-한정(cvr-qualified) 버전인 경우
  • T2T1과 호환되는 타입의 signed 또는 unsigned 버전인 경우
  • T2가 앞서 나열한 타입 중 하나를 멤버로 포함하는 집합체(aggregate) 또는 공용체 타입인 경우 (재귀적으로 하위 집합체나 포함된 공용체의 멤버를 포함해요)
  • T2문자 타입(char, signed char, unsigned char)인 경우
int i = 7;
char* pc = (char*)(&i);

if (pc[0] == '\x7') // aliasing through char is OK
    puts("This system is little-endian");
else
    puts("This system is big-endian");

float* pf = (float*)(&i);
float d = *pf; // UB: float lvalue *p cannot be used to access int

이 규칙은 두 포인터를 받는 함수를 컴파일할 때, 컴파일러가 한쪽으로 포인터를 통해 쓴 뒤 다른쪽을 다시 읽는 코드를 내보내야 하는지도 결정해요.

// int* and double* cannot alias
void f1(int* pi, double* pd, double d)
{
    // the read from *pi can be done only once, before the loop
    for (int i = 0; i < *pi; i++)
        *pd++ = d;
}
struct S { int a, b; };

// int* and struct S* may alias because S is an aggregate type with a member of type int
void f2(int* pi, struct S* ps, struct S s)
{
    // read from *pi must take place after every write through *ps
    for (int i = 0; i < *pi; i++)
        *ps++ = s;
}

restrict 한정자를 쓰면 위 규칙에서 두 포인터의 앨리어싱이 허용되더라도, 두 포인터가 앨리어싱하지 않는다고 명시할 수 있어요.

타입 펀닝(type-punning)은 공용체의 비활성 멤버(inactive member)를 통해서도 수행될 수 있어요.

정렬 (Alignment)

모든 **완전 객체 타입(complete object type)**은 **정렬 요구사항(alignment requirement)**이라는 속성을 가져요. 이는 size_t 타입의 정수 값으로, 이 타입의 객체를 할당할 수 있는 연속된 주소 사이의 바이트 수를 나타내요. 유효한 정렬 값은 음이 아닌 2의 거듭제곱이에요.

어떤 타입의 정렬 요구사항은 _Alignof(C23까지) / alignof(C23부터)로 조회할 수 있어요. (C11부터)

구조체의 모든 멤버의 정렬 요구사항을 만족시키기 위해, 일부 멤버 뒤에 패딩이 삽입될 수 있어요.

#include <stdalign.h>
#include <stdio.h>

// objects of struct S can be allocated at any address
// because both S.a and S.b can be allocated at any address
struct S
{
    char a; // size: 1, alignment: 1
    char b; // size: 1, alignment: 1
}; // size: 2, alignment: 1

// objects of struct X must be allocated at 4-byte boundaries
// because X.n must be allocated at 4-byte boundaries
// because int's alignment requirement is (usually) 4
struct X
{
    int n;  // size: 4, alignment: 4
    char c; // size: 1, alignment: 1
    // three bytes padding
}; // size: 8, alignment: 4

int main(void)
{
    printf("sizeof(struct S) = %zu\n", sizeof(struct S));
    printf("alignof(struct S) = %zu\n", alignof(struct S));
    printf("sizeof(struct X) = %zu\n", sizeof(struct X));
    printf("alignof(struct X) = %zu\n", alignof(struct X));
}

가능한 출력:

sizeof(struct S) = 2
alignof(struct S) = 1
sizeof(struct X) = 8
alignof(struct X) = 4

각 객체 타입은 그 타입의 모든 객체에 자신의 정렬 요구사항을 부과해요. 가장 약한(작은) 정렬은 char, signed char, unsigned char 타입의 정렬이고 1이에요. 어떤 타입의 가장 엄격한(큰) **기본 정렬(fundamental alignment)**은 구현 정의이며 max_align_t의 정렬과 같아요(C11부터).

기본 정렬은 모든 종류의 저장 수명을 가진 객체에 대해 지원돼요.

_Alignof(C23까지)/alignof(C23부터)를 써서 객체의 정렬을 max_align_t보다 엄격하게(크게) 만들면, 그 객체는 **확장 정렬 요구사항(extended alignment requirement)**을 가져요. 확장 정렬을 가진 멤버가 있는 struct/union 타입은 **과정렬 타입(over-aligned type)**이에요. 과정렬 타입이 지원되는지는 구현 정의이며, 각 저장 수명의 종류마다 지원이 다를 수 있어요.

어떤 struct/union 타입 S에 과정렬 타입의 멤버가 없고, 확장 정렬을 지정하는 정렬 지정자로 선언된 것도 없다면, S는 기본 정렬을 가져요.

모든 산술 타입이나 포인터 타입의 원자(atomic) 버전은 기본 정렬을 가져요. (C11부터)

더 알아보기

  • 객체 표현과 메모리 배치는 **메모리 모델(Memory model)**에서 이어져요.
  • 유효 타입·엄격한 앨리어싱은 포인터와 형변환, 그리고 restrict 문서와 깊이 연결돼요.
  • cppreference의 Objects and alignment 원문에서 DR 445 등 결함 보고 기록을 더 볼 수 있어요.