Skip to content
블로그로 돌아가기
튜토리얼

Q15 고정소수점 계산법: Q 포맷 변환과 반올림·오버플로

0.1을 Q15에 넣으면 3277이 되고, 1.0은 포화 없이는 -1.0으로 뒤집힙니다. Q 표기법과 수동 변환을 온라인에서 무료로 확인하세요.

15 분 소요

Q15 고정소수점 계산법: Q 포맷 변환과 반올림·오버플로

Q15 고정소수점은 소수를 평범한 16비트 부호 있는 정수로 저장하고, 2^15 = 32768이라는 이진 스케일을 암묵적으로 약속해 둔 형식입니다. 인코딩은 곱셈 한 번과 반올림 한 번이 전부입니다:

raw = round(value × 32768)

디코딩은 나눗셈 한 번입니다:

value = raw ÷ 32768

산술은 이것으로 끝입니다. 0.5 × 32768 = 16384이므로 0.5는 메모리에 정수 16384로 들어앉고, 16384 ÷ 32768은 정확히 0.5를 반환합니다. 지수 필드도 없고 숨은 비트도 없습니다. 이진 소수점은 그 워드를 쓰는 쪽과 읽는 쪽이 맺은 약속일 뿐이고, 하드웨어가 보는 것은 언제나 int16 하나뿐입니다.

이 공식에서 곧바로 따라 나오는 결과가 둘 있습니다. 둘 다 반나절쯤은 통째로 날려 먹기 좋은 자리입니다.

0.1 × 32768 = 3276.8은 정수가 아닙니다. Q15는 3277을 저장하고, 다시 읽어 오는 값은 0.1이 아니라 0.100006103515625입니다.

1.0 × 32768 = 32768은 16비트 부호 있는 정수의 최댓값을 딱 하나 넘어섭니다. 그래서 1.0은 Q15 인코딩 자체가 없고, 그때 코드가 무엇을 하는지는 대부분의 코드베이스가 한 번도 적어 두지 않은 정책에 달려 있습니다. 포화시키면 0.999969482421875가 나오고, 순환시키면 -1.0이 나옵니다.

두 실패의 뿌리는 하나입니다. 형식이 정확히 무엇인지 아무도 적어 두지 않았다는 점입니다. 반올림 규칙도 오버플로 규칙도 툴체인이 조용히 골라 두고, 그 선택은 문서 어디에도 나오지 않습니다.

Q15, Q1.15, Qm.n은 같은 배치일까요?

Q15가 무슨 뜻이냐고 문헌 세 곳에 물어보면 답이 세 개 나올 수 있습니다. 읽는 쪽이 잘못한 것이 아닙니다. 이 표기법은 애초에 표준화된 적이 없고, 의견이 갈리는 지점은 비트 하나입니다.

Qm.n 읽는 법

숫자를 두 개 쓰는 형태가 정직한 쪽입니다. Qm.n에서 n은 소수부 비트 수이고 m은 정수부 비트 수입니다. 이 글은 부호 자리를 m 안에 넣어 세는데, Q16.16이 32비트 워드가 되는 것도 그 관례에서입니다. 부호를 포함한 정수부 16비트에 소수부 16비트, 스케일은 2^16 = 65536입니다. 1.5를 Q16.16으로 인코딩하면 1.5 × 65536 = 98304, 16진수로 0x00018000이 되고 반올림 오차는 전혀 없습니다.

문제는 부호 비트에서 시작됩니다. 어떤 저자는 부호 비트를 m 안에 넣어 세고, 어떤 저자는 m 위에 따로 얹습니다. 첫 번째 관례에서 Q1.15는 16비트 워드입니다. 부호 겸 정수 자리 하나에 소수부 15비트입니다. 두 번째 관례에서 같은 이름표는 17비트를 가리키는데, 그런 기계는 없습니다.

같은 Q15라는 이름표가 문서마다 다른 폭을 뜻하는 이유

숫자를 하나만 쓰는 Q15 형태는 m을 아예 빼 버려서 워드 폭을 암묵적으로 남겨 둡니다. DSP 실무에서 이 표기는 거의 언제나 소수부 15비트를 가진 16비트 부호 있는 2의 보수 워드를 뜻하고, TI와 ARM 생태계가 수십 년 전에 합의한 의미도 이쪽입니다.

