ろぐれこーど

限界組み込みエンジニアの学習記録とちょっぴりポエム

固定小数点数による分解能表記(LSB)と演算

組み込み開発時に、一般的な(?)プログラミングではあまり扱わない要素として「固定小数点数」の利用があります。組織によっては単に「LSB」とか呼ばれたりしますが、新入社員の時にさも当然のことのように言われて困惑した思い出があるので、ここにメモとして残しておきます。

固定小数点数

おそらくプログラミングに触れて最初に知ることになる小数の表現方法は「浮動小数点数」だと思います。これは仮数部と指数部からなる表現方法で…というのは大体の教科書に書いてると思うので省略します。固定小数点数の説明は以下です。

固定小数点数(こていしょうすうてんすう、英: fixed-point number)は、小数点が置かれる桁を固定して表された数のことで、コンピュータ上で小数を表現する方法として使用される形式のひとつである。ある桁数のうちのある場所に小数点が固定されているもの(固定小数点)として扱う方式であるため、表現される仮数部に対して小数点の位置が移動する浮動小数点数の対義語として用いられる。すなわち、「固定-小数点数」ではなく「固定小数点-数」である。

演算自体は整数型と同じ方法、同じハードウェアで行われ、小数点位置は設計者の意図によって決定される

固定小数点数 - Wikipedia

絵的には下のサイトがわかりやすいかもしれません。

www.hdlab.co.jp

例えば、以下のような2進数があったとします。

11010011

これは整数型だと、十進数で211ですが、「この桁から下は小数点とします」と設計者が決めると固定小数点数となります。下位2bitを小数点以下とした場合、

110100.11

となり、この場合52.75と表現できることになります。データ自体は変わっていないため、「(同じ単位での固定小数点同士なら)演算自体は整数型と同じ方法でできる」というのが大きな特徴です。ざっくりまとめると、以下のようなメリットがあります。

  • 必要最小限のサイズで小数を表現できる(上の例では1byte)
  • 浮動小数点数より高速に演算できる

ただし当然ながら、以下のデメリットがあります。

  • 異なる単位での固定小数点数同士で演算できない
  • 小数単位は設計者が決める必要がある
  • 取りうる値から、設計者が変数サイズを決定する必要がある

特に二つ目は重要で、設計資料等が残っていないとただの整数型と見分けがつかないので、どういう数値を意味しているのかがわからなくなります。固定小数点数はA/D変換後の物理値などによく使われますが、上の例がもし電圧[mV]を表していた場合、211[mV]52.75[mV]はえらい違いです。コード中にコメントを記せばある程度緩和できますが、あくまでただのコメントでしかないため設計資料とのトレースが取れる状態を保つことが重要となります。1

単位としての「LSB」

「この変数は下位2bitを小数とします」と設計書に書くのは些か不格好なので、ちゃんと表記を統一したいところです。ここで「1bitで表現できる数」を考えてみると、

110100.11

は最下位bitは0.25を表すことになります。上の数値を10進数で読み、これに最下位bitが表す数値をかけることでも小数を求めることができます。(211 * 0.25 = 52.75)

この「最下位bitが表す数値」に単位をつけてしまうことで、より汎用的な表現とすることができます。ここで便宜的にLSBという単位を導入すると、上記の数値を物理込みで

0.25 [mV/LSB]

のように記述できます2。こうすることで、「この数値は最下位bitで0.25[mV]を表す」ということが簡潔に表現できます。計算上は

211[LSB] * 0.25[mV/LSB] = 52.75[mV]

のように書けるので、単位系的にもわかりやすくなります。この[mV/LSB]という単位、対象の物理量によって分子部分が変わるので、読む時は単に「分解能」「LSB」のように呼ばれたりします。LSB(Least Significant Bit)はバイトオーダーについての説明でよく出てくる単語ですが、組み込み系では開き直って単位名として使われるわけですね。会話の中では「分解能が xxx LSB」とか「1LSBでxxx mV」とかいう言い回しをすることが多いようです。

この場合は2の冪乗が分解能となっていますが3、物理値は本来連続なので2の冪乗である必要はありません。設計次第では以下のような定義も可能です。

0.777 [mV/LSB]

また、場合によってはOffsetと呼ばれる偏差を導入することもあります。Offsetはそのままの意味で「原点をずらす」用途で使われます。Offsetについては以下が参考になります。

algorithm.joho.info

例えば分解能を0.25[mV/LSB]、Offsetを-100mVと指定すると、

211[LSB] * 0.25[mV/LSB] - 100[mV] = -47.25[mV]

となります。Offsetは基本的に計算後の物理量と対応するため、単位も物理量と同等のものをとります。データが取る範囲は1byteで十分表現できるけど、絶対値が大きい場合にはOffsetが役に立ちます。当然これも設計者が決めるため、ソースコード中には含まれない情報です。

ここまでの説明で、ちゃんと物理を勉強してきた人とかは

『分解能に単位がつくのはおかしい!』

『計算上の数値はただの数値であり、物理量ではない!』

