C# - float (단정도 실수) 저장소의 비트 구조
예전 글에서,
단정도/배정도 부동 소수점의 정밀도(Precision)에 따른 형변환 손실
; https://www.sysnet.pe.kr/2/0/13212
그림으로만 float (단정도 실수), double (배정도 실수)를 설명하고 지나갔는데요, 실제로 코드를 사용해서 이것을 들여다보겠습니다. ^^
우선, 단정도 실수의 분해는 다음의 그림에 따라,
[단정도 실수 - 그림 출처:
https://ko.wikipedia.org/wiki/IEEE_754]
C# 7.0부터 리터럴에 "_" 밑줄 구분자를 임의의 위치에 추가할 수 있다는 점과
C# 7.2에 추가된 숫자 리터럴의 선행 밑줄을 통해 다음과 같은 표현으로 쉽게 분해할 수 있습니다.
namespace ConsoleApp1;
internal class Program
{
static unsafe void Main(string[] args)
{
float f = -118.625f;
Console.WriteLine($"{f}: sizeof(float): {sizeof(float)}");
Console.WriteLine();
byte* pFloat = (byte*)&f;
PrintFloatFormat(pFloat);
}
private static unsafe void PrintFloatFormat(byte* pFloat)
{
uint data = *(uint*)pFloat;
uint signBitMask = 0b_1000_0000_0000_0000_0000_0000_0000_0000; // C# 7.2부터 컴파일 가능
uint exponentMask = 0b_0111_1111_1000_0000_0000_0000_0000_0000;
uint fractionMask = 0b_0000_0000_0111_1111_1111_1111_1111_1111;
uint signBit = (data & signBitMask);
uint exponentBits = (data & exponentMask);
uint fractionBits = (data & fractionMask);
Console.WriteLine(Convert.ToString((long)signBit, 2).PadLeft(32, '0').Separator(4, '_'));
Console.WriteLine(Convert.ToString((long)exponentBits, 2).PadLeft(32, '0').Separator(4, '_'));
Console.WriteLine(Convert.ToString((long)fractionBits, 2).PadLeft(32, '0').Separator(4, '_'));
}
}
public static class StringExtension
{
// ...[생략: 첨부 소스코드 참조]...
}
실행하면 다음과 같은 결과가 나오는데요,
-118.625: sizeof(float): 4
signBit: 1000_0000_0000_0000_0000_0000_0000_0000
exponentBits: 0100_0010_1000_0000_0000_0000_0000_0000
fractionBits: 0000_0000_0110_1101_0100_0000_0000_0000
왜 저런 결과가 나왔는지를 이해하기 위해서는 먼저 IEEE 754 표준에 따라 거치는 정규화 과정을 알아야 합니다. 즉, 위의 경우 "-118.625"는 다음과 같은 정규화 과정을 거칩니다.
2진수 변환)
-118.625 ==> 1110110.101
지수 표현)
1110110.101 ==> 1.110110101 * 26
지수부: 6
가수부: 1.110110101
그런데, 이상하군요? ^^ 위에서 지수 6은 2진수로 표현하면 0110인데, 어떻게 코드에서 출력한 exponentBits(100_0010_1), 즉 133이 되었을까요? 그것은 지수부를 표현하는 8비트를 절반 나누어 음의 지수와 양의 지수로 쓰기 때문입니다. 8비트니까, 0 ~ 255까지의 값을 표현할 수 있는데요, 중간인 127을 2의 0승으로 두고 그것보다 작으면 음, 크면 양의 제곱으로 처리를 하는 방식입니다. 따라서 여기서 지수는 6이므로 +127을 해서 133을 exponentBits에 저장한 것이고 그래서 100_0010_1 값이 나온 것입니다.
가수부의 처리도 재미있습니다. 위의 경우 보존해야 할 값은 1110110101이지만 정규화했을 때 언제나 앞자리 하나는 1이므로 (비트를 절약하기 위해) 그 부분은 절삭하고 (1)110110101의 110110101 값만 가수로 저장합니다.
대충 이해가 되시죠? ^^
그렇다면, 위와 같이 분해된 정보로부터 원래의 실숫값을 복원하는 것도 가능합니다.
우선, 가수부의 110110101에서 생략된 가장 상위의 1을 복원시켜줍니다.
1_1011_0101 ==> 11_1011_0101
그다음, 지수부의 133을 원래의 지수로 만들어줍니다. 이를 위해 (반대로) 127을 빼주면 됩니다.
6 = 133 - 127
이렇게 구한 값들을 통해 처음의 float 값으로 복원할 수 있습니다.
1.110110101 * 2E6
==> 1110110.101
10진수로 ==> 118.625
==> sign 비트 적용
-118.625
(118.625의 2진수 값이 실제로 1110110.101인지 진법 계산을 해보면 나오겠지만,
간편하게 온라인 진법 계산기를 사용해 확인할 수도 있습니다.)
이 과정을 코드로 표현하면 대충 다음과 같이 만들 수 있습니다. ^^
{
bool minus = signBit != 0;
uint exponents = exponentBits >> 23;
// 삭제된 1을 복원하고,
uint fractions = fractionBits | 0b_0000_0000_1000_0000_0000_0000_0000_0000;
int shift = (int)exponents - 127;
// (삭제된 1비트의 복원으로 9비트가 아닌) 8비트만 shift 시키면 원래의 가수로 변환
fractions = fractions << 8;
string mantissa = Convert.ToString(fractions, 2).TrimEnd('0');
Console.WriteLine($"{(minus ? "-" : "")}{mantissa} * 2E{shift}");
mantissa = Convert.ToString(fractions, 2).TrimEnd('0');
mantissa = MarkDecimalPoint(mantissa, shift);
Console.WriteLine($"{(minus ? "-" : "")}{mantissa}");
decimal value = Recomposite(mantissa) * (minus ? -1 : 1);
Console.WriteLine($"{value}, (float: {(float)value})");
}
private static decimal Recomposite(string mantissa)
{
int pos = mantissa.IndexOf('.');
string left = mantissa;
string right = "";
if (pos != -1)
{
left = mantissa[0..pos];
right = mantissa[(pos + 1)..];
}
decimal integer = parseInteger(left);
decimal decimalPart = parseDecimalPart(right);
return integer + decimalPart;
}
private static decimal parseInteger(string left)
{
decimal result = 0;
decimal pow2 = 1;
foreach (char ch in left.Reverse())
{
result = result + ((ch == '1') ? 1 : 0) * pow2;
pow2 *= 2;
}
return result;
}
private static decimal parseDecimalPart(string right)
{
decimal result = 0;
decimal pow2 = 1m / 2m;
foreach (char ch in right)
{
result = result + ((ch == '1') ? 1 : 0) * pow2;
pow2 /= 2m;
}
return result;
}
private static string MarkDecimalPoint(string mantissa, int shift)
{
if (shift >= 0)
{
shift++;
mantissa = mantissa.PadRight(shift, '0');
}
else
{
string decimalPart = new string('0', -shift - 1);
mantissa = "0." + decimalPart + mantissa;
return mantissa;
}
if (mantissa.Length == shift)
{
return mantissa;
}
string left = mantissa[0..shift];
string right = mantissa[shift..];
return $"{left}.{right}";
}
이전 코드와 합쳐서 실행해 보면 이런 결과를 얻을 수 있습니다.
-118.625 (decimal: -118.625): sizeof(float): 4
signBit: 1000_0000_0000_0000_0000_0000_0000_0000
exponentBits: 0100_0010_1000_0000_0000_0000_0000_0000
fractionBits: 0000_0000_0110_1101_0100_0000_0000_0000
-1110110101 * 2E6
-1110110.101
-118.625, (float: -118.625)
잘 복원이 되었죠? ^^
(
첨부 파일은 이 글의 예제 코드를 포함>합니다.)
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]