그런데 이런 식의 설명이 널리 복사되어 돌아다닙니다. Q15는 소수부가 15비트라는 뜻이므로, 32비트 수로 정의하면 부호 비트 1개와 정수부 16비트가 있다. 찬찬히 읽어 보면 이 문장은 부호 비트를 m 안이 아니라 m 위에 얹어 세고 있습니다. 여기서 쓰는 것과는 다른 관례입니다. 서술된 배치 자체는 실재하고, 보통은 Q16.15라고 적습니다. 잘못된 것은 거기 붙은 이름표입니다. 정수부가 16비트인 32비트 워드는 어느 관례에서도 Q15가 아니기 때문입니다. 같은 문단이 충분히 많은 블로그에 재생산된 나머지, 일부 검색어에서는 이제 올바른 정의보다 위에 노출됩니다.

낯선 문서에 그냥 Q15라고만 적혀 있으면 사실이 아니라 가설로 취급하세요. 메모리 맵의 레지스터 폭, 드라이버 헤더의 C 타입, 이미 아는 샘플 값처럼 직접 측정할 수 있는 것에 대조해 확인하면 됩니다.

남의 코드와 부딪혀도 버티는 서술

적어야 할 것은 세 가지입니다.

  • 부호 유무: 부호 있는 2의 보수인가, 부호 없는 수인가
  • W: 워드의 전체 비트 수
  • F: 소수부 비트 수

signed, W=16, F=15는 잘못 읽힐 수가 없습니다. unsigned, W=32, F=16도 마찬가지입니다. 나머지는 전부 여기서 유도됩니다. 스케일은 2^F, 분해능은 2^-F, 범위는 워드의 정수 범위를 2^F로 나눈 값입니다. 이 세 값을 프로토콜 문서와 구조체 주석에 적어 두면 이 논쟁을 다시 할 일이 없습니다.

온라인 Q 포맷 변환기가 결과마다 부호 유무, W, F, 스케일을 함께 출력하는 것도 같은 이유입니다. 데이터시트에는 “Q15”라고 적혀 있는데 동료의 파서는 다른 말을 할 때, 이미 아는 워드 하나를 디코딩해 보면 10초쯤이면 결판이 납니다.

손으로 풀어 보는 변환 공식

양방향 모두 종이에 적어 풀 수 있을 만큼 짧습니다. 오실로스코프 화면의 16진 덤프를 노려보고 있을 때 이 점이 중요합니다.

부동소수점 → 고정소수점: round(x × 2^F)

0.5를 Q15로 옮겨 보겠습니다.

  1. 스케일링: 0.5 × 32768 = 16384
  2. 반올림: 이미 정수이므로 16384 그대로입니다
  3. 범위 검사: 16비트 부호 있는 정수는 -32768부터 32767까지 담고, 16384는 들어갑니다
  4. 저장: 16384, 16진수 0x4000, 2진수 0100000000000000

정보를 잃을 수 있는 곳은 2단계뿐이고, 실패할 수 있는 곳은 3단계뿐입니다. 고정소수점에서 흥미로운 일은 전부 이 두 곳 중 하나에서 일어납니다.

고정소수점 → 부동소수점: raw ÷ 2^F

이번에는 반대 방향으로, 부호 있는 Q15 필드에서 0xC000으로 읽힌 레지스터 캡처에서 출발합니다.

  1. 텍스트를 부호 없는 16비트 코드로 파싱합니다: 0xC000 = 49152
  2. 형식이 부호 있는 수이고 최상위 비트가 켜져 있으므로 2^16을 뺍니다: 49152 - 65536 = -16384
  3. 스케일을 되돌립니다: -16384 ÷ 32768 = -0.5

사람들이 건너뛰는 것은 2단계입니다. 2의 보수 보정을 하지 않으면 0xC000+1.5로 읽히는데, 이 값은 Q15의 범위 안에 있지도 않습니다. 그래서 이 단계가 쓸 만한 검산 장치가 됩니다. 디코딩한 값이 형식의 범위 밖으로 나갔다면 부호 보정을 빠뜨렸다고 봐도 거의 틀리지 않습니다.

from fractions import Fraction


def encode_q15(x):
    """Decimal value -> signed Q15 stored integer (ties to even)."""
    return round(Fraction(str(x)) * 32768)


def decode_q15(word):
    """Unsigned 16-bit code -> exact Q15 value."""
    if word & 0x8000:
        word -= 0x10000
    return Fraction(word, 32768)


print(encode_q15(0.5))              # 16384
print(encode_q15(0.1))              # 3277
print(float(decode_q15(0xC000)))    # -0.5
print(float(decode_q15(0x0CCD)))    # 0.100006103515625

여기서 Fraction은 실제로 제 몫을 하고 있습니다. float를 거쳐 스케일링하면 하필 그 오차를 측정하려는 순간에 이진 반올림이 다시 끼어들고, Fraction(str(x))는 그 수에 가장 가까운 double이 아니라 입력한 10진 리터럴을 그대로 읽습니다.