という疑問が湧いてきたりするのですが、組み込み開発ではこの表記がデファクトスタンダードとなっているようです4。組み込み業界で生きるなら諦めて慣れましょう。

演算時の注意

固定小数点数の概要と表記について説明できたので、これですぐ使い始められる…と思いますが、浮動小数点数の演算と比較して留意すべきことがあります。除算とデータサイズです。

除算

固定小数点数は整数型演算で処理します。ゆえに、除算は自動的に切り捨て除算となります。必然的に分子<分母となるような演算は0になってしまうので、演算の順序には気をつけなければなりません。

また、浮動小数点数なら多くの場合用意されているROUND()も、自分で計算する必要があります。

// 切り捨て除算
result = a / b;

// ROUND
result = (a + (b/2)) / b;

分母の二分の一を分子に足すことで、ROUND演算ができます(負の数は分子から引きます)。計算自体は単純ですが、除算する箇所で全てこの式を書くのは煩雑になります。やるとしたら以下のような関数を利用したくなりますが、

// ROUNDを実施する除算(正数のみ想定)
long fp_number_div_round(long a, long b){
  return (a + (b/2) / b);
}

この関数では「引数と戻り値がlong型固定」になってしまいます。C++ならオーバーロードという手段が取れそうですが、Cでは適宜キャストしてやる以外の解決方法がありません5。(何かいい解決策あればご指摘お願いします、、)

データサイズ

演算結果がデータサイズにおさまっても、演算途中でオーバーフローしてしまうと意味がありません。オーバーフローが起こるのは浮動小数点数でも同様ですが、任意精度演算をサポートするような言語に慣れた方は注意が必要です。データサイズは以下のように、乗算・加算で広がる感じです。

uint8_t a;
uint8_t b;

// 8bit * 8bit = 16bit
result = a * b;

// 8bit + 8bit = 9bit
result = a + b;

基本的には型が取り得る最大値・最小値を考慮した設計とすべきですが、センサなどの仕様で入出力の定義域が分かっている場合はこの限りではありません。

実例

固定小数点数を使用した演算の実例です。単純な1次遅れのLPFをソフトで実現しようとした時、後退差分による離散化を利用することが多いと思います(後退差分による離散化はここを参照)。今回値の入出力を x[n], y[n] 、サンプリング周期と時定数をそれぞれ T, \tauとおくと、出力は

{\displaystyle
y[n]=
\frac{T}{T+\tau}x[n]
+\frac{\tau}{T + \tau} y[n-1]
}

で求まります。この式を浮動小数点数の演算と同じように書くと、以下のようになったりします。

// x: 入力
// T: サンプリング周期
// tau: 時定数
// y: 出力
// y_Z: 出力の前回値

// 1次LPF
y = (T / (T + tau)) * x + (tau / (T + tau)) * y_Z;

どこが問題になるでしょうか?

まず一つ目に、除算によって各項が確実に0になるため、出力yは常に0になってしまいます。このような場合、通常は交換法則を利用して、分子の乗算を先に計算します。

// 第一項の計算
tmp = (T * x) / (T + tau);

このような演算に置き換えます。(T * x)>(T + tau)が設計上、常に保証されるならとりあえずこれで計算できますが、これでもまだ不十分です。除算は

  • 切り捨て(良くて四捨五入)なので精度が粗くなる
  • 一般に乗算より遅い

ため、できるだけ避けたい演算です。と言うことは

 {\displaystyle
y[n] =
(T \cdot x[n] + \tau \cdot y[n-1])
\frac{1}{(T + \tau)}
}

という式にした方が良いことがわかります。

// 除算回数を減らすため、分子を先に計算
tmp = T * x + tau * y_Z;
// 出力を計算
y = tmp / (T + tau);

ここで問題になるのが、T * x + tau * y_Zのとる範囲を予め計算しておく必要があると言う点です。データサイズの節で述べたオーバーフローですね。処理系が16bitとかだと簡単に超えますし、32bitでもlong型を多めに使うようなAD値とかだと意外と油断できません。

オーバーフローの可能性がある場合は、

  • 除算を先に実施する
  • 適宜リミット処理を設ける

などの対応が必要です。固定小数点数を使用する場合はシステムとしての入出力と演算を考慮して設計を行うべきでしょう。

まとめ

固定小数点数についての分解能表記と演算時の注意について述べました。明らかに人間にとっては読みづらい表現なため、制約が厳しい一部の組み込み開発以外ではまず使うことはないでしょう…。普通にプログラミングに触れるのではおそらく知る機会がないので、「リソース制約がある環境での開発が具体的に何を意識しているのか」を知りたい人の参考になれば幸いです。


  1. コメントだけで管理してると簡単にデグレする(実体験)

  2. 一般的な表記ではないですが、個人的には一番わかりやすいと思ってます

  3. 実際、量子化の都合上そういうケースの方が多い

  4. 某大手R社のマイコンデータシートにもLSBの表記が使われています

  5. 引数・戻り値をその処理系が取りうる最大サイズ(long longとか)に指定しておけば汎用的に演算すること自体は可能ですが、言わずもがなリソースは食います