12. The C memory model

포인터는 프로그램 실행 환경과 상태에 대한 추상화, C 메모리 모델을 제공한다. register 키워드가 붙은 오브젝트를 제외한 모든 오브젝트에 & 연산자를 붙여 주소값을 알아낼 수 있고 프로그램 실행 상태를 알아볼 수 있다. 이러한 접근은 추상화된 접근인데, 오브젝트의 실제 위치는 프로그래머 입장에서 여전히 알 수 없기 때문이다. 현대 OS에서 포인터를 통해 얻게 되는 주소는 항상 가상 메모리로, 프로세스에서 기계 위의 주소 공간으로의 매핑을 수행한다. 이는 특정 기계의 물리적 메모리 주소를 신경쓸 필요가 없는 포터블한 프로그래밍과, 프로세스에서 가상 메모리를 읽거나 쓰는 것이 OS나 다른 프로세스에 영향을 주지 않기 위한 안전성을 보장한다. C에서 고려해야 하는 것은 포인터 주소에 위치한 오브젝트의 타입이다.

Takeaway 2.12.0.1. 기반 타입이 다른 포인터는 다르다.

이는 모든 오브젝트가 바이트의 집합(오브젝트 표현)임을 알려준다. 오브젝트의 표현을 조사하는 편리한 도구는 공용체가 있다. 이렇게 오브젝트의 표현을 직접 조작하는 것은 편리하기도 하지만, 타입 없는 포인터/형변환/유효 타입/정렬 제한 등의 제한이 있다.

12.1. A uniform memory model.

모든 오브젝트는 바이트의 배열이다. sizeof 연산자는 그 오브젝트가 몇 바이트인지를 나타낸다. char, unsigned char, signed char는 정의에 의해 1바이트를 차지한다.

Takeaway 2.12.1.1. sizeof(char)는 1이다.

Takeaway 2.12.1.2. 모든 오브젝트 A는 unsigned char[sizeof A]로 볼 수 있다.

Takeaway 2.12.1.3. 문자 타입에 대한 포인터는 특별하다.

Takeaway 2.12.1.4. char로 문자나 문자열 데이터를 표현하라.

Takeaway 2.12.1.5. unsigned char로 오브젝트의 구성 단위를 표현하라.

Takeaway 2.12.1.6. sizeof은 오브젝트 그리고 오브젝트 타입에 대해 쓰일 수 있다.

Takeaway 2.12.1.7. 오브젝트 타입 T에 대한 sizeof은 sizeof(T)로 쓴다.

12.2. Unions.

오브젝트의 바이트 표현을 조사하는 도구로서는 union이 있다. struct와 문법은 비슷하지만 조금 다르다.

#include <inttypes.h>

typedef union unsignedInspect unsignedInspect;
union unsignedInspect {
    unsigned val;
    unsigned char bytes[sizeof(unsigned)];
};
unsignedInspect twofold = { .val = 0xAABBCCDD, };

union의 차이는 다른 타입의 오브젝트를 묶어 더 큰 하나의 오브젝트를 만드는 것이 아니라, 오브젝트들을 메모리 상에서 겹쳐서 서로 다른 타입 표현을 알아볼 수 있도록 하는 것이다. 위의 예에서, sizeof(unsigned) == 4인 플랫폼에서 twofold.val은 0xAABBCCDD고, twofold.bytes는 그 오브젝트 표현을 unsigned char의 배열로 표현한다. 기계에 따라 다르지만, 다음의 결과가 나올 수 있다.

bytes[0] : 0xDD
bytes[1] : 0xCC
bytes[2] : 0xBB
bytes[3] : 0xAA

unsigned의 바이트 표현이 거꾸로 나오고 있다. 이것은 C 표준에서 정의하는 것이 아니다. 구현체에 따라 다르다.

Takeaway 2.12.2.1. 산술 타입의 자리 표현의 메모리 순서는 구현체에 따라 다르다.

이를 엔디안이라 한다. 위처럼 거꾸로 나오는 것을 리틀 엔디안, 맞는 순서로 나오는 것을 빅 엔디안이라 한다. 둘 다 폭넓게 쓰이며, 어떤 프로세서는 자유자재로 상호 변환하기도 한다. unsigned char가 16진수 2자리인 것도 표준에 정의되어 있는 것이 아니다. 대부분의 플랫폼에서는 그렇게 정의하기는 하지만.

Takeaway 2.12.2.2. 대부분의 아키텍쳐에서는 CHAR_BIT이 8이고 UCHAR_MAX가 255이다.

부호 없는 정수형은 알아보기가 쉽다. 부호 있는 정수형은 부호를 포함해야 한다. 부동 소수점은 부호, 지수, 가수를 표현해야 하고, 포인터형은 기반 아키텍쳐의 자체 내부 컨벤션을 따른다.

12.3. Memory and state.

포인터를 통한 오브젝트 주소의 접근과 수정은 추상 상태의 실행 상태 판단을 어렵게 만든다.

double blub(double const* a, double* b);

