-1 が 65535 に見えるとき — 2の補数と符号付き整数
センサーの値が突然ありえない大きさになったこと、ありませんか?
自分はロボットのサーボから電流値を読む処理を書いていて、それに遭遇しました。サーボが外力に抗っているとき、画面に表示される電流値が -1 や -5 ではなく、65535 や 65531 という巨大な正の数になっているのです。ハードウェアの故障を疑いかけたころ、コードレビューで「符号付きへの変換が抜けています」と指摘されて、はじめて原因がわかりました。
同じビット列なのに、読み方ひとつで値がまったく変わる。その話をします。
スマートサーボとは
今回題材になるのは「スマートサーボ(シリアルサーボ)」と呼ばれる種類のアクチュエータです。一般的なRCサーボがPWM信号で角度を指定するだけなのに対し、スマートサーボはモーター・ドライバ・マイコンがひとつのケースに収まっていて、状態を数値として外部に公開できます。
内部では、現在の角度・電流・速度・温度などの状態が レジスタ(番地付きのメモリ領域)に書き込まれ続けています。制御ボード側は、シリアルバスを通じてサーボに「アドレス◯番のレジスタを読め」という命令を送ることで、その時点の値を取り出せます。
制御ボード シリアルバス サーボ内部
┌────────────┐ コマンド送信 ┌────────────────────┐
│ control SW │ ────────────▶ │ マイコン │
│ │ │ ┌────────────────┐ │
│ │ ◀──────────── │ │ レジスタ群 │ │
│ 受取った値 │ 値を返送 │ │ 角度/電流/温度 │ │
│ を処理 │ │ └────────────────┘ │
└────────────┘ └────────────────────┘
このシリアル通信の仕組みのおかげで、制御ソフトは「今どれだけの力がかかっているか」をリアルタイムに把握できます。電流値が大きければ強い負荷がかかっている、ゼロに近ければほぼ無負荷、という具合です。
ただし、電流はモーターの「押す方向」と「踏ん張る方向」で極性が変わります。片方向だけ正の値で表せばよいPWM角度とは違い、電流は正負の両方が意味を持ちます。そのため電流レジスタは 符号付き整数 として定義されているのが一般的です。ここで二の補数の話が出てきます。
何が起きていたのか
自分が作っているロボットの脚には複数のサーボが使われています。各サーボは現在の電流値をレジスタに保持していて、制御ソフトはそのレジスタを定期的に読み出して使います。
サーボの仕様書を確認すると、電流レジスタは「16ビット、符号付き整数」と書かれています。正方向の電流(押す)は正の値、逆方向の電流(踏ん張る)は負の値として格納されています。
ところが自分の実装では、レジスタから読み取った生の16ビット値をそのまま使っていました。「生の値」というのは、符号への変換処理を一切かけていない状態です。結果として、本来 -1 であるべきデータが 65535 として扱われていました。
コードの問題箇所はたった1行の変換漏れでしたが、「なぜ -1 が 65535 になるのか」という理由は、2の補数という仕組みを理解しないと腑に落ちません。
ビット列は「解釈」によって意味が変わる
コンピュータはすべてのデータをビット(0と1)の列として扱います。ビットの基礎については ビットとは何か で詳しく書いていますが、ここでは「解釈の違い」に絞って話を進めます。
16ビットのビット列がひとつあるとします。
1111 1111 1111 1111
このビット列を 符号なし(unsigned)16ビット整数 として読むか、符号付き(signed)16ビット整数 として読むかで、得られる数値がまったく変わります。
| 解釈 | 範囲 | 1111 1111 1111 1111 の値 |
|---|---|---|
| 符号なし(unsigned) | 0 〜 65535 | 65535 |
| 符号付き(signed、2の補数) | -32768 〜 32767 | -1 |
同じビット列が 65535 にも -1 にもなります。「どちらが正しい」ではなく、「どちらの解釈を使うか」の問題です。サーボのレジスタは符号付きで定義されているにもかかわらず、自分のコードは符号なしとして読んでいた——それがバグの正体でした。
2の補数の仕組み
符号付き整数の表現方式として現代のコンピュータが採用しているのが 2の補数(two’s complement) です。
16ビットの場合、最上位ビット(一番左のビット)が 0 のときは正の数、1 のときは負の数を表します。
0xxx xxxx xxxx xxxx→ 0 〜 32767(正の整数またはゼロ)1xxx xxxx xxxx xxxx→ -32768 〜 -1(負の整数)
具体的な対応を表にすると次のようになります。
| 16ビット値(符号なし) | 16進数 | 2の補数(符号付き) |
|---|---|---|
| 65535 | 0xFFFF | -1 |
| 65534 | 0xFFFE | -2 |
| 65531 | 0xFFFB | -5 |
| 32768 | 0x8000 | -32768 |
| 32767 | 0x7FFF | 32767 |
| 0 | 0x0000 | 0 |
符号なしで 32768〜65535 の範囲にある値が、2の補数では -32768〜-1 に対応しています。上から見ると、65535 が -1、65534 が -2 と、65536 を引いた値になっていることがわかります。
変換は1行で書ける
符号なしの16ビット値として読んでしまった数値を、符号付きに直す方法はシンプルです。
def to_signed_16(v: int) -> int:
"""符号なし16ビット値を符号付き16ビット値に変換する"""
if v >= 32768:
return v - 65536
return v
65535 を渡すと 65535 - 65536 = -1 が返ります。65531 なら 65531 - 65536 = -5 です。32767 以下はそのまま正の値なので変換不要です。
数学的には「値が符号付きの負の範囲(最上位ビットが1)に入っていたら、2の16乗(65536)を引く」という操作です。なぜこれで合うのかは、2の補数の定義から導けます。
なぜ2の補数が使われるのか
2の補数が採用されている理由は大きく2つあります。
1. 加算回路を符号付きと符号なしで共通化できる
3 + (-1) を計算するとき、2の補数表現なら 3 のビット列と 65535 のビット列をそのまま足すだけで答えが出ます。
0000 0000 0000 0011 (3)
+ 1111 1111 1111 1111 (65535 = -1)
= 0000 0000 0000 0010 (2、繰り上がりは16ビットを超えて消える)
3 + (-1) = 2 が正しく得られます。減算専用の回路を用意しなくてよいのは、ハードウェア設計で大きなメリットです。
2. ゼロの表現が1通りしかない
2の補数を使わない「符号付き絶対値表現」では、+0(0000 0000 0000 0000)と -0(1000 0000 0000 0000)の2つが生まれてしまいます。これだとゼロ判定のたびに2パターン確認が必要になり、回路も複雑になります。2の補数ではゼロは 0000 0000 0000 0000 のただ1通りです。
ハードとの境界で初めて気づくこと
普通のアプリケーションプログラミングをしているときは、int 型を使えば処理系が自動で符号付きとして扱ってくれるため、符号と幅を意識する場面がほぼありません。
自分はソフトウェアだけ書いていた時期が長く、「整数は整数」という感覚が染み付いていました。ハードウェアのレジスタやバイナリプロトコルに触れるまで、「同じビット列の解釈は自分で選ぶ必要がある」という現実を実感したことがなかったのです。
レジスタを読み出すライブラリは、多くの場合「生のワードをそのまま返す」モードを持っています。変換をしないぶん汎用性が高く、いろいろな用途に使いまわせるからです。ただし使う側が「符号付きか符号なしか」「何ビットか」を仕様で確認して、適切な変換を自分でかける必要があります。
自分はその確認を怠り、ライブラリが返す生の値をそのまま使ってしまいました。データシートを先に読んでいれば、「16ビット符号付き」という一言で気づけたはずです。
組み込みでレジスタを読むときの確認点
今回の経験を踏まえて、レジスタやバイナリデータを扱うときに自分が確認するようにしたことをまとめます。
- ビット幅を確認する: 8ビット(0〜255 / -128〜127)なのか、16ビット(0〜65535 / -32768〜32767)なのか、32ビットなのかをデータシートで確認する
- 符号の有無を確認する: 「unsigned」「signed」の記載はデータシートのレジスタ定義に必ず書かれている
- 変換は1関数に閉じ込める: 読むたびにインラインで
v - 65536を書くのではなく、to_signed_16(v)のような変換関数を1つ作ってそこだけ触るようにする。変換ロジックがコード上に散らばると、見落としが起きやすい
特に最後の点は今回の反省から来ています。レジスタを読む場所が複数あるとき、変換漏れは1箇所でも致命的なバグになります。「生値を返す関数」と「変換済みの値を返す関数」を分けておくと、呼び出し側が変換を意識しなくて済みます。
65535 という数字を見たとき、「符号なし16ビットの最大値、つまり符号付きでは -1 かもしれない」と反射的に思えるようになったのは、今回のバグを踏んだおかげです。失敗から得たパターン認識は、何度読んだ教科書よりも体に残ります。
記事の更新をメールで受け取る
質問・リクエストを送る
記事についての質問や、取り上げてほしいテーマがあればお気軽にどうぞ。いただいた質問はブログ記事として回答し、Q&Aページで公開することがあります。