Ichigo Notes

2 進数の基本:コンピュータが数を扱う仕組みを C 言語で確かめる

更新 約 17 分で読めます

コンピュータは 0 と 1 しか扱えない、というのは有名な話です。では、実際に 0 と 1 だけでどのようにして計算をしているのか、-5 のようなマイナスの数、0.5 のような小数は 0 と 1 だけでどうやって表しているのか、説明できますか?エンジニアの素養として身につけておくべき 2 進数の基礎を、実際にプログラムを動かしながら一つずつ確かめました。

この記事でわかること

  • コンピュータが 0 と 1 で情報を扱う理由と、ビット・バイトの意味
  • 2 進数の読み方と、10 進数との変換
  • マイナスの数を表す「2 の補数」のしくみ
  • シフト演算と論理演算でできること
  • 0.1 を 100 回足しても 10 にならない理由と、小数の表し方

試した環境

項目バージョン
OSArch Linux (WSL2)
C コンパイラGCC 16.2.1
Python3.14.8

数がどんな 0 と 1 の並びになっているかは、C で書いた次の関数で表示しました。値を 8 桁の 2 進数で表示し、そのビット列を「マイナスありの数(符号付き)」として読んだ値と、「マイナスなしの数(符号なし)」として読んだ値を並べます。中で使っている >> と & は、後半のシフト演算・論理演算で説明します。今は「2 進数で表示する関数」とだけ思ってください。

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

static void show( const char *label, int v )
{
    // 下位 8 ビットだけを取り出す
    uint8_t b = (uint8_t)v;
    printf( "%-12s ", label );
    for( int i = 7; i >= 0; i-- ) putchar( ( b >> i ) & 1 ? '1' : '0' );
    printf( "  signed=%4d  unsigned=%3u\n", (int8_t)b, b );
}

記事中の出力は、説明に必要な行を抜き出し、← のコメントを書き足しています。符号拡張と小数のビット列は、show() とは別の短いコードで表示しました。

コンピュータと 2 進数

コンピュータは電圧の高い・低いで情報を表す

電灯のスイッチは、オンとオフの 2 つの状態しかありません。コンピュータも、これと同じように 2 つの状態だけで情報を表しています。

コンピュータの CPU やメモリは、IC(集積回路)という電子部品でできています。IC の中の回路やピン(端子)を流れるデジタルの信号は、電圧が決まった値より「高い」か「低い」かの 2 つの状態のどちらかとして扱われます。この 2 つの状態を 1 と 0 に対応させるので、コンピュータの中の情報は、すべて 0 と 1 の並びで表されます。

0 と 1 の 1 桁をビット(bit、binary digit の略)と呼びます。1 ビットで表せるのは 2 通りだけですが、ビットを並べると表せるパターンは倍々に増えます。8 ビットなら 28=2562^8 = 256 通りです。この 8 ビットのまとまりをバイト(byte)と呼び、データを扱う基本の単位になっています。メモリの番地(アドレス)も、1 バイトごとに付いています。

ビット数表せるパターンの数
12
24
8(1 バイト)256
32(4 バイト)約 43 億

プログラムに 53 のような 10 進数や、'A' のような文字を書いても、コンパイルすると、どれも 0 と 1 の並びに変換されます(文字は、文字コードで決められた番号として扱われます)。

2 進数は 2 になったら繰り上がる数え方

私たちが普段使っている 10 進数は、0〜9 の 10 個の数字を使い、9 の次で桁が繰り上がります。2 進数は 0 と 1 の 2 個の数字だけを使い、1 の次でもう繰り上がります。

10 進数012345678
2 進数0110111001011101111000

10 進数の 253 は「100 が 2 個、10 が 5 個、1 が 3 個」という意味です。各桁に 1・10・100… という重みがあります。2 進数では、桁の重みが右から 1・2・4・8… と 2 倍ずつになります。1 になっている桁の重みを足すと、10 進数に直せます。