int main(void) {
    double c = 35;
    double d = 3.5;
    printf("blub is %g\n", blub(&c, &d));
    printf("after blub the sum is %g\n", c + d);
}

위의 코드에서 blub은 c, d에 어떤 일을 하는지 알 수 없기 때문에 d가 수정되는지 알 수 없다. 따라서 c + d는 어떤 값도 될 수가 있다.

double blub(double const* a, double* b) {
    double myA = *a;
    *b = 2 * myA;
    return *a; // may be myA or 2myA
}

위의 코드에서 blub이 서로 다른 주소를 인자로 받는다면 리턴값은 myA일 것이다. 그러나 blub(&c, &c) 처럼 호출한다면 *b에 대한 대입이 *a도 바꾸므로 2myA가 된다. 이렇게 같은 오브젝트를 다른 포인터로 참조하는 것을 에일리어싱이라 한다. 최적화의 관점에서, 두 포인터는 항상 에일리어싱하거나 절대 에일리어싱하지 않는다는 두 가지의 선택지만 남겨두면 최적화가 쉬워진다. 그러므로 C는 다음을 강제한다.

Takeaway 2.12.3.1(Aliasing). 문자 타입을 제외하면, 같은 타입의 포인터끼리만 에일리어싱이 가능하다.

size_t blub(size_t const* a, double* b) {
    size_t myA = *a;
    *b = 2 * myA;
    return *a; // must be myA
}

두 파라미터의 기반 타입이 다르므로, C는 이 둘이 같은 오브젝트를 가리키지 않는다고 가정한다. 사실 blob(&e, &e)와 같은 함수 호출은 에러가 난다. 같은 오브젝트를 가리키는 포인터들을 통해 컴파일러를 속일 수 있지만, 그러면 추상 상태가 정의되지 않게 되므로 그런 짓은 하지 말자.

Takeaway 2.12.3.2. & 연산자를 가급적 피하라.

컴파일러가 변수의 주소값이 취해지지 않는다는 것을 알면 에일리어싱이 되지 않음을 보장한다. register 키워드로 & 연산자를 막을 수 있으며, restrict 키워드로 같은 타입의 포인터끼리 에일리어싱을 방지할 수 있다.

12.4. Pointers to unspecific objects.

X의 오브젝트 표현은 배열 unsigned char[sizeof X]이다. 이 배열의 시작 주소 unsigned char*는 본래의 타입 정보를 잃은 메모리 접근을 제공한다. 이에 대한 범용 포인터로 void*가 있다.

Takeaway 2.12.4.1. 모든 오브젝트 포인터는 void*로, void*로부터 변환될 수 있다.

함수 포인터에 대해서는 성립하지 않는다. void* 포인터는 오브젝트를 담은 저장소 인스턴스에 대한 포인터로 볼 수 있다.

Takeaway 2.12.4.2. 오브젝트는 저장소, 타입, 값을 갖고 있다.

Takeaway 2.12.4.3. 오브젝트 포인터를 void*로 변환한 뒤 본래 포인터 타입으로 변환하는 것은 항등 함수이다.

즉 void*로 변환한다고 오브젝트의 값은 바뀌지 않는다.

Takeaway 2.12.4.4(avoid). void*의 사용을 가급적 피하라.

void*는 타입 정보를 유실시킨다.

12.5. Explicit conversions.

오브젝트의 표현을 조사하는 편리한 방법은 오브젝트를 가리키는 포인터를 unsigned char*로 변환하는 것이다. 이는 묵시적 형변환이 되지 않는다.

double X;
unsigned char* Xp = &X; // error; implicit conversion not allowed

앞서 좁은 정수형은 연산 전 int로 승격됨을 알았을 것이다. 이는 굉장히 특별한 환경에서만 의미가 있다.

  • 메모리를 아끼기 위해 수백, 수천만 개의 작은 숫자들의 배열은 저장해야 할 때.
  • 문자와 문자열을 표현하기 위해 char를 쓸 때. 이 때는 산술 연산을 하면 안 된다.
  • 오브젝트 바이트를 표현하기 위해 unsigned char를 쓸 때. 이 때도 산술 연산은 하면 안 된다.

데이터 포인터에 대해서는 2가지의 묵시적 형변환만이 존재한다: void*로의 형변환과 void*로부터의 형변환.

float f = 37.0; // conversion: to float
double a = f; // conversion: to double
float* pf = &f; // exact type
float const* pdc = &f; // conversion: adding a qualifier
void* pv = &f; // conversion: pointer to void*
float* pfv = pv; // conversion: pointer from void*
float* pd = &a; // error: incompatible pointer type
double* pdv = pv; // undefined behavior if used

마지막 두 줄이 문제다. 첫 줄은 컴파일러가 잡아 준다. 마지막 줄은 문법적으로 맞기 때문에 더 문제가 된다. void*로 변경하면서 타입 정보를 잃어버렸기 때문에 컴파일러는 그 오브젝트가 원래 무슨 타입이었는지 알 수가 없다.

