3. 게임을 위한 소프트웨어 엔지니어링 기초 - 2

이관중·2023년 7월 29일

3.3 데이터, 코드, 메모리 레이아웃

3.3.1 수 표현

소프트웨어 엔지니어라면 누구나 컴퓨터에서 숫자가 어떻게 표현되고 저장되는지 알고 있어야 한다.

3.3.1.1 수의 기초

  • 컴퓨터 공학에서는 정수나 실수 같은 수학적 단위가 컴퓨터의 메모리에 저장돼야 한다.
  • 컴퓨터는 숫자를 이진 형식으로 저장한다. (0과 1)
  • 0b 를 앞에 붙여 이진수라고 표현
    • ob1101은 10진수로는 13
  • 16진수 또는 16진법도 자주 사용된다. (16진수 하나가 정확히 4비트 표현이기 때문)
  • C/C++ 에서는 앞에 0x를 붙여 표현.
    • 0xFF = ob11111111 = 255

3.3.1.2 부호 있는 정수와 부호 없는 정수

  • 컴퓨터 공학에서는 부호 있는 정수와 없는 정수를 모두 사용
  • signed, unsigned
  • 부호 없는 32비트 정수: 그냥 값을 이진수로 표현하기만 하면 됨
    • 0x00000000(0) 부터 0xFFFFFFFF(4,294,967,295) 범위의 값을 가짐
  • 부호 있는 32비트 정수: 양과 음의 값을 구분할 방법 필요
    • 쉬운방법:
      • 제일 높은 비트가 부호만 나타내게 하는 방법 (부호비트가0이면 양, 1이면 음)
      • 값으로는 총 31비트를 사용하게 되어서 범위가 절반으로 줄어듦
    • 실제 사용하는 방법:
      • 2의 보수 이용
      • 0xFFFFFFFF = -1 (음수는 여기서부터 하나씩 감소하며 나타냄)
      • 최상위 비트most significant bit가 1인 수는 음수
      • 0x00000000(0) ~ 0x7FFFFFFF(2,147,483,647)까지 양의 정수
      • 0x80000000(-2,147,483,648) ~ 0xFFFFFFFF(-1)까지 음의 정수
      • 이 경우, 음의 0과 양의 0 따로 존재하지 않고 하나의 0만 있음

3.3.1.3 고정소수점 표현법fixed-point

  • 정수부를 표현할 비트와 소수부를 표현할 비트의 수를 임의로 고정
  • 왼쪽에서 오른쪽으로 갈수록(최상위 비트에서 최하위 비트로 갈수록) 정수부는 감소하는 2의 제곱을 나타냄(..., 16, 8, 4, 2, 1)
  • 소수부는 감소하는 2의 제곱의 역수를 나타낸다(1/2, 1/4, 1/8, 1/16 ...)
  • -173.25를 32비트 고정소수점으로 나태내면
    • 1비트는 부호 비트 = ob1
    • 16비트는 정수 = 173 = ob0000000010101101
    • 15비트는 소수 = 0.25 = 1/4 = 0b0100000000000000
    • 합치면 0x8056A000
  • 단점:
    • 정수부의 범위 제한
    • 소수부의 정확도 제한

3.3.1.4 부동소수점 표현법floating-point notation

  • 소수점이 고정이 아니라 임의의 위치에 올 수 있으며 지수를 이용해 나타낸다.
  • 구조
    • 정수와 소수를 합친 수인 가수mantissa
    • 가수 안에서 어디에 소수점이 위치할지를 나타내는 지수
    • 부호 비트
  • 널리 사용되는 표준은 IEEE-754
    • 가장 높은 비트: 부호 비트
    • 그 다음 8비트: 지수
    • 나머지 23비트: 가수
    • 식으로 표현: 부호비트 s, 지수 e, 가수 m
      • v = s X 2(e - 127) X (1 + m)

