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

コンピュータは 0 と 1 しか扱えない、というのは有名な話です。では、実際に 0 と 1 だけでどのようにして計算をしているのか、-5 のようなマイナスの数、0.5 のような小数は 0 と 1 だけでどうやって表しているのか、説明できますか?エンジニアの素養として身につけておくべき 2 進数の基礎を、実際にプログラムを動かしながら一つずつ確かめました。
この記事でわかること
- コンピュータが 0 と 1 で情報を扱う理由と、ビット・バイトの意味
- 2 進数の読み方と、10 進数との変換
- マイナスの数を表す「2 の補数」のしくみ
- シフト演算と論理演算でできること
- 0.1 を 100 回足しても 10 にならない理由と、小数の表し方
試した環境
| 項目 | バージョン |
|---|---|
| OS | Arch Linux (WSL2) |
| C コンパイラ | GCC 16.2.1 |
| Python | 3.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 ビットなら 通りです。この 8 ビットのまとまりをバイト(byte)と呼び、データを扱う基本の単位になっています。メモリの番地(アドレス)も、1 バイトごとに付いています。
| ビット数 | 表せるパターンの数 |
|---|---|
| 1 | 2 |
| 2 | 4 |
| 8(1 バイト) | 256 |
| 32(4 バイト) | 約 43 億 |
プログラムに 53 のような 10 進数や、'A' のような文字を書いても、コンパイルすると、どれも 0 と 1 の並びに変換されます(文字は、文字コードで決められた番号として扱われます)。
2 進数は 2 になったら繰り上がる数え方
私たちが普段使っている 10 進数は、0〜9 の 10 個の数字を使い、9 の次で桁が繰り上がります。2 進数は 0 と 1 の 2 個の数字だけを使い、1 の次でもう繰り上がります。
| 10 進数 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 2 進数 | 0 | 1 | 10 | 11 | 100 | 101 | 110 | 111 | 1000 |
10 進数の 253 は「100 が 2 個、10 が 5 個、1 が 3 個」という意味です。各桁に 1・10・100… という重みがあります。2 進数では、桁の重みが右から 1・2・4・8… と 2 倍ずつになります。1 になっている桁の重みを足すと、10 進数に直せます。
| 桁の重み | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|---|---|
00001101 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 1 |
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_t | 0〜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 つで、入力と結果の組み合わせを真理値表にまとめられます。
| A | B | A AND B | A OR B | A XOR B | NOT A |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 1 |
| 0 | 1 | 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 | 0 |
| 1 | 1 | 1 | 1 | 0 | 0 |
言葉にすると、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 |
| 基数 | 累乗のもとになる数 | 10 |
| 指数 | 何乗するか(小数点の位置) | 23 |
コンピュータでは基数が 2 で、基数はいつも 2 なので保存しません。残りの符号・指数・仮数を、決まったビット数に詰め込みます。よく使われるのは次の 2 つの形式です(IEEE 754 という規格で決められています)。
| 形式 | C の型 | 全体 | 符号 | 指数 | 仮数 |
|---|---|---|---|---|---|
| 単精度 | float | 32 ビット | 1 | 8 | 23 |
| 倍精度 | double | 64 ビット | 1 | 11 | 52 |
ビット列を読むには、詰め込むときの 2 つの決まりを知っておく必要があります。
- 仮数は 1.xxx の形にそろえる(正規化): を とは書かないように、整数部分が 1 桁になるよう指数を調整します。2 進数では 0 以外の 1 桁の数字は 1 しかないので、整数部分は必ず 1 です(0 などの例外を除く)。そこでその 1 は保存せず、小数点以下だけを仮数に入れます。
- 指数には決まった数を足して保存する(イクセス表現): 指数はマイナスにもなるので、単精度なら 127 を足して、0 以上の数として保存します(倍精度は 1023 を足します)。IEEE 754 では、この足す数を「バイアス」と呼びます。
0.5 で通して確かめます。0.5 は 2 進数で です。指数の -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 は 1 + 0.5 で、10 進数の 1.5)です。指数は 0.5 と同じで、仮数は 1.1 の小数点以下の
1から始まります。 - 0.1 の指数は 123 で、123 - 127 = -4 なので、0.1 ≒ です。仮数は同じ並びの繰り返しが 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 で表すことから来る性質でした。金額のように誤差が許されない計算では、整数で持つ習慣を付けておこうと思います。