본문 바로가기

소수·부동소수점·문자

이진 소수와 고정소수점을 읽고, IEEE 754 binary32가 부호·지수·분수 필드로 수를 나타내는 방법과 표현할 수 없는 수를 가장 가까운 짝수 쪽으로 반올림하는 규칙을 비트 패턴으로 확인합니다. 같은 바이트를 문자로 읽는 ASCII, Unicode, UTF-8과 BCD를 비교합니다.

Python에서 0.1 + 0.2를 출력하면 0.3이 아니라 0.30000000000000004가 나옵니다. 계산이 틀린 것이 아니라, 비트 수가 정해진 컴퓨터가 0.1을 정확히 담지 못해 가장 가까운 값을 대신 담기 때문입니다. 또 같은 바이트라도 수로 읽을지 문자로 읽을지는 미리 정한 약속에 달려 있습니다. 이 글에서는 진법과 고정 길이 정수의 정수 표현을 넘어, 소수와 문자를 비트로 나타내는 방법을 다룹니다.

이진 소수와 고정소수점

이진수의 소수점 아래 자리는 무게가 1/2, 1/4, 1/8, … 입니다. 그래서 0.1012=12+18=0.6250.101_2=\frac12+\frac18=0.625이고, 13.625는 정수 부분 1101과 소수 부분 .101을 합쳐 1101.10121101.101_2입니다. 거꾸로 십진 소수를 이진 소수로 바꿀 때는 2를 곱해 정수 부분을 떼어 내는 일을 반복하는데, 0.1처럼 이 과정이 끝나지 않는 수는 유한한 비트에 정확히 담을 수 없습니다. 십진수에서 1/3이 0.333…으로 끝나지 않는 것과 같은 이유입니다.

가장 간단한 방법은 소수점의 위치를 미리 정해 두는 고정소수점(fixed-point)입니다. 예를 들어 아래 2비트를 소수 부분으로 약속하면, 저장된 정수 25는 25/4 = 6.25를 뜻합니다. 계산이 정수 연산과 같아서 빠르고 단순하지만, 아주 큰 수와 아주 작은 수를 함께 다루기에는 범위가 좁습니다.

부동소수점: 부호, 지수, 분수

부동소수점(floating-point)은 수를 과학적 표기법처럼 유효숫자와 지수로 나누어 담습니다. 1101.10121101.101_2는 소수점을 왼쪽으로 세 칸 옮겨 1.1011012×231.101101_2\times2^3으로 쓸 수 있는데, 이렇게 맨 앞에 1 하나만 남도록 소수점을 옮기는 것을 정규화(normalization)라고 합니다. 소수점이 수에 따라 움직인다는 뜻에서 부동소수점이라는 이름이 붙었습니다.

가장 널리 쓰는 형식은 IEEE 754 표준입니다. 그 가운데 32비트 binary32, 흔히 단정밀도(single precision)라 부르는 형식은 부호 1비트, 지수 8비트, 분수 23비트로 나뉩니다. 정규화된 수는 맨 앞 자리가 언제나 1이므로 그 1은 저장하지 않고, 소수점 아래 부분만 분수 필드에 넣습니다. 지수는 음수도 필요하므로 실제 지수에 127을 더한 값을 부호 없는 수로 저장하는데, 이 127을 바이어스(bias)라고 합니다. 13.625는 부호 0, 저장 지수 3 + 127 = 130, 곧 10000010, 분수 101101 뒤에 0을 채운 23비트가 됩니다.

지수 필드가 모두 0이거나 모두 1인 패턴은 이 규칙을 따르지 않고 특별한 값을 나타냅니다. 지수와 분수가 모두 0이면 0이고, 지수가 모두 0이면서 분수가 0이 아니면 정규화된 수보다 더 작은 수를 나타내는 비정규 수(subnormal number)입니다. 지수가 모두 1이면 분수가 0일 때 무한대, 0이 아닐 때 NaN(not a number)입니다. 64비트 binary64, 곧 배정밀도(double precision)는 지수 11비트, 분수 52비트, 바이어스 1023으로 같은 구조이며, Python의 float가 이 형식입니다.