절댓값과 정확도 간의 균형

  • 부동소수의 정확도는 절댓값이 작을수록 높아진다.
    • 한정된 비트로 표현된 가수를 정수부와 소수부가 나눠 써야 하기 때문
  • 절댓값이 큰 수를 표현하려고 정수부에 많은 비트 사용하면 소수부를 표현하기 위한 비트는 줄어듦
  • 부동소수점 표현법에서 가장 큰 수
    • FLT_MAX ≈ 3.403 X 1038
    • 32비트 IEEE 형식에서 이 값은 0x7F7FFFFF
      • 23비트로 표현할 수 있는 가장 큰 가수는 0x00FFFFFF, 24개의 연속된 이진수 1 (23비트에 맨앞에 붙는 암묵적 1비트 합친것)
      • 지수 255는 IEEE-754에서 숫자가 아닌 값NaN이나 무한대를 나타내는 특수한 용도로 사용
      • 8비트의 지수에서 가장 큰 값은 254
      • 중심 값 127을 빼고 나면 127이 실제 지수 값
  • 부동 소수점 표현에서는 정해진 유효 숫자(유효 비트)가 있고 지수를 이용해 유효 숫자를 시프트해서 크게 만들거나 작게 만든다.

준정규값

  • 실수를 부동소수로 표현할 때 양자화quantization가 일어난다.
  • 0주변의 값들은 준정규값subnormal values이라는 확장 개념 도입해 더 자세히 표현할 수 있음
    • 지수는 0이 아니라 1인 것으로 간주하고 가수 앞에 항상 1이 오는 것으로 취급하던 것을 0이 오는 것으로 간주
    • -FLT_MIN과 +FLT_MIN 사이에는 일정한 간격으로 값들(준정규값)이 채워지게 된다
      • FLT_MIN == std::numeric_limits<float>::min
    • 0에 가장 가까운 float 준정규값을 FLT_TRUE_MIN이라는 상수로 표현한다.

머신 엡실론

  • 각 부동소수점 표현에는 머신 엡실론machine epsilon이라는 개념
  • 1 + ε ≠ 1 식을 충족하는 가장 작은 수
  • 23비트 정확도(가수)를 지닌 IEEE-754 부동소수점에서 머신 엡실론은 2-23이고 대략 1,192 X 10-7 정도다.
  • 머신 엡실론보다 작은 수는 더해도 잘려 나가버린다.

ULP

  • Units in the Last Place
  • 2개의 부동소수가 있는데, 가수의 가장 낮은 유효 숫자의 값을 제외하고는 모두 같다고 하면 이 때 두 값은 1 ULP의 차가 있다고 한다.
  • 1 ULP의 실제 값은 지수에 따라 달라진다.
  • 일반적으로 부동소수의 지수값(-127을 더한 값)이 x인 경우:
    • 1 ULP = 2x * ε
  • 용도:
    • 부동소수 연산에 내재하는 오류의 오차 수치화에 도움이 됨
    • 알려진 값보다 바로 다음 큰 표현 가능한 부동소수를 찾는데
    • 부등호 이상 연산을 초과 연산으로 바꾸는데 쓸 수 있다.
      • 수학적으로 a >= ba + 1 ULP > b 와 동일하다
    • 사용 예)
      • 너티독에서는 대화 시스템 로직 단순화에 사용함. 비교 연산만을 갖고 캐릭터가 말해야 할 대사를 선택, 조합 가능한 모든 비교 연산자 지원하는 대신, 초과와 미만 연산만을 지원하여 이상이나 이하가 필요할때는 비교하는 수에 1ULP를 더하거나 빼서 처리함.

3.3.2 기본적인 데이터 타입

C/C++ 표준에서 타입의 상대적 크기나 부호에 관해 가이드라인이 있긴 하지만, 대상 하드웨어에 최적 성능을 내고자 컴파일러가 어느 정도 변화를 줄 수도 있다.

  • char:
    보통 8비트 ASCII나 UTF-8 부호 크기. 부호를 가질 수도 있고 아닌 경우도 있다.
  • int, shor, long int:
    대상 플랫폼에서 제일 효율적으로 처리할 수 있는 크기의 부호 있는 정수 타입. 팬티엄 계열 PC에서는 32비트. short는 int보다는 작은 수를 담고 보통 16비트를 쓴다. long은 int와 같거나 더 클 수도 있는데, 32비트 혹은 64비트이다.
  • float:
    오늘날 대부분 컴파일러에서 32비트 IEEE-754 부동소수를 사용한다.
  • double:
    2배 정밀도double precision(64비트) IEEE-754 부동소수이다.
  • bool:
    참 또는 거짓을 나타내는 값. bool의 크기는 컴파일러나 하드웨어 아키텍처마다 다르다. 1비트로 구현하지는 않으며 8비트를 사용하는 컴파일러도 있고 32비트를 전부 쓰는 컴파일러도 있다.

