부동소수점 표현법은 형태를 가지는 유리수를 인코딩하는 방식입니다. 이는 매우 큰 수, 0에 매우 가까운 수, 그리고 더 일반적으로는 실수 연산의 근사치를 다루는 데 매우 유용합니다.
1980년대까지 모든 컴퓨터 제조사는 각자 자신만의 부동소수점 표현 방식과 연산 규칙을 사용했습니다. 또한, 수치적 정밀도보다는 속도와 구현의 용이성을 더 중요하게 여겨 연산의 정확성에 크게 신경 쓰지 않았습니다.
이러한 모든 상황은 1985년경, 부동소수점 숫자와 그 연산을 표현하는 매우 정교한 표준인 IEEE 754가 등장하면서 바뀌었습니다.
이 표준화 작업은 1976년 인텔의 후원으로 시작되었으며, 윌리엄 카한(William Kahan) 교수가 인텔의 컨설턴트로 참여하여 미래 프로세서를 위한 부동소수점 표준을 설계했습니다. 이후 IEEE(국제 전기 전자 기술자 협회) 주관하에 산업계 표준을 만드는 위원회가 결성되었고, 이 위원회는 카한 교수가 제안한 표준을 채택했습니다.
오늘날에는 거의 모든 컴퓨터가 이 IEEE 부동소수점 표준을 지원하며, 덕분에 과학 응용 프로그램들이 서로 다른 컴퓨터 기종 사이에서도 동일하게 작동할 수 있게 되었습니다. (이식성 향상)
이 섹션에서는 IEEE 부동소수점 형식에서 숫자가 어떻게 표현되는지 살펴볼 것입니다. 또한, 숫자가 형식에 정확히 맞지 않아 올리거나 내려야 하는 반올림(rounding) 문제와, 덧셈, 곱셈, 비교 연산자의 수학적 속성에 대해서도 탐구할 것입니다. 많은 프로그래머들이 부동소수점을 어렵다고 생각하지만, IEEE 형식은 작고 일관된 원칙에 기반하고 있어 매우 우아하고 이해하기 쉽다는 것을 알게 될 것입니다.
부동소수점을 이해하는 첫 단계는 소수점을 가진 이진수를 이해하는 것입니다.
우리가 익숙한 10진수 소수 12.34는 각 자릿수에 10의 거듭제곱 가중치를 곱하여 계산됩니다. 소수점을 기준으로 왼쪽은 양의 거듭제곱(100,101,...), 오른쪽은 음의 거듭제곱(10−1,10−2,...)을 사용합니다.
2진수 소수도 같은 원리입니다. 각 자릿수에 2의 거듭제곱 가중치를 곱합니다. 소수점을 기준으로 왼쪽은 양의 거듭제곱(20,21,...), 오른쪽은 음의 거듭제곱(2−1,2−2,...)을 사용합니다.
- 소수점 이동:
- 2진 소수점을 왼쪽으로 한 칸 옮기면 숫자를 2로 나누는 효과가 있습니다.
- 2진 소수점을 오른쪽으로 한 칸 옮기면 숫자를 2로 곱하는 효과가 있습니다.
유한한 자릿수의 10진수 소수로는 31(0.333...)을 정확히 표현할 수 없는 것처럼, 유한한 자릿수의 2진수 소수 역시 모든 수를 정확히 표현할 수는 없습니다.
2진수 소수는 오직 형태로 나타낼 수 있는 수만 정확하게 표현 가능합니다.
0.2로 정확히 표현되지만, 2진수로는 무한히 반복되는 소수가 되어 정확한 표현이 불가능합니다. 비트 수를 늘릴수록 근사치의 정확도만 높아질 뿐입니다.0.0011..._20.00110011..._20.001100110011..._2 = 0.19999...10이러한 한계 때문에 컴퓨터는 많은 실수를 정확한 값이 아닌 가장 가까운 근사치로 표현하게 됩니다.
단순한 2진 소수점 표기법은 매우 큰 수를 표현하기에 비효율적입니다. 대신 IEEE 부동소수점 표준은 숫자를 과학적 표기법과 유사한 형태로 표현합니다.
s=0)인지 음수(s=1)인지를 결정합니다.이 세 요소를 인코딩하기 위해, 부동소수점 숫자의 비트 표현은 세 개의 필드로 나뉩니다.
| 타입 | 전체 비트 | 부호(s) 필드 | 지수(exp) 필드 | 가수(frac) 필드 |
|---|---|---|---|---|
float (단정밀도) | 32 | 1 비트 | 8 비트 | 23 비트 |
double (배정밀도) | 64 | 1 비트 | 11 비트 | 52 비트 |
컴퓨터는 지수(exp) 필드의 비트 패턴을 보고 숫자를 세 가지 다른 케이스로 해석합니다.
지수(exp) 필드가 모두 0도 아니고, 모두 1도 아닐 때입니다.E (지수) 계산: E=e−Biase: 지수(exp) 필드를 부호 없는 정수로 읽은 값입니다.Bias: 미리 정해진 값으로, float는 127, double은 1023입니다. 이 Bias 덕분에 음수 지수도 표현할 수 있습니다.M (가수) 계산: M=1+ff: 가수(frac) 필드를 0.frac 형태의 소수로 읽은 값입니다. (예: frac이 101이면 f는 0.101₂)1이 맨 앞에 있다고 암묵적으로 가정하기 때문에, 23비트만으로 24비트의 정밀도를 얻는 효과가 있습니다.지수(exp) 필드가 모두 0일 때입니다.E (지수) 계산: E=1−Bias (가장 작은 지수 값으로 고정)M (가수) 계산: M=f가수(frac) 필드의 값이 그대로 가수가 됩니다.가수(frac) 필드마저 모두 0이면, 이 숫자는 0이 됩니다. (s가 0이면 +0.0, 1이면 -0.0)지수(exp) 필드가 모두 1일 때입니다.가수(frac) 필드가 모두 0일 때.s가 0이면 양의 무한대(+∞), 1이면 음의 무한대(∞)입니다.가수(frac) 필드가 0이 아닐 때.IEEE 부동소수점 형식으로 표현할 수 있는 숫자들은 수직선 상에 균등하게 분포하지 않습니다. 대신 0에 가까울수록 더 촘촘하게(정밀하게) 분포합니다.
IEEE 표준은 비정규화된 수의 지수 E를 -Bias가 아닌 1-Bias로 정의하는 영리한 방법을 사용합니다. 이 덕분에 가장 큰 비정규화된 수와 가장 작은 정규화된 수 사이의 간격이 다른 숫자들 사이의 간격과 거의 비슷해져, 값들이 0 주변에서 갑자기 변하지 않고 부드럽게 이어지게 됩니다.
가상의 8비트 부동소수점(Bias=7)을 예로 들어보겠습니다.
지수(exp) 필드: 가장 작은 값인 0001 (e=1)E = e - Bias = 1 - 7 = -6가수(frac) 필드: 가장 작은 값인 000M = 1 + f = 1.0 (숨어있는 1 때문에)8/512)만약 비정규화된 수의 지수 E가 단순히 정규화된 수보다 하나 작은 -Bias(-7)였다고 가정해 봅시다.
지수(exp) 필드: 0000E = -Bias = -7 (잘못된 가정)가수(frac) 필드: 가장 큰 값인 111M = f = 0.111₂ = 7/8 (숨어있는 1이 없음)문제점: 가장 큰 비정규화된 수(7/1024)와 가장 작은 정규화된 수(8/512 = 16/1024) 사이에 엄청난 간격(gap)이 생깁니다. 숫자 체계가 뚝 끊어지는 것이죠.
IEEE 표준은 이 문제를 해결하기 위해 비정규화된 수의 지수 E를 정규화된 수의 가장 작은 지수와 똑같이 맞춰주는 방법을 사용합니다.
지수(exp) 필드: 0000E = 1 - Bias = 1 - 7 = -6 (규칙 적용!)가수(frac) 필드: 111M = f = 0.111₂ = 7/8결과:
보시는 것처럼, 두 숫자 사이의 간격이 1/512로, 다른 숫자들 사이의 간격과 일정하게 유지됩니다. 마치 두 개의 자가 아무런 빈틈없이 완벽하게 이어지는 것과 같습니다.
IEEE 형식의 흥미로운 특징 중 하나는, 양수 부동소수점 값들의 비트 패턴을 부호 없는 정수로 해석하면 그 크기 순서가 실제 부동소수점 값의 크기 순서와 일치한다는 점입니다.
0 → 0000 00001/512 → 0000 0001...240 (최대 정규화 수) → 0111 0111+∞ → 0111 1000이처럼 비트 값의 크기와 실제 수의 크기가 비례하도록 설계되었기 때문에, 일반적인 정수 정렬 알고리즘을 사용해서 부동소수점 숫자를 정렬할 수 있어 하드웨어적으로 매우 효율적입니다.
정수 12,345를 32비트 float으로 변환하는 과정은 다음과 같습니다.
12345 → 11000000111001₂1.xxxx... 형태로 만듭니다. 소수점을 왼쪽으로 13칸 이동해야 합니다.1.1000000111001₂ × 2¹³1.1000000111001에서 1.을 제외한 1000000111001을 저장하고, 23비트를 채우기 위해 뒤에 0을 10개 붙입니다.e = E + Bias = 13 + 127 = 140. 140의 8비트 2진수는 10001100.s | exp | frac → 0 | 10001100 | 10000001110010000000000이 32비트 패턴을 16진수로 변환하면 0x4640E400이 됩니다.
부동소수점은 표현의 정밀도에 한계가 있기 때문에 실제 값을 완벽하게 나타낼 수 없고, 가장 가까운 값으로 근사해야 합니다. 이 과정을 반올림(Rounding)이라고 합니다.
IEEE 부동소수점 표준은 네 가지의 서로 다른 반올림 모드를 정의합니다.
'가장 가까운 짝수 값으로' 반올림하는 방식입니다.
1.40 → 1, 1.60 → 2)1 또는 2) 중 마지막 자리 숫자가 짝수인 쪽으로 반올림합니다.1.50 → 1과 2의 중간. 2가 짝수이므로 2로 반올림.2.50 → 2와 3의 중간. 2가 짝수이므로 2로 반올림.만약 중간 값을 항상 올림(우리가 흔히 쓰는 '사사오입')한다면, 많은 데이터를 처리했을 때 전체 평균값이 실제보다 약간 더 커지는 통계적 편향이 발생합니다. 반대로 항상 내림하면 평균값이 더 작아지겠죠.
'짝수쪽으로 반올림'은 중간 값을 올리는 경우와 내리는 경우의 확률을 거의 50:50으로 만들어, 이러한 통계적 편향을 최소화하는 매우 합리적인 방법입니다.
양수는 내리고 음수는 올립니다. 즉, 소수점 이하를 그냥 버리는(truncate) 것과 같습니다.
1.8 → 1-1.8 → -1항상 더 작은 값(음의 무한대 방향)으로 내립니다. 수학의 floor() 함수와 같습니다.
-1.8 → -11.2 → 2항상 더 큰 값(양의 무한대 방향)으로 올립니다. 수학의 ceil() 함수와 같습니다.
-1.2 → -21.8 → 1IEEE 표준은 덧셈이나 곱셈 같은 산술 연산의 결과를 결정하는 간단한 규칙을 명시합니다. 그 규칙은 "두 실수를 가지고 정확한 연산을 수행한 뒤, 그 결과를 표준 반올림 규칙에 따라 처리한 값"을 최종 결과로 삼는 것입니다.
부동소수점 덧셈(x +f y)은 우리가 아는 덧셈의 여러 수학적 속성을 따르지만, 반올림과 오버플로우 때문에 중요한 차이점을 보입니다.
x + y = y + x는 성립합니다.(3.14 + 1e10) - 1e103.14 + 1e10을 계산하면, 3.14는 너무 작은 값이어서 무시되고 결과는 1e10이 됩니다.1e10 - 1e10의 최종 결과는 0.0이 됩니다.3.14 + (1e10 - 1e10)1e10 - 1e10은 0.0입니다.3.14 + 0.0의 최종 결과는 3.14입니다.이처럼 계산 순서에 따라 결과가 달라질 수 있으므로, 컴파일러는 부동소수점 연산의 순서를 함부로 바꾸는 최적화를 매우 보수적으로 수행합니다.
부동소수점 곱셈(x *f y)도 덧셈과 유사한 특징을 가집니다.
x * y = y * x는 성립합니다.(1e20 * 1e20) * 1e-20 → ∞ * 1e-20 → +∞1e20 * (1e20 * 1e-20) → 1e20 * 1.0 → 1e20a * (b + c) = a * b + a * c도 성립하지 않습니다.정수 연산과 달리, 부동소수점 연산은 단조성이라는 중요한 속성을 만족합니다. (NaN 제외)
a ≥ b 이면, x + a ≥ x + b 입니다.a ≥ b이고 c ≥ 0이면, a * c ≥ b * c 입니다.이러한 비직관적인 특징들(특히 결합법칙의 부재)은 과학 계산 프로그래머나 컴파일러 개발자에게 매우 중요한 고려사항이며, 정확한 수치 계산을 어렵게 만드는 원인이 됩니다.
모든 C언어 버전은 float와 double이라는 두 가지 부동소수점 자료형을 제공합니다.
IEEE 부동소수점 표준을 지원하는 컴퓨터에서는 이 두 자료형이 각각 단정밀도(single-precision)와 배정밀도(double-precision)에 해당하며, 기본 반올림 방식으로는 '짝수쪽으로 반올림(round-to-even)'을 사용합니다. 하지만 C 표준 자체가 IEEE 방식을 강제하지는 않기 때문에, 반올림 모드를 바꾸거나 +∞, NaN 같은 특수 값을 사용하는 표준화된 방법은 없습니다. (대부분 시스템 라이브러리를 통해 개별적으로 지원합니다.)
int, float, double 사이에서 형 변환이 일어날 때, 숫자 값과 비트 표현은 다음과 같이 변합니다. (int가 32비트라고 가정)
int → float:int의 모든 값을 표현할 만큼 float의 범위는 충분히 넓어서 오버플로우는 발생하지 않습니다. 하지만 float의 정밀도(유효 자릿수)는 약 7자리이므로, 그보다 큰 정수는 반올림되어 근사값으로 저장될 수 있습니다.int 또는 float → double:double은 범위와 정밀도가 훨씬 더 뛰어나므로, 정확한 숫자 값이 그대로 보존됩니다.double → float:double이 표현하던 수가 float의 표현 범위를 벗어나면 +∞ 또는 ∞로 오버플로우가 발생할 수 있습니다. 범위 안에 있더라도, 정밀도가 더 낮기 때문에 반올림될 수 있습니다.float 또는 double → int:1.999 → 11.999 → 1int의 표현 범위를 벗어나면 오버플로우가 발생할 수 있습니다. C 표준은 이때의 결과를 정해두지 않았지만, 인텔 호환 프로세서는 이런 경우 정수(integer indefinite) 값, 즉 가장 작은 음수(TMin, -2147483648)를 결과로 내놓습니다. (예: (int) +1e10 → 2147483648)컴퓨터는 정보를 비트(bit)로 인코딩하며, 이 비트들은 일반적으로 바이트(byte) 단위로 묶여 관리됩니다. 정수, 실수, 문자열을 표현하기 위해 서로 다른 인코딩 방식이 사용되며, 컴퓨터 기종마다 숫자 인코딩 방식이나 바이트 순서(엔디언)에 대한 규칙이 다릅니다.
C언어는 다양한 워드 크기와 숫자 인코딩 방식을 수용하도록 설계되었습니다. 최근 32비트 머신을 대체하여 64비트 워드 크기를 가진 머신이 보편화되었습니다. 64비트 머신은 32비트용으로 컴파일된 프로그램도 실행할 수 있으므로, 기계 자체보다는 32비트 프로그램과 64비트 프로그램의 차이에 초점을 맞추는 것이 중요합니다. 64비트 프로그램의 가장 큰 장점은 32비트의 4GB 주소 공간 한계를 넘어설 수 있다는 점입니다.
대부분의 컴퓨터는 부호 있는 숫자를 2의 보수 방식으로, 부동소수점 숫자는 IEEE 754 표준으로 인코딩합니다. 프로그래머가 모든 숫자 범위에서 올바르게 동작하는 프로그램을 작성하기 위해서는 이러한 인코딩 방식을 비트 수준에서 이해하고 산술 연산의 수학적 특징을 파악하는 것이 중요합니다.
x*x가 음수가 되는 등 이상한 결과가 나올 수 있습니다.7*x를 (x<<3)-x로 변환)