16진 워드 읽고 쓰기

레지스터 뷰에 실제로 나타나는 형태가 16진수이고, 변환은 기계적입니다. 3277을 16진수로 쓰면 0xCCD이고, W/4 자리에 맞춰 0x0CCD으로 채웁니다. 자리는 항상 채우세요. Q15 워드는 16진수 네 자리, Q31 워드는 여덟 자리입니다. 앞의 0을 빠뜨리는 것이 일괄 디코더에서 값이 어긋나는 전형적인 경로입니다.

원시 코드는 16진수에서 부호 없이 유지하세요. 0x8000+32768이 아니라 Q15에서 가장 작은 음수 워드이고, 이것을 -0x8000이라고 쓰면 아무에게도 도움이 되지 않습니다. 바이트 순서는 별개의 문제입니다. Q 포맷은 수치 스케일링을 규정할 뿐 엔디언에 대해서는 아무 말도 하지 않기 때문입니다. 0x0CCD을 리틀 엔디언으로 덤프하면 바이트는 CD 0C 순서로 도착합니다. 덤프를 읽다가 스케일이나 부호 폭 없이 진법만 변환하고 싶다면 진법 변환기가 2진수, 8진수, 16진수를 처리합니다.

Q7, Q15, Q31, Q16.16: 범위와 분해능

이 표의 모든 수는 한쪽 끝이 -2^(W-1)2^F로 나눈 값이고 다른 쪽 끝이 (2^(W-1) - 1)2^F로 나눈 값입니다. 표시용으로 반올림한 값이 아니라 정확한 값입니다.

형식소수부 비트 F스케일 2^F최솟값최댓값 (정확값)분해능
Q787128-1127/128 = 0.99218751/128 = 0.0078125
Q15161532768-132767/32768 = 0.9999694824218751/32768 = 0.000030517578125
Q3132312147483648-1(2^31−1)/2^31 = 0.99999999953433871269226074218751/2^31 ≈ 4.6566128730773926e-10
Q16.16321665536-32768(2^31−1)/65536 = 32767.99998474121093751/65536 = 0.0000152587890625

이 경계값 중 아무거나 Q 포맷 변환기에 붙여넣으면 저장 정수, 16진 워드, 정확한 10진값을 그대로 출력합니다. 펌웨어 상수를 출하 전에 확인하는 가장 빠른 방법입니다.

Q15 형식 범위의 위쪽 끝이 0.999969482421875인 이유

Q15의 형식 범위는 비대칭이고, 그 비대칭은 고정소수점 고유의 무언가가 아니라 2의 보수에서 옵니다. 16비트 부호 있는 워드는 정수 -32768부터 32767까지를 덮습니다. 양 끝을 32768로 나누면 범위는 -32768/32768부터 32767/32768까지, 즉 -1부터 0.999969482421875까지가 됩니다.

그래서 1.0은 정확히 1 LSB만큼 모자랍니다. “대략 1.0이 최댓값”이라거나 “반올림하면 1.0”인 것이 아닙니다. 그 값은 인코딩 자체가 없고, 포화가 일어났다는 말 없이 Q15에서 1.0을 보고하는 변환기는 거짓말을 하고 있습니다.

-1이 포함되는 이유

적지 않은 문헌이 Q15 범위를 양쪽 모두 열린 구간으로 -1 < X < 0.9999695처럼 씁니다. 아래쪽 경계가 틀렸습니다. -32768 ÷ 32768 = -1이 정확히 성립하므로 -1은 표현 가능하고, [-1, 0.999969482421875]의 양 끝은 둘 다 실제로 취할 수 있는 값입니다. 이 범위를 [-1, 1)로 적어 둔 문헌도 보게 되는데, 같은 이야기를 풀스케일 기준으로 쓴 것입니다. 1.0 자체에는 닿을 수 없지만, 닿을 수 있는 가장 큰 값은 거기에 조금 못 미치는 어떤 값이 아니라 0.999969482421875입니다.

이것은 표기법 트집보다 중요한 문제입니다. -1은 곱셈을 깨뜨리는 값입니다. -1 × -1 = 1인데 1은 범위 밖이기 때문입니다. -1에 도달할 수 없다고 가정하는 사람은 그 경우를 잡아 줄 포화 분기를 작성하지 않습니다.