桁の重み1286432168421
0000110100001101

8 + 4 + 1 = 13 なので、00001101 は 10 進数の 13 です。Python で確かめると、同じ結果になりました。

>>> int('00001101', 2)
13
>>> bin(13)
'0b1101'

2 進数は桁が多くて読みにくいので、プログラムでは 2 進数の 4 桁を 1 文字にまとめた 16 進数(0〜9 と A〜F)もよく使われます。C や Python では、先頭に 0x を付けて書きます。たとえば 0000 1101 は 0x0D です。

マイナスの数の表し方(2 の補数)

0 と 1 しかないなら、マイナスの記号はどう表せばよいでしょうか。

まず思いつくのは、先頭の 1 ビットを符号に使う方法です。先頭が 0 ならプラス、1 ならマイナスとして、-1 を 10000001 と表します。この先頭のビットを符号ビットと呼びます。

ところが、この方法には困ったことがあります。1 + (-1) を、普通の足し算と同じように筆算してみます。

  00000001   (1)
+ 10000001   (-1 のつもり)
-----------
  10000010   (-2 になってしまう)

答えが 0 になりません。足し算のたびに符号を見て計算方法を変えればよいのですが、それでは回路が複雑になります。そこで、今のコンピュータは 2 の補数という別の表し方を使っています。2 の補数でも、先頭のビットが 1 ならマイナスです。

考え方は、車の走行距離計に似ています。2 桁しかない距離計は、99 に 1 を足すと、3 桁目が表示されないので 00 に戻ります。つまり、2 桁の世界では 99 を -1 の代わりに使えます。

2 進数でも同じで、8 ビットの世界では 11111111 を -1 として扱います。プログラムで確かめてみます。

1            00000001  signed=   1  unsigned=  1
-1           11111111  signed=  -1  unsigned=255
1 + (-1)     00000000  signed=   0  unsigned=  0

-1 は 11111111 になっていて、1 + (-1) は 0 になりました。筆算すると 00000001 + 11111111 = 1 00000000 で、9 桁目にあふれた 1 を捨てると 0 になります。2 の補数を使うと、マイナスの数を足すだけで引き算ができるので、コンピュータは引き算を足し算の回路で計算できます。

ある数のマイナスを 2 の補数で求めるには、全ビットを反転(0 と 1 を入れ替え)して 1 を足します。5 から -5 を作ってみました。

5            00000101  signed=   5  unsigned=  5
~5           11111010  signed=  -6  unsigned=250   ← 全ビットを反転
~5 + 1       11111011  signed=  -5  unsigned=251   ← 1 を足す
-5           11111011  signed=  -5  unsigned=251   ← プログラムで -5 と書いたとき

反転して 1 を足した結果が、プログラムで -5 と書いたときのビット列と一致しました。

右側の数字にも注目してください。同じ 11111011 が、符号付きとして読むと -5、符号なしとして読むと 251 です。ビット列そのものはただの 0 と 1 の並びで、それをどう読むかは、プログラムの型(int8_t か uint8_t か)が決めています。8 ビットの場合、表せる範囲は次のとおりです。

読み方C の型の例範囲
符号なしuint8_t0〜255
符号付きint8_t-128〜127

符号付きでマイナス側が 1 つ多いのは、0 が符号ビット 0 の側(プラス側)に入っているためです。

シフト演算と論理演算

シフト演算で桁をずらすと 2 倍や 2 で割った商になる

10 進数で、53 の後ろに 0 を付けて 530 にすると 10 倍になります。桁を 1 つ左にずらしたからです。2 進数でも同じ理屈で、桁を 1 つ左にずらすと 2 倍、右にずらすと 2 で割った商(余りは切り捨て)になります。この操作をシフト演算と呼び、C では <<(左シフト)と >>(右シフト)で書きます。