표현할 수 없는 수는 반올림한다

binary32의 정규화된 수는 숨겨진 1과 분수 23비트를 합쳐 유효숫자가 24비트입니다. 그래서 2242^{24}부터 2252^{25} 사이에서는 이웃한 두 표현의 간격이 224−23=22^{24-23}=2입니다. 224=167772162^{24}=16777216과 16777218은 정확히 나타낼 수 있지만, 그 사이의 16777217은 나타낼 수 없습니다.

표현할 수 없는 값은 반올림 규칙에 따라 이웃한 표현 가운데 하나로 바꿉니다. 기본 규칙은 가장 가까운 값을 고르되, 두 값까지의 거리가 같으면 유효숫자의 마지막 비트가 0인 쪽을 고르는 최근접 짝수 반올림(round to nearest, ties to even)입니다. 16777217은 16777216과 16777218에서 똑같이 1 떨어져 있으므로, 유효숫자의 마지막 비트가 0인 16777216이 됩니다. 16777219는 16777218과 16777220 사이의 동점인데, 이번에는 16777220 쪽의 마지막 비트가 0이라 위로 올라갑니다. 늘 버리거나 늘 올리는 것이 아니라 동점을 양쪽으로 고르게 나누는 규칙이라, 반올림 오차가 한쪽으로 쌓이지 않습니다.

반올림 때문에 부동소수점 연산에서는 수학의 법칙이 그대로 성립하지 않을 수 있습니다. 덧셈의 결합법칙이 대표적이라, 더하는 순서를 바꾸면 결과가 달라질 수 있습니다.

import struct


def binary32_bits(value):
    return int.from_bytes(struct.pack(">f", value), "big")


def to_binary32(value):
    return struct.unpack(">f", struct.pack(">f", value))[0]


bits = binary32_bits(13.625)
print(f"{bits:032b}", hex(bits))
print(bits >> 31, (bits >> 23) & 0xFF, f"{bits & 0x7FFFFF:023b}")
print(0.1 + 0.2, 0.1 + 0.2 == 0.3)
print((0.1 + 0.2) + 0.3, 0.1 + (0.2 + 0.3))
for n in (2**24 + 1, 2**24 + 2, 2**24 + 3):
    print(n, int(to_binary32(n)), hex(binary32_bits(n)))
01000001010110100000000000000000 0x415a0000
0 130 10110100000000000000000
0.30000000000000004 False
0.6000000000000001 0.6
16777217 16777216 0x4b800000
16777218 16777218 0x4b800001
16777219 16777220 0x4b800002

struct.pack(">f", value)는 값을 빅 엔디언 binary32의 4바이트로 바꾸고, unpack은 그 바이트를 다시 수로 읽습니다. 둘째 줄은 13.625의 비트 패턴을 시프트와 마스크로 부호 0, 저장 지수 130, 분수 필드로 나누어 본 것입니다. 셋째 줄과 넷째 줄은 Python의 float, 곧 binary64에서 0.1 + 0.2가 0.3과 같지 않고, 더하는 순서에 따라 결과가 달라진다는 것을 보여 줍니다. 마지막 세 줄은 정수를 binary32로 바꾼 결과인데, 16777217은 아래로, 16777219는 위로 반올림되고 패턴의 마지막 자리가 짝수인 0과 2로 끝납니다. 그래서 부동소수점 값은 == 대신 문제에 맞는 허용 오차를 정해 math.isclose 같은 함수로 비교하는 경우가 많습니다.

더 깊이: 반올림 방식과 예외를 하드웨어는 어떻게 다룰까