이식 가능한 크기 타입

  • 표준 C/C++ 기본 데이터 타입은 기본적으로 이식 가능하도록 설계됐기 떄문에 세세히 신경 쓸 필요는 없음
  • 게임을 비롯한 여러 소프트웨어 엔지니어링에서 특정 변수가 정확히 몇 비트로 이뤄졌는지 알아야 할 때 있음
  • C++11 나오기 전에는 컴파일러가 제공하는 이식성 없는 크기의 타입을 쓸 수 밖에 없었음.
    • 예)
      • VS studio에서는 __int8, __int16, __int32, __int64 같은 확장 키워드 지원.
      • 다른 컴파일러도 자신만의 특정 크기 데이터 타입 갖고 있음
  • 컴파일러마다 차이가 있다보니 거의 모든 게임 엔진은 이식성을 위해 직접 자신의 크기 타입을 정의함.
    • 너티독의 예)
      • F32: 32비트 IEEE-754 부동소수점 변수
      • U8, I8, U16, U32, I32, U64, I64: 각각 부호가 있고 없는 8, 16, 32, 64비트 정수
      • U32F, I32F: 부호가 있고 없고 '빠른' 32비트 값. 최소 32비트 크기의 값이지만 대상 CPU에서의 성능을 위해 더 클 수 있다.

<cstdint>

  • C++11 표준 라이브러리는 표준화된 크기의 정수 타입들을 도입함.
  • 이것들이 <cstdint> 헤더에 선언돼 있음
  • 부호 가진:
    • std::int8_t, std::int16_t, std::int32_t, std::int64_t
  • 부호 없는:
    • std::uint8_t, std::uint16_t, std::uint32_t, std::uint64_t
  • '빠른' 타입들도 있다. (너티독이 사용한 I32F, U32F 와 비슷한)
  • 이것들을 통해 프로그래머들은 컴파일러 전용 타입들을 감싸서 사용해야 했던 괴로움에서 해방됐다.
  • https://en.cppreference.com/w/cpp/types/integer

오거 엔진의 기본 데이터 타입

  • OGRE에는 자체적으로 정의한 데이터 타입이 꽤 있다.
  • 부호없는 정수 타입:
    • Ogre::uint8, Ogre::uint16, Ogre::uint32
  • 부동소수
    • Ogre:Real
    • 보통 float가 같은 32비트, 전처리기 매크로인 OGRE_DOUBLE_PRECISION을 1로 정의하면 엔진 전체에서 64비트(double)타입가 되게 바꿀 수 있음(자주 쓰지는 않음)
    • GPU는 항상 계산을 32비트 또는 16비트 float으로 하고 CPU/FPU 역시 float 타입을 계산하는게 빠름
    • SIMD 연산자는 32비트 float을 4개 합친 128비트 레지스터를 사용
    • 따라서 대부분 게임에서는 2배 정밀도를 사용하지 않음
  • 이 외에도 다양한 타입 지원
  • 오거 엔진은 다른 게임 엔진처럼 특정 크기를 지닌 내장 데이터 타입을 정의하지 않음
    • 8, 16, 32비트 등의 정수 타입이 없음
    • 오거 엔진 사용한다면 언젠가는 이런 타입들을 직접 만들어서 써야 함

3.3.2.1 멀티바이트 데이터와 엔디언

  • 8비트 즉, 1바이트 보다 큰 값들을 멀티바이트 값multi-byte quantity이라고 한다.
  • 예)
    • 4660 = 0x1234 는
      • 0x12, 0x34 두 바이트로 이뤄져 있다.
      • 0x12 가 최상위 바이트MSB, Most Significant Byte
      • 0x34 가 최하위 바이트LSB, Least Significant Byte
    • 0xABCD1234 는
      • MSB는 0xAB
      • LSB는 0x34
  • 멀티바이트 정수가 메모리에 저장되는 방법은 두 가지가 있는데 어느 방법 사용하는지는 마이크로프로세서마다 다름
    • 리틀 엔디언little-endian :
      • LSB가 MSB보다 낮은 메모리 주소에 저장되는 방법
      • 0xABCD1234는 메모리 낮은 바이트 순서에 따라 0x34, 0x12, 0xCD, 0xAB로 저장됨
    • 빅 엔디언big-endian :
      • MSB가 LSB보다 낮은 메모리 주소에 저장되는 방법
      • 0xABCD1234는 메모리 낮은 바이트 순서에 따라 0xAB, 0xCD, 0x12, 0x34로 저장됨
  • 프로그래머가 엔디언까지 신경써야 하는 경우는 드물다. 하지만 게임 프로그래머라면 엔디언 때문에 골치아플수도 있다.
    • 개발할 때는 인텔 펜티엄 PC나 리눅스에서(리틀 엔디언),
    • 게임은 파워PC 계열 프로세서 콘솔에서(기본은 빅 엔디언) 하는 경우가 자주 있음
    • 해결법:
      1. 모든 데이터 파일을 텍스트 형태로 저장하고 멀티바이트 숫자는 한 숫자가 한 바이트가 되게 십진수 형태로 저장. 디스크 용량 관점에서 비효율적이지만 적어도 제대로 돌아감
      2. 툴에서 디스크에 저장하기 직전에 엔디언을 바꾸게하는 방법. PC가 무조건 콘솔쪽의 엔디언을 따르게 하는 방법.