5            00000101  signed=   5  unsigned=  5
5 << 1       00001010  signed=  10  unsigned= 10   ← 左に 1 桁: 2 倍
5 << 2       00010100  signed=  20  unsigned= 20   ← 左に 2 桁: 4 倍
5 >> 1       00000010  signed=   2  unsigned=  2   ← 右に 1 桁: 5 ÷ 2 = 2 余り 1

左にずらしたり足し算をしたりして値が大きくなると、いつか 8 ビットに収まらなくなります。このように桁が足りなくなることを、桁あふれ(オーバーフロー)と呼びます。8 ビットの世界で、符号付きの最大値 127(01111111)に 1 を足すと 10000000 になり、符号付きとして読むと -128 になってしまいます。

127          01111111  signed= 127  unsigned=127
127 + 1      10000000  signed=-128  unsigned=128

厳密には、C は 8 ビットの値どうしの計算もいったん int に広げて行うので、計算結果は 128 です。それを 8 ビットに詰め直したところで -128 になっています。int そのものの範囲を超えた場合、符号付き整数の計算があふれたときの結果は C では保証されません(符号なし整数は、0 に戻って一周することが保証されています)。

右シフトでは、空いた左端の桁を何で埋めるかで 2 種類に分かれます。

種類空いた左端を埋める値向いている数
論理右シフト常に 0符号なしの数
算術右シフト元の先頭のビット(符号ビット)と同じ値符号付きの数

マイナスの数を右にずらすとき、左端を 0 で埋めると、符号ビットが 0 になってプラスの数に変わってしまいます。そこで算術右シフトでは、符号ビットと同じ値で埋めて、マイナスの数をマイナスのまま 2 で割ります。C では、符号なしの数の >> は論理右シフトです。マイナスの数の >> の結果は規格では処理系に任されていますが、GCC などの主なコンパイラは算術右シフトにします。

同じ 11111011 を、符号付き(-5)と符号なし(16 進数で 0xFB = 251。末尾の u は符号なしの印)で右シフトしてみました。

-5 >> 1      11111101  signed=  -3  unsigned=253   ← 符号付き: 1 で埋める(算術)
0xFBu >> 1   01111101  signed= 125  unsigned=125   ← 符号なし: 0 で埋める(論理)

符号付きのほうは -3 で、マイナスのままです。なお、-5 ÷ 2 は -2.5 です。C の割り算 -5 / 2 は 0 に近いほうへ切り捨てて -2 になりますが、算術右シフトは小さいほうへ切り捨てるので -3 になります。マイナスの数を割るときは、シフトではなく / で書くのが安全です。

同じ考え方は、int8_t の値を int16_t や int の変数に代入して、ビット数を広げるときにも使われます。増えた左側の桁を、符号付きの数なら符号ビットで埋め(符号拡張)、符号なしの数なら 0 で埋めます。16 ビットに広げてみました。

sign-extend: 1111111111111011 (-5)
zero-extend: 0000000011111011 (251)

符号拡張した -5 は、16 ビットでも -5 のままです。

論理演算は 1 ビットずつ計算する

足し算や引き算のように、数として計算するのが算術演算です。これに対して、0 と 1 を 1 ビットずつ計算するのが論理演算です。基本となるのは AND・OR・XOR・NOT の 4 つで、入力と結果の組み合わせを真理値表にまとめられます。

ABA AND BA OR BA XOR BNOT A
000001
010111
100110
111100

言葉にすると、AND は「両方 1 なら 1」、OR は「どちらかが 1 なら 1」、XOR は「違っていれば 1」、NOT は「反転」です。C では &・|・^・~ で書きます。

論理演算は、たくさんのビットのうち一部だけを操作したいときに便利です。操作したい桁だけを 1 にした値をマスクと呼びます。マスクと論理演算を組み合わせると、一部のビットだけを取り出したり、1 にしたり、反転したり、0 にしたりできます。右側 4 桁が 1 のマスクで試しました。