최근접 짝수 반올림은 기본 규칙일 뿐이고, IEEE 754는 다른 방향의 반올림도 정합니다. RISC-V의 F 확장은 최근접 짝수(RNE), 0 쪽으로 버림(RTZ), 음의 무한대 쪽(RDN), 양의 무한대 쪽(RUP), 그리고 동점일 때 크기가 큰 쪽을 고르는 최근접(RMM)의 다섯 가지를 지원합니다. 명령어의 3비트 rm 필드에 방식을 직접 적을 수도 있고, 111을 적어 frm 레지스터에 정해 둔 방식을 따르게 할 수도 있습니다.

연산 중에 생긴 예외 상황도 프로그램을 멈추지 않습니다. 잘못된 연산(NV), 0으로 나누기(DZ), 넘침(OF), 아래 넘침(UF), 정확하지 않은 결과(NX)는 fflags 레지스터에 쌓이는 표시로 남을 뿐이고, 기본 RISC-V ISA는 이 표시가 켜질 때 트랩을 일으키지 않습니다. 규격은 IEEE 754-2008이 허용하는 대로 트랩 대신 소프트웨어가 플래그를 직접 확인하게 했다고 설명합니다. 16777217을 binary32로 바꿀 때처럼 반올림이 일어나는 연산이라면 NX가 켜집니다.

NaN을 다루는 방법도 하드웨어가 고릅니다. IEEE 754-2008은 NaN에 담긴 추가 정보(payload)를 연산 결과로 전하라고 권하지만, RISC-V는 하드웨어 비용 때문에 이를 따르지 않고 연산 결과가 NaN이면 따로 정한 경우가 아닌 한 표준 NaN(canonical NaN)을 냅니다. binary32의 표준 NaN은 0x7fc00000입니다. 규격은 이 권고가 IEEE 754-2008에서 선택 사항이어서 이식성 있는 코드가 기대할 수 없다는 점도 이유로 듭니다.

문자도 약속에 따른 해석이다

ASCII는 영문자, 숫자, 기호, 제어 문자를 7비트 코드로 정한 약속입니다. 문자 '7'의 코드는 55이고, 이것은 정수 7과 다른 값입니다. 전 세계의 문자를 다루는 Unicode는 문자마다 코드 포인트(code point)라는 번호를 붙이고, UTF-8은 그 번호를 1바이트에서 4바이트로 부호화하는 방식입니다. ASCII 문자는 UTF-8에서도 1바이트 그대로이고, 한글 음절은 3바이트가 됩니다.

text = "A7가"
for ch in text:
    print(ch, ord(ch), hex(ord(ch)), ch.encode("utf-8").hex(" "))
print(len(text), len(text.encode("utf-8")), len(text.encode("utf-16-le")))
print(f"{0b0101_1001:08b}", 59, f"{59:08b}")
A 65 0x41 41
7 55 0x37 37
가 44032 0xac00 ea b0 80
3 5 6
01011001 59 00111011

ord는 문자의 코드 포인트를, encode("utf-8")은 그 문자를 UTF-8 바이트로 바꾼 결과를 돌려줍니다. 넷째 줄은 같은 문자열이 문자 3개, UTF-8로는 5바이트, UTF-16으로는 6바이트라는 것을 보여 줍니다. 문자 수와 바이트 수는 서로 다른 값이고, 바이트 수는 어느 부호화를 쓰는지 정해야 말할 수 있습니다.

마지막 줄은 BCD(binary-coded decimal)입니다. BCD는 십진수의 각 자리를 4비트씩 따로 담는 방법으로, 59는 5와 9를 각각 0101, 1001로 적어 01011001이 됩니다. 같은 59를 이진수로 적은 00111011과 다른 패턴입니다. BCD는 십진수 자리를 그대로 표시해야 하는 계산기나 시계 같은 장치에서 쓰입니다. 비트 패턴 하나가 무엇을 뜻하는지는 패턴만 보고는 알 수 없고, 어떤 약속으로 읽는지에 따라 정해집니다.