같은 문헌에 나오는 잘린 상한 0.9999695는 표시상의 부산물입니다. 이 값에 순환하는 부분은 없습니다. 값은 32767/32768이고 32768은 2의 거듭제곱이므로, 10진 전개는 15자리에서 0.999969482421875로 끝납니다.

0.1은 Q15에 들어가지 않습니다

스케일링해 보면 문제가 곧바로 드러납니다. 0.1 × 32768 = 3276.8입니다. 고정소수점은 정수만 저장할 수 있으므로 어딘가는 양보해야 합니다.

가장 가까운 값으로 반올림하면 저장되는 워드는 3277, 16진수로 0x0CCD입니다. 그 워드가 표현하는 값은 다음과 같습니다:

3277 ÷ 32768 = 0.100006103515625

양자화 오차는 요청한 값과 격자가 줄 수 있는 값의 차이입니다:

0.100006103515625 - 0.1 = +0.000006103515625

백만분의 6쯤, 즉 1 LSB의 5분의 1 정도입니다. 볼륨 조절에서는 무해합니다. 초당 천 번씩 이 값을 다시 더해 나가는 적분기에서는 무해하지 않습니다. 누적값이 초당 0.006쯤 꾸준히 흘러가기 때문입니다.

고정소수점의 오차는 균일하고, 부동소수점의 오차는 그렇지 않습니다

Q15는 [-1, 0.999969482421875] 구간에 정확히 1/32768 간격으로 65536개의 점을 깔아 격자를 만듭니다. 0.9 부근의 간격과 0.0001 부근의 간격이 같으므로, 최악의 절대 오차는 어디서나 0.5 LSB입니다. 덕분에 오차 분석이 가장 바람직한 의미로 지루해집니다. 신입 엔지니어도 검산할 수 있는 산수로 필터의 잡음 바닥을 한계 지을 수 있습니다.

IEEE 754는 정반대로 합니다. 유효 비트 수를 고정해 두고 지수를 움직이므로, 이웃한 double 사이의 절대 간격은 크기에 따라 커지지만 상대 오차는 거의 일정하게 유지됩니다. 1.0 근처에서 그 간격은 약 2.2e-16이고, 1e12 근처에서는 약 0.0001220703125입니다.

같은 실패의 다른 모양입니다. double도 0.1을 담지 못합니다. 0.1000000000000000055511151231257827021181583404541015625를 저장하고, 그래서 0.1 + 0.20.30000000000000004를 반환합니다. 그 사례는 부동소수점 정밀도를 다룬 글에서 끝까지 따라갑니다. 고정소수점이 2진수 안의 10진 분수 문제를 풀어 주지는 않습니다. 오차의 크기를 예측 가능하게 해 줄 뿐입니다.

1 LSB 차이로 갈리는 세 가지 반올림 모드

반올림 규칙은 구현이 알아서 할 문제로 취급되기 쉽지만, 실제로는 데이터 계약에 속합니다. 반올림에서 어긋나는 두 올바른 구현은 마지막 비트가 영원히 다른 테스트 벡터를 내놓고, 그것을 추적하는 일은 비참합니다.

모드규칙10911.744-3276.8
가장 가까운 값으로 반올림, 동점은 짝수로가장 가까운 격자점, 정확히 절반이면 짝수 정수로10912-3277
0 방향 절단소수부를 버리므로 크기는 줄어들기만 함10911-3276
음의 무한대 방향 내림수직선에서 언제나 아래쪽으로10911-3277

두 열을 나란히 둔 이유는 예제 하나로는 절대 충분하지 않기 때문입니다. 양수에서는 절단과 내림이 일치합니다. 음수에서는 1 LSB만큼 통째로 갈라집니다. 절단은 -3276.8을 0 쪽으로 끌어올리고, 내림은 아래로 밀어 내리기 때문입니다.

다들 틀리는 그 예제

0.333을 Q15로 넣는 것은 경계에 바짝 붙어 있어서 좋은 시험 사례입니다. 0.333 × 32768 = 10911.744입니다.

  • 절단: 10911, 다시 읽으면 0.332977294921875
  • 가장 가까운 값으로 반올림: 10912, 다시 읽으면 0.3330078125

오래된 튜토리얼은 10911을 찍어 놓고 어느 규칙에서 나온 값인지는 말하지 않은 채 넘어갑니다. 그 숫자를 인코더가 반올림하는 코드베이스로 가져가면 골든 벡터가 첫 실행부터 깨지는데, 차이가 1뿐이라 정책 불일치가 아니라 오타처럼 보입니다.