x            10101100  signed= -84  unsigned=172
mask         00001111  signed=  15  unsigned= 15
x & mask     00001100  signed=  12  unsigned= 12   ← 右側 4 桁だけを取り出す
x | mask     10101111  signed= -81  unsigned=175   ← 右側 4 桁を 1 にする
x ^ mask     10100011  signed= -93  unsigned=163   ← 右側 4 桁を反転する
x & ~mask    10100000  signed= -96  unsigned=160   ← 右側 4 桁を 0 にする

設定のオン・オフを 1 ビットずつに割り当てて管理する「フラグ」などで、よく使われる書き方です。

小数の表し方

0.1 を 100 回足しても 10 にならない

0.1 を 100 回足せば 10 になるはずです。C で実際に計算してみます(%.17g は、桁を多めに表示する指定です)。

double d = 0;
for( int i = 0; i < 100; i++ ) d += 0.1;
printf( "%.17g\n", d );
9.9999999999999805

10 になりませんでした。原因は、0.1 が 2 進数では割り切れないことです。

10 進数で 1 ÷ 3 を計算すると、0.333… と 3 がどこまでも続きます。同じように、10 進数ではきりのよい 0.1 も、2 進数にすると 0.000110011001100… と同じ並びがずっと繰り返す数になります。コンピュータは決まった桁数しか持てないので、途中で打ち切って丸めた「0.1 に近い数」を使うしかありません。実際に保存されている値を、小数点以下 20 桁まで表示してみました。

0.1 = 0.10000000000000000555

わずかにずれています。さらに、足し算をするたびに結果も決まった桁数に丸められ、そのずれが 100 回分積み重なります。保存された 0.1 は本当の 0.1 より少し大きいのに合計が 10 より小さくなったのは、この足し算ごとの丸めが効いているためです。

一方、0.5 は 2 進数でちょうど 0.1 と表せるので、0.5 を 100 回足すと、ぴったり 50 になりました。

50

2 進数で正確に表せるのは、0.5(1/2)・0.25(1/4)・0.75(3/4)のように、分母を 2・4・8・16… にできる分数で、しかも決まった桁数に収まる数だけです。

小数は浮動小数点数で表す

コンピュータは小数を、浮動小数点数という形で表します。考え方は、理科で習う「6.02×10236.02 \times 10^{23}」のような書き方と同じです。

部分意味6.02×10236.02 \times 10^{23} の場合
符号プラスかマイナスかプラス
仮数数の中身(有効数字)6.02
基数累乗のもとになる数10
指数何乗するか(小数点の位置)23

コンピュータでは基数が 2 で、基数はいつも 2 なので保存しません。残りの符号・指数・仮数を、決まったビット数に詰め込みます。よく使われるのは次の 2 つの形式です(IEEE 754 という規格で決められています)。

形式C の型全体符号指数仮数
単精度float32 ビット1823
倍精度double64 ビット11152

ビット列を読むには、詰め込むときの 2 つの決まりを知っておく必要があります。

  • 仮数は 1.xxx の形にそろえる(正規化): 6.02×10236.02 \times 10^{23} を 60.2×102260.2 \times 10^{22} とは書かないように、整数部分が 1 桁になるよう指数を調整します。2 進数では 0 以外の 1 桁の数字は 1 しかないので、整数部分は必ず 1 です(0 などの例外を除く)。そこでその 1 は保存せず、小数点以下だけを仮数に入れます。
  • 指数には決まった数を足して保存する(イクセス表現): 指数はマイナスにもなるので、単精度なら 127 を足して、0 以上の数として保存します(倍精度は 1023 を足します)。IEEE 754 では、この足す数を「バイアス」と呼びます。