묵시적 형변환 이외에 C는 형변환을 제공한다. 이는 포인터가 가리키는 오브젝트의 타입에 대해 당신이 컴파일러보다 잘 안다고 선언하는 것이다. 절대 다수의 경우에는 당신이 틀리고 컴파일러가 맞다.

Takeaway 2.12.5.1. 형변환을 사용하지 마라.

써도 될 때는 오브젝트의 바이트를 조사하기 위해 unsigned char*로 형변환해야 하는데 union을 쓸 수 없는 상황일 때 뿐이다. 이 경우에는 형변환은 거의 안전하다.

12.6. Effective types.

C에는 유효 타입이라는 개념이 있다.

Takeaway 2.12.6.1(Effective type). 오브젝트는 그 유효 타입을 통해 접근되거나 문자 타입 포인터를 통해서만 접근되어야 한다.

union에 대해서는 이 제한이 완화된다.

Takeaway 2.12.6.2. 유효 union 타입을 가진 오브젝트 멤버는 접근 타입하의 바이트 표현이 올바른 경우에 한해서 어떻게 접근해도 상관없다.

Takeaway 2.12.6.3. 변수나 복합 리터럴의 유효 타입은 선언시의 타입이다.

Takeaway 2.12.6.4. 변수와 복합 리터럴은 선언 타입이나 문자 타입 포인터를 통해서만 접근되어야 한다.

역방향은 성립하지 않는다. 오브젝트의 표현을 나타내는 문자 타입 배열을 통해 다른 타입으로 변환할 수는 없다. 아래에서 *p에 대한 접근은 정의되지 않은 동작이다. 이는 union에서 바이트 표현을 unsigned char의 배열로 볼 수 있던 것과는 다른 것이다.

unsigned char A[sizeof(unsigned)] = {9};
unsigned* p = (unsigned*) A; // valid but useless, as most casts are
printf("value %u\n", *p); // error: access with a type that is neither the effective type nor a character type

12.7. Alignment.

포인터의 역방향 변환 (문자 포인터로부터 오브젝트 포인터로의)은 에일리어싱 면에서도 문제지만 정렬 면에서도 문제가 된다. 문자가 아닌 오브젝트는 아무 바이트 주소에서 시작할 수 없고, 단어 경계에서 시작해야 한다.

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

typedef complex double cdbl;

int main(void) {
    // an overlay of complex values and bytes
    union {
        cdbl val[2];
        unsigned char buf[sizeof(cdbl[2])];
    } toocomplex = {
        .val = {0.5 + 0.5 * I, 0.75 + 0.75 * I, },
    };
    printf("size/alignment: %zu/%zu\n", sizeof(cdbl), alignof(cdbl));
    for (size_t offset = sizeof(cdbl); offset; offset /= 2) {
        printf("offset\t%zu:\t", offset);
        fflush(stdout);
        cdbl* bp = (cdbl*) (&toocomplex.buf[offset]); // align!
        printf("%g\t+%gI\t", creal(*bp), ciamg(*bp));
        fflush(stdout);
        *bp *= *bp;
        printf("%g\t+%gI\t", creal(*bp), ciamg(*bp));
        fputc('\n', stdout);
    }
}

위 프로그램을 실행하면, 플랫폼에 따라 디테일은 다르지만, 다음의 버스 에러가 난다.

size/alignment: 16/8
offset 16: 0.75 +0.75I 0 +1.125I
offset 8: 0.5 +0I 0.25 +0I
offset 4: Bus error

for 루프 안에서 bp는 toocomplex.buf의 시작 주소로부터 offset만큼 떨어진 unsigned char*로부터 캐스팅된다. 이 offset은 16, 8, 4, 2, 1의 순서로 줄어드는데, 16, 8까지는 정렬 제한을 만족시켜서 상관이 없지만 (엉뚱한 값을 읽게 되기는 한다) 시작 주소로부터 4만큼 떨어진 곳은 정렬 제한을 만족시키지 못하므로 프로그램이 죽는다.

위의 코드에서는 alignof으로 어떤 타입의 정렬 관계를 알 수 있고, alignas로 어떤 오브젝트의 정렬을 강제할 수 있다. alignas로 오브젝트를 정렬할 때는 본래 정렬값보다 더 작은 값으로 정렬하는 것이 불가능하다. 또한, alignas로 정렬한다고 해서 오브젝트의 유효 타입 접근 룰을 어길 수도 없다.

요점 정리

  • 메모리와 오브젝트는 몇 가지 추상화 층을 제공한다: 물리적 메모리, 가상 메모리, 저장소 인스턴스, 오브젝트 표현, 이진 표현.
  • 모든 오브젝트는 unsigned char의 배열로 볼 수 있다.
  • union은 같은 오브젝트 표현에 대한 다른 오브젝트 타입의 겹침을 제공한다.
  • 메모리는 데이터 타입에 따라 다르게 정렬될 수 있다. 모든 unsigned char의 배열이 모든 오브젝트를 표현할 수 있는 것은 아니다.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중