정수의 엔디언 바꾸기

  • MSB에서 시작해서 LSB의 값을 바꾼다. 가운데 도달할때까지 반복.
  • 까다로운 점은 어느 바이트를 바꿔야 하느냐이다.
    • eg) C 구조체나 C++ 클래스의 내용을 파일에 저장한다고 하면
      • 구조체 안에서 각 데이터 멤버의 위치와 크기를 정확히 알아야 함

      • 크기에 따라 각 멤버를 적절히 바꿔야 함

        // 예시 구조체
        struct Example
        {
            U32	m_a;
            U16	m_b;
            U32	m_c;
        };
        // 다음과 같은 방법으로 파일에 쓸 수 있다.
        void wrtieExampleStruct(Example& ex, Stream& stream)
        {
            stream.writeU32(swapU32(ex.m_a));
            stream.writeU16(swapU16(ex.m_b));
            stream.writeU32(swapU32(ex.m_c));
        }
        // 엔디언을 바꾸는 함수
        inline U16 swapU16(U16 value)
        {
            return ((value & 0x00FF) << 8)
                | ((value & 0xFF00)  >> 8);
        }
        
        inline U32 swapU32(U32 value)
        {
            return ((value & 0x000000FF) << 24)
                | ((value & 0x0000FF00) << 8)
                | ((value & 0x00FF0000) >> 8)
                | ((value & 0xFF000000) >> 24);
        }
      • Exapmle 구조체 전체를 단순히 바이트의 배열로 놓고 무작적 한 번에 바꾸면 안 된다.

      • 어느 멤버를 바꾸는지 그 멤버가 몇 바이트인지를 정확히 알고 각각 멤버를 변환해야 한다.

      • 어떤 컴파일러(예:gcc)는 엔디언을 바꾸는 매크로를 자체적으로 지원해서 손으로 코드를 짤 필요가 없다.

      • gcc의 __buildin_bswapXX() 계열 매크로.

부동소수의 엔디언 바꾸기

  • 엔디언 바꾸기를 할 때는 그냥 정수인 것처럼 생각하고 바꾸면 된다.(어차피 컴퓨터 입장에서는 바이트일 뿐임)
    1. 부동소수의 비트 패턴을 reinterpret_cat를 통해 std::int32_t로 형변환
    2. 엔디언 바꾸는 연산을 한 다음
    3. 다시 reinterpret_castfloat으로 변경
  • C++ reinterpret_cast 를 통해 부동소수를 정수로 바꾸러면
    1. 부동소수에 대한 포인터에 reinterpret_cast 수행

    2. 이것을 정수 타입의 포인터로 해석dereference하면 된다.

      • 이를 타입 퍼닝type punning이라고 한다.
      • 타입 퍼닝은 엄격한 에일리어싱strict aliasing을 사용할 경우 최적화 버그 유발할 수 있음
      • 다른 쉬운 방법은 유니언union을 사용하는 방법이 있음.
      union U32F32
      {
          U32	m_asU32;
          F32	m_asF32;
      };
      
      inline F32 swapF32(F32 value)
      {
          U32F32 u;
          u.m_asF32 = value;
          
          // 정수로 바꿔서 엔디언을 변경한다.
          u.m_asU32 = swapU32(u.m_asU32);
          
          return u.m_asF32;
      }
profile
컴퓨터 그래픽스를 알고싶은 개발자

0개의 댓글