0.5 で通して確かめます。0.5 は 2 進数で 1.0×2−11.0 \times 2^{-1} です。指数の -1 に 127 を足すと 126 で、2 進数では 01111110 です。仮数は 1.0 の小数点以下なので、全部 0 です。float のビット列を、左から符号(1 ビット)・指数(8 ビット)・仮数(23 ビット)に分けて表示すると、そのとおりになっていました。

0.5    0 01111110 00000000000000000000000
-0.5   1 01111110 00000000000000000000000
0.75   0 01111110 10000000000000000000000
0.1    0 01111011 10011001100110011001101
  • -0.5 は、0.5 と符号のビットだけが違います。
  • 0.75 は 2 進数で 1.1×2−11.1 \times 2^{-1}(1.1 は 1 + 0.5 で、10 進数の 1.5)です。指数は 0.5 と同じで、仮数は 1.1 の小数点以下の 1 から始まります。
  • 0.1 の指数は 123 で、123 - 127 = -4 なので、0.1 ≒ 1.6×2−41.6 \times 2^{-4} です。仮数は同じ並びの繰り返しが 23 ビットで打ち切られ、最後の桁が丸められています。

前に見た 0.1 のずれは、この丸めから来ています。double でも、仮数が 52 ビットに増えるだけで、しくみは同じです。

小数の誤差を避けるには整数や 10 進数で計算する

誤差を避ける方法のひとつは、小数を整数に置き換えて計算することです。0.1 を 10 倍して 1 として扱い、100 回足してから最後に 10 で割ると、正確に 10 になりました。

// 0.1 を 10 倍した 1 を足す
int s = 0;
for( int i = 0; i < 100; i++ ) s += 1;
printf( "%d / 10 = %g\n", s, s / 10.0 );
100 / 10 = 10

お金の計算で、金額を「1.5 ドル」ではなく「150 セント」のような整数で持つのも、同じ考え方です。

もうひとつは、10 進数のまま計算する方法です。10 進数の 1 桁を 4 ビットずつで表す BCD(2 進化 10 進数)という形式があり、たとえば 53 は 0101 0011(5 と 3)になります。今のプログラミング言語では、Python の decimal モジュールのように、10 進数のまま計算できるしくみを使うのが手軽です。Decimal('0.1') を 100 回足すと、10 になりました。

>>> from decimal import Decimal
>>> s = Decimal('0')
>>> for _ in range(100): s += Decimal('0.1')
...
>>> s
Decimal('10.0')

Decimal(0.1) のように数値で渡すと、2 進数で丸めたあとの 0.1 がそのまま入ってしまうので、文字列で渡します。また、10 進数でも 1/3 のように割り切れない数は、正確には表せません。

まとめ

  • コンピュータは電圧の高い・低いで情報を表すので、すべての情報は 0 と 1 の並び(ビット)になり、8 ビットを 1 バイトとして扱う
  • 2 進数は 2 で繰り上がる数え方で、1 になっている桁の重み(1・2・4・8…)を足すと 10 進数に直せる
  • マイナスの数は 2 の補数(全ビットを反転して 1 を足す)で表し、引き算を足し算で計算できる
  • シフト演算で桁をずらすと 2 倍や 2 で割った商になり、マイナスの数は符号ビットで埋めてマイナスのまま扱う
  • 論理演算(AND・OR・XOR・NOT)は 1 ビットずつ計算し、マスクと組み合わせて一部のビットを操作できる
  • 0.1 は 2 進数で割り切れないので、0.1 を 100 回足しても 10 にならない
  • 小数は浮動小数点数(符号・指数・仮数)で表し、仮数は 1.xxx の形にそろえ、指数には決まった数を足して保存する
  • 小数の誤差を避けるには、整数に置き換えて計算するか、10 進数のまま計算するしくみを使う

0.1 を足して 10 にならないのは、プログラムの不具合ではなく、数を 0 と 1 で表すことから来る性質でした。金額のように誤差が許されない計算では、整数で持つ習慣を付けておこうと思います。

参考文献