음수에서 세 규칙이 서로 갈라집니다. -0.1 × 32768 = -3276.8은 절단에서 -3276(값 -0.0999755859375), 내림이나 반올림에서 -3277(값 -0.100006103515625)이 됩니다. 절단과 반올림은 두 부호를 똑같이 다루어 각각 ±3276±3277을 내놓으므로, 대칭인 계수 쌍은 대칭인 채로 남습니다. 내림은 그렇지 않습니다. +0.13276으로 보내면서 -0.1-3277로 보내므로, 쌍이 1 LSB만큼 한쪽으로 치우칩니다.

언어가 기본으로 하는 일

어느 기본값도 소스 코드 표면에 드러나지 않습니다. 캐스트 한 줄만 봐서는 어떤 규칙이 걸려 있는지 알 수 없다는 뜻입니다.

  • C/C++: 부동소수점에서 정수 타입으로의 캐스트는 0 방향으로 절단합니다. (int16_t)(0.333f * 32768)10911입니다.
  • Python: 내장 round()는 동점을 짝수로 처리하므로 round(3276.8)3277이고 round(2.5)2입니다.
  • 자바스크립트(JavaScript): Math.round는 동점을 양의 무한대 쪽으로 처리하는데, 이는 대칭이 아닙니다. Math.round(2.5)3이지만 Math.round(-2.5)-2입니다.
  • 하드웨어: 많은 DSP 곱셈-누산 경로가 시프트 시점에 반올림하고 nearest-even을 모드 비트로 지원합니다. 누군가 모드 레지스터를 읽어 볼 때까지 참조 C 모델과 실리콘이 서로 어긋나는 이유가 이것입니다.

규칙을 하나 고른 다음 형식 명세의 W, F 옆에 그 이름을 적어 두세요. 테스트 벡터에도 같이 실어 보내야 합니다.

오버플로: 포화와 순환

Q15 오버플로는 셋 중 하나를 합니다. 값을 거부하거나, 0.999969482421875로 클램프하거나, -1.0으로 순환시킵니다. 어느 쪽이 될지는 툴체인이 고른 정책이고, 세 번째 선택지는 부호를 소리 없이 뒤집습니다.

1.0을 봅시다. 스케일링하면 1.0 × 32768 = 32768인데, 16비트 부호 있는 정수의 최댓값은 32767입니다. 딱 1만큼 범위를 벗어납니다.

정책저장 워드다시 읽은 값하류에서 보이는 모습
오류없음변환 거부요란하고 잡아낼 수 있으며, 도구에는 대개 이쪽이 맞음
포화0x7FFF = 327670.999969482421875귀로도 눈으로도 1.0과 구별 불가
순환0x8000 = -32768-1.0풀스케일 부호 반전

포화 행은 0.000030517578125을 잃고 아무도 알아채지 못합니다. 순환 행은 풀스케일 양의 샘플을 풀스케일 음의 샘플로 뒤집는데, 오디오 경로에서 이것은 방 건너편까지 들리는 클릭 잡음입니다. 제어 루프에서는 반대 방향으로 나가는 풀스케일 명령입니다.

순환의 위험한 성질은 겉보기에 완벽하게 멀쩡한 워드를 내놓는다는 점입니다. 0x8000은 -1.0의 적법한 Q15 인코딩입니다. 하류의 어느 것도 이것을 진짜 -1.0 샘플과 구별하지 못하므로, 사후에 grep으로 찾을 서명이 남지 않습니다. 오버플로가 일어난 지점에 계측을 넣어야만 발견할 수 있습니다.

DSP 실리콘이 포화 명령어를 싣고 나오는 이유

포화는 신호 처리가 원하는 동작이므로, 프로세서는 이것을 분기에 맡기지 않고 직접 구현합니다. ARM에는 QADD/QSUB와 포화 시프트 명령어 SSAT/USAT가 있고, NEON에는 VQADD를 비롯한 명령어가 있으며, x86 SSE에는 paddsw 같은 팩드 포화 덧셈이 있습니다. TI의 C6000과 C55x 계열은 누산기 경로의 모드 비트로 포화를 노출합니다.

필터나 믹서에서 이따금 클리핑되는 샘플 하나는 작은 국소 왜곡입니다. 순환된 샘플은 스펙트럼 전체에 에너지를 뿌리는 불연속입니다. 하드웨어는 기본적으로 파국적으로 틀리는 쪽보다 조금 틀리는 쪽을 고릅니다. 다만 그 기능을 켜 두었을 때만 그렇고, 같은 칩에서도 평범한 C 정수 산술은 여전히 순환합니다.

Q15 곱셈에 15비트 오른쪽 시프트가 필요한 이유