정리

  • 이진 소수의 자리는 1/2, 1/4, 1/8, … 의 무게를 가지며, 0.1처럼 이진수로 끝나지 않는 수는 유한한 비트에 정확히 담기지 않습니다.
  • 고정소수점은 소수점 위치를 미리 정하고, 부동소수점은 수를 정규화해 부호, 지수, 분수로 나누어 담습니다.
  • binary32는 부호 1비트, 바이어스 127인 지수 8비트, 분수 23비트이며, 지수 필드가 모두 0이나 모두 1이면 0, 비정규 수, 무한대, NaN을 나타냅니다.
  • 표현할 수 없는 값은 최근접 짝수 반올림으로 바꾸며, 그 결과 덧셈의 결합법칙 같은 수학의 법칙이 성립하지 않을 수 있습니다.
  • 문자는 ASCII나 Unicode의 번호로 나타내고, UTF-8은 그 번호를 1바이트에서 4바이트로 부호화합니다. 문자 수와 바이트 수는 다릅니다.

연습 문제

1. 같은 비트를 다르게 읽기

0.01120.011_2의 값을 구하세요. 아래 4비트를 소수 부분으로 쓰는 고정소수점에서 저장된 정수 18은 무엇을 뜻하나요? 바이트 0x41을 부호 없는 정수와 ASCII 문자로 각각 읽으세요.

해설 보기

0.0112=14+18=0.3750.011_2=\frac14+\frac18=0.375입니다. 소수 부분이 4비트이면 분모가 24=162^4=16이므로 저장된 18은 18/16 = 1.125를 뜻합니다. 0x41은 4×16+1=654\times16+1=65이고, ASCII에서 65는 대문자 A입니다. 같은 비트라도 읽는 약속에 따라 0.375, 1.125, 65, A처럼 전혀 다른 뜻이 됩니다.

2. 동점일 때의 반올림

binary32로 224+52^{24}+5를 나타내려고 합니다. 이웃한 두 표현과 각각까지의 거리를 쓰고, 최근접 짝수 반올림이 어느 쪽을 고르는지 설명하세요. 두 후보의 비트 패턴은 각각 0x4b800002와 0x4b800003입니다.

해설 보기

이 구간의 간격이 2이므로 이웃한 두 표현은 224+42^{24}+4와 224+62^{24}+6이고, 각각까지의 거리는 1로 같습니다. 동점이므로 유효숫자의 마지막 비트가 0인 쪽을 고르는데, 0x4b800002의 마지막 자리가 짝수이므로 224+42^{24}+4, 곧 16777220이 됩니다. 본문 코드의 to_binary32에 넣어도 16777220이 나옵니다. 정수 값 자체의 홀짝이 아니라 유효숫자 비트의 홀짝으로 고른다는 점에 주의해야 합니다. 두 후보 16777220과 16777222는 모두 짝수이기 때문입니다.

3. 한글 한 글자는 몇 바이트인가

"한글 한 글자는 언제나 2바이트"라는 주장을 평가하세요.

해설 보기

부호화를 정하지 않으면 맞다고도 틀리다고도 할 수 없는 주장입니다. 본문의 결과처럼 가는 UTF-8에서 ea b0 80의 3바이트이고, UTF-16에서는 2바이트입니다. 오래된 한국어 전용 부호화인 EUC-KR에서는 2바이트였으므로 이런 말이 생겼지만, 오늘날 웹과 파일에서 가장 많이 쓰는 UTF-8에서는 3바이트입니다. 문자 수, 코드 포인트 수, 바이트 수는 서로 다른 값이므로, 크기를 말할 때는 어느 것을 세는지와 어느 부호화인지를 함께 밝혀야 합니다.

부동소수점 결과가 이상하면 그 값이 정말 정확히 담길 수 있는 수인지부터 확인해 보세요. 형식의 자세한 규칙은 IEEE 754 표준 안내와 Unicode의 UTF 관련 FAQ에서 볼 수 있습니다. 다음 글 게이트와 진리표에서는 이 비트들을 실제로 계산하는 논리 회로를 다룹니다.