-1 が 65535 に見えるとき — 2の補数と符号付き整数

9分で読める テック

センサーの値が突然ありえない大きさになったこと、ありませんか?

自分はロボットのサーボから電流値を読む処理を書いていて、それに遭遇しました。サーボが外力に抗っているとき、画面に表示される電流値が -1-5 ではなく、6553565531 という巨大な正の数になっているのです。ハードウェアの故障を疑いかけたころ、コードレビューで「符号付きへの変換が抜けています」と指摘されて、はじめて原因がわかりました。

同じビット列なのに、読み方ひとつで値がまったく変わる。その話をします。

スマートサーボとは

今回題材になるのは「スマートサーボ(シリアルサーボ)」と呼ばれる種類のアクチュエータです。一般的なRCサーボがPWM信号で角度を指定するだけなのに対し、スマートサーボはモーター・ドライバ・マイコンがひとつのケースに収まっていて、状態を数値として外部に公開できます。

内部では、現在の角度・電流・速度・温度などの状態が レジスタ(番地付きのメモリ領域)に書き込まれ続けています。制御ボード側は、シリアルバスを通じてサーボに「アドレス◯番のレジスタを読め」という命令を送ることで、その時点の値を取り出せます。

制御ボード                  シリアルバス          サーボ内部
┌────────────┐  コマンド送信  ┌────────────────────┐
│ control SW │ ────────────▶ │ マイコン           │
│            │               │ ┌────────────────┐ │
│            │ ◀──────────── │ │ レジスタ群      │ │
│ 受取った値 │  値を返送       │ │ 角度/電流/温度  │ │
│ を処理     │               │ └────────────────┘ │
└────────────┘               └────────────────────┘

このシリアル通信の仕組みのおかげで、制御ソフトは「今どれだけの力がかかっているか」をリアルタイムに把握できます。電流値が大きければ強い負荷がかかっている、ゼロに近ければほぼ無負荷、という具合です。

ただし、電流はモーターの「押す方向」と「踏ん張る方向」で極性が変わります。片方向だけ正の値で表せばよいPWM角度とは違い、電流は正負の両方が意味を持ちます。そのため電流レジスタは 符号付き整数 として定義されているのが一般的です。ここで二の補数の話が出てきます。

何が起きていたのか

自分が作っているロボットの脚には複数のサーボが使われています。各サーボは現在の電流値をレジスタに保持していて、制御ソフトはそのレジスタを定期的に読み出して使います。

サーボの仕様書を確認すると、電流レジスタは「16ビット、符号付き整数」と書かれています。正方向の電流(押す)は正の値、逆方向の電流(踏ん張る)は負の値として格納されています。

ところが自分の実装では、レジスタから読み取った生の16ビット値をそのまま使っていました。「生の値」というのは、符号への変換処理を一切かけていない状態です。結果として、本来 -1 であるべきデータが 65535 として扱われていました。

コードの問題箇所はたった1行の変換漏れでしたが、「なぜ -165535 になるのか」という理由は、2の補数という仕組みを理解しないと腑に落ちません。

ビット列は「解釈」によって意味が変わる

コンピュータはすべてのデータをビット(0と1)の列として扱います。ビットの基礎については ビットとは何か で詳しく書いていますが、ここでは「解釈の違い」に絞って話を進めます。

16ビットのビット列がひとつあるとします。

1111 1111 1111 1111

このビット列を 符号なし(unsigned)16ビット整数 として読むか、符号付き(signed)16ビット整数 として読むかで、得られる数値がまったく変わります。

解釈範囲1111 1111 1111 1111 の値
符号なし(unsigned)0 〜 6553565535
符号付き(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の補数(符号付き)
655350xFFFF-1
655340xFFFE-2
655310xFFFB-5
327680x8000-32768
327670x7FFF32767
00x00000

符号なしで 32768〜65535 の範囲にある値が、2の補数では -32768〜-1 に対応しています。上から見ると、65535-165534-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の補数を使わない「符号付き絶対値表現」では、+00000 0000 0000 0000)と -01000 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ページで公開することがあります。

このサイトについて

井上 周(Amane Inoue)の個人ブログです。技術・読書・ドラマ・旅・大学生活のことを書いています。