3.3 데이터, 코드, 메모리 레이아웃
3.3.1 수 표현
소프트웨어 엔지니어라면 누구나 컴퓨터에서 숫자가 어떻게 표현되고 저장되는지 알고 있어야 한다.
3.3.1.1 수의 기초
- 컴퓨터 공학에서는 정수나 실수 같은 수학적 단위가 컴퓨터의 메모리에 저장돼야 한다.
- 컴퓨터는 숫자를 이진 형식으로 저장한다. (0과 1)
0b 를 앞에 붙여 이진수라고 표현
- 16진수 또는 16진법도 자주 사용된다. (16진수 하나가 정확히 4비트 표현이기 때문)
- C/C++ 에서는 앞에
0x를 붙여 표현.
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인 경우:
- 용도:
- 부동소수 연산에 내재하는 오류의 오차 수치화에 도움이 됨
- 알려진 값보다 바로 다음 큰 표현 가능한 부동소수를 찾는데
- 부등호
이상 연산을 초과 연산으로 바꾸는데 쓸 수 있다.
- 수학적으로
a >= b 는 a + 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 는
- 멀티바이트 정수가 메모리에 저장되는 방법은 두 가지가 있는데 어느 방법 사용하는지는 마이크로프로세서마다 다름
- 리틀 엔디언little-endian :
- LSB가 MSB보다 낮은 메모리 주소에 저장되는 방법
- 0xABCD1234는 메모리 낮은 바이트 순서에 따라 0x34, 0x12, 0xCD, 0xAB로 저장됨
- 빅 엔디언big-endian :
- MSB가 LSB보다 낮은 메모리 주소에 저장되는 방법
- 0xABCD1234는 메모리 낮은 바이트 순서에 따라 0xAB, 0xCD, 0x12, 0x34로 저장됨
- 프로그래머가 엔디언까지 신경써야 하는 경우는 드물다. 하지만 게임 프로그래머라면 엔디언 때문에 골치아플수도 있다.
- 개발할 때는 인텔 펜티엄 PC나 리눅스에서(리틀 엔디언),
- 게임은 파워PC 계열 프로세서 콘솔에서(기본은 빅 엔디언) 하는 경우가 자주 있음
- 해결법:
- 모든 데이터 파일을 텍스트 형태로 저장하고 멀티바이트 숫자는 한 숫자가 한 바이트가 되게 십진수 형태로 저장. 디스크 용량 관점에서 비효율적이지만 적어도 제대로 돌아감
- 툴에서 디스크에 저장하기 직전에 엔디언을 바꾸게하는 방법. 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() 계열 매크로.
부동소수의 엔디언 바꾸기
- 엔디언 바꾸기를 할 때는 그냥 정수인 것처럼 생각하고 바꾸면 된다.(어차피 컴퓨터 입장에서는 바이트일 뿐임)
- 부동소수의 비트 패턴을
reinterpret_cat를 통해 std::int32_t로 형변환
- 엔디언 바꾸는 연산을 한 다음
- 다시
reinterpret_cast로 float으로 변경
- C++
reinterpret_cast 를 통해 부동소수를 정수로 바꾸러면
-
부동소수에 대한 포인터에 reinterpret_cast 수행
-
이것을 정수 타입의 포인터로 해석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;
}