Q15 워드 두 개를 정수로 곱하면 결과 자체는 맞지만, 더 이상 Q15가 아닙니다. 곱셈에서는 소수부 비트가 더해집니다. Q15 × Q15는 Q30이 됩니다.

정답이 0.25인 것이 뻔한 0.5 × 0.5를 따라가 봅시다:

16384 × 16384 = 268435456          ← this is Q30, not Q15
268435456 ÷ 2^30 = 0.25            ← read as Q30, correct
268435456 >> 15 = 8192             ← realign to Q15
8192 ÷ 32768 = 0.25                ← same answer, back in Q15

268435456을 Q15로 해석하면 8192.0으로 읽히는데, 32768배만큼 어긋난 값입니다. 그 배수가 버그의 전부입니다. “거의 동작하는” 고정소수점 필터가 흔히 2의 거듭제곱만큼 어긋나 있는 것도 여기서 옵니다.

곱에는 자리도 필요합니다. 16비트 값 두 개는 최대 32비트로 곱해지므로 중간값은 int32_t여야 합니다. 곱을 여러 개 누적하려면 여유가 더 필요한데, C55x 같은 부품의 DSP 누산기가 40비트인 것도 그래서입니다.

비트를 그냥 버리지 말고 시프트에서 반올림하세요

맨몸의 >> 15는 하위 15비트를 버리는데, 부호 있는 값에서 이는 음의 무한대 방향 내림입니다. 나가는 쪽 정밀도의 0.5 LSB를 먼저 더해 주면 가장 가까운 값으로의 반올림이 됩니다:

#include <stdint.h>
#include <stdio.h>

static int16_t sat_q15(int32_t v) {
    if (v >  32767) return  32767;
    if (v < -32768) return -32768;
    return (int16_t)v;
}

static int16_t mul_q15(int16_t a, int16_t b) {
    int32_t prod = (int32_t)a * (int32_t)b;   /* Q30 */
    int32_t back = (prod + (1 << 14)) >> 15;  /* round, then Q30 -> Q15 */
    return sat_q15(back);
}

int main(void) {
    printf("0.5*0.5   -> %d\n", mul_q15(16384, 16384));
    printf("0.1*0.1   -> %d\n", mul_q15(3277, 3277));
    printf("-1*-1     -> %d\n", mul_q15(-32768, -32768));
    printf("no-round  -> %d\n", (int)(((int32_t)3277 * 3277) >> 15));
    return 0;
}

cc -std=c11 -Wall -o q15 q15.c && ./q15로 컴파일하면 다음이 출력됩니다:

0.5*0.5   -> 8192
0.1*0.1   -> 328
-1*-1     -> 32767
no-round  -> 327

세 번째 줄이 앞에서 나온 -1 사례입니다. -32768 × -32768 = 1073741824은 Q30에서 1.0이고 Q15의 범위 밖이므로, sat_q1532767로 클램프합니다. 클램프를 없애면 int16_t로의 캐스트가 이 값을 -32768로 순환시켜서 -1 × -1-1이 됩니다.

마지막 두 줄이 반올림 차이입니다. 3277 × 3277 = 10738729이고, 맨몸 시프트는 327(0.009979248046875)을, 반올림한 시프트는 328(0.010009765625)을 냅니다. 참값은 0.01이므로 반올림한 쪽이 두 배 이상 가깝게 떨어집니다. 덧셈 한 번을 더 하고 얻는 결과입니다.

시프트 자체에 대해 한 가지 단서가 있습니다. 음수인 부호 있는 정수를 오른쪽으로 시프트하는 동작은 C23 이전에는 구현 정의이지만, 실제로 마주칠 컴파일러는 모두 산술 시프트를 수행합니다. 그래도 마음이 불편하다면 32768로 나누어 컴파일러가 시프트를 내보내게 하거나, 부호 없는 타입으로 바이어스를 준 뒤 시프트하세요. 시프트와 마스크의 더 넓은 동작 원리는 비트 연산 가이드에서 다룹니다.

덧셈은 Q 값부터 맞춰야 합니다

곱셈은 Q 값을 예측 가능하게 변화시킵니다. 덧셈은 불일치를 조금도 용납하지 않습니다. Q7 워드를 Q15 워드에 더하면 말이 되지 않는 값이 나오는데, 피연산자가 스케일을 공유하지 않기 때문입니다.

먼저 시프트로 줄을 맞추세요. Q7에서 0.564이고, 64 << 816384, 즉 Q15의 0.5입니다. 시프트량은 소수부 비트 수의 차이인 15 - 7 = 8입니다.

위로 시프트하는 것은 정확하지만 여유 자리를 소모합니다. Q15로 승격된 Q7 값에는 더 넓은 그릇이 필요하기 때문입니다. 아래로 시프트하는 것은 손실이 있고, 곱셈과 똑같은 반올림 결정을 요구합니다. 어느 쪽이든 모든 중간값의 Q 값을 주석에 적어 두세요. 스케일이 작성자 머릿속에만 있는 고정소수점 코드는 한 달 안에 유지보수가 불가능해집니다.

Q15 고정소수점과 IEEE 754 부동소수점, 무엇을 고를까

둘 다 2진 자릿값 체계이므로 원리상 어느 쪽이 더 정확하지는 않습니다. 선택은 대상 하드웨어가 무엇을 요구하는지, 그리고 어떤 보장이 필요한지로 갈립니다.

질문고정소수점 쪽 근거부동소수점 쪽 근거
하드웨어 FPU가 있습니까?FPU 없음, 또는 소프트 부동소수점 라이브러리단일 사이클 연산이 되는 하드웨어 FPU
동적 범위는 얼마나 넓습니까?정규화된 오디오처럼 알려져 있고 한정됨여러 자릿수를 넘나듦
전송 형식이 스케일을 규정합니까?프로토콜이나 레지스터가 이진 스케일을 고정함필드가 진짜 실수형
빌드가 달라도 비트 단위로 같아야 합니까?그렇다면 정수는 어디서나 재현됨FMA와 최적화에 따라 달라져도 무방
메모리나 대역폭이 빠듯합니까?16비트 샘플은 32비트 실수의 절반제약 아님
코드는 누가 유지보수합니까?이미 Q 표기법에 익숙한 팀혼성 팀이라 스케일링 버그가 더 큰 위험

마지막 행은 농담이 아닙니다. 고정소수점은 오차를 런타임에서 설계 단계로 옮기는데, 이는 누군가 그 설계 작업을 실제로 할 때만 좋은 거래입니다. 하드웨어 FPU가 달린 Cortex-M4F에서는 단정밀도 실수가 더 빠르면서 동시에 더 안전한 선택인 경우가 많고, Q15부터 집어 드는 습관은 이제 주류가 아닌 부품에서 굳어졌습니다.

IEEE 754가 넘겨받는 지점

워드 자체가 고정된 스케일이 아니라 부호와 지수와 유효숫자를 담는다면 부동소수점을 쓰세요. 그 순간이 Q 포맷이 더 이상 적용되지 않는 지점입니다. 나눌 단일한 2^F가 없습니다. 지수가 값마다 달라지기 때문입니다.

두 표현은 실무에서 끊임없이 만납니다. 센서 데이터가 Q15 레지스터 워드로 들어와서, 긴 계산을 위해 실수로 승격되었다가, DAC를 위해 다시 Q15로 돌아옵니다. 그 경로의 부동소수점 쪽 절반을 비트 단위로 들여다보려면 IEEE 754 변환기를 쓰면 됩니다. 값을 부호, 지수, 가수로 분해하고 저장된 정확한 10진값을 출력합니다. Q 포맷 변환기가 고정 스케일에 대해 하는 일과 같습니다.

둘 다 같은 토대 위에 있습니다

Q 포맷, IEEE 754, 평범한 정수는 모두 같은 비트를 두고 소수점이 어디에 있는지, 그리고 그것이 움직일 수 있는지에 대한 규칙만 달리해서 읽는 것입니다. 자릿값 쪽이 아직 흔들린다면, 혹은 계산기를 꺼내지 않고 0x0CCD0000 1100 1100 1101로 읽는 속도를 올리고 싶다면, 2진수·16진수·8진수 변환 입문 글이 두 형식이 함께 딛고 선 기초를 다룹니다.

Q15 고정소수점 FAQ

Q15는 무슨 뜻인가요?

흔히 쓰이는 DSP 관례에서 Q15는 소수부 비트가 15개인 16비트 부호 있는 2의 보수 워드입니다. 부호 자리 하나에 소수 자리 15개입니다. 스케일은 2^15 = 32768, 분해능은 1/32768 = 0.000030517578125이고, 범위는 -1부터 32767/32768까지입니다. Q 이름표는 문서마다 다르므로 이름표만 믿지 말고 전체 폭과 부호 유무를 확인하세요.

Q15와 Q1.15는 같은가요?

대개 같은 16비트 부호 있는 배치를 가리키며, Q1.151이 부호 자리를 세는 것입니다. 다만 이 표기법은 보편적이지 않고, 일부 저자는 부호 비트를 m 안에 세는 대신 그 위에 얹습니다. 믿을 만한 서술은 부호 유무와 전체 비트 수 W, 소수부 비트 수 F입니다. 이 배치라면 signed, W=16, F=15입니다.

Q15에서 0.5는 얼마인가요?

Q15에서 0.5는 저장되는 정수 16384, 16진 워드로는 0x4000입니다. 계산은 0.5 × 32768 = 16384이고 이미 정수이므로 반올림도 양자화 오차도 없습니다. 디코딩이 이를 확인해 줍니다. 16384 ÷ 32768 = 0.5가 정확히 성립합니다.

Q15의 최댓값과 최솟값은 얼마인가요?

최솟값은 -1이고 범위에 포함됩니다. -32768 ÷ 32768이 정확히 -1이기 때문입니다. 최댓값은 32767/32768 = 0.999969482421875입니다. 둘 다 실제로 취할 수 있는 값이므로 범위는 [-1, 0.999969482421875]이고, 그 밖으로 벗어나는 값이 1.0입니다. 아래쪽 경계를 열린 구간으로 쓰는 문헌은 틀렸으며, 그 오류가 -1 × -1 오버플로 사례를 가립니다.

Q15가 오버플로하면 어떻게 되나요?

적용된 정책에 따라 다릅니다. 오류 정책은 변환을 거부합니다. 포화는 가장 가까운 끝점으로 클램프하므로 1.00.999969482421875가 되고, 이는 대개 귀에 들리지 않는 1 LSB의 손실입니다. 순환은 2^16에 대한 모듈러 연산을 적용하므로 1.0은 32768로 스케일링되었다가 -32768, 즉 -1.0으로 다시 읽힙니다. 부호가 통째로 뒤집힙니다. 그래서 DSP 하드웨어는 포화를 기본값으로 두지만, 평범한 C 정수 산술은 여전히 순환합니다.

Q15 수 두 개를 곱한 뒤에 왜 15비트 오른쪽 시프트가 필요한가요?

소수부 비트가 더해지기 때문입니다. Q15 × Q15는 Q30 곱을 내놓으므로 정수 결과가 소수부 비트를 15개가 아니라 30개 지니게 됩니다. 오른쪽으로 15비트 시프트하면 Q15로 다시 정렬됩니다. 16384 × 16384 = 268435456이고 268435456 >> 15 = 8192이며, 이는 0.25로 디코딩됩니다. 절단 대신 가장 가까운 값으로 반올림하려면 시프트 전에 1 << 14를 더하고, 32비트 곱이 넘치지 않도록 중간값은 int32_t로 유지하세요.

IEEE 754 대신 Q 포맷을 써야 할 때는 언제인가요?

프로토콜, 레지스터 맵, DSP 알고리즘, 코덱이 정수 워드에 대해 이진 스케일을 이미 고정해 둔 경우에 Q 포맷을 쓰세요. 그 스케일은 인터페이스의 일부라서 골라잡을 수 있는 값이 아닙니다. 값에 넓은 동적 범위가 필요할 때, 대상에 하드웨어 FPU가 있을 때, 필드가 실제로 부호와 지수와 유효숫자를 저장할 때는 IEEE 754를 쓰세요. 스케일이 붙지 않은 단순한 진법 변경이라면 둘 다 해당하지 않습니다. 그것은 평범한 진법 변환입니다.

요약

Q15 고정소수점은 곱셈 하나, 반올림 결정 하나, 범위 검사 하나입니다. 들어갈 때는 raw = round(value × 32768), 나올 때는 value = raw ÷ 32768입니다. 공식은 사소한데, 실패는 전부 아무도 문서로 적어 두지 않는 쪽에서 나옵니다.

그러니 적어 두세요. 모든 Q 이름표 옆에 부호 유무와 W와 F를 쓰고, 반올림 모드의 이름도 같은 자리에 남기세요. 절단과 내림은 음수에서 1 LSB만큼 통째로 갈라집니다. 오버플로가 오류를 내는지 포화하는지 순환하는지도 명시해야 합니다. 순환은 1.0-1.0으로 뒤집어 놓고 아무 흔적도 남기지 않습니다.

레지스터 워드와 스프레드시트가 서로 어긋날 때는 이미 아는 값 하나를 Q 포맷 변환기에서 디코딩해 보세요. 저장 정수, 정확한 10진값, 양자화 오차, 표현 가능 범위를 나란히 보여 주고, 전부 브라우저 안에서 계산됩니다. W와 F에 대한 누구의 가정이 틀렸는지 가리는 데는 보통 이것으로 충분합니다.

태그: fixed-point dsp embedded q